Exporting Ldap users to CSV for Webuntis import works now

This commit is contained in:
Rhywden
2026-08-18 18:45:11 +02:00
parent 0ee89b08f1
commit b1a62e83f5
9 changed files with 257 additions and 10 deletions
+1
View File
@@ -20,6 +20,7 @@
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["js/file-download.js"]"></script>
</body>
+61 -3
View File
@@ -1,7 +1,65 @@
@page "/"
@inject Wollmilchsau.Services.Ldap.ILdapService LdapService
@inject IJSRuntime JsRuntime
<PageTitle>Home</PageTitle>
<PageTitle>LDAP Users</PageTitle>
<h1>Hello, world!</h1>
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(users is null)">Export CSV</button>
Welcome to your new app.
<h1>LDAP Users</h1>
@if (users is null)
{
<p><em>Loading users...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Surname</th>
<th>Classroom</th>
<th>Given name</th>
<th>Incomplete</th>
</tr>
</thead>
<tbody>
@foreach (var user in users)
{
<tr>
<td>@user.Username</td>
<td>@user.Surname</td>
<td>@user.Classroom</td>
<td>@user.GivenName</td>
<td>@user.Incomplete</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<Wollmilchsau.Models.LdapUser>? users;
protected override async Task OnInitializedAsync()
{
users = [];
await foreach (var user in LdapService.GetAllUsers())
{
users.Add(user);
}
}
private Task DownloadCsvAsync()
{
var csv = string.Join("\r\n", users!.Select(user => string.Join(';',
user.GivenName ?? string.Empty,
user.Surname ?? string.Empty,
user.Classroom ?? string.Empty,
user.Username)));
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
}
}
+6 -3
View File
@@ -1,5 +1,8 @@
namespace Wollmilchsau.Models;
public sealed record LdapUser
{
}
public sealed record LdapUser(
string Username,
string? Surname,
string? Classroom,
string? GivenName,
bool Incomplete);
+8 -1
View File
@@ -5,9 +5,16 @@ using MudBlazor.Services;
using System.Net.Http.Headers;
using Wollmilchsau.Components;
using Wollmilchsau.Exceptions;
using Wollmilchsau.Services.Ldap;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<LdapOptions>()
.BindConfiguration("LDAP")
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddScoped<ILdapService, LdapService>();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
@@ -27,7 +34,7 @@ builder.Services.AddHttpClient("matrix_synapse", client =>
});
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("cache") ?? throw new ConfigurationException("ConnectionStrings:cache");
options.Configuration = builder.Configuration.GetConnectionString("RedisCache") ?? throw new ConfigurationException("ConnectionStrings:RedisCache");
options.InstanceName = "OneForAll:";
});
@@ -0,0 +1,8 @@
using Wollmilchsau.Models;
namespace Wollmilchsau.Services.Ldap;
public interface ILdapService
{
IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default);
}
+27
View File
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Wollmilchsau.Services.Ldap;
public sealed class LdapOptions
{
[Required]
public string LdapUri { get; init; } = string.Empty;
[Required]
public string BindPassword { get; init; } = string.Empty;
[Required]
public string BindDN { get; init; } = string.Empty;
[Required]
public string BaseDN { get; init; } = string.Empty;
[Required]
public string UserBase { get; init; } = string.Empty;
[Required]
public string RoleBase { get; init; } = string.Empty;
[Required]
public string StudentsRole { get; init; } = string.Empty;
}
+132
View File
@@ -0,0 +1,132 @@
using System.DirectoryServices.Protocols;
using System.Net;
using System.Text;
using Microsoft.Extensions.Options;
using Wollmilchsau.Models;
namespace Wollmilchsau.Services.Ldap;
public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
{
private readonly LdapOptions _options = options.Value;
public async IAsyncEnumerable<LdapUser> GetAllUsers(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var connection = CreateConnection();
foreach (var user in GetUsers(connection))
{
cancellationToken.ThrowIfCancellationRequested();
yield return user;
await Task.Yield();
}
}
private LdapConnection CreateConnection()
{
var ldapUri = new Uri(_options.LdapUri, UriKind.Absolute);
if (!string.Equals(ldapUri.Scheme, "ldap", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(ldapUri.Scheme, "ldaps", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("LDAP:LdapUri must use the ldap or ldaps scheme.");
}
var connection = new LdapConnection(new LdapDirectoryIdentifier(ldapUri.Host, ldapUri.Port))
{
AuthType = AuthType.Basic,
Timeout = TimeSpan.FromSeconds(30)
};
connection.SessionOptions.ProtocolVersion = 3;
connection.SessionOptions.SecureSocketLayer = string.Equals(ldapUri.Scheme, "ldaps", StringComparison.OrdinalIgnoreCase);
connection.Bind(new NetworkCredential(_options.BindDN, _options.BindPassword));
return connection;
}
private IEnumerable<LdapUser> GetUsers(LdapConnection connection)
{
var studentRoleDn = BuildDistinguishedName(_options.StudentsRole, _options.RoleBase);
var request = new SearchRequest(
BuildDistinguishedName(_options.UserBase),
$"(memberOf={EscapeFilterValue(studentRoleDn)})",
SearchScope.Subtree,
"uid",
"sn",
"departmentNumber",
"givenName");
var response = (SearchResponse)connection.SendRequest(request);
return response.Entries
.Cast<SearchResultEntry>()
.Select(CreateUser);
}
private LdapUser CreateUser(SearchResultEntry entry)
{
var username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid.");
var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn"));
var classroom = GetFirstAttributeValue(entry, "departmentNumber");
var givenName = GetFirstAttributeValue(entry, "givenName");
return new LdapUser(
username,
surname,
classroom,
givenName,
string.IsNullOrWhiteSpace(surname) ||
string.IsNullOrWhiteSpace(classroom) ||
string.IsNullOrWhiteSpace(givenName));
}
private string BuildDistinguishedName(params string[] relativeNames)
{
return string.Join(',', relativeNames.Append(_options.BaseDN));
}
private static IEnumerable<string> GetAttributeValues(SearchResultEntry entry, string attributeName)
{
if (!entry.Attributes.Contains(attributeName))
{
return [];
}
return entry.Attributes[attributeName]
.GetValues(typeof(string))
.OfType<string>();
}
private static string? GetFirstAttributeValue(SearchResultEntry entry, string attributeName)
{
return GetAttributeValues(entry, attributeName).FirstOrDefault();
}
private static string? CapitalizeFirstLetter(string? value)
{
return string.IsNullOrWhiteSpace(value)
? value
: char.ToUpperInvariant(value[0]) + value[1..];
}
private static string EscapeFilterValue(string value)
{
var builder = new StringBuilder();
foreach (var valueByte in Encoding.UTF8.GetBytes(value))
{
if (valueByte is >= 0x20 and <= 0x7e and not (byte)'*' and not (byte)'(' and not (byte)')' and not (byte)'\\')
{
builder.Append((char)valueByte);
}
else
{
builder.Append('\\');
builder.Append(valueByte.ToString("X2"));
}
}
return builder.ToString();
}
}
+3 -3
View File
@@ -50,14 +50,14 @@
"LdapUri": "ldaps://bs18-hh.de:10636",
"BindPassword": "skMXHFyf3fjW4iJhsoEyfQdc0wYthuVs6xim7BKhX3c5EZSuPcRrYYiGonynBnHW",
"BindDN": "cn=bs18ldap,ou=ldap,dc=bs18-hh,dc=de",
"BaseDN": "ou=dc=bs18-hh,dc=de",
"BaseDN": "dc=bs18-hh,dc=de",
"UserBase": "ou=users",
"RoleBase": "ou=roles",
"GroupBase": "ou=groups",
"PupilRole": "CN=ROLE_STUDENT",
"StudentsRole": "CN=ROLE_STUDENT",
"TeacherRole": "CN=ROLE_TEACHER",
"AdminRole": "CN=ROLE_ADMIN",
"KlassgroupFilter": "(cn=klasse.*)",
"KlasseGroupFilter": "(cn=klasse.*)",
"UserFilter": "(uid=*)"
}
}
+11
View File
@@ -0,0 +1,11 @@
window.downloadTextFile = (fileName, content) => {
const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
};