From 1f356a807fdaff65da3b99dcfe285046e98c284f Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Fri, 3 Oct 2025 16:31:08 +0200 Subject: [PATCH 1/4] Added LDAP migrations, made CreateObject async, moved LdapOptions to Models, fixed user password not hashed on user creation --- src/Controllers/UsersController.cs | 16 ++- src/Exceptions/MigrationExceptions.cs | 3 + .../LdapConfigModel.cs} | 7 +- src/Models/MigrationModels.cs | 28 +++++ src/Program.cs | 11 +- src/Services/LdapService.cs | 63 ++++++++-- src/Services/MigrationService.cs | 115 ++++++++++++++++++ 7 files changed, 220 insertions(+), 23 deletions(-) create mode 100644 src/Exceptions/MigrationExceptions.cs rename src/{Services/LdapOptions.cs => Models/LdapConfigModel.cs} (71%) create mode 100644 src/Models/MigrationModels.cs create mode 100644 src/Services/MigrationService.cs diff --git a/src/Controllers/UsersController.cs b/src/Controllers/UsersController.cs index 48811d2..5430b89 100644 --- a/src/Controllers/UsersController.cs +++ b/src/Controllers/UsersController.cs @@ -3,6 +3,8 @@ using Berufsschule_HAM.Services; using Microsoft.AspNetCore.Mvc; using Novell.Directory.Ldap; using Berufsschule_HAM.Models; +using System.Security.Cryptography; +using System.Text; [Route("[controller]")] public class UsersController : Controller @@ -58,7 +60,7 @@ public class UsersController : Controller } [HttpGet("Create")] - public bool Create(string cn, string sn, string? title, string? uid, string userPassword, string? description, string jpegPhoto) + public async Task Create(string cn, string sn, string? title, string? uid, string userPassword, string? description, string jpegPhoto) { try { @@ -66,8 +68,14 @@ public class UsersController : Controller uid ??= sn.ToLower() + cn.ToLower(); title ??= ""; description ??= "{}"; - LdapAttributeSet attributeSet = new LdapAttributeSet + if (!userPassword.StartsWith('{')) { + byte[] passwordBytes = Encoding.UTF8.GetBytes(userPassword); + byte[] hashedPassword = SHA256.HashData(passwordBytes); + userPassword = "{SHA256}" + Convert.ToBase64String(hashedPassword); + } + LdapAttributeSet attributeSet = + [ new LdapAttribute("objectClass", "inetOrgPerson"), new LdapAttribute("cn", cn), new LdapAttribute("sn", sn), @@ -76,8 +84,8 @@ public class UsersController : Controller new LdapAttribute("jpegPhoto", jpegPhoto), new LdapAttribute("description", description), new LdapAttribute("userPassword", userPassword) - }; - _ldap.CreateUser(uid, attributeSet); + ]; + await _ldap.CreateUser(uid, attributeSet); return true; } catch (Exception ex) diff --git a/src/Exceptions/MigrationExceptions.cs b/src/Exceptions/MigrationExceptions.cs new file mode 100644 index 0000000..edb02dc --- /dev/null +++ b/src/Exceptions/MigrationExceptions.cs @@ -0,0 +1,3 @@ +namespace Berufsschule_HAM.Exceptions; + +public class InvalidMigrationDescriptionModel : Exception {} \ No newline at end of file diff --git a/src/Services/LdapOptions.cs b/src/Models/LdapConfigModel.cs similarity index 71% rename from src/Services/LdapOptions.cs rename to src/Models/LdapConfigModel.cs index 53326be..7d9eca7 100644 --- a/src/Services/LdapOptions.cs +++ b/src/Models/LdapConfigModel.cs @@ -1,5 +1,6 @@ -namespace Berufsschule_HAM.Services; -public class LdapOptions +namespace Berufsschule_HAM.Models; + +public class LdapConfig { public required string Host { get; set; } public int Port { get; set; } = 389; @@ -10,4 +11,6 @@ public class LdapOptions public string AssetsOu { get; set; } = "ou=assets"; public string LocationsOu { get; set; } = "ou=locations"; public string UsersOu { get; set; } = "ou=users"; + public string GroupsOu { get; set; } = "ou=groups"; + public string MigrationsOu { get; set; } = "ou=migrations"; } \ No newline at end of file diff --git a/src/Models/MigrationModels.cs b/src/Models/MigrationModels.cs new file mode 100644 index 0000000..6c9edab --- /dev/null +++ b/src/Models/MigrationModels.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using Berufsschule_HAM.Exceptions; + +namespace Berufsschule_HAM.Models; + +public class MigrationModel +{ + public int Version { get; set; } + public MigrationModel(Dictionary ldapData) + { + string? description = ldapData.GetValueOrDefault("description"); + if (description is null) + { + Version = 0; + } + else + { + MigrationDescriptionModel? descriptionModel = JsonSerializer.Deserialize(description) + ?? throw new InvalidMigrationDescriptionModel(); + Version = descriptionModel.Version; + } + } +} + +public class MigrationDescriptionModel +{ + public required int Version { get; set; } +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs index 006b691..9712d7c 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -3,13 +3,11 @@ using ElmahCore.Mvc; using Serilog; using Microsoft.AspNetCore.Authentication.Cookies; using Berufsschule_HAM.Services; +using Berufsschule_HAM.Models; var builder = WebApplication.CreateBuilder(args); // Bind options -builder.Services.Configure(builder.Configuration.GetSection("Ldap")); - -// Register LDAP service as singleton (it manages its own connection) - +builder.Services.Configure(builder.Configuration.GetSection("Ldap")); builder.Services.AddControllersWithViews(); builder.Services.AddEndpointsApiExplorer(); @@ -26,6 +24,7 @@ builder.Services.AddElmah(Options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => @@ -54,4 +53,8 @@ app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); +// Run migrations +using var scope = app.Services.CreateScope(); +var migrationService = scope.ServiceProvider.GetRequiredService(); + app.Run(); diff --git a/src/Services/LdapService.cs b/src/Services/LdapService.cs index 48c1420..736ef8e 100644 --- a/src/Services/LdapService.cs +++ b/src/Services/LdapService.cs @@ -4,15 +4,16 @@ using Berufsschule_HAM.Models; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; +using System.Text.Json; namespace Berufsschule_HAM.Services; public partial class LdapService : IDisposable { - private readonly LdapOptions _opts; + private readonly LdapConfig _opts; private readonly LdapConnection _conn; private ILogger _logger; - public LdapService(IOptions options, ILogger logger) + public LdapService(IOptions options, ILogger logger) { _opts = options.Value; _conn = new LdapConnection { SecureSocketLayer = _opts.UseSsl }; @@ -39,9 +40,11 @@ public partial class LdapService : IDisposable await _conn.BindAsync(_opts.BindDn, _opts.BindPassword); } - private string AssetsBaseDn => string.IsNullOrEmpty(_opts.AssetsOu) ? _opts.BaseDn : $"{_opts.AssetsOu},{_opts.BaseDn}"; - private string LocationsBaseDn => string.IsNullOrEmpty(_opts.LocationsOu) ? _opts.BaseDn : $"{_opts.LocationsOu},{_opts.BaseDn}"; - private string UsersBaseDn => string.IsNullOrEmpty(_opts.UsersOu) ? _opts.BaseDn : $"{_opts.UsersOu},{_opts.BaseDn}"; + public string AssetsBaseDn => string.IsNullOrEmpty(_opts.AssetsOu) ? _opts.BaseDn : $"{_opts.AssetsOu},{_opts.BaseDn}"; + public string LocationsBaseDn => string.IsNullOrEmpty(_opts.LocationsOu) ? _opts.BaseDn : $"{_opts.LocationsOu},{_opts.BaseDn}"; + public string UsersBaseDn => string.IsNullOrEmpty(_opts.UsersOu) ? _opts.BaseDn : $"{_opts.UsersOu},{_opts.BaseDn}"; + public string GroupsBaseDn => string.IsNullOrEmpty(_opts.GroupsOu) ? _opts.BaseDn : $"{_opts.GroupsOu},{_opts.BaseDn}"; + public string MigrationsBaseDn => string.IsNullOrEmpty(_opts.MigrationsOu) ? _opts.BaseDn : $"{_opts.MigrationsOu},{_opts.BaseDn}"; public async Task>> ListLocationsAsync() { @@ -53,6 +56,41 @@ public partial class LdapService : IDisposable return await ListObjectBy(UsersBaseDn, "", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]); } + public async Task GetMigrationVersionAsync() + { + Dictionary objects; + try + { + objects = (await ListObjectBy(_opts.BaseDn, _opts.MigrationsOu, ["description"])).First(); + } + catch (Exception) + { + objects = []; + } + return new MigrationModel(objects); + } + + public async Task UpdateMigrationVersionAsync(MigrationModel migrationModel) + { + await ConnectAndBind(); + try + { + string dn = MigrationsBaseDn; //PrependRDN($"", MigrationsBaseDn); + string targetText = JsonSerializer.Serialize(migrationModel); + _logger.LogInformation("Setting the LDAP migration description to {targetText} for {dn}", [targetText, dn]); + var modification = new LdapModification( + LdapModification.Replace, + new LdapAttribute("description", targetText) + ); + await _conn.ModifyAsync(dn, modification); + } + catch (Exception) + { + return false; + } + return true; + } + public async Task> ListUsersAsync(string[] attributes) { List returnValue = []; @@ -85,20 +123,20 @@ public partial class LdapService : IDisposable return await ListObjectBy(AssetsBaseDn, "(objectClass=device)", ["CN", "description", "l", "owner", "serialNumber"]); } - public void CreateUser(string uid, LdapAttributeSet attributeSet) + public async Task CreateUser(string uid, LdapAttributeSet attributeSet) { string dn = PrependRDN($"uid={uid}", UsersBaseDn); - CreateObject(dn, attributeSet); + await CreateObject(dn, attributeSet); } - public void CreateAsset(LdapAttributeSet attributeSet) + public async Task CreateAsset(LdapAttributeSet attributeSet) { - CreateObject(AssetsBaseDn, attributeSet); + await CreateObject(AssetsBaseDn, attributeSet); } - public void CreateLocation(LdapAttributeSet attributeSet) + public async Task CreateLocation(LdapAttributeSet attributeSet) { - CreateObject(LocationsBaseDn, attributeSet); + await CreateObject(LocationsBaseDn, attributeSet); } public async Task AuthenticateUser(string username, string password) @@ -201,13 +239,12 @@ public partial class LdapService : IDisposable } } - public async void DeleteObjectByDn(string dn) { await _conn.DeleteAsync(dn); } - public async void CreateObject(string dn, LdapAttributeSet attributeSet) + public async Task CreateObject(string dn, LdapAttributeSet attributeSet) { await ConnectAndBind(); LdapEntry ldapEntry = new(dn, attributeSet); diff --git a/src/Services/MigrationService.cs b/src/Services/MigrationService.cs new file mode 100644 index 0000000..b5717cd --- /dev/null +++ b/src/Services/MigrationService.cs @@ -0,0 +1,115 @@ +using Berufsschule_HAM.Models; +using Microsoft.Extensions.Options; +using Novell.Directory.Ldap; +using System.Reflection; +namespace Berufsschule_HAM.Services; + +public class MigrationService +{ + private readonly LdapService _ldapService; + private readonly ILogger _logger; + private readonly LdapConfig _ldapConfig; + + public MigrationService(LdapService ldapService, ILogger logger, IOptions ldapConfig) + { + _ldapService = ldapService; + _logger = logger; + _ldapConfig = ldapConfig.Value; + MigrateAsync().ConfigureAwait(false); + } + public async Task MigrateAsync() + { + MigrationModel migrationModel = await _ldapService.GetMigrationVersionAsync(); + int version = migrationModel.Version; + string dc = _ldapConfig.BaseDn.Split("=")[1]; + + List updateMethods = [.. typeof(MigrationService) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name.StartsWith("UpdateFrom") + && m.Name.EndsWith("Async") + && m.ReturnType == typeof(Task)) + .OrderBy(m => int.Parse(m.Name["UpdateFrom".Length..^5]))]; // TODO remove magic number ("5" -> global constant) + + foreach (var method in updateMethods) + { + int methodVersion = int.Parse(method.Name["UpdateFrom".Length..^5]); // TODO TODO remove magic number ("5" -> global constant) + if (methodVersion >= version) + { + _logger.LogInformation("Migrating LDAP database from version {version} to {methodVersion}", [version, methodVersion + 1]); + if (method is null) { continue; } +#pragma warning disable CS8605 // Unboxing a possibly null value. + version = (int)method.Invoke(null, [_ldapService]); +#pragma warning restore CS8605 // Unboxing a possibly null value. + } + } + + if (version != migrationModel.Version) + { + migrationModel.Version = version; + await _ldapService.UpdateMigrationVersionAsync(migrationModel); + } + + return migrationModel; + } + + public static async Task UpdateFrom0Async(LdapService ldapService) + { + LdapAttributeSet usersAttributes = + [ + new("objectClass", "organizationalUnit"), + new("objectClass", "top"), + new("ou", "users") + ]; + await TryCreateObjectIgnoreAlreadyExists(ldapService, ldapService.UsersBaseDn, usersAttributes); + LdapAttributeSet locationsAttributes = + [ + new("objectClass", "organizationalUnit"), + new("objectClass", "top"), + new("ou", "locations") + ]; + await TryCreateObjectIgnoreAlreadyExists(ldapService, ldapService.LocationsBaseDn, locationsAttributes); + LdapAttributeSet groupsAttributes = + [ + new("objectClass", "organizationalUnit"), + new("objectClass", "top"), + new("ou", "groups") + ]; + await TryCreateObjectIgnoreAlreadyExists(ldapService, ldapService.GroupsBaseDn, groupsAttributes); + LdapAttributeSet assetsAttributes = + [ + new("objectClass", "organizationalUnit"), + new("objectClass", "top"), + new("ou", "assets") + ]; + await TryCreateObjectIgnoreAlreadyExists(ldapService, ldapService.AssetsBaseDn, assetsAttributes); + LdapAttributeSet migrationsAttributes = + [ + new("objectClass", "organizationalUnit"), + new("objectClass", "top"), + new("ou", "migrations"), + new("description", "{\"Version\": 1}") + ]; + await TryCreateObjectIgnoreAlreadyExists(ldapService, ldapService.MigrationsBaseDn, migrationsAttributes); + return 1; + } + + private static async Task TryCreateObjectIgnoreAlreadyExists(LdapService ldapService, string dn, LdapAttributeSet ldapAttributes) + { + try + { + await ldapService.CreateObject(dn, ldapAttributes); + } + catch (LdapException ex) + { + if (ex.ResultCode != LdapException.EntryAlreadyExists) + { + throw; + } + } + catch (Exception) + { + throw; + } + } + +} \ No newline at end of file From da3ab2f7901b41e71c7eb525449cb5d756c8e7a1 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Fri, 3 Oct 2025 16:31:24 +0200 Subject: [PATCH 2/4] Fixed application name in appsettings --- src/appsettings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/appsettings.json b/src/appsettings.json index f003816..87aeaa7 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -13,7 +13,7 @@ { "Name": "File", "Args": { "path": "logs/log.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7 } } ], "Properties": { - "Application": "Embeddingsearch.Indexer" + "Application": "Berufsschule_HAM" } }, "Elmah": { @@ -28,9 +28,9 @@ "Host": "ld50.dev", "Port": 8389, "UseSsl": false, - "BindDn": "cn=admin,dc=localhost", + "BindDn": "cn=admin,dc=migrationTest", "BindPassword": "TestLDAP", - "BaseDn": "dc=localhost", + "BaseDn": "dc=migrationTest", "AssetsOu": "ou=assets", "LocationsOu": "ou=locations", "UsersOu": "ou=users" From 0bb5f650b644fb2a9df969944a5ffb9af887c2ec Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Fri, 3 Oct 2025 16:39:33 +0200 Subject: [PATCH 3/4] Fixed misconfiguration oversight from last commit --- src/appsettings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/appsettings.json b/src/appsettings.json index 87aeaa7..4da589d 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -28,9 +28,9 @@ "Host": "ld50.dev", "Port": 8389, "UseSsl": false, - "BindDn": "cn=admin,dc=migrationTest", + "BindDn": "cn=admin,dc=localhost", "BindPassword": "TestLDAP", - "BaseDn": "dc=migrationTest", + "BaseDn": "dc=localhost", "AssetsOu": "ou=assets", "LocationsOu": "ou=locations", "UsersOu": "ou=users" From a5a08e5876d17058eac91e6229d53789f53458ae Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Fri, 3 Oct 2025 16:40:16 +0200 Subject: [PATCH 4/4] Fixed uncaught exception when user not found --- src/Services/LdapService.cs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Services/LdapService.cs b/src/Services/LdapService.cs index 736ef8e..47b2506 100644 --- a/src/Services/LdapService.cs +++ b/src/Services/LdapService.cs @@ -144,16 +144,23 @@ public partial class LdapService : IDisposable await ConnectAndBind(); try { - UserModel user = await GetUserByUidAsync(username); - if (user.UserPassword is null) + try + { + UserModel user = await GetUserByUidAsync(username); + if (user.UserPassword is null) + { + return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials }; + } + if (CompareStringToSha256(password, user.UserPassword)) + { + return new() { Success = true }; + } + return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials }; + } + catch (InvalidOperationException) { return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials }; } - if (CompareStringToSha256(password, user.UserPassword)) - { - return new() { Success = true}; - } - return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials }; } catch (LdapException) {