diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..bb4b6c4 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,120 @@ +import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Request, Response } from "express"; +import { getIronSession } from "iron-session"; +import { AuthService } from "./auth.service"; +import { LoginDto } from "./dto/login.dto"; +import { RefreshDto } from "./dto/refresh.dto"; +import { RegisterDto } from "./dto/register.dto"; +import { Verify2faDto } from "./dto/verify-2fa.dto"; +import { getSessionOptions, SessionData } from "./session.config"; + +@Controller("auth") +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + @Post("register") + register(@Body() registerDto: RegisterDto) { + return this.authService.register(registerDto); + } + + @Post("login") + async login( + @Body() loginDto: LoginDto, + @Headers("user-agent") userAgent: string, + @Req() req: Request, + @Res() res: Response, + ) { + const ip = req.ip; + const result = await this.authService.login(loginDto, userAgent, ip); + + if (result.access_token) { + const session = await getIronSession( + req, + res, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + session.accessToken = result.access_token; + session.refreshToken = result.refresh_token; + session.userId = result.userId; + await session.save(); + + // On ne renvoie pas les tokens dans le body pour plus de sécurité + return res.json({ + message: result.message, + userId: result.userId, + }); + } + + return res.json(result); + } + + @Post("verify-2fa") + async verifyTwoFactor( + @Body() verify2faDto: Verify2faDto, + @Headers("user-agent") userAgent: string, + @Req() req: Request, + @Res() res: Response, + ) { + const ip = req.ip; + const result = await this.authService.verifyTwoFactorLogin( + verify2faDto.userId, + verify2faDto.token, + userAgent, + ip, + ); + + if (result.access_token) { + const session = await getIronSession( + req, + res, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + session.accessToken = result.access_token; + session.refreshToken = result.refresh_token; + session.userId = verify2faDto.userId; + await session.save(); + + return res.json({ + message: result.message, + }); + } + + return res.json(result); + } + + @Post("refresh") + async refresh(@Req() req: Request, @Res() res: Response) { + const session = await getIronSession( + req, + res, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + + if (!session.refreshToken) { + return res.status(401).json({ message: "No refresh token" }); + } + + const result = await this.authService.refresh(session.refreshToken); + + session.accessToken = result.access_token; + session.refreshToken = result.refresh_token; + await session.save(); + + return res.json({ message: "Token refreshed" }); + } + + @Post("logout") + async logout(@Req() req: Request, @Res() res: Response) { + const session = await getIronSession( + req, + res, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + session.destroy(); + return res.json({ message: "User logged out" }); + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..7d6dfba --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; +import { CryptoModule } from "../crypto/crypto.module"; +import { DatabaseModule } from "../database/database.module"; +import { SessionsModule } from "../sessions/sessions.module"; +import { UsersModule } from "../users/users.module"; +import { AuthController } from "./auth.controller"; +import { AuthService } from "./auth.service"; +import { RbacService } from "./rbac.service"; + +@Module({ + imports: [UsersModule, CryptoModule, SessionsModule, DatabaseModule], + controllers: [AuthController], + providers: [AuthService, RbacService], + exports: [AuthService, RbacService], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..d68e7a4 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,197 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { authenticator } from "otplib"; +import { toDataURL } from "qrcode"; +import { CryptoService } from "../crypto/crypto.service"; +import { SessionsService } from "../sessions/sessions.service"; +import { UsersService } from "../users/users.service"; +import { LoginDto } from "./dto/login.dto"; +import { RegisterDto } from "./dto/register.dto"; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly cryptoService: CryptoService, + private readonly sessionsService: SessionsService, + private readonly configService: ConfigService, + ) {} + + async generateTwoFactorSecret(userId: string) { + const user = await this.usersService.findOne(userId); + if (!user) throw new UnauthorizedException(); + + const secret = authenticator.generateSecret(); + const otpauthUrl = authenticator.keyuri( + user.username, + this.configService.get("DOMAIN_NAME") || "Memegoat", + secret, + ); + + await this.usersService.setTwoFactorSecret(userId, secret); + + const qrCodeDataUrl = await toDataURL(otpauthUrl); + return { + secret, + qrCodeDataUrl, + }; + } + + async enableTwoFactor(userId: string, token: string) { + const secret = await this.usersService.getTwoFactorSecret(userId); + if (!secret) { + throw new BadRequestException("2FA not initiated"); + } + + const isValid = authenticator.verify({ token, secret }); + if (!isValid) { + throw new BadRequestException("Invalid 2FA token"); + } + + await this.usersService.toggleTwoFactor(userId, true); + return { message: "2FA enabled successfully" }; + } + + async disableTwoFactor(userId: string, token: string) { + const secret = await this.usersService.getTwoFactorSecret(userId); + if (!secret) { + throw new BadRequestException("2FA not enabled"); + } + + const isValid = authenticator.verify({ token, secret }); + if (!isValid) { + throw new BadRequestException("Invalid 2FA token"); + } + + await this.usersService.toggleTwoFactor(userId, false); + return { message: "2FA disabled successfully" }; + } + + async register(dto: RegisterDto) { + const { username, email, password } = dto; + + const passwordHash = await this.cryptoService.hashPassword(password); + const emailHash = await this.cryptoService.hashEmail(email); + + const user = await this.usersService.create({ + username, + email, + passwordHash, + emailHash, + }); + + return { + message: "User registered successfully", + userId: user.uuid, + }; + } + + async login(dto: LoginDto, userAgent?: string, ip?: string) { + const { email, password } = dto; + + const emailHash = await this.cryptoService.hashEmail(email); + const user = await this.usersService.findByEmailHash(emailHash); + + if (!user) { + throw new UnauthorizedException("Invalid credentials"); + } + + const isPasswordValid = await this.cryptoService.verifyPassword( + password, + user.passwordHash, + ); + + if (!isPasswordValid) { + throw new UnauthorizedException("Invalid credentials"); + } + + if (user.isTwoFactorEnabled) { + return { + message: "2FA required", + requires2FA: true, + userId: user.uuid, + }; + } + + const accessToken = await this.cryptoService.generateJwt({ + sub: user.uuid, + username: user.username, + }); + + const session = await this.sessionsService.createSession( + user.uuid, + userAgent, + ip, + ); + + return { + message: "User logged in successfully", + access_token: accessToken, + refresh_token: session.refreshToken, + }; + } + + async verifyTwoFactorLogin( + userId: string, + token: string, + userAgent?: string, + ip?: string, + ) { + const user = await this.usersService.findOneWithPrivateData(userId); + if (!user || !user.isTwoFactorEnabled) { + throw new UnauthorizedException(); + } + + const secret = await this.usersService.getTwoFactorSecret(userId); + if (!secret) throw new UnauthorizedException(); + + const isValid = authenticator.verify({ token, secret }); + if (!isValid) { + throw new UnauthorizedException("Invalid 2FA token"); + } + + const accessToken = await this.cryptoService.generateJwt({ + sub: user.uuid, + username: user.username, + }); + + const session = await this.sessionsService.createSession( + user.uuid, + userAgent, + ip, + ); + + return { + message: "User logged in successfully (2FA)", + access_token: accessToken, + refresh_token: session.refreshToken, + }; + } + + async refresh(refreshToken: string) { + const session = await this.sessionsService.refreshSession(refreshToken); + const user = await this.usersService.findOne(session.userId); + + if (!user) { + throw new UnauthorizedException("User not found"); + } + + const accessToken = await this.cryptoService.generateJwt({ + sub: user.uuid, + username: user.username, + }); + + return { + access_token: accessToken, + refresh_token: session.refreshToken, + }; + } + + async logout() { + return { message: "User logged out" }; + } +} diff --git a/backend/src/auth/decorators/roles.decorator.ts b/backend/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..b1efe4b --- /dev/null +++ b/backend/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from "@nestjs/common"; + +export const Roles = (...roles: string[]) => SetMetadata("roles", roles); diff --git a/backend/src/auth/dto/login.dto.ts b/backend/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..c6dacbe --- /dev/null +++ b/backend/src/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsNotEmpty, IsString } from "class-validator"; + +export class LoginDto { + @IsEmail() + email!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/backend/src/auth/dto/refresh.dto.ts b/backend/src/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..ec4dc22 --- /dev/null +++ b/backend/src/auth/dto/refresh.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class RefreshDto { + @IsString() + @IsNotEmpty() + refresh_token!: string; +} diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..51c371b --- /dev/null +++ b/backend/src/auth/dto/register.dto.ts @@ -0,0 +1,14 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from "class-validator"; + +export class RegisterDto { + @IsString() + @IsNotEmpty() + username!: string; + + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; +} diff --git a/backend/src/auth/dto/verify-2fa.dto.ts b/backend/src/auth/dto/verify-2fa.dto.ts new file mode 100644 index 0000000..0f072e1 --- /dev/null +++ b/backend/src/auth/dto/verify-2fa.dto.ts @@ -0,0 +1,10 @@ +import { IsNotEmpty, IsString, IsUUID } from "class-validator"; + +export class Verify2faDto { + @IsUUID() + userId!: string; + + @IsString() + @IsNotEmpty() + token!: string; +} diff --git a/backend/src/auth/guards/auth.guard.ts b/backend/src/auth/guards/auth.guard.ts new file mode 100644 index 0000000..2073569 --- /dev/null +++ b/backend/src/auth/guards/auth.guard.ts @@ -0,0 +1,44 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { getIronSession } from "iron-session"; +import { CryptoService } from "../../crypto/crypto.service"; +import { getSessionOptions, SessionData } from "../session.config"; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly cryptoService: CryptoService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + const session = await getIronSession( + request, + response, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + + const token = session.accessToken; + + if (!token) { + throw new UnauthorizedException(); + } + + try { + const payload = await this.cryptoService.verifyJwt(token); + request.user = payload; + } catch { + throw new UnauthorizedException(); + } + + return true; + } +} diff --git a/backend/src/auth/guards/roles.guard.ts b/backend/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..e4211f5 --- /dev/null +++ b/backend/src/auth/guards/roles.guard.ts @@ -0,0 +1,28 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { RbacService } from "../rbac.service"; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private rbacService: RbacService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.getAllAndOverride("roles", [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + if (!user) { + return false; + } + + const userRoles = await this.rbacService.getUserRoles(user.sub); + return requiredRoles.some((role) => userRoles.includes(role)); + } +} diff --git a/backend/src/auth/rbac.service.ts b/backend/src/auth/rbac.service.ts new file mode 100644 index 0000000..ca429e3 --- /dev/null +++ b/backend/src/auth/rbac.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { eq } from "drizzle-orm"; +import { DatabaseService } from "../database/database.service"; +import { + permissions, + roles, + rolesToPermissions, + usersToRoles, +} from "../database/schemas"; + +@Injectable() +export class RbacService { + constructor(private readonly databaseService: DatabaseService) {} + + async getUserRoles(userId: string) { + const result = await this.databaseService.db + .select({ + slug: roles.slug, + }) + .from(usersToRoles) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(eq(usersToRoles.userId, userId)); + + return result.map((r) => r.slug); + } + + async getUserPermissions(userId: string) { + const result = await this.databaseService.db + .select({ + slug: permissions.slug, + }) + .from(usersToRoles) + .innerJoin( + rolesToPermissions, + eq(usersToRoles.roleId, rolesToPermissions.roleId), + ) + .innerJoin(permissions, eq(rolesToPermissions.permissionId, permissions.id)) + .where(eq(usersToRoles.userId, userId)); + + return Array.from(new Set(result.map((p) => p.slug))); + } +} diff --git a/backend/src/auth/session.config.ts b/backend/src/auth/session.config.ts new file mode 100644 index 0000000..29acc0e --- /dev/null +++ b/backend/src/auth/session.config.ts @@ -0,0 +1,18 @@ +import { SessionOptions } from "iron-session"; + +export interface SessionData { + accessToken?: string; + refreshToken?: string; + userId?: string; +} + +export const getSessionOptions = (password: string): SessionOptions => ({ + password, + cookieName: "memegoat_session", + cookieOptions: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "strict", + maxAge: 60 * 60 * 24 * 7, // 7 days + }, +});