154 lines
6.1 KiB
TypeScript
154 lines
6.1 KiB
TypeScript
import type { FastifyPluginAsync } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
|
import { hashToken } from '../auth/account-tokens.js';
|
|
import type { AuthUser } from '../auth/plugin.js';
|
|
import { pool } from '../db/pool.js';
|
|
import { config } from '../config.js';
|
|
|
|
const credentialsSchema = z.object({
|
|
email: z.string().email().transform((email) => email.toLowerCase()),
|
|
password: z.string().min(12).max(200),
|
|
});
|
|
|
|
const registerSchema = credentialsSchema.extend({
|
|
name: z.string().trim().min(2).max(120),
|
|
});
|
|
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() });
|
|
|
|
type UserRow = {
|
|
id: string;
|
|
email: string;
|
|
display_name: string;
|
|
role: AuthUser['role'];
|
|
password_hash: string;
|
|
is_active: boolean;
|
|
};
|
|
|
|
const serializeUser = (user: UserRow): AuthUser => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.display_name,
|
|
role: user.role,
|
|
});
|
|
|
|
export const authRoutes: FastifyPluginAsync = async (app) => {
|
|
const publicAttempts = new Map<string, number[]>();
|
|
const allowPublicAuthAttempt = (ip: string) => {
|
|
const now = Date.now();
|
|
const earliestAllowed = now - config.AUTH_RATE_LIMIT_WINDOW_SECONDS * 1000;
|
|
const attempts = (publicAttempts.get(ip) || []).filter((timestamp) => timestamp > earliestAllowed);
|
|
if (attempts.length >= config.AUTH_RATE_LIMIT_MAX) {
|
|
publicAttempts.set(ip, attempts);
|
|
return false;
|
|
}
|
|
attempts.push(now);
|
|
publicAttempts.set(ip, attempts);
|
|
return true;
|
|
};
|
|
const rejectIfRateLimited = (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
|
|
if (allowPublicAuthAttempt(ip)) return false;
|
|
reply.code(429).send({ error: 'Too many attempts. Please try again later.' });
|
|
return true;
|
|
};
|
|
|
|
app.post('/accept-invitation', async (request, reply) => {
|
|
if (rejectIfRateLimited(request.ip, reply)) return;
|
|
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('begin');
|
|
const token = await client.query<{ email: string; role: AuthUser['role'] }>(
|
|
`update account_access_tokens set used_at = now()
|
|
where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now()
|
|
returning email, role`, [hashToken(input.token)],
|
|
);
|
|
if (!token.rows[0]) {
|
|
await client.query('rollback');
|
|
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
|
|
}
|
|
const result = await client.query<UserRow>(
|
|
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
|
|
returning id, email, display_name, role, password_hash, is_active`,
|
|
[token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role],
|
|
);
|
|
await client.query('commit');
|
|
const user = serializeUser(result.rows[0]);
|
|
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
|
|
} catch {
|
|
await client.query('rollback');
|
|
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
app.post('/reset-password', async (request, reply) => {
|
|
if (rejectIfRateLimited(request.ip, reply)) return;
|
|
const input = tokenPasswordSchema.parse(request.body);
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('begin');
|
|
const token = await client.query<{ email: string }>(
|
|
`update account_access_tokens set used_at = now()
|
|
where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now()
|
|
returning email`, [hashToken(input.token)],
|
|
);
|
|
if (!token.rows[0]) {
|
|
await client.query('rollback');
|
|
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
|
|
}
|
|
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
|
|
await client.query('commit');
|
|
return reply.code(204).send();
|
|
} catch (error) {
|
|
await client.query('rollback');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
app.post('/register', async (request, reply) => {
|
|
if (rejectIfRateLimited(request.ip, reply)) return;
|
|
const input = registerSchema.parse(request.body);
|
|
const passwordHash = await hashPassword(input.password);
|
|
|
|
try {
|
|
const result = await pool.query<UserRow>(
|
|
`insert into users (email, password_hash, display_name)
|
|
values ($1, $2, $3)
|
|
returning id, email, display_name, role, password_hash, is_active`,
|
|
[input.email, passwordHash, input.name],
|
|
);
|
|
const user = serializeUser(result.rows[0]);
|
|
const token = await reply.jwtSign(user, { expiresIn: '7d' });
|
|
return reply.code(201).send({ token, user });
|
|
} catch (error: unknown) {
|
|
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
|
|
return reply.code(409).send({ error: 'An account with this email already exists' });
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
app.post('/login', async (request, reply) => {
|
|
if (rejectIfRateLimited(request.ip, reply)) return;
|
|
const input = credentialsSchema.parse(request.body);
|
|
const result = await pool.query<UserRow>(
|
|
`select id, email, display_name, role, password_hash, is_active from users where email = $1`,
|
|
[input.email],
|
|
);
|
|
const account = result.rows[0];
|
|
|
|
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
|
|
return reply.code(401).send({ error: 'Invalid email or password' });
|
|
}
|
|
|
|
const user = serializeUser(account);
|
|
const token = await reply.jwtSign(user, { expiresIn: '7d' });
|
|
return { token, user };
|
|
});
|
|
|
|
app.get('/me', { preHandler: app.authenticate }, async (request) => ({ user: request.user }));
|
|
};
|