- 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>
244 lines
8.5 KiB
TypeScript
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.' };
|
|
}
|
|
}
|