diff --git a/LehrerApp.Api.Tests/LehrerApp.Api.Tests.csproj b/LehrerApp.Api.Tests/LehrerApp.Api.Tests.csproj new file mode 100644 index 0000000..ed9f61e --- /dev/null +++ b/LehrerApp.Api.Tests/LehrerApp.Api.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/LehrerApp.Api.Tests/PasswordHasherTests.cs b/LehrerApp.Api.Tests/PasswordHasherTests.cs new file mode 100644 index 0000000..8c735fa --- /dev/null +++ b/LehrerApp.Api.Tests/PasswordHasherTests.cs @@ -0,0 +1,41 @@ +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class PasswordHasherTests +{ + [Fact] + public void Verify_RichtigesPasswort_GibtTrueZurueck() + { + var hash = PasswordHasher.Hash("korrektes-passwort-123"); + + Assert.True(PasswordHasher.Verify("korrektes-passwort-123", hash)); + } + + [Fact] + public void Verify_FalschesPasswort_GibtFalseZurueck() + { + var hash = PasswordHasher.Hash("korrektes-passwort-123"); + + Assert.False(PasswordHasher.Verify("falsches-passwort-456", hash)); + } + + [Fact] + public void Hash_ZweiAufrufeMitGleichemPasswort_ErzeugenUnterschiedlicheHashes() + { + // Zufälliger Salt pro Nutzer -> selbst identische Passwörter dürfen nicht denselben + // gespeicherten Wert ergeben. + var hash1 = PasswordHasher.Hash("dasselbe-passwort"); + var hash2 = PasswordHasher.Hash("dasselbe-passwort"); + + Assert.NotEqual(hash1, hash2); + Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash1)); + Assert.True(PasswordHasher.Verify("dasselbe-passwort", hash2)); + } + + [Fact] + public void Verify_UngueltigesGespeichertesFormat_GibtFalseZurueckStattZuWerfen() + { + Assert.False(PasswordHasher.Verify("irgendwas", "kein-gueltiges-format")); + } +} diff --git a/LehrerApp.Api.Tests/UserStoreTests.cs b/LehrerApp.Api.Tests/UserStoreTests.cs new file mode 100644 index 0000000..89e4da7 --- /dev/null +++ b/LehrerApp.Api.Tests/UserStoreTests.cs @@ -0,0 +1,64 @@ +using Xunit; + +namespace LehrerApp.Api.Tests; + +public sealed class UserStoreTests +{ + [Fact] + public void CreateUser_NeuerNutzername_GibtTrueZurueckUndKannSichAnmelden() + { + using var temp = new TempUserStore(); + + var created = temp.Store.CreateUser("sebastian", "einSicheresPasswort"); + + Assert.True(created); + Assert.True(temp.Store.VerifyPassword("sebastian", "einSicheresPasswort")); + } + + [Fact] + public void CreateUser_BereitsVorhandenerNutzername_GibtFalseZurueck() + { + using var temp = new TempUserStore(); + temp.Store.CreateUser("sebastian", "einSicheresPasswort"); + + var created = temp.Store.CreateUser("sebastian", "einAnderesPasswort"); + + Assert.False(created); + } + + [Fact] + public void VerifyPassword_FalschesPasswort_GibtFalseZurueck() + { + using var temp = new TempUserStore(); + temp.Store.CreateUser("sebastian", "einSicheresPasswort"); + + Assert.False(temp.Store.VerifyPassword("sebastian", "falschesPasswort")); + } + + [Fact] + public void VerifyPassword_UnbekannterNutzer_GibtFalseZurueck() + { + using var temp = new TempUserStore(); + + Assert.False(temp.Store.VerifyPassword("unbekannt", "irgendwas")); + } + + private sealed class TempUserStore : IDisposable + { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), $"lehrerapp-api-tests-{Guid.NewGuid():N}"); + public UserStore Store { get; } + + public TempUserStore() + { + Directory.CreateDirectory(_directory); + Store = new UserStore(_directory); + } + + public void Dispose() + { + Store.Dispose(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/LehrerApp.Api/Cli.cs b/LehrerApp.Api/Cli.cs new file mode 100644 index 0000000..1d3898c --- /dev/null +++ b/LehrerApp.Api/Cli.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace LehrerApp.Api; + +internal static class Cli +{ + public static Task RunCreateUserAsync(string dataPath, string[] args) + { + if (args.Length < 2) + { + Console.Error.WriteLine("Verwendung: create-user [--password ]"); + return Task.FromResult(1); + } + + var username = args[1]; + string? password = null; + for (var i = 2; i < args.Length - 1; i++) + if (args[i] == "--password") password = args[i + 1]; + + password ??= ReadPassword("Passwort (mind. 12 Zeichen): "); + + if (string.IsNullOrWhiteSpace(password) || password.Length < 12) + { + Console.Error.WriteLine("Passwort muss mindestens 12 Zeichen lang sein."); + return Task.FromResult(1); + } + + Directory.CreateDirectory(dataPath); + using var store = new UserStore(dataPath); + if (!store.CreateUser(username, password)) + { + Console.Error.WriteLine($"Nutzer '{username}' existiert bereits."); + return Task.FromResult(1); + } + + Console.WriteLine($"Nutzer '{username}' angelegt."); + return Task.FromResult(0); + } + + // docker exec ohne -it liefert kein TTY -> ReadKey wäre nicht möglich, dann normal lesen. + private static string ReadPassword(string prompt) + { + Console.Write(prompt); + if (Console.IsInputRedirected) return Console.ReadLine() ?? ""; + + var sb = new StringBuilder(); + ConsoleKeyInfo key; + while ((key = Console.ReadKey(intercept: true)).Key != ConsoleKey.Enter) + { + if (key.Key == ConsoleKey.Backspace && sb.Length > 0) { sb.Length--; Console.Write("\b \b"); } + else if (!char.IsControl(key.KeyChar)) { sb.Append(key.KeyChar); Console.Write('*'); } + } + Console.WriteLine(); + return sb.ToString(); + } +} diff --git a/LehrerApp.Api/Endpoints/Endpoints.cs b/LehrerApp.Api/Endpoints/Endpoints.cs index 63306a2..2817cae 100644 --- a/LehrerApp.Api/Endpoints/Endpoints.cs +++ b/LehrerApp.Api/Endpoints/Endpoints.cs @@ -14,19 +14,12 @@ public static class Endpoints public static void MapAuthEndpoints(this WebApplication app, string secret) { - app.MapPost("/api/auth/login", (LoginRequest req) => + app.MapPost("/api/auth/login", (LoginRequest req, UserStore store) => { if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) return Results.Unauthorized(); - // TODO: Passwort gegen DB prüfen - return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); - }); - - app.MapPost("/api/auth/register", (RegisterRequest req) => - { - if (req.Password.Length < 12) - return Results.BadRequest("Passwort mind. 12 Zeichen."); - // TODO: User anlegen, Passwort hashen (BCrypt) + if (!store.VerifyPassword(req.Username, req.Password)) + return Results.Unauthorized(); return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username }); }); } @@ -130,4 +123,3 @@ public static class Endpoints } public record LoginRequest(string Username, string Password); -public record RegisterRequest(string Username, string Password, string DisplayName); diff --git a/LehrerApp.Api/PasswordHasher.cs b/LehrerApp.Api/PasswordHasher.cs new file mode 100644 index 0000000..e5c64ca --- /dev/null +++ b/LehrerApp.Api/PasswordHasher.cs @@ -0,0 +1,27 @@ +using System.Security.Cryptography; + +namespace LehrerApp.Api; + +public static class PasswordHasher +{ + private const int Iterations = 100_000; + private const int SaltSize = 16; + private const int HashSize = 32; + + public static string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); + return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}"; + } + + public static bool Verify(string password, string stored) + { + var parts = stored.Split(':'); + if (parts.Length != 2) return false; + var salt = Convert.FromBase64String(parts[0]); + var expected = Convert.FromBase64String(parts[1]); + var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, HashSize); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } +} diff --git a/LehrerApp.Api/Program.cs b/LehrerApp.Api/Program.cs index 0d47454..42ad736 100644 --- a/LehrerApp.Api/Program.cs +++ b/LehrerApp.Api/Program.cs @@ -11,6 +11,11 @@ builder.WebHost.UseKestrel(o => o.ListenAnyIP(port); }); +var data = builder.Configuration["Api:DataPath"] ?? "./data"; + +if (args.Length > 0 && args[0] == "create-user") + return await Cli.RunCreateUserAsync(data, args); + var secret = builder.Configuration["JWT_SECRET"] ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); @@ -24,7 +29,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddAuthorization(); -var data = builder.Configuration["Api:DataPath"] ?? "./data"; +builder.Services.AddSingleton(_ => new UserStore(data)); builder.Services.AddSingleton(_ => new EventStore(data)); builder.Services.AddSingleton(_ => new SnapshotStore(data)); builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data)); @@ -40,3 +45,4 @@ app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); app.Run(); +return 0; diff --git a/LehrerApp.Api/UserStore.cs b/LehrerApp.Api/UserStore.cs new file mode 100644 index 0000000..1c99120 --- /dev/null +++ b/LehrerApp.Api/UserStore.cs @@ -0,0 +1,40 @@ +using LiteDB; + +namespace LehrerApp.Api; + +public class UserStore(string dataPath) : IDisposable +{ + private readonly LiteDatabase _db = new(Path.Combine(dataPath, "users.db")); + + private ILiteCollection Col + { + get + { + var col = _db.GetCollection("users"); + col.EnsureIndex(x => x.Username, unique: true); + return col; + } + } + + public bool CreateUser(string username, string password) + { + if (Col.Exists(x => x.Username == username)) return false; + Col.Insert(new UserEntry { Username = username, PasswordHash = PasswordHasher.Hash(password) }); + return true; + } + + public bool VerifyPassword(string username, string password) + { + var user = Col.FindOne(x => x.Username == username); + return user is not null && PasswordHasher.Verify(password, user.PasswordHash); + } + + public void Dispose() => _db.Dispose(); +} + +internal class UserEntry +{ + public ObjectId Id { get; set; } = ObjectId.NewObjectId(); + public string Username { get; set; } = ""; + public string PasswordHash { get; set; } = ""; +} diff --git a/LehrerApp.sln b/LehrerApp.sln index 403d2be..880f825 100644 --- a/LehrerApp.sln +++ b/LehrerApp.sln @@ -18,6 +18,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop.Tests", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync.Tests", "LehrerApp.Sync.Tests\LehrerApp.Sync.Tests.csproj", "{2E2B2FA3-B0C4-415D-913F-0A0432394EEC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api.Tests", "LehrerApp.Api.Tests\LehrerApp.Api.Tests.csproj", "{E8152216-11F1-427E-B189-D8CEC9A71C33}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -124,6 +126,18 @@ Global {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x64.Build.0 = Release|Any CPU {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.ActiveCfg = Release|Any CPU {2E2B2FA3-B0C4-415D-913F-0A0432394EEC}.Release|x86.Build.0 = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x64.Build.0 = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.ActiveCfg = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Debug|x86.Build.0 = Debug|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|Any CPU.Build.0 = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.ActiveCfg = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x64.Build.0 = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.ActiveCfg = Release|Any CPU + {E8152216-11F1-427E-B189-D8CEC9A71C33}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..e15162c --- /dev/null +++ b/docker/README.md @@ -0,0 +1,30 @@ +# LehrerApp.Api – Deployment per Docker + +## Starten + +```bash +cd docker +JWT_SECRET= docker compose up -d +``` + +`./data` (relativ zu `docker/`) wird als Volume gemountet und enthält alle Server-Daten +(Ereignis-Logs, Snapshots, Nutzer) – bei Neustarts/Updates bleibt es erhalten. + +## Nutzer anlegen + +Es gibt keine offene Registrierung (`/api/auth/register`). Neue Nutzer werden per CLI im +laufenden Container angelegt: + +```bash +docker compose exec api dotnet LehrerApp.Api.dll create-user +``` + +Fragt interaktiv nach einem Passwort (mind. 12 Zeichen). Alternativ nicht-interaktiv, z. B. aus +einem Skript: + +```bash +docker compose exec api dotnet LehrerApp.Api.dll create-user --password "" +``` + +Der Befehl beendet sich danach sofort wieder, ohne den API-Dienst zu starten – für den +eigentlichen Serverbetrieb läuft `docker compose up` unverändert weiter.