diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 3cf804d..43b5436 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Headers, Post, Req, Res } from "@nestjs/common"; +import { Body, Controller, Get, Headers, Post, Query, Req, Res } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Throttle } from "@nestjs/throttler"; import type { Request, Response } from "express"; import { getIronSession } from "iron-session"; import { AuthService } from "./auth.service"; +import { BootstrapService } from "./bootstrap.service"; import { LoginDto } from "./dto/login.dto"; import { RegisterDto } from "./dto/register.dto"; import { Verify2faDto } from "./dto/verify-2fa.dto"; @@ -13,6 +14,7 @@ import { getSessionOptions, SessionData } from "./session.config"; export class AuthController { constructor( private readonly authService: AuthService, + private readonly bootstrapService: BootstrapService, private readonly configService: ConfigService, ) {} @@ -120,4 +122,12 @@ export class AuthController { session.destroy(); return res.json({ message: "User logged out" }); } + + @Get("bootstrap-admin") + async bootstrapAdmin( + @Query("token") token: string, + @Query("username") username: string, + ) { + return this.bootstrapService.consumeToken(token, username); + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index b15c0fa..895f907 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -3,6 +3,7 @@ import { SessionsModule } from "../sessions/sessions.module"; import { UsersModule } from "../users/users.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { BootstrapService } from "./bootstrap.service"; import { AuthGuard } from "./guards/auth.guard"; import { OptionalAuthGuard } from "./guards/optional-auth.guard"; import { RolesGuard } from "./guards/roles.guard"; @@ -15,6 +16,7 @@ import { RbacRepository } from "./repositories/rbac.repository"; providers: [ AuthService, RbacService, + BootstrapService, RbacRepository, AuthGuard, OptionalAuthGuard, diff --git a/backend/src/auth/bootstrap.service.spec.ts b/backend/src/auth/bootstrap.service.spec.ts new file mode 100644 index 0000000..8090a36 --- /dev/null +++ b/backend/src/auth/bootstrap.service.spec.ts @@ -0,0 +1,105 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { UnauthorizedException } from "@nestjs/common"; +import { BootstrapService } from "./bootstrap.service"; +import { RbacService } from "./rbac.service"; +import { UsersService } from "../users/users.service"; + +describe("BootstrapService", () => { + let service: BootstrapService; + let rbacService: RbacService; + let usersService: UsersService; + + const mockRbacService = { + countAdmins: jest.fn(), + assignRoleToUser: jest.fn(), + }; + + const mockUsersService = { + findPublicProfile: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BootstrapService, + { provide: RbacService, useValue: mockRbacService }, + { provide: UsersService, useValue: mockUsersService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(BootstrapService); + rbacService = module.get(RbacService); + usersService = module.get(UsersService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("onApplicationBootstrap", () => { + it("should generate a token if no admin exists", async () => { + mockRbacService.countAdmins.mockResolvedValue(0); + const generateTokenSpy = jest.spyOn(service as any, "generateBootstrapToken"); + + await service.onApplicationBootstrap(); + + expect(rbacService.countAdmins).toHaveBeenCalled(); + expect(generateTokenSpy).toHaveBeenCalled(); + }); + + it("should not generate a token if admin exists", async () => { + mockRbacService.countAdmins.mockResolvedValue(1); + const generateTokenSpy = jest.spyOn(service as any, "generateBootstrapToken"); + + await service.onApplicationBootstrap(); + + expect(rbacService.countAdmins).toHaveBeenCalled(); + expect(generateTokenSpy).not.toHaveBeenCalled(); + }); + }); + + describe("consumeToken", () => { + it("should throw UnauthorizedException if token is invalid", async () => { + mockRbacService.countAdmins.mockResolvedValue(0); + await service.onApplicationBootstrap(); + + await expect( + service.consumeToken("wrong-token", "user1"), + ).rejects.toThrow(UnauthorizedException); + }); + + it("should throw UnauthorizedException if user not found", async () => { + mockRbacService.countAdmins.mockResolvedValue(0); + await service.onApplicationBootstrap(); + const token = (service as any).bootstrapToken; + + mockUsersService.findPublicProfile.mockResolvedValue(null); + + await expect(service.consumeToken(token, "user1")).rejects.toThrow( + UnauthorizedException, + ); + }); + + it("should assign admin role and invalidate token on success", async () => { + mockRbacService.countAdmins.mockResolvedValue(0); + await service.onApplicationBootstrap(); + const token = (service as any).bootstrapToken; + + const mockUser = { uuid: "user-uuid", username: "user1" }; + mockUsersService.findPublicProfile.mockResolvedValue(mockUser); + + const result = await service.consumeToken(token, "user1"); + + expect(rbacService.assignRoleToUser).toHaveBeenCalledWith("user-uuid", "admin"); + expect((service as any).bootstrapToken).toBeNull(); + expect(result.message).toContain("user1 is now an administrator"); + }); + }); +}); diff --git a/backend/src/auth/bootstrap.service.ts b/backend/src/auth/bootstrap.service.ts new file mode 100644 index 0000000..d755c55 --- /dev/null +++ b/backend/src/auth/bootstrap.service.ts @@ -0,0 +1,59 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as crypto from "node:crypto"; +import { UsersService } from "../users/users.service"; +import { RbacService } from "./rbac.service"; + +@Injectable() +export class BootstrapService implements OnApplicationBootstrap { + private readonly logger = new Logger(BootstrapService.name); + private bootstrapToken: string | null = null; + + constructor( + private readonly rbacService: RbacService, + private readonly usersService: UsersService, + private readonly configService: ConfigService, + ) {} + + async onApplicationBootstrap() { + const adminCount = await this.rbacService.countAdmins(); + if (adminCount === 0) { + this.generateBootstrapToken(); + } + } + + private generateBootstrapToken() { + this.bootstrapToken = crypto.randomBytes(32).toString("hex"); + const domain = this.configService.get("DOMAIN_NAME") || "localhost"; + const protocol = domain.includes("localhost") ? "http" : "https"; + const url = `${protocol}://${domain}/auth/bootstrap-admin`; + + this.logger.warn("SECURITY ALERT: No administrator found in database."); + this.logger.warn("To create the first administrator, use the following endpoint:"); + this.logger.warn(`Endpoint: GET ${url}?token=${this.bootstrapToken}&username=votre_nom_utilisateur`); + this.logger.warn("Exemple: curl -X GET \"http://localhost/auth/bootstrap-admin?token=...&username=...\""); + this.logger.warn("This token is one-time use only."); + } + + async consumeToken(token: string, username: string) { + if (!this.bootstrapToken || token !== this.bootstrapToken) { + throw new UnauthorizedException("Invalid or expired bootstrap token"); + } + + const user = await this.usersService.findPublicProfile(username); + if (!user) { + throw new UnauthorizedException(`User ${username} not found`); + } + + await this.rbacService.assignRoleToUser(user.uuid, "admin"); + this.bootstrapToken = null; // One-time use + + this.logger.log(`User ${username} has been promoted to administrator via bootstrap token.`); + return { message: `User ${username} is now an administrator` }; + } +} diff --git a/backend/src/auth/rbac.service.ts b/backend/src/auth/rbac.service.ts index 2bfca56..d419b29 100644 --- a/backend/src/auth/rbac.service.ts +++ b/backend/src/auth/rbac.service.ts @@ -51,4 +51,12 @@ export class RbacService implements OnApplicationBootstrap { async getUserPermissions(userId: string) { return this.rbacRepository.findPermissionsByUserId(userId); } + + async countAdmins() { + return this.rbacRepository.countAdmins(); + } + + async assignRoleToUser(userId: string, roleSlug: string) { + return this.rbacRepository.assignRole(userId, roleSlug); + } } diff --git a/backend/src/auth/repositories/rbac.repository.ts b/backend/src/auth/repositories/rbac.repository.ts index e88a6f4..ad3d5d4 100644 --- a/backend/src/auth/repositories/rbac.repository.ts +++ b/backend/src/auth/repositories/rbac.repository.ts @@ -47,6 +47,15 @@ export class RbacRepository { return result.length; } + async countAdmins(): Promise { + const result = await this.databaseService.db + .select({ count: usersToRoles.userId }) + .from(usersToRoles) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(eq(roles.slug, "admin")); + return result.length; + } + async createRole(name: string, slug: string, description?: string) { return this.databaseService.db .insert(roles) @@ -57,4 +66,25 @@ export class RbacRepository { }) .returning(); } + + async assignRole(userId: string, roleSlug: string) { + const role = await this.databaseService.db + .select() + .from(roles) + .where(eq(roles.slug, roleSlug)) + .limit(1); + + if (!role[0]) { + throw new Error(`Role with slug ${roleSlug} not found`); + } + + return this.databaseService.db + .insert(usersToRoles) + .values({ + userId, + roleId: role[0].id, + }) + .onConflictDoNothing() + .returning(); + } }