fix: harden owner bootstrap and auth persistence
This commit is contained in:
+1
-3
@@ -2,9 +2,7 @@ POSTGRES_DB=nexus
|
|||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
POSTGRES_PASSWORD=replace-with-a-strong-database-password
|
POSTGRES_PASSWORD=replace-with-a-strong-database-password
|
||||||
JWT_KEY=replace-with-at-least-32-random-bytes
|
JWT_KEY=replace-with-at-least-32-random-bytes
|
||||||
OWNER_EMAIL=owner@example.com
|
BOOTSTRAP_OWNER_EMAIL=owner@example.com
|
||||||
OWNER_PASSWORD=replace-with-at-least-14-characters
|
|
||||||
OWNER_DISPLAY_NAME=Owner
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=
|
OPENCLAW_GATEWAY_TOKEN=
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
|||||||
+2
-4
@@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
|
|||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
|
|
||||||
# ── Owner Account ───────────────────────────────────────
|
# ── Bootstrap Owner (first seed only) ───────────────────
|
||||||
OWNER_EMAIL=***
|
BOOTSTRAP_OWNER_EMAIL=***
|
||||||
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
|
|
||||||
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
|
|
||||||
|
|
||||||
# ── OpenClaw Integration ────────────────────────────────
|
# ── OpenClaw Integration ────────────────────────────────
|
||||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||||
|
|||||||
@@ -43,20 +43,13 @@ jobs:
|
|||||||
- name: Prepare .env
|
- name: Prepare .env
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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_DB=nexus\n' > "${ENV_TMPFILE}"
|
||||||
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
|
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
|
||||||
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
|
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
|
||||||
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
|
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
|
||||||
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
|
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
|
||||||
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
|
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
|
||||||
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
|
printf 'BOOTSTRAP_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 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\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_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
|
||||||
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${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 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
|
||||||
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
|
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
|
||||||
printf '\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 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 'echo "Postgres volume preserved (nexus-postgres) — no WAL reset"\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 '\n' >> "$SCRIPT"
|
printf '\n' >> "$SCRIPT"
|
||||||
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
|
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
|
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ jobs:
|
|||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
|
# Owner password is not injected at deploy time.
|
||||||
# This ensures the password stays consistent across deploys and the DB is the
|
# After first seed, the database is the only password source.
|
||||||
# single source of truth after initial seed (enforced by SeedAudit guard).
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
@@ -113,37 +112,25 @@ jobs:
|
|||||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
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
|
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||||
# to a file inside the workspace that gets rsync'd to
|
# to a file inside the workspace that gets rsync'd to
|
||||||
# the host. The temp file is deleted immediately after
|
# the host. The temp file is deleted immediately after
|
||||||
# compose operations complete.
|
# compose operations complete.
|
||||||
#
|
#
|
||||||
# OWNER_PASSWORD is read from the host's persistent .env
|
# Owner password is deliberately omitted so production deploys
|
||||||
# to ensure it stays the single source of truth. Other
|
# cannot overwrite the persisted DB password.
|
||||||
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
# Other secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
||||||
# come from Gitea secrets.
|
# come from Gitea secrets.
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Prepare .env (secrets + host .env → temp file)
|
- name: Prepare .env (secrets → temp file)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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}" <<EOF
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
# Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
|
# Managed via Gitea Secrets → do NOT edit manually.
|
||||||
# This file lives in /tmp and is removed after deploy completes.
|
# This file lives in /tmp and is removed after deploy completes.
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
@@ -151,9 +138,7 @@ jobs:
|
|||||||
JWT_KEY=${ENV_JWT_KEY}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
|
||||||
OWNER_DISPLAY_NAME=
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
@@ -205,18 +190,10 @@ set -e
|
|||||||
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
cat > /tmp/nexus-deploy-env
|
cat > /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 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
|
echo "Postgres volume preserved (nexus-postgres) — no WAL reset"
|
||||||
|
|
||||||
# ── 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
|
|
||||||
|
|
||||||
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
||||||
SERVICE="${DEPLOY_SERVICE:-}"
|
SERVICE="${DEPLOY_SERVICE:-}"
|
||||||
|
|||||||
@@ -94,22 +94,12 @@ jobs:
|
|||||||
fi
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
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}" <<EOF
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
@@ -118,9 +108,7 @@ jobs:
|
|||||||
JWT_KEY=${ENV_JWT_KEY}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
|
||||||
OWNER_DISPLAY_NAME=
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
|
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
|
||||||
# OWNER_EMAIL and OWNER_PASSWORD.
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
curl http://127.0.0.1:18880/health
|
curl http://127.0.0.1:18880/health
|
||||||
```
|
```
|
||||||
|
|
||||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
|
||||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
|
derives the initial display name from that email, and logs a generated temporary password once.
|
||||||
characters. Existing databases are never overwritten by the bootstrap process.
|
After first seed the password lives only in PostgreSQL. Existing databases are
|
||||||
|
never overwritten by the bootstrap process.
|
||||||
|
|
||||||
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||||
Health checks, rate limiting, and security headers are active.
|
Health checks, rate limiting, and security headers are active.
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthServiceTests
|
||||||
|
{
|
||||||
|
// ── Fixture helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a test fixture with an in-memory database, a UserRepository,
|
||||||
|
/// and an AuthService backed by an in-memory configuration.
|
||||||
|
/// </summary>
|
||||||
|
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.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<string, string?>
|
||||||
|
{
|
||||||
|
["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<AuthService>.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<NexusUser> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal in-memory IConfiguration implementation for unit tests.
|
||||||
|
/// Reads from a case-insensitive dictionary.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string?> _data;
|
||||||
|
private readonly Dictionary<string, MemoryConfigSection> _sections;
|
||||||
|
|
||||||
|
public MemoryConfig(Dictionary<string, string?> data)
|
||||||
|
{
|
||||||
|
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
|
||||||
|
_sections = new Dictionary<string, MemoryConfigSection>(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<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||||
|
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||||
|
|
||||||
|
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<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||||
|
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||||
|
|
||||||
|
public IChangeToken GetReloadToken()
|
||||||
|
=> NeverToken.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
|
||||||
|
internal sealed class NeverToken : IChangeToken
|
||||||
|
{
|
||||||
|
public static readonly NeverToken Instance = new();
|
||||||
|
public bool HasChanged => false;
|
||||||
|
public bool ActiveChangeCallbacks => false;
|
||||||
|
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NoopDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NoopDisposable Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public static class ApplicationBuilderExtensions
|
|||||||
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
||||||
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
|
/// Uses a <see cref="SeedAudit"/> 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.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
||||||
{
|
{
|
||||||
@@ -30,46 +34,49 @@ public static class ApplicationBuilderExtensions
|
|||||||
if (alreadySeeded)
|
if (alreadySeeded)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
|
||||||
var ownerPassword = configuration["Owner:Password"];
|
|
||||||
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
|
|
||||||
var hasUsers = await db.Users.AnyAsync();
|
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))
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
|
||||||
|
|
||||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
if (!hasUsers)
|
||||||
? 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
|
|
||||||
{
|
{
|
||||||
Email = ownerEmail,
|
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
|
||||||
DisplayName = initialDisplayName,
|
|
||||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
|
||||||
Role = "owner"
|
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
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}");
|
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Record the seed attempt regardless of whether users already existed.
|
// Record the seed attempt regardless of whether users already existed.
|
||||||
// This prevents re-seeding even if the Users table is wiped.
|
// This prevents re-seeding even if the Users table is wiped.
|
||||||
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
|
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
|
|||||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
user.UpdatedAt = 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);
|
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
||||||
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-24
@@ -1,9 +1,8 @@
|
|||||||
name: nexus
|
name: nexus
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -29,22 +28,16 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
memory: 512M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 128M
|
memory: 128M
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 5s
|
|
||||||
max_attempts: 3
|
|
||||||
window: 120s
|
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -52,12 +45,8 @@ services:
|
|||||||
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
||||||
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
||||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||||
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
|
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||||
# OWNER_PASSWORD is only used during initial seed (first deploy).
|
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||||
# 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}
|
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
@@ -91,22 +80,16 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 128M
|
memory: 128M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 32M
|
memory: 32M
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 5s
|
|
||||||
max_attempts: 3
|
|
||||||
window: 120s
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
||||||
@@ -133,13 +116,11 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nexus:
|
nexus:
|
||||||
openclaw_default:
|
openclaw_default:
|
||||||
external: true
|
external: true
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nexus-postgres:
|
nexus-postgres:
|
||||||
|
|||||||
+3
-3
@@ -3,10 +3,10 @@
|
|||||||
> Letzte Aktualisierung: 2026-06-21
|
> Letzte Aktualisierung: 2026-06-21
|
||||||
|
|
||||||
- 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).**
|
- 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.
|
- 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.
|
- Workflow-Fix: Deploy- und Rollback-Workflows injizieren kein `OWNER_PASSWORD` mehr.
|
||||||
- `compose.yaml`: Kommentar hinzugefuegt dass OWNER_PASSWORD nur beim initialen Seed verwendet wird.
|
- `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`.
|
- Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`.
|
||||||
- Git: Commit `f95463e`, manuell ausgerollt.
|
- 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`.
|
- Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`.
|
||||||
|
|||||||
@@ -109,12 +109,12 @@ schedule:
|
|||||||
|
|
||||||
### Owner Password Persistence (2026-06-21, permanent fix)
|
### 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)**:
|
**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.
|
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.
|
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`).
|
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)**:
|
**Verifikation (2026-06-21)**:
|
||||||
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
|
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
|
||||||
@@ -122,7 +122,7 @@ schedule:
|
|||||||
- Login funktioniert nach `docker compose restart`
|
- Login funktioniert nach `docker compose restart`
|
||||||
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup
|
- 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
|
### 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_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
|
||||||
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
|
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
|
||||||
|
|
||||||
> **Hinweis**: `ENV_OWNER_PASSWORD` wurde aus den Gitea-Secrets ENTFERNT (2026-06-21).
|
> **Hinweis**: `ENV_OWNER_PASSWORD` bleibt entfernt. `OWNER_PASSWORD` wird auch nicht mehr aus Host-`.env` eingelesen.
|
||||||
> OWNER_PASSWORD kommt ausschliesslich aus dem Host-`.env` auf dem Deploy-Pfad.
|
|
||||||
|
|
||||||
### Safe Secret Handling (v3)
|
### Safe Secret Handling (v3)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user