Files
ersatztv/ErsatzTV/Services/ApiKeyProvider.cs
T
timothyandClaude Opus 4.8 0b23d4b6b1
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): #295 PR1 — browser SPA session auth (session-OR-key gate, server-only)
Implements the ratified #295 design (PR1, server-only, backward compatible). The
/api surface now accepts a valid X-Api-Key (machine) OR an authenticated session
(browser cookie, local login or OIDC), gated by the evolved ApiAuthorizationFilter
(renamed from ApiKeyAuthorizationFilter; same fail-closed EndpointRequiresKey
predicate). Machine/key behavior is byte-identical and the SPA keeps working via
its stored key — the SPA login flow lands in PR2.

- ApiAuthorizationFilter: key-first (CSRF-immune) then session; session-authed
  mutations require the X-CSRF header (403 otherwise). Attributes renamed
  [RequiresApiKey]->[RequiresAuthentication], [SkipApiKeyAuthorization]->[SkipApiAuthorization].
- Cookie scheme ctv-session always registered (Lax/SameAsRequest/14d sliding, 401 not
  redirect for /api); OIDC handler revived when configured (profile scope, userinfo,
  auth-method claim); UseAuthentication/UseAuthorization/UseRateLimiter revived in the
  legacy MapWhen branch.
- Local admin = single credential in ConfigElement rows (username / PBKDF2 hash via
  Microsoft.Extensions.Identity.Core / rotating security stamp) — NO DB migration.
  Password change rotates the stamp; CookieSecurityStampValidator revokes stale local
  sessions. Env-seed recovery (Auth:LocalAdmin:*) via LocalAdminSeedService.
- AuthController /api/auth/{config,session,setup,login,logout,password} + browser-nav
  GET /auth/oidc/login; excluded from OpenAPI (machine-audience spec). Per-IP rate limit
  on login/setup/password; dummy-hash verify (no user enumeration).
- ForwardedHeaders now strict opt-in: X-Forwarded-* ignored unless KnownProxies/Networks
  configured (rate-limiter IP + cookie-Secure integrity). Deployment: operators behind a
  proxy must set ForwardedHeaders:KnownProxies.
- Tests: session/CSRF filter cases + 17 Application/Auth handler tests; full ErsatzTV.Tests
  green (1499). No OpenAPI/generated-artifact drift.
- Docs: api-conventions section 9 rewritten; decisions.md entry (supersedes #206 inert-OIDC note).

Refs #295 #197 #206 #58

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

152 lines
6.0 KiB
C#

using System.Runtime.InteropServices;
using System.Security.Cryptography;
using ErsatzTV.Core;
namespace ErsatzTV.Services;
/// <summary>
/// Resolves the effective API key used by <see cref="Filters.ApiAuthorizationFilter" /> and
/// the read-gating policy. Resolved once at startup: the configured <c>Api:WriteKey</c> wins;
/// otherwise a previously-persisted key is loaded from the config volume; otherwise a fresh
/// 256-bit key is generated and persisted. The key is never empty, so write authorization is
/// always fail-closed (issue #280).
/// </summary>
public interface IApiKeyProvider
{
/// <summary>The effective API key. Never null or empty.</summary>
string ApiKey { get; }
/// <summary>When true (the default), read (GET/HEAD) requests under <c>/api</c> also require the key.</summary>
bool RequireKeyForReads { get; }
}
public sealed class ApiKeyProvider : IApiKeyProvider
{
public const string WriteKeyConfigurationKey = "Api:WriteKey";
public const string RequireKeyForReadsConfigurationKey = "Api:RequireKeyForReads";
public ApiKeyProvider(IConfiguration configuration, ILogger<ApiKeyProvider> logger)
{
// Defense-in-depth default: gate reads too. Operators behind an authenticating proxy who
// want anonymous catalog reads can opt out with Api:RequireKeyForReads=false.
RequireKeyForReads = configuration.GetValue(RequireKeyForReadsConfigurationKey, true);
ApiKey = ResolveKey(configuration, FileSystemLayout.ApiKeyPath, logger);
}
public string ApiKey { get; }
public bool RequireKeyForReads { get; }
internal static string ResolveKey(
IConfiguration configuration,
string keyFilePath,
ILogger logger,
Func<string, string> readKeyFile = null)
{
readKeyFile ??= File.ReadAllText;
string configured = configuration[WriteKeyConfigurationKey];
if (!string.IsNullOrWhiteSpace(configured))
{
logger.LogInformation("Using API key from configuration ({ConfigurationKey})", WriteKeyConfigurationKey);
return configured.Trim();
}
if (File.Exists(keyFilePath))
{
try
{
string existing = readKeyFile(keyFilePath).Trim();
if (!string.IsNullOrWhiteSpace(existing))
{
logger.LogInformation("Loaded persisted API key from {Path}", keyFilePath);
return existing;
}
logger.LogWarning("Persisted API key file {Path} was empty; generating a new key", keyFilePath);
}
catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
// Raced with a delete between the existence check and the read; treat as absent.
logger.LogWarning(
"Persisted API key file {Path} disappeared before it could be read; generating a new key",
keyFilePath);
}
catch (Exception ex)
{
// Present but unreadable (e.g. wrong permissions). Fail loud rather than silently
// generate + overwrite it, which would invalidate every client's stored key.
logger.LogError(
ex,
"API key file {Path} exists but could not be read; refusing to overwrite it. Fix its "
+ "permissions or set {ConfigurationKey}.",
keyFilePath,
WriteKeyConfigurationKey);
throw;
}
}
string generated = GenerateKey();
Persist(keyFilePath, generated, logger);
return generated;
}
internal static string GenerateKey() =>
// 256 bits of entropy, rendered as lowercase hex so it is trivial to copy/paste with no
// URL-/header-unsafe characters.
Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
private static void Persist(string path, string key, ILogger logger)
{
try
{
string directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
File.WriteAllText(path, key);
}
else
{
// Create the file owner-only (0600) up front so the key is never briefly world-readable
// between write and chmod. SetUnixFileMode afterwards re-asserts it if the file pre-existed
// (UnixCreateMode only applies to a newly-created file, not a truncated one).
const UnixFileMode ownerReadWrite = UnixFileMode.UserRead | UnixFileMode.UserWrite;
var options = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
UnixCreateMode = ownerReadWrite
};
using (var stream = new FileStream(path, options))
using (var writer = new StreamWriter(stream))
{
writer.Write(key);
}
File.SetUnixFileMode(path, ownerReadWrite);
}
logger.LogWarning(
"Generated a new API key and saved it to {Path}. Send it via the '{HeaderName}' header " +
"(ChicoryTV: Settings → API Key). The key is required for API requests.",
path,
Filters.ApiAuthorizationFilter.HeaderName);
}
catch (Exception ex)
{
logger.LogError(
ex,
"Failed to persist generated API key to {Path}; it will not survive a restart. " +
"Set {ConfigurationKey} to a fixed value to avoid this.",
path,
WriteKeyConfigurationKey);
}
}
}