Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,17 +51,15 @@ 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);
},
};
},
}),

QueueModule,
StorageModule,
UsersModule,
Expand All @@ -66,10 +68,14 @@ import { CacheService } from './cache/cache.service';
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 {}
export class AppModule {}
36 changes: 34 additions & 2 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
15 changes: 14 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
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 { MicrosoftStrategy } from './strategies/microsoft.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<string>('JWT_SECRET', 'change-me-in-env'),
signOptions: { expiresIn: '15m' },
}),
}),
UsersModule,
MailModule,
],
controllers: [AuthController],
providers: [AuthService, GoogleStrategy, JwtStrategy],
providers: [AuthService, GoogleStrategy, MicrosoftStrategy],
exports: [AuthService],
})
Expand Down
129 changes: 100 additions & 29 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,110 @@
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 {
constructor(
private readonly usersService: UsersService,
private readonly mailService: MailService,
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
@InjectRepository(PasswordResetToken)
private readonly tokenRepository: Repository<PasswordResetToken>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
) {}

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<void> {
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,
Expand All @@ -37,37 +114,20 @@ export class AuthService {
await this.tokenRepository.save(resetToken);

const frontendUrl = this.configService.get<string>('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<void> {
// 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');
}
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');
}
Expand Down Expand Up @@ -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 };
}
}
9 changes: 9 additions & 0 deletions backend/src/auth/dtos/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsEmail, IsString } from 'class-validator';

export class LoginDto {
@IsEmail()
email: string;

@IsString()
password: string;
}
6 changes: 6 additions & 0 deletions backend/src/auth/dtos/refresh.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';

export class RefreshDto {
@IsString()
refreshToken: string;
}
16 changes: 16 additions & 0 deletions backend/src/auth/dtos/register.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions backend/src/auth/entities/refresh-token.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading