feat: implement TASK-WP1-04 admin security
This commit is contained in:
@@ -3,6 +3,11 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
AdminAuthenticatedUserSchema,
|
||||
AdminLoginCompleteRequestSchema,
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminLoginSendRequestSchema,
|
||||
AdminSessionResponseSchema,
|
||||
BootstrapResponseSchema,
|
||||
CorrelationIdSchema,
|
||||
AuthenticatedUserSchema,
|
||||
@@ -30,6 +35,8 @@ import {
|
||||
createErrorEnvelope,
|
||||
isCorrelationId,
|
||||
type BootstrapResponse,
|
||||
type AdminLoginCompleteRequest,
|
||||
type AdminLoginSendRequest,
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
@@ -101,6 +108,8 @@ const contentSecurityPolicy = [
|
||||
].join("; ");
|
||||
const authFlowCookieName = "dada_auth_flow";
|
||||
const userSessionCookieName = "dada_session";
|
||||
const adminAuthFlowCookieName = "dada_admin_auth_flow";
|
||||
const adminSessionCookieName = "dada_admin_session";
|
||||
|
||||
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
|
||||
const header = headers["x-correlation-id"];
|
||||
@@ -211,6 +220,11 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
AuthenticatedUserSchema,
|
||||
AdminAuthenticatedUserSchema,
|
||||
AdminLoginSendRequestSchema,
|
||||
AdminLoginCompleteRequestSchema,
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminSessionResponseSchema,
|
||||
CreditSummarySchema,
|
||||
RegistrationSendRequestSchema,
|
||||
RegistrationSendResponseSchema,
|
||||
@@ -302,6 +316,140 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin-auth/login/send",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(AdminLoginSendRequestSchema),
|
||||
operationId: "sendAdminLoginCode",
|
||||
response: {
|
||||
200: Type.Ref(RegistrationSendResponseSchema),
|
||||
400: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
429: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin 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), adminAuthFlowCookieName);
|
||||
const clientKey = existingFlow ?? randomBytes(32).toString("base64url");
|
||||
try {
|
||||
const body = request.body as AdminLoginSendRequest;
|
||||
const result = await options.registration.sendAdminLoginCode({ clientKey, email: body.email });
|
||||
if (!existingFlow) {
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`${adminAuthFlowCookieName}=${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/admin-auth/login/complete",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(AdminLoginCompleteRequestSchema),
|
||||
headers: Type.Ref(RegistrationCompleteHeadersSchema),
|
||||
operationId: "completeAdminLogin",
|
||||
response: {
|
||||
200: Type.Ref(AdminLoginCompleteResponseSchema),
|
||||
400: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
429: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin 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), adminAuthFlowCookieName);
|
||||
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
||||
if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id);
|
||||
try {
|
||||
const body = request.body as AdminLoginCompleteRequest;
|
||||
const result = options.registration.completeAdminLogin({
|
||||
clientKey,
|
||||
code: body.verification_code,
|
||||
idempotencyKey,
|
||||
registrationId: body.registration_id,
|
||||
});
|
||||
reply.header(
|
||||
"Set-Cookie",
|
||||
`${adminSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
|
||||
);
|
||||
return {
|
||||
admin: {
|
||||
role: result.admin.role,
|
||||
status: result.admin.status,
|
||||
user_id: result.admin.userId,
|
||||
},
|
||||
audience: result.audience,
|
||||
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
|
||||
status: result.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return registrationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin-auth/session",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getAdminSession",
|
||||
response: {
|
||||
200: Type.Ref(AdminSessionResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Authentication"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
const session = token ? options.registration.readAdminSession(token) : undefined;
|
||||
if (!session) {
|
||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
}
|
||||
return {
|
||||
acknowledged_private_content_notice_version: null,
|
||||
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
|
||||
audience: "admin" as const,
|
||||
authenticated: true as const,
|
||||
csrf_token: options.registration.issueAdminCsrfToken(token!),
|
||||
current_private_content_notice_version: null,
|
||||
expires_at: new Date(session.expires_at).toISOString(),
|
||||
notice_acknowledged: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/auth/login/send",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user