Exporting Ldap users to CSV for Webuntis import works now
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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=*)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user