edtech / apps /api /src /routes /auth.ts
CognxSafeTrack
refactor(debt): resolve all 10 technical debt items from audit
a966957
import { FastifyInstance } from 'fastify';
import { AuthService } from '../services/auth';
import { logger } from '../logger';
import { prisma } from '../services/prisma';
export async function authRoutes(fastify: FastifyInstance) {
// Login with Email/Password
fastify.post('/login', {
config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
organizationId: { type: 'string' }
}
}
}
}, async (request, reply) => {
const { email, password, organizationId: bodyOrgId } = request.body as { email: string; password: string; organizationId?: string };
// If organizationId is omitted (older frontend), resolve it from the user record.
// Security: password is still verified — this doesn't bypass auth.
let user;
if (bodyOrgId) {
logger.info(`[AUTH] Login attempt for ${email} (Org: ${bodyOrgId})`);
user = await AuthService.findUserByEmail(email, bodyOrgId);
} else {
logger.info(`[AUTH] Login attempt for ${email} (no org — resolving from email)`);
user = await AuthService.findUserByEmailOnly(email);
}
if (!user || !user.passwordHash) {
logger.warn(`[AUTH] User not found: ${email} in Org: ${bodyOrgId ?? 'unknown'}`);
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
}
const isValid = await AuthService.verifyPassword(password, user.passwordHash);
if (!isValid) {
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
}
const token = fastify.jwt.sign({
id: user.id,
organizationId: user.organizationId,
role: user.role
});
logger.info(`[AUTH] User ${email} logged in successfully for org ${user.organizationId}`);
return {
token,
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
organizationId: user.organizationId,
organization: user.organization
}
};
});
// Request a password reset link
fastify.post('/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (request, reply) => {
const { email } = request.body as { email: string };
if (!email) return reply.code(400).send({ error: 'Email required' });
// Always return 200 to avoid email enumeration
const user = await prisma.user.findFirst({ where: { email } });
if (!user) return reply.send({ ok: true });
// Sign a short-lived reset token (1 hour) with extra 'purpose' claim not in the standard payload shape
const resetToken = (fastify.jwt.sign as Function)(
{ id: user.id, purpose: 'reset' },
{ expiresIn: '1h' }
) as string;
const adminUrl = process.env.ADMIN_URL || 'https://edtechadmin.netlify.app';
const resetLink = `${adminUrl}/reset-password?token=${resetToken}`;
const { scheduleEmail } = await import('../services/queue');
await scheduleEmail({
to: email,
subject: 'Réinitialisation de votre mot de passe — XAMLÉ',
htmlContent: `<p>Bonjour ${user.name || ''},</p>
<p>Cliquez sur le lien ci-dessous pour réinitialiser votre mot de passe. Ce lien expire dans 1 heure.</p>
<p><a href="${resetLink}" style="background:#1e293b;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;">Réinitialiser mon mot de passe</a></p>
<p>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email.</p>`
});
logger.info({ email }, '[AUTH] Password reset link sent');
return reply.send({ ok: true });
});
// Set new password via reset token
fastify.post('/reset-password', async (request, reply) => {
const { token, password } = request.body as { token: string; password: string };
if (!token || !password) return reply.code(400).send({ error: 'Token and password required' });
if (password.length < 6) return reply.code(400).send({ error: 'Password must be at least 6 characters' });
let payload: { id: string; purpose?: string };
try {
payload = fastify.jwt.verify(token) as { id: string; purpose?: string };
} catch {
return reply.code(401).send({ error: 'Invalid or expired token' });
}
if (payload.purpose !== 'reset') {
return reply.code(401).send({ error: 'Invalid token purpose' });
}
const passwordHash = await AuthService.hashPassword(password);
await prisma.user.update({
where: { id: payload.id },
data: { passwordHash }
});
logger.info({ userId: payload.id }, '[AUTH] Password reset successfully');
return reply.send({ ok: true });
});
// Get current user profile (guarded by JWT)
fastify.get('/me', async (request, reply) => {
const { id } = request.user;
const user = await prisma.user.findUnique({
where: { id },
include: { organization: true }
});
if (!user) {
return reply.code(404).send({ error: 'Not Found', message: 'User not found' });
}
return {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
organizationId: user.organizationId,
organization: user.organization
}
};
});
}