feat: implement TASK-WP1-02 login sessions
This commit is contained in:
+160
-3
@@ -10,6 +10,11 @@ import {
|
|||||||
ErrorDetailsSchema,
|
ErrorDetailsSchema,
|
||||||
ErrorEnvelopeSchema,
|
ErrorEnvelopeSchema,
|
||||||
GenerationErrorCategorySchema,
|
GenerationErrorCategorySchema,
|
||||||
|
LoginCompleteRequestSchema,
|
||||||
|
LoginCompleteResponseSchema,
|
||||||
|
LoginSendRequestSchema,
|
||||||
|
LogoutHeadersSchema,
|
||||||
|
LogoutResponseSchema,
|
||||||
ModelConfigSseEventSchema,
|
ModelConfigSseEventSchema,
|
||||||
ModelRuntimeSseEventSchema,
|
ModelRuntimeSseEventSchema,
|
||||||
RegistrationCompleteHeadersSchema,
|
RegistrationCompleteHeadersSchema,
|
||||||
@@ -25,6 +30,8 @@ import {
|
|||||||
createErrorEnvelope,
|
createErrorEnvelope,
|
||||||
isCorrelationId,
|
isCorrelationId,
|
||||||
type BootstrapResponse,
|
type BootstrapResponse,
|
||||||
|
type LoginCompleteRequest,
|
||||||
|
type LoginSendRequest,
|
||||||
type RegistrationCompleteRequest,
|
type RegistrationCompleteRequest,
|
||||||
type RegistrationSendRequest,
|
type RegistrationSendRequest,
|
||||||
} from "@dada/shared-contracts";
|
} from "@dada/shared-contracts";
|
||||||
@@ -92,6 +99,8 @@ const contentSecurityPolicy = [
|
|||||||
"base-uri 'none'",
|
"base-uri 'none'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
|
const authFlowCookieName = "dada_auth_flow";
|
||||||
|
const userSessionCookieName = "dada_session";
|
||||||
|
|
||||||
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||||
const header = headers["x-correlation-id"];
|
const header = headers["x-correlation-id"];
|
||||||
@@ -208,6 +217,11 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
RegistrationCompleteRequestSchema,
|
RegistrationCompleteRequestSchema,
|
||||||
RegistrationCompleteHeadersSchema,
|
RegistrationCompleteHeadersSchema,
|
||||||
RegistrationCompleteResponseSchema,
|
RegistrationCompleteResponseSchema,
|
||||||
|
LoginSendRequestSchema,
|
||||||
|
LoginCompleteRequestSchema,
|
||||||
|
LoginCompleteResponseSchema,
|
||||||
|
LogoutHeadersSchema,
|
||||||
|
LogoutResponseSchema,
|
||||||
UserSessionResponseSchema,
|
UserSessionResponseSchema,
|
||||||
BrowserUnsupportedReasonSchema,
|
BrowserUnsupportedReasonSchema,
|
||||||
BrowserSupportRequestSchema,
|
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(
|
app.post(
|
||||||
"/api/v1/auth/register/send",
|
"/api/v1/auth/register/send",
|
||||||
{
|
{
|
||||||
@@ -299,6 +418,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
200: Type.Ref(RegistrationSendResponseSchema),
|
200: Type.Ref(RegistrationSendResponseSchema),
|
||||||
400: Type.Ref(ErrorEnvelopeSchema),
|
400: Type.Ref(ErrorEnvelopeSchema),
|
||||||
409: Type.Ref(ErrorEnvelopeSchema),
|
409: Type.Ref(ErrorEnvelopeSchema),
|
||||||
|
429: Type.Ref(ErrorEnvelopeSchema),
|
||||||
503: Type.Ref(ErrorEnvelopeSchema),
|
503: Type.Ref(ErrorEnvelopeSchema),
|
||||||
},
|
},
|
||||||
tags: ["Authentication"],
|
tags: ["Authentication"],
|
||||||
@@ -361,7 +481,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
reply.header(
|
reply.header(
|
||||||
"Set-Cookie",
|
"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 {
|
return {
|
||||||
credits: {
|
credits: {
|
||||||
@@ -401,7 +521,7 @@ export async function createApp(options: CreateAppOptions = {}) {
|
|||||||
if (!options.registration) {
|
if (!options.registration) {
|
||||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
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;
|
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
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,
|
available_balance: session.credits.availableBalance,
|
||||||
reserved_balance: session.credits.reservedBalance,
|
reserved_balance: session.credits.reservedBalance,
|
||||||
},
|
},
|
||||||
csrf_token: randomBytes(32).toString("base64url"),
|
csrf_token: options.registration.issueUserCsrfToken(token!),
|
||||||
expires_at: new Date(session.expiresAt).toISOString(),
|
expires_at: new Date(session.expiresAt).toISOString(),
|
||||||
user: {
|
user: {
|
||||||
creator_name: session.user.creatorName,
|
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(
|
app.post(
|
||||||
"/api/v1/support/check",
|
"/api/v1/support/check",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,22 +10,42 @@ export type RegistrationErrorReason =
|
|||||||
| "privacy_consent_required"
|
| "privacy_consent_required"
|
||||||
| "privacy_notice_version_invalid"
|
| "privacy_notice_version_invalid"
|
||||||
| "profile_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 =
|
export type RegistrationErrorCode =
|
||||||
| "REGISTRATION_REJECTED"
|
| "REGISTRATION_REJECTED"
|
||||||
| "REGISTRATION_REQUEST_INVALID"
|
| "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 {
|
export class RegistrationError extends Error {
|
||||||
readonly code: RegistrationErrorCode;
|
readonly code: RegistrationErrorCode;
|
||||||
readonly httpStatus: 400 | 409;
|
readonly httpStatus: 400 | 401 | 403 | 409 | 429;
|
||||||
readonly reason: RegistrationErrorReason;
|
readonly reason: RegistrationErrorReason;
|
||||||
|
|
||||||
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
|
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
|
||||||
super(code);
|
super(code);
|
||||||
this.code = 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;
|
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" },
|
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
|
||||||
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
|
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
|
||||||
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
|
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];
|
return entries[reason];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,23 @@ const stageLimit = 10;
|
|||||||
const challengeLifetimeMilliseconds = 10 * 60 * 1_000;
|
const challengeLifetimeMilliseconds = 10 * 60 * 1_000;
|
||||||
const resendDelayMilliseconds = 60 * 1_000;
|
const resendDelayMilliseconds = 60 * 1_000;
|
||||||
const sessionLifetimeMilliseconds = 30 * 24 * 60 * 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 {
|
export interface RegistrationTransactionEvent {
|
||||||
mode: "BEGIN IMMEDIATE";
|
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";
|
outcome: "committed" | "rejected" | "idempotent_replay";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ interface ChallengeRow {
|
|||||||
consumed_at: number | null;
|
consumed_at: number | null;
|
||||||
email: string;
|
email: string;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
invite_id: string;
|
invite_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttemptRow {
|
interface AttemptRow {
|
||||||
@@ -72,6 +85,21 @@ interface UserResultRow {
|
|||||||
user_id: string;
|
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 {
|
export interface RegistrationSendResult {
|
||||||
challengeExpiresAt: number;
|
challengeExpiresAt: number;
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
@@ -111,6 +139,18 @@ export interface UserSessionResult {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginCompleteInput {
|
||||||
|
clientKey: string;
|
||||||
|
code: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
registrationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginCompleteResult = Omit<RegistrationCompleteResult, "status"> & {
|
||||||
|
audience: "user";
|
||||||
|
status: "authenticated";
|
||||||
|
};
|
||||||
|
|
||||||
interface ImmediateResult<T> {
|
interface ImmediateResult<T> {
|
||||||
outcome: RegistrationTransactionEvent["outcome"];
|
outcome: RegistrationTransactionEvent["outcome"];
|
||||||
value: T;
|
value: T;
|
||||||
@@ -210,10 +250,13 @@ export class RegistrationService {
|
|||||||
this.assertInviteAvailable(invite, now);
|
this.assertInviteAvailable(invite, now);
|
||||||
this.assertStageCapacity();
|
this.assertStageCapacity();
|
||||||
const existing = this.database.prepare(`
|
const existing = this.database.prepare(`
|
||||||
SELECT user_id FROM users
|
SELECT user_id, role, status FROM users
|
||||||
WHERE normalized_email = ? AND status <> 'deleted'
|
WHERE normalized_email = ? AND status <> 'deleted'
|
||||||
`).get(email);
|
`).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined;
|
||||||
if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered");
|
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(`
|
this.database.prepare(`
|
||||||
INSERT INTO email_challenges (
|
INSERT INTO email_challenges (
|
||||||
@@ -241,7 +284,7 @@ export class RegistrationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.options.resend.sendRegistrationCode({ challengeId, code, email, purpose: "register" });
|
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
|
||||||
} catch {
|
} catch {
|
||||||
this.runImmediate("registration_send_compensation", () => {
|
this.runImmediate("registration_send_compensation", () => {
|
||||||
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendLoginCode(input: { clientKey: string; email: string }): Promise<RegistrationSendResult> {
|
||||||
|
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<LoginCompleteResult | RegistrationError>("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 {
|
completeRegistration(input: RegistrationCompleteInput): RegistrationCompleteResult {
|
||||||
const creatorName = normalizeProfileValue(input.creatorName, 80);
|
const creatorName = normalizeProfileValue(input.creatorName, 80);
|
||||||
const socialId = normalizeSocialId(input.socialId);
|
const socialId = normalizeSocialId(input.socialId);
|
||||||
@@ -391,6 +571,103 @@ export class RegistrationService {
|
|||||||
return outcome;
|
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 {
|
readUserSession(token: string): UserSessionResult | undefined {
|
||||||
const now = this.options.clock();
|
const now = this.options.clock();
|
||||||
const row = this.database.prepare(`
|
const row = this.database.prepare(`
|
||||||
@@ -484,7 +761,7 @@ export class RegistrationService {
|
|||||||
CREATE TABLE IF NOT EXISTS email_challenges (
|
CREATE TABLE IF NOT EXISTS email_challenges (
|
||||||
challenge_id TEXT PRIMARY KEY,
|
challenge_id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL,
|
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,
|
code_hmac TEXT NOT NULL,
|
||||||
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')),
|
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')),
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
@@ -498,6 +775,7 @@ export class RegistrationService {
|
|||||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||||
audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')),
|
audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')),
|
||||||
token_digest TEXT NOT NULL UNIQUE,
|
token_digest TEXT NOT NULL UNIQUE,
|
||||||
|
csrf_token_digest TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
expires_at INTEGER NOT NULL,
|
expires_at INTEGER NOT NULL,
|
||||||
revoked_at INTEGER
|
revoked_at INTEGER
|
||||||
@@ -512,6 +790,27 @@ export class RegistrationService {
|
|||||||
session_id TEXT REFERENCES sessions(session_id),
|
session_id TEXT REFERENCES sessions(session_id),
|
||||||
created_at INTEGER NOT NULL
|
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");
|
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<RegistrationError> {
|
||||||
|
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() {
|
private stageIsFull() {
|
||||||
const row = this.database.prepare(`
|
const row = this.database.prepare(`
|
||||||
SELECT COUNT(*) AS count FROM users
|
SELECT COUNT(*) AS count FROM users
|
||||||
@@ -605,6 +1015,17 @@ export class RegistrationService {
|
|||||||
return this.result(row);
|
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 {
|
private result(row: UserResultRow): RegistrationCompleteResult {
|
||||||
return {
|
return {
|
||||||
credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance },
|
credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance },
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ export interface RegistrationCodeMessage {
|
|||||||
challengeId: string;
|
challengeId: string;
|
||||||
code: string;
|
code: string;
|
||||||
email: string;
|
email: string;
|
||||||
purpose: "register";
|
purpose: "register" | "login";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResendAdapter {
|
export interface ResendAdapter {
|
||||||
sendRegistrationCode(message: RegistrationCodeMessage): Promise<void>;
|
sendVerificationCode(message: RegistrationCodeMessage): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MockResendAdapter implements ResendAdapter {
|
export class MockResendAdapter implements ResendAdapter {
|
||||||
readonly calls: RegistrationCodeMessage[] = [];
|
readonly calls: RegistrationCodeMessage[] = [];
|
||||||
|
|
||||||
async sendRegistrationCode(message: RegistrationCodeMessage) {
|
async sendVerificationCode(message: RegistrationCodeMessage) {
|
||||||
this.calls.push({ ...message });
|
this.calls.push({ ...message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
// 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; }
|
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<LoginCompleteResponse> {
|
||||||
|
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<LoginCompleteResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
export async function completeRegistration(body: RegistrationCompleteRequest, options: ClientOptions = {}): Promise<RegistrationCompleteResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
@@ -104,6 +113,22 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
|
|||||||
return response.json() as Promise<UserSessionResponse>;
|
return response.json() as Promise<UserSessionResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logoutUser(options: ClientOptions = {}): Promise<LogoutResponse> {
|
||||||
|
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<LogoutResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
|
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<RegistrationSendResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
export async function sendRegistrationCode(body: RegistrationSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
|
||||||
const request = options.fetch ?? globalThis.fetch;
|
const request = options.fetch ?? globalThis.fetch;
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export type ErrorDetails = {
|
|||||||
|
|
||||||
export type ErrorEnvelope = {
|
export type ErrorEnvelope = {
|
||||||
"error": {
|
"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;
|
"correlation_id": string;
|
||||||
"details": {
|
"details": {
|
||||||
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
|
"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 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 = {
|
export type ModelConfigSseEvent = {
|
||||||
"config_set_version": number;
|
"config_set_version": number;
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
@@ -170,7 +196,7 @@ export type SseEvent = {
|
|||||||
"runtime_availability_version": number;
|
"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 = {
|
export type StateSseEvent = {
|
||||||
"entity_ref": string;
|
"entity_ref": string;
|
||||||
|
|||||||
+14
-4
@@ -1,8 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import { ToolchainProbe } from "./toolchain-probe.js";
|
|
||||||
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
|
||||||
|
import { UserAuthPage } from "./user-auth.js";
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
@@ -13,8 +13,18 @@ if (!root) {
|
|||||||
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
// Cache failure leaves public assets network-backed and must not create alternate persistence.
|
||||||
void registerPublicAssetServiceWorker().catch(() => undefined);
|
void registerPublicAssetServiceWorker().catch(() => undefined);
|
||||||
|
|
||||||
createRoot(root).render(
|
const appRoot = createRoot(root);
|
||||||
|
let authRevision = 0;
|
||||||
|
|
||||||
|
function renderAuthenticationEntry() {
|
||||||
|
authRevision += 1;
|
||||||
|
appRoot.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ToolchainProbe />
|
<UserAuthPage key={authRevision} />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any authenticated surface can dispatch this after a revoked/invalid session response.
|
||||||
|
window.addEventListener("dada:session-invalid", renderAuthenticationEntry);
|
||||||
|
renderAuthenticationEntry();
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
"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<HTMLButtonElement>(null);
|
||||||
|
const registerTab = useRef<HTMLButtonElement>(null);
|
||||||
|
const [mode, setMode] = useState<AuthMode>("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
|
const [registrationId, setRegistrationId] = useState<string>();
|
||||||
|
const [sendState, setSendState] = useState<SendState>("idle");
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
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 (
|
||||||
|
<main className="auth-page">
|
||||||
|
<section className="auth-art" aria-label="Dada 创作艺术带">
|
||||||
|
<div className="auth-wordmark">DADA</div>
|
||||||
|
<div className="auth-art-copy">
|
||||||
|
<span>LOCAL CREATIVE SYSTEM / WINDOWS P0-A</span>
|
||||||
|
<strong>在本机展开你的创作</strong>
|
||||||
|
</div>
|
||||||
|
<div className="auth-art-block" aria-hidden="true" />
|
||||||
|
<div className="auth-art-line" aria-hidden="true" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="auth-content">
|
||||||
|
<a className="auth-admin-link" href="/admin">管理员登录</a>
|
||||||
|
<div className="auth-panel">
|
||||||
|
<div className="auth-tabs" role="tablist" aria-label="认证方式">
|
||||||
|
<button
|
||||||
|
aria-selected={mode === "login"}
|
||||||
|
className="auth-tab"
|
||||||
|
onClick={() => switchMode("login")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
switchTab("register");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={loginTab}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={mode === "login" ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-selected={mode === "register"}
|
||||||
|
className="auth-tab"
|
||||||
|
onClick={() => switchMode("register")}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
switchTab("login");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={registerTab}
|
||||||
|
role="tab"
|
||||||
|
tabIndex={mode === "register" ? 0 : -1}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "login" ? (
|
||||||
|
<form className="auth-form" onSubmit={login}>
|
||||||
|
<h1>邮箱验证码登录</h1>
|
||||||
|
<label htmlFor={emailId}>邮箱</label>
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
id={emailId}
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<label htmlFor={codeId}>验证码</label>
|
||||||
|
<div className="auth-code-row">
|
||||||
|
<input
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
id={codeId}
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
|
||||||
|
placeholder="6 位验证码"
|
||||||
|
value={code}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="auth-secondary"
|
||||||
|
disabled={!emailValid || sendState === "sending" || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendState === "sent" ? (
|
||||||
|
<p className="auth-status" role="status">验证码已发送至 {maskedEmail(email)}</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
className="auth-primary"
|
||||||
|
disabled={!registrationId || code.length !== 6 || submitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{submitting ? "登录中" : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<section className="auth-form" aria-labelledby="registration-title">
|
||||||
|
<h1 id="registration-title">邀请码注册</h1>
|
||||||
|
<label htmlFor={inviteId}>邀请码</label>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
id={inviteId}
|
||||||
|
onChange={(event) => setInviteCode(event.target.value)}
|
||||||
|
value={inviteCode}
|
||||||
|
/>
|
||||||
|
<label htmlFor={emailId}>邮箱</label>
|
||||||
|
<input
|
||||||
|
autoComplete="email"
|
||||||
|
id={emailId}
|
||||||
|
inputMode="email"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="auth-primary"
|
||||||
|
disabled={!emailValid || inviteCode.trim().length < 8 || sendState === "sending" || countdown > 0}
|
||||||
|
onClick={sendCode}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sendState === "sending" ? "发送中" : countdown > 0 ? `${countdown}s` : "获取验证码"}
|
||||||
|
</button>
|
||||||
|
{error ? <p className="auth-error" role="alert">{error}</p> : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="auth-local-notice">
|
||||||
|
测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -639,6 +639,24 @@
|
|||||||
"AUTH_SERVICE_UNAVAILABLE"
|
"AUTH_SERVICE_UNAVAILABLE"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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": {
|
"ModelConfigSseEvent": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1336,6 +1458,24 @@
|
|||||||
"AUTH_SERVICE_UNAVAILABLE"
|
"AUTH_SERVICE_UNAVAILABLE"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"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": {
|
"/api/v1/auth/register/complete": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "completeRegistration",
|
"operationId": "completeRegistration",
|
||||||
@@ -1541,6 +1918,16 @@
|
|||||||
},
|
},
|
||||||
"description": "Default Response"
|
"description": "Default Response"
|
||||||
},
|
},
|
||||||
|
"429": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Default Response"
|
||||||
|
},
|
||||||
"503": {
|
"503": {
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -1869,6 +2256,24 @@
|
|||||||
"AUTH_SERVICE_UNAVAILABLE"
|
"AUTH_SERVICE_UNAVAILABLE"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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"
|
"AUTH_SERVICE_UNAVAILABLE"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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"
|
"AUTH_SERVICE_UNAVAILABLE"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"AUTH_ENTRY_REJECTED"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"AUTH_RATE_LIMITED"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"AUTH_CSRF_INVALID"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -14,7 +14,7 @@
|
|||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
"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: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:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
"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": "node scripts/run-wp0-09-validation.mjs",
|
||||||
"test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red",
|
"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": "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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const stableEngineeringErrors = {
|
|||||||
IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" },
|
IDEMPOTENCY_KEY_CONFLICT: { httpStatus: 409, messageKey: "request.idempotency_conflict" },
|
||||||
AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" },
|
AUTH_SESSION_INVALID: { httpStatus: 401, messageKey: "auth.session.invalid" },
|
||||||
AUTH_SERVICE_UNAVAILABLE: { httpStatus: 503, messageKey: "auth.service.unavailable" },
|
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;
|
} as const;
|
||||||
|
|
||||||
export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors;
|
export type StableEngineeringErrorCode = keyof typeof stableEngineeringErrors;
|
||||||
@@ -55,6 +58,9 @@ export const StableEngineeringErrorCodeSchema = Type.Union(
|
|||||||
Type.Literal("IDEMPOTENCY_KEY_CONFLICT"),
|
Type.Literal("IDEMPOTENCY_KEY_CONFLICT"),
|
||||||
Type.Literal("AUTH_SESSION_INVALID"),
|
Type.Literal("AUTH_SESSION_INVALID"),
|
||||||
Type.Literal("AUTH_SERVICE_UNAVAILABLE"),
|
Type.Literal("AUTH_SERVICE_UNAVAILABLE"),
|
||||||
|
Type.Literal("AUTH_ENTRY_REJECTED"),
|
||||||
|
Type.Literal("AUTH_RATE_LIMITED"),
|
||||||
|
Type.Literal("AUTH_CSRF_INVALID"),
|
||||||
],
|
],
|
||||||
{ $id: "StableEngineeringErrorCode" },
|
{ $id: "StableEngineeringErrorCode" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,8 +82,49 @@ export const UserSessionResponseSchema = Type.Object(
|
|||||||
{ additionalProperties: false, $id: "UserSessionResponse" },
|
{ 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<typeof RegistrationSendRequestSchema>;
|
export type RegistrationSendRequest = Static<typeof RegistrationSendRequestSchema>;
|
||||||
export type RegistrationSendResponse = Static<typeof RegistrationSendResponseSchema>;
|
export type RegistrationSendResponse = Static<typeof RegistrationSendResponseSchema>;
|
||||||
export type RegistrationCompleteRequest = Static<typeof RegistrationCompleteRequestSchema>;
|
export type RegistrationCompleteRequest = Static<typeof RegistrationCompleteRequestSchema>;
|
||||||
export type RegistrationCompleteResponse = Static<typeof RegistrationCompleteResponseSchema>;
|
export type RegistrationCompleteResponse = Static<typeof RegistrationCompleteResponseSchema>;
|
||||||
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
export type UserSessionResponse = Static<typeof UserSessionResponseSchema>;
|
||||||
|
export type LoginSendRequest = Static<typeof LoginSendRequestSchema>;
|
||||||
|
export type LoginCompleteRequest = Static<typeof LoginCompleteRequestSchema>;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -112,6 +112,9 @@ describe("TDD-WP0-API-001 schema envelope", () => {
|
|||||||
expect(Object.keys(stableEngineeringErrors).sort()).toEqual([
|
expect(Object.keys(stableEngineeringErrors).sort()).toEqual([
|
||||||
"ASSET_CLEANUP_CANDIDATE_STALE",
|
"ASSET_CLEANUP_CANDIDATE_STALE",
|
||||||
"ASSET_HISTORY_REFERENCE_CONFLICT",
|
"ASSET_HISTORY_REFERENCE_CONFLICT",
|
||||||
|
"AUTH_CSRF_INVALID",
|
||||||
|
"AUTH_ENTRY_REJECTED",
|
||||||
|
"AUTH_RATE_LIMITED",
|
||||||
"AUTH_SERVICE_UNAVAILABLE",
|
"AUTH_SERVICE_UNAVAILABLE",
|
||||||
"AUTH_SESSION_INVALID",
|
"AUTH_SESSION_INVALID",
|
||||||
"BROWSER_UNSUPPORTED",
|
"BROWSER_UNSUPPORTED",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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") });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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<T>(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<string, string> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user