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:
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
/**
|
||||
* Global interceptor: forces users with mustChangePassword=true to change
|
||||
* their password before accessing any other resource (Pitfall 5 / D-06).
|
||||
*
|
||||
* Allowed routes when mustChangePassword=true:
|
||||
* - POST /auth/change-password (the password change endpoint itself)
|
||||
* - POST /auth/logout (user should always be able to log out)
|
||||
* - GET /auth/me (so frontend can detect mustChangePassword flag)
|
||||
*
|
||||
* All other routes return 403 with FORCE_PASSWORD_CHANGE message.
|
||||
* T-02-14: Prevents bypass via direct API access.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
// Skip public routes (login, health, reset-password)
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// No user on request (shouldn't happen after auth guard, but be defensive)
|
||||
if (!user) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// User doesn't need to change password
|
||||
if (!user.mustChangePassword) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// Allow specific routes even when password change is required
|
||||
const path = request.route?.path || request.url;
|
||||
const method = request.method;
|
||||
|
||||
const allowedPaths = [
|
||||
'/auth/change-password',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
];
|
||||
|
||||
if (allowedPaths.some((allowed) => path.includes(allowed))) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// Block all other routes
|
||||
throw new ForbiddenException({
|
||||
statusCode: 403,
|
||||
message: 'FORCE_PASSWORD_CHANGE',
|
||||
error: 'Must change password before continuing',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user