Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel preview was declared Unavailable and could not run at all. Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController): mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default, JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op when JWT is off), used by the channel-preview panel and the troubleshooting screen. Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now JWT-agnostic. Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one (garbage token -> 401); token endpoint 401s anonymous, mints with a session. Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session), outside the JWT filter, so it was never gated. The withIptvToken call there is a harmless defensive no-op. Docs: security.iptv-browser-token (api-auth-security.md), amended api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi + unchanged Preview schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
13 KiB
C#
299 lines
13 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 ErsatzTV.Services;
|
|
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, IApiKeyProvider apiKeyProvider)
|
|
: 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/v1/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/v1/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>
|
|
/// The effective machine <c>X-Api-Key</c> (issue #301), for the ChicoryTV "API Key" settings screen to
|
|
/// display once it moves off session-managed auth. Requires any authenticated session (OIDC or local);
|
|
/// 401 otherwise. Never CSRF-gated — it's a GET, and SOP already blocks a cross-site page from reading a
|
|
/// credentialed response body.
|
|
/// </summary>
|
|
[HttpGet("/api/v1/auth/machine-key")]
|
|
public IActionResult MachineKey()
|
|
{
|
|
if (User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return Unauthorized(new ProblemDetails
|
|
{
|
|
Status = StatusCodes.Status401Unauthorized,
|
|
Title = "Unauthorized",
|
|
Detail = "An authenticated session is required."
|
|
});
|
|
}
|
|
|
|
Response.Headers.CacheControl = "no-store";
|
|
Response.Headers.Pragma = "no-cache";
|
|
return Ok(new MachineKeyResponse(apiKeyProvider.ApiKey));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mint a short-lived, globally-scoped <c>/iptv</c> access token for the browser (issue #552), so the
|
|
/// channel-preview and playback-troubleshooting screens can reach the JWT-gated <c>/iptv/*</c> manifest
|
|
/// endpoints (which do not accept the <c>ctv-session</c> cookie) by appending <c>?access_token=</c>.
|
|
/// Requires any authenticated session (401 otherwise). Returns <b>204 No Content</b> when IPTV JWT auth
|
|
/// is not configured — <c>/iptv/*</c> is open then, so there is nothing to mint and the SPA plays the
|
|
/// plain URL. Never CSRF-gated: it's a GET that writes no server state (the JWT is stateless), and SOP
|
|
/// already blocks a cross-site page from reading the credentialed response body (same reasoning as
|
|
/// <see cref="MachineKey" />).
|
|
/// </summary>
|
|
[HttpGet("/api/v1/auth/iptv-token")]
|
|
public IActionResult IptvToken()
|
|
{
|
|
if (User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return Unauthorized(new ProblemDetails
|
|
{
|
|
Status = StatusCodes.Status401Unauthorized,
|
|
Title = "Unauthorized",
|
|
Detail = "An authenticated session is required."
|
|
});
|
|
}
|
|
|
|
if (!JwtHelper.IsEnabled)
|
|
{
|
|
return NoContent();
|
|
}
|
|
|
|
Response.Headers.CacheControl = "no-store";
|
|
Response.Headers.Pragma = "no-cache";
|
|
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
|
|
return Ok(new IptvTokenResponse(token, expiresAt));
|
|
}
|
|
|
|
/// <summary>First-run setup-claim: create the local admin. Fails 409 if one already exists.</summary>
|
|
[HttpPost("/api/v1/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/v1/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/v1/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/v1/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);
|
|
|
|
public record MachineKeyResponse(string ApiKey);
|
|
|
|
public record IptvTokenResponse(string Token, DateTimeOffset ExpiresAt);
|