From e4b1fd36985facd089a74bfacb3f785245c30e2f Mon Sep 17 00:00:00 2001 From: TomikeDS Date: Fri, 26 Jun 2026 10:55:31 +0100 Subject: [PATCH] feat: rate limiting, i18n, JWT auth, register/login/refresh - Configure @nestjs/throttler rate limiting for all endpoints (#881) - Wire up nestjs-i18n with English translations (#882) - Implement user registration and login with bcrypt + JWT (#883) - Add refresh token rotation, logout, and GET /auth/me (#884) --- backend/src/app.module.ts | 44 +++++- backend/src/auth/auth.controller.ts | 36 ++++- backend/src/auth/auth.module.ts | 16 ++- backend/src/auth/auth.service.ts | 129 ++++++++++++++---- backend/src/auth/dtos/login.dto.ts | 9 ++ backend/src/auth/dtos/refresh.dto.ts | 6 + backend/src/auth/dtos/register.dto.ts | 16 +++ .../src/auth/entities/refresh-token.entity.ts | 27 ++++ backend/src/auth/strategies/jwt.strategy.ts | 27 ++++ backend/src/i18n/en/translation.json | 41 ++++++ backend/src/users/entities/user.entity.ts | 12 ++ backend/src/users/users.service.ts | 4 + 12 files changed, 329 insertions(+), 38 deletions(-) create mode 100644 backend/src/auth/dtos/login.dto.ts create mode 100644 backend/src/auth/dtos/refresh.dto.ts create mode 100644 backend/src/auth/dtos/register.dto.ts create mode 100644 backend/src/auth/entities/refresh-token.entity.ts create mode 100644 backend/src/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/i18n/en/translation.json diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 414286bd..86bdcdc2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,6 +3,10 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CacheModule } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-store'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; +import { AcceptLanguageResolver, I18nModule, QueryResolver } from 'nestjs-i18n'; +import * as path from 'path'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; @@ -45,27 +49,57 @@ import { CacheService } from './cache/cache.service'; host, port, ttl, - // Guard/Fallback: Non-crashing error mitigation loop for missing/offline redis engines retry_strategy: (options: any) => { if (options.error && options.error.code === 'ECONNREFUSED') { return new Error('Redis connection refused. Operating with inline graceful fallback.'); } - return Math.min(options.attempt * 100, 3000); // Backoff connection retry + return Math.min(options.attempt * 100, 3000); }, }; }, }), + // #881 [BE-08] Configure rate limiting to protect all API endpoints + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + throttlers: [ + { + ttl: 60000, + limit: parseInt(configService.get('THROTTLE_LIMIT', '60'), 10), + }, + ], + }), + }), + + // #882 [BE-09] Wire up nestjs-i18n for internationalization support + I18nModule.forRoot({ + fallbackLanguage: 'en', + loaderOptions: { + path: path.join(__dirname, '/i18n/'), + watch: true, + }, + resolvers: [ + { use: QueryResolver, options: ['lang'] }, + AcceptLanguageResolver, + ], + }), + UsersModule, AuthModule, ], controllers: [AppController], providers: [ AppService, - CacheService, // Registering the shared custom cache service utility directly within global context + CacheService, + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, ], exports: [ - CacheService, // Exporting CacheService so modules implementing custom invalidation can resolve it cleanly + CacheService, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index f18dafaf..a510fdfd 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -10,11 +10,45 @@ import { } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { AuthService } from './auth.service'; +import { RegisterDto } from './dtos/register.dto'; +import { LoginDto } from './dtos/login.dto'; +import { RefreshDto } from './dtos/refresh.dto'; @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} + @Post('register') + async register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + @HttpCode(HttpStatus.OK) + async login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + async refresh(@Body() dto: RefreshDto) { + return this.authService.refresh(dto.refreshToken); + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @UseGuards(AuthGuard('jwt')) + async logout(@Req() req: any) { + await this.authService.logout(req.user.id); + return { message: 'Logged out successfully' }; + } + + @Get('me') + @UseGuards(AuthGuard('jwt')) + async getProfile(@Req() req: any) { + return this.authService.getProfile(req.user.id); + } + @Post('forgot-password') @HttpCode(HttpStatus.OK) async forgotPassword(@Body('email') email: string) { @@ -32,13 +66,11 @@ export class AuthController { @Get('google') @UseGuards(AuthGuard('google')) async googleAuth() { - // Initiates the Google OAuth flow } @Get('google/callback') @UseGuards(AuthGuard('google')) async googleAuthRedirect(@Req() req: any) { - // user is attached to req by the strategy return req.user; } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 1e522df2..96309b4c 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -1,22 +1,34 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './strategies/google.strategy'; +import { JwtStrategy } from './strategies/jwt.strategy'; import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; import { UsersModule } from '../users/users.module'; import { MailModule } from '../mail/mail.module'; @Module({ imports: [ - TypeOrmModule.forFeature([PasswordResetToken]), + TypeOrmModule.forFeature([PasswordResetToken, RefreshToken]), PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'change-me-in-env'), + signOptions: { expiresIn: '15m' }, + }), + }), UsersModule, MailModule, ], controllers: [AuthController], - providers: [AuthService, GoogleStrategy], + providers: [AuthService, GoogleStrategy, JwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 18ac75e5..98efb5b7 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,12 +1,16 @@ -import { Injectable, BadRequestException } from '@nestjs/common'; +import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; import { UsersService } from '../users/users.service'; import { MailService } from '../mail/mail.service'; import { PasswordResetToken } from './entities/password-reset-token.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { RegisterDto } from './dtos/register.dto'; +import { LoginDto } from './dtos/login.dto'; @Injectable() export class AuthService { @@ -14,20 +18,93 @@ export class AuthService { private readonly usersService: UsersService, private readonly mailService: MailService, private readonly configService: ConfigService, + private readonly jwtService: JwtService, @InjectRepository(PasswordResetToken) private readonly tokenRepository: Repository, + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, ) {} + async register(dto: RegisterDto) { + const existing = await this.usersService.findByEmail(dto.email); + if (existing) { + throw new BadRequestException('Email already registered'); + } + + const passwordHash = await bcrypt.hash(dto.password, 10); + const user = await this.usersService.create({ + email: dto.email, + passwordHash, + firstName: dto.firstName, + lastName: dto.lastName, + }); + + return this.generateTokens(user.id, user.email); + } + + async login(dto: LoginDto) { + const user = await this.usersService.findByEmail(dto.email); + if (!user || !user.passwordHash) { + throw new UnauthorizedException('Invalid credentials'); + } + + const isValid = await bcrypt.compare(dto.password, user.passwordHash); + if (!isValid) { + throw new UnauthorizedException('Invalid credentials'); + } + + return this.generateTokens(user.id, user.email); + } + + async refresh(refreshToken: string) { + const tokenHash = await bcrypt.hash(refreshToken, 10); + const tokens = await this.refreshTokenRepository.find({ + where: { revokedAt: null }, + relations: ['user'], + }); + + let validToken: RefreshToken | null = null; + for (const token of tokens) { + if (token.expiresAt < new Date()) { + continue; + } + const isValid = await bcrypt.compare(refreshToken, token.tokenHash); + if (isValid) { + validToken = token; + break; + } + } + + if (!validToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + validToken.revokedAt = new Date(); + await this.refreshTokenRepository.save(validToken); + + return this.generateTokens(validToken.userId, validToken.user.email); + } + + async logout(userId: string) { + await this.refreshTokenRepository.update( + { userId, revokedAt: null }, + { revokedAt: new Date() }, + ); + } + + async getProfile(userId: string) { + return this.usersService.findById(userId); + } + async forgotPassword(email: string): Promise { const user = await this.usersService.findByEmail(email); if (!user) { - // Always return 200 without revealing if email exists return; } const rawToken = crypto.randomBytes(32).toString('hex'); const tokenHash = await bcrypt.hash(rawToken, 10); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); const resetToken = this.tokenRepository.create({ userId: user.id, @@ -37,29 +114,12 @@ export class AuthService { await this.tokenRepository.save(resetToken); const frontendUrl = this.configService.get('FRONTEND_URL', 'http://localhost:3000'); - const resetLink = `${frontendUrl}/auth/reset-password?token=${rawToken}`; - + const resetLink = `${frontendUrl}/auth/reset-password?token=${resetToken.id}.${rawToken}`; + await this.mailService.sendPasswordResetEmail(email, resetLink); } async resetPassword(token: string, newPassword: string): Promise { - // We don't have the plain token stored, we must compare hashes. - // However, to find the token we would typically pass a user id or token id. - // Since the acceptance criteria only says "POST /auth/reset-password body: { token, newPassword }", - // and raw tokens are typically purely random strings, finding the token requires either embedding the id in the token or scanning. - // A common practice is token format: `${tokenId}.${rawTokenString}`. - // For simplicity, we'll assume the client sends the raw token. To find it, we need to iterate over unused tokens or format it. - // Let's implement the standard approach: scan valid unused tokens or we should have generated the token as `${tokenId}:${rawToken}`. - // I'll update the forgotPassword to just use the raw token as the lookup key, but the instructions say "stores hashed version". - // If it stores the hashed version, we can't look it up by raw token efficiently without the ID. - // Let's assume the token passed IS the raw token, but we will find ALL unused valid tokens and compare. - // Or better: the token is just securely hashed. Wait, if it's bcrypt, we can't query by hash. - // Let's use crypto.createHash('sha256') for fast querying! Bcrypt is for passwords. - // But acceptance criteria: "tokenHash (bcrypt hash of the raw token)". - // If it's a bcrypt hash, we MUST fetch tokens and use bcrypt.compare. - // Fetching all tokens in DB is bad. We must append userId or tokenId to the token sent to user. - // Let's assume the token sent to user is `${resetToken.id}.${rawToken}`! - const parts = token.split('.'); if (parts.length !== 2) { throw new BadRequestException('Invalid token format'); @@ -67,7 +127,7 @@ export class AuthService { const [tokenId, rawToken] = parts; const resetToken = await this.tokenRepository.findOne({ where: { id: tokenId } }); - + if (!resetToken) { throw new BadRequestException('Token not found, expired, or already used'); } @@ -103,11 +163,22 @@ export class AuthService { }); } - // Generate tokens (dummy for now) - return { - accessToken: 'dummy-access-token', - refreshToken: 'dummy-refresh-token', - user, - }; + return this.generateTokens(user.id, user.email); + } + + private async generateTokens(userId: string, email: string) { + const accessToken = this.jwtService.sign({ sub: userId, email }); + const refreshTokenRaw = crypto.randomBytes(48).toString('hex'); + const refreshTokenHash = await bcrypt.hash(refreshTokenRaw, 10); + + await this.refreshTokenRepository.save( + this.refreshTokenRepository.create({ + userId, + tokenHash: refreshTokenHash, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }), + ); + + return { accessToken, refreshToken: refreshTokenRaw }; } } diff --git a/backend/src/auth/dtos/login.dto.ts b/backend/src/auth/dtos/login.dto.ts new file mode 100644 index 00000000..1d3893de --- /dev/null +++ b/backend/src/auth/dtos/login.dto.ts @@ -0,0 +1,9 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email: string; + + @IsString() + password: string; +} diff --git a/backend/src/auth/dtos/refresh.dto.ts b/backend/src/auth/dtos/refresh.dto.ts new file mode 100644 index 00000000..31e4410e --- /dev/null +++ b/backend/src/auth/dtos/refresh.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshDto { + @IsString() + refreshToken: string; +} diff --git a/backend/src/auth/dtos/register.dto.ts b/backend/src/auth/dtos/register.dto.ts new file mode 100644 index 00000000..0cdc962f --- /dev/null +++ b/backend/src/auth/dtos/register.dto.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsString, MinLength } from 'class-validator'; + +export class RegisterDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(8) + password: string; + + @IsString() + firstName: string; + + @IsString() + lastName: string; +} diff --git a/backend/src/auth/entities/refresh-token.entity.ts b/backend/src/auth/entities/refresh-token.entity.ts new file mode 100644 index 00000000..3a963969 --- /dev/null +++ b/backend/src/auth/entities/refresh-token.entity.ts @@ -0,0 +1,27 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column() + tokenHash: string; + + @Column() + expiresAt: Date; + + @Column({ nullable: true }) + revokedAt: Date; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 00000000..023e528f --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,27 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private readonly configService: ConfigService, + private readonly usersService: UsersService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET', 'change-me-in-env'), + }); + } + + async validate(payload: { sub: string; email: string }) { + const user = await this.usersService.findById(payload.sub); + if (!user) { + throw new UnauthorizedException(); + } + return user; + } +} diff --git a/backend/src/i18n/en/translation.json b/backend/src/i18n/en/translation.json new file mode 100644 index 00000000..9bb4836d --- /dev/null +++ b/backend/src/i18n/en/translation.json @@ -0,0 +1,41 @@ +{ + "auth": { + "login": { + "success": "Login successful", + "invalid": "Invalid credentials" + }, + "register": { + "success": "Registration successful", + "email_exists": "Email already registered" + }, + "logout": { + "success": "Logged out successfully" + }, + "forgot_password": { + "success": "If the email exists, a reset link has been sent." + }, + "reset_password": { + "success": "Password has been reset successfully" + } + }, + "users": { + "not_found": "User not found", + "created": "User created successfully", + "updated": "User updated successfully", + "deleted": "User deleted successfully" + }, + "assets": { + "not_found": "Asset not found", + "created": "Asset created successfully", + "updated": "Asset updated successfully", + "deleted": "Asset deleted successfully", + "checked_out": "Asset checked out successfully", + "checked_in": "Asset checked in successfully" + }, + "errors": { + "internal_server_error": "Internal server error", + "not_found": "Resource not found", + "forbidden": "Access denied", + "unauthorized": "Unauthorized" + } +} diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index f98784fd..7090e129 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -14,6 +14,18 @@ export class User { @Column({ nullable: true }) googleId: string; + @Column({ nullable: true }) + firstName: string; + + @Column({ nullable: true }) + lastName: string; + + @Column({ nullable: true }) + avatarUrl: string; + + @Column({ default: true }) + isActive: boolean; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index dae01d6b..31e317a7 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -14,6 +14,10 @@ export class UsersService { return this.userRepository.findOne({ where: { email } }); } + async findById(id: string): Promise { + return this.userRepository.findOne({ where: { id } }); + } + async findByGoogleId(googleId: string): Promise { return this.userRepository.findOne({ where: { googleId } }); }