feat(auth): add bootstrap token flow for initial admin creation

- Introduced `BootstrapService` to handle admin creation when no admins exist.
- Added `/auth/bootstrap-admin` endpoint to consume bootstrap tokens.
- Updated `RbacRepository` to support counting admins and assigning roles.
- Included unit tests for `BootstrapService` to ensure token behavior and admin assignment.
This commit is contained in:
Mathis HERRIOT
2026-01-21 11:07:02 +01:00
parent aff8acebf8
commit 3f0b1e5119
6 changed files with 215 additions and 1 deletions

View File

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