feat: implement AuthModule with authentication and RBAC features

Added AuthModule with services, controllers, and guards for authentication. Implements session management, role-based access control, 2FA, and DTOs for user login, registration, and token refresh.
This commit is contained in:
Mathis HERRIOT
2026-01-08 15:24:40 +01:00
parent 9406ed9350
commit 42805e371e
12 changed files with 509 additions and 0 deletions

View File

@@ -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<SessionData>(
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<SessionData>(
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<SessionData>(
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<SessionData>(
req,
res,
getSessionOptions(this.configService.get("SESSION_PASSWORD") as string),
);
session.destroy();
return res.json({ message: "User logged out" });
}
}

View File

@@ -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 {}

View File

@@ -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" };
}
}

View File

@@ -0,0 +1,3 @@
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from "class-validator";
export class RefreshDto {
@IsString()
@IsNotEmpty()
refresh_token!: string;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,10 @@
import { IsNotEmpty, IsString, IsUUID } from "class-validator";
export class Verify2faDto {
@IsUUID()
userId!: string;
@IsString()
@IsNotEmpty()
token!: string;
}

View File

@@ -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<boolean> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const session = await getIronSession<SessionData>(
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;
}
}

View File

@@ -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<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<string[]>("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));
}
}

View File

@@ -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)));
}
}

View File

@@ -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
},
});