Files
HOA_Financial_Platform/backend/src/modules/auth/auth.controller.ts
olsch01 19fb2c037c feat(security): address findings from v2 security assessment
- L2: Add server_tokens off to nginx configs to hide version
- M1: Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy headers to all nginx routes
- L3: Add global NoCacheInterceptor (Cache-Control: no-store) on all
  API responses to prevent caching of sensitive financial data
- C1: Disable open registration by default (ALLOW_OPEN_REGISTRATION env)
- H3: Add logout endpoint with correct HTTP 200 status code
- M2: Implement full password reset flow (forgot-password, reset-password,
  change-password) with hashed tokens, 15-min expiry, single-use
- Reduce JWT access token expiry from 24h to 1h
- Add EmailService stub (logs to shared.email_log)
- Add DB migration 016 for password_reset_tokens table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:46:11 -04:00

244 lines
8.5 KiB
TypeScript

import {
Controller,
Post,
Patch,
Body,
UseGuards,
Request,
Get,
Res,
Query,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { SwitchOrgDto } from './dto/switch-org.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
function setRefreshCookie(res: Response, token: string) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
});
}
function clearRefreshCookie(res: Response) {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
path: '/api/auth',
});
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const result = await this.authService.register(dto);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
const result = await this.authService.login(req.user, ip, ua);
// MFA challenge — no cookie, just return the challenge token
if ('mfaRequired' in result) {
return result;
}
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken: _rt, ...response } = result as any;
return response;
}
@Post('refresh')
@ApiOperation({ summary: 'Refresh access token using httpOnly cookie' })
async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME];
if (!rawToken) {
throw new BadRequestException('No refresh token');
}
return this.authService.refreshAccessToken(rawToken);
}
@Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' })
@HttpCode(200)
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME];
if (rawToken) {
await this.authService.logout(rawToken);
}
clearRefreshCookie(res);
return { success: true };
}
@Post('logout-everywhere')
@ApiOperation({ summary: 'Revoke all sessions' })
@HttpCode(200)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
await this.authService.logoutEverywhere(req.user.sub);
clearRefreshCookie(res);
return { success: true };
}
@Get('profile')
@ApiOperation({ summary: 'Get current user profile' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getProfile(@Request() req: any) {
return this.authService.getProfile(req.user.sub);
}
@Patch('intro-seen')
@ApiOperation({ summary: 'Mark the how-to intro as seen for the current user' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async markIntroSeen(@Request() req: any) {
await this.authService.markIntroSeen(req.user.sub);
return { success: true };
}
@Post('switch-org')
@ApiOperation({ summary: 'Switch active organization' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) {
const ip = req.headers['x-forwarded-for'] || req.ip;
const ua = req.headers['user-agent'];
const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
// ─── Activation Endpoints ─────────────────────────────────────────
@Get('activate')
@ApiOperation({ summary: 'Validate an activation token' })
async validateActivation(@Query('token') token: string) {
if (!token) throw new BadRequestException('Token required');
return this.authService.validateInviteToken(token);
}
@Post('activate')
@ApiOperation({ summary: 'Activate user account with password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async activate(
@Body() body: { token: string; password: string; fullName: string },
@Res({ passthrough: true }) res: Response,
) {
if (!body.token || !body.password || !body.fullName) {
throw new BadRequestException('Token, password, and fullName are required');
}
if (body.password.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
const result = await this.authService.activateUser(body.token, body.password, body.fullName);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
}
@Post('resend-activation')
@ApiOperation({ summary: 'Resend activation email' })
@Throttle({ default: { limit: 2, ttl: 60000 } })
async resendActivation(@Body() body: { email: string }) {
// Stubbed — will be implemented when email service is ready
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
}
// ─── Password Reset Flow ──────────────────────────────────────────
@Post('forgot-password')
@ApiOperation({ summary: 'Request a password reset email' })
@HttpCode(200)
@Throttle({ default: { limit: 3, ttl: 60000 } })
async forgotPassword(@Body() body: { email: string }) {
if (!body.email) throw new BadRequestException('Email is required');
await this.authService.requestPasswordReset(body.email);
// Always return same message to prevent account enumeration
return { message: 'If that email exists, a password reset link has been sent.' };
}
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@Patch('change-password')
@ApiOperation({ summary: 'Change password (authenticated)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async changePassword(
@Request() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('currentPassword and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword);
return { message: 'Password changed successfully.' };
}
}