Skip to content

Commit cde5445

Browse files
feat: enforce backend rate limiting and secure refresh cookies
1 parent cadcc7a commit cde5445

5 files changed

Lines changed: 72 additions & 10 deletions

File tree

apps/backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"prisma": "^6.4.1",
3535
"reflect-metadata": "^0.2.2",
3636
"rxjs": "^7.8.1",
37-
"@sentry/node": "^10.6.0"
37+
"@sentry/node": "^10.6.0",
38+
"@nestjs/throttler": "^6.4.0"
3839
},
3940
"devDependencies": {
4041
"@nestjs/cli": "^11.0.0",

apps/backend/src/app.module.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { Module } from '@nestjs/common';
1+
import { Module } from '@nestjs/common';
2+
import { APP_GUARD } from '@nestjs/core';
23
import { ConfigModule } from '@nestjs/config';
4+
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
35
import { AppConfigModule } from './config/app-config.module';
46
import { AuthModule } from './auth/auth.module';
57
import { UsersModule } from './users/users.module';
@@ -18,6 +20,12 @@ import { ActivityModule } from './activity/activity.module';
1820
@Module({
1921
imports: [
2022
ConfigModule.forRoot({ isGlobal: true }),
23+
ThrottlerModule.forRoot([
24+
{
25+
ttl: 60_000,
26+
limit: 100,
27+
},
28+
]),
2129
AppConfigModule,
2230
PrismaModule,
2331
RedisModule,
@@ -33,5 +41,11 @@ import { ActivityModule } from './activity/activity.module';
3341
ReceiptsModule,
3442
NotificationsModule,
3543
],
44+
providers: [
45+
{
46+
provide: APP_GUARD,
47+
useClass: ThrottlerGuard,
48+
},
49+
],
3650
})
3751
export class AppModule {}
Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Body, Controller, Post } from '@nestjs/common';
1+
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
2+
import { Request, Response } from 'express';
23
import { AuthService } from './auth.service';
34
import { RegisterDto } from './dto/register.dto';
45
import { LoginDto } from './dto/login.dto';
@@ -9,17 +10,35 @@ export class AuthController {
910
constructor(private readonly authService: AuthService) {}
1011

1112
@Post('register')
12-
register(@Body() dto: RegisterDto) {
13-
return this.authService.register(dto);
13+
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
14+
const payload = await this.authService.register(dto);
15+
this.authService.setRefreshTokenCookie(res, payload.refreshToken);
16+
return payload;
1417
}
1518

1619
@Post('login')
17-
login(@Body() dto: LoginDto) {
18-
return this.authService.login(dto);
20+
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
21+
const payload = await this.authService.login(dto);
22+
this.authService.setRefreshTokenCookie(res, payload.refreshToken);
23+
return payload;
1924
}
2025

2126
@Post('google')
22-
google(@Body() dto: GoogleLoginDto) {
23-
return this.authService.googleLogin(dto);
27+
async google(@Body() dto: GoogleLoginDto, @Res({ passthrough: true }) res: Response) {
28+
const payload = await this.authService.googleLogin(dto);
29+
this.authService.setRefreshTokenCookie(res, payload.refreshToken);
30+
return payload;
31+
}
32+
33+
@Post('refresh')
34+
async refresh(@Req() req: Request & { cookies?: Record<string, string> }, @Res({ passthrough: true }) res: Response) {
35+
const token = req.cookies?.refresh_token;
36+
if (!token) {
37+
return { message: 'Missing refresh token' };
38+
}
39+
40+
const payload = await this.authService.refresh(token);
41+
this.authService.setRefreshTokenCookie(res, payload.refreshToken);
42+
return payload;
2443
}
2544
}

apps/backend/src/auth/auth.service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
22
import { JwtService } from '@nestjs/jwt';
33
import { compare, hash } from 'bcrypt';
44
import { randomUUID } from 'crypto';
5+
import { Response } from 'express';
56
import { AuthTokensDto } from '@fairshare/shared-types';
67
import { PrismaService } from '../common/prisma.service';
78
import { AppConfigService } from '../config/app-config.service';
@@ -74,7 +75,9 @@ export class AuthService {
7475
});
7576

7677
const matched = await Promise.any(
77-
tokens.map(async (token) => ((await compare(refreshToken, token.tokenHash)) ? token : Promise.reject(new Error('No match')))),
78+
tokens.map(async (token) =>
79+
(await compare(refreshToken, token.tokenHash)) ? token : Promise.reject(new Error('No match')),
80+
),
7881
).catch(() => null);
7982

8083
if (!matched) {
@@ -89,6 +92,16 @@ export class AuthService {
8992
return this.issueTokens(payload.sub, payload.email);
9093
}
9194

95+
setRefreshTokenCookie(res: Response, refreshToken: string): void {
96+
res.cookie('refresh_token', refreshToken, {
97+
httpOnly: true,
98+
secure: process.env.NODE_ENV === 'production',
99+
sameSite: 'lax',
100+
path: '/api/v1/auth/refresh',
101+
maxAge: 7 * 24 * 60 * 60 * 1000,
102+
});
103+
}
104+
92105
private async issueTokens(userId: string, email: string): Promise<AuthTokensDto> {
93106
const payload: JwtPayload = { sub: userId, email };
94107

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)