diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e0809d1..7b34905 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,5 +1,5 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; @@ -7,6 +7,7 @@ import { AppController } from './app.controller'; import { DatabaseModule } from './database/database.module'; import { TenantMiddleware } from './database/tenant.middleware'; import { WriteAccessGuard } from './common/guards/write-access.guard'; +import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor'; import { AuthModule } from './modules/auth/auth.module'; import { OrganizationsModule } from './modules/organizations/organizations.module'; import { UsersModule } from './modules/users/users.module'; @@ -29,6 +30,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module'; import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module'; import { HealthScoresModule } from './modules/health-scores/health-scores.module'; import { BoardPlanningModule } from './modules/board-planning/board-planning.module'; +import { EmailModule } from './modules/email/email.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -81,6 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule'; InvestmentPlanningModule, HealthScoresModule, BoardPlanningModule, + EmailModule, ScheduleModule.forRoot(), ], controllers: [AppController], @@ -89,6 +92,10 @@ import { ScheduleModule } from '@nestjs/schedule'; provide: APP_GUARD, useClass: WriteAccessGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: NoCacheInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/interceptors/no-cache.interceptor.ts b/backend/src/common/interceptors/no-cache.interceptor.ts new file mode 100644 index 0000000..247536d --- /dev/null +++ b/backend/src/common/interceptors/no-cache.interceptor.ts @@ -0,0 +1,16 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; + +/** + * Prevents browsers and proxies from caching authenticated API responses + * containing sensitive financial data (account balances, transactions, PII). + */ +@Injectable() +export class NoCacheInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const res = context.switchToHttp().getResponse(); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private'); + res.setHeader('Pragma', 'no-cache'); + return next.handle(); + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 3cffad7..c10f248 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -6,6 +6,9 @@ import { UseGuards, Request, Get, + HttpCode, + ForbiddenException, + BadRequestException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; @@ -17,15 +20,22 @@ import { SwitchOrgDto } from './dto/switch-org.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; +const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true'; + @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('register') - @ApiOperation({ summary: 'Register a new user' }) + @ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' }) @Throttle({ default: { limit: 5, ttl: 60000 } }) async register(@Body() dto: RegisterDto) { + if (!isOpenRegistration) { + throw new ForbiddenException( + 'Open registration is disabled. Please use an invitation link to create your account.', + ); + } return this.authService.register(dto); } @@ -39,6 +49,16 @@ export class AuthController { return this.authService.login(req.user, ip, ua); } + @Post('logout') + @ApiOperation({ summary: 'Logout (invalidate current session)' }) + @HttpCode(200) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async logout(@Request() req: any) { + await this.authService.logout(req.user.sub); + return { success: true }; + } + @Get('profile') @ApiOperation({ summary: 'Get current user profile' }) @ApiBearerAuth() @@ -67,4 +87,51 @@ export class AuthController { const ua = req.headers['user-agent']; return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua); } + + // ─── 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.' }; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 44d9096..287b2b3 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -21,7 +21,7 @@ import { OrganizationsModule } from '../organizations/organizations.module'; inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '24h' }, + signOptions: { expiresIn: '1h' }, }), }), ], diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index d57b403..ba76a4c 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -4,21 +4,33 @@ import { ConflictException, ForbiddenException, NotFoundException, + BadRequestException, + Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import * as bcrypt from 'bcryptjs'; +import { randomBytes, createHash } from 'crypto'; import { UsersService } from '../users/users.service'; +import { EmailService } from '../email/email.service'; import { RegisterDto } from './dto/register.dto'; import { User } from '../users/entities/user.entity'; @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly appUrl: string; + constructor( private usersService: UsersService, private jwtService: JwtService, + private configService: ConfigService, private dataSource: DataSource, - ) {} + private emailService: EmailService, + ) { + this.appUrl = this.configService.get('APP_URL') || 'http://localhost:5173'; + } async register(dto: RegisterDto) { const existing = await this.usersService.findByEmail(dto.email); @@ -75,6 +87,14 @@ export class AuthService { return this.generateTokenResponse(u); } + /** + * Logout — currently a no-op on the server since JWT is stateless. + * When refresh tokens are added, this should revoke the refresh token. + */ + async logout(_userId: string): Promise { + // Placeholder for refresh token revocation + } + async getProfile(userId: string) { const user = await this.usersService.findByIdWithOrgs(userId); if (!user) { @@ -139,6 +159,105 @@ export class AuthService { await this.usersService.markIntroSeen(userId); } + // ─── Password Reset Flow ────────────────────────────────────────── + + /** + * Request a password reset. Generates a token, stores its hash, and sends an email. + * Silently succeeds even if the email doesn't exist (prevents enumeration). + */ + async requestPasswordReset(email: string): Promise { + const user = await this.usersService.findByEmail(email); + if (!user) { + // Silently return — don't reveal whether the account exists + return; + } + + // Invalidate any existing reset tokens for this user + await this.dataSource.query( + `UPDATE shared.password_reset_tokens SET used_at = NOW() + WHERE user_id = $1 AND used_at IS NULL`, + [user.id], + ); + + // Generate a 64-byte random token + const rawToken = randomBytes(64).toString('base64url'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + await this.dataSource.query( + `INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [user.id, tokenHash, expiresAt], + ); + + const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`; + await this.emailService.sendPasswordResetEmail(user.email, resetUrl); + } + + /** + * Reset password using a valid reset token. + */ + async resetPassword(rawToken: string, newPassword: string): Promise { + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + + const rows = await this.dataSource.query( + `SELECT id, user_id, expires_at, used_at + FROM shared.password_reset_tokens + WHERE token_hash = $1`, + [tokenHash], + ); + + if (rows.length === 0) { + throw new BadRequestException('Invalid or expired reset token'); + } + + const record = rows[0]; + + if (record.used_at) { + throw new BadRequestException('This reset link has already been used'); + } + + if (new Date(record.expires_at) < new Date()) { + throw new BadRequestException('This reset link has expired'); + } + + // Update password + const passwordHash = await bcrypt.hash(newPassword, 12); + await this.dataSource.query( + `UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, + [passwordHash, record.user_id], + ); + + // Mark token as used + await this.dataSource.query( + `UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`, + [record.id], + ); + } + + /** + * Change password for an authenticated user (requires current password). + */ + async changePassword(userId: string, currentPassword: string, newPassword: string): Promise { + const user = await this.usersService.findById(userId); + if (!user || !user.passwordHash) { + throw new UnauthorizedException('User not found'); + } + + const isValid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!isValid) { + throw new UnauthorizedException('Current password is incorrect'); + } + + const passwordHash = await bcrypt.hash(newPassword, 12); + await this.dataSource.query( + `UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, + [passwordHash, userId], + ); + } + + // ─── Private Helpers ────────────────────────────────────────────── + private async recordLoginHistory( userId: string, organizationId: string | null, diff --git a/backend/src/modules/email/email.module.ts b/backend/src/modules/email/email.module.ts new file mode 100644 index 0000000..f91efa6 --- /dev/null +++ b/backend/src/modules/email/email.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Global() +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts new file mode 100644 index 0000000..25fd24f --- /dev/null +++ b/backend/src/modules/email/email.service.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +/** + * Stubbed email service — logs to console and stores in shared.email_log. + * Replace internals with Resend/SendGrid when ready for production. + */ +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + + constructor(private dataSource: DataSource) {} + + async sendPasswordResetEmail(email: string, resetUrl: string): Promise { + const subject = 'Reset your HOA LedgerIQ password'; + const body = [ + `You requested a password reset for your HOA LedgerIQ account.`, + ``, + `Click the link below to reset your password:`, + resetUrl, + ``, + `This link expires in 15 minutes. If you didn't request this, ignore this email.`, + ].join('\n'); + + await this.log(email, subject, body, 'password_reset', { resetUrl }); + } + + private async log( + toEmail: string, + subject: string, + body: string, + template: string, + metadata: Record, + ): Promise { + this.logger.log(`EMAIL STUB -> ${toEmail}`); + this.logger.log(` Subject: ${subject}`); + this.logger.log(` Body:\n${body}`); + + try { + await this.dataSource.query( + `INSERT INTO shared.email_log (to_email, subject, body, template, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [toEmail, subject, body, template, JSON.stringify(metadata)], + ); + } catch (err) { + this.logger.warn(`Failed to log email: ${err}`); + } + } +} diff --git a/db/migrations/016-password-reset-tokens.sql b/db/migrations/016-password-reset-tokens.sql new file mode 100644 index 0000000..82e39ca --- /dev/null +++ b/db/migrations/016-password-reset-tokens.sql @@ -0,0 +1,25 @@ +-- Migration 016: Password Reset Tokens +-- Adds table for password reset token storage (hashed, single-use, short-lived). + +CREATE TABLE IF NOT EXISTS shared.password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON shared.password_reset_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON shared.password_reset_tokens(user_id); + +-- Also ensure email_log table exists (may not exist if migration 015 hasn't been applied) +CREATE TABLE IF NOT EXISTS shared.email_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(500) NOT NULL, + body TEXT, + template VARCHAR(100), + metadata JSONB, + sent_at TIMESTAMPTZ DEFAULT NOW() +); diff --git a/nginx/host-production.conf b/nginx/host-production.conf index d2ea243..c40656d 100644 --- a/nginx/host-production.conf +++ b/nginx/host-production.conf @@ -12,6 +12,9 @@ # # Replace "app.yourdomain.com" with your actual hostname throughout this file. +# Hide nginx version from Server header +server_tokens off; + # --- Rate limiting --- # 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs) limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; @@ -49,6 +52,12 @@ server { ssl_session_timeout 10m; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + # Security headers — applied to all routes + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # --- Proxy defaults --- proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/nginx/production.conf b/nginx/production.conf index 11d0826..8b1831f 100644 --- a/nginx/production.conf +++ b/nginx/production.conf @@ -8,6 +8,9 @@ upstream frontend { keepalive 16; } +# Hide nginx version from Server header +server_tokens off; + # Shared proxy settings proxy_http_version 1.1; proxy_set_header Connection ""; # enable keepalive to upstreams @@ -30,6 +33,12 @@ server { listen 80; server_name _; + # Security headers — applied to all routes at the nginx layer + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # --- API routes → backend --- location /api/ { limit_req zone=api_limit burst=30 nodelay;