feat: implement TASK-WP1-02 login sessions

This commit is contained in:
suyx
2026-07-28 16:28:30 +08:00
parent f467e7c09f
commit ee70001d44
20 changed files with 2480 additions and 28 deletions
+160 -3
View File
@@ -10,6 +10,11 @@ import {
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LoginSendRequestSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
RegistrationCompleteHeadersSchema,
@@ -25,6 +30,8 @@ import {
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
type LoginCompleteRequest,
type LoginSendRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -92,6 +99,8 @@ const contentSecurityPolicy = [
"base-uri 'none'",
"frame-ancestors 'none'",
].join("; ");
const authFlowCookieName = "dada_auth_flow";
const userSessionCookieName = "dada_session";
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
const header = headers["x-correlation-id"];
@@ -208,6 +217,11 @@ export async function createApp(options: CreateAppOptions = {}) {
RegistrationCompleteRequestSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteResponseSchema,
LoginSendRequestSchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
UserSessionResponseSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
@@ -288,6 +302,111 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/login/send",
{
attachValidation: true,
schema: {
body: Type.Ref(LoginSendRequestSchema),
operationId: "sendLoginCode",
response: {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const existingFlow = cookieValue(headerValue(request.headers.cookie), authFlowCookieName);
const clientKey = existingFlow ?? randomBytes(32).toString("base64url");
try {
const body = request.body as LoginSendRequest;
const result = await options.registration.sendLoginCode({ clientKey, email: body.email });
if (!existingFlow) {
reply.header(
"Set-Cookie",
`${authFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
}
return {
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
registration_id: result.registrationId,
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
status: result.status,
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/login/complete",
{
attachValidation: true,
schema: {
body: Type.Ref(LoginCompleteRequestSchema),
headers: Type.Ref(RegistrationCompleteHeadersSchema),
operationId: "completeLogin",
response: {
200: Type.Ref(LoginCompleteResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const clientKey = cookieValue(headerValue(request.headers.cookie), authFlowCookieName);
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id);
try {
const body = request.body as LoginCompleteRequest;
const result = options.registration.completeLogin({
clientKey,
code: body.verification_code,
idempotencyKey,
registrationId: body.registration_id,
});
reply.header(
"Set-Cookie",
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
audience: result.audience,
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/register/send",
{
@@ -299,6 +418,7 @@ export async function createApp(options: CreateAppOptions = {}) {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
@@ -361,7 +481,7 @@ export async function createApp(options: CreateAppOptions = {}) {
});
reply.header(
"Set-Cookie",
`dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
credits: {
@@ -401,7 +521,7 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), "dada_session");
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
@@ -413,7 +533,7 @@ export async function createApp(options: CreateAppOptions = {}) {
available_balance: session.credits.availableBalance,
reserved_balance: session.credits.reservedBalance,
},
csrf_token: randomBytes(32).toString("base64url"),
csrf_token: options.registration.issueUserCsrfToken(token!),
expires_at: new Date(session.expiresAt).toISOString(),
user: {
creator_name: session.user.creatorName,
@@ -426,6 +546,43 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/logout",
{
attachValidation: true,
schema: {
headers: Type.Ref(LogoutHeadersSchema),
operationId: "logoutUser",
response: {
200: Type.Ref(LogoutResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
options.registration.logoutUser({ csrfToken, sessionToken: token });
reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`);
return { status: "logged_out" as const };
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/support/check",
{
+32 -4
View File
@@ -10,22 +10,42 @@ export type RegistrationErrorReason =
| "privacy_consent_required"
| "privacy_notice_version_invalid"
| "profile_invalid"
| "idempotency_conflict";
| "idempotency_conflict"
| "registration_login_required"
| "login_registration_required"
| "account_suspended"
| "login_admin_required"
| "resend_too_soon"
| "too_many_attempts"
| "csrf_invalid"
| "session_invalid";
export type RegistrationErrorCode =
| "REGISTRATION_REJECTED"
| "REGISTRATION_REQUEST_INVALID"
| "IDEMPOTENCY_KEY_CONFLICT";
| "IDEMPOTENCY_KEY_CONFLICT"
| "AUTH_ENTRY_REJECTED"
| "AUTH_RATE_LIMITED"
| "AUTH_CSRF_INVALID"
| "AUTH_SESSION_INVALID";
export class RegistrationError extends Error {
readonly code: RegistrationErrorCode;
readonly httpStatus: 400 | 409;
readonly httpStatus: 400 | 401 | 403 | 409 | 429;
readonly reason: RegistrationErrorReason;
constructor(code: RegistrationErrorCode, reason: RegistrationErrorReason) {
super(code);
this.code = code;
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID" ? 400 : 409;
this.httpStatus = code === "REGISTRATION_REQUEST_INVALID"
? 400
: code === "AUTH_SESSION_INVALID"
? 401
: code === "AUTH_CSRF_INVALID"
? 403
: code === "AUTH_RATE_LIMITED"
? 429
: 409;
this.reason = reason;
}
}
@@ -44,6 +64,14 @@ export function registrationFieldError(reason: RegistrationErrorReason) {
privacy_notice_version_invalid: { field: "privacy_notice_version", message_key: "auth.privacy.notice_version_invalid" },
profile_invalid: { field: "profile", message_key: "auth.profile.invalid" },
stage_limit_reached: { field: "invite_code", message_key: "auth.registration.stage_limit_reached" },
registration_login_required: { field: "email", message_key: "auth.registration.login_required" },
login_registration_required: { field: "email", message_key: "auth.login.registration_required" },
account_suspended: { field: "email", message_key: "auth.account.suspended" },
login_admin_required: { field: "email", message_key: "auth.login.admin_required" },
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
session_invalid: { field: "session", message_key: "auth.session.invalid" },
};
return entries[reason];
}
+428 -7
View File
@@ -17,10 +17,23 @@ const stageLimit = 10;
const challengeLifetimeMilliseconds = 10 * 60 * 1_000;
const resendDelayMilliseconds = 60 * 1_000;
const sessionLifetimeMilliseconds = 30 * 24 * 60 * 60 * 1_000;
const rateWindowMilliseconds = 10 * 60 * 1_000;
const rateBlockMilliseconds = 10 * 60 * 1_000;
const maximumFailedAttempts = 5;
const maximumSendsPerWindow = 5;
export interface RegistrationTransactionEvent {
mode: "BEGIN IMMEDIATE";
operation: "invite_create" | "registration_send" | "registration_complete" | "registration_send_compensation";
operation:
| "invite_create"
| "registration_send"
| "registration_complete"
| "registration_send_compensation"
| "login_send"
| "login_complete"
| "session_issue"
| "session_revoke"
| "csrf_issue";
outcome: "committed" | "rejected" | "idempotent_replay";
}
@@ -51,7 +64,7 @@ interface ChallengeRow {
consumed_at: number | null;
email: string;
expires_at: number;
invite_id: string;
invite_id: string | null;
}
interface AttemptRow {
@@ -72,6 +85,21 @@ interface UserResultRow {
user_id: string;
}
interface RateLimitRow {
blocked_until: number | null;
failed_attempts: number;
send_count: number;
window_started_at: number;
}
interface LoginAttemptRow {
failure_reason: RegistrationErrorReason | null;
outcome_code: "success" | "failure";
request_hash: string;
session_id: string | null;
user_id: string | null;
}
export interface RegistrationSendResult {
challengeExpiresAt: number;
registrationId: string;
@@ -111,6 +139,18 @@ export interface UserSessionResult {
userId: string;
}
export interface LoginCompleteInput {
clientKey: string;
code: string;
idempotencyKey: string;
registrationId: string;
}
export type LoginCompleteResult = Omit<RegistrationCompleteResult, "status"> & {
audience: "user";
status: "authenticated";
};
interface ImmediateResult<T> {
outcome: RegistrationTransactionEvent["outcome"];
value: T;
@@ -210,10 +250,13 @@ export class RegistrationService {
this.assertInviteAvailable(invite, now);
this.assertStageCapacity();
const existing = this.database.prepare(`
SELECT user_id FROM users
SELECT user_id, role, status FROM users
WHERE normalized_email = ? AND status <> 'deleted'
`).get(email);
if (existing) throw new RegistrationError("REGISTRATION_REJECTED", "email_already_registered");
`).get(email) as { role: "user" | "super_admin"; status: "active" | "suspended" } | undefined;
if (existing?.status === "suspended") throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended");
if (existing) throw new RegistrationError("AUTH_ENTRY_REJECTED", "registration_login_required");
this.assertChallengeSendAllowed(email, "register", "registration", now);
this.recordRateSend(email, "registration", now);
this.database.prepare(`
INSERT INTO email_challenges (
@@ -241,7 +284,7 @@ export class RegistrationService {
});
try {
await this.options.resend.sendRegistrationCode({ challengeId, code, email, purpose: "register" });
await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "register" });
} catch {
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId);
@@ -252,6 +295,143 @@ export class RegistrationService {
return result;
}
async sendLoginCode(input: { clientKey: string; email: string }): Promise<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 {
const creatorName = normalizeProfileValue(input.creatorName, 80);
const socialId = normalizeSocialId(input.socialId);
@@ -391,6 +571,103 @@ export class RegistrationService {
return outcome;
}
issueAuthenticatedSession(userId: string, audience: "user" | "admin") {
const now = this.options.clock();
return this.runImmediate("session_issue", () => {
const user = this.database.prepare("SELECT role, status FROM users WHERE user_id = ?")
.get(userId) as { role: "user" | "super_admin"; status: "active" | "suspended" | "deleted" } | undefined;
const role = audience === "user" ? "user" : "super_admin";
if (!user || user.status !== "active" || user.role !== role) {
throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
}
if (audience === "admin") {
const access = this.database.prepare("SELECT allowed FROM admin_access WHERE user_id = ?")
.get(userId) as { allowed: 0 | 1 } | undefined;
if (access?.allowed !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
}
const issued = this.insertSession(userId, audience, now);
return {
outcome: "committed",
value: {
sessionExpiresAt: issued.sessionExpiresAt,
sessionId: issued.sessionId,
sessionToken: issued.sessionToken,
},
};
});
}
issueUserCsrfToken(sessionToken: string) {
const now = this.options.clock();
const csrfToken = randomBytes(32).toString("base64url");
return this.runImmediate("csrf_issue", () => {
const session = this.database.prepare(`
SELECT s.session_id FROM sessions s
JOIN users u ON u.user_id = s.user_id
WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL
AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active'
`).get(digest(sessionToken), now) as { session_id: string } | undefined;
if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?")
.run(digest(csrfToken), session.session_id);
return { outcome: "committed", value: csrfToken };
});
}
logoutUser(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
const session = this.database.prepare(`
SELECT session_id, user_id, csrf_token_digest FROM sessions
WHERE token_digest = ? AND audience = 'user' AND revoked_at IS NULL AND expires_at > ?
`).get(digest(input.sessionToken), now) as { csrf_token_digest: string | null; session_id: string; user_id: string } | undefined;
if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
const csrfDigest = digest(input.csrfToken);
if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, csrfDigest)) {
throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid");
}
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
.run(now, session.user_id);
return { outcome: "committed", value: undefined };
});
}
changeUserStatus(userId: string, status: "suspended" | "deleted") {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
const changed = this.database.prepare("UPDATE users SET status = ? WHERE user_id = ? AND role = 'user'")
.run(status, userId);
if (changed.changes !== 1) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
.run(now, userId);
return { outcome: "committed", value: undefined };
});
}
revokeAdminSessions(userId: string, reason: "logout" | "disabled" | "whitelist_removed") {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
const user = this.database.prepare("SELECT role FROM users WHERE user_id = ?").get(userId) as { role: string } | undefined;
if (user?.role !== "super_admin") throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
if (reason === "disabled") this.database.prepare("UPDATE users SET status = 'suspended' WHERE user_id = ?").run(userId);
if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId);
this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL")
.run(now, userId);
return { outcome: "committed", value: undefined };
});
}
readAdminSession(token: string) {
const now = this.options.clock();
return this.database.prepare(`
SELECT s.expires_at, u.user_id FROM sessions s
JOIN users u ON u.user_id = s.user_id
JOIN admin_access a ON a.user_id = u.user_id
WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL
AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1
`).get(digest(token), now) as { expires_at: number; user_id: string } | undefined;
}
readUserSession(token: string): UserSessionResult | undefined {
const now = this.options.clock();
const row = this.database.prepare(`
@@ -484,7 +761,7 @@ export class RegistrationService {
CREATE TABLE IF NOT EXISTS email_challenges (
challenge_id TEXT PRIMARY KEY,
email TEXT NOT NULL,
invite_id TEXT NOT NULL REFERENCES invite_codes(invite_id),
invite_id TEXT REFERENCES invite_codes(invite_id),
code_hmac TEXT NOT NULL,
purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'admin_login')),
expires_at INTEGER NOT NULL,
@@ -498,6 +775,7 @@ export class RegistrationService {
user_id TEXT NOT NULL REFERENCES users(user_id),
audience TEXT NOT NULL CHECK (audience IN ('user', 'admin')),
token_digest TEXT NOT NULL UNIQUE,
csrf_token_digest TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
revoked_at INTEGER
@@ -512,6 +790,27 @@ export class RegistrationService {
session_id TEXT REFERENCES sessions(session_id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS admin_access (
user_id TEXT PRIMARY KEY REFERENCES users(user_id),
allowed INTEGER NOT NULL CHECK (allowed IN (0, 1))
);
CREATE TABLE IF NOT EXISTS auth_rate_limits (
rate_key TEXT PRIMARY KEY,
window_started_at INTEGER NOT NULL,
send_count INTEGER NOT NULL CHECK (send_count >= 0),
failed_attempts INTEGER NOT NULL CHECK (failed_attempts >= 0),
blocked_until INTEGER
);
CREATE TABLE IF NOT EXISTS login_attempts (
idempotency_key_digest TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
challenge_id TEXT NOT NULL,
outcome_code TEXT NOT NULL CHECK (outcome_code IN ('success', 'failure')),
failure_reason TEXT,
user_id TEXT REFERENCES users(user_id),
session_id TEXT REFERENCES sessions(session_id),
created_at INTEGER NOT NULL
);
`);
}
@@ -548,6 +847,117 @@ export class RegistrationService {
return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url");
}
private rateKey(email: string, clientKey: string) {
return this.keyedHmac(this.options.challengePepper, `rate:${email}:${clientKey}`);
}
private readRateLimit(email: string, clientKey: string, now: number): RateLimitRow {
const row = this.database.prepare(`
SELECT window_started_at, send_count, failed_attempts, blocked_until
FROM auth_rate_limits WHERE rate_key = ?
`).get(this.rateKey(email, clientKey)) as RateLimitRow | undefined;
if (!row || now - row.window_started_at >= rateWindowMilliseconds) {
return { blocked_until: null, failed_attempts: 0, send_count: 0, window_started_at: now };
}
return row;
}
private recordRateSend(email: string, clientKey: string, now: number) {
const key = this.rateKey(email, clientKey);
const row = this.readRateLimit(email, clientKey, now);
if (row.blocked_until !== null && row.blocked_until > now) {
throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts");
}
if (row.send_count >= maximumSendsPerWindow) {
throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts");
}
this.database.prepare(`
INSERT INTO auth_rate_limits (
rate_key, window_started_at, send_count, failed_attempts, blocked_until
) VALUES (?, ?, 1, 0, NULL)
ON CONFLICT(rate_key) DO UPDATE SET
window_started_at = excluded.window_started_at,
send_count = ?,
failed_attempts = ?,
blocked_until = ?
`).run(
key,
row.window_started_at,
row.send_count + 1,
row.failed_attempts,
row.blocked_until,
);
}
private recordRateFailure(email: string, clientKey: string, now: number) {
const key = this.rateKey(email, clientKey);
const row = this.readRateLimit(email, clientKey, now);
const failedAttempts = row.failed_attempts + 1;
const blockedUntil = failedAttempts >= maximumFailedAttempts ? now + rateBlockMilliseconds : row.blocked_until;
this.database.prepare(`
INSERT INTO auth_rate_limits (
rate_key, window_started_at, send_count, failed_attempts, blocked_until
) VALUES (?, ?, 0, ?, ?)
ON CONFLICT(rate_key) DO UPDATE SET
window_started_at = excluded.window_started_at,
send_count = ?,
failed_attempts = excluded.failed_attempts,
blocked_until = excluded.blocked_until
`).run(key, row.window_started_at, failedAttempts, blockedUntil, row.send_count);
return failedAttempts;
}
private assertChallengeSendAllowed(
email: string,
purpose: "register" | "login",
clientKey: string,
now: number,
) {
const latest = this.database.prepare(`
SELECT resend_available_at FROM email_challenges
WHERE email = ? AND purpose = ? ORDER BY created_at DESC LIMIT 1
`).get(email, purpose) as { resend_available_at: number } | undefined;
if (latest && latest.resend_available_at > now) {
throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon");
}
const rate = this.readRateLimit(email, clientKey, now);
if ((rate.blocked_until !== null && rate.blocked_until > now) || rate.send_count >= maximumSendsPerWindow) {
throw new RegistrationError("AUTH_RATE_LIMITED", "too_many_attempts");
}
}
private insertSession(userId: string, audience: "user" | "admin", now: number) {
const sessionId = randomUUID();
const sessionToken = this.sessionToken(sessionId);
const sessionExpiresAt = now + sessionLifetimeMilliseconds;
this.database.prepare(`
INSERT INTO sessions (
session_id, user_id, audience, token_digest, csrf_token_digest,
created_at, expires_at, revoked_at
) VALUES (?, ?, ?, ?, NULL, ?, ?, NULL)
`).run(sessionId, userId, audience, digest(sessionToken), now, sessionExpiresAt);
return { sessionExpiresAt, sessionId, sessionToken };
}
private recordLoginFailure(
idempotencyKeyDigest: string,
requestHash: string,
challengeId: string,
reason: RegistrationErrorReason,
now: number,
): ImmediateResult<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() {
const row = this.database.prepare(`
SELECT COUNT(*) AS count FROM users
@@ -605,6 +1015,17 @@ export class RegistrationService {
return this.result(row);
}
private loginResult(result: RegistrationCompleteResult): LoginCompleteResult {
return {
audience: "user",
credits: result.credits,
sessionExpiresAt: result.sessionExpiresAt,
sessionToken: result.sessionToken,
status: "authenticated",
user: result.user,
};
}
private result(row: UserResultRow): RegistrationCompleteResult {
return {
credits: { availableBalance: row.available_balance, reservedBalance: row.reserved_balance },
+3 -3
View File
@@ -2,17 +2,17 @@ export interface RegistrationCodeMessage {
challengeId: string;
code: string;
email: string;
purpose: "register";
purpose: "register" | "login";
}
export interface ResendAdapter {
sendRegistrationCode(message: RegistrationCodeMessage): Promise<void>;
sendVerificationCode(message: RegistrationCodeMessage): Promise<void>;
}
export class MockResendAdapter implements ResendAdapter {
readonly calls: RegistrationCodeMessage[] = [];
async sendRegistrationCode(message: RegistrationCodeMessage) {
async sendVerificationCode(message: RegistrationCodeMessage) {
this.calls.push({ ...message });
}
+26 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, RegistrationSendResponse, RegistrationSendRequest } from "./types.gen.js";
import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
}>;
}
export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise<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> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -104,6 +113,22 @@ export async function getUserSession(options: ClientOptions = {}): Promise<UserS
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> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+28 -2
View File
@@ -78,7 +78,7 @@ export type ErrorDetails = {
export type ErrorEnvelope = {
"error": {
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
"code": "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
"correlation_id": string;
"details": {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
@@ -102,6 +102,32 @@ export type ErrorEnvelope = {
export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable";
export type LoginCompleteRequest = {
"registration_id": string;
"verification_code": string;
};
export type LoginCompleteResponse = {
"audience": "user";
"credits": CreditSummary;
"session_expires_at": string;
"status": "authenticated";
"user": AuthenticatedUser;
};
export type LoginSendRequest = {
"email": string;
};
export type LogoutHeaders = {
"idempotency-key": string;
"x-csrf-token": string;
};
export type LogoutResponse = {
"status": "logged_out";
};
export type ModelConfigSseEvent = {
"config_set_version": number;
"entity_ref": string;
@@ -170,7 +196,7 @@ export type SseEvent = {
"runtime_availability_version": number;
};
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE";
export type StableEngineeringErrorCode = "BROWSER_UNSUPPORTED" | "MODEL_CONFIG_VERSION_CONFLICT" | "MODEL_DEFAULT_REPLACEMENT_REQUIRED" | "MODEL_DEFAULT_REPLACEMENT_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_INVALID" | "MODEL_RECOMMENDATION_PRIORITY_CONFLICT" | "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED" | "ASSET_HISTORY_REFERENCE_CONFLICT" | "ASSET_CLEANUP_CANDIDATE_STALE" | "STORAGE_CAPACITY_EXCEEDED" | "REGISTRATION_REJECTED" | "REGISTRATION_REQUEST_INVALID" | "IDEMPOTENCY_KEY_CONFLICT" | "AUTH_SESSION_INVALID" | "AUTH_SERVICE_UNAVAILABLE" | "AUTH_ENTRY_REJECTED" | "AUTH_RATE_LIMITED" | "AUTH_CSRF_INVALID";
export type StateSseEvent = {
"entity_ref": string;
+16 -6
View File
@@ -1,8 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ToolchainProbe } from "./toolchain-probe.js";
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { UserAuthPage } from "./user-auth.js";
const root = document.getElementById("root");
@@ -13,8 +13,18 @@ if (!root) {
// Cache failure leaves public assets network-backed and must not create alternate persistence.
void registerPublicAssetServiceWorker().catch(() => undefined);
createRoot(root).render(
<StrictMode>
<ToolchainProbe />
</StrictMode>,
);
const appRoot = createRoot(root);
let authRevision = 0;
function renderAuthenticationEntry() {
authRevision += 1;
appRoot.render(
<StrictMode>
<UserAuthPage key={authRevision} />
</StrictMode>,
);
}
// Any authenticated surface can dispatch this after a revoked/invalid session response.
window.addEventListener("dada:session-invalid", renderAuthenticationEntry);
renderAuthenticationEntry();
+325
View File
@@ -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%;
}
}
+267
View File
@@ -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>
);
}