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