Files
ersatztv/ErsatzTV/Controllers/Api/AuthController.cs
T
timothyandClaude Opus 4.8 e8c3481ea5
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(api): #295 PR1 — fold in fix-commit re-review (2nd Codex round)
Fix-commit re-review confirmed the 1st-round fixes resolved and caught a 2nd round:

- HIGH — env-seed vs. setup race: an attacker could claim admin in the startup
  window before LocalAdminSeedService runs, and the seed's insert would then be
  swallowed (attacker credential persists, defeating env recovery). Fixed
  structurally: the setup-claim endpoint is CLOSED (409) whenever
  Auth:LocalAdmin:Password is configured — the env seed owns the credential, so
  there's no claim to race (also strengthens the setup-claim TOFU posture).
  Config.setupRequired reflects it.
- LOW — a concurrent setup race-loser now returns 409 (not 422); ClaimLocalAdmin's
  DbUpdateException catch re-checks existence and rethrows genuine/transient DB
  errors instead of masking them as "already configured".
- MEDIUM (accepted, documented) — two simultaneous authenticated password changes
  are a non-serializable lost-update; accepted for a single-admin system
  (self-healing via re-login, implausible timing).

+3 AuthController tests (env-seed closes setup / setupRequired gating). Full
ErsatzTV.Tests green (1506); no generated drift. Docs updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:52:23 +02:00

235 lines
10 KiB
C#

using System.Security.Claims;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace ErsatzTV.Controllers.Api;
/// <summary>
/// Browser SPA session authentication (issue #295). Excluded from the OpenAPI document — the spec's
/// audience is machine <c>X-Api-Key</c> clients, and a browser-interactive cookie login is not something a
/// generated client drives — and exempt from the global <see cref="ApiAuthorizationFilter" /> (this surface
/// must be reachable before a caller has a session). Sensitive operations self-check the principal.
/// </summary>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[SkipApiAuthorization]
public class AuthController(IMediator mediator, IConfiguration configuration) : ControllerBase
{
// When the operator has set an env seed, the local admin is managed via configuration — the browser
// setup-claim is closed. This also eliminates the startup race where an attacker could claim admin in the
// window before LocalAdminSeedService runs (the seed would then lose and its insert be swallowed).
private bool EnvSeedConfigured => !string.IsNullOrWhiteSpace(configuration["Auth:LocalAdmin:Password"]);
/// <summary>Public: what auth options exist + whether first-run setup is still needed (drives the SPA boot gate).</summary>
[HttpGet("/api/auth/config")]
public async Task<IActionResult> Config(CancellationToken cancellationToken)
{
bool configured = await mediator.Send(new IsLocalAdminConfigured(), cancellationToken);
return Ok(new AuthConfigResponse(OidcHelper.IsEnabled, true, !configured && !EnvSeedConfigured));
}
/// <summary>The current session (anonymous is a 200 with authenticated=false, never a 401).</summary>
[HttpGet("/api/auth/session")]
public IActionResult Session()
{
if (User.Identity?.IsAuthenticated != true)
{
return Ok(new AuthSessionResponse(false, null, null));
}
return Ok(new AuthSessionResponse(true, User.Identity?.Name, User.FindFirst(AuthConstants.AuthMethodClaim)?.Value));
}
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
[HttpPost("/api/auth/setup")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> Setup([FromBody] SetupRequest request, CancellationToken cancellationToken)
{
if (EnvSeedConfigured)
{
return ApiResults.ConflictProblem(
"Managed via configuration",
"The local administrator is provisioned from Auth:LocalAdmin:* configuration; browser setup is disabled.");
}
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new ClaimLocalAdmin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: async error =>
{
// A concurrent claim that lost the race reports 409 (the record now exists), not the 422 a
// validation error gets.
if (await mediator.Send(new IsLocalAdminConfigured(), cancellationToken))
{
return ApiResults.ConflictProblem("Already configured", "A local administrator already exists.");
}
return error.ToErrorResult();
},
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>Local username/password login. A generic 401 on any failure (no username enumeration).</summary>
[HttpPost("/api/auth/login")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{
Either<BaseError, LocalAdminPrincipal> result =
await mediator.Send(new VerifyLocalAdminLogin(request.Username, request.Password), cancellationToken);
return await result.Match(
Left: _ => Task.FromResult((IActionResult)Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "Invalid username or password."
})),
Right: async principal =>
{
await IssueLocalCookieAsync(principal);
return (IActionResult)Ok(new AuthSessionResponse(true, principal.Username, AuthConstants.MethodLocal));
});
}
/// <summary>
/// Sign out of the cookie session. Requires the CSRF header (this controller is filter-exempt). For a
/// local session this rotates the security stamp, ending the session <b>server-side</b> (a captured
/// cookie can't be replayed after logout) — which, for the single local admin, revokes all local
/// sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated caller
/// can't force-revoke the admin.
/// </summary>
[HttpPost("/api/auth/logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
// Prevent forced-logout CSRF: a same-site form POST carries the Lax cookie but can't set a custom
// header. The whole controller is [SkipApiAuthorization], so the filter's CSRF check doesn't apply.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
if (User.Identity?.IsAuthenticated == true &&
string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
await mediator.Send(new RotateLocalAdminSecurityStamp(), cancellationToken);
}
await HttpContext.SignOutAsync(AuthConstants.CookieScheme);
return NoContent();
}
/// <summary>Change the local admin password (requires a local-login session); rotates the stamp, revoking other sessions.</summary>
[HttpPost("/api/auth/password")]
[EnableRateLimiting("auth")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
// Filter-exempt controller, so apply the session-mutation CSRF check here explicitly.
if (RequiresCsrf(out IActionResult csrfError))
{
return csrfError;
}
// A machine key must not be able to rotate the local admin's password — require a local session.
if (User.Identity?.IsAuthenticated != true ||
!string.Equals(
User.FindFirst(AuthConstants.AuthMethodClaim)?.Value,
AuthConstants.MethodLocal,
StringComparison.Ordinal))
{
return Unauthorized(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = "A local-login session is required to change the password."
});
}
Either<BaseError, LocalAdminPrincipal> result = await mediator.Send(
new ChangeLocalAdminPassword(User.Identity?.Name, request.CurrentPassword, request.NewPassword),
cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async principal =>
{
// Re-issue the cookie with the rotated stamp so THIS session survives while others are revoked.
await IssueLocalCookieAsync(principal);
return (IActionResult)NoContent();
});
}
/// <summary>Browser-navigation OIDC challenge. Outside <c>/api</c> (a top-level GET redirect). 404 when OIDC is off.</summary>
[HttpGet("/auth/oidc/login")]
public IActionResult OidcLogin()
{
if (!OidcHelper.IsEnabled)
{
return NotFound();
}
return Challenge(
new AuthenticationProperties { RedirectUri = $"{Request.PathBase}/app" },
AuthConstants.OidcScheme);
}
// Session-mutation CSRF gate for the [SkipApiAuthorization] auth surface (the global filter doesn't see
// it). Presence-only: a custom header can't be set by a cross-site form/navigation. Returns true (with a
// 403 result) when the header is missing.
private bool RequiresCsrf(out IActionResult error)
{
if (Request.Headers.ContainsKey(ApiAuthorizationFilter.CsrfHeaderName))
{
error = null;
return false;
}
error = new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Forbidden",
Detail = $"This request requires the '{ApiAuthorizationFilter.CsrfHeaderName}' header."
})
{
StatusCode = StatusCodes.Status403Forbidden
};
return true;
}
private Task IssueLocalCookieAsync(LocalAdminPrincipal principal)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, principal.Username),
new(AuthConstants.AuthMethodClaim, AuthConstants.MethodLocal),
new(AuthConstants.SecurityStampClaim, principal.SecurityStamp)
};
var identity = new ClaimsIdentity(claims, AuthConstants.CookieScheme);
return HttpContext.SignInAsync(AuthConstants.CookieScheme, new ClaimsPrincipal(identity));
}
}
public record AuthConfigResponse(bool OidcEnabled, bool LocalLoginEnabled, bool SetupRequired);
public record AuthSessionResponse(bool Authenticated, string Username, string Method);