feat: Sprint 1 — backend fondations TetaRdPG

Auth SuperOAuth (JWT validation + httpOnly cookie), entités users/characters/level_thresholds,
lazy calculation endurance, seed 100 niveaux, config prod-ready (trust proxy, helmet, CORS, rate limit).
Validé : health 200, auth flow, character CRUD, endurance lazy, 401 sans cookie.
This commit is contained in:
2026-03-15 05:51:02 +01:00
commit da3237bf3f
29 changed files with 7249 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
import {
Controller,
Post,
Get,
Body,
Res,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Response, Request } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
import { SetSessionDto } from './dto/set-session.dto';
import { User } from '../user/user.entity';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('session')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async setSession(
@Body() dto: SetSessionDto,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.setSession(dto, res);
}
@Get('me')
@UseGuards(AuthGuard)
async getMe(@Req() req: Request & { user: User }) {
return this.authService.getMe(req.user);
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Res({ passthrough: true }) res: Response) {
this.authService.logout(res);
}
}

25
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../user/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthGuard, TypeOrmModule],
})
export class AuthModule {}

89
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,89 @@
import {
Injectable,
UnauthorizedException,
InternalServerErrorException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Response } from 'express';
import { User } from '../user/user.entity';
import { SetSessionDto } from './dto/set-session.dto';
// Payload émis par SuperOAuth
interface SuperOAuthPayload {
sub: string; // ID provider (Twitch ID, Discord ID…)
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
username: string;
avatar_url?: string;
iat: number;
exp: number;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
let payload: SuperOAuthPayload;
try {
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
});
} catch {
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
}
if (!payload.sub || !payload.provider || !payload.username) {
throw new UnauthorizedException('Payload JWT incomplet');
}
// Upsert user
let user = await this.userRepository.findOne({
where: { oauthId: payload.sub, provider: payload.provider },
});
if (!user) {
user = this.userRepository.create({
oauthId: payload.sub,
provider: payload.provider,
username: payload.username,
avatarUrl: payload.avatar_url ?? null,
});
} else {
user.username = payload.username;
user.avatarUrl = payload.avatar_url ?? null;
}
await this.userRepository.save(user);
// Cookie httpOnly signé — valeur = UUID interne
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie('session', user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
});
const { oauthId: _, ...safeUser } = user;
return safeUser;
}
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
const { oauthId: _, ...safeUser } = user;
return safeUser;
}
logout(res: Response): void {
res.clearCookie('session');
}
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SetSessionDto {
@IsString()
@IsNotEmpty()
jwt: string;
}

View File

@@ -0,0 +1,41 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../user/user.entity';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const userId: string | false = request.signedCookies?.session;
if (!userId) {
throw new UnauthorizedException('Session manquante ou invalide');
}
// Validation UUID basique avant la requête DB
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
throw new UnauthorizedException('Session invalide');
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable');
}
request.user = user;
return true;
}
}