186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
import type { FastifySchema } from 'fastify';
|
|
import type pg from 'pg';
|
|
import { z } from 'zod';
|
|
import type { Authenticate } from '../../../shared/auth.js';
|
|
import { AppError } from '../../../shared/errors.js';
|
|
import { errorSchema } from '../../../shared/swagger.js';
|
|
import { BackofficeLogin } from '../application/login.js';
|
|
import { BackofficeLogout } from '../application/logout.js';
|
|
import { InMemoryBackofficeLoginRateLimiter } from '../infrastructure/backoffice-login-rate-limiter.js';
|
|
import type { BackofficeLoginRateLimiter } from '../domain/ports.js';
|
|
import { Argon2BackofficePasswordHasher } from '../infrastructure/backoffice-password-hasher.js';
|
|
import { PgBackofficeUserRepository } from '../infrastructure/pg-backoffice-user-repository.js';
|
|
import { PgBackofficeSessionRepository } from '../infrastructure/pg-backoffice-session-repository.js';
|
|
import {
|
|
generateBackofficeToken,
|
|
hashBackofficeToken,
|
|
} from '../infrastructure/backoffice-session-token.js';
|
|
import { BACKOFFICE_SESSION_TTL_MS } from '../domain/backoffice-session.js';
|
|
import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js';
|
|
|
|
export const BACKOFFICE_SESSION_COOKIE_NAME = 'backoffice_session';
|
|
|
|
export interface BackofficeRoutesDeps {
|
|
pool: pg.Pool;
|
|
hasher?: import('../domain/ports.js').BackofficePasswordHasher;
|
|
rateLimiter?: BackofficeLoginRateLimiter;
|
|
/** Session authenticator injected by the composition root. */
|
|
authenticate?: Authenticate;
|
|
}
|
|
|
|
const credentialsSchema = z.object({
|
|
email: z.email(),
|
|
password: z.string().min(8).max(128),
|
|
});
|
|
|
|
export async function registerBackofficeRoutes(
|
|
app: FastifyInstance,
|
|
deps: BackofficeRoutesDeps,
|
|
): Promise<void> {
|
|
const hasher = deps.hasher ?? new Argon2BackofficePasswordHasher();
|
|
const users = new PgBackofficeUserRepository(deps.pool);
|
|
const sessions = new PgBackofficeSessionRepository(deps.pool);
|
|
const rateLimiter = deps.rateLimiter ?? new InMemoryBackofficeLoginRateLimiter();
|
|
const login = new BackofficeLogin({
|
|
users,
|
|
sessions,
|
|
hasher,
|
|
rateLimiter,
|
|
generateToken: generateBackofficeToken,
|
|
hashToken: hashBackofficeToken,
|
|
});
|
|
const logout = new BackofficeLogout(sessions, hashBackofficeToken);
|
|
|
|
const loginSchema: FastifySchema = {
|
|
tags: ['Backoffice'],
|
|
summary: 'Backoffice login',
|
|
description:
|
|
'Inicia sesión en el backoffice y devuelve una cookie `backoffice_session` (válida 8h).',
|
|
body: {
|
|
type: 'object',
|
|
required: ['email', 'password'],
|
|
properties: {
|
|
email: { type: 'string', format: 'email' },
|
|
password: { type: 'string' },
|
|
},
|
|
},
|
|
response: {
|
|
200: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
email: { type: 'string', format: 'email' },
|
|
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
|
|
},
|
|
},
|
|
401: errorSchema,
|
|
429: errorSchema,
|
|
},
|
|
};
|
|
|
|
const logoutSchema: FastifySchema = {
|
|
tags: ['Backoffice'],
|
|
summary: 'Backoffice logout',
|
|
response: { 204: { type: 'null' } },
|
|
};
|
|
|
|
const meSchema: FastifySchema = {
|
|
tags: ['Backoffice'],
|
|
summary: 'Current backoffice user',
|
|
description: 'Devuelve el usuario backoffice autenticado o `{user: null}` si no hay sesión.',
|
|
response: {
|
|
200: {
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string', format: 'uuid' },
|
|
email: { type: 'string', format: 'email' },
|
|
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
|
|
},
|
|
},
|
|
{ type: 'object', properties: { user: { type: 'null' } } },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
app.post('/backoffice/auth/login', { schema: loginSchema }, async (request, reply) => {
|
|
const parseResult = credentialsSchema.safeParse(request.body);
|
|
if (!parseResult.success) {
|
|
throw new AppError(
|
|
400,
|
|
'VALIDATION_ERROR',
|
|
'Invalid request payload',
|
|
parseResult.error.issues.map((issue) => ({
|
|
path: issue.path.join('.'),
|
|
message: issue.message,
|
|
code: issue.code,
|
|
})),
|
|
);
|
|
}
|
|
const input = parseResult.data;
|
|
try {
|
|
const result = await login.execute(input);
|
|
setCookie(reply, result.token, isSecureRequest(request));
|
|
return reply
|
|
.code(200)
|
|
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
|
} catch (error) {
|
|
if (error instanceof BackofficeRateLimitedError) {
|
|
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
|
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
|
}
|
|
if (error instanceof BackofficeInvalidCredentialsError) {
|
|
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
app.post('/backoffice/auth/logout', { schema: logoutSchema }, async (request, reply) => {
|
|
const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
|
await logout.execute(token);
|
|
clearCookie(reply, isSecureRequest(request));
|
|
return reply.code(204).send();
|
|
});
|
|
|
|
app.get('/backoffice/auth/me', { schema: meSchema }, async (request, reply) => {
|
|
try {
|
|
const user = await deps.authenticate!(request);
|
|
return reply.send({ id: user.id, email: user.email, role: user.role });
|
|
} catch (error) {
|
|
if (error instanceof AppError && error.statusCode === 401) {
|
|
return reply.send({ user: null });
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
function isSecureRequest(request: FastifyRequest): boolean {
|
|
const forwardedProto = request.headers['x-forwarded-proto'];
|
|
const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
|
return request.protocol === 'https' || proto === 'https';
|
|
}
|
|
|
|
function setCookie(reply: FastifyReply, token: string, secure: boolean): void {
|
|
void reply.setCookie(BACKOFFICE_SESSION_COOKIE_NAME, token, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure,
|
|
maxAge: Math.floor(BACKOFFICE_SESSION_TTL_MS / 1000),
|
|
});
|
|
}
|
|
|
|
function clearCookie(reply: FastifyReply, secure: boolean): void {
|
|
void reply.clearCookie(BACKOFFICE_SESSION_COOKIE_NAME, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure,
|
|
});
|
|
}
|