Files
ersatztv/ErsatzTV.Application/Auth/VerifyLocalAdminLoginHandler.cs
T
timothyandClaude Opus 4.8 6ac5150fd0
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(api): #295 PR1 — fold in cold-fork + Codex review findings
Independent review (cold fork = MERGEABLE-WITH-NITS; Codex = BLOCKED, caught
concurrency defects the fork missed). All actionable findings folded in:

- HIGH (Codex) atomic first-claim-wins: ClaimLocalAdmin now writes the three
  credential rows in ONE transaction guarded by the unique ConfigElement.Key
  index (lost race -> DbUpdateException -> 409), so concurrent claims can't
  produce a mixed-state credential.
- HIGH (Codex) consistent login snapshot: VerifyLocalAdminLogin reads hash+stamp
  in one query and drops rehash-on-verify, so a login racing a password change
  can't capture a stamp newer than the hash it verified (concurrent change ->
  old password fails, or the issued cookie carries the pre-change stamp ->
  revoked next request).
- MEDIUM (Codex) env-seed migration race: LocalAdminSeedService is now a RunOnce
  BackgroundService that awaits SystemStartup.WaitForDatabase (the migrator is a
  BackgroundService; registration order didn't guarantee the schema) + try/catch.
- MEDIUM (fork M1) ForwardedHeaders: reverted the strict-opt-in flip — it would
  regress /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme) behind a
  proxy without KnownProxies. Kept #285 behavior; KnownProxies still recommended.
- LOW (Codex/fork) require X-CSRF on /api/auth/logout + /password (the
  [SkipApiAuthorization] surface isn't covered by the filter's CSRF check;
  closes forced-logout CSRF).
- ChangeLocalAdminPassword also writes hash+stamp atomically. Input length caps
  on username/password.

Deferred with a tracked gate: MEDIUM (Codex) side-effecting [RequiresAuthentication]
GETs (troubleshoot playback/archive) aren't CSRF-covered -> #301, gates PR2
(latent in PR1: the SPA still uses the machine key).

Verify: full ErsatzTV.Tests green (1501); no OpenAPI/generated drift. Docs updated
(api-conventions §9, decisions.md).

Refs #295 #301

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

54 lines
2.7 KiB
C#

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);
}
}