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
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>
152 lines
6.0 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|