Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m20s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Live E2E found that replaying a pre-logout cookie still authenticated (200, not 401): SignOutAsync only clears the CLIENT cookie, but the stateless encrypted cookie ticket stays valid server-side because its security stamp is unchanged — a captured cookie was replayable after logout until ticket expiry. Fix: logout now rotates the local-admin security stamp (RotateLocalAdminSecurityStamp), so every outstanding local session (old stamp) fails OnValidatePrincipal on its next request. For the single admin this is "log out everywhere". Gated on an authenticated local session so an unauthenticated caller can't force-revoke the admin. OIDC sessions (no stamp) are unaffected; SignOutAsync still clears the client cookie for UX. +2 handler tests (rotate-when-configured / no-op-when-unconfigured). Auth suite green (21). Docs: decisions.md note updated. Refs #295 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
8.9 KiB
C#
213 lines
8.9 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) : ControllerBase
|
|
{
|
|
/// <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));
|
|
}
|
|
|
|
/// <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 (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: error => Task.FromResult(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);
|