From b1a62e83f5c63f131302e01fd3b87d132907c513 Mon Sep 17 00:00:00 2001 From: Rhywden Date: Tue, 18 Aug 2026 18:45:11 +0200 Subject: [PATCH] Exporting Ldap users to CSV for Webuntis import works now --- Wollmilchsau/Components/App.razor | 1 + Wollmilchsau/Components/Pages/Home.razor | 64 +++++++++- Wollmilchsau/Models/LdapUser.cs | 9 +- Wollmilchsau/Program.cs | 9 +- Wollmilchsau/Services/Ldap/ILdapService.cs | 8 ++ Wollmilchsau/Services/Ldap/LdapOptions.cs | 27 +++++ Wollmilchsau/Services/Ldap/LdapService.cs | 132 +++++++++++++++++++++ Wollmilchsau/appsettings.Development.json | 6 +- Wollmilchsau/wwwroot/js/file-download.js | 11 ++ 9 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 Wollmilchsau/Services/Ldap/ILdapService.cs create mode 100644 Wollmilchsau/Services/Ldap/LdapOptions.cs create mode 100644 Wollmilchsau/Services/Ldap/LdapService.cs create mode 100644 Wollmilchsau/wwwroot/js/file-download.js diff --git a/Wollmilchsau/Components/App.razor b/Wollmilchsau/Components/App.razor index 5435db4..af1ea3c 100644 --- a/Wollmilchsau/Components/App.razor +++ b/Wollmilchsau/Components/App.razor @@ -20,6 +20,7 @@ + diff --git a/Wollmilchsau/Components/Pages/Home.razor b/Wollmilchsau/Components/Pages/Home.razor index 9001e0b..81b9641 100644 --- a/Wollmilchsau/Components/Pages/Home.razor +++ b/Wollmilchsau/Components/Pages/Home.razor @@ -1,7 +1,65 @@ @page "/" +@inject Wollmilchsau.Services.Ldap.ILdapService LdapService +@inject IJSRuntime JsRuntime -Home +LDAP Users -

Hello, world!

+ -Welcome to your new app. +

LDAP Users

+ +@if (users is null) +{ +

Loading users...

+} +else +{ + + + + + + + + + + + + @foreach (var user in users) + { + + + + + + + + } + +
UsernameSurnameClassroomGiven nameIncomplete
@user.Username@user.Surname@user.Classroom@user.GivenName@user.Incomplete
+} + +@code { + private List? 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(); + } +} diff --git a/Wollmilchsau/Models/LdapUser.cs b/Wollmilchsau/Models/LdapUser.cs index fd2a43d..29a062b 100644 --- a/Wollmilchsau/Models/LdapUser.cs +++ b/Wollmilchsau/Models/LdapUser.cs @@ -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); diff --git a/Wollmilchsau/Program.cs b/Wollmilchsau/Program.cs index 1e129ab..e0c7c29 100644 --- a/Wollmilchsau/Program.cs +++ b/Wollmilchsau/Program.cs @@ -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() + .BindConfiguration("LDAP") + .ValidateDataAnnotations() + .ValidateOnStart(); +builder.Services.AddScoped(); + // 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:"; }); diff --git a/Wollmilchsau/Services/Ldap/ILdapService.cs b/Wollmilchsau/Services/Ldap/ILdapService.cs new file mode 100644 index 0000000..473b176 --- /dev/null +++ b/Wollmilchsau/Services/Ldap/ILdapService.cs @@ -0,0 +1,8 @@ +using Wollmilchsau.Models; + +namespace Wollmilchsau.Services.Ldap; + +public interface ILdapService +{ + IAsyncEnumerable GetAllUsers(CancellationToken cancellationToken = default); +} diff --git a/Wollmilchsau/Services/Ldap/LdapOptions.cs b/Wollmilchsau/Services/Ldap/LdapOptions.cs new file mode 100644 index 0000000..5149cbf --- /dev/null +++ b/Wollmilchsau/Services/Ldap/LdapOptions.cs @@ -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; +} diff --git a/Wollmilchsau/Services/Ldap/LdapService.cs b/Wollmilchsau/Services/Ldap/LdapService.cs new file mode 100644 index 0000000..b34f2bc --- /dev/null +++ b/Wollmilchsau/Services/Ldap/LdapService.cs @@ -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 options) : ILdapService +{ + private readonly LdapOptions _options = options.Value; + + public async IAsyncEnumerable 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 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() + .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 GetAttributeValues(SearchResultEntry entry, string attributeName) + { + if (!entry.Attributes.Contains(attributeName)) + { + return []; + } + + return entry.Attributes[attributeName] + .GetValues(typeof(string)) + .OfType(); + } + + 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(); + } +} diff --git a/Wollmilchsau/appsettings.Development.json b/Wollmilchsau/appsettings.Development.json index 3c961aa..510c3b6 100644 --- a/Wollmilchsau/appsettings.Development.json +++ b/Wollmilchsau/appsettings.Development.json @@ -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=*)" } } diff --git a/Wollmilchsau/wwwroot/js/file-download.js b/Wollmilchsau/wwwroot/js/file-download.js new file mode 100644 index 0000000..7e3c1a2 --- /dev/null +++ b/Wollmilchsau/wwwroot/js/file-download.js @@ -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); +};