From a2272c5df6bcec1b36d62479dd8fd152dde434ac Mon Sep 17 00:00:00 2001 From: DevOps Date: Tue, 23 Jun 2026 18:33:51 +0200 Subject: [PATCH] fix: harden owner bootstrap and auth persistence --- .env.example | 4 +- .env.template | 6 +- .gitea/workflows/deploy-now.yaml | 21 +- .gitea/workflows/deploy.yaml | 47 +-- .gitea/workflows/rollback.yaml | 18 +- README.md | 10 +- backend-tests/AuthServiceTests.cs | 397 ++++++++++++++++++ backend-tests/Nexus.Api.Tests.csproj | 1 + .../ApplicationBuilderExtensions.cs | 69 +-- backend/Services/AuthService.cs | 5 + compose.yaml | 29 +- phases/changelog.md | 6 +- phases/deployment.md | 11 +- 13 files changed, 481 insertions(+), 143 deletions(-) create mode 100644 backend-tests/AuthServiceTests.cs diff --git a/.env.example b/.env.example index 1dcc95a..a6c731f 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,7 @@ POSTGRES_DB=nexus POSTGRES_USER=nexus POSTGRES_PASSWORD=replace-with-a-strong-database-password JWT_KEY=replace-with-at-least-32-random-bytes -OWNER_EMAIL=owner@example.com -OWNER_PASSWORD=replace-with-at-least-14-characters -OWNER_DISPLAY_NAME=Owner +BOOTSTRAP_OWNER_EMAIL=owner@example.com OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_GATEWAY_TOKEN= OPENCLAW_GATEWAY_PASSWORD= diff --git a/.env.template b/.env.template index cf01fd4..50e9922 100644 --- a/.env.template +++ b/.env.template @@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded) JWT_ISSUER=nexus JWT_AUDIENCE=nexus-web -# ── Owner Account ─────────────────────────────────────── -OWNER_EMAIL=*** -OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated -OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email +# ── Bootstrap Owner (first seed only) ─────────────────── +BOOTSTRAP_OWNER_EMAIL=*** # ── OpenClaw Integration ──────────────────────────────── # Base URL of the OpenClaw gateway (host.docker.internal from inside container) diff --git a/.gitea/workflows/deploy-now.yaml b/.gitea/workflows/deploy-now.yaml index 7f5ee75..21b5df8 100644 --- a/.gitea/workflows/deploy-now.yaml +++ b/.gitea/workflows/deploy-now.yaml @@ -43,20 +43,13 @@ jobs: - name: Prepare .env run: | set -euo pipefail - HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true) - if [ -z "${HOST_OWNER_PASSWORD}" ]; then - echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env" - exit 1 - fi printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}" printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}" printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}" printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}" printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}" printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}" - printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}" - printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}" - printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}" + printf 'BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}" printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}" printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}" printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}" @@ -82,16 +75,10 @@ jobs: printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT" printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT" printf '\n' >> "$SCRIPT" + printf '# ── Graceful shutdown (preserves DB volume integrity) ──\n' >> "$SCRIPT" + printf 'docker compose --env-file /tmp/nexus-deploy-env stop postgres 2>/dev/null || true\n' >> "$SCRIPT" printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT" - printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT" - printf '\n' >> "$SCRIPT" - printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT" - printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT" - printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT" - printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT" - printf 'else\n' >> "$SCRIPT" - printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT" - printf 'fi\n' >> "$SCRIPT" + printf 'echo "Postgres volume preserved (nexus-postgres) — no WAL reset"\n' >> "$SCRIPT" printf '\n' >> "$SCRIPT" printf 'echo "Deploying all services"\n' >> "$SCRIPT" printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT" diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 45eb625..a9ffc79 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -52,9 +52,8 @@ jobs: ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }} - # OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret. - # This ensures the password stays consistent across deploys and the DB is the - # single source of truth after initial seed (enforced by SeedAudit guard). + # Owner password is not injected at deploy time. + # After first seed, the database is the only password source. steps: # ═══════════════════════════════════════════════════ @@ -113,37 +112,25 @@ jobs: echo "mutated_main=false" >> "$GITEA_OUTPUT" # ═══════════════════════════════════════════════════ - # Step 4: Build .env from secrets + host .env (SAFE) + # Step 4: Build .env from secrets (SAFE) # # Secrets are written to /tmp/nexus-deploy-env — NEVER # to a file inside the workspace that gets rsync'd to # the host. The temp file is deleted immediately after # compose operations complete. # - # OWNER_PASSWORD is read from the host's persistent .env - # to ensure it stays the single source of truth. Other - # secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN) + # Owner password is deliberately omitted so production deploys + # cannot overwrite the persisted DB password. + # Other secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN) # come from Gitea secrets. # ═══════════════════════════════════════════════════ - - name: Prepare .env (secrets + host .env → temp file) + - name: Prepare .env (secrets → temp file) run: | set -euo pipefail - # Read OWNER_PASSWORD from the host's persistent .env - HOST_OWNER_PASSWORD="" - if [ -f "${DEPLOY_PATH}/.env" ]; then - HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true) - fi - if [ -z "${HOST_OWNER_PASSWORD}" ]; then - echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env" - echo " The host .env is the single source of truth for the owner password." - echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying." - exit 1 - fi - cat > "${ENV_TMPFILE}" < /tmp/nexus-deploy-env -# ── Clean up zombie containers ── +# ── Graceful shutdown (preserves DB volume integrity) ── +docker compose --env-file /tmp/nexus-deploy-env stop postgres 2>/dev/null || true docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true -docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true - -# ── WAL recovery ── -PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1) -if [ -n "$PG_VOL" ]; then - echo "Checking postgres WAL integrity..." - docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'" 2>&1 || echo "pg_resetwal failed (may be benign)" -else - echo "Postgres volume not found - will be created fresh" -fi +echo "Postgres volume preserved (nexus-postgres) — no WAL reset" BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}" SERVICE="${DEPLOY_SERVICE:-}" diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml index e613c34..f31c301 100644 --- a/.gitea/workflows/rollback.yaml +++ b/.gitea/workflows/rollback.yaml @@ -94,22 +94,12 @@ jobs: fi # ═══════════════════════════════════════════════════ - # Step 3: Prepare .env from secrets + host .env (safe temp file) + # Step 3: Prepare .env from secrets (safe temp file) # ═══════════════════════════════════════════════════ - - name: Prepare .env (secrets + host .env → temp file) + - name: Prepare .env (secrets → temp file) run: | set -euo pipefail - # Read OWNER_PASSWORD from the host's persistent .env - HOST_OWNER_PASSWORD="" - if [ -f "${DEPLOY_PATH}/.env" ]; then - HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true) - fi - if [ -z "${HOST_OWNER_PASSWORD}" ]; then - echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env" - exit 1 - fi - cat > "${ENV_TMPFILE}" < +/// Tests for AuthService login, change-password, admin-reset, and related flows. +/// These are unit-level tests using an in-memory EF Core database so no +/// external PostgreSQL instance is needed. +/// +public sealed class AuthServiceTests +{ + // ── Fixture helpers ───────────────────────────────────────────────── + + /// + /// Creates a test fixture with an in-memory database, a UserRepository, + /// and an AuthService backed by an in-memory configuration. + /// + private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var db = new NexusDbContext(options); + var repo = new UserRepository(db); + + // In-memory config with minimum required JWT settings + var config = new MemoryConfig(new Dictionary + { + ["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!", + ["Jwt:Issuer"] = "nexus-test", + ["Jwt:Audience"] = "nexus-test-web", + }); + + var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + var auth = new AuthService(repo, config, logger); + return (db, repo, auth); + } + + private static LoginRequest Login(string email, string password) + => new() { Email = email, Password = password }; + + private static async Task SeedUserAsync(NexusDbContext db, string email, string password, string role = "user") + { + var user = new NexusUser + { + Email = email, + NormalizedEmail = AuthService.NormalizeEmail(email), + DisplayName = email.Split('@')[0], + PasswordHash = PasswordSecurity.Hash(password), + Role = role + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + // ══════════════════════════════════════════════════════════════════ + // Password Security Unit Tests + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public void Hash_And_Verify_RoundTrip_Succeeds() + { + const string password = "MyTestPassword123!"; + var hash = PasswordSecurity.Hash(password); + Assert.NotNull(hash); + Assert.StartsWith("v1.", hash); + + var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade); + Assert.True(ok); + Assert.False(needsUpgrade); + } + + [Fact] + public void Verify_WrongPassword_Fails() + { + var hash = PasswordSecurity.Hash("CorrectPassword123!"); + Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _)); + } + + [Fact] + public void Verify_EmptyHash_ReturnsFalse() + { + Assert.False(PasswordSecurity.Verify("password", "", out _)); + } + + [Fact] + public void Verify_LegacySha256_PassesAndFlagsUpgrade() + { + const string password = "OldFormatPassword123!"; + var legacyHash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(password))); + + var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade); + Assert.True(ok); + Assert.True(needsUpgrade); + } + + // ══════════════════════════════════════════════════════════════════ + // Login Tests + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Login_WithValidCredentials_Succeeds() + { + var (db, repo, auth) = CreateFixture(); + const string password = "ValidPassword123!"; + await SeedUserAsync(db, "test@example.com", password); + + var session = await auth.LoginAsync(Login("test@example.com", password)); + Assert.NotNull(session); + Assert.Equal("test", session.User.DisplayName); + } + + [Fact] + public async Task Login_WithWrongPassword_ReturnsNull() + { + var (db, repo, auth) = CreateFixture(); + await SeedUserAsync(db, "test@example.com", "CorrectPassword123!"); + + Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!"))); + } + + [Fact] + public async Task Login_WithNonexistentEmail_ReturnsNull() + { + var (db, repo, auth) = CreateFixture(); + Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!"))); + } + + [Fact] + public async Task Login_UpdatesLastLoginAt() + { + var (db, repo, auth) = CreateFixture(); + const string password = "TestPassword123!"; + var user = await SeedUserAsync(db, "test@example.com", password); + + var beforeLogin = user.LastLoginAt; + await Task.Delay(10); + + Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password))); + + var updated = await repo.GetByIdAsync(user.Id); + Assert.NotNull(updated!.LastLoginAt); + Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null); + } + + /// + /// Validates that LoginAsync persists a password hash upgrade AND login + /// timestamps even when there are NO expired refresh tokens. Previously + /// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync, + /// but that only happens when oldTokens.Count > 0. + /// + [Fact] + public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens() + { + var (db, repo, auth) = CreateFixture(); + const string password = "LegacyUpgradePassword123!"; + + var legacyHash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(password))); + + var user = new NexusUser + { + Email = "legacy@example.com", + NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"), + DisplayName = "Legacy", + PasswordHash = legacyHash, + Role = "user" + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + + // Login triggers hash upgrade + Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password))); + + var updated = await repo.GetByIdAsync(user.Id); + Assert.NotNull(updated); + Assert.StartsWith("v1.", updated.PasswordHash); + Assert.NotEqual(legacyHash, updated.PasswordHash); + + // Second login with the upgraded hash should also work + Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password))); + } + + [Fact] + public async Task Login_WithExistingHash_DoesNotChangeHash() + { + var (db, repo, auth) = CreateFixture(); + const string password = "StablePassword123!"; + var user = await SeedUserAsync(db, "stable@example.com", password); + + var originalHash = user.PasswordHash; + Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password))); + + var updated = await repo.GetByIdAsync(user.Id); + Assert.NotNull(updated); + Assert.Equal(originalHash, updated.PasswordHash); + } + + // ══════════════════════════════════════════════════════════════════ + // Change Password Tests + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds() + { + var (db, repo, auth) = CreateFixture(); + const string oldPw = "OldPassword123!"; + const string newPw = "NewPassword456!"; + var user = await SeedUserAsync(db, "changepw@example.com", oldPw); + + var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest + { + CurrentPassword = oldPw, + NewPassword = newPw + }); + Assert.True(result); + + Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw))); + Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw))); + } + + [Fact] + public async Task ChangePassword_WithWrongCurrentPassword_Fails() + { + var (db, repo, auth) = CreateFixture(); + var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!"); + + Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest + { + CurrentPassword = "WrongPassword456!", + NewPassword = "NewPassword789!" + })); + } + + // ══════════════════════════════════════════════════════════════════ + // Admin Reset Password Tests + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task AdminResetPassword_WithValidToken_Succeeds() + { + var (db, repo, auth) = CreateFixture(); + Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123"); + + const string oldPw = "OldPassword123!"; + const string newPw = "NewAdminPassword456!"; + await SeedUserAsync(db, "adminreset@example.com", oldPw); + + Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123")); + Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw))); + Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw))); + } + + [Fact] + public async Task AdminResetPassword_WithInvalidToken_Fails() + { + var (db, repo, auth) = CreateFixture(); + Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz"); + await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!"); + + Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token")); + } + + [Fact] + public async Task AdminResetPassword_NonexistentUser_Fails() + { + var (db, repo, auth) = CreateFixture(); + Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token"); + Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token")); + } + + [Fact] + public async Task AdminResetPassword_ShortPassword_Fails() + { + var (db, repo, auth) = CreateFixture(); + Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token"); + Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token")); + } + + // ══════════════════════════════════════════════════════════════════ + // Profile Update Tests + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public async Task UpdateProfile_ChangesDisplayName() + { + var (db, repo, auth) = CreateFixture(); + const string password = "Password123!"; + var user = await SeedUserAsync(db, "profile@example.com", password); + + var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest + { + DisplayName = "New Name" + }); + Assert.NotNull(updated); + Assert.Equal("New Name", updated.DisplayName); + } + + // ══════════════════════════════════════════════════════════════════ + // NormalizeEmail + // ══════════════════════════════════════════════════════════════════ + + [Fact] + public void NormalizeEmail_TrimsAndUppercases() + { + Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com ")); + Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com")); + } +} + +/// +/// Minimal in-memory IConfiguration implementation for unit tests. +/// Reads from a case-insensitive dictionary. +/// +internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration +{ + private readonly Dictionary _data; + private readonly Dictionary _sections; + + public MemoryConfig(Dictionary data) + { + _data = new Dictionary(data, StringComparer.OrdinalIgnoreCase); + _sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public string? this[string key] + { + get => _data.TryGetValue(key, out var val) ? val : null; + set => _data[key] = value ?? string.Empty; + } + + public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key) + { + if (!_sections.TryGetValue(key, out var section)) + { + section = new MemoryConfigSection(key, this); + _sections[key] = section; + } + return section; + } + + public IEnumerable GetChildren() + => Enumerable.Empty(); + + public IChangeToken GetReloadToken() + => NeverToken.Instance; +} + +internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection +{ + public string Key => path.Split(':').Last(); + public string Path => path; + public string? Value { get => root[path]; set => root[path] = value; } + + public string? this[string key] + { + get => root[$"{path}:{key}"]; + set => root[$"{path}:{key}"] = value; + } + + public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key) + => root.GetSection($"{path}:{key}"); + + public IEnumerable GetChildren() + => Enumerable.Empty(); + + public IChangeToken GetReloadToken() + => NeverToken.Instance; +} + +/// A change token that never signals — for test-use IConfiguration stubs. +internal sealed class NeverToken : IChangeToken +{ + public static readonly NeverToken Instance = new(); + public bool HasChanged => false; + public bool ActiveChangeCallbacks => false; + public IDisposable RegisterChangeCallback(Action callback, object? state) => NoopDisposable.Instance; +} + +internal sealed class NoopDisposable : IDisposable +{ + public static readonly NoopDisposable Instance = new(); + public void Dispose() { } +} diff --git a/backend-tests/Nexus.Api.Tests.csproj b/backend-tests/Nexus.Api.Tests.csproj index bf13817..0e64fdf 100644 --- a/backend-tests/Nexus.Api.Tests.csproj +++ b/backend-tests/Nexus.Api.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/backend/Extensions/ApplicationBuilderExtensions.cs b/backend/Extensions/ApplicationBuilderExtensions.cs index f1b13e2..7f452fb 100644 --- a/backend/Extensions/ApplicationBuilderExtensions.cs +++ b/backend/Extensions/ApplicationBuilderExtensions.cs @@ -15,6 +15,10 @@ public static class ApplicationBuilderExtensions /// Applies pending EF Core migrations and seeds the initial owner account if none exist. /// Uses a guard so the owner is never re-created even if all users /// are deleted — the DB is the single source of truth for the owner password after first seed. + /// + /// Single-transaction guarantee: if the seed block is entered at all (user creation needed + /// or just the audit-log write), the SeedAudit row is written inside the same transaction + /// so that a crash mid-way can never leave the DB in a re-seedable state. /// public static async Task EnsureDatabaseAsync(this WebApplication app) { @@ -30,46 +34,49 @@ public static class ApplicationBuilderExtensions if (alreadySeeded) return; - var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant(); - var ownerPassword = configuration["Owner:Password"]; - var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim(); + var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant(); var hasUsers = await db.Users.AnyAsync(); - if (!hasUsers) + // ── Double-check SeedAudit after the migration — if another pod wrote it + // while we were reading, bail out early. ── + alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey); + if (alreadySeeded) + return; + + // ── Use a strategy-based transaction so the user + audit row are + // persisted atomically. If the DB crashes after SaveChanges the + // entire transaction is rolled back, preventing partial-seed states. + var strategy = db.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => { - if (string.IsNullOrWhiteSpace(ownerEmail)) - throw new InvalidOperationException("Owner:Email is required for initial setup."); + await using var tx = await db.Database.BeginTransactionAsync(); - var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) - ? PasswordHelper.BuildOwnerDisplayName(ownerEmail) - : ownerDisplayName; - var initialPassword = string.IsNullOrWhiteSpace(ownerPassword) - ? PasswordHelper.GenerateTemporaryPassword() - : ownerPassword; - - if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10) - throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly."); - - db.Users.Add(new NexusUser + if (!hasUsers) { - Email = ownerEmail, - NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), - DisplayName = initialDisplayName, - PasswordHash = PasswordSecurity.Hash(initialPassword), - Role = "owner" - }); - await db.SaveChangesAsync(); + if (string.IsNullOrWhiteSpace(ownerEmail)) + throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup."); + + var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail); + var initialPassword = PasswordHelper.GenerateTemporaryPassword(); + + db.Users.Add(new NexusUser + { + Email = ownerEmail, + NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), + DisplayName = initialDisplayName, + PasswordHash = PasswordSecurity.Hash(initialPassword), + Role = "owner" + }); - if (string.IsNullOrWhiteSpace(ownerPassword)) - { Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); } - } - // Record the seed attempt regardless of whether users already existed. - // This prevents re-seeding even if the Users table is wiped. - db.SeedAudits.Add(new SeedAudit { Key = seedKey }); - await db.SaveChangesAsync(); + // Record the seed attempt regardless of whether users already existed. + // This prevents re-seeding even if the Users table is wiped. + db.SeedAudits.Add(new SeedAudit { Key = seedKey }); + await db.SaveChangesAsync(); + await tx.CommitAsync(); + }); } } diff --git a/backend/Services/AuthService.cs b/backend/Services/AuthService.cs index 96844b1..5c5afc0 100644 --- a/backend/Services/AuthService.cs +++ b/backend/Services/AuthService.cs @@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService user.LastLoginAt = DateTimeOffset.UtcNow; user.UpdatedAt = DateTimeOffset.UtcNow; + // Persist user changes (password upgrade, login timestamp) immediately. + // Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to + // trigger SaveChangesAsync is fragile — if zero tokens are expired the + // tracked changes might not be flushed before the response is produced. + await _users.UpdateAsync(user, ct); await _users.RemoveExpiredTokensAsync(user.Id, ct); return await CreateSessionAsync(user, Guid.NewGuid(), null, ct); } diff --git a/compose.yaml b/compose.yaml index 36f9d7e..85f56ee 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,9 +1,8 @@ name: nexus - services: postgres: image: postgres:17-alpine - restart: unless-stopped + restart: always deploy: resources: limits: @@ -29,22 +28,16 @@ services: options: max-size: "10m" max-file: "3" - api: build: context: ./backend - restart: unless-stopped + restart: always deploy: resources: limits: memory: 512M reservations: memory: 128M - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - window: 120s environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 @@ -52,12 +45,8 @@ services: Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env} Jwt__Issuer: ${JWT_ISSUER:-nexus} Jwt__Audience: ${JWT_AUDIENCE:-nexus-web} - Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env} - # OWNER_PASSWORD is only used during initial seed (first deploy). - # After that the DB is the single source of truth, enforced by SeedAudit. - # Default: empty (seed uses a random password if unset on first run). - Owner__Password: ${OWNER_PASSWORD:-} - Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner} + Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env} + # Initial owner password is generated once at first seed and then lives only in the DB. Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} @@ -91,22 +80,16 @@ services: options: max-size: "10m" max-file: "3" - web: build: context: ./frontend - restart: unless-stopped + restart: always deploy: resources: limits: memory: 128M reservations: memory: 32M - restart_policy: - condition: on-failure - delay: 5s - max_attempts: 3 - window: 120s labels: - "traefik.enable=true" - "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)" @@ -133,13 +116,11 @@ services: options: max-size: "10m" max-file: "3" - networks: nexus: openclaw_default: external: true proxy: external: true - volumes: nexus-postgres: diff --git a/phases/changelog.md b/phases/changelog.md index 567c2bc..d8ab765 100644 --- a/phases/changelog.md +++ b/phases/changelog.md @@ -3,10 +3,10 @@ > Letzte Aktualisierung: 2026-06-21 - 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).** - - Root Cause: Dual-Source-Architektur (Gitea-Secret vs Host-.env) verursachte Passwort-Drift nach DB-Reseed. + - Root Cause: Passwort-Injektion über Deploy-Runtime erzeugte einen unnötigen zweiten Pfad neben der DB und verursachte Drift nach DB-Reseed. - Code-Fix: `SeedAudit`-Entity + Migration (`20260621081500_AddSeedAudit`) eingebaut. `EnsureDatabaseAsync` prueft jetzt `SeedAudit` VOR dem Seeden. Key `owner_created` blockiert erneutes Seeden permanent. - - Workflow-Fix: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` aus dem Host-`.env` (Single Source of Truth), nicht mehr aus Gitea-Secret. - - `compose.yaml`: Kommentar hinzugefuegt dass OWNER_PASSWORD nur beim initialen Seed verwendet wird. + - Workflow-Fix: Deploy- und Rollback-Workflows injizieren kein `OWNER_PASSWORD` mehr. + - `compose.yaml`: `Owner__Password` entfernt; Bootstrap-Konfig auf `BOOTSTRAP_OWNER_EMAIL` reduziert; Initialpasswort wird nur noch einmalig beim ersten Seed generiert. - Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`. - Git: Commit `f95463e`, manuell ausgerollt. - Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`. diff --git a/phases/deployment.md b/phases/deployment.md index b04810b..1530cfd 100644 --- a/phases/deployment.md +++ b/phases/deployment.md @@ -109,12 +109,12 @@ schedule: ### Owner Password Persistence (2026-06-21, permanent fix) -**Root Cause**: Dual-Source-Architektur fuer das Owner-Passwort (Gitea-Secret `ENV_OWNER_PASSWORD` vs Host `.env` `OWNER_PASSWORD`) verursachte Drift wenn die DB jemals neu geseedet wurde. +**Root Cause**: Die fruehere Passwort-Injektion ueber Deploy-Runtime schuf einen unnötigen zweiten Pfad neben der DB und machte Passwort-Drift/Re-Seeding-Folgen möglich. **Fix (3 Schichten)**: 1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird. -2. **Single Source of Truth**: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` jetzt aus dem persistenten Host-`.env` (via `grep` auf dem Deploy-Pfad), NICHT mehr aus separatem Gitea-Secret. Das Host-`.env` ist die kanonische Quelle. -3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`). +2. **Single Source of Truth**: Deploy- und Rollback-Workflows injizieren gar kein `OWNER_PASSWORD` mehr. Nach dem ersten Seed ist ausschließlich die DB kanonisch. +3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`). Bootstrap läuft nur noch über `BOOTSTRAP_OWNER_EMAIL`. **Verifikation (2026-06-21)**: - Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart) @@ -122,7 +122,7 @@ schedule: - Login funktioniert nach `docker compose restart` - SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup -**Regel gegen Wiederholung**: `OWNER_PASSWORD` nur im Host-`.env` aendern. Das Host-`.env` wird von CI-Deploys gelesen. Niemals ein separates Gitea-Secret fuer OWNER_PASSWORD anlegen. +**Regel gegen Wiederholung**: Kein `OWNER_PASSWORD` mehr in Deploy-Runtime, Host-`.env` oder Secrets pflegen. Passwort-Änderungen laufen nur noch über App/DB-Pfade. ### Secrets in Gitea @@ -134,8 +134,7 @@ Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfi | `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) | | `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token | -> **Hinweis**: `ENV_OWNER_PASSWORD` wurde aus den Gitea-Secrets ENTFERNT (2026-06-21). -> OWNER_PASSWORD kommt ausschliesslich aus dem Host-`.env` auf dem Deploy-Pfad. +> **Hinweis**: `ENV_OWNER_PASSWORD` bleibt entfernt. `OWNER_PASSWORD` wird auch nicht mehr aus Host-`.env` eingelesen. ### Safe Secret Handling (v3)