feat: implement TASK-WP1-02 login sessions
This commit is contained in:
+160
-3
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user