285 lines
8.7 KiB
TypeScript
285 lines
8.7 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import { z } from 'zod';
|
|
import bcrypt from 'bcryptjs';
|
|
import { UserRole, UserStatus } from '@prisma/client';
|
|
import { authService } from './auth.service';
|
|
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
|
import { validate } from '../../middleware/validate';
|
|
import { authenticate } from '../../middleware/auth.middleware';
|
|
import { authRateLimit } from '../../middleware/rate-limit';
|
|
import { prisma } from '../../config/database';
|
|
import { verificationTokenService } from '../../services/verification-token.service';
|
|
import { passwordResetTokenService } from '../../services/password-reset-token.service';
|
|
import { emailService } from '../../services/email.service';
|
|
import { siteSettingsService } from '../settings/settings.service';
|
|
import { env } from '../../config/env';
|
|
import { logger } from '../../utils/logger';
|
|
import { createVerificationRateLimit, createResetRateLimit } from './auth.rate-limits';
|
|
|
|
const router = Router();
|
|
|
|
// POST /api/auth/login
|
|
router.post(
|
|
'/login',
|
|
authRateLimit,
|
|
validate(loginSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const result = await authService.login(req.body.email, req.body.password);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/register
|
|
router.post(
|
|
'/register',
|
|
authRateLimit,
|
|
validate(registerSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const result = await authService.register(req.body);
|
|
res.status(201).json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/verify-email
|
|
const verifyEmailSchema = z.object({ token: z.string().min(1) });
|
|
|
|
router.post(
|
|
'/verify-email',
|
|
authRateLimit,
|
|
validate(verifyEmailSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const { token } = req.body;
|
|
const result = await verificationTokenService.verifyToken(token);
|
|
|
|
if (!result.valid || !result.userId) {
|
|
res.status(400).json({
|
|
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const settings = await siteSettingsService.get();
|
|
const autoApprove = settings.autoApproveVerifiedUsers;
|
|
|
|
const newStatus = autoApprove ? UserStatus.ACTIVE : UserStatus.PENDING_APPROVAL;
|
|
|
|
await prisma.user.update({
|
|
where: { id: result.userId },
|
|
data: { emailVerified: true, status: newStatus },
|
|
});
|
|
|
|
// If not auto-approved, notify admins
|
|
if (!autoApprove) {
|
|
const user = await prisma.user.findUnique({ where: { id: result.userId } });
|
|
if (user) {
|
|
const admins = await prisma.user.findMany({
|
|
where: { role: UserRole.SUPER_ADMIN, status: UserStatus.ACTIVE },
|
|
select: { email: true },
|
|
});
|
|
if (admins.length > 0) {
|
|
await emailService.sendPendingApprovalNotification({
|
|
adminEmails: admins.map(a => a.email),
|
|
newUserEmail: user.email,
|
|
newUserName: user.name || '',
|
|
}).catch(err => logger.error('Failed to send approval notification:', err));
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
verified: true,
|
|
approved: autoApprove,
|
|
message: autoApprove
|
|
? 'Email verified. You can now log in.'
|
|
: 'Email verified. Your account is pending admin approval.',
|
|
});
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/resend-verification
|
|
const resendVerificationSchema = z.object({ email: z.string().email() });
|
|
|
|
router.post(
|
|
'/resend-verification',
|
|
createVerificationRateLimit(),
|
|
validate(resendVerificationSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
// Always return success to prevent user enumeration
|
|
res.json({ message: 'If your email is registered and pending verification, a new verification link has been sent.' });
|
|
|
|
// Send asynchronously (don't block response)
|
|
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
|
|
if (user && user.status === UserStatus.PENDING_VERIFICATION) {
|
|
const token = await verificationTokenService.createToken(user.id);
|
|
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
|
|
const verificationUrl = `${adminUrl}/verify-email?token=${token}`;
|
|
await emailService.sendVerificationEmail({
|
|
recipientEmail: user.email,
|
|
recipientName: user.name || 'there',
|
|
verificationUrl,
|
|
}).catch(err => logger.error('Failed to resend verification email:', err));
|
|
}
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/forgot-password
|
|
const forgotPasswordSchema = z.object({ email: z.string().email() });
|
|
|
|
router.post(
|
|
'/forgot-password',
|
|
createResetRateLimit(),
|
|
validate(forgotPasswordSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
// Always return success to prevent user enumeration
|
|
res.json({ message: 'If your email is registered, a password reset link has been sent.' });
|
|
|
|
// Send asynchronously
|
|
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
|
|
if (user && user.status === UserStatus.ACTIVE) {
|
|
const token = await passwordResetTokenService.createToken(user.id);
|
|
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
|
|
const resetUrl = `${adminUrl}/reset-password?token=${token}`;
|
|
await emailService.sendPasswordResetEmail({
|
|
recipientEmail: user.email,
|
|
recipientName: user.name || 'there',
|
|
resetUrl,
|
|
}).catch(err => logger.error('Failed to send password reset email:', err));
|
|
}
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/reset-password
|
|
const resetPasswordSchema = z.object({
|
|
token: z.string().min(1),
|
|
password: z.string()
|
|
.min(12, 'Password must be at least 12 characters')
|
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
.regex(/[0-9]/, 'Password must contain at least one digit'),
|
|
});
|
|
|
|
router.post(
|
|
'/reset-password',
|
|
authRateLimit,
|
|
validate(resetPasswordSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const { token, password } = req.body;
|
|
const result = await passwordResetTokenService.validateToken(token);
|
|
|
|
if (!result.valid || !result.userId) {
|
|
res.status(400).json({
|
|
error: { message: result.error || 'Invalid token', code: 'INVALID_TOKEN' },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const hashedPassword = await bcrypt.hash(password, 12);
|
|
|
|
// Update password, mark token used, invalidate all refresh tokens
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.user.update({
|
|
where: { id: result.userId },
|
|
data: { password: hashedPassword },
|
|
});
|
|
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
|
|
});
|
|
|
|
await passwordResetTokenService.markTokenUsed(token);
|
|
|
|
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/refresh
|
|
router.post(
|
|
'/refresh',
|
|
authRateLimit,
|
|
validate(refreshSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const result = await authService.refreshTokens(req.body.refreshToken);
|
|
res.json(result);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// POST /api/auth/logout
|
|
router.post(
|
|
'/logout',
|
|
authRateLimit,
|
|
validate(refreshSchema),
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
await authService.logout(req.body.refreshToken);
|
|
res.json({ message: 'Logged out' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
// GET /api/auth/me
|
|
router.get(
|
|
'/me',
|
|
authenticate,
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.id },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
phone: true,
|
|
role: true,
|
|
roles: true,
|
|
status: true,
|
|
permissions: true,
|
|
createdVia: true,
|
|
emailVerified: true,
|
|
lastLoginAt: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
res.status(401).json({ error: { message: 'Invalid token', code: 'INVALID_TOKEN' } });
|
|
return;
|
|
}
|
|
|
|
res.json(user);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
);
|
|
|
|
export { router as authRouter };
|