Compare commits
1 Commits
claude/ten
...
fb20c917e1
| Author | SHA1 | Date | |
|---|---|---|---|
| fb20c917e1 |
@@ -1,5 +1,5 @@
|
|||||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
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 { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
import { UsersModule } from './modules/users/users.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 { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||||
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -81,6 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
BoardPlanningModule,
|
BoardPlanningModule,
|
||||||
|
EmailModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
@@ -89,6 +92,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: NoCacheInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
|
|||||||
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal file
16
backend/src/common/interceptors/no-cache.interceptor.ts
Normal file
@@ -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<any> {
|
||||||
|
const res = context.switchToHttp().getResponse();
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
@@ -17,15 +20,22 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
|
|||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
|
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
@Post('register')
|
@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 } })
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
async register(@Body() dto: RegisterDto) {
|
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);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +49,16 @@ export class AuthController {
|
|||||||
return this.authService.login(req.user, ip, ua);
|
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')
|
@Get('profile')
|
||||||
@ApiOperation({ summary: 'Get current user profile' })
|
@ApiOperation({ summary: 'Get current user profile' })
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -67,4 +87,51 @@ export class AuthController {
|
|||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
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.' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
secret: configService.get<string>('JWT_SECRET'),
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
signOptions: { expiresIn: '24h' },
|
signOptions: { expiresIn: '1h' },
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,21 +4,33 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes, createHash } from 'crypto';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { EmailService } from '../email/email.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
private readonly appUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private configService: ConfigService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
) {}
|
private emailService: EmailService,
|
||||||
|
) {
|
||||||
|
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
||||||
|
}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
async register(dto: RegisterDto) {
|
||||||
const existing = await this.usersService.findByEmail(dto.email);
|
const existing = await this.usersService.findByEmail(dto.email);
|
||||||
@@ -75,6 +87,14 @@ export class AuthService {
|
|||||||
return this.generateTokenResponse(u);
|
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<void> {
|
||||||
|
// Placeholder for refresh token revocation
|
||||||
|
}
|
||||||
|
|
||||||
async getProfile(userId: string) {
|
async getProfile(userId: string) {
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -139,6 +159,105 @@ export class AuthService {
|
|||||||
await this.usersService.markIntroSeen(userId);
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(
|
private async recordLoginHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
organizationId: string | null,
|
organizationId: string | null,
|
||||||
|
|||||||
9
backend/src/modules/email/email.module.ts
Normal file
9
backend/src/modules/email/email.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { EmailService } from './email.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EmailService],
|
||||||
|
exports: [EmailService],
|
||||||
|
})
|
||||||
|
export class EmailModule {}
|
||||||
49
backend/src/modules/email/email.service.ts
Normal file
49
backend/src/modules/email/email.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string, any>,
|
||||||
|
): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
db/migrations/016-password-reset-tokens.sql
Normal file
25
db/migrations/016-password-reset-tokens.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
#
|
#
|
||||||
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
|
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
|
||||||
|
|
||||||
|
# Hide nginx version from Server header
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
# --- Rate limiting ---
|
# --- Rate limiting ---
|
||||||
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
|
# 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;
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
@@ -49,6 +52,12 @@ server {
|
|||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
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 defaults ---
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ upstream frontend {
|
|||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Hide nginx version from Server header
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
# Shared proxy settings
|
# Shared proxy settings
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection ""; # enable keepalive to upstreams
|
proxy_set_header Connection ""; # enable keepalive to upstreams
|
||||||
@@ -30,6 +33,12 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
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 ---
|
# --- API routes → backend ---
|
||||||
location /api/ {
|
location /api/ {
|
||||||
limit_req zone=api_limit burst=30 nodelay;
|
limit_req zone=api_limit burst=30 nodelay;
|
||||||
|
|||||||
Reference in New Issue
Block a user