using System.Security.Claims; using Flaro.Api.Auth; using Flaro.Core.Auth; using Flaro.Core.Users; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; public static class AuthEndpoints { /// Ein Hash, gegen den geprüft wird, wenn es den Benutzer gar nicht gibt. /// /// /// Gegen das Abfragen von Benutzernamen über die Uhr. Ohne ihn /// antwortet der Server bei einem unbekannten Namen sofort und bei einem /// bekannten erst nach dem Argon2-Lauf — vierzig Millisekunden Unterschied, /// und damit lässt sich eine Liste gültiger Konten zusammentragen. /// private static readonly Lazy Strohmann = new(() => PasswordHasher.Hash(Secrets.Create()), LazyThreadSafetyMode.ExecutionAndPublication); public static void MapAuth(this IEndpointRouteBuilder app) { app.MapPost("/api/v1/auth/login", async ( LoginRequest anfrage, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) => { var name = anfrage.UserName.Trim(); var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name); if (benutzer is null) { // Dieselbe Arbeit wie bei einem echten Konto, dann ablehnen. PasswordHasher.Verify(anfrage.Password, Strohmann.Value); return Abgelehnt(); } if (!PasswordHasher.Verify(anfrage.Password, benutzer.PasswordHash)) return Abgelehnt(); // Gelegenheit genutzt: hier liegt das Passwort im Klartext vor, und // nur hier lässt sich ein nach alten Kennwerten gebildeter Hash // überhaupt erneuern. if (PasswordHasher.NeedsRehash(benutzer.PasswordHash)) benutzer.PasswordHash = PasswordHasher.Hash(anfrage.Password); var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId); await db.SaveChangesAsync(); CookieSetzen(zusammenhang, antwort.Token); return Results.Ok(antwort); }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); app.MapPost("/api/v1/auth/logout", async ( ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) => { zusammenhang.Response.Cookies.Delete(FlaroTokenCookie); if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id)) return Results.Unauthorized(); var eintrag = await db.DeviceTokens.FindAsync(id); if (eintrag is not null) { eintrag.RevokedAt = clock.GetUtcNow(); await db.SaveChangesAsync(); } return Results.NoContent(); }).RequireAuthorization(); app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) => Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? Results.Ok(new AuthenticatedUser( id, wer.FindFirstValue(ClaimTypes.Name) ?? "", wer.IsInRole(FlaroRoles.Administrator))) : Results.Unauthorized()) .RequireAuthorization(); MapCodeFlow(app); } /// Anmelden mit einem Code auf dem Bildschirm. private static void MapCodeFlow(IEndpointRouteBuilder app) { app.MapPost("/api/v1/auth/code", async (CodeRequest anfrage, FlaroContext db, TimeProvider clock) => { var jetzt = clock.GetUtcNow(); // Beiläufig aufräumen. Ein eigener Hintergrunddienst für ein paar // Zeilen je Tag wäre Aufwand ohne Gegenwert. await db.AuthCodes.Where(c => c.ExpiresAt < jetzt).ExecuteDeleteAsync(); var abholschein = Secrets.Create(); var code = new AuthCode { Code = AuthCode.NewCode(), HandleFingerprint = Secrets.Fingerprint(abholschein), DeviceName = Kürzen(anfrage.DeviceName, 128), DeviceId = Kürzen(anfrage.DeviceId, 128), CreatedAt = jetzt, ExpiresAt = jetzt + AuthCode.Lifetime, }; db.AuthCodes.Add(code); await db.SaveChangesAsync(); return Results.Ok(new CodeResponse(code.Code, abholschein, code.ExpiresAt)); }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn); app.MapGet("/api/v1/auth/code/{handle}", async ( string handle, FlaroContext db, TimeProvider clock) => { var jetzt = clock.GetUtcNow(); var fingerabdruck = Secrets.Fingerprint(handle); var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck); if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null) return Results.Ok(new CodeState("expired", null)); if (code.ApprovedByUserId is not { } benutzerId) return Results.Ok(new CodeState("pending", null)); var benutzer = await db.Users.FindAsync(benutzerId); if (benutzer is null) return Results.Ok(new CodeState("expired", null)); // Einmal abgeholt ist abgeholt — vor dem Ausstellen gesetzt, damit // zwei gleichzeitige Abholungen nicht zwei Token ergeben. code.ConsumedAt = jetzt; var antwort = await TokenAusstellen(db, clock, benutzer, code.DeviceName, code.DeviceId); await db.SaveChangesAsync(); return Results.Ok(new CodeState("approved", antwort)); }).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.Polling); app.MapPost("/api/v1/auth/code/{code}", async ( string code, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => { if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var benutzerId)) return Results.Unauthorized(); var jetzt = clock.GetUtcNow(); var offen = await db.AuthCodes.FirstOrDefaultAsync(c => c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt); if (offen is null) return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound); offen.ApprovedByUserId = benutzerId; await db.SaveChangesAsync(); // Damit am Telefon steht, was man da gerade hereinlässt. return Results.Ok(new { device = offen.DeviceName }); }).RequireAuthorization().RequireRateLimiting(FlaroRateLimits.SignIn); } /// Stellt ein Geräte-Token aus und löst ein älteres desselben Geräts ab. /// /// /// Ohne das Ablösen füllt sich die Geräteliste mit Karteileichen: jede /// erneute Anmeldung derselben App hinterliesse einen weiteren Eintrag, /// und nach einem Jahr weiss niemand mehr, welches Telefon welches ist. /// private static async Task TokenAusstellen( FlaroContext db, TimeProvider clock, User benutzer, string geräteName, string geräteKennung) { var jetzt = clock.GetUtcNow(); var kennung = Kürzen(geräteKennung, 128); await db.DeviceTokens .Where(t => t.UserId == benutzer.Id && t.DeviceId == kennung && t.RevokedAt == null) .ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, jetzt)); var token = Secrets.Create(); db.DeviceTokens.Add(new DeviceToken { UserId = benutzer.Id, TokenFingerprint = Secrets.Fingerprint(token), DeviceName = Kürzen(geräteName, 128), DeviceId = kennung, CreatedAt = jetzt, LastSeenAt = jetzt, }); return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator)); } private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName; /// Legt dasselbe Token als Cookie ab — für den Browser. /// /// /// HttpOnly: kein Skript kommt heran, also auch keines, das sich /// über eine Lücke in der Oberfläche eingeschlichen hat. Strict: /// eine fremde Seite kann damit nichts auslösen. Secure nur bei /// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar /// nicht benutzen. /// private static void CookieSetzen(HttpContext zusammenhang, string token) => zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Strict, Secure = zusammenhang.Request.IsHttps, Path = "/", MaxAge = TimeSpan.FromDays(365), }); /// Eine Antwort, die nicht verrät, welche Hälfte falsch war. private static IResult Abgelehnt() => Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized); private static string Kürzen(string? wert, int höchstens) { var sauber = (wert ?? "").Trim(); return sauber.Length <= höchstens ? sauber : sauber[..höchstens]; } }