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.