diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 16db547..900f5ca 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,11 @@ import { ErrorDetailsSchema, ErrorEnvelopeSchema, GenerationErrorCategorySchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LoginSendRequestSchema, + LogoutHeadersSchema, + LogoutResponseSchema, ModelConfigSseEventSchema, ModelRuntimeSseEventSchema, RegistrationCompleteHeadersSchema, @@ -25,6 +30,8 @@ import { createErrorEnvelope, isCorrelationId, type BootstrapResponse, + type LoginCompleteRequest, + type LoginSendRequest, type RegistrationCompleteRequest, type RegistrationSendRequest, } from "@dada/shared-contracts"; @@ -92,6 +99,8 @@ const contentSecurityPolicy = [ "base-uri 'none'", "frame-ancestors 'none'", ].join("; "); +const authFlowCookieName = "dada_auth_flow"; +const userSessionCookieName = "dada_session"; function requestCorrelationId(headers: Record) { const header = headers["x-correlation-id"]; @@ -208,6 +217,11 @@ export async function createApp(options: CreateAppOptions = {}) { RegistrationCompleteRequestSchema, RegistrationCompleteHeadersSchema, RegistrationCompleteResponseSchema, + LoginSendRequestSchema, + LoginCompleteRequestSchema, + LoginCompleteResponseSchema, + LogoutHeadersSchema, + LogoutResponseSchema, UserSessionResponseSchema, BrowserUnsupportedReasonSchema, BrowserSupportRequestSchema, @@ -288,6 +302,111 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginSendRequestSchema), + operationId: "sendLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as LoginSendRequest; + const result = await options.registration.sendLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${authFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + return { + challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(), + registration_id: result.registrationId, + resend_available_at: new Date(result.resendAvailableAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/auth/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(LoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeLogin", + response: { + 200: Type.Ref(LoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), authFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as LoginCompleteRequest; + const result = options.registration.completeLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + audience: result.audience, + credits: { + available_balance: result.credits.availableBalance, + reserved_balance: result.credits.reservedBalance, + }, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + user: { + creator_name: result.user.creatorName, + role: result.user.role, + social_id: result.user.socialId, + status: result.user.status, + user_id: result.user.userId, + }, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + app.post( "/api/v1/auth/register/send", { @@ -299,6 +418,7 @@ export async function createApp(options: CreateAppOptions = {}) { 200: Type.Ref(RegistrationSendResponseSchema), 400: Type.Ref(ErrorEnvelopeSchema), 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), 503: Type.Ref(ErrorEnvelopeSchema), }, tags: ["Authentication"], @@ -361,7 +481,7 @@ export async function createApp(options: CreateAppOptions = {}) { }); reply.header( "Set-Cookie", - `dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + `${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, ); return { credits: { @@ -401,7 +521,7 @@ export async function createApp(options: CreateAppOptions = {}) { if (!options.registration) { return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); } - const token = cookieValue(headerValue(request.headers.cookie), "dada_session"); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); const session = token ? options.registration.readUserSession(token) : undefined; if (!session) { return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); @@ -413,7 +533,7 @@ export async function createApp(options: CreateAppOptions = {}) { available_balance: session.credits.availableBalance, reserved_balance: session.credits.reservedBalance, }, - csrf_token: randomBytes(32).toString("base64url"), + csrf_token: options.registration.issueUserCsrfToken(token!), expires_at: new Date(session.expiresAt).toISOString(), user: { creator_name: session.user.creatorName, @@ -426,6 +546,43 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/auth/logout", + { + attachValidation: true, + schema: { + headers: Type.Ref(LogoutHeadersSchema), + operationId: "logoutUser", + response: { + 200: Type.Ref(LogoutResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + try { + options.registration.logoutUser({ csrfToken, sessionToken: token }); + reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); + return { status: "logged_out" as const }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + app.post( "/api/v1/support/check", { diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts index 6ab6241..b2a91c7 100644 --- a/apps/api/src/registration-errors.ts +++ b/apps/api/src/registration-errors.ts @@ -10,22 +10,42 @@ export type RegistrationErrorReason = | "privacy_consent_required" | "privacy_notice_version_invalid" | "profile_invalid" - | "idempotency_conflict"; + | "idempotency_conflict" + | "registration_login_required" + | "login_registration_required" + | "account_suspended" + | "login_admin_required" + | "resend_too_soon" + | "too_many_attempts" + | "csrf_invalid" + | "session_invalid"; export type RegistrationErrorCode = | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" - | "IDEMPOTENCY_KEY_CONFLICT"; + | "IDEMPOTENCY_KEY_CONFLICT" + | "AUTH_ENTRY_REJECTED" + | "AUTH_RATE_LIMITED" + | "AUTH_CSRF_INVALID" + | "AUTH_SESSION_INVALID"; export class RegistrationError extends Error { readonly code: RegistrationErrorCode; - readonly httpStatus: 400 | 409; + readonly httpStatus: 400 | 401 | 403 | 409 | 429; readonly reason: RegistrationErrorReason; constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) { super(code); this.code = code; - this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" ? 400 : 409; + this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" + ? 400 + : code === "AUTH_SESSION_INVALID" + ? 401 + : code === "AUTH_CSRF_INVALID" + ? 403 + : code === "AUTH_RATE_LIMITED" + ? 429 + : 409; this.reason = reason; } } @@ -44,6 +64,14 @@ export function registrationFieldError(reason: RegistrationErrorReason) { privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" }, profile_invalid: { field: "profile", message_key: "auth.profile.invalid" }, stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" }, + registration_login_required: { field: "email", message_key: "auth.registration.login_required" }, + login_registration_required: { field: "email", message_key: "auth.login.registration_required" }, + account_suspended: { field: "email", message_key: "auth.account.suspended" }, + login_admin_required: { field: "email", message_key: "auth.login.admin_required" }, + resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, + too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, + csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, + session_invalid: { field: "session", message_key: "auth.session.invalid" }, }; return entries[reason]; } diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 9e15400..ea67670 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -17,10 +17,23 @@ const stageLimit = 10; const challengeLifetimeMilliseconds = 10 * 60 * 1_000; const resendDelayMilliseconds = 60 * 1_000; const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000; +const rateWindowMilliseconds = 10 * 60 * 1_000; +const rateBlockMilliseconds = 10 * 60 * 1_000; +const maximumFailedAttempts = 5; +const maximumSendsPerWindow = 5; export interface RegistrationTransactionEvent { mode: "BEGIN IMMEDIATE"; - operation: "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation"; + operation: + | "invite_create" + | "registration_send" + | "registration_complete" + | "registration_send_compensation" + | "login_send" + | "login_complete" + | "session_issue" + | "session_revoke" + | "csrf_issue"; outcome: "committed" | "rejected" | "idempotent_replay"; } @@ -51,7 +64,7 @@ interface ChallengeRow { consumed_at: number | null; email: string; expires_at: number; - invite_id: string; + invite_id: string | null; } interface AttemptRow { @@ -72,6 +85,21 @@ interface UserResultRow { user_id: string; } +interface RateLimitRow { + blocked_until: number | null; + failed_attempts: number; + send_count: number; + window_started_at: number; +} + +interface LoginAttemptRow { + failure_reason: RegistrationErrorReason | null; + outcome_code: "success" | "failure"; + request_hash: string; + session_id: string | null; + user_id: string | null; +} + export interface RegistrationSendResult { challengeExpiresAt: number; registrationId: string; @@ -111,6 +139,18 @@ export interface UserSessionResult { userId: string; } +export interface LoginCompleteInput { + clientKey: string; + code: string; + idempotencyKey: string; + registrationId: string; +} + +export type LoginCompleteResult = Omit & { + audience: "user"; + status: "authenticated"; +}; + interface ImmediateResult { outcome: RegistrationTransactionEvent["outcome"]; value: T; @@ -210,10 +250,13 @@ export class RegistrationService { this.assertInviteAvailable(invite, now); this.assertStageCapacity(); const existing = this.database.prepare(` - SELECT user_id FROM users + SELECT user_id, role, status FROM users WHERE normalized_email = ? AND status <> 'deleted' - `).get(email); - if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered"); + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined; + if (existing?.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required"); + this.assertChallengeSendAllowed(email, "register", "registration", now); + this.recordRateSend(email, "registration", now); this.database.prepare(` INSERT INTO email_challenges ( @@ -241,7 +284,7 @@ export class RegistrationService { }); try { - await this.options.resend.sendRegistrationCode({ challengeId, code, email, purpose: "register" }); + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" }); } catch { this.runImmediate("registration_send_compensation", () => { this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); @@ -252,6 +295,143 @@ export class RegistrationService { return result; } + async sendLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const result = this.runImmediate("login_send", () => { + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_registration_required"); + if (user.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended"); + if (user.role !== "user") throw new RegistrationError("AUTH_ENTRY_REJECTED", "login_admin_required"); + this.assertChallengeSendAllowed(email, "login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "login" }); + } catch { + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return result; + } + + completeLogin(input: LoginCompleteInput): LoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + + const outcome = this.runImmediate("login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + const reason = previous.failure_reason ?? "challenge_invalid"; + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "idempotent_replay", value: new RegistrationError(code, reason) }; + } + return { + outcome: "idempotent_replay", + value: this.loginResult(this.readCompletedRegistration(previous.user_id!, previous.session_id!)), + }; + } + + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + const reason = failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid"; + return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, reason, now); + } + const user = this.database.prepare(` + SELECT user_id, role, status FROM users + WHERE normalized_email = ? AND status <> 'deleted' + `).get(challenge.email) as { role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (!user) return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_registration_required", now); + if (user.status === "suspended") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "account_suspended", now); + if (user.role !== "user") return this.recordLoginFailure(idempotencyDigest, requestHash, input.registrationId, "login_admin_required", now); + + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "user", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + return { + outcome: "committed", + value: this.loginResult(this.readCompletedRegistration(user.user_id, issued.sessionId)), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult { const creatorName = normalizeProfileValue(input.creatorName, 80); const socialId = normalizeSocialId(input.socialId); @@ -391,6 +571,103 @@ export class RegistrationService { return outcome; } + issueAuthenticatedSession(userId: string, audience: "user" | "admin") { + const now = this.options.clock(); + return this.runImmediate("session_issue", () => { + const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?") + .get(userId) as { role: "user" | "super_admin"; status: "active" | "suspended" | "deleted" } | undefined; + const role = audience === "user" ? "user" : "super_admin"; + if (!user || user.status !== "active" || user.role !== role) { + throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + if (audience === "admin") { + const access = this.database.prepare("SELECT allowed FROM admin_access WHERE user_id = ?") + .get(userId) as { allowed: 0 | 1 } | undefined; + if (access?.allowed !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + } + const issued = this.insertSession(userId, audience, now); + return { + outcome: "committed", + value: { + sessionExpiresAt: issued.sessionExpiresAt, + sessionId: issued.sessionId, + sessionToken: issued.sessionToken, + }, + }; + }); + } + + issueUserCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active' + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + + logoutUser(input: { csrfToken: string; sessionToken: string }) { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const session = this.database.prepare(` + SELECT session_id, user_id, csrf_token_digest FROM sessions + WHERE token_digest = ? AND audience = 'user' AND revoked_at IS NULL AND expires_at > ? + `).get(digest(input.sessionToken), now) as { csrf_token_digest: string | null; session_id: string; user_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + const csrfDigest = digest(input.csrfToken); + if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, csrfDigest)) { + throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid"); + } + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, session.user_id); + return { outcome: "committed", value: undefined }; + }); + } + + changeUserStatus(userId: string, status: "suspended" | "deleted") { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'") + .run(status, userId); + if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL") + .run(now, userId); + return { outcome: "committed", value: undefined }; + }); + } + + revokeAdminSessions(userId: string, reason: "logout" | "disabled" | "whitelist_removed") { + const now = this.options.clock(); + this.runImmediate("session_revoke", () => { + const user = this.database.prepare("SELECT role FROM users WHERE user_id = ?").get(userId) as { role: string } | undefined; + if (user?.role !== "super_admin") throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + if (reason === "disabled") this.database.prepare("UPDATE users SET status = 'suspended' WHERE user_id = ?").run(userId); + if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); + this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") + .run(now, userId); + return { outcome: "committed", value: undefined }; + }); + } + + readAdminSession(token: string) { + const now = this.options.clock(); + return this.database.prepare(` + SELECT s.expires_at, u.user_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(token), now) as { expires_at: number; user_id: string } | undefined; + } + readUserSession(token: string): UserSessionResult | undefined { const now = this.options.clock(); const row = this.database.prepare(` @@ -484,7 +761,7 @@ export class RegistrationService { CREATE TABLE IF NOT EXISTS email_challenges ( challenge_id TEXT PRIMARY KEY, email TEXT NOT NULL, - invite_id TEXT NOT NULL REFERENCES invite_codes(invite_id), + invite_id TEXT REFERENCES invite_codes(invite_id), code_hmac TEXT NOT NULL, purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')), expires_at INTEGER NOT NULL, @@ -498,6 +775,7 @@ export class RegistrationService { user_id TEXT NOT NULL REFERENCES users(user_id), audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')), token_digest TEXT NOT NULL UNIQUE, + csrf_token_digest TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER @@ -512,6 +790,27 @@ export class RegistrationService { session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS admin_access ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id), + allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) + ); + CREATE TABLE IF NOT EXISTS auth_rate_limits ( + rate_key TEXT PRIMARY KEY, + window_started_at INTEGER NOT NULL, + send_count INTEGER NOT NULL CHECK (send_count >= 0), + failed_attempts INTEGER NOT NULL CHECK (failed_attempts >= 0), + blocked_until INTEGER + ); + CREATE TABLE IF NOT EXISTS login_attempts ( + idempotency_key_digest TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + challenge_id TEXT NOT NULL, + outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')), + failure_reason TEXT, + user_id TEXT REFERENCES users(user_id), + session_id TEXT REFERENCES sessions(session_id), + created_at INTEGER NOT NULL + ); `); } @@ -548,6 +847,117 @@ export class RegistrationService { return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); } + private rateKey(email: string, clientKey: string) { + return this.keyedHmac(this.options.challengePepper, `rate:${email}:${clientKey}`); + } + + private readRateLimit(email: string, clientKey: string, now: number): RateLimitRow { + const row = this.database.prepare(` + SELECT window_started_at, send_count, failed_attempts, blocked_until + FROM auth_rate_limits WHERE rate_key = ? + `).get(this.rateKey(email, clientKey)) as RateLimitRow | undefined; + if (!row || now - row.window_started_at >= rateWindowMilliseconds) { + return { blocked_until: null, failed_attempts: 0, send_count: 0, window_started_at: now }; + } + return row; + } + + private recordRateSend(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + if (row.blocked_until !== null && row.blocked_until > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + if (row.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 1, 0, NULL) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = ?, + blocked_until = ? + `).run( + key, + row.window_started_at, + row.send_count + 1, + row.failed_attempts, + row.blocked_until, + ); + } + + private recordRateFailure(email: string, clientKey: string, now: number) { + const key = this.rateKey(email, clientKey); + const row = this.readRateLimit(email, clientKey, now); + const failedAttempts = row.failed_attempts + 1; + const blockedUntil = failedAttempts >= maximumFailedAttempts ? now + rateBlockMilliseconds : row.blocked_until; + this.database.prepare(` + INSERT INTO auth_rate_limits ( + rate_key, window_started_at, send_count, failed_attempts, blocked_until + ) VALUES (?, ?, 0, ?, ?) + ON CONFLICT(rate_key) DO UPDATE SET + window_started_at = excluded.window_started_at, + send_count = ?, + failed_attempts = excluded.failed_attempts, + blocked_until = excluded.blocked_until + `).run(key, row.window_started_at, failedAttempts, blockedUntil, row.send_count); + return failedAttempts; + } + + private assertChallengeSendAllowed( + email: string, + purpose: "register" | "login", + clientKey: string, + now: number, + ) { + const latest = this.database.prepare(` + SELECT resend_available_at FROM email_challenges + WHERE email = ? AND purpose = ? ORDER BY created_at DESC LIMIT 1 + `).get(email, purpose) as { resend_available_at: number } | undefined; + if (latest && latest.resend_available_at > now) { + throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon"); + } + const rate = this.readRateLimit(email, clientKey, now); + if ((rate.blocked_until !== null && rate.blocked_until > now) || rate.send_count >= maximumSendsPerWindow) { + throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts"); + } + } + + private insertSession(userId: string, audience: "user" | "admin", now: number) { + const sessionId = randomUUID(); + const sessionToken = this.sessionToken(sessionId); + const sessionExpiresAt = now + sessionLifetimeMilliseconds; + this.database.prepare(` + INSERT INTO sessions ( + session_id, user_id, audience, token_digest, csrf_token_digest, + created_at, expires_at, revoked_at + ) VALUES (?, ?, ?, ?, NULL, ?, ?, NULL) + `).run(sessionId, userId, audience, digest(sessionToken), now, sessionExpiresAt); + return { sessionExpiresAt, sessionId, sessionToken }; + } + + private recordLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ): ImmediateResult { + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'failure', ?, NULL, NULL, ?) + `).run(idempotencyKeyDigest, requestHash, challengeId, reason, now); + const code = reason === "resend_too_soon" || reason === "too_many_attempts" + ? "AUTH_RATE_LIMITED" + : "AUTH_ENTRY_REJECTED"; + return { outcome: "rejected", value: new RegistrationError(code, reason) }; + } + private stageIsFull() { const row = this.database.prepare(` SELECT COUNT(*) AS count FROM users @@ -605,6 +1015,17 @@ export class RegistrationService { return this.result(row); } + private loginResult(result: RegistrationCompleteResult): LoginCompleteResult { + return { + audience: "user", + credits: result.credits, + sessionExpiresAt: result.sessionExpiresAt, + sessionToken: result.sessionToken, + status: "authenticated", + user: result.user, + }; + } + private result(row: UserResultRow): RegistrationCompleteResult { return { credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance }, diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts index 4cb2bed..a9f9516 100644 --- a/apps/api/src/resend-adapter.ts +++ b/apps/api/src/resend-adapter.ts @@ -2,17 +2,17 @@ export interface RegistrationCodeMessage { challengeId: string; code: string; email: string; - purpose: "register"; + purpose: "register" | "login"; } export interface ResendAdapter { - sendRegistrationCode(message: RegistrationCodeMessage): Promise; + sendVerificationCode(message: RegistrationCodeMessage): Promise; } export class MockResendAdapter implements ResendAdapter { readonly calls: RegistrationCodeMessage[] = []; - async sendRegistrationCode(message: RegistrationCodeMessage) { + async sendVerificationCode(message: RegistrationCodeMessage) { this.calls.push({ ...message }); } diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 566d531..8a19298 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js"; +import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: { }>; } +export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -104,6 +113,22 @@ export async function getUserSession(options: ClientOptions = {}): Promise; } +export async function logoutUser(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/logout`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + +export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/auth/login/send`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 0f45b13..cb2d72e 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -78,7 +78,7 @@ export type ErrorDetails = { export type ErrorEnvelope = { "error": { - "code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE"; + "code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID"; "correlation_id": string; "details": { "capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable"; @@ -102,6 +102,32 @@ export type ErrorEnvelope = { export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; +export type LoginCompleteRequest = { + "registration_id": string; + "verification_code": string; +}; + +export type LoginCompleteResponse = { + "audience": "user"; + "credits": CreditSummary; + "session_expires_at": string; + "status": "authenticated"; + "user": AuthenticatedUser; +}; + +export type LoginSendRequest = { + "email": string; +}; + +export type LogoutHeaders = { + "idempotency-key": string; + "x-csrf-token": string; +}; + +export type LogoutResponse = { + "status": "logged_out"; +}; + export type ModelConfigSseEvent = { "config_set_version": number; "entity_ref": string; @@ -170,7 +196,7 @@ export type SseEvent = { "runtime_availability_version": number; }; -export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE"; +export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID"; export type StateSseEvent = { "entity_ref": string; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cf3cf82..75d77c7 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { ToolchainProbe } from "./toolchain-probe.js"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; +import { UserAuthPage } from "./user-auth.js"; const root = document.getElementById("root"); @@ -13,8 +13,18 @@ if (!root) { // Cache failure leaves public assets network-backed and must not create alternate persistence. void registerPublicAssetServiceWorker().catch(() => undefined); -createRoot(root).render( - - - , -); +const appRoot = createRoot(root); +let authRevision = 0; + +function renderAuthenticationEntry() { + authRevision += 1; + appRoot.render( + + + , + ); +} + +// Any authenticated surface can dispatch this after a revoked/invalid session response. +window.addEventListener("dada:session-invalid", renderAuthenticationEntry); +renderAuthenticationEntry(); diff --git a/apps/web/src/user-auth.css b/apps/web/src/user-auth.css new file mode 100644 index 0000000..f5afd8b --- /dev/null +++ b/apps/web/src/user-auth.css @@ -0,0 +1,325 @@ +:root { + color: #111111; + background: #f6f6f4; + font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif; + font-synthesis: none; + letter-spacing: 0; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + background: #f6f6f4; +} + +button, +input { + font: inherit; + letter-spacing: 0; +} + +button, +a, +input { + outline-offset: 3px; +} + +.auth-page { + min-height: 100vh; + display: grid; + grid-template-rows: 230px minmax(520px, 1fr) 48px; +} + +.auth-art { + position: relative; + display: grid; + grid-template-columns: minmax(320px, 30%) 1fr; + overflow: hidden; + border-bottom: 1px solid #c8c8c3; + background: #eeeee9; +} + +.auth-wordmark { + display: flex; + align-items: center; + padding: 0 48px; + color: #111111; + background: #f2f500; + font-family: Arial Black, "Segoe UI", sans-serif; + font-size: 96px; + font-weight: 900; + line-height: 1; +} + +.auth-art-copy { + z-index: 2; + display: flex; + flex-direction: column; + justify-content: center; + gap: 54px; + padding: 26px 54px 20px; +} + +.auth-art-copy span { + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.auth-art-copy strong { + max-width: 520px; + font-size: 27px; + line-height: 1.2; +} + +.auth-art-block { + position: absolute; + top: 90px; + right: 16%; + width: 31%; + height: 92px; + background: #b9bab4; +} + +.auth-art-line { + position: absolute; + right: 4%; + bottom: 38px; + width: 48%; + height: 48px; + border-right: 1px solid #111111; + border-bottom: 1px solid #111111; + background: #f2f500; +} + +.auth-content { + position: relative; + width: min(1180px, 100%); + margin: 0 auto; + padding: 32px 28px 64px; +} + +.auth-admin-link { + position: absolute; + top: 34px; + right: 30px; + color: #333333; + font-size: 13px; + text-decoration-thickness: 1px; + text-underline-offset: 4px; +} + +.auth-panel { + width: min(480px, 100%); + margin-left: 84px; +} + +.auth-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + width: 100%; + height: 50px; + border: 1px solid #8a8a86; +} + +.auth-tab { + border: 0; + color: #222222; + background: #f6f6f4; + font-weight: 700; + cursor: pointer; +} + +.auth-tab + .auth-tab { + border-left: 1px solid #8a8a86; +} + +.auth-tab[aria-selected="true"] { + background: #f2f500; +} + +.auth-form { + display: flex; + flex-direction: column; + min-height: 350px; + padding-top: 20px; +} + +.auth-form h1 { + margin: 0 0 20px; + font-size: 27px; + line-height: 1.25; +} + +.auth-form label { + margin: 0 0 7px; + font-size: 13px; + font-weight: 700; +} + +.auth-form input { + width: 100%; + height: 46px; + margin-bottom: 16px; + border: 1px solid #777773; + border-radius: 0; + padding: 0 13px; + color: #111111; + background: #ffffff; +} + +.auth-form input:focus { + border-color: #111111; + outline: 2px solid #f2f500; +} + +.auth-code-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 142px; + gap: 10px; +} + +.auth-code-row input { + margin-bottom: 0; +} + +.auth-secondary, +.auth-primary { + height: 46px; + border: 1px solid #777773; + border-radius: 0; + font-weight: 700; +} + +.auth-secondary { + background: #ffffff; +} + +.auth-primary { + width: 100%; + margin-top: 14px; + color: #111111; + background: #f2f500; +} + +.auth-secondary:not(:disabled), +.auth-primary:not(:disabled) { + cursor: pointer; +} + +.auth-secondary:disabled, +.auth-primary:disabled { + color: #777773; + background: #deded9; +} + +.auth-status, +.auth-error { + min-height: 20px; + margin: 10px 0 0; + font-size: 13px; + line-height: 1.5; +} + +.auth-status { + color: #3c5b32; +} + +.auth-error { + border-left: 4px solid #c7432f; + padding: 8px 10px; + color: #8d281b; + background: #fff0ed; +} + +.auth-local-notice { + display: grid; + place-items: center; + min-width: 0; + padding: 0 18px; + color: #f2f500; + background: #111111; + font-size: 12px; + font-weight: 700; + text-align: center; +} + +@media (max-width: 760px) { + .auth-page { + grid-template-rows: 150px minmax(560px, 1fr) auto; + } + + .auth-art { + grid-template-columns: 42% 58%; + } + + .auth-wordmark { + padding: 0 18px; + font-size: 48px; + } + + .auth-art-copy { + gap: 30px; + padding: 18px; + } + + .auth-art-copy span { + font-size: 8px; + } + + .auth-art-copy strong { + font-size: 18px; + } + + .auth-art-block { + top: 54px; + right: 8%; + width: 33%; + height: 52px; + } + + .auth-art-line { + right: 2%; + bottom: 20px; + width: 44%; + height: 28px; + } + + .auth-content { + padding: 56px 20px 42px; + } + + .auth-admin-link { + top: 22px; + right: 20px; + } + + .auth-panel { + margin-left: 0; + } + + .auth-code-row { + grid-template-columns: minmax(0, 1fr) 126px; + } + + .auth-local-notice { + min-height: 56px; + padding-block: 12px; + } +} + +@media (max-width: 380px) { + .auth-code-row { + grid-template-columns: 1fr; + } + + .auth-secondary { + width: 100%; + } +} diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx new file mode 100644 index 0000000..2db1c6e --- /dev/null +++ b/apps/web/src/user-auth.tsx @@ -0,0 +1,267 @@ +import { useEffect, useId, useRef, useState, type FormEvent } from "react"; + +import "./user-auth.css"; + +type AuthMode = "login" | "register"; +type SendState = "idle" | "sending" | "sent" | "error"; + +interface ErrorEnvelopeBody { + error?: { + details?: { field_errors?: Array<{ message_key?: string }> }; + message_key?: string; + }; +} + +const messageByKey: Record = { + "auth.account.suspended": "账号已暂停,请联系管理员。", + "auth.challenge.expired": "验证码已过期,请重新获取。", + "auth.challenge.invalid": "验证码不正确,请检查后重试。", + "auth.challenge.resend_too_soon": "请等待倒计时结束后重新获取验证码。", + "auth.challenge.too_many_attempts": "尝试次数过多,请稍后再试。", + "auth.login.admin_required": "此邮箱需从管理员登录入口进入。", + "auth.login.registration_required": "该邮箱尚未注册,请切换到注册。", + "auth.service.unavailable": "邮件服务暂时不可用,请稍后重试。", +}; + +function errorMessage(body: ErrorEnvelopeBody) { + const key = body.error?.details?.field_errors?.[0]?.message_key ?? body.error?.message_key; + return key ? (messageByKey[key] ?? "请求未完成,请检查后重试。") : "请求未完成,请检查后重试。"; +} + +function maskedEmail(email: string) { + const [local = "", domain = ""] = email.split("@", 2); + const visible = local.slice(0, Math.min(2, local.length)); + return `${visible}${"*".repeat(Math.max(3, local.length - visible.length))}@${domain}`; +} + +export function UserAuthPage() { + const emailId = useId(); + const codeId = useId(); + const inviteId = useId(); + const loginTab = useRef(null); + const registerTab = useRef(null); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [inviteCode, setInviteCode] = useState(""); + const [registrationId, setRegistrationId] = useState(); + const [sendState, setSendState] = useState("idle"); + const [countdown, setCountdown] = useState(0); + const [error, setError] = useState(); + const [submitting, setSubmitting] = useState(false); + const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + + useEffect(() => { + if (countdown <= 0) return; + const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000); + return () => window.clearInterval(timer); + }, [countdown]); + + function switchMode(nextMode: AuthMode) { + if (nextMode === mode) return; + setMode(nextMode); + setCode(""); + setInviteCode(""); + setRegistrationId(undefined); + setSendState("idle"); + setCountdown(0); + setError(undefined); + } + + function switchTab(nextMode: AuthMode) { + switchMode(nextMode); + window.requestAnimationFrame(() => (nextMode === "login" ? loginTab : registerTab).current?.focus()); + } + + async function sendCode() { + if (!emailValid || sendState === "sending" || countdown > 0) return; + setSendState("sending"); + setError(undefined); + const endpoint = mode === "login" ? "/api/v1/auth/login/send" : "/api/v1/auth/register/send"; + const payload = mode === "login" ? { email } : { email, invite_code: inviteCode }; + try { + const response = await fetch(endpoint, { + body: JSON.stringify(payload), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string }; + if (!response.ok || !body.registration_id) { + setSendState("error"); + setError(errorMessage(body)); + return; + } + setRegistrationId(body.registration_id); + setSendState("sent"); + setCountdown(60); + } catch { + setSendState("error"); + setError(messageByKey["auth.service.unavailable"]); + } + } + + async function login(event: FormEvent) { + event.preventDefault(); + if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/auth/login/complete", { + body: JSON.stringify({ registration_id: registrationId, verification_code: code }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(errorMessage(body)); + return; + } + window.location.assign("/app"); + } catch { + setError("登录请求未完成,请重试。"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+
DADA
+
+ LOCAL CREATIVE SYSTEM / WINDOWS P0-A + 在本机展开你的创作 +
+
+ +
+ 管理员登录 +
+
+ + +
+ + {mode === "login" ? ( +
+

邮箱验证码登录

+ + setEmail(event.target.value)} + placeholder="请输入邮箱" + type="email" + value={email} + /> + +
+ setCode(event.target.value.replace(/\D/g, ""))} + placeholder="6 位验证码" + value={code} + /> + +
+ {sendState === "sent" ? ( +

验证码已发送至 {maskedEmail(email)}

+ ) : null} + {error ?

{error}

: null} + +
+ ) : ( +
+

邀请码注册

+ + setInviteCode(event.target.value)} + value={inviteCode} + /> + + setEmail(event.target.value)} + type="email" + value={email} + /> + + {error ?

{error}

: null} +
+ )} +
+
+ +
+ 测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。 +
+
+ ); +} diff --git a/openapi/openapi.json b/openapi/openapi.json index 67d243c..9b33945 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -639,6 +639,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -929,6 +947,110 @@ } ] }, + "LoginCompleteRequest": { + "additionalProperties": false, + "properties": { + "registration_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "verification_code": { + "pattern": "^[0-9]{6}$", + "type": "string" + } + }, + "required": [ + "registration_id", + "verification_code" + ], + "type": "object" + }, + "LoginCompleteResponse": { + "additionalProperties": false, + "properties": { + "audience": { + "enum": [ + "user" + ], + "type": "string" + }, + "credits": { + "$ref": "#/components/schemas/CreditSummary" + }, + "session_expires_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "status": { + "enum": [ + "authenticated" + ], + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "audience", + "credits", + "session_expires_at", + "status", + "user" + ], + "type": "object" + }, + "LoginSendRequest": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 320, + "pattern": "^[^@\\s]{1,128}@[^@\\s]{1,190}$", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "LogoutHeaders": { + "additionalProperties": true, + "properties": { + "idempotency-key": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "x-csrf-token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "idempotency-key", + "x-csrf-token" + ], + "type": "object" + }, + "LogoutResponse": { + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "logged_out" + ], + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, "ModelConfigSseEvent": { "additionalProperties": false, "properties": { @@ -1336,6 +1458,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -1424,6 +1564,243 @@ }, "openapi": "3.1.0", "paths": { + "/api/v1/auth/login/complete": { + "post": { + "operationId": "completeLogin", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginCompleteResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/auth/login/send": { + "post": { + "operationId": "sendLoginCode", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginSendRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationSendResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "operationId": "logoutUser", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogoutResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Authentication" + ] + } + }, "/api/v1/auth/register/complete": { "post": { "operationId": "completeRegistration", @@ -1541,6 +1918,16 @@ }, "description": "Default Response" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, "503": { "content": { "application/json": { @@ -1869,6 +2256,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -2333,6 +2738,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, @@ -2838,6 +3261,24 @@ "AUTH_SERVICE_UNAVAILABLE" ], "type": "string" + }, + { + "enum": [ + "AUTH_ENTRY_REJECTED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_RATE_LIMITED" + ], + "type": "string" + }, + { + "enum": [ + "AUTH_CSRF_INVALID" + ], + "type": "string" } ] }, diff --git a/package.json b/package.json index 0420146..dba05c9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -41,7 +41,9 @@ "test:wp0-09": "node scripts/run-wp0-09-validation.mjs", "test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red", "test:wp1-01": "node scripts/run-wp1-01-validation.mjs", - "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red" + "test:wp1-01:red": "node scripts/run-wp1-01-validation.mjs --phase red", + "test:wp1-02": "node scripts/run-wp1-02-validation.mjs", + "test:wp1-02:red": "node scripts/run-wp1-02-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/api.ts b/packages/shared-contracts/src/api.ts index dab1ed8..59f76b7 100644 --- a/packages/shared-contracts/src/api.ts +++ b/packages/shared-contracts/src/api.ts @@ -19,6 +19,9 @@ export const stableEngineeringErrors = { IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" }, AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" }, AUTH_SERVICE_UNAVAILABLE: { httpStatus: 503, messageKey: "auth.service.unavailable" }, + AUTH_ENTRY_REJECTED: { httpStatus: 409, messageKey: "auth.entry.rejected" }, + AUTH_RATE_LIMITED: { httpStatus: 429, messageKey: "auth.rate_limited" }, + AUTH_CSRF_INVALID: { httpStatus: 403, messageKey: "auth.csrf.invalid" }, } as const; export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors; @@ -55,6 +58,9 @@ export const StableEngineeringErrorCodeSchema = Type.Union( Type.Literal("IDEMPOTENCY_KEY_CONFLICT"), Type.Literal("AUTH_SESSION_INVALID"), Type.Literal("AUTH_SERVICE_UNAVAILABLE"), + Type.Literal("AUTH_ENTRY_REJECTED"), + Type.Literal("AUTH_RATE_LIMITED"), + Type.Literal("AUTH_CSRF_INVALID"), ], { $id: "StableEngineeringErrorCode" }, ); diff --git a/packages/shared-contracts/src/auth.ts b/packages/shared-contracts/src/auth.ts index abe54d4..a01a909 100644 --- a/packages/shared-contracts/src/auth.ts +++ b/packages/shared-contracts/src/auth.ts @@ -82,8 +82,49 @@ export const UserSessionResponseSchema = Type.Object( { additionalProperties: false, $id: "UserSessionResponse" }, ); +export const LoginSendRequestSchema = Type.Object( + { + email: Type.String({ maxLength: 320, pattern: emailPattern }), + }, + { additionalProperties: false, $id: "LoginSendRequest" }, +); + +export const LoginCompleteRequestSchema = Type.Object( + { + registration_id: Type.String({ pattern: uuidPattern }), + verification_code: Type.String({ pattern: "^[0-9]{6}$" }), + }, + { additionalProperties: false, $id: "LoginCompleteRequest" }, +); + +export const LoginCompleteResponseSchema = Type.Object( + { + audience: Type.Literal("user"), + credits: Type.Ref(CreditSummarySchema), + session_expires_at: Type.String({ pattern: isoTimestampPattern }), + status: Type.Literal("authenticated"), + user: Type.Ref(AuthenticatedUserSchema), + }, + { additionalProperties: false, $id: "LoginCompleteResponse" }, +); + +export const LogoutHeadersSchema = Type.Object( + { + "idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }), + "x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), + }, + { additionalProperties: true, $id: "LogoutHeaders" }, +); + +export const LogoutResponseSchema = Type.Object( + { status: Type.Literal("logged_out") }, + { additionalProperties: false, $id: "LogoutResponse" }, +); + export type RegistrationSendRequest = Static; export type RegistrationSendResponse = Static; export type RegistrationCompleteRequest = Static; export type RegistrationCompleteResponse = Static; export type UserSessionResponse = Static; +export type LoginSendRequest = Static; +export type LoginCompleteRequest = Static; diff --git a/scripts/run-wp1-02-validation.mjs b/scripts/run-wp1-02-validation.mjs new file mode 100644 index 0000000..f45dab1 --- /dev/null +++ b/scripts/run-wp1-02-validation.mjs @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp1-02-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const cases = [ + "TDD-WP1-AUTH-003-entry-state-matrix", + "TDD-WP1-AUTH-004-challenge-guards", + "TDD-WP1-AUTH-004-session-revocation", +]; +const directories = Object.fromEntries(cases.map((id) => [id, resolve(runDirectory, "cases", id)])); +const playwrightDirectory = resolve(runDirectory, "playwright"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +for (const directory of Object.values(directories)) mkdirSync(directory, { recursive: true }); + +const commandsToRun = phase === "red" + ? [ + ["pnpm exec vitest run tests/integration/wp1-02-auth-state.test.ts", ["exec", "vitest", "run", "tests/integration/wp1-02-auth-state.test.ts"]], + ["pnpm exec vitest run tests/api/wp1-02-login-session.test.ts", ["exec", "vitest", "run", "tests/api/wp1-02-login-session.test.ts"]], + ["pnpm exec playwright test tests/e2e/user-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts --config playwright.config.ts", ["exec", "playwright", "test", "tests/e2e/user-auth.spec.ts", "tests/e2e/entry-state-ui.spec.ts", "tests/e2e/session-invalid-ui.spec.ts", "--config", "playwright.config.ts"]], + ] + : [ + ["pnpm test:api", ["test:api"]], + ["pnpm test:e2e", ["test:e2e"]], + ["pnpm test:integration", ["test:integration"]], + ["pnpm validate:tdd-trace", ["validate:tdd-trace"]], + ]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_AUTH_GUARDS: directories[cases[1]], + DADA_EVIDENCE_DIR_AUTH_MATRIX: directories[cases[0]], + DADA_EVIDENCE_DIR_AUTH_REVOKE: directories[cases[2]], + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, +}; +const startedAt = new Date().toISOString(); +const commandResults = []; +for (const [command, args] of commandsToRun) { + const started_at = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${args.join(" ")}`] : args; + const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commandResults.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +function find(root, name) { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((entry) => { + const child = resolve(root, entry); + return statSync(child).isDirectory() ? find(child, name) : entry === name ? [child] : []; + }); +} +if (phase === "green") { + const traces = find(playwrightDirectory, "trace.zip"); + const entryTrace = traces.find((path) => path.replaceAll("\\", "/").includes("entry-state-ui")); + const revocationTrace = traces.find((path) => path.replaceAll("\\", "/").includes("session-invalid-ui")); + if (entryTrace) copyFileSync(entryTrace, resolve(directories[cases[0]], "trace.zip")); + if (revocationTrace) copyFileSync(revocationTrace, resolve(directories[cases[2]], "trace.zip")); +} + +const commands = { commands: commandResults, phase, run_id: runId, schema_version: "1.0" }; +for (const directory of Object.values(directories)) writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify(commands, null, 2)}\n`); +const expectedEvidence = { + [cases[0]]: ["response.json", "db-diff.json", "trace.zip", "screenshots/entry-state.png"], + [cases[1]]: ["response.json", "db-diff.json", "external-calls.json"], + [cases[2]]: ["response.json", "db-diff.json", "trace.zip", "screenshots/revoked.png"], +}; +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commandState = phase === "red" ? commandResults.every((item) => item.exit_code !== 0) : commandResults.every((item) => item.exit_code === 0); +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const worktreeDirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const results = cases.map((testId) => { + const evidence_refs = expectedEvidence[testId]; + const missing_evidence = phase === "green" ? evidence_refs.filter((path) => !existsSync(resolve(directories[testId], path))) : []; + const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missing_evidence.length === 0 ? "passed" : "failed"); + const result = { + acceptance_criteria: ["AC-22", "AC-33", "AC-49"], automation: ["automated"], commit, + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, evidence_refs, + finished_at: new Date().toISOString(), layer: ["API", "E2E", "DB"], manifest, missing_evidence, + parent_family: testId.match(/^(TDD-WP[0-7]-[A-Z0-9]+-[0-9]{3})-/)?.[1], phase, + release_gate: ["work_package:WP-1", "release:P0-A"], + requirements: ["AUTH-01", "AUTH-02", "AUTH-04", "AUTH-05", "AUTH-06", "AUTH-07"], + run_id: runId, schema_version: "1.0", started_at: startedAt, status, + task_id: "TASK-WP1-02", test_id: testId, work_package: "WP-1", + worktree_under_test: worktreeDirty ? "uncommitted implementation" : "clean committed implementation", + }; + writeFileSync(resolve(directories[testId], "result.json"), `${JSON.stringify(result, null, 2)}\n`); + return result; +}); +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const passed = results.every((result) => result.status === targetStatus); +const summary = { cases: results.map(({ missing_evidence, status, test_id }) => ({ missing_evidence, status, test_id })), phase, run_id: runId, status: passed ? targetStatus : "failed" }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (!passed) process.exit(1); diff --git a/tests/api/wp0-02-schema-envelope.test.ts b/tests/api/wp0-02-schema-envelope.test.ts index 84fe072..bf224a3 100644 --- a/tests/api/wp0-02-schema-envelope.test.ts +++ b/tests/api/wp0-02-schema-envelope.test.ts @@ -112,6 +112,9 @@ describe("TDD-WP0-API-001 schema envelope", () => { expect(Object.keys(stableEngineeringErrors).sort()).toEqual([ "ASSET_CLEANUP_CANDIDATE_STALE", "ASSET_HISTORY_REFERENCE_CONFLICT", + "AUTH_CSRF_INVALID", + "AUTH_ENTRY_REJECTED", + "AUTH_RATE_LIMITED", "AUTH_SERVICE_UNAVAILABLE", "AUTH_SESSION_INVALID", "BROWSER_UNSUPPORTED", diff --git a/tests/api/wp1-02-login-session.test.ts b/tests/api/wp1-02-login-session.test.ts new file mode 100644 index 0000000..d36096c --- /dev/null +++ b/tests/api/wp1-02-login-session.test.ts @@ -0,0 +1,145 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const roots: string[] = []; +const services: RegistrationService[] = []; +const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +function harness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-api-")); + roots.push(root); + const resend = new MockResendAdapter(); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x61), + codeGenerator: () => "621904", + currentPrivacyNoticeVersion: "p0a-notice-v1", + databasePath: join(root, "dada.sqlite3"), + invitePepper: Buffer.alloc(32, 0x62), + resend, + sessionPepper: Buffer.alloc(32, 0x63), + }); + services.push(registration); + return { registration, resend }; +} + +function seedUser(registration: RegistrationService, email: string, status: "active" | "suspended" = "active") { + const userId = randomUUID(); + registration.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'user', ?, 1, ?, ?) + `).run(userId, email, status, randomUUID(), Date.now()); + registration.database.prepare(` + INSERT INTO user_profiles ( + user_id, creator_name, social_id, private_content_notice_version, + private_content_notice_acknowledged_at + ) VALUES (?, 'API User', '@api_user', NULL, NULL) + `).run(userId); + registration.database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, Date.now()); + return userId; +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TASK-WP1-02 login and session API", () => { + it("logs an active user in, issues CSRF, and revokes all sessions on logout", async () => { + const { registration, resend } = harness(); + const userId = seedUser(registration, "login-api@example.invalid"); + registration.issueAuthenticatedSession(userId, "user"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + + const sent = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "login-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(sent.statusCode).toBe(200); + const flowCookie = sent.headers["set-cookie"]; + + const completed = await app.inject({ + headers: { ...writeHeaders, cookie: flowCookie, "idempotency-key": "wp1-02-api-login-complete-000000001" }, + method: "POST", + payload: { + registration_id: sent.json().registration_id, + verification_code: resend.readLatestCode("login-api@example.invalid"), + }, + url: "/api/v1/auth/login/complete", + }); + expect(completed.statusCode).toBe(200); + expect(completed.json()).toMatchObject({ audience: "user", status: "authenticated" }); + const sessionCookie = completed.headers["set-cookie"]; + + const session = await app.inject({ + headers: { cookie: sessionCookie, host: writeHeaders.host }, + method: "GET", + url: "/api/v1/auth/session", + }); + expect(session.statusCode).toBe(200); + expect(session.json().csrf_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + + const logout = await app.inject({ + headers: { + ...writeHeaders, + cookie: sessionCookie, + "idempotency-key": "wp1-02-api-logout-00000000000001", + "x-csrf-token": session.json().csrf_token, + }, + method: "POST", + url: "/api/v1/auth/logout", + }); + expect(logout.statusCode).toBe(200); + expect(logout.json()).toEqual({ status: "logged_out" }); + + const revoked = await app.inject({ + headers: { cookie: sessionCookie, host: writeHeaders.host }, + method: "GET", + url: "/api/v1/auth/session", + }); + expect(revoked.statusCode).toBe(401); + await app.close(); + }); + + it("returns stable entry-state and 429 errors without switching flows", async () => { + const { registration } = harness(); + seedUser(registration, "suspended-api@example.invalid", "suspended"); + const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration }); + const suspended = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "suspended-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(suspended.statusCode).toBe(409); + expect(suspended.json()).toMatchObject({ + error: { code: "AUTH_ENTRY_REJECTED", details: { field_errors: [{ message_key: "auth.account.suspended" }] } }, + }); + + const missing = await app.inject({ + headers: writeHeaders, + method: "POST", + payload: { email: "missing-api@example.invalid" }, + url: "/api/v1/auth/login/send", + }); + expect(missing.statusCode).toBe(409); + expect(missing.json()).toMatchObject({ + error: { details: { field_errors: [{ message_key: "auth.login.registration_required" }] } }, + }); + await app.close(); + }); +}); diff --git a/tests/e2e/entry-state-ui.spec.ts b/tests/e2e/entry-state-ui.spec.ts new file mode 100644 index 0000000..87f6f16 --- /dev/null +++ b/tests/e2e/entry-state-ui.spec.ts @@ -0,0 +1,40 @@ +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-003 keeps registration and login as separate keyboard entries", async ({ page }) => { + await page.goto(webUrl); + const loginTab = page.getByRole("tab", { name: "登录" }); + const registerTab = page.getByRole("tab", { name: "注册" }); + + await expect(loginTab).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0); + await loginTab.focus(); + await page.keyboard.press("ArrowRight"); + await expect(registerTab).toBeFocused(); + await expect(registerTab).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("textbox", { name: "邀请码" })).toBeVisible(); + await page.keyboard.press("ArrowLeft"); + await expect(loginTab).toBeFocused(); + await expect(page.getByRole("textbox", { name: "邀请码" })).toHaveCount(0); + await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin"); + await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); +}); diff --git a/tests/e2e/session-invalid-ui.spec.ts b/tests/e2e/session-invalid-ui.spec.ts new file mode 100644 index 0000000..a68b06a --- /dev/null +++ b/tests/e2e/session-invalid-ui.spec.ts @@ -0,0 +1,43 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-004 purges the opened surface on session invalidation", async ({ page }) => { + await page.goto(webUrl); + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + const email = page.getByLabel("邮箱"); + await email.fill("private-local-state"); + await expect(email).toHaveValue("private-local-state"); + + await page.evaluate(() => { + window.dispatchEvent(new Event("dada:session-invalid")); + }); + + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + await expect(page.getByLabel("邮箱")).toHaveValue(""); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_REVOKE; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "revoked.png") }); + } +}); diff --git a/tests/e2e/user-auth.spec.ts b/tests/e2e/user-auth.spec.ts new file mode 100644 index 0000000..5bb626d --- /dev/null +++ b/tests/e2e/user-auth.spec.ts @@ -0,0 +1,88 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; + +// Raw traces for these mocked auth requests would retain the submitted email. +test.use({ trace: "off" }); + +test.beforeAll(async () => { + vite = await createServer({ + configFile: resolve("apps/web/vite.config.ts"), + root: resolve("apps/web"), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP1-AUTH-003 renders Z0pf8 login states without silently switching entry", async ({ page }) => { + await page.route("**/api/v1/auth/login/send", async (route) => { + await route.fulfill({ + contentType: "application/json", + status: 409, + body: JSON.stringify({ + error: { + code: "AUTH_ENTRY_REJECTED", + correlation_id: "00000000-0000-4000-8000-000000000001", + details: { field_errors: [{ field: "email", message_key: "auth.account.suspended" }] }, + message_key: "auth.entry.rejected", + }, + }), + }); + }); + await page.goto(webUrl); + await expect(page.getByRole("heading", { name: "邮箱验证码登录" })).toBeVisible(); + await expect(page.getByText("测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。")).toBeVisible(); + await expect(page.getByRole("link", { name: "管理员登录" })).toHaveAttribute("href", "/admin"); + await expect(page.getByLabel("邀请码")).toHaveCount(0); + await expect(page.getByLabel("创作署名")).toHaveCount(0); + await expect(page.getByLabel("社交 ID")).toHaveCount(0); + + await page.getByRole("tab", { name: "登录" }).focus(); + await page.keyboard.press("Tab"); + await expect(page.getByLabel("邮箱")).toBeFocused(); + await page.getByLabel("邮箱").fill("suspended@example.invalid"); + await page.getByRole("button", { name: "获取验证码" }).click(); + await expect(page.getByRole("alert")).toContainText("账号已暂停"); + await expect(page.getByRole("tab", { name: "登录" })).toHaveAttribute("aria-selected", "true"); + await page.getByLabel("邮箱").fill(""); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_AUTH_MATRIX; + if (evidenceDirectory) { + mkdirSync(resolve(evidenceDirectory, "screenshots"), { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(evidenceDirectory, "screenshots", "entry-state.png") }); + } +}); + +test("TDD-WP1-AUTH-004 login controls remain stable on mobile and loading states", async ({ page }) => { + await page.setViewportSize({ height: 844, width: 390 }); + await page.route("**/api/v1/auth/login/send", async (route) => { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + await route.fulfill({ + contentType: "application/json", + status: 200, + body: JSON.stringify({ + challenge_expires_at: "2026-07-28T08:10:00.000Z", + registration_id: "00000000-0000-4000-8000-000000000002", + resend_available_at: "2026-07-28T08:01:00.000Z", + status: "verification_sent", + }), + }); + }); + await page.goto(webUrl); + await page.getByLabel("邮箱").fill("mobile@example.invalid"); + const send = page.getByRole("button", { name: "获取验证码" }); + await send.click(); + await expect(page.getByRole("button", { name: "发送中" })).toBeDisabled(); + await expect(page.getByText(/验证码已发送至/)).toBeVisible(); + await expect(page.locator("body")).not.toHaveCSS("overflow-x", "scroll"); +}); diff --git a/tests/integration/wp1-02-auth-state.test.ts b/tests/integration/wp1-02-auth-state.test.ts new file mode 100644 index 0000000..b59b499 --- /dev/null +++ b/tests/integration/wp1-02-auth-state.test.ts @@ -0,0 +1,283 @@ +import { createRequire } from "node:module"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { RegistrationError, RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url)); +const Database = requireFromApi("better-sqlite3"); +const roots: string[] = []; +const services: RegistrationService[] = []; + +function createHarness() { + const root = mkdtempSync(join(tmpdir(), "dada-wp1-02-")); + roots.push(root); + const databasePath = join(root, "dada.sqlite3"); + const resend = new MockResendAdapter(); + let now = Date.parse("2026-07-28T08:00:00.000Z"); + let inviteSequence = 0; + let codeSequence = 100_000; + const service = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x51), + clock: () => now, + codeGenerator: () => String(codeSequence++), + currentPrivacyNoticeVersion: "p0a-notice-v1", + databasePath, + inviteCodeGenerator: () => `DADA-WP1-02-${String(inviteSequence++).padStart(3, "0")}`, + invitePepper: Buffer.alloc(32, 0x52), + resend, + sessionPepper: Buffer.alloc(32, 0x53), + }); + services.push(service); + return { + advance(milliseconds: number) { now += milliseconds; }, + databasePath, + now: () => now, + resend, + service, + }; +} + +function withDatabase(databasePath: string, operation: (database: any) => T): T { + const database = new Database(databasePath); + try { return operation(database); } finally { database.close(); } +} + +function seedUser( + databasePath: string, + input: { email: string; role?: "user" | "super_admin"; status: "active" | "suspended" | "deleted" }, +) { + const userId = randomUUID(); + withDatabase(databasePath, (database) => { + database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(userId, input.email, input.role ?? "user", input.status, input.role === "super_admin" ? 0 : 1, randomUUID(), Date.now()); + database.prepare(` + INSERT INTO user_profiles ( + user_id, creator_name, social_id, private_content_notice_version, + private_content_notice_acknowledged_at + ) VALUES (?, ?, ?, NULL, NULL) + `).run(userId, "Fixture User", "@fixture_user"); + database.prepare(` + INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) + VALUES (?, 10, 0, ?) + `).run(userId, Date.now()); + if (input.role === "super_admin") { + database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + } + }); + return userId; +} + +function writeEvidence(environmentName: string, file: string, value: unknown) { + const directory = process.env[environmentName]; + if (!directory) return; + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const service of services.splice(0)) service.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TDD-WP1-AUTH-003-entry-state-matrix", () => { + it("keeps register and login entry behavior separate for every account state", async () => { + const harness = createHarness(); + const invite = harness.service.createInvite({ expiresAt: harness.now() + 86_400_000, maxUses: 4 }); + seedUser(harness.databasePath, { email: "active@example.invalid", status: "active" }); + seedUser(harness.databasePath, { email: "deleted@example.invalid", status: "deleted" }); + seedUser(harness.databasePath, { email: "suspended@example.invalid", status: "suspended" }); + + const registrationResults: Record = {}; + for (const state of ["unregistered", "active", "deleted", "suspended"] as const) { + const email = state === "unregistered" ? "new@example.invalid" : `${state}@example.invalid`; + try { + const sent = await harness.service.sendRegistrationCode({ email, inviteCode: invite.code }); + registrationResults[state] = sent.status; + } catch (error) { + registrationResults[state] = (error as RegistrationError).reason; + } + harness.advance(61_000); + } + expect(registrationResults).toEqual({ + active: "registration_login_required", + deleted: "verification_sent", + suspended: "account_suspended", + unregistered: "verification_sent", + }); + + const loginResults: Record = {}; + for (const state of ["unregistered", "active", "deleted", "suspended"] as const) { + const email = state === "unregistered" ? "missing@example.invalid" : `${state}@example.invalid`; + try { + const sent = await harness.service.sendLoginCode({ clientKey: `matrix-${state}`, email }); + loginResults[state] = sent.status; + if (state === "active") { + const completed = harness.service.completeLogin({ + clientKey: `matrix-${state}`, + code: harness.resend.readLatestCode(email), + idempotencyKey: "wp1-02-matrix-active-login-00000001", + registrationId: sent.registrationId, + }); + expect(harness.service.readUserSession(completed.sessionToken)?.audience).toBe("user"); + } + } catch (error) { + loginResults[state] = (error as RegistrationError).reason; + } + harness.advance(61_000); + } + expect(loginResults).toEqual({ + active: "verification_sent", + deleted: "login_registration_required", + suspended: "account_suspended", + unregistered: "login_registration_required", + }); + + const deletedRows = withDatabase(harness.databasePath, (database) => database.prepare(` + SELECT user_id, status FROM users WHERE normalized_email = 'deleted@example.invalid' ORDER BY created_at + `).all()); + expect(deletedRows).toHaveLength(1); + expect(deletedRows[0].status).toBe("deleted"); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "response.json", { login: loginResults, registration: registrationResults }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_MATRIX", "db-diff.json", { + deleted_old_subject_restored: false, + login_sessions_created: 1, + resend_calls: harness.resend.calls.length, + }); + }); +}); + +describe("TDD-WP1-AUTH-004-challenge-guards", () => { + it("enforces one use, ten minutes, sixty seconds and consecutive-failure limiting", async () => { + const harness = createHarness(); + seedUser(harness.databasePath, { email: "guard@example.invalid", status: "active" }); + + const first = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" }); + const firstCode = harness.resend.readLatestCode("guard@example.invalid"); + const loggedIn = harness.service.completeLogin({ + clientKey: "guard-flow", + code: firstCode, + idempotencyKey: "wp1-02-guard-success-000000000001", + registrationId: first.registrationId, + }); + expect(harness.service.readUserSession(loggedIn.sessionToken)).toBeDefined(); + expect(() => harness.service.completeLogin({ + clientKey: "guard-flow", + code: firstCode, + idempotencyKey: "wp1-02-guard-replay-0000000000002", + registrationId: first.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_invalid" })); + + harness.advance(61_000); + const expiring = await harness.service.sendLoginCode({ clientKey: "guard-flow", email: "guard@example.invalid" }); + const expiringCode = harness.resend.readLatestCode("guard@example.invalid"); + harness.advance(600_001); + expect(() => harness.service.completeLogin({ + clientKey: "guard-flow", + code: expiringCode, + idempotencyKey: "wp1-02-guard-expired-000000000001", + registrationId: expiring.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_expired" })); + + const resendBlocked = await harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" }); + await expect(harness.service.sendLoginCode({ clientKey: "resend-flow", email: "guard@example.invalid" })) + .rejects.toMatchObject({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "resend_too_soon" }); + + harness.advance(61_000); + const guarded = await harness.service.sendLoginCode({ clientKey: "failure-flow", email: "guard@example.invalid" }); + for (let attempt = 0; attempt < 4; attempt += 1) { + expect(() => harness.service.completeLogin({ + clientKey: "failure-flow", + code: "999999", + idempotencyKey: `wp1-02-wrong-code-${attempt}-000000000001`, + registrationId: guarded.registrationId, + })).toThrowError(expect.objectContaining({ reason: "challenge_invalid" })); + } + expect(() => harness.service.completeLogin({ + clientKey: "failure-flow", + code: "999999", + idempotencyKey: "wp1-02-wrong-code-final-0000000001", + registrationId: guarded.registrationId, + })).toThrowError(expect.objectContaining({ code: "AUTH_RATE_LIMITED", httpStatus: 429, reason: "too_many_attempts" })); + + const databaseState = withDatabase(harness.databasePath, (database) => ({ + consumed: database.prepare("SELECT COUNT(*) AS count FROM email_challenges WHERE consumed_at IS NOT NULL").get().count, + failed: database.prepare("SELECT MAX(failure_count) AS count FROM email_challenges").get().count, + sessions: database.prepare("SELECT COUNT(*) AS count FROM sessions").get().count, + })); + expect(databaseState).toEqual({ consumed: 1, failed: 5, sessions: 1 }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "response.json", { + expired: "challenge_expired", + replay: "challenge_invalid", + resend: "resend_too_soon", + rate_limit: "too_many_attempts", + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "db-diff.json", databaseState); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_GUARDS", "external-calls.json", { + resend_calls: harness.resend.calls.length, + resend_calls_after_block: harness.resend.calls.length, + blocked_challenge_id: resendBlocked.registrationId, + }); + }); +}); + +describe("TDD-WP1-AUTH-004-session-revocation", () => { + it.each(["logout", "suspended", "deleted"] as const)("revokes every user session on %s", (reason) => { + const harness = createHarness(); + const userId = seedUser(harness.databasePath, { email: `${reason}@example.invalid`, status: "active" }); + const first = harness.service.issueAuthenticatedSession(userId, "user"); + const second = harness.service.issueAuthenticatedSession(userId, "user"); + expect(harness.service.readUserSession(first.sessionToken)).toBeDefined(); + expect(harness.service.readUserSession(second.sessionToken)).toBeDefined(); + + if (reason === "logout") { + const csrf = harness.service.issueUserCsrfToken(first.sessionToken); + harness.service.logoutUser({ csrfToken: csrf, sessionToken: first.sessionToken }); + } else { + harness.service.changeUserStatus(userId, reason); + } + + expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined(); + expect(harness.service.readUserSession(second.sessionToken)).toBeUndefined(); + const state = withDatabase(harness.databasePath, (database) => ({ + revoked: database.prepare("SELECT COUNT(*) AS count FROM sessions WHERE user_id = ? AND revoked_at IS NOT NULL").get(userId).count, + status: database.prepare("SELECT status FROM users WHERE user_id = ?").get(userId).status, + })); + expect(state.revoked).toBe(2); + expect(state.status).toBe(reason === "logout" ? "active" : reason); + }); + + it.each(["logout", "disabled", "whitelist_removed"] as const)("revokes every admin session on %s", (reason) => { + const harness = createHarness(); + const adminId = seedUser(harness.databasePath, { email: `admin-${reason}@example.invalid`, role: "super_admin", status: "active" }); + const first = harness.service.issueAuthenticatedSession(adminId, "admin"); + const second = harness.service.issueAuthenticatedSession(adminId, "admin"); + expect(harness.service.readAdminSession(first.sessionToken)).toBeDefined(); + expect(harness.service.readUserSession(first.sessionToken)).toBeUndefined(); + harness.service.revokeAdminSessions(adminId, reason); + expect(harness.service.readAdminSession(first.sessionToken)).toBeUndefined(); + expect(harness.service.readAdminSession(second.sessionToken)).toBeUndefined(); + expect(harness.service.readAdminSession(harness.service.issueAuthenticatedSession( + seedUser(harness.databasePath, { email: `audience-${reason}@example.invalid`, role: "super_admin", status: "active" }), + "admin", + ).sessionToken)).toBeDefined(); + }); + + it("writes aggregate revocation evidence", () => { + writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "response.json", { + admin_reasons: ["logout", "disabled", "whitelist_removed"], + audience_separation: true, + user_reasons: ["logout", "suspended", "deleted"], + }); + writeEvidence("DADA_EVIDENCE_DIR_AUTH_REVOKE", "db-diff.json", { sessions_revoked_per_subject: 2 }); + }); +});