Merge remote-tracking branch 'origin/main' into ci/303-api-docs-blocking
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts: # docs/decisions.md
This commit is contained in:
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Husky pre-push backstop for ersatztv#303 H6 — the fast-forward-to-main path the Claude merge
|
||||
# hook (pretooluse-merge-consent.sh) can't see. Reads git's pre-push ref lines on stdin; for a push
|
||||
# to main it scans the pushed commits for a Gitea close-keyword (`fixes #N`), and if the linked
|
||||
# issue's "## Done-when" checklist still has unticked boxes it BLOCKS the push.
|
||||
#
|
||||
# A git hook has no interactive "ask", so this is deliberately fail-OPEN: it only blocks when it can
|
||||
# positively prove an unticked box (creds present, issue fetched, non-docs change). No creds, Gitea
|
||||
# unreachable, docs-only diff, or no linked issue -> allow (a loud warning at most). The authoritative
|
||||
# gate is the merge hook; this just catches a direct `git push origin main`.
|
||||
#
|
||||
# Auth (never committed): ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH; ETV_GITEA_URL overrides the base.
|
||||
set -euo pipefail
|
||||
|
||||
# git passes "<localref> <localsha> <remoteref> <remotesha>" lines on stdin.
|
||||
refs=$(cat || true)
|
||||
printf '%s\n' "$refs" | grep -q 'refs/heads/main' || exit 0 # only gate pushes to main
|
||||
|
||||
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
||||
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
exit 0 # can't verify -> fail-open (the merge hook is the real gate)
|
||||
fi
|
||||
gq() {
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$1" 2>/dev/null || true
|
||||
else
|
||||
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$1" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
zero=0000000000000000000000000000000000000000
|
||||
blocked=""
|
||||
while read -r localref localsha remoteref remotesha; do
|
||||
[ "$remoteref" = "refs/heads/main" ] || continue
|
||||
[ "$localsha" = "$zero" ] && continue # branch deletion
|
||||
# Commit range being pushed. New branch (remotesha all-zero) -> just the tip, don't rescan history.
|
||||
if [ "$remotesha" = "$zero" ]; then range="$localsha -1"; else range="$remotesha..$localsha"; fi
|
||||
msgs=$(git log --format='%B' $range 2>/dev/null || true)
|
||||
issues=$(printf '%s' "$msgs" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
||||
[ -n "$issues" ] || continue
|
||||
|
||||
# Docs-only exemption over the pushed range.
|
||||
changed=$(git diff --name-only $range 2>/dev/null || true)
|
||||
if [ -n "$changed" ] && ! printf '%s\n' "$changed" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for n in $issues; do
|
||||
ibody=$(gq "repos/timothy/ersatztv/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
[ -n "$ibody" ] || continue # can't fetch -> fail-open
|
||||
unchecked=$(printf '%s\n' "$ibody" | awk '
|
||||
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
||||
grab && /^##[[:space:]]/ {grab=0}
|
||||
grab {print}' | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
||||
if [ "${unchecked:-0}" -gt 0 ]; then
|
||||
blocked="${blocked} - issue #$n has $unchecked unticked ## Done-when box(es)\n"
|
||||
fi
|
||||
done
|
||||
done <<EOF
|
||||
$refs
|
||||
EOF
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
printf 'husky - H6 merge-consent (ersatztv#303): push to main BLOCKED\n' >&2
|
||||
printf '%b' "$blocked" >&2
|
||||
printf 'Finish/tick every Done-when criterion (incl. adversarial review) first, or push a docs-only change.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
|
||||
# trusting the agent's judgment (ersatztv#303 H6). A PR merge is the one irreversible op; allow it
|
||||
# only when BOTH are true:
|
||||
# (a) the PR's CI combined status is green, AND
|
||||
# (b) every checkbox in the linked issue's "## Done-when" section is ticked.
|
||||
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
|
||||
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
|
||||
#
|
||||
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
|
||||
# - state derivable and NOT satisfied -> deny (actionable reason)
|
||||
# - state derivable and satisfied -> allow
|
||||
# - state NOT derivable (no creds, Gitea down,
|
||||
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
|
||||
# Only a real merge is gated; every other pull_request_write method is allowed untouched.
|
||||
#
|
||||
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
|
||||
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
||||
set -euo pipefail
|
||||
input=$(cat)
|
||||
|
||||
decide() { # $1=allow|deny|ask $2=reason
|
||||
case "$1" in
|
||||
allow) exit 0 ;;
|
||||
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
|
||||
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
|
||||
[ "$method" = "merge" ] || decide allow ""
|
||||
|
||||
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
|
||||
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
|
||||
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
|
||||
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
|
||||
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
|
||||
|
||||
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
||||
# curl wrapper carrying whichever auth is configured; empty output on any failure.
|
||||
gq() {
|
||||
local path="$1"
|
||||
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
||||
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
||||
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
||||
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
|
||||
fi
|
||||
|
||||
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
|
||||
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
|
||||
|
||||
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
||||
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
|
||||
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
|
||||
files=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=100" | jq -r '.[].filename // empty' 2>/dev/null || true)
|
||||
if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)'; then
|
||||
decide allow "" # all changed files are docs/process-only
|
||||
fi
|
||||
|
||||
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
|
||||
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
||||
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
|
||||
|
||||
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
|
||||
for n in $issues; do
|
||||
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
||||
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
|
||||
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
|
||||
section=$(printf '%s\n' "$ibody" | awk '
|
||||
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
||||
grab && /^##[[:space:]]/ {grab=0}
|
||||
grab {print}')
|
||||
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
||||
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
|
||||
fi
|
||||
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
||||
if [ "${unchecked:-0}" -gt 0 ]; then
|
||||
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
|
||||
fi
|
||||
done
|
||||
|
||||
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
|
||||
if [ "$mwcs" != "true" ]; then
|
||||
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
|
||||
state=$(gq "repos/$owner/$repo/commits/$sha/status" | jq -r '.state // ""' 2>/dev/null || true)
|
||||
case "$state" in
|
||||
success) : ;;
|
||||
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
|
||||
*) decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success'. Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging." ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Both derivable and satisfied -> allow.
|
||||
decide allow ""
|
||||
@@ -36,6 +36,16 @@
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__gitea__pull_request_write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/pretooluse-merge-consent.sh\"",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# H6 merge-consent backstop (ersatztv#303): gate a direct push to main on the linked issue's
|
||||
# ## Done-when checklist. Read git's pre-push ref lines FIRST (before the web checks below, which
|
||||
# may consume stdin) and forward them. Fail-open: no creds / not main / docs-only -> allow.
|
||||
_prepush_refs="$(cat)"
|
||||
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-donewhen.sh || exit 1
|
||||
|
||||
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
|
||||
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
|
||||
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
|
||||
|
||||
@@ -82,6 +82,12 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
**Merge-consent is derived from state, not asserted (`## Done-when` convention — ersatztv#303 H6).** Any issue whose PR will merge to `main` should carry a `## Done-when` section in its **issue body** — a checklist of completion criteria (always include an "adversarial review passed" box; add per-issue criteria like tests-green, docs-updated, live-E2E). Two hooks derive merge-consent from it so a premature merge is blocked *by construction*, not by memory:
|
||||
- `pretooluse-merge-consent.sh` (Claude PreToolUse on the Gitea merge tool) — **allows** a merge only when the PR's CI is green **and** every `## Done-when` box on the linked issue (`fixes #N`) is ticked; **denies** on an unticked box or red CI; **asks** (falls back to a human prompt) when it can't derive state (no linked issue, no `## Done-when` section, no creds, Gitea down).
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — a fail-open backstop that blocks a direct `git push origin main` whose commits `fix #N` an issue with unticked boxes.
|
||||
|
||||
Both need Gitea read creds in the env to enforce (**`ETV_GITEA_BASICAUTH=user:pass`** or `ETV_GITEA_TOKEN`; `ETV_GITEA_URL` overrides the base). Without them the merge hook asks and the push backstop is a no-op — the gate degrades to today's manual confirmation, never a silent pass. Docs-only PRs/pushes are exempt.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Shared constants for the browser-SPA session authentication (issue #295): the cookie scheme name,
|
||||
/// the custom claim types the local-login path stamps onto the principal, and the auth-method marker
|
||||
/// values. The web host (cookie <c>OnValidatePrincipal</c>, <c>AuthController</c>) and the Application
|
||||
/// handlers both reference these so the claim contract has a single definition.
|
||||
/// </summary>
|
||||
public static class AuthConstants
|
||||
{
|
||||
/// <summary>The cookie authentication scheme name shared by local login and the OIDC callback.</summary>
|
||||
public const string CookieScheme = "cookie";
|
||||
|
||||
/// <summary>The OIDC challenge scheme name.</summary>
|
||||
public const string OidcScheme = "oidc";
|
||||
|
||||
/// <summary>Claim type recording how the principal signed in (<see cref="MethodLocal" /> / <see cref="MethodOidc" />).</summary>
|
||||
public const string AuthMethodClaim = "etv:auth_method";
|
||||
|
||||
/// <summary>Claim type carrying the local admin's security stamp (checked on every request to revoke sessions).</summary>
|
||||
public const string SecurityStampClaim = "etv:security_stamp";
|
||||
|
||||
public const string MethodLocal = "local";
|
||||
public const string MethodOidc = "oidc";
|
||||
|
||||
/// <summary>Minimum length for a local admin password.</summary>
|
||||
public const int MinPasswordLength = 8;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the local admin password after verifying the current one. Rotates the security stamp so all
|
||||
/// other sessions are revoked. <see cref="Username" /> is the signed-in principal's name.
|
||||
/// </summary>
|
||||
public record ChangeLocalAdminPassword(string Username, string CurrentPassword, string NewPassword)
|
||||
: IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -0,0 +1,68 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class ChangeLocalAdminPasswordHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<ChangeLocalAdminPassword, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
ChangeLocalAdminPassword request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.NewPassword))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<ConfigElement> rows = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
ConfigElement userRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminUsername.Key);
|
||||
ConfigElement hashRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key);
|
||||
ConfigElement stampRow = rows.Find(r => r.Key == ConfigElementKey.AuthSecurityStamp.Key);
|
||||
|
||||
if (hashRow is null)
|
||||
{
|
||||
return BaseError.New("No local administrator is configured");
|
||||
}
|
||||
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
bool userMatches = userRow is not null
|
||||
&& string.Equals(userRow.Value, username, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
LocalPasswordVerification result =
|
||||
passwordHasher.Verify(hashRow.Value, request.CurrentPassword ?? string.Empty);
|
||||
|
||||
if (!userMatches || result == LocalPasswordVerification.Failed)
|
||||
{
|
||||
return BaseError.New("Current password is incorrect");
|
||||
}
|
||||
|
||||
// Atomic: the new hash and rotated stamp commit together, so a crash can't leave the new password
|
||||
// active with the old stamp still authorizing revoked sessions.
|
||||
string stamp = LocalAdminHelpers.NewSecurityStamp();
|
||||
hashRow.Value = passwordHasher.Hash(request.NewPassword);
|
||||
if (stampRow is null)
|
||||
{
|
||||
dbContext.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
|
||||
}
|
||||
else
|
||||
{
|
||||
stampRow.Value = stamp;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new LocalAdminPrincipal(userRow.Value, stamp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// First-run setup-claim: creates the single local administrator. Fails if one already exists
|
||||
/// (first-claim-wins), so a later anonymous call cannot take over the account.
|
||||
/// </summary>
|
||||
public record ClaimLocalAdmin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -0,0 +1,68 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class ClaimLocalAdminHandler(IDbContextFactory<TvContext> dbContextFactory, ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<ClaimLocalAdmin, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
ClaimLocalAdmin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidateNewCredentials(request.Username, request.Password))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
string username = request.Username.Trim();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Fast path for the common already-configured case (clean 409). The real first-claim-wins guard is
|
||||
// the unique index on ConfigElement.Key + the single atomic SaveChanges below: two concurrent claims
|
||||
// both pass this check, but only one INSERT of the three credential rows commits — the loser's
|
||||
// SaveChanges violates the unique Key index and rolls back wholesale (no mixed-state credential).
|
||||
bool alreadyConfigured = await dbContext.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
|
||||
if (alreadyConfigured)
|
||||
{
|
||||
return BaseError.New("A local administrator has already been configured");
|
||||
}
|
||||
|
||||
string stamp = LocalAdminHelpers.NewSecurityStamp();
|
||||
dbContext.ConfigElements.AddRange(
|
||||
new ConfigElement { Key = ConfigElementKey.AuthLocalAdminUsername.Key, Value = username },
|
||||
new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.AuthLocalAdminPasswordHash.Key,
|
||||
Value = passwordHasher.Hash(request.Password)
|
||||
},
|
||||
new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// A write conflict here is (almost always) a lost first-claim race — a concurrent claim inserted
|
||||
// these keys first (unique Key index). Confirm the row now exists on a fresh context before
|
||||
// reporting "already configured"; otherwise this was a genuine/transient DB error → rethrow rather
|
||||
// than mask it.
|
||||
await using TvContext verifyContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
bool nowConfigured = await verifyContext.ConfigElements
|
||||
.AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
|
||||
if (nowConfigured)
|
||||
{
|
||||
return BaseError.New("A local administrator has already been configured");
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
return new LocalAdminPrincipal(username, stamp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The current local-admin security stamp, or <c>None</c> if no local admin is configured. The cookie
|
||||
/// <c>OnValidatePrincipal</c> compares this to the principal's stamp claim on every request; a mismatch
|
||||
/// (i.e. the password was changed) rejects the session.
|
||||
/// </summary>
|
||||
public record GetLocalAdminSecurityStamp : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class GetLocalAdminSecurityStampHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetLocalAdminSecurityStamp, Option<string>>
|
||||
{
|
||||
public async Task<Option<string>> Handle(GetLocalAdminSecurityStamp request, CancellationToken cancellationToken) =>
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public enum LocalPasswordVerification
|
||||
{
|
||||
Failed,
|
||||
Success,
|
||||
SuccessRehashNeeded
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps ASP.NET Core Identity's <c>PasswordHasher</c> (PBKDF2) behind a minimal, framework-agnostic
|
||||
/// surface so the Auth handlers don't depend on Identity types directly.
|
||||
/// </summary>
|
||||
public interface ILocalPasswordHasher
|
||||
{
|
||||
/// <summary>Hashes a password for storage (random per-hash salt embedded in the returned string).</summary>
|
||||
string Hash(string password);
|
||||
|
||||
/// <summary>Verifies a password against a stored hash in constant time (delegated to Identity).</summary>
|
||||
LocalPasswordVerification Verify(string hash, string password);
|
||||
|
||||
/// <summary>
|
||||
/// A stable, valid hash of a throwaway password. Verify against this when no real credential exists
|
||||
/// so an unknown-username / unconfigured login costs the same as a real one (no user enumeration).
|
||||
/// </summary>
|
||||
string DummyHash { get; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>True once a local administrator credential has been set (first-run setup is complete).</summary>
|
||||
public record IsLocalAdminConfigured : IRequest<bool>;
|
||||
@@ -0,0 +1,15 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class IsLocalAdminConfiguredHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<IsLocalAdminConfigured, bool>
|
||||
{
|
||||
public async Task<bool> Handle(IsLocalAdminConfigured request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> hash =
|
||||
await configElementRepository.GetConfigElement(ConfigElementKey.AuthLocalAdminPasswordHash, cancellationToken);
|
||||
return hash.IsSome;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Cryptography;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
internal static class LocalAdminHelpers
|
||||
{
|
||||
public const int MaxUsernameLength = 256;
|
||||
|
||||
// Upper bound so an absurdly long password can't burn CPU in PBKDF2 (the request body is also capped
|
||||
// by Kestrel, #283; this is defense-in-depth on the field itself).
|
||||
public const int MaxPasswordLength = 1024;
|
||||
|
||||
/// <summary>128 bits of random, lowercase hex. Rotated on every password change to revoke sessions.</summary>
|
||||
public static string NewSecurityStamp() =>
|
||||
Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||
|
||||
/// <summary>Validates a new username + password. Returns the error, or None if valid.</summary>
|
||||
public static Option<BaseError> ValidateNewCredentials(string username, string password)
|
||||
{
|
||||
string trimmed = (username ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return BaseError.New("Username is required");
|
||||
}
|
||||
|
||||
if (trimmed.Length > MaxUsernameLength)
|
||||
{
|
||||
return BaseError.New("Username is too long");
|
||||
}
|
||||
|
||||
return ValidatePassword(password);
|
||||
}
|
||||
|
||||
public static Option<BaseError> ValidatePassword(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password) || password.Length < AuthConstants.MinPasswordLength)
|
||||
{
|
||||
return BaseError.New($"Password must be at least {AuthConstants.MinPasswordLength} characters");
|
||||
}
|
||||
|
||||
if (password.Length > MaxPasswordLength)
|
||||
{
|
||||
return BaseError.New($"Password must be at most {MaxPasswordLength} characters");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// The identity of the single local administrator, as returned by a successful claim / login / password
|
||||
/// change. The web host turns this into a cookie principal: <see cref="Username" /> becomes the name claim
|
||||
/// and <see cref="SecurityStamp" /> is stamped as <see cref="AuthConstants.SecurityStampClaim" /> so a later
|
||||
/// password change (which rotates the stamp) revokes the session.
|
||||
/// </summary>
|
||||
public record LocalAdminPrincipal(string Username, string SecurityStamp);
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ILocalPasswordHasher" /> backed by ASP.NET Core Identity's <see cref="PasswordHasher{TUser}" />
|
||||
/// (PBKDF2-HMAC-SHA512, per-hash random salt, format-versioned so a future work-factor bump is a
|
||||
/// transparent rehash-on-verify). Stateless and thread-safe → registered as a singleton.
|
||||
/// </summary>
|
||||
public sealed class LocalPasswordHasher : ILocalPasswordHasher
|
||||
{
|
||||
// The generic user parameter is unused by the hasher (it takes no per-user data), so a shared sentinel
|
||||
// is fine.
|
||||
private static readonly object Sentinel = new();
|
||||
|
||||
private readonly PasswordHasher<object> _hasher = new();
|
||||
private readonly Lazy<string> _dummyHash;
|
||||
|
||||
public LocalPasswordHasher() =>
|
||||
_dummyHash = new Lazy<string>(() => _hasher.HashPassword(Sentinel, "not-a-real-password"));
|
||||
|
||||
public string DummyHash => _dummyHash.Value;
|
||||
|
||||
public string Hash(string password) => _hasher.HashPassword(Sentinel, password);
|
||||
|
||||
public LocalPasswordVerification Verify(string hash, string password) =>
|
||||
_hasher.VerifyHashedPassword(Sentinel, hash, password) switch
|
||||
{
|
||||
PasswordVerificationResult.Success => LocalPasswordVerification.Success,
|
||||
PasswordVerificationResult.SuccessRehashNeeded => LocalPasswordVerification.SuccessRehashNeeded,
|
||||
_ => LocalPasswordVerification.Failed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the local admin security stamp, revoking every outstanding local session server-side (their
|
||||
/// cookies carry the old stamp and fail <c>OnValidatePrincipal</c> on their next request). Used by logout
|
||||
/// so signing out actually ends the session server-side, not just client-side. A no-op when no local
|
||||
/// admin is configured. OIDC sessions are unaffected (they carry no stamp).
|
||||
/// </summary>
|
||||
public record RotateLocalAdminSecurityStamp : IRequest<Unit>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class RotateLocalAdminSecurityStampHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<RotateLocalAdminSecurityStamp, Unit>
|
||||
{
|
||||
public async Task<Unit> Handle(RotateLocalAdminSecurityStamp request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
ConfigElement stampRow = await dbContext.ConfigElements
|
||||
.FirstOrDefaultAsync(c => c.Key == ConfigElementKey.AuthSecurityStamp.Key, cancellationToken);
|
||||
|
||||
// No local admin configured → nothing to revoke.
|
||||
if (stampRow is null)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
stampRow.Value = LocalAdminHelpers.NewSecurityStamp();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Recovery/bootstrap path: (re)sets the local admin from configuration (env
|
||||
/// <c>Auth:LocalAdmin:Username</c>/<c>Password</c>). Overwrites any existing credential and rotates the
|
||||
/// stamp (revoking sessions), so an operator who is locked out can reset by setting the env and
|
||||
/// restarting. Runs at startup only when a password is configured.
|
||||
/// </summary>
|
||||
public record SeedLocalAdminFromEnvironment(string Username, string Password) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,63 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class SeedLocalAdminFromEnvironmentHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<SeedLocalAdminFromEnvironment, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
SeedLocalAdminFromEnvironment request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
if (username.Length == 0)
|
||||
{
|
||||
username = "admin";
|
||||
}
|
||||
|
||||
if (username.Length > LocalAdminHelpers.MaxUsernameLength)
|
||||
{
|
||||
return BaseError.New("Seed username is too long");
|
||||
}
|
||||
|
||||
foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.Password))
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<ConfigElement> rows = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Overwrite (recovery/bootstrap) atomically: username + new hash + rotated stamp commit together.
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminUsername.Key, username);
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthLocalAdminPasswordHash.Key, passwordHasher.Hash(request.Password));
|
||||
Upsert(dbContext, rows, ConfigElementKey.AuthSecurityStamp.Key, LocalAdminHelpers.NewSecurityStamp());
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static void Upsert(TvContext dbContext, List<ConfigElement> existing, string key, string value)
|
||||
{
|
||||
ConfigElement row = existing.Find(r => r.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
dbContext.ConfigElements.Add(new ConfigElement { Key = key, Value = value });
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a local-login username/password. On success returns the principal (username + current
|
||||
/// security stamp) to sign into a cookie. A generic error (no username enumeration) on any failure.
|
||||
/// </summary>
|
||||
public record VerifyLocalAdminLogin(string Username, string Password) : IRequest<Either<BaseError, LocalAdminPrincipal>>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Auth;
|
||||
|
||||
public class VerifyLocalAdminLoginHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILocalPasswordHasher passwordHasher)
|
||||
: IRequestHandler<VerifyLocalAdminLogin, Either<BaseError, LocalAdminPrincipal>>
|
||||
{
|
||||
private static readonly BaseError InvalidCredentials = BaseError.New("Invalid username or password");
|
||||
|
||||
public async Task<Either<BaseError, LocalAdminPrincipal>> Handle(
|
||||
VerifyLocalAdminLogin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string username = (request.Username ?? string.Empty).Trim();
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
// Read the hash and stamp in ONE snapshot so they are consistent (issue: a login racing a password
|
||||
// change must not return a stamp newer than the hash it verified). A concurrent change is then either
|
||||
// wholly before this read (the old password fails to verify) or wholly after it (we return the
|
||||
// pre-change stamp, so the cookie AuthController issues is revoked on its very next request by
|
||||
// CookieSecurityStampValidator). No writes happen here, so there is nothing to clobber.
|
||||
Dictionary<string, string> config = await dbContext.ConfigElements
|
||||
.Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
|
||||
|| c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
|
||||
|| c.Key == ConfigElementKey.AuthSecurityStamp.Key)
|
||||
.ToDictionaryAsync(c => c.Key, c => c.Value, cancellationToken);
|
||||
|
||||
config.TryGetValue(ConfigElementKey.AuthLocalAdminUsername.Key, out string storedUser);
|
||||
config.TryGetValue(ConfigElementKey.AuthLocalAdminPasswordHash.Key, out string storedHash);
|
||||
config.TryGetValue(ConfigElementKey.AuthSecurityStamp.Key, out string stamp);
|
||||
|
||||
// Always run exactly one PBKDF2 verify — against a dummy hash when unconfigured/unknown — so response
|
||||
// timing does not reveal whether the account exists (no user enumeration).
|
||||
string candidateHash = storedHash ?? passwordHasher.DummyHash;
|
||||
LocalPasswordVerification result = passwordHasher.Verify(candidateHash, request.Password ?? string.Empty);
|
||||
|
||||
bool userMatches = storedUser is not null
|
||||
&& string.Equals(storedUser, username, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (storedHash is null || !userMatches || result == LocalPasswordVerification.Failed)
|
||||
{
|
||||
return InvalidCredentials;
|
||||
}
|
||||
|
||||
return new LocalAdminPrincipal(storedUser, stamp ?? string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
|
||||
@@ -61,4 +61,11 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey XmltvTimeZone => new("xmltv.time_zone");
|
||||
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
|
||||
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
|
||||
|
||||
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
|
||||
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
|
||||
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
|
||||
public static ConfigElementKey AuthLocalAdminUsername => new("auth.local_admin.username");
|
||||
public static ConfigElementKey AuthLocalAdminPasswordHash => new("auth.local_admin.password_hash");
|
||||
public static ConfigElementKey AuthSecurityStamp => new("auth.security_stamp");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class ChangeLocalAdminPasswordHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ILocalPasswordHasher _passwordHasher = null!;
|
||||
|
||||
private const string Username = "Operator";
|
||||
private const string CurrentPassword = "supersecret";
|
||||
private const string NewPassword = "evenbettersecret";
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
||||
_passwordHasher = new LocalPasswordHasher();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private ChangeLocalAdminPasswordHandler MakeHandler() =>
|
||||
new(_db.Factory, _passwordHasher);
|
||||
|
||||
private async Task SeedAdmin()
|
||||
{
|
||||
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
|
||||
(await claim.Handle(new ClaimLocalAdmin(Username, CurrentPassword), CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private async Task<string> StoredHash() =>
|
||||
(await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthLocalAdminPasswordHash,
|
||||
CancellationToken.None)).IfNone("");
|
||||
|
||||
private async Task<string> StoredStamp() =>
|
||||
(await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthSecurityStamp,
|
||||
CancellationToken.None)).IfNone("");
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Change_Password_And_Rotate_Stamp_On_Valid_Current_Password()
|
||||
{
|
||||
await SeedAdmin();
|
||||
string originalStamp = await StoredStamp();
|
||||
ChangeLocalAdminPasswordHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
string storedHash = await StoredHash();
|
||||
_passwordHasher.Verify(storedHash, NewPassword).ShouldNotBe(LocalPasswordVerification.Failed);
|
||||
_passwordHasher.Verify(storedHash, CurrentPassword).ShouldBe(LocalPasswordVerification.Failed);
|
||||
|
||||
string newStamp = await StoredStamp();
|
||||
newStamp.ShouldNotBe(originalStamp);
|
||||
LocalAdminPrincipal principal = result.Match(
|
||||
Left: e => throw new ShouldAssertException(e.ToString()),
|
||||
Right: p => p);
|
||||
principal.SecurityStamp.ShouldBe(newStamp);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Fail_On_Wrong_Current_Password_And_Change_Nothing()
|
||||
{
|
||||
await SeedAdmin();
|
||||
string originalHash = await StoredHash();
|
||||
string originalStamp = await StoredStamp();
|
||||
ChangeLocalAdminPasswordHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ChangeLocalAdminPassword(Username, "notthecurrentpassword", NewPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
(await StoredHash()).ShouldBe(originalHash);
|
||||
(await StoredStamp()).ShouldBe(originalStamp);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Short_New_Password()
|
||||
{
|
||||
await SeedAdmin();
|
||||
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
|
||||
ChangeLocalAdminPasswordHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ChangeLocalAdminPassword(Username, CurrentPassword, shortPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Fail_On_Unconfigured_Db()
|
||||
{
|
||||
ChangeLocalAdminPasswordHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class ClaimLocalAdminHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ILocalPasswordHasher _passwordHasher = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
||||
_passwordHasher = new LocalPasswordHasher();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private ClaimLocalAdminHandler MakeHandler() =>
|
||||
new(_db.Factory, _passwordHasher);
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Claim_Fresh_Admin_And_Persist_All_Config_Elements()
|
||||
{
|
||||
ClaimLocalAdminHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ClaimLocalAdmin("Operator", "supersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
LocalAdminPrincipal principal = result.Match(
|
||||
Left: e => throw new ShouldAssertException(e.ToString()),
|
||||
Right: p => p);
|
||||
principal.Username.ShouldBe("Operator");
|
||||
principal.SecurityStamp.ShouldNotBeNullOrEmpty();
|
||||
|
||||
Option<string> storedUser = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthLocalAdminUsername,
|
||||
CancellationToken.None);
|
||||
storedUser.IfNone("").ShouldBe("Operator");
|
||||
|
||||
Option<string> storedStamp = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthSecurityStamp,
|
||||
CancellationToken.None);
|
||||
storedStamp.IfNone("").ShouldBe(principal.SecurityStamp);
|
||||
|
||||
Option<string> storedHash = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthLocalAdminPasswordHash,
|
||||
CancellationToken.None);
|
||||
storedHash.IsSome.ShouldBeTrue();
|
||||
_passwordHasher.Verify(storedHash.IfNone(""), "supersecret")
|
||||
.ShouldNotBe(LocalPasswordVerification.Failed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Refuse_Second_Claim_When_Admin_Already_Configured()
|
||||
{
|
||||
ClaimLocalAdminHandler handler = MakeHandler();
|
||||
(await handler.Handle(new ClaimLocalAdmin("First", "supersecret"), CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ClaimLocalAdmin("Second", "anothersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
|
||||
// The original credential is untouched.
|
||||
Option<string> storedUser = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthLocalAdminUsername,
|
||||
CancellationToken.None);
|
||||
storedUser.IfNone("").ShouldBe("First");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Whitespace_Username_And_Persist_Nothing()
|
||||
{
|
||||
ClaimLocalAdminHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ClaimLocalAdmin(" ", "supersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await AssertNothingPersisted();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Short_Password_And_Persist_Nothing()
|
||||
{
|
||||
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
|
||||
ClaimLocalAdminHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ClaimLocalAdmin("Operator", shortPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await AssertNothingPersisted();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Over_Long_Password_And_Persist_Nothing()
|
||||
{
|
||||
// LocalAdminHelpers.MaxPasswordLength (1024) is internal; use the documented bound directly.
|
||||
string longPassword = new('a', 1024 + 1);
|
||||
ClaimLocalAdminHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new ClaimLocalAdmin("Operator", longPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await AssertNothingPersisted();
|
||||
}
|
||||
|
||||
private async Task AssertNothingPersisted()
|
||||
{
|
||||
(await _configElementRepository.GetConfigElement(
|
||||
ConfigElementKey.AuthLocalAdminUsername,
|
||||
CancellationToken.None)).IsNone.ShouldBeTrue();
|
||||
(await _configElementRepository.GetConfigElement(
|
||||
ConfigElementKey.AuthLocalAdminPasswordHash,
|
||||
CancellationToken.None)).IsNone.ShouldBeTrue();
|
||||
(await _configElementRepository.GetConfigElement(
|
||||
ConfigElementKey.AuthSecurityStamp,
|
||||
CancellationToken.None)).IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class RotateLocalAdminSecurityStampHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ILocalPasswordHasher _passwordHasher = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
||||
_passwordHasher = new LocalPasswordHasher();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private RotateLocalAdminSecurityStampHandler MakeHandler() => new(_db.Factory);
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Rotate_The_Stamp_When_Configured()
|
||||
{
|
||||
// Arrange: claim an admin so a stamp exists.
|
||||
Either<BaseError, LocalAdminPrincipal> claim = await new ClaimLocalAdminHandler(_db.Factory, _passwordHasher)
|
||||
.Handle(new ClaimLocalAdmin("admin", "supersecret"), CancellationToken.None);
|
||||
claim.IsRight.ShouldBeTrue();
|
||||
|
||||
Option<string> before =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
||||
string originalStamp = before.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
|
||||
|
||||
// Act
|
||||
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
|
||||
|
||||
// Assert: the stamp changed (all outstanding sessions carrying the old stamp are now stale).
|
||||
Option<string> after =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
||||
string rotatedStamp = after.Match(s => s, () => throw new ShouldAssertException("expected a stamp"));
|
||||
rotatedStamp.ShouldNotBe(originalStamp);
|
||||
rotatedStamp.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Be_A_No_Op_When_Unconfigured()
|
||||
{
|
||||
await MakeHandler().Handle(new RotateLocalAdminSecurityStamp(), CancellationToken.None);
|
||||
|
||||
Option<string> stamp =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.AuthSecurityStamp, CancellationToken.None);
|
||||
stamp.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class SeedLocalAdminFromEnvironmentHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ILocalPasswordHasher _passwordHasher = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
||||
_passwordHasher = new LocalPasswordHasher();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private SeedLocalAdminFromEnvironmentHandler MakeHandler() =>
|
||||
new(_db.Factory, _passwordHasher);
|
||||
|
||||
private async Task<string> StoredValue(ConfigElementKey key) =>
|
||||
(await _configElementRepository.GetValue<string>(key, CancellationToken.None)).IfNone("");
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Seed_Username_Hash_And_Stamp_On_Fresh_Db()
|
||||
{
|
||||
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new SeedLocalAdminFromEnvironment("Operator", "supersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Operator");
|
||||
(await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBeNullOrEmpty();
|
||||
_passwordHasher.Verify(await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash), "supersecret")
|
||||
.ShouldNotBe(LocalPasswordVerification.Failed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Overwrite_Existing_Credential_And_Rotate_Stamp()
|
||||
{
|
||||
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
|
||||
(await claim.Handle(new ClaimLocalAdmin("Original", "originalsecret"), CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
string originalStamp = await StoredValue(ConfigElementKey.AuthSecurityStamp);
|
||||
|
||||
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new SeedLocalAdminFromEnvironment("Replacement", "replacementsecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Replacement");
|
||||
(await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBe(originalStamp);
|
||||
|
||||
string storedHash = await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash);
|
||||
_passwordHasher.Verify(storedHash, "replacementsecret").ShouldNotBe(LocalPasswordVerification.Failed);
|
||||
_passwordHasher.Verify(storedHash, "originalsecret").ShouldBe(LocalPasswordVerification.Failed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Default_Empty_Username_To_Admin()
|
||||
{
|
||||
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new SeedLocalAdminFromEnvironment(" ", "supersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Over_Long_Username()
|
||||
{
|
||||
// LocalAdminHelpers.MaxUsernameLength (256) is internal; use the documented bound directly.
|
||||
string longUsername = new('u', 256 + 1);
|
||||
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new SeedLocalAdminFromEnvironment(longUsername, "supersecret"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
(await _configElementRepository.GetConfigElement(
|
||||
ConfigElementKey.AuthLocalAdminUsername,
|
||||
CancellationToken.None)).IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Reject_Short_Password()
|
||||
{
|
||||
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
|
||||
SeedLocalAdminFromEnvironmentHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new SeedLocalAdminFromEnvironment("Operator", shortPassword),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
(await _configElementRepository.GetConfigElement(
|
||||
ConfigElementKey.AuthLocalAdminPasswordHash,
|
||||
CancellationToken.None)).IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Auth;
|
||||
|
||||
[TestFixture]
|
||||
public class VerifyLocalAdminLoginHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ILocalPasswordHasher _passwordHasher = null!;
|
||||
|
||||
private const string Username = "Operator";
|
||||
private const string Password = "supersecret";
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = new ConfigElementRepository(_db.Factory);
|
||||
_passwordHasher = new LocalPasswordHasher();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private VerifyLocalAdminLoginHandler MakeHandler() =>
|
||||
new(_db.Factory, _passwordHasher);
|
||||
|
||||
private async Task SeedAdmin()
|
||||
{
|
||||
var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher);
|
||||
(await claim.Handle(new ClaimLocalAdmin(Username, Password), CancellationToken.None))
|
||||
.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private async Task<string> StoredStamp() =>
|
||||
(await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.AuthSecurityStamp,
|
||||
CancellationToken.None)).IfNone("");
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Principal_With_Stored_Stamp_On_Valid_Credentials()
|
||||
{
|
||||
await SeedAdmin();
|
||||
VerifyLocalAdminLoginHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new VerifyLocalAdminLogin(Username, Password),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
LocalAdminPrincipal principal = result.Match(
|
||||
Left: e => throw new ShouldAssertException(e.ToString()),
|
||||
Right: p => p);
|
||||
principal.Username.ShouldBe(Username);
|
||||
principal.SecurityStamp.ShouldBe(await StoredStamp());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Fail_On_Wrong_Password()
|
||||
{
|
||||
await SeedAdmin();
|
||||
VerifyLocalAdminLoginHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new VerifyLocalAdminLogin(Username, "wrongpassword"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Fail_On_Wrong_Username()
|
||||
{
|
||||
await SeedAdmin();
|
||||
VerifyLocalAdminLoginHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new VerifyLocalAdminLogin("SomebodyElse", Password),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Fail_On_Unconfigured_Db()
|
||||
{
|
||||
VerifyLocalAdminLoginHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new VerifyLocalAdminLogin(Username, Password),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Match_Username_Case_Insensitively()
|
||||
{
|
||||
await SeedAdmin();
|
||||
VerifyLocalAdminLoginHandler handler = MakeHandler();
|
||||
|
||||
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
||||
new VerifyLocalAdminLogin(Username.ToUpperInvariant(), Password),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
LocalAdminPrincipal principal = result.Match(
|
||||
Left: e => throw new ShouldAssertException(e.ToString()),
|
||||
Right: p => p);
|
||||
// The stored (canonical) username is returned, not the differently-cased input.
|
||||
principal.Username.ShouldBe(Username);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace ErsatzTV.Tests.Controllers;
|
||||
[TestFixture]
|
||||
public class ApiControllerSecurityTests
|
||||
{
|
||||
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
|
||||
private static readonly bool ApiAuthorizationFilterIsGlobal = IsApiAuthorizationFilterRegisteredGlobally();
|
||||
|
||||
[Test]
|
||||
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
||||
@@ -47,7 +47,7 @@ public class ApiControllerSecurityTests
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
bool controllerSkipsApiKey = controllerType
|
||||
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
||||
.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true)
|
||||
.Any();
|
||||
|
||||
foreach (MethodInfo action in controllerType
|
||||
@@ -64,7 +64,7 @@ public class ApiControllerSecurityTests
|
||||
}
|
||||
|
||||
bool actionSkipsApiKey = action
|
||||
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
||||
.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true)
|
||||
.Any();
|
||||
|
||||
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
|
||||
@@ -74,23 +74,27 @@ public class ApiControllerSecurityTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
|
||||
public void Only_Scanner_And_Auth_Controllers_Should_Be_Auth_Exempt()
|
||||
{
|
||||
// ScannerController: internal loopback callback, gated by [LocalhostOnly] instead of a credential.
|
||||
// AuthController: the /api/auth/* surface itself must be reachable before a caller is authenticated
|
||||
// (config/session/login/setup) — its sensitive action (password change) self-checks the principal.
|
||||
// Any OTHER [SkipApiAuthorization] controller is a fail-open hole and must be caught here.
|
||||
Type[] exemptControllers = typeof(ScannerController)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
|
||||
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
||||
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
|
||||
.Where(t => t.GetCustomAttributes<SkipApiAuthorizationAttribute>(inherit: true).Any())
|
||||
.ToArray();
|
||||
|
||||
exemptControllers.ShouldBe([typeof(ScannerController)]);
|
||||
exemptControllers.ShouldBe([typeof(ScannerController), typeof(AuthController)], ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
|
||||
public void Startup_Should_Register_ApiAuthorizationFilter_Globally()
|
||||
{
|
||||
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
|
||||
ApiAuthorizationFilterIsGlobal.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -109,8 +113,8 @@ public class ApiControllerSecurityTests
|
||||
|
||||
foreach (Type controllerType in sensitiveControllers)
|
||||
{
|
||||
controllerType.GetCustomAttributes<RequiresApiKeyAttribute>(inherit: true).Any()
|
||||
.ShouldBeTrue($"{controllerType.Name} must carry [RequiresApiKey]");
|
||||
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any()
|
||||
.ShouldBeTrue($"{controllerType.Name} must carry [RequiresAuthentication]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +129,9 @@ public class ApiControllerSecurityTests
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
|
||||
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
|
||||
|
||||
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
|
||||
private static bool IsApiAuthorizationFilterRegisteredGlobally()
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
@@ -155,6 +159,6 @@ public class ApiControllerSecurityTests
|
||||
|
||||
return options.Filters
|
||||
.OfType<ServiceFilterAttribute>()
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
.Any(a => a.ServiceType == typeof(ApiAuthorizationFilter));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class AuthControllerTests
|
||||
{
|
||||
private static IConfiguration Config(bool envSeed) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
envSeed
|
||||
? new Dictionary<string, string?> { ["Auth:LocalAdmin:Password"] = "seed-password" }
|
||||
: new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
[Test]
|
||||
public async Task Config_Reports_Setup_Not_Required_When_Env_Seed_Configured()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
|
||||
var controller = new AuthController(mediator, Config(envSeed: true));
|
||||
|
||||
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
|
||||
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
|
||||
|
||||
// Env seed owns the credential → the SPA must not offer the browser setup-claim.
|
||||
body.SetupRequired.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Config_Reports_Setup_Required_When_Unconfigured_And_No_Env_Seed()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
|
||||
|
||||
var controller = new AuthController(mediator, Config(envSeed: false));
|
||||
|
||||
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
|
||||
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
|
||||
|
||||
body.SetupRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Setup_Is_Closed_With_409_When_Env_Seed_Configured()
|
||||
{
|
||||
var mediator = Substitute.For<IMediator>();
|
||||
var controller = new AuthController(mediator, Config(envSeed: true))
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
var result = await controller.Setup(new SetupRequest("admin", "hunter2pw"), CancellationToken.None);
|
||||
|
||||
var problem = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
problem.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
|
||||
// The claim must never be attempted while the env seed owns the credential.
|
||||
await mediator.DidNotReceive().Send(Arg.Any<ClaimLocalAdmin>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
+73
-9
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using ErsatzTV.Filters;
|
||||
using ErsatzTV.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -12,7 +13,7 @@ using Shouldly;
|
||||
namespace ErsatzTV.Tests.Filters;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthorizationFilterTests
|
||||
public class ApiAuthorizationFilterTests
|
||||
{
|
||||
private const string Key = "secret";
|
||||
|
||||
@@ -27,25 +28,38 @@ public class ApiKeyAuthorizationFilterTests
|
||||
string? apiKeyHeader,
|
||||
string path = "/api/channels",
|
||||
bool skipApiKeyAuthorization = false,
|
||||
bool requiresApiKey = false)
|
||||
bool requiresApiKey = false,
|
||||
bool authenticatedSession = false,
|
||||
bool csrfHeader = false)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
httpContext.Request.Path = path;
|
||||
if (apiKeyHeader is not null)
|
||||
{
|
||||
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
|
||||
httpContext.Request.Headers[ApiAuthorizationFilter.HeaderName] = apiKeyHeader;
|
||||
}
|
||||
|
||||
if (csrfHeader)
|
||||
{
|
||||
httpContext.Request.Headers[ApiAuthorizationFilter.CsrfHeaderName] = "1";
|
||||
}
|
||||
|
||||
if (authenticatedSession)
|
||||
{
|
||||
// A ClaimsIdentity with an authentication type reports IsAuthenticated == true.
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "cookie"));
|
||||
}
|
||||
|
||||
var metadata = new List<object>();
|
||||
if (skipApiKeyAuthorization)
|
||||
{
|
||||
metadata.Add(new SkipApiKeyAuthorizationAttribute());
|
||||
metadata.Add(new SkipApiAuthorizationAttribute());
|
||||
}
|
||||
|
||||
if (requiresApiKey)
|
||||
{
|
||||
metadata.Add(new RequiresApiKeyAttribute());
|
||||
metadata.Add(new RequiresAuthenticationAttribute());
|
||||
}
|
||||
|
||||
var actionDescriptor = new ActionDescriptor { EndpointMetadata = metadata };
|
||||
@@ -53,7 +67,7 @@ public class ApiKeyAuthorizationFilterTests
|
||||
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
|
||||
}
|
||||
|
||||
private static ApiKeyAuthorizationFilter MakeFilter(bool requireKeyForReads = true) =>
|
||||
private static ApiAuthorizationFilter MakeFilter(bool requireKeyForReads = true) =>
|
||||
new(new FakeApiKeyProvider(Key, requireKeyForReads));
|
||||
|
||||
private static void ShouldBeUnauthorized(AuthorizationFilterContext context)
|
||||
@@ -124,10 +138,10 @@ public class ApiKeyAuthorizationFilterTests
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- the sensitive-read tier stays gated even with reads open ([RequiresApiKey]) ----
|
||||
// ---- the sensitive-read tier stays gated even with reads open ([RequiresAuthentication]) ----
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Read_On_RequiresApiKey_Endpoint_Even_When_Reads_Not_Required()
|
||||
public void Should_Reject_Read_On_RequiresAuthentication_Endpoint_Even_When_Reads_Not_Required()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, requiresApiKey: true);
|
||||
MakeFilter(requireKeyForReads: false).OnAuthorization(context);
|
||||
@@ -135,7 +149,7 @@ public class ApiKeyAuthorizationFilterTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Read_On_RequiresApiKey_Endpoint_When_Key_Correct()
|
||||
public void Should_Allow_Read_On_RequiresAuthentication_Endpoint_When_Key_Correct()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: Key, requiresApiKey: true);
|
||||
MakeFilter(requireKeyForReads: false).OnAuthorization(context);
|
||||
@@ -172,4 +186,54 @@ public class ApiKeyAuthorizationFilterTests
|
||||
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- session (cookie) authentication accepted as an alternative credential (issue #295) ----
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Read_When_Session_Authenticated_Without_Key()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, authenticatedSession: true);
|
||||
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Session_Mutation_When_Csrf_Header_Present()
|
||||
{
|
||||
AuthorizationFilterContext context =
|
||||
MakeContext("POST", apiKeyHeader: null, authenticatedSession: true, csrfHeader: true);
|
||||
MakeFilter().OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Session_Mutation_When_Csrf_Header_Missing()
|
||||
{
|
||||
AuthorizationFilterContext context =
|
||||
MakeContext("POST", apiKeyHeader: null, authenticatedSession: true, csrfHeader: false);
|
||||
MakeFilter().OnAuthorization(context);
|
||||
|
||||
var result = context.Result.ShouldBeOfType<ObjectResult>();
|
||||
result.StatusCode.ShouldBe(StatusCodes.Status403Forbidden);
|
||||
var problemDetails = result.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_When_Neither_Key_Nor_Session_Present()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null, authenticatedSession: false);
|
||||
MakeFilter(requireKeyForReads: true).OnAuthorization(context);
|
||||
ShouldBeUnauthorized(context);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Prefer_Machine_Key_Over_Session_And_Skip_Csrf()
|
||||
{
|
||||
// A valid X-Api-Key is CSRF-immune: a session cookie riding along must not force a CSRF check.
|
||||
AuthorizationFilterContext context =
|
||||
MakeContext("POST", apiKeyHeader: Key, authenticatedSession: true, csrfHeader: false);
|
||||
MakeFilter().OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ using Shouldly;
|
||||
namespace ErsatzTV.Tests.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the shared <see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" /> predicate — the one
|
||||
/// Proves the shared <see cref="ApiAuthorizationFilter.EndpointRequiresKey" /> predicate — the one
|
||||
/// the OpenAPI security/401 transformer consumes — agrees with what the filter actually enforces at
|
||||
/// runtime, across a representative matrix. If the two ever diverged, the spec could claim an endpoint
|
||||
/// is open while the filter gates it (or vice-versa); this test is the anti-drift guard (#287).
|
||||
@@ -37,10 +37,10 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
new object[] { "DELETE", false, false, false, true },
|
||||
new object[] { "GET", false, false, true, true }, // read gated when reads-required
|
||||
new object[] { "GET", false, false, false, false }, // read open when reads-not-required
|
||||
new object[] { "GET", true, false, false, true }, // [RequiresApiKey] gates read even so
|
||||
new object[] { "GET", true, false, false, true }, // [RequiresAuthentication] gates read even so
|
||||
new object[] { "HEAD", false, false, false, false }, // read verb, open
|
||||
new object[] { "OPTIONS", false, false, true, false },// preflight always exempt
|
||||
new object[] { "POST", false, true, false, false } // [SkipApiKeyAuthorization] exempt
|
||||
new object[] { "POST", false, true, false, false } // [SkipApiAuthorization] exempt
|
||||
];
|
||||
|
||||
[TestCaseSource(nameof(Matrix))]
|
||||
@@ -54,15 +54,15 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
var metadata = new List<object>();
|
||||
if (requiresApiKey)
|
||||
{
|
||||
metadata.Add(new RequiresApiKeyAttribute());
|
||||
metadata.Add(new RequiresAuthenticationAttribute());
|
||||
}
|
||||
|
||||
if (skip)
|
||||
{
|
||||
metadata.Add(new SkipApiKeyAuthorizationAttribute());
|
||||
metadata.Add(new SkipApiAuthorizationAttribute());
|
||||
}
|
||||
|
||||
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, requireKeyForReads)
|
||||
ApiAuthorizationFilter.EndpointRequiresKey(method, metadata, requireKeyForReads)
|
||||
.ShouldBe(expected);
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
// The filter, on an /api path with the header MISSING, produces a 401 exactly when the predicate
|
||||
// says the endpoint requires a key. Drive the real filter and compare its decision to the predicate.
|
||||
AuthorizationFilterContext context = MakeContext(method, requiresApiKey, skip);
|
||||
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
|
||||
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads)).OnAuthorization(context);
|
||||
|
||||
bool filterGated = context.Result is not null;
|
||||
filterGated.ShouldBe(expected);
|
||||
filterGated.ShouldBe(
|
||||
ApiKeyAuthorizationFilter.EndpointRequiresKey(method, Metadata(requiresApiKey, skip), requireKeyForReads));
|
||||
ApiAuthorizationFilter.EndpointRequiresKey(method, Metadata(requiresApiKey, skip), requireKeyForReads));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -90,11 +90,11 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
{
|
||||
// The predicate assumes an /api endpoint; the filter's path scoping precedes it. A mutating request
|
||||
// outside /api must pass untouched even though the predicate (given the same method) returns true.
|
||||
ApiKeyAuthorizationFilter.EndpointRequiresKey("POST", new List<object>(), requireKeyForReads: true)
|
||||
ApiAuthorizationFilter.EndpointRequiresKey("POST", new List<object>(), requireKeyForReads: true)
|
||||
.ShouldBeTrue();
|
||||
|
||||
AuthorizationFilterContext context = MakeContext("POST", requiresApiKey: false, skip: false, path: "/iptv/x.m3u");
|
||||
new ApiKeyAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
|
||||
new ApiAuthorizationFilter(new FakeApiKeyProvider(requireKeyForReads: true)).OnAuthorization(context);
|
||||
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
@@ -104,12 +104,12 @@ public class ApiKeyEndpointRequiresKeyTests
|
||||
var metadata = new List<object>();
|
||||
if (requiresApiKey)
|
||||
{
|
||||
metadata.Add(new RequiresApiKeyAttribute());
|
||||
metadata.Add(new RequiresAuthenticationAttribute());
|
||||
}
|
||||
|
||||
if (skip)
|
||||
{
|
||||
metadata.Add(new SkipApiKeyAuthorizationAttribute());
|
||||
metadata.Add(new SkipApiAuthorizationAttribute());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Security.Claims;
|
||||
using ErsatzTV.Application.Auth;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace ErsatzTV.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Cookie <c>OnValidatePrincipal</c> handler enforcing local-admin security-stamp revocation (#295):
|
||||
/// a local-login session carries the admin's security stamp as a claim; a password change rotates the
|
||||
/// stored stamp, so a mismatch here rejects the (now stale) session on its next request. OIDC sessions
|
||||
/// carry no stamp claim and are governed by the IdP, so they are skipped.
|
||||
/// </summary>
|
||||
public static class CookieSecurityStampValidator
|
||||
{
|
||||
public static async Task ValidateAsync(CookieValidatePrincipalContext context)
|
||||
{
|
||||
ClaimsPrincipal principal = context.Principal;
|
||||
string method = principal?.FindFirst(AuthConstants.AuthMethodClaim)?.Value;
|
||||
if (!string.Equals(method, AuthConstants.MethodLocal, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string presented = principal.FindFirst(AuthConstants.SecurityStampClaim)?.Value;
|
||||
IMediator mediator = context.HttpContext.RequestServices.GetRequiredService<IMediator>();
|
||||
Option<string> stored = await mediator.Send(new GetLocalAdminSecurityStamp());
|
||||
string current = stored.IfNone(string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(presented) || !string.Equals(presented, current, StringComparison.Ordinal))
|
||||
{
|
||||
context.RejectPrincipal();
|
||||
await context.HttpContext.SignOutAsync(AuthConstants.CookieScheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
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);
|
||||
@@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[RequiresApiKey]
|
||||
[RequiresAuthentication]
|
||||
public class LogsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
private const int MaxPageSize = 100;
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
[RequiresApiKey]
|
||||
[RequiresAuthentication]
|
||||
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
[HttpPost("/api/maintenance/gc")]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
|
||||
public record SetupRequest(string Username, string Password);
|
||||
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SkipApiKeyAuthorization]
|
||||
[SkipApiAuthorization]
|
||||
[LocalhostOnly]
|
||||
[Route("api/scan/{scanId:guid}")]
|
||||
public class ScannerController(
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
[RequiresApiKey]
|
||||
[RequiresAuthentication]
|
||||
public class SettingsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
// FFmpeg settings
|
||||
|
||||
@@ -27,7 +27,7 @@ using Serilog.Context;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[RequiresApiKey]
|
||||
[RequiresAuthentication]
|
||||
public class TroubleshootController(
|
||||
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
|
||||
IFileSystem fileSystem,
|
||||
@@ -92,7 +92,8 @@ public class TroubleshootController(
|
||||
[ProducesResponseType(typeof(ValidateSequentialScheduleResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ValidateSchedule(
|
||||
[Required] [FromBody] ValidateSequentialScheduleRequest request,
|
||||
[Required] [FromBody]
|
||||
ValidateSequentialScheduleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Yaml))
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ErsatzTV.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization for the JSON API. A request under <c>/api/*</c> that requires authentication is
|
||||
/// accepted on either of two credentials (issue #295):
|
||||
/// <list type="number">
|
||||
/// <item>a matching machine <c>X-Api-Key</c> header (MCP / external clients), or</item>
|
||||
/// <item>an authenticated session principal (browser cookie, from OIDC or local login).</item>
|
||||
/// </list>
|
||||
/// Which endpoints require authentication is decided by <see cref="EndpointRequiresKey" /> — the
|
||||
/// single predicate shared with OpenAPI generation — so the spec can never drift from enforcement:
|
||||
/// mutating verbs are always fail-closed; reads are gated when <c>Api:RequireKeyForReads</c> is set
|
||||
/// (the default) or the endpoint carries <see cref="RequiresAuthenticationAttribute" />. Endpoints
|
||||
/// marked <see cref="SkipApiAuthorizationAttribute" /> and anything outside <c>/api</c> (e.g.
|
||||
/// <c>/iptv/*</c>, <c>/artwork/*</c>, the SPA) are never affected.
|
||||
/// <para>
|
||||
/// The machine key is CSRF-immune (a browser cannot set a custom header cross-origin without a
|
||||
/// credentialed CORS grant, which is never issued). A cookie session is not: session-authenticated
|
||||
/// <b>mutations</b> must additionally carry the <see cref="CsrfHeaderName" /> header, which — being a
|
||||
/// custom header — forces a CORS preflight that a cross-site attacker page cannot satisfy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class ApiAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
|
||||
/// <summary>
|
||||
/// Required on session-authenticated mutating requests as a CSRF defense. Presence is the whole
|
||||
/// check — a custom request header cannot be set by a cross-site form/navigation and forces a CORS
|
||||
/// preflight for cross-origin XHR, so only same-origin (the SPA) can send it.
|
||||
/// </summary>
|
||||
public const string CsrfHeaderName = "X-CSRF";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
HttpRequest request = context.HttpContext.Request;
|
||||
|
||||
// Never gate anything outside the JSON API surface.
|
||||
if (!request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EndpointRequiresKey(request.Method, context.ActionDescriptor.EndpointMetadata, apiKeyProvider.RequireKeyForReads))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Machine key wins and is CSRF-immune. Check it first so a browser that happens to hold both
|
||||
// a cookie and a key is still treated as a machine caller (no CSRF header required).
|
||||
if (request.Headers.TryGetValue(HeaderName, out StringValues provided)
|
||||
&& KeysMatch(provided.ToString(), apiKeyProvider.ApiKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Authenticated session (cookie principal from OIDC or local login).
|
||||
if (context.HttpContext.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
// Session-authenticated mutations require the CSRF header. Reads are safe (SameSite=Lax +
|
||||
// no credentialed CORS means a cross-site read can't be issued with the cookie either).
|
||||
if (IsMutating(request.Method) && !request.Headers.ContainsKey(CsrfHeaderName))
|
||||
{
|
||||
context.Result = new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status403Forbidden,
|
||||
Title = "Forbidden",
|
||||
Detail = $"Session-authenticated writes require the '{CsrfHeaderName}' header."
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status403Forbidden
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Neither credential presented.
|
||||
context.Result = new UnauthorizedObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = $"Authentication is required. Provide a valid '{HeaderName}' header (machine clients) "
|
||||
+ "or sign in to obtain a session (browser)."
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single decision shared between runtime enforcement (this filter) and the OpenAPI document
|
||||
/// generation (<c>ApiSecurityOperationTransformer</c>), so the spec's declared
|
||||
/// <c>security</c>/<c>401</c> can never drift from what is actually enforced. Assumes the endpoint
|
||||
/// is already known to be under <c>/api</c> (the caller's responsibility). Returns
|
||||
/// <see langword="true" /> when the endpoint requires authentication: any mutating verb
|
||||
/// (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when
|
||||
/// <paramref name="requireKeyForReads" /> is set or the endpoint carries
|
||||
/// <see cref="RequiresAuthenticationAttribute" />. <c>OPTIONS</c> preflight and endpoints marked
|
||||
/// <see cref="SkipApiAuthorizationAttribute" /> are exempt. (The name is retained from the
|
||||
/// API-key-only era for spec-generation stability; "key" here means "credential".)
|
||||
/// </summary>
|
||||
public static bool EndpointRequiresKey(
|
||||
string httpMethod,
|
||||
IEnumerable<object> endpointMetadata,
|
||||
bool requireKeyForReads)
|
||||
{
|
||||
// CORS preflight carries no custom headers and is handled by the CORS middleware.
|
||||
if (HttpMethods.IsOptions(httpMethod))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit opt-out for internal, separately-guarded endpoints (scanner callback, /api/auth/*).
|
||||
if (endpointMetadata.OfType<SkipApiAuthorizationAttribute>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Writes are always fail-closed; reads are gated by the global flag or a per-endpoint opt-in.
|
||||
return IsMutating(httpMethod)
|
||||
|| requireKeyForReads
|
||||
|| endpointMetadata.OfType<RequiresAuthenticationAttribute>().Any();
|
||||
}
|
||||
|
||||
private static bool IsMutating(string httpMethod) =>
|
||||
HttpMethods.IsPost(httpMethod)
|
||||
|| HttpMethods.IsPut(httpMethod)
|
||||
|| HttpMethods.IsPatch(httpMethod)
|
||||
|| HttpMethods.IsDelete(httpMethod);
|
||||
|
||||
// Compare in constant time so a remote attacker cannot use response-timing to recover the
|
||||
// key prefix-by-prefix. FixedTimeEquals also short-circuits length differences without
|
||||
// leaking anything beyond "lengths differ" (still not the matching-prefix length).
|
||||
private static bool KeysMatch(string provided, string configured) =>
|
||||
CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(provided),
|
||||
Encoding.UTF8.GetBytes(configured));
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ErsatzTV.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// API-key authorization for the JSON API. The effective key (from <see cref="IApiKeyProvider" />)
|
||||
/// is never empty, so this is fail-closed by construction (issue #280): every mutating request
|
||||
/// under <c>/api/*</c> must present a matching <c>X-Api-Key</c> header. Read (GET/HEAD) requests
|
||||
/// under <c>/api/*</c> are also gated when <c>Api:RequireKeyForReads</c> is enabled (the default)
|
||||
/// or the endpoint carries <see cref="RequiresApiKeyAttribute" />. Endpoints marked
|
||||
/// <see cref="SkipApiKeyAuthorizationAttribute" /> and anything outside <c>/api</c> (e.g.
|
||||
/// <c>/iptv/*</c>, <c>/artwork/*</c>, the SPA) are never affected. Independent of
|
||||
/// <see cref="JwtHelper" />.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthorizationFilter(IApiKeyProvider apiKeyProvider) : IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
HttpRequest request = context.HttpContext.Request;
|
||||
|
||||
// Never gate anything outside the JSON API surface.
|
||||
if (!request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EndpointRequiresKey(request.Method, context.ActionDescriptor.EndpointMetadata, apiKeyProvider.RequireKeyForReads))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Headers.TryGetValue(HeaderName, out StringValues provided)
|
||||
|| !KeysMatch(provided.ToString(), apiKeyProvider.ApiKey))
|
||||
{
|
||||
context.Result = new UnauthorizedObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = $"A valid API key is required. Provide it via the '{HeaderName}' header."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single decision shared between runtime enforcement (this filter) and the OpenAPI
|
||||
/// document generation (<c>ApiSecurityOperationTransformer</c>), so the spec's declared
|
||||
/// <c>security</c>/<c>401</c> can never drift from what is actually enforced. Assumes the
|
||||
/// endpoint is already known to be under <c>/api</c> (the caller's responsibility). Returns
|
||||
/// <see langword="true" /> when the endpoint requires a valid <c>X-Api-Key</c>: any mutating
|
||||
/// verb (POST/PUT/PATCH/DELETE) is always fail-closed; reads are gated when
|
||||
/// <paramref name="requireKeyForReads" /> is set or the endpoint carries
|
||||
/// <see cref="RequiresApiKeyAttribute" />. <c>OPTIONS</c> preflight and endpoints marked
|
||||
/// <see cref="SkipApiKeyAuthorizationAttribute" /> are exempt.
|
||||
/// </summary>
|
||||
public static bool EndpointRequiresKey(
|
||||
string httpMethod,
|
||||
IEnumerable<object> endpointMetadata,
|
||||
bool requireKeyForReads)
|
||||
{
|
||||
// CORS preflight carries no custom headers and is handled by the CORS middleware.
|
||||
if (HttpMethods.IsOptions(httpMethod))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit opt-out for internal, separately-guarded endpoints (e.g. the scanner callback).
|
||||
if (endpointMetadata.OfType<SkipApiKeyAuthorizationAttribute>().Any())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isMutating = HttpMethods.IsPost(httpMethod)
|
||||
|| HttpMethods.IsPut(httpMethod)
|
||||
|| HttpMethods.IsPatch(httpMethod)
|
||||
|| HttpMethods.IsDelete(httpMethod);
|
||||
|
||||
// Writes are always fail-closed; reads are gated by the global flag or a per-endpoint opt-in.
|
||||
return isMutating
|
||||
|| requireKeyForReads
|
||||
|| endpointMetadata.OfType<RequiresApiKeyAttribute>().Any();
|
||||
}
|
||||
|
||||
// Compare in constant time so a remote attacker cannot use response-timing to recover the
|
||||
// key prefix-by-prefix. FixedTimeEquals also short-circuits length differences without
|
||||
// leaking anything beyond "lengths differ" (still not the matching-prefix length).
|
||||
private static bool KeysMatch(string provided, string configured) =>
|
||||
CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(provided),
|
||||
Encoding.UTF8.GetBytes(configured));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a read (GET/HEAD) API endpoint as always requiring the <c>X-Api-Key</c> header, even
|
||||
/// when <c>Api:RequireKeyForReads</c> is disabled. Applied to the sensitive-read tier
|
||||
/// (troubleshoot, logs, settings, maintenance) that discloses secrets/paths or triggers work.
|
||||
/// The mirror of <see cref="SkipApiKeyAuthorizationAttribute" />. See issue #282.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class RequiresApiKeyAttribute : Attribute, IFilterMetadata;
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a read (GET/HEAD) API endpoint as always requiring authentication (a valid machine
|
||||
/// <c>X-Api-Key</c> header or an authenticated session), even when <c>Api:RequireKeyForReads</c>
|
||||
/// is disabled. Applied to the sensitive-read tier (troubleshoot, logs, settings, maintenance)
|
||||
/// that discloses secrets/paths or triggers work. An authenticated session satisfies this tier
|
||||
/// just as the machine key does. The mirror of <see cref="SkipApiAuthorizationAttribute" />.
|
||||
/// See issues #282, #295.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class RequiresAuthenticationAttribute : Attribute, IFilterMetadata;
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an internal API endpoint as exempt from the global <see cref="ApiAuthorizationFilter" />
|
||||
/// (neither a machine key nor a session is required). Used for endpoints that are guarded by a
|
||||
/// different mechanism (e.g. the scanner callback's localhost-only check) and for the
|
||||
/// <c>/api/auth/*</c> surface itself, which must be reachable before a caller is authenticated.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class SkipApiAuthorizationAttribute : Attribute, IFilterMetadata;
|
||||
@@ -1,9 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an internal API endpoint as exempt from global API-key write authorization.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class SkipApiKeyAuthorizationAttribute : Attribute, IFilterMetadata;
|
||||
@@ -11,7 +11,7 @@ namespace ErsatzTV.Serialization;
|
||||
/// every operation that actually requires the <c>X-Api-Key</c> header it injects the
|
||||
/// <c>ApiKey</c> security requirement and a documented <c>401</c> response. The "requires a key"
|
||||
/// decision is the exact same predicate the runtime filter enforces
|
||||
/// (<see cref="ApiKeyAuthorizationFilter.EndpointRequiresKey" />), so the spec can never claim an
|
||||
/// (<see cref="ApiAuthorizationFilter.EndpointRequiresKey" />), so the spec can never claim an
|
||||
/// endpoint is open when it is gated (or vice-versa). The <c>ApiKey</c> scheme itself and the
|
||||
/// <c>ProblemDetails</c> schema the <c>401</c> references are declared by
|
||||
/// <see cref="ApiSecuritySchemeDocumentTransformer" />. See issues #286/#287.
|
||||
@@ -28,7 +28,7 @@ public sealed class ApiSecurityOperationTransformer(IApiKeyProvider apiKeyProvid
|
||||
string method = context.Description.HttpMethod ?? string.Empty;
|
||||
IEnumerable<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
|
||||
|
||||
if (!ApiKeyAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads))
|
||||
if (!ApiAuthorizationFilter.EndpointRequiresKey(method, metadata, apiKeyProvider.RequireKeyForReads))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public static class ApiSecuritySchemeDocumentTransformer
|
||||
document.Components.SecuritySchemes[ApiSecurityOperationTransformer.SchemeName] = new OpenApiSecurityScheme
|
||||
{
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Name = ApiKeyAuthorizationFilter.HeaderName,
|
||||
Name = ApiAuthorizationFilter.HeaderName,
|
||||
In = ParameterLocation.Header,
|
||||
Description =
|
||||
"API key sent in the 'X-Api-Key' request header. Required for all mutating requests and, " +
|
||||
|
||||
@@ -5,7 +5,7 @@ using ErsatzTV.Core;
|
||||
namespace ErsatzTV.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective API key used by <see cref="Filters.ApiKeyAuthorizationFilter" /> and
|
||||
/// 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
|
||||
@@ -136,7 +136,7 @@ public sealed class ApiKeyProvider : IApiKeyProvider
|
||||
"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.ApiKeyAuthorizationFilter.HeaderName);
|
||||
Filters.ApiAuthorizationFilter.HeaderName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Services.RunOnce;
|
||||
|
||||
/// <summary>
|
||||
/// Recovery/bootstrap: when <c>Auth:LocalAdmin:Password</c> is configured, (re)seeds the single local
|
||||
/// administrator at startup (issue #295). Overwrites any existing credential and rotates the security
|
||||
/// stamp, so an operator locked out of the browser UI can reset by setting the env and restarting. A
|
||||
/// no-op when unset. Follows the RunOnce pattern (waits for the database to be ready — the migrator is a
|
||||
/// BackgroundService, so registration order alone does not guarantee the schema exists).
|
||||
/// </summary>
|
||||
public class LocalAdminSeedService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IConfiguration configuration,
|
||||
SystemStartup systemStartup,
|
||||
ILogger<LocalAdminSeedService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Yield();
|
||||
|
||||
string password = configuration["Auth:LocalAdmin:Password"];
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await systemStartup.WaitForDatabase(stoppingToken);
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string username = configuration["Auth:LocalAdmin:Username"];
|
||||
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(new SeedLocalAdminFromEnvironment(username, password), stoppingToken);
|
||||
|
||||
result.Match(
|
||||
Right: _ => logger.LogWarning(
|
||||
"Seeded the local administrator from Auth:LocalAdmin:* configuration (any existing "
|
||||
+ "credential was overwritten and all sessions revoked). Unset Auth:LocalAdmin:Password "
|
||||
+ "after signing in."),
|
||||
Left: error => logger.LogError(
|
||||
"Failed to seed the local administrator from configuration: {Error}",
|
||||
error.Value));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to seed the local administrator from configuration");
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-81
@@ -7,11 +7,15 @@ using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.RateLimiting;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Auth;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using ErsatzTV.Auth;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -81,6 +85,7 @@ using ErsatzTV.Services.RunOnce;
|
||||
using ErsatzTV.Services.Validators;
|
||||
using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
@@ -88,6 +93,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Localization;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
@@ -210,6 +216,13 @@ public class Startup
|
||||
|
||||
if (!trustRestricted)
|
||||
{
|
||||
// Kept as-is from #285 (trust any peer, warn) rather than flipped to ForwardedHeaders.None:
|
||||
// the forwarded scheme/host feed /iptv M3U/XMLTV/HLS absolute-URL generation
|
||||
// (Request.Scheme in GetChannelGuideHandler / IptvController), so ignoring them would regress
|
||||
// stream URLs to http/internal-host for a proxied deployment that hasn't set KnownProxies.
|
||||
// Setting ForwardedHeaders:KnownProxies/:KnownNetworks is still strongly recommended when
|
||||
// exposing ErsatzTV beyond a trusted LAN — it also gives the #295 login rate limiter an
|
||||
// unspoofable client IP and lets the session cookie be marked Secure behind TLS.
|
||||
Log.Warning(
|
||||
"ForwardedHeaders trusts X-Forwarded-* from any peer (spoofable). Set " +
|
||||
"ForwardedHeaders:KnownProxies and/or ForwardedHeaders:KnownNetworks to restrict " +
|
||||
@@ -277,64 +290,121 @@ public class Startup
|
||||
JwtHelper.Init(Configuration);
|
||||
SearchHelper.Init(Configuration);
|
||||
|
||||
// Browser SPA authentication (#295). A cookie session is ALWAYS registered — local username/password
|
||||
// login and the OIDC callback both sign into it. OIDC is added only when configured; the /iptv JWT
|
||||
// bearer is added only when configured. The /api surface accepts a session OR the machine X-Api-Key
|
||||
// (see ApiAuthorizationFilter); real enforcement is that filter, not a DefaultPolicy.
|
||||
AuthenticationBuilder authenticationBuilder = services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = AuthConstants.CookieScheme;
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
options.DefaultChallengeScheme = AuthConstants.OidcScheme;
|
||||
}
|
||||
})
|
||||
.AddCookie(
|
||||
AuthConstants.CookieScheme,
|
||||
options =>
|
||||
{
|
||||
options.CookieManager = new ChunkingCookieManager();
|
||||
|
||||
options.Cookie.Name = "ctv-session";
|
||||
options.Cookie.HttpOnly = true;
|
||||
// Lax + no credentialed CORS keeps the cookie same-origin (the SPA is served from /app);
|
||||
// this is a core CSRF defense alongside the required X-CSRF header on session mutations.
|
||||
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||
// SameAsRequest (not Always) so a plain-HTTP LAN deployment is not locked out; behind a
|
||||
// TLS-terminating proxy the app sees https once ForwardedHeaders:KnownProxies is set.
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(14);
|
||||
options.SlidingExpiration = true;
|
||||
|
||||
options.Events = new CookieAuthenticationEvents
|
||||
{
|
||||
// /api is an XHR surface — answer 401/403 rather than redirecting to a login page.
|
||||
OnRedirectToLogin = context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Response.Redirect(context.RedirectUri);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToAccessDenied = context =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments("/api"))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Response.Redirect(context.RedirectUri);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnValidatePrincipal = CookieSecurityStampValidator.ValidateAsync
|
||||
};
|
||||
});
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
services.AddAuthentication(options =>
|
||||
authenticationBuilder.AddOpenIdConnect(
|
||||
AuthConstants.OidcScheme,
|
||||
options =>
|
||||
{
|
||||
options.DefaultScheme = "cookie";
|
||||
options.DefaultChallengeScheme = "oidc";
|
||||
})
|
||||
.AddCookie(
|
||||
"cookie",
|
||||
options =>
|
||||
options.Authority = OidcHelper.Authority;
|
||||
options.ClientId = OidcHelper.ClientId;
|
||||
options.ClientSecret = OidcHelper.ClientSecret;
|
||||
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.UsePkce = true;
|
||||
options.ResponseMode = OpenIdConnectResponseMode.Query;
|
||||
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("profile");
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
|
||||
options.CallbackPath = new PathString("/callback");
|
||||
|
||||
options.SaveTokens = true;
|
||||
|
||||
options.NonceCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
options.CookieManager = new ChunkingCookieManager();
|
||||
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.None;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
})
|
||||
.AddOpenIdConnect(
|
||||
"oidc",
|
||||
options =>
|
||||
{
|
||||
options.Authority = OidcHelper.Authority;
|
||||
options.ClientId = OidcHelper.ClientId;
|
||||
options.ClientSecret = OidcHelper.ClientSecret;
|
||||
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.UsePkce = true;
|
||||
options.ResponseMode = OpenIdConnectResponseMode.Query;
|
||||
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
|
||||
options.CallbackPath = new PathString("/callback");
|
||||
|
||||
options.SaveTokens = true;
|
||||
|
||||
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
|
||||
// Mark the session as OIDC so the cookie stamp validator skips it (the stamp is a
|
||||
// local-login concept; OIDC sessions are governed by the IdP).
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
options.Events = new OpenIdConnectEvents
|
||||
if (context.Principal?.Identity is ClaimsIdentity identity)
|
||||
{
|
||||
OnRedirectToIdentityProviderForSignOut = context =>
|
||||
{
|
||||
context.Response.Redirect(OidcHelper.LogoutUri);
|
||||
context.HandleResponse();
|
||||
identity.AddClaim(new Claim(AuthConstants.AuthMethodClaim, AuthConstants.MethodOidc));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToIdentityProviderForSignOut = context =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
|
||||
{
|
||||
context.Response.Redirect(OidcHelper.LogoutUri);
|
||||
context.HandleResponse();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (JwtHelper.IsEnabled)
|
||||
{
|
||||
services.AddAuthentication().AddJwtBearer(
|
||||
authenticationBuilder.AddJwtBearer(
|
||||
"jwt",
|
||||
options =>
|
||||
{
|
||||
@@ -362,34 +432,38 @@ public class Startup
|
||||
});
|
||||
}
|
||||
|
||||
if (OidcHelper.IsEnabled || JwtHelper.IsEnabled)
|
||||
// Authorization is always registered now that the pipeline always runs UseAuthorization (the cookie
|
||||
// scheme is always present). No DefaultPolicy: /api is gated by ApiAuthorizationFilter, and no
|
||||
// endpoint carries [Authorize]. The JWT-only policy stays for /iptv's ConditionalIptvAuthorizeFilter.
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
if (OidcHelper.IsEnabled)
|
||||
if (JwtHelper.IsEnabled)
|
||||
{
|
||||
options.AddPolicy(
|
||||
"JwtOnlyScheme",
|
||||
new AuthorizationPolicyBuilder("jwt")
|
||||
.RequireAuthenticatedUser()
|
||||
.Build());
|
||||
}
|
||||
});
|
||||
|
||||
// Per-IP rate limit for the unauthenticated auth surface (login/setup/password) — blunts local
|
||||
// password brute-force. Keyed on the connection remote IP (accurate only when
|
||||
// ForwardedHeaders:KnownProxies is configured behind a proxy — see UseForwardedHeaders below).
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy(
|
||||
"auth",
|
||||
httpContext => RateLimitPartition.GetFixedWindowLimiter(
|
||||
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
|
||||
"cookie",
|
||||
"oidc");
|
||||
|
||||
defaultAuthorizationPolicyBuilder =
|
||||
defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
|
||||
|
||||
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
|
||||
}
|
||||
|
||||
if (JwtHelper.IsEnabled)
|
||||
{
|
||||
var onlyJwtSchemePolicyBuilder = new AuthorizationPolicyBuilder("jwt");
|
||||
options.AddPolicy(
|
||||
"JwtOnlyScheme",
|
||||
onlyJwtSchemePolicyBuilder
|
||||
.RequireAuthenticatedUser()
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
PermitLimit = 10,
|
||||
Window = TimeSpan.FromMinutes(5),
|
||||
QueueLimit = 0
|
||||
}));
|
||||
});
|
||||
|
||||
services.AddCors(o => o.AddPolicy(
|
||||
"ApiCors",
|
||||
@@ -406,9 +480,12 @@ public class Startup
|
||||
"Accept",
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
ApiKeyAuthorizationFilter.HeaderName,
|
||||
ApiAuthorizationFilter.HeaderName,
|
||||
ApiAuthorizationFilter.CsrfHeaderName,
|
||||
"If-Match")
|
||||
.WithExposedHeaders("ETag");
|
||||
// Note: AllowCredentials is deliberately NOT set — cross-origin cookie auth is impossible
|
||||
// by design (a CSRF defense). Cross-origin clients authenticate with X-Api-Key.
|
||||
}
|
||||
|
||||
// No configured origins => no cross-origin access (the SPA is same-origin from /app).
|
||||
@@ -423,7 +500,7 @@ public class Startup
|
||||
options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter());
|
||||
options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter());
|
||||
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
|
||||
options.Filters.AddService<ApiKeyAuthorizationFilter>();
|
||||
options.Filters.AddService<ApiAuthorizationFilter>();
|
||||
})
|
||||
.AddNewtonsoftJson(opt =>
|
||||
{
|
||||
@@ -438,7 +515,10 @@ public class Startup
|
||||
// API-key authorization for the JSON API (independent of JWT/OIDC). The provider resolves the
|
||||
// effective key once (config, else persisted, else generated) so writes are fail-closed.
|
||||
services.AddSingleton<IApiKeyProvider, ApiKeyProvider>();
|
||||
services.AddScoped<ApiKeyAuthorizationFilter>();
|
||||
services.AddScoped<ApiAuthorizationFilter>();
|
||||
|
||||
// Local-admin password hashing (browser SPA session auth, #295). Stateless → singleton.
|
||||
services.AddSingleton<ILocalPasswordHasher, LocalPasswordHasher>();
|
||||
|
||||
services.AddFluentValidationAutoValidation();
|
||||
services.AddValidatorsFromAssemblyContaining<Startup>();
|
||||
@@ -820,12 +900,16 @@ public class Startup
|
||||
|
||||
legacy.UseRouting();
|
||||
|
||||
// Blazor removal (#91 phase b, #206): the OIDC challenge's only attachment point
|
||||
// was the now-deleted Razor Pages / Blazor UI (AuthorizeFolder("/") + the Blazor
|
||||
// hub). The OIDC/JWT *service* wiring stays registered (inert unless configured);
|
||||
// real SPA/API auth is #197. /iptv keeps its own ConditionalIptvAuthorizeFilter
|
||||
// and mutating /api/* its ApiKeyAuthorizationFilter — both independent of this
|
||||
// middleware.
|
||||
// Browser SPA / API authentication (#295). This branch hosts /api, the OIDC /callback, and
|
||||
// /docs. UseAuthentication populates HttpContext.User from the cookie (default scheme) — the
|
||||
// credential ApiAuthorizationFilter accepts alongside the machine X-Api-Key — and lets the
|
||||
// OIDC middleware intercept /callback. UseAuthorization is required for the middleware to run
|
||||
// (no [Authorize] endpoints; /api is gated by ApiAuthorizationFilter, /iptv by its own
|
||||
// ConditionalIptvAuthorizeFilter). UseRateLimiter enforces the "auth" per-IP policy on the
|
||||
// login/setup/password endpoints.
|
||||
legacy.UseAuthentication();
|
||||
legacy.UseAuthorization();
|
||||
legacy.UseRateLimiter();
|
||||
|
||||
legacy.UseEndpoints(endpoints =>
|
||||
{
|
||||
@@ -1061,6 +1145,9 @@ public class Startup
|
||||
// run-once/blocking startup services
|
||||
services.AddHostedService<EndpointValidatorService>();
|
||||
services.AddHostedService<DatabaseMigratorService>();
|
||||
// Waits on SystemStartup.WaitForDatabase before seeding (#295 env seed) — the migrator is a
|
||||
// BackgroundService, so registration order alone does not guarantee the schema exists.
|
||||
services.AddHostedService<LocalAdminSeedService>();
|
||||
services.AddHostedService<DatabaseCleanerService>();
|
||||
services.AddHostedService<LoadLoggingLevelService>();
|
||||
services.AddHostedService<CacheCleanerService>();
|
||||
|
||||
+70
-26
@@ -521,38 +521,82 @@ for items that have no group, so the SPA can render an "ungrouped" bucket
|
||||
concept elsewhere, this is the established pattern to follow — but be aware it means `Id` is not a
|
||||
reliable real-entity id for those synthetic rows.
|
||||
|
||||
## 9. Authentication — API key posture (fail-closed)
|
||||
## 9. Authentication — session-or-key posture (fail-closed)
|
||||
|
||||
The whole `/api` surface is gated by the `X-Api-Key` header via the global `ApiKeyAuthorizationFilter`
|
||||
(issue #197 Bundle A). When you add an endpoint:
|
||||
The whole `/api` surface is gated by the global `ApiAuthorizationFilter` (renamed from
|
||||
`ApiKeyAuthorizationFilter` in #295). A request that requires authentication is accepted on **either**
|
||||
credential:
|
||||
|
||||
- **Do nothing** for the common case. Writes (POST/PUT/PATCH/DELETE) always require the key
|
||||
(fail-closed — there is no "open" mode). Reads (GET/HEAD) require the key when
|
||||
`Api:RequireKeyForReads` is enabled, which is the **default** (`true`). `OPTIONS` preflight is exempt.
|
||||
- The effective key comes from `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`): `Api:WriteKey`
|
||||
if configured, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`, `0600`), else
|
||||
a freshly generated 256-bit key. It is never empty.
|
||||
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresApiKey]` so
|
||||
they stay gated even if an operator sets `Api:RequireKeyForReads=false`. Current tier:
|
||||
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
|
||||
- **Internal loopback callbacks** (the scanner's `/api/scan/*`) use `[SkipApiKeyAuthorization]` +
|
||||
`[LocalhostOnly]` — the API key is a poor fit for a co-located child process, so the gate is the
|
||||
loopback check (sound only because `ForwardedHeaders` trust is restricted via
|
||||
`ForwardedHeaders:KnownProxies`/`KnownNetworks`).
|
||||
1. a matching machine **`X-Api-Key`** header (MCP / external clients — issue #197 Bundle A), or
|
||||
2. an **authenticated session** principal (browser cookie `ctv-session`, from local login or OIDC — #295).
|
||||
|
||||
**Which endpoints require authentication is unchanged** and still decided by the single shared predicate
|
||||
`ApiAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` (also used by
|
||||
OpenAPI generation, so the spec can't drift). When you add an endpoint:
|
||||
|
||||
- **Do nothing** for the common case. Writes (POST/PUT/PATCH/DELETE) always require a credential
|
||||
(fail-closed — there is no "open" mode). Reads (GET/HEAD) require one when `Api:RequireKeyForReads` is
|
||||
enabled, the **default** (`true`). `OPTIONS` preflight is exempt.
|
||||
- The effective machine key comes from `IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`):
|
||||
`Api:WriteKey` if configured, else a key persisted at `FileSystemLayout.ApiKeyPath` (`/config/api.key`,
|
||||
`0600`), else a freshly generated 256-bit key. It is never empty.
|
||||
- **CSRF (session credential only).** The machine key is CSRF-immune (a browser can't set a custom header
|
||||
cross-origin without a credentialed CORS grant, which is never issued). A cookie session is not: a
|
||||
**session-authenticated mutation** must additionally carry the `X-CSRF` header (`ApiAuthorizationFilter.CsrfHeaderName`)
|
||||
or it is rejected **403** — presence is the whole check (a custom header forces a CORS preflight a
|
||||
cross-site page can't satisfy; reinforced by `SameSite=Lax` + CORS without `AllowCredentials`). Key-authed
|
||||
requests are exempt. When you add a SPA mutation, send `X-CSRF: 1` (the SPA client does this centrally).
|
||||
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresAuthentication]`
|
||||
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
|
||||
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
|
||||
`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
|
||||
- **Internal loopback callbacks** (the scanner's `/api/scan/*`) and the **`/api/auth/*` surface itself** use
|
||||
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
|
||||
auth surface must be reachable before a caller is authenticated, and its one sensitive action
|
||||
(`POST /api/auth/password`) self-checks the principal. `ApiControllerSecurityTests` asserts these two are the
|
||||
**only** auth-exempt controllers.
|
||||
- **The `/api/auth/*` surface** (`AuthController`, `[ApiExplorerSettings(IgnoreApi = true)]` → excluded from the
|
||||
OpenAPI doc, whose audience is machine clients): `GET config` (what auth options exist + `setupRequired`),
|
||||
`GET session`, `POST setup` (first-run claim), `POST login`, `POST logout`, `POST password`. The browser-nav
|
||||
OIDC challenge is `GET /auth/oidc/login` (outside `/api`). `login`/`setup`/`password` carry a per-IP rate
|
||||
limit (`[EnableRateLimiting("auth")]`, 10 / 5 min). The local admin is a single credential in `ConfigElement`
|
||||
rows (`AuthLocalAdminUsername`/`AuthLocalAdminPasswordHash` (PBKDF2) / `AuthSecurityStamp`) — **no DB
|
||||
migration**; a password change rotates the stamp, revoking sessions via the cookie `OnValidatePrincipal`.
|
||||
Recovery/bootstrap without the browser: set `Auth:LocalAdmin:Password` (+ optional `…:Username`, default
|
||||
`admin`) and restart (`LocalAdminSeedService` reseeds + rotates the stamp). While that env is set the
|
||||
**browser setup-claim is disabled** (409) — the env seed owns the credential, which also removes the
|
||||
startup setup-vs-seed race. Logout rotates the security stamp for a local session (ends it server-side,
|
||||
"log out everywhere"), gated on an authenticated session.
|
||||
- **CORS** is opt-in: no cross-origin access by default (the SPA is same-origin from `/app`); set
|
||||
`Api:CorsAllowedOrigins` (semicolon-separated exact origins) to allow specific browser origins — the
|
||||
policy already permits `X-Api-Key`/`If-Match` and exposes `ETag`.
|
||||
- The SPA sends the stored key (`ctv-api-key`) on **every** request; users enter it on the keyless
|
||||
**API Key** screen (`web/src/screens/ApiKeyScreen.tsx`, route `/app/api-key`). See spa-conventions §5e.
|
||||
`Api:CorsAllowedOrigins` (semicolon-separated exact origins). `AllowCredentials` is deliberately **not**
|
||||
set — cross-origin cookie auth is impossible by design (a CSRF defense); cross-origin machine clients use
|
||||
`X-Api-Key`. The policy permits `X-Api-Key`/`X-CSRF`/`If-Match` and exposes `ETag`.
|
||||
- **ForwardedHeaders trust is unchanged from #285** (trust `X-Forwarded-*` from any peer by default, with a
|
||||
warning; restrict via `ForwardedHeaders:KnownProxies`/`:KnownNetworks`). A stricter "ignore unless a proxy is
|
||||
configured" default was considered for #295 but **reverted** — it would regress `/iptv` M3U/XMLTV/HLS
|
||||
absolute-URL generation (which reads `Request.Scheme`/`Host`) for a proxied deployment that hasn't set
|
||||
`KnownProxies`. **Strongly set `KnownProxies`/`:KnownNetworks`** when exposing ErsatzTV behind a proxy — it
|
||||
also gives the #295 login rate limiter an unspoofable client IP and lets the session cookie be marked
|
||||
`Secure` behind TLS.
|
||||
- **Side-effecting GET endpoints are not CSRF-covered by the filter** (it only checks mutating verbs). A few
|
||||
`[RequiresAuthentication]` GETs have side effects (e.g. `GET /api/troubleshoot/playback.m3u8` starts playback).
|
||||
Once a session cookie is a normal SPA credential (PR2), those need POST-ification or an explicit `X-CSRF` gate
|
||||
— tracked as **#301**, which gates PR2 (latent in PR1: the SPA still uses the machine key, so no session
|
||||
reaches them in normal use).
|
||||
- **PR1 scope note (this change is server-only).** The SPA still authenticates with the stored key
|
||||
(`ctv-api-key`, `X-Api-Key`) until the SPA login flow lands (PR2) — PR1 is backward compatible. The
|
||||
`ApiKeyScreen` → machine-key-management repurpose, the SPA login/setup screens, and `spa-conventions §5e`
|
||||
update all land in PR2.
|
||||
|
||||
**The OpenAPI "v1" document now declares this posture by construction (#287).** An `ApiKey` security
|
||||
**The OpenAPI "v1" document declares the machine posture by construction (#287).** An `ApiKey` security
|
||||
scheme (`X-Api-Key`, `in: header`) is declared in `components.securitySchemes`, and
|
||||
`ApiSecurityOperationTransformer` injects a per-operation `security` requirement + a documented `401`
|
||||
for exactly the operations that require the key — using the **same** shared predicate
|
||||
`ApiKeyAuthorizationFilter.EndpointRequiresKey(httpMethod, endpointMetadata, requireKeyForReads)` that
|
||||
the runtime filter enforces, so the spec can never drift from enforcement. The document is generated
|
||||
against the effective default (`Api:RequireKeyForReads=true`), under which every documented operation
|
||||
requires the key. Two companion transformers run on the "v1" document only:
|
||||
for exactly the operations that require a credential — using the **same** shared predicate
|
||||
`ApiAuthorizationFilter.EndpointRequiresKey(...)` the runtime filter enforces, so the spec can never drift from
|
||||
enforcement. The document is generated against the effective default (`Api:RequireKeyForReads=true`), under
|
||||
which every documented operation requires the credential; the browser-session path is an additional accepted
|
||||
credential the spec (machine audience) needn't express. Two companion transformers run on the "v1" document
|
||||
only:
|
||||
`OperationIdOpenApiTransformer` synthesizes a stable `operationId` (from controller+action) for the ~90
|
||||
operations that lacked a `Name=` — and disambiguates the HEAD/GET pairs that share a controller+action
|
||||
**structurally, independent of ApiExplorer visitation order** (#197 Bundle C): when 2+ synthesized ops
|
||||
|
||||
@@ -53,6 +53,9 @@ below (or that establishes a new convention worth recording).**
|
||||
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
|
||||
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
|
||||
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
|
||||
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
|
||||
- [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6)
|
||||
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h45)
|
||||
|
||||
---
|
||||
|
||||
@@ -1134,6 +1137,129 @@ headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLT
|
||||
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
|
||||
contract absent a federation requirement.
|
||||
|
||||
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
|
||||
|
||||
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
|
||||
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now **revived**, and a
|
||||
cookie session becomes a first-class `/api` credential alongside the machine `X-Api-Key`. **PR1 is
|
||||
server-only and backward compatible** — the SPA keeps sending its stored key; the SPA login flow, the
|
||||
`ApiKeyScreen`→machine-key repurpose, and `spa-conventions §5e` land in **PR2**.
|
||||
|
||||
**One gate, evolved (not `[Authorize]`-per-controller).** `ApiKeyAuthorizationFilter` → `ApiAuthorizationFilter`,
|
||||
same fail-closed-by-omission logic (a forgotten `[Authorize]` fails *open* — the #280 failure mode — so the
|
||||
global filter stays the gate). It now accepts a request when a valid `X-Api-Key` matches **OR** the principal
|
||||
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
|
||||
`EndpointRequiresKey(...)` predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
|
||||
match the widened meaning: `[RequiresApiKey]`→`[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`→
|
||||
`[SkipApiAuthorization]`. `IApiKeyProvider`, the `X-Api-Key` header, and `Api:WriteKey`/`Api:RequireKeyForReads`
|
||||
are unchanged — **machine/key behavior is byte-identical** (verified: no OpenAPI drift, existing filter tests
|
||||
still green).
|
||||
|
||||
**CSRF (session only).** The machine key is CSRF-immune (a browser can't set a custom header cross-origin
|
||||
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated **mutation**
|
||||
must carry the `X-CSRF` header (presence-only — a custom header forces a CORS preflight a cross-site page can't
|
||||
satisfy) or is rejected **403**. Reinforced by `SameSite=Lax` + CORS without `AllowCredentials` (cross-origin
|
||||
cookie auth is impossible by design). No antiforgery-token machinery.
|
||||
|
||||
**Cookie `ctv-session`.** Always registered (local login works with no IdP); OIDC handler added only when
|
||||
`OIDC:*` is configured. `HttpOnly`, `SameSite=Lax`, `SecurePolicy=SameAsRequest` (so a plain-HTTP LAN isn't
|
||||
bricked), 14-day sliding. `/api` XHR gets **401/403, not a redirect** (`OnRedirectToLogin`/`AccessDenied`).
|
||||
The `UseAuthentication`/`UseAuthorization` middleware — deleted with Blazor in #91b — is **revived in the
|
||||
`legacy` `MapWhen` branch only** (hosts `/api` + OIDC `/callback` + `/docs`; `/iptv` and `/app` untouched).
|
||||
|
||||
**Local store = `ConfigElement` rows, single admin, NO migration** (owner ruling F2):
|
||||
`AuthLocalAdminUsername`, `AuthLocalAdminPasswordHash` (ASP.NET `PasswordHasher`, PBKDF2, via
|
||||
`Microsoft.Extensions.Identity.Core`), `AuthSecurityStamp`. A password change rotates the stamp; the cookie
|
||||
`OnValidatePrincipal` (`CookieSecurityStampValidator`) compares the claim to the stored stamp and rejects a
|
||||
stale session (revocation). OIDC sessions carry an `etv:auth_method=oidc` claim and skip the stamp check
|
||||
(governed by the IdP).
|
||||
|
||||
**Fail-closed out of the box + recovery.** An unconfigured instance keeps `/api` gated (the key still works);
|
||||
first-run is a **setup-claim** (`POST /api/auth/setup`, first-claim-wins, only valid while unconfigured —
|
||||
owner ruling F1). Recovery without the browser: `Auth:LocalAdmin:Password` env seed (`LocalAdminSeedService`,
|
||||
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
|
||||
(`[EnableRateLimiting("auth")]`, 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
|
||||
user (no enumeration).
|
||||
|
||||
**Authelia = app-owned OIDC session; never trust proxy identity headers** (owner ruling F3): the container is
|
||||
LAN-reachable bypassing the proxy, so `Remote-User`/`Remote-Email` header trust is spoofable. OIDC→Authelia
|
||||
gives SSO without a double login. **`ForwardedHeaders` behaviour is kept unchanged from #285** (trust any peer
|
||||
with a warning; restrict via `KnownProxies`/`:KnownNetworks`). A stricter "ignore `X-Forwarded-*` unless a proxy
|
||||
is configured" default was implemented and then **reverted** after review (cold fork M1): the forwarded
|
||||
scheme/host feed `/iptv` M3U/XMLTV/HLS absolute-URL generation (`Request.Scheme` in `GetChannelGuideHandler`/
|
||||
`IptvController`), so ignoring them would regress stream URLs to `http`/internal-host for a proxied deployment
|
||||
that hasn't set `KnownProxies`. **Deployment coordination:** operators behind a proxy should set
|
||||
`ForwardedHeaders:KnownProxies`/`:KnownNetworks` — it gives the login rate limiter an unspoofable client IP and
|
||||
marks the session cookie `Secure` behind TLS. The residual (a direct LAN peer can spoof `X-Forwarded-For` to
|
||||
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
|
||||
no-enumeration.
|
||||
|
||||
**Review hardening (fork + independent Codex pass, folded into PR1).** Codex caught concurrency defects the
|
||||
fork missed — folded in: (a) **atomic first-claim-wins** — setup writes the three credential rows in one
|
||||
transaction guarded by the unique `ConfigElement.Key` index (a lost race → `DbUpdateException` → 409), so a
|
||||
concurrent claim can't produce a mixed-state credential; (b) **consistent login snapshot** — login reads the
|
||||
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
|
||||
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
|
||||
cookie carrying the pre-change stamp → revoked next request); (c) **env-seed waits on
|
||||
`SystemStartup.WaitForDatabase`** (the migrator is a `BackgroundService`, so registration order alone didn't
|
||||
guarantee the schema existed) — moved to `Services/RunOnce/`. Also: **logout + password require `X-CSRF`**
|
||||
(the `[SkipApiAuthorization]` auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
|
||||
input length caps on username/password. **Logout rotates the security stamp** when called from a local session
|
||||
(E2E-caught: `SignOutAsync` alone only clears the *client* cookie, leaving the stateless encrypted ticket
|
||||
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
|
||||
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
|
||||
caller can't force-revoke the admin. **Deferred with a tracked gate:** side-effecting `[RequiresAuthentication]`
|
||||
GETs (troubleshoot playback/archive) aren't CSRF-covered — **#301**, gating PR2 (latent in PR1: the SPA still
|
||||
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
|
||||
|
||||
A **fix-commit re-review** (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
|
||||
(a) **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's credential persists,
|
||||
defeating the env recovery path). Fixed structurally: **the setup-claim endpoint is closed whenever
|
||||
`Auth:LocalAdmin:Password` is configured** — the env seed owns the credential, so there is no claim to race
|
||||
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
|
||||
and browser setup is disabled). (b) **LOW**: a concurrent setup race-loser now returns **409** (not 422), and
|
||||
`ClaimLocalAdmin`'s `DbUpdateException` catch re-checks existence and **rethrows genuine/transient DB errors**
|
||||
rather than masking them as "already configured". (c) **MEDIUM — accepted**: two *simultaneous* authenticated
|
||||
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
|
||||
revoked). Accepted for a **single-admin** system: it needs two concurrent authenticated sessions both submitting
|
||||
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
|
||||
optimistic concurrency to the credential rows is disproportionate here.
|
||||
|
||||
**OpenAPI = `ApiKey`-only; `/api/auth/*` excluded** (owner ruling F4): the spec's audience is machine/MCP
|
||||
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
|
||||
is an additional accepted credential the doc needn't express. `AuthController` is `[ApiExplorerSettings(IgnoreApi
|
||||
= true)]`. Verified: no `v1.json`/`v1.d.ts`/`endpoint-index` drift from this PR.
|
||||
|
||||
**Phasing.** PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
|
||||
`X-CSRF`, `AuthContext` + boot gate, login/setup screens, `ApiKeyScreen`→machine-key management, E2E,
|
||||
`spa-conventions §5e`). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
|
||||
same release, then a manual Authelia round-trip checklist before the prod pin bump.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)
|
||||
|
||||
**An issue's `## Done-when` checklist (in the issue body) is the machine-readable source of truth for whether
|
||||
its PR may merge; consent is *derived*, not asserted.** Rationale: DONE/OPEN status used to live in
|
||||
append-only prose that lags live Gitea state (the queue-drift #303 fixes) — so the completion gate moves out
|
||||
of memory and into a checklist two hooks read. Convention: the issue body carries a `## Done-when` section
|
||||
(always an "adversarial review passed" box, plus per-issue criteria); a merge is allowed only when the PR's CI
|
||||
is green **and** every box on the linked issue (`fixes #N`) is ticked.
|
||||
|
||||
Enforcement (both fail *safe*, never a silent pass):
|
||||
- `pretooluse-merge-consent.sh` — Claude PreToolUse on `mcp__gitea__pull_request_write` merge: **deny** on an
|
||||
unticked box or non-green CI; **allow** when both satisfied; **ask** (human prompt) when state isn't
|
||||
derivable (no linked issue, no `## Done-when`, no creds, Gitea unreachable). Docs-only PRs exempt.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — backstop for a direct `git push origin main`; fail-*open* (a
|
||||
git hook has no "ask"), blocks only on a positively-proven unticked box.
|
||||
|
||||
Both authenticate to Gitea from env only (`ETV_GITEA_BASICAUTH` / `ETV_GITEA_TOKEN`, `ETV_GITEA_URL`) — no
|
||||
creds committed; without them the gate degrades to today's manual confirmation. Rollout is non-breaking: until
|
||||
issues adopt `## Done-when`, the merge hook simply *asks* rather than auto-allowing. See CLAUDE.md → Task
|
||||
Completion Protocol. (H6 lives with H1/H2/H8 in `.claude/settings.json`; H7 worktree-owner guard is its
|
||||
sibling Wave-2 hook.)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
|
||||
|
||||
Reference in New Issue
Block a user