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
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>
69 lines
2.7 KiB
C#
69 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 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);
|
|
}
|
|
}
|