feat(02-03): password reset flow, force-change interceptor, MailModule
- MailModule with SMTP transport configured from ENV variables - MailService for password reset and welcome emails (plain text, i18n) - Password reset flow: request-reset (public), reset-password (token-based) - Change password for logged-in users with current password verification - Admin reset password endpoint (ADMIN/SUPER_ADMIN only, D-03) - ForcePasswordChangeInterceptor blocks all routes except change-password, logout, me when mustChangePassword=true (D-06, Pitfall 5) - Frontend: reset-password request page, token reset page, change-password page - Forgot password link added to login page - MailHog service added to docker-compose.dev.yml for dev email testing - SMTP env vars added to docker-compose.yml (defaults to MailHog) - Complete DE/EN i18n coverage for reset and change password flows - SUS packages installed: @nestjs-modules/mailer, nodemailer, ldapts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Response } from 'express';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private mailService: MailService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,6 +69,7 @@ export class AuthService {
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
|
||||
const token = this.jwtService.sign(payload);
|
||||
@@ -92,4 +103,147 @@ export class AuthService {
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a password reset (D-03 self-service).
|
||||
* T-02-12: Always returns success, even if email not found (prevent enumeration).
|
||||
* T-02-13: Single-use token with 1-hour expiry.
|
||||
*/
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Always return success to prevent email enumeration (T-02-12)
|
||||
if (!user || !user.isActive) {
|
||||
this.logger.log(
|
||||
`Password reset requested for unknown/inactive email: ${email}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a unique reset token
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
// Create the reset token record
|
||||
await this.prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Send the reset email (fire-and-forget, errors logged by MailService)
|
||||
await this.mailService.sendPasswordResetEmail(email, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password using a valid token (D-03 self-service).
|
||||
* T-02-13: Validates token not expired, not used. Marks as used after success.
|
||||
*/
|
||||
async resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
const resetToken = await this.prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!resetToken) {
|
||||
throw new BadRequestException('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
// Check if token has already been used
|
||||
if (resetToken.usedAt) {
|
||||
throw new BadRequestException('Reset token has already been used');
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (resetToken.expiresAt < new Date()) {
|
||||
throw new BadRequestException('Reset token has expired');
|
||||
}
|
||||
|
||||
// Hash the new password and update user
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Mark token as used (T-02-13)
|
||||
await this.prisma.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
this.logger.log(`Password reset completed for user ${resetToken.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password for the currently logged-in user.
|
||||
* Verifies current password before allowing change.
|
||||
*/
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('User not found or has no local password');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValid = await argon2.verify(user.passwordHash, currentPassword);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
mustChangePassword: false,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Password changed for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reset of a user's password (D-03 admin reset).
|
||||
* T-02-15: Only ADMIN/SUPER_ADMIN via RolesGuard.
|
||||
*/
|
||||
async adminResetPassword(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
mustChangePassword: boolean = true,
|
||||
): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash,
|
||||
mustChangePassword,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Admin reset password for user ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user