Merge pull request #49 from LD-Reborn/9-feature-implement-ldap-migrations

9 feature implement ldap migrations
This commit is contained in:
LD50
2025-10-03 16:40:44 +02:00
committed by GitHub
8 changed files with 235 additions and 31 deletions

View File

@@ -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<bool> 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)

View File

@@ -0,0 +1,3 @@
namespace Berufsschule_HAM.Exceptions;
public class InvalidMigrationDescriptionModel : Exception {}

View File

@@ -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";
}

View File

@@ -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<string, string> ldapData)
{
string? description = ldapData.GetValueOrDefault("description");
if (description is null)
{
Version = 0;
}
else
{
MigrationDescriptionModel? descriptionModel = JsonSerializer.Deserialize<MigrationDescriptionModel>(description)
?? throw new InvalidMigrationDescriptionModel();
Version = descriptionModel.Version;
}
}
}
public class MigrationDescriptionModel
{
public required int Version { get; set; }
}

View File

@@ -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<LdapOptions>(builder.Configuration.GetSection("Ldap"));
// Register LDAP service as singleton (it manages its own connection)
builder.Services.Configure<LdapConfig>(builder.Configuration.GetSection("Ldap"));
builder.Services.AddControllersWithViews();
builder.Services.AddEndpointsApiExplorer();
@@ -26,6 +24,7 @@ builder.Services.AddElmah<XmlFileErrorLog>(Options =>
});
builder.Services.AddSingleton<LdapService>();
builder.Services.AddSingleton<MigrationService>();
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<MigrationService>();
app.Run();

View File

@@ -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<LdapOptions> options, ILogger<LdapService> logger)
public LdapService(IOptions<LdapConfig> options, ILogger<LdapService> 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<IEnumerable<Dictionary<string, string>>> ListLocationsAsync()
{
@@ -53,6 +56,41 @@ public partial class LdapService : IDisposable
return await ListObjectBy(UsersBaseDn, "", ["cn", "sn", "title", "uid", "jpegPhoto", "userPassword", "description"]);
}
public async Task<MigrationModel> GetMigrationVersionAsync()
{
Dictionary<string, string> objects;
try
{
objects = (await ListObjectBy(_opts.BaseDn, _opts.MigrationsOu, ["description"])).First();
}
catch (Exception)
{
objects = [];
}
return new MigrationModel(objects);
}
public async Task<bool> 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<IEnumerable<UserModel>> ListUsersAsync(string[] attributes)
{
List<UserModel> returnValue = [];
@@ -85,25 +123,27 @@ 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<UserAuthenticationResult> AuthenticateUser(string username, string password)
{
await ConnectAndBind();
try
{
try
{
UserModel user = await GetUserByUidAsync(username);
@@ -117,6 +157,11 @@ public partial class LdapService : IDisposable
}
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
}
catch (InvalidOperationException)
{
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
}
}
catch (LdapException)
{
return new() { Success = false, AuthenticationState = UserNotAuthenticatedReason.InvalidCredentials };
@@ -201,13 +246,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);

View File

@@ -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<MigrationService> _logger;
private readonly LdapConfig _ldapConfig;
public MigrationService(LdapService ldapService, ILogger<MigrationService> logger, IOptions<LdapConfig> ldapConfig)
{
_ldapService = ldapService;
_logger = logger;
_ldapConfig = ldapConfig.Value;
MigrateAsync().ConfigureAwait(false);
}
public async Task<MigrationModel> MigrateAsync()
{
MigrationModel migrationModel = await _ldapService.GetMigrationVersionAsync();
int version = migrationModel.Version;
string dc = _ldapConfig.BaseDn.Split("=")[1];
List<MethodInfo> updateMethods = [.. typeof(MigrationService)
.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where(m => m.Name.StartsWith("UpdateFrom")
&& m.Name.EndsWith("Async")
&& m.ReturnType == typeof(Task<int>))
.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<int> 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;
}
}
}

View File

@@ -13,7 +13,7 @@
{ "Name": "File", "Args": { "path": "logs/log.txt", "rollingInterval": "Day", "retainedFileCountLimit": 7 } }
],
"Properties": {
"Application": "Embeddingsearch.Indexer"
"Application": "Berufsschule_HAM"
}
},
"Elmah": {