Exporting Ldap users to CSV for Webuntis import works now
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
|
||||||
|
<script src="@Assets["js/file-download.js"]"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,65 @@
|
|||||||
@page "/"
|
@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;
|
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 System.Net.Http.Headers;
|
||||||
using Wollmilchsau.Components;
|
using Wollmilchsau.Components;
|
||||||
using Wollmilchsau.Exceptions;
|
using Wollmilchsau.Exceptions;
|
||||||
|
using Wollmilchsau.Services.Ldap;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddOptions<LdapOptions>()
|
||||||
|
.BindConfiguration("LDAP")
|
||||||
|
.ValidateDataAnnotations()
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.AddScoped<ILdapService, LdapService>();
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
@@ -27,7 +34,7 @@ builder.Services.AddHttpClient("matrix_synapse", client =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddStackExchangeRedisCache(options =>
|
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:";
|
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",
|
"LdapUri": "ldaps://bs18-hh.de:10636",
|
||||||
"BindPassword": "skMXHFyf3fjW4iJhsoEyfQdc0wYthuVs6xim7BKhX3c5EZSuPcRrYYiGonynBnHW",
|
"BindPassword": "skMXHFyf3fjW4iJhsoEyfQdc0wYthuVs6xim7BKhX3c5EZSuPcRrYYiGonynBnHW",
|
||||||
"BindDN": "cn=bs18ldap,ou=ldap,dc=bs18-hh,dc=de",
|
"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",
|
"UserBase": "ou=users",
|
||||||
"RoleBase": "ou=roles",
|
"RoleBase": "ou=roles",
|
||||||
"GroupBase": "ou=groups",
|
"GroupBase": "ou=groups",
|
||||||
"PupilRole": "CN=ROLE_STUDENT",
|
"StudentsRole": "CN=ROLE_STUDENT",
|
||||||
"TeacherRole": "CN=ROLE_TEACHER",
|
"TeacherRole": "CN=ROLE_TEACHER",
|
||||||
"AdminRole": "CN=ROLE_ADMIN",
|
"AdminRole": "CN=ROLE_ADMIN",
|
||||||
"KlassgroupFilter": "(cn=klasse.*)",
|
"KlasseGroupFilter": "(cn=klasse.*)",
|
||||||
"UserFilter": "(uid=*)"
|
"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