Import source: app/backend/src/routes/auth
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import * as argon2 from '@node-rs/argon2';
|
||||
import { prisma } from '../db.js';
|
||||
import { redis } from '../redis.js';
|
||||
import type { SessionData } from '../plugins/session.js';
|
||||
import { verifyCaptcha } from '../lib/captcha.js';
|
||||
import { issueOtp, verifyOtp } from '../lib/otp.js';
|
||||
import { checkRateLimit, rateLimitResponse } from '../lib/rate-limit.js';
|
||||
import { logSecurityEvent, recordLoginFailure, checkAccountLock } from '../lib/security-audit.js';
|
||||
|
||||
const LOGIN_FAIL_MAX = 5;
|
||||
const TWO_FACTOR_TTL_SECONDS = 5 * 60;
|
||||
const USERNAME_RE = /^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/;
|
||||
|
||||
const loginSchema = z.object({
|
||||
identifier: z.string().trim().min(3).max(254),
|
||||
password: z.string().min(1).max(256),
|
||||
captchaId: z.string().trim().min(1).max(64),
|
||||
userSolution: z.string().trim().min(1).max(20),
|
||||
});
|
||||
|
||||
const twoFactorVerifySchema = z.object({
|
||||
ticket: z.string().trim().min(16).max(160),
|
||||
code: z.string().trim().regex(/^\d{6}$/),
|
||||
});
|
||||
|
||||
const twoFactorSettingsSchema = z.object({
|
||||
enabled: z.coerce.boolean(),
|
||||
});
|
||||
|
||||
function publicUser(u: {
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
username: string | null;
|
||||
name: string;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
role: string;
|
||||
status: string;
|
||||
emailVerifiedAt: Date | null;
|
||||
phoneVerifiedAt: Date | null;
|
||||
twoFactorEnabled: boolean;
|
||||
}) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
phone: u.phone,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
bio: u.bio,
|
||||
avatarUrl: u.avatarUrl,
|
||||
role: u.role,
|
||||
status: u.status,
|
||||
emailVerified: !!u.emailVerifiedAt,
|
||||
phoneVerified: !!u.phoneVerifiedAt,
|
||||
twoFactorEnabled: u.twoFactorEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_input' });
|
||||
}
|
||||
const { password, captchaId, userSolution } = parsed.data;
|
||||
const identifier = parsed.data.identifier.replace(/^@/, '').toLowerCase();
|
||||
|
||||
const captchaValid = await verifyCaptcha(captchaId, userSolution);
|
||||
if (!captchaValid) {
|
||||
return reply.code(400).send({ error: 'invalid_captcha' });
|
||||
}
|
||||
|
||||
const ip = req.ip;
|
||||
const failKey = `loginfail:${ip}`;
|
||||
const failCount = Number((await redis.get(failKey)) ?? 0);
|
||||
if (failCount >= LOGIN_FAIL_MAX) {
|
||||
const ttl = await redis.ttl(failKey);
|
||||
reply.header('Retry-After', String(Math.max(ttl, 1)));
|
||||
return reply.code(429).send({ error: 'too_many_attempts', retryAfter: ttl });
|
||||
}
|
||||
|
||||
const user = USERNAME_RE.test(identifier) && !identifier.includes('@')
|
||||
? await prisma.user.findUnique({ where: { username: identifier } })
|
||||
: await prisma.user.findUnique({ where: { email: identifier } });
|
||||
const fakeHash = '$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
const ok = await argon2
|
||||
.verify(user?.passwordHash ?? fakeHash, password)
|
||||
.catch(() => false);
|
||||
|
||||
if (!user || !ok) {
|
||||
const next = await redis.incr(failKey);
|
||||
if (next === 1) await redis.expire(failKey, 15 * 60);
|
||||
const { locked } = await recordLoginFailure(ip, user?.id ?? null, req.headers['user-agent']);
|
||||
if (locked) {
|
||||
return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 });
|
||||
}
|
||||
return reply.code(401).send({ error: 'invalid_credentials' });
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return reply.code(403).send({ error: 'account_' + user.status });
|
||||
}
|
||||
|
||||
if (await checkAccountLock(user.id)) {
|
||||
return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!user.phone) return reply.code(409).send({ error: 'two_factor_phone_required' });
|
||||
const sent = await issueOtp(user.phone);
|
||||
if (!sent.sent) return reply.code(503).send({ error: 'two_factor_unavailable', reason: sent.reason });
|
||||
const ticket = randomBytes(32).toString('base64url');
|
||||
await redis.set(`2fa:${ticket}`, user.id, 'EX', TWO_FACTOR_TTL_SECONDS);
|
||||
await redis.del(failKey);
|
||||
return reply.send({ ok: true, twoFactorRequired: true, ticket });
|
||||
}
|
||||
|
||||
await redis.del(failKey);
|
||||
await logSecurityEvent({ type: 'login_success', userId: user.id, ip, userAgent: req.headers['user-agent'] });
|
||||
|
||||
const pca = user.passwordChangedAt?.getTime() ?? 0;
|
||||
if (req.session?.sid) {
|
||||
await reply.rotateSession({
|
||||
userId: user.id,
|
||||
role: user.role as SessionData['role'],
|
||||
});
|
||||
} else {
|
||||
await reply.createSession({
|
||||
userId: user.id,
|
||||
role: user.role as SessionData['role'],
|
||||
}, pca);
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }).catch(() => undefined);
|
||||
return reply.send({ ok: true, user: publicUser(user) });
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/2fa/verify', async (req, reply) => {
|
||||
const rl = await checkRateLimit(req.ip, '2fa-verify', 5, 300);
|
||||
if (rl.limited) return rateLimitResponse(reply, rl.retryAfter);
|
||||
|
||||
const parsed = twoFactorVerifySchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
const userId = await redis.get(`2fa:${parsed.data.ticket}`);
|
||||
if (!userId) return reply.code(400).send({ error: 'two_factor_ticket_expired' });
|
||||
|
||||
if (await checkAccountLock(userId)) {
|
||||
return reply.code(423).send({ error: 'account_locked', retryAfter: 30 * 60 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.phone) return reply.code(400).send({ error: 'two_factor_phone_required' });
|
||||
const ok = await verifyOtp(user.phone, parsed.data.code);
|
||||
if (!ok) {
|
||||
await logSecurityEvent({ type: '2fa_failure', userId, ip: req.ip, userAgent: req.headers['user-agent'] });
|
||||
return reply.code(400).send({ error: 'invalid_otp' });
|
||||
}
|
||||
await redis.del(`2fa:${parsed.data.ticket}`);
|
||||
await logSecurityEvent({ type: 'login_success', userId: user.id, ip: req.ip, userAgent: req.headers['user-agent'] });
|
||||
|
||||
const pca = user.passwordChangedAt?.getTime() ?? 0;
|
||||
if (req.session?.sid) {
|
||||
await reply.rotateSession({
|
||||
userId: user.id,
|
||||
role: user.role as SessionData['role'],
|
||||
});
|
||||
} else {
|
||||
await reply.createSession({
|
||||
userId: user.id,
|
||||
role: user.role as SessionData['role'],
|
||||
}, pca);
|
||||
}
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }).catch(() => undefined);
|
||||
return reply.send({ ok: true, user: publicUser(user) });
|
||||
});
|
||||
|
||||
app.put('/api/v1/auth/2fa', async (req, reply) => {
|
||||
if (!req.session) return reply.code(401).send({ error: 'unauthenticated' });
|
||||
const parsed = twoFactorSettingsSchema.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return reply.code(404).send({ error: 'user_not_found' });
|
||||
if (parsed.data.enabled && !user.phone) return reply.code(409).send({ error: 'phone_required' });
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: parsed.data.enabled, twoFactorMethod: parsed.data.enabled ? 'sms' : null },
|
||||
});
|
||||
await logSecurityEvent({
|
||||
type: parsed.data.enabled ? '2fa_enabled' : '2fa_disabled',
|
||||
userId: user.id,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
return reply.send({ ok: true, user: publicUser(updated) });
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
||||
await reply.destroySession();
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
if (!req.session) return reply.code(401).send({ error: 'unauthenticated' });
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
await reply.destroySession();
|
||||
return reply.code(401).send({ error: 'unauthenticated' });
|
||||
}
|
||||
return reply.send({ user: publicUser(user) });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user