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];
}
}