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-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>
235 lines
10 KiB
C#
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);
|