feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature

This commit is contained in:
diallolatoile 2025-10-24 16:17:40 +00:00
commit e5f44ffdc0
37 changed files with 14263 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

35
eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11490
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "dcb-user-service",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.1.7",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.7",
"@nestjs/jwt": "^11.0.1",
"@nestjs/platform-express": "^11.1.7",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"axios": "^1.12.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"helmet": "^8.1.0",
"jwt-decode": "^4.0.0",
"keycloak-connect": "^26.1.1",
"nest-keycloak-connect": "^1.10.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.38.0",
"@nestjs/cli": "^11.0.10",
"@nestjs/schematics": "^11.0.9",
"@nestjs/testing": "^11.1.7",
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/joi": "^17.2.3",
"@types/node": "^24.9.1",
"@types/passport-http-bearer": "^1.0.42",
"@types/supertest": "^6.0.3",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"globals": "^16.4.0",
"jest": "^30.2.0",
"prettier": "^3.6.2",
"source-map-support": "^0.5.21",
"supertest": "^7.1.4",
"ts-jest": "^29.4.5",
"ts-loader": "^9.5.4",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

10
src/api/api.module.ts Normal file
View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ApiController } from './controllers/api.controller';
@Module({
imports: [AuthModule],
controllers: [ApiController],
})
export class ApiModule {}

View File

@ -0,0 +1,33 @@
import { Controller, Get, Req, UseGuards, Logger } from '@nestjs/common';
import { ClientCredentialsGuard } from '../../auth/guards/client-credentials.guard';
import { Roles } from '../../decorators/roles.decorator';
@Controller('api')
@UseGuards(ClientCredentialsGuard) // applique le guard à tout le controller
export class ApiController {
private readonly logger = new Logger(ApiController.name);
@Get('protected')
@Roles('DCB_ADMIN') // ex: uniquement les clients avec DCB_ADMIN peuvent accéder
getProtected() {
this.logger.log('Accessed protected route');
return {
message: 'Protected route accessed successfully',
time: new Date().toISOString(),
};
}
@Get('protected-data')
@Roles('DCB_MANAGER', 'DCB_ADMIN') // plusieurs rôles possibles
getProtectedData(@Req() request: Request) {
const token = request['accessToken']; // injecté par le ClientCredentialsGuard
this.logger.log('Accessing protected data with client credentials');
return {
message: 'This is protected data accessed using client credentials',
timestamp: new Date().toISOString(),
tokenPreview: token ? `${token.substring(0, 20)}...` : 'No token',
};
}
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

12
src/app.controller.ts.org Normal file
View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

95
src/app.module.ts Normal file
View File

@ -0,0 +1,95 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import {
AuthGuard,
KeycloakConnectConfig,
KeycloakConnectModule,
ResourceGuard,
RoleGuard,
TokenValidation,
} from 'nest-keycloak-connect';
import { HttpModule } from '@nestjs/axios';
import { TerminusModule } from '@nestjs/terminus';
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
import { AuthModule } from './auth/auth.module';
import { ApiModule } from './api/api.module';
import { HealthModule } from './health/health.module';
import { UsersModule } from './users/users.module';
@Module({
imports: [
// Configuration Module
ConfigModule.forRoot({
isGlobal: true,
load: [keycloakConfig],
validationSchema: keycloakConfigValidationSchema,
validationOptions: {
allowUnknown: false,
abortEarly: true,
},
}),
// Keycloak Connect Module (Async configuration)
KeycloakConnectModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService): KeycloakConnectConfig => {
const keycloakConfig = configService.get('keycloak');
return {
authServerUrl: keycloakConfig.serverUrl,
realm: keycloakConfig.realm,
clientId: keycloakConfig.clientId,
secret: keycloakConfig.clientSecret,
useNestLogger: true,
bearerOnly: true,
/**
* Validation OFFLINE :
* Le token JWT est validé localement (RS256 signature)
* Aucun appel réseau vers Keycloak pour introspection.
*/
tokenValidation: TokenValidation.OFFLINE,
// Optional: Add more Keycloak options as needed
// publicClient: false,
// verifyTokenAudience: true,
// confidentialPort: 0,
};
},
}),
// HTTP and Health Modules
HttpModule.register({
timeout: 5000,
maxRedirects: 5,
}),
TerminusModule,
// Feature Modules
AuthModule,
ApiModule,
HealthModule,
UsersModule,
],
providers: [
// Global Authentication Guard
{
provide: APP_GUARD,
useClass: AuthGuard,
},
// Global Resource Guard
{
provide: APP_GUARD,
useClass: ResourceGuard,
},
// Global Role Guard
{
provide: APP_GUARD,
useClass: RoleGuard,
},
],
})
export class AppModule {}

8
src/app.service.ts.org Normal file
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { JwtModule } from '@nestjs/jwt';
import { StartupService } from './services/startup.service';
import { TokenService } from './services/token.service';
import { KeycloakApiService } from './services/keycloak-api.service';
import { AuthController } from './controllers/auth.controller';
import { HealthController } from '../health/health.controller';
import { UsersService } from '../users/services/users.service';
@Module({
imports: [
HttpModule,
JwtModule.register({}),
],
providers: [StartupService, TokenService, KeycloakApiService, UsersService],
controllers: [AuthController, HealthController],
exports: [StartupService, TokenService, KeycloakApiService, UsersService, JwtModule],
})
export class AuthModule {}

View File

@ -0,0 +1,203 @@
import {
Controller,
Req,
Get,
Post,
Body,
Param,
Logger,
HttpException,
HttpStatus,
} from '@nestjs/common';
import {
AuthenticatedUser,
Public,
Roles,
} from 'nest-keycloak-connect';
import { TokenService } from '../services/token.service';
import { KeycloakApiService } from '../services/keycloak-api.service';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UsersService } from '../../users/services/users.service';
interface LoginDto {
username: string;
password: string;
}
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(
private readonly tokenService: TokenService,
private readonly keycloakApiService: KeycloakApiService,
private readonly configService: ConfigService,
private readonly usersService: UsersService
) {}
/** -------------------------------
* LOGIN (Resource Owner Password Credentials)
* ------------------------------- */
@Public()
@Post('login')
async login(
@Body() loginDto: LoginDto
): Promise<{
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}> {
const { username, password } = loginDto;
if (!username || !password) {
throw new HttpException('Username and password are required', HttpStatus.BAD_REQUEST);
}
try {
const tokenResponse = await this.tokenService.acquireUserToken(username, password);
this.logger.log(`User "${username}" authenticated successfully`);
return {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expires_in: tokenResponse.expires_in,
token_type: 'Bearer',
};
} catch (err: any) {
const errorMessage = err.message;
// Gestion spécifique des erreurs Keycloak
if (errorMessage.includes('Account is not fully set up')) {
this.logger.warn(`User account not fully set up: "${username}"`);
throw new HttpException(
'Account setup incomplete. Please contact administrator.',
HttpStatus.FORBIDDEN
);
}
if (errorMessage.includes('Invalid user credentials')) {
this.logger.warn(`Invalid credentials for user: "${username}"`);
throw new HttpException(
'Invalid username or password',
HttpStatus.UNAUTHORIZED
);
}
if (errorMessage.includes('User is disabled')) {
this.logger.warn(`Disabled user attempted login: "${username}"`);
throw new HttpException(
'Account is disabled',
HttpStatus.FORBIDDEN
);
}
this.logger.warn(`Authentication failed for "${username}": ${errorMessage}`);
throw new HttpException(
'Authentication failed',
HttpStatus.UNAUTHORIZED
);
}
}
/** -------------------------------
* LOGOUT
* ------------------------------- */
@Post('logout')
async logout(@Req() req: Request) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
}
try {
const refreshToken = await this.tokenService.getStoredRefreshToken(token);
if (refreshToken) {
await this.tokenService.revokeToken(refreshToken);
}
this.logger.log(`User logged out successfully`);
return { message: 'Logout successful' };
} catch (error: any) {
this.logger.error('Logout failed', error);
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/** -------------------------------
* GET CURRENT USER (from token)
* ------------------------------- */
@Get('me')
async getCurrentUser(@Req() req: Request, @AuthenticatedUser() user: any) {
const authHeader = req.headers['authorization'];
const token = authHeader?.replace('Bearer ', '').trim();
if (!token) throw new HttpException('Missing token', HttpStatus.BAD_REQUEST);
return this.usersService.getUserProfile(token);
}
/** -------------------------------
* GET USER BY ID
* ------------------------------- */
@Get('profile/:id')
@Roles({ roles: ['admin', 'viewer'] })
async getUserById(@Param('id') id: string) {
return this.usersService.getUserById(id);
}
/** -------------------------------
* REFRESH TOKEN
* ------------------------------- */
@Public()
@Post('refresh')
async refreshToken(@Body() body: { refresh_token: string }) {
const { refresh_token } = body;
if (!refresh_token) {
throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
}
try {
const tokenResponse = await this.tokenService.refreshToken(refresh_token);
return {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expires_in: tokenResponse.expires_in,
token_type: 'Bearer',
};
} catch (error: any) {
this.logger.error('Token refresh failed', error);
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
}
}
/** -------------------------------
* AUTH STATUS CHECK (public)
* ------------------------------- */
@Public()
@Get('status')
async getAuthStatus(@Req() req: Request) {
const authHeader = req.headers['authorization'];
let isValid = false;
if (authHeader) {
try {
const token = authHeader.replace('Bearer ', '');
isValid = await this.tokenService.validateToken(token);
} catch (error) {
this.logger.debug('Token validation failed in status check');
}
}
return {
authenticated: isValid,
status: isValid ? 'Token is valid' : 'Token is invalid or expired',
};
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, CanActivate, ExecutionContext, Logger, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { TokenService } from '../services/token.service';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class ClientCredentialsGuard implements CanActivate {
private readonly logger = new Logger(ClientCredentialsGuard.name);
constructor(
private readonly tokenService: TokenService,
private readonly jwtService: JwtService, // Injection du JwtService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
try {
// Récupère ou rafraîchit le token client
const token = await this.tokenService.getToken();
request['accessToken'] = token;
// Décodage JWT avec JwtService (ne vérifie pas la signature côté client)
const payload: any = this.jwtService.decode(token);
if (!payload) {
this.logger.warn('Token could not be decoded');
throw new UnauthorizedException('Invalid token');
}
const roles: string[] = payload.realm_access?.roles || [];
// Vérification facultative des rôles spécifiés via metadata
const requiredRoles = this.getRequiredRoles(context);
if (requiredRoles.length > 0) {
const hasRole = requiredRoles.some(role => roles.includes(role));
if (!hasRole) {
this.logger.warn(`Access denied, missing required roles: ${requiredRoles}`);
throw new ForbiddenException('Insufficient service role');
}
}
return true;
} catch (err: any) {
this.logger.error('Client credentials guard failed: ' + (err?.message ?? err));
if (err instanceof ForbiddenException) throw err;
throw new UnauthorizedException('Service authentication failed');
}
}
/**
* Récupère les rôles requis définis via un décorateur @Roles() sur le handler.
*/
private getRequiredRoles(context: ExecutionContext): string[] {
const handler = context.getHandler();
return Reflect.getMetadata('roles', handler) || [];
}
}

View File

@ -0,0 +1,68 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UserAuthGuard implements CanActivate {
private readonly logger = new Logger(UserAuthGuard.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Vérifie la présence du header Authorization
const authHeader = request.headers['authorization'];
if (!authHeader) {
throw new UnauthorizedException('Authorization header missing');
}
// Extraction du token
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Invalid or missing Bearer token');
}
try {
// Récupère la clé publique Keycloak
const publicKey = this.configService.get<string>('keycloak.publicKey');
if (!publicKey) {
throw new Error('Keycloak public key not configured');
}
// Vérifie et décode le token
const payload = this.jwtService.verify(token, {
algorithms: ['RS256'],
publicKey: this.formatPublicKey(publicKey),
});
// Vérifie que le token appartient bien à ton realm Keycloak
const expectedIssuer = `${this.configService.get<string>('keycloak.serverUrl')}/realms/${this.configService.get<string>('keycloak.realm')}`;
if (payload.iss !== expectedIssuer) {
throw new UnauthorizedException('Invalid token issuer');
}
// Attache le payload utilisateur à la requête
request.user = payload;
return true;
} catch (err: any) {
this.logger.error(`UserAuthGuard failed: ${err?.message}`);
throw new UnauthorizedException('Invalid or expired token');
}
}
private formatPublicKey(key: string): string {
if (key.includes('BEGIN PUBLIC KEY')) return key;
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
}
}

View File

@ -0,0 +1,385 @@
import { Injectable, Logger, HttpException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
import { TokenService } from './token.service';
import jwtDecode from 'jwt-decode';
import { KeycloakConfig } from '../../config/keycloak.config';
interface DecodedToken {
sub: string;
preferred_username?: string;
email?: string;
given_name?: string;
family_name?: string;
realm_access?: { roles: string[] };
resource_access?: Record<string, { roles: string[] }>;
scope?: string;
}
@Injectable()
export class KeycloakApiService {
private readonly logger = new Logger(KeycloakApiService.name);
private readonly keycloakBaseUrl: string;
constructor(
private readonly httpService: HttpService,
private readonly tokenService: TokenService,
private readonly configService: ConfigService,
) {
this.keycloakBaseUrl = this.configService.get<string>('keycloak.serverUrl')
|| 'https://keycloak-dcb.app.cameleonapp.com';
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
data?: any,
opts?: { timeoutMs?: number },
): Promise<T> {
const token = await this.tokenService.getToken();
const config = {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
};
try {
let obs;
const timeoutMs = opts?.timeoutMs ?? 5000;
switch (method) {
case 'GET':
obs = this.httpService.get<T>(url, config);
break;
case 'POST':
obs = this.httpService.post<T>(url, data, config);
break;
case 'PUT':
obs = this.httpService.put<T>(url, data, config);
break;
case 'DELETE':
obs = this.httpService.delete<T>(url, config);
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
const response: AxiosResponse<T> = await firstValueFrom(obs.pipe(rxjsTimeout(timeoutMs)));
return response.data;
} catch (err: unknown) {
const error = err as any;
const urlWithoutToken = this.sanitizeUrl(url);
let errorMessage = `Request to ${urlWithoutToken} failed`;
if (error?.response) {
errorMessage += `: ${error.response.status} ${error.response.statusText}`;
// Gestion spécifique des erreurs 404
if (error.response.status === 404) {
throw new NotFoundException(`Resource not found: ${urlWithoutToken}`);
}
this.logger.error(`HTTP Error for ${method} ${urlWithoutToken}`, {
status: error.response.status,
data: error.response.data,
});
} else if (error?.message) {
errorMessage += `: ${error.message}`;
this.logger.error(`Network error for ${method} ${urlWithoutToken}`);
} else {
errorMessage += `: Unknown error`;
this.logger.error(`Unknown error for ${method} ${urlWithoutToken}`);
}
throw new HttpException(errorMessage, error?.response?.status || 500);
}
}
private sanitizeUrl(url: string): string {
return url.replace(/\/[0-9a-fA-F-]{36}\//g, '/***/');
}
private buildKeycloakUrl(path: string): string {
return `${this.keycloakBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
// === REALM OPERATIONS ===
async getRealmClients(realm: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients`);
return this.request<any[]>('GET', url);
}
async getRealmInfo(realm: string): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}`);
return this.request<any>('GET', url);
}
// === USER OPERATIONS ===
async getUserById(realm: string, userId: string): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
return this.request<any>('GET', url);
}
private decodeJwt<T>(token: string): T {
if (!token) throw new Error('Token is required');
const payload = token.split('.')[1];
if (!payload) throw new Error('Invalid JWT token');
const decodedJson = Buffer.from(payload, 'base64').toString('utf-8');
return JSON.parse(decodedJson) as T;
}
/**
* Récupère le profil utilisateur.
* Offline validation décodage JWT
* Fallback Keycloak /userinfo
*/
async getUserProfile(realm: string, accessToken: string): Promise<any> {
// --- 1. Décodage du token (offline) ---
try {
const decoded: DecodedToken = this.decodeJwt<DecodedToken>(accessToken);
if (!decoded?.sub) throw new HttpException('Invalid token', 401);
const resourceRoles = decoded.resource_access?.['dcb-user-service-pwd']?.roles || [];
const realmRoles = decoded.realm_access?.roles || [];
return {
sub: decoded.sub,
username: decoded.preferred_username,
email: decoded.email,
given_name: decoded.given_name,
family_name: decoded.family_name,
roles: [...realmRoles, ...resourceRoles],
scope: decoded.scope,
};
} catch (error: any) {
this.logger.warn(`Offline token decoding failed: ${error.message}. Falling back to /userinfo.`);
}
// --- 2. Fallback : Appel Keycloak /userinfo ---
try {
const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`;
const response = await firstValueFrom(
this.httpService.get(url, {
headers: { Authorization: `Bearer ${accessToken}` },
}),
);
return response.data;
} catch (error: any) {
const status = error.response?.status || 500;
const data = error.response?.data || error.message;
this.logger.error(`Failed to fetch user profile from Keycloak: ${status}`, data);
if (status === 401) throw new HttpException('Invalid or expired token', 401);
throw new HttpException('Failed to fetch user profile', status);
}
}
async getUsers(realm: string, queryParams?: {
briefRepresentation?: boolean;
email?: string;
first?: number;
firstName?: string;
lastName?: string;
max?: number;
search?: string;
username?: string;
}): Promise<any[]> {
let url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
// Ajouter les paramètres de query s'ils sont fournis
if (queryParams) {
const params = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, value.toString());
}
});
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
}
}
return this.request<any[]>('GET', url);
}
async createUser(realm: string, user: any): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
return this.request<any>('POST', url, user);
}
async updateUser(realm: string, userId: string, data: any): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
return this.request<any>('PUT', url, data);
}
async deleteUser(realm: string, userId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
await this.request<void>('DELETE', url);
}
// === ROLE OPERATIONS ===
async getRealmRoles(realm: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/roles`);
return this.request<any[]>('GET', url);
}
async getUserRealmRoles(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
return this.request<any[]>('GET', url);
}
async assignRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
// S'assurer que les rôles sont au format attendu par Keycloak
const rolesToAssign = roles.map(role => {
if (typeof role === 'string') {
return { id: role, name: role };
}
return role;
});
return this.request<void>('POST', url, rolesToAssign);
}
async removeRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
// S'assurer que les rôles sont au format attendu par Keycloak
const rolesToRemove = roles.map(role => {
if (typeof role === 'string') {
return { id: role, name: role };
}
return role;
});
return this.request<void>('DELETE', url, rolesToRemove);
}
// === PASSWORD OPERATIONS ===
async resetPassword(realm: string, userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/reset-password`);
const credentials = {
type: 'password',
value: newPassword,
temporary: temporary,
};
return this.request<void>('PUT', url, credentials);
}
// === GROUP OPERATIONS ===
async getUserGroups(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups`);
return this.request<any[]>('GET', url);
}
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
return this.request<void>('PUT', url);
}
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
return this.request<void>('DELETE', url);
}
// === CLIENT OPERATIONS ===
async getClientRoles(realm: string, clientId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients/${clientId}/roles`);
return this.request<any[]>('GET', url);
}
async getUserClientRoles(realm: string, userId: string, clientId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/clients/${clientId}`);
return this.request<any[]>('GET', url);
}
// === SESSION OPERATIONS ===
async getUserSessions(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/sessions`);
return this.request<any[]>('GET', url);
}
async logoutUser(realm: string, userId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/logout`);
return this.request<void>('POST', url);
}
// === SEARCH OPERATIONS ===
async searchUsers(realm: string, search: string, maxResults: number = 50): Promise<any[]> {
const users = await this.getUsers(realm, { search, max: maxResults });
return users;
}
async getUserByUsername(realm: string, username: string): Promise<any> {
const users = await this.getUsers(realm, { username, max: 1 });
return users.length > 0 ? users[0] : null;
}
async getUserByEmail(realm: string, email: string): Promise<any> {
const users = await this.getUsers(realm, { email, max: 1 });
return users.length > 0 ? users[0] : null;
}
// === BULK OPERATIONS ===
async updateUserAttributes(realm: string, userId: string, attributes: Record<string, any>): Promise<void> {
const user = await this.getUserById(realm, userId);
const updateData = {
...user,
attributes: {
...user.attributes,
...attributes,
},
};
await this.updateUser(realm, userId, updateData);
}
async disableUser(realm: string, userId: string): Promise<void> {
await this.updateUser(realm, userId, { enabled: false });
}
async enableUser(realm: string, userId: string): Promise<void> {
await this.updateUser(realm, userId, { enabled: true });
}
// === HEALTH CHECK ===
async checkHealth(): Promise<{ status: string; realm: string }> {
try {
const realm = this.configService.get<string>('keycloak.realm');
if (!realm) {
throw new Error('Keycloak configuration not found');
}
// Test basique de connexion
await this.getRealmInfo(realm);
return {
status: 'healthy',
realm
};
} catch (error) {
this.logger.error('Keycloak health check failed', error);
return {
status: 'unhealthy',
realm: 'unknown'
};
}
}
}

View File

@ -0,0 +1,47 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { TokenService } from './token.service';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class StartupService implements OnModuleInit {
private readonly logger = new Logger(StartupService.name);
private isInitialized = false;
private initializationError: string | null = null;
constructor(
private readonly tokenService: TokenService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
this.logger.log('Starting Keycloak connection...');
try {
// Test simple : acquisition du token admin
await this.tokenService.getToken();
this.isInitialized = true;
this.logger.log('✅ Keycloak connection established successfully');
} catch (error) {
this.initializationError = error.message;
this.logger.error('❌ Keycloak connection failed', error);
// On ne throw pas l'erreur pour permettre à l'app de démarrer
}
}
getStatus() {
return {
status: this.isInitialized ? 'healthy' : 'unhealthy',
keycloak: {
connected: this.isInitialized,
realm: this.configService.get('keycloak.realm'),
serverUrl: this.configService.get('keycloak.serverUrl'),
},
timestamp: new Date(),
error: this.initializationError,
};
}
isHealthy(): boolean {
return this.isInitialized;
}
}

View File

@ -0,0 +1,268 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
export interface KeycloakTokenResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
token_type: string;
scope?: string;
}
@Injectable()
export class TokenService implements OnModuleInit {
private readonly logger = new Logger(TokenService.name);
private currentToken: string | null = null;
private tokenExpiry: Date | null = null;
private acquiringTokenPromise: Promise<string> | null = null;
// Mapping access_token → refresh_token pour gérer les logouts utilisateurs
private userRefreshTokens = new Map<string, string>();
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {}
async onModuleInit() {
try {
await this.acquireClientToken();
} catch {
this.logger.warn('Initial Keycloak client token acquisition failed (will retry on demand).');
}
}
private getTokenUrl(): string {
const serverUrl = this.configService.get<string>('keycloak.serverUrl');
const realm = this.configService.get<string>('keycloak.realm');
if (!serverUrl || !realm) {
throw new Error('Keycloak serverUrl or realm not configured');
}
return `${serverUrl}/realms/${realm}/protocol/openid-connect/token`;
}
getAccessTokenFromHeader(request: Request): string | null {
const authHeader = request.headers['authorization'];
if (!authHeader) return null;
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : null;
}
private getLogoutUrl(): string {
const serverUrl = this.configService.get<string>('keycloak.serverUrl');
const realm = this.configService.get<string>('keycloak.realm');
return `${serverUrl}/realms/${realm}/protocol/openid-connect/logout`;
}
private validateClientConfig(): { clientId: string; clientSecret: string } {
const clientId = this.configService.get<string>('keycloak.clientId');
const clientSecret = this.configService.get<string>('keycloak.clientSecret');
if (!clientId) throw new Error('KEYCLOAK_CLIENT_ID is not configured');
if (!clientSecret) throw new Error('KEYCLOAK_CLIENT_SECRET is not configured');
return { clientId, clientSecret };
}
private validateUserClientConfig(): { userClientId: string; userClientSecret: string } {
const userClientId = this.configService.get<string>('keycloak.userClientId');
const userClientSecret = this.configService.get<string>('keycloak.userClientSecret');
if (!userClientId) throw new Error('KEYCLOAK_CLIENT_ID is not configured');
if (!userClientSecret) throw new Error('KEYCLOAK_CLIENT_SECRET is not configured');
return { userClientId, userClientSecret };
}
/** -------------------------------
* CLIENT CREDENTIALS TOKEN
* ------------------------------- */
async acquireClientToken(): Promise<string> {
if (this.acquiringTokenPromise) return this.acquiringTokenPromise;
this.acquiringTokenPromise = (async () => {
try {
const tokenUrl = this.getTokenUrl();
const { clientId, clientSecret } = this.validateClientConfig();
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', clientId);
params.append('client_secret', clientSecret);
const response = await firstValueFrom(
this.httpService
.post<KeycloakTokenResponse>(tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.pipe(rxjsTimeout(10000)),
);
if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response');
this.currentToken = response.data.access_token;
const expiresIn = response.data.expires_in ?? 60;
const buffer = this.configService.get<number>('keycloak.tokenBufferSeconds') ?? 30;
const expiry = new Date();
expiry.setSeconds(expiry.getSeconds() + Math.max(0, expiresIn - buffer));
this.tokenExpiry = expiry;
this.logger.log(`Acquired Keycloak client token (expires in ${expiresIn}s)`);
return this.currentToken;
} finally {
this.acquiringTokenPromise = null;
}
})();
return this.acquiringTokenPromise;
}
/** -------------------------------
* USER PASSWORD TOKEN (ROPC)
* ------------------------------- */
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
const tokenUrl = this.getTokenUrl();
const { userClientId, userClientSecret } = this.validateUserClientConfig();
if (!username || !password) throw new Error('Username and password are required');
const params = new URLSearchParams();
params.append('grant_type', 'password');
params.append('client_id', userClientId);
params.append('client_secret', userClientSecret);
params.append('username', username);
params.append('password', password);
try {
const response = await firstValueFrom(
this.httpService
.post<KeycloakTokenResponse>(tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.pipe(rxjsTimeout(10000)),
);
if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response');
// 🔹 Stocke le refresh_token associé
if (response.data.refresh_token) {
this.userRefreshTokens.set(response.data.access_token, response.data.refresh_token);
}
this.logger.log(`User token acquired for "${username}"`);
return response.data;
} catch (err: any) {
this.logger.error(`Failed to acquire Keycloak user token: ${err?.message ?? err}`);
throw new Error('Invalid username or password');
}
}
/** -------------------------------
* REFRESH TOKEN
* ------------------------------- */
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
const tokenUrl = this.getTokenUrl();
const { userClientId, userClientSecret } = this.validateUserClientConfig();
if (!refreshToken) throw new Error('Refresh token is required');
const params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('client_id', userClientId);
params.append('client_secret', userClientSecret);
params.append('refresh_token', refreshToken);
try {
const response = await firstValueFrom(
this.httpService
.post<KeycloakTokenResponse>(tokenUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.pipe(rxjsTimeout(10000))
);
if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response');
// Met à jour le mapping
if (response.data.refresh_token) {
this.userRefreshTokens.set(response.data.access_token, response.data.refresh_token);
}
this.logger.log('Token refreshed successfully');
return response.data;
} catch (error: any) {
this.logger.error(`Failed to refresh token: ${error.message}`);
throw new Error('Failed to refresh token');
}
}
/** -------------------------------
* REVOKE TOKEN (LOGOUT)
* ------------------------------- */
async revokeToken(refreshToken: string): Promise<void> {
const logoutUrl = this.getLogoutUrl();
const clientId = this.configService.get<string>('keycloak.userClientId');
const clientSecret = this.configService.get<string>('keycloak.userClientSecret');
if (!clientId || !clientSecret) throw new Error('ClientId or ClientSecret not configured');
if (!refreshToken) throw new Error('Refresh token is required');
const params = new URLSearchParams();
params.append('client_id', clientId);
params.append('client_secret', clientSecret);
params.append('refresh_token', refreshToken);
try {
await firstValueFrom(
this.httpService
.post(logoutUrl, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
.pipe(rxjsTimeout(10000)),
);
this.logger.log(`Refresh token revoked successfully`);
} catch (error: any) {
this.logger.warn(`Failed to revoke refresh token: ${error.message}`);
}
}
/** -------------------------------
* HELPER METHODS
* ------------------------------- */
async getToken(): Promise<string> {
if (this.isTokenValid()) return this.currentToken!;
return this.acquireClientToken();
}
isTokenValid(): boolean {
return !!this.currentToken && !!this.tokenExpiry && new Date() < this.tokenExpiry;
}
clearToken(): void {
this.currentToken = null;
this.tokenExpiry = null;
}
async getStoredRefreshToken(accessToken: string): Promise<string | null> {
return this.userRefreshTokens.get(accessToken) ?? null;
}
getTokenInfo() {
if (!this.currentToken) return null;
return {
isValid: this.isTokenValid(),
expiresAt: this.tokenExpiry,
expiresIn: this.tokenExpiry
? Math.floor((this.tokenExpiry.getTime() - new Date().getTime()) / 1000)
: 0,
};
}
}

View File

@ -0,0 +1,269 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { KeycloakConfig } from '../../config/keycloak.config';
import * as jwt from 'jsonwebtoken';
export interface KeycloakTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
scope?: string;
}
@Injectable()
export class TokenService {
private readonly logger = new Logger(TokenService.name);
private currentToken: string | null = null;
private tokenExpiry: Date | null = null;
constructor(
private configService: ConfigService,
private httpService: HttpService,
) {}
// === POUR L'API ADMIN (KeycloakApiService) - Client Credentials ===
async getToken(): Promise<string> {
// Si nous avons un token valide, le retourner
if (this.currentToken && this.isTokenValid()) {
return this.currentToken;
}
// Sinon, acquérir un nouveau token en utilisant client_credentials
return await this.acquireClientCredentialsToken();
}
private async acquireClientCredentialsToken(): Promise<string> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', keycloakConfig.adminClientId); // ← Client admin
params.append('client_secret', keycloakConfig.adminClientSecret); // ← Secret admin
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
// Stocker le token et sa date d'expiration
this.currentToken = response.data.access_token;
this.tokenExpiry = new Date(Date.now() + (response.data.expires_in * 1000));
this.logger.log('Successfully acquired client credentials token');
return this.currentToken;
} catch (error: any) {
this.logger.error('Failed to acquire client token', error.response?.data);
throw new Error(error.response?.data?.error_description || 'Failed to acquire client token');
}
}
private isTokenValid(): boolean {
if (!this.currentToken || !this.tokenExpiry) {
return false;
}
// Ajouter un buffer de sécurité (30 secondes par défaut)
const bufferSeconds = this.configService.get<number>('keycloak.tokenBufferSeconds') || 30;
const bufferMs = bufferSeconds * 1000;
return this.tokenExpiry.getTime() > (Date.now() + bufferMs);
}
// === POUR L'AUTHENTIFICATION UTILISATEUR (AuthController) - Password Grant ===
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const params = new URLSearchParams();
params.append('grant_type', 'password');
params.append('client_id', keycloakConfig.authClientId); // ← Client auth
params.append('client_secret', keycloakConfig.authClientSecret); // ← Secret auth
params.append('username', username);
params.append('password', password);
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
this.logger.log(`User token acquired successfully for: ${username}`);
return response.data;
} catch (error: any) {
this.logger.error('Failed to acquire user token', error.response?.data);
throw new Error(error.response?.data?.error_description || 'Authentication failed');
}
}
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('client_id', keycloakConfig.authClientId); // ← Utiliser le client auth pour le refresh
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('refresh_token', refreshToken);
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
return response.data;
} catch (error: any) {
this.logger.error('Token refresh failed', error.response?.data);
throw new Error(error.response?.data?.error_description || 'Token refresh failed');
}
}
async revokeToken(token: string): Promise<void> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const revokeEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/revoke`;
const params = new URLSearchParams();
// Utiliser le client auth pour la révocation (car c'est généralement lié aux tokens utilisateur)
params.append('client_id', keycloakConfig.authClientId);
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('token', token);
try {
await firstValueFrom(
this.httpService.post(revokeEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
this.logger.log('Token revoked successfully');
} catch (error: any) {
this.logger.error('Token revocation failed', error.response?.data);
throw new Error('Token revocation failed');
}
}
async validateOffline(token: string): Promise<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig?.publicKey) {
this.logger.error('Missing Keycloak public key for offline validation');
return false;
}
try {
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
jwt.verify(token, formattedKey, {
algorithms: ['RS256'],
audience: keycloakConfig.authClientId,
});
return true;
} catch (err) {
this.logger.error('Offline token validation failed:', err.message);
return false;
}
}
async validateToken(token: string): Promise<boolean> {
const mode = this.configService.get<string>('keycloak.validationMode') || 'online';
if (mode === 'offline') {
return this.validateOffline(token);
} else {
return this.validateOnline(token);
}
}
private async validateOnline(token: string): Promise<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const introspectEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token/introspect`;
const params = new URLSearchParams();
params.append('client_id', keycloakConfig.authClientId);
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('token', token);
try {
const response = await firstValueFrom(
this.httpService.post(introspectEndpoint, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
return response.data.active === true;
} catch (error: any) {
this.logger.error('Online token validation failed', error.response?.data);
return false;
}
}
async getStoredRefreshToken(accessToken: string): Promise<string | null> {
// Implémentez votre logique de stockage des refresh tokens ici
// Pour l'instant, retournez null ou implémentez selon vos besoins
return null;
}
// === MÉTHODES UTILITAIRES ===
clearToken(): void {
this.currentToken = null;
this.tokenExpiry = null;
this.logger.log('Admin client token cleared from cache');
}
getTokenInfo(): { hasToken: boolean; expiresIn?: number; clientType: string } {
if (!this.currentToken || !this.tokenExpiry) {
return { hasToken: false, clientType: 'admin' };
}
const expiresIn = this.tokenExpiry.getTime() - Date.now();
return {
hasToken: true,
expiresIn: Math.max(0, Math.floor(expiresIn / 1000)), // en secondes
clientType: 'admin'
};
}
}

View File

@ -0,0 +1,109 @@
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
export interface KeycloakConfig {
serverUrl: string;
realm: string;
publicKey?: string;
// Client pour l'API Admin (Service Account - client_credentials)
adminClientId: string;
adminClientSecret: string;
// Client pour l'authentification utilisateur (Password Grant)
authClientId: string;
authClientSecret: string;
validationMode: string;
tokenBufferSeconds: number;
}
export default registerAs('keycloak', (): KeycloakConfig => ({
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak-dcb.app.cameleonapp.com',
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
// Client pour Service Account (API Admin)
adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
// Client pour Password Grant (Authentification utilisateur)
authClientId: process.env.KEYCLOAK_AUTH_CLIENT_ID || 'dcb-user-service-pwd',
authClientSecret: process.env.KEYCLOAK_AUTH_CLIENT_SECRET || '',
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
}));
export const keycloakConfigValidationSchema = Joi.object({
KEYCLOAK_SERVER_URL: Joi.string()
.uri()
.required()
.messages({
'string.uri': 'KEYCLOAK_SERVER_URL must be a valid URL',
'any.required': 'KEYCLOAK_SERVER_URL is required'
}),
KEYCLOAK_REALM: Joi.string()
.required()
.pattern(/^[a-zA-Z0-9_-]+$/)
.messages({
'any.required': 'KEYCLOAK_REALM is required',
'string.pattern.base': 'KEYCLOAK_REALM can only contain letters, numbers, underscores and hyphens'
}),
KEYCLOAK_PUBLIC_KEY: Joi.string()
.required()
.messages({
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
}),
KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
.required()
.messages({
'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required'
}),
KEYCLOAK_ADMIN_CLIENT_SECRET: Joi.string()
.required()
.min(1)
.messages({
'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required',
'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty'
}),
KEYCLOAK_AUTH_CLIENT_ID: Joi.string()
.required()
.messages({
'any.required': 'KEYCLOAK_AUTH_CLIENT_ID is required'
}),
KEYCLOAK_AUTH_CLIENT_SECRET: Joi.string()
.required()
.min(1)
.messages({
'any.required': 'KEYCLOAK_AUTH_CLIENT_SECRET is required',
'string.min': 'KEYCLOAK_AUTH_CLIENT_SECRET cannot be empty'
}),
KEYCLOAK_VALIDATION_MODE: Joi.string()
.required()
.min(1)
.messages({
'any.required': 'KEYCLOAK_VALIDATION_MODE is required',
'string.min': 'KEYCLOAK_VALIDATION_MODE cannot be empty'
}),
KEYCLOAK_TOKEN_BUFFER_SECONDS: Joi.number()
.integer()
.min(0)
.max(300)
.default(30)
.messages({
'number.max': 'KEYCLOAK_TOKEN_BUFFER_SECONDS cannot exceed 300 seconds'
}),
// Variables d'environnement générales
NODE_ENV: Joi.string()
.valid('development', 'production', 'test', 'staging')
.default('development'),
PORT: Joi.number()
.port()
.default(3000),
}).unknown(true);

View File

@ -0,0 +1,8 @@
import { SetMetadata } from '@nestjs/common';
/**
* Décorateur custom pour définir les rôles requis sur une route.
* Exemple :
* @Roles('DCB_ADMIN', 'DCB_MANAGER')
*/
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

View File

@ -0,0 +1,35 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class KeycloakExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(KeycloakExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = 500;
let message = 'Internal server error';
if (exception instanceof HttpException) {
status = exception.getStatus();
message = exception.message;
} else if (exception instanceof Error) {
message = exception.message;
}
this.logger.error(
`Keycloak Client Credentials Error: ${message}`,
exception instanceof Error ? exception.stack : '',
);
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
});
}
}

View File

@ -0,0 +1,32 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from 'nest-keycloak-connect';
import { StartupService } from '../auth/services/startup.service';
@Controller('health')
export class HealthController {
constructor(private readonly startupService: StartupService) {}
@Public()
@Get()
getHealth() {
return this.startupService.getStatus();
}
@Public()
@Get('readiness')
getReadiness() {
return {
status: this.startupService.isHealthy() ? 'ready' : 'not-ready',
timestamp: new Date(),
};
}
@Public()
@Get('liveness')
getLiveness() {
return {
status: 'live',
timestamp: new Date(),
};
}
}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
import { HttpModule } from '@nestjs/axios';
import { StartupService } from '../auth/services/startup.service';
import { TokenService } from '../auth/services/token.service';
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
@Module({
imports: [
TerminusModule,
HttpModule,
],
providers: [StartupService, TokenService, KeycloakApiService],
controllers: [HealthController],
exports: [StartupService, TokenService, KeycloakApiService],
})
export class HealthModule {}

23
src/main.ts Normal file
View File

@ -0,0 +1,23 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common';
import helmet from 'helmet';
import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('dcb-user-service');
app.use(helmet());
app.enableCors();
app.useGlobalFilters(new KeycloakExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.setGlobalPrefix('api/v1');
app.enableCors({ origin: '*' })
const port = process.env.PORT || 3000;
await app.listen(port);
logger.log(`Application running on http://localhost:${port}`);
}
bootstrap();

View File

@ -0,0 +1,210 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
Logger,
HttpException,
HttpStatus,
} from "@nestjs/common";
import {
Roles,
AuthenticatedUser,
Public
} from "nest-keycloak-connect";
import { UsersService } from "../services/users.service";
import { User, CreateUserDto, UpdateUserDto, UserResponse } from "../models/user";
import { ROLES } from "../models/roles.enum";
@Controller("user")
export class UserController {
private readonly logger = new Logger(UserController.name);
constructor(private readonly userService: UsersService) {}
@Post("create")
@Roles({ roles: [ROLES.CREATE_USER] })
async createUser(
@Body() payload: CreateUserDto,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} creating new user: ${payload.username}`);
try {
const createdUser = await this.userService.createUser(payload);
return new UserResponse(createdUser);
} catch (error: any) {
this.logger.error(`Failed to create user: ${error.message}`);
throw new HttpException(
error.message || 'Failed to create user',
HttpStatus.BAD_REQUEST
);
}
}
@Put(':id')
@Roles({ roles: [ROLES.UPDATE_USER] })
async updateUser(
@Param('id') id: string,
@Body() payload: UpdateUserDto,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} updating user: ${id}`);
try {
const updatedUser = await this.userService.updateUser(id, payload);
return new UserResponse(updatedUser);
} catch (error: any) {
this.logger.error(`Failed to update user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to update user',
HttpStatus.BAD_REQUEST
);
}
}
@Delete(":id")
@Roles({ roles: [ROLES.DELETE_USER] })
async deleteUser(
@Param("id") id: string,
@AuthenticatedUser() user: any
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} deleting user: ${id}`);
try {
await this.userService.deleteUser(id);
return { message: `User ${id} deleted successfully` };
} catch (error: any) {
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to delete user',
HttpStatus.BAD_REQUEST
);
}
}
@Get()
@Roles({ roles: [ROLES.READ_USERS] })
async findAllUsers(
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
@Query('search') search: string = '',
@Query('enabled') enabled: boolean | string,
@AuthenticatedUser() user: any
): Promise<{ users: UserResponse[]; total: number }> {
this.logger.log(`User ${user.sub} accessing users list`);
// Convert enabled query param to boolean if provided
const enabledFilter = enabled === 'true' ? true :
enabled === 'false' ? false : undefined;
try {
const result = await this.userService.findAllUsers(
page,
limit,
search,
enabledFilter
);
return {
users: result.users.map(user => new UserResponse(user)),
total: result.total
};
} catch (error: any) {
this.logger.error(`Failed to fetch users: ${error.message}`);
throw new HttpException(
error.message || 'Failed to fetch users',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Get(":id")
@Roles({ roles: [ROLES.READ_USERS] })
async getUserById(
@Param('id') id: string,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} accessing profile of user: ${id}`);
try {
const userData = await this.userService.getUserById(id);
return new UserResponse(userData);
} catch (error: any) {
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'User not found',
HttpStatus.NOT_FOUND
);
}
}
@Get("profile/me")
async getCurrentUserProfile(
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} accessing own profile`);
try {
// Utiliser l'ID de l'utilisateur authentifié
const userData = await this.userService.getUserById(user.sub);
return new UserResponse(userData);
} catch (error: any) {
this.logger.error(`Failed to fetch current user profile: ${error.message}`);
throw new HttpException(
error.message || 'Failed to fetch user profile',
HttpStatus.NOT_FOUND
);
}
}
@Post(":id/roles")
@Roles({ roles: [ROLES.UPDATE_USER] })
async assignRoles(
@Param('id') id: string,
@Body() body: { roles: string[] },
@AuthenticatedUser() user: any
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} assigning roles to user: ${id}`);
try {
await this.userService.assignRealmRoles(id, body.roles);
return { message: 'Roles assigned successfully' };
} catch (error: any) {
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to assign roles',
HttpStatus.BAD_REQUEST
);
}
}
@Put(":id/password")
@Roles({ roles: [ROLES.UPDATE_USER] })
async resetPassword(
@Param('id') id: string,
@Body() body: { password: string; temporary: boolean },
@AuthenticatedUser() user: any
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} resetting password for user: ${id}`);
try {
await this.userService.resetPassword(
id,
body.password,
body.temporary ?? true
);
return { message: 'Password reset successfully' };
} catch (error: any) {
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to reset password',
HttpStatus.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,14 @@
export enum ROLES {
// User Management Roles
CREATE_USER = 'create-user',
UPDATE_USER = 'update-user',
DELETE_USER = 'delete-user',
READ_USERS = 'read-users',
}
export const ROLE_DESCRIPTIONS = {
[ROLES.CREATE_USER]: 'Can create new users in the system',
[ROLES.UPDATE_USER]: 'Can update existing user information',
[ROLES.DELETE_USER]: 'Can delete users from the system',
[ROLES.READ_USERS]: 'Can view the list of all users',
};

101
src/users/models/user.ts Normal file
View File

@ -0,0 +1,101 @@
export class User {
id?: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
enabled?: boolean = true;
emailVerified?: boolean = false;
attributes?: Record<string, any>;
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
groups?: string[];
requiredActions?: string[];
credentials?: UserCredentials[];
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserCredentials {
type: string;
value: string;
temporary: boolean = false;
constructor(type: string, value: string, temporary: boolean = false) {
this.type = type;
this.value = value;
this.temporary = temporary;
}
}
export class CreateUserDto {
username: string;
email: string;
firstName?: string;
lastName?: string;
password: string;
enabled?: boolean = true;
emailVerified?: boolean = false;
attributes?: Record<string, any>;
realmRoles?: string[];
groups?: string[];
constructor(partial?: Partial<CreateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UpdateUserDto {
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: Record<string, any>;
realmRoles?: string[];
groups?: string[];
constructor(partial?: Partial<UpdateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserResponse {
id: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
enabled: boolean;
emailVerified: boolean;
attributes?: Record<string, any>;
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
groups?: string[];
createdTimestamp: number;
constructor(user: any) {
this.id = user.id;
this.username = user.username;
this.email = user.email;
this.firstName = user.firstName;
this.lastName = user.lastName;
this.enabled = user.enabled;
this.emailVerified = user.emailVerified;
this.attributes = user.attributes;
this.realmRoles = user.realmRoles;
this.clientRoles = user.clientRoles;
this.groups = user.groups;
this.createdTimestamp = user.createdTimestamp;
}
}

View File

@ -0,0 +1,311 @@
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { ConfigService } from '@nestjs/config';
import { CreateUserDto, UpdateUserDto } from '../models/user';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
private readonly realm: string;
constructor(
private readonly keycloakApi: KeycloakApiService,
private readonly configService: ConfigService,
) {
this.realm = this.configService.get<string>('keycloak.realm')!;
}
async getUserById(userId: string): Promise<any> {
try {
this.logger.debug(`Fetching user by ID: ${userId}`);
const user = await this.keycloakApi.getUserById(this.realm, userId);
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
return user;
} catch (error: any) {
this.logger.error(`Failed to fetch user ${userId}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
throw new NotFoundException(`User with ID ${userId} not found`);
}
}
async getUserProfile(accessToken: string): Promise<any> {
try {
this.logger.debug('Fetching user profile from token');
const profile = await this.keycloakApi.getUserProfile(this.realm, accessToken);
if (!profile) {
throw new NotFoundException('User profile not found');
}
return profile;
} catch (error: any) {
this.logger.error(`Failed to fetch user profile: ${error.message}`);
throw new NotFoundException('Failed to fetch user profile');
}
}
async findAllUsers(
page: number = 1,
limit: number = 10,
search: string = '',
enabled?: boolean
): Promise<{ users: any[]; total: number }> {
try {
this.logger.debug(`Fetching users - page: ${page}, limit: ${limit}, search: ${search}`);
// Récupérer tous les utilisateurs avec les filtres
let users = await this.keycloakApi.getUsers(this.realm);
// Appliquer les filtres
if (search) {
const searchLower = search.toLowerCase();
users = users.filter(user =>
user.username?.toLowerCase().includes(searchLower) ||
user.email?.toLowerCase().includes(searchLower) ||
user.firstName?.toLowerCase().includes(searchLower) ||
user.lastName?.toLowerCase().includes(searchLower)
);
}
if (enabled !== undefined) {
users = users.filter(user => user.enabled === enabled);
}
// Pagination
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = users.slice(startIndex, endIndex);
return {
users: paginatedUsers,
total: users.length
};
} catch (error: any) {
this.logger.error(`Failed to fetch users: ${error.message}`);
throw new BadRequestException('Failed to fetch users');
}
}
async createUser(userData: CreateUserDto): Promise<any> {
try {
this.logger.debug(`Creating new user: ${userData.username}`);
// Validation basique
if (!userData.username || !userData.email) {
throw new BadRequestException('Username and email are required');
}
// Vérifier si l'utilisateur existe déjà
const existingUsers = await this.keycloakApi.getUsers(this.realm);
const userExists = existingUsers.some(user =>
user.username === userData.username || user.email === userData.email
);
if (userExists) {
throw new ConflictException('User with this username or email already exists');
}
// Préparer les données pour Keycloak
const keycloakUserData = {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false,
attributes: userData.attributes || {},
credentials: userData.password ? [
{
type: 'password',
value: userData.password,
temporary: false
}
] : [],
realmRoles: userData.realmRoles || [],
groups: userData.groups || []
};
const createdUser = await this.keycloakApi.createUser(this.realm, keycloakUserData);
this.logger.log(`User created successfully: ${userData.username}`);
return createdUser;
} catch (error: any) {
this.logger.error(`Failed to create user ${userData.username}: ${error.message}`);
if (error instanceof BadRequestException || error instanceof ConflictException) {
throw error;
}
throw new BadRequestException('Failed to create user');
}
}
async updateUser(id: string, userData: UpdateUserDto): Promise<any> {
try {
this.logger.debug(`Updating user: ${id}`);
// Vérifier que l'utilisateur existe
const existingUser = await this.getUserById(id);
if (!existingUser) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// Préparer les données de mise à jour
const updateData: any = {};
if (userData.username !== undefined) updateData.username = userData.username;
if (userData.email !== undefined) updateData.email = userData.email;
if (userData.firstName !== undefined) updateData.firstName = userData.firstName;
if (userData.lastName !== undefined) updateData.lastName = userData.lastName;
if (userData.enabled !== undefined) updateData.enabled = userData.enabled;
if (userData.emailVerified !== undefined) updateData.emailVerified = userData.emailVerified;
if (userData.attributes !== undefined) updateData.attributes = userData.attributes;
if (userData.realmRoles !== undefined) updateData.realmRoles = userData.realmRoles;
if (userData.groups !== undefined) updateData.groups = userData.groups;
const updatedUser = await this.keycloakApi.updateUser(this.realm, id, updateData);
this.logger.log(`User updated successfully: ${id}`);
return updatedUser;
} catch (error: any) {
this.logger.error(`Failed to update user ${id}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Failed to update user');
}
}
async deleteUser(id: string): Promise<void> {
try {
this.logger.debug(`Deleting user: ${id}`);
// Vérifier que l'utilisateur existe
const existingUser = await this.getUserById(id);
if (!existingUser) {
throw new NotFoundException(`User with ID ${id} not found`);
}
await this.keycloakApi.deleteUser(this.realm, id);
this.logger.log(`User deleted successfully: ${id}`);
} catch (error: any) {
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Failed to delete user');
}
}
async assignRealmRoles(userId: string, roles: string[]): Promise<void> {
try {
this.logger.debug(`Assigning roles to user ${userId}: ${roles.join(', ')}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.assignRealmRoles(this.realm, userId, roles);
this.logger.log(`Roles assigned successfully to user: ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to assign roles to user');
}
}
async removeRealmRoles(userId: string, roles: string[]): Promise<void> {
try {
this.logger.debug(`Removing roles from user ${userId}: ${roles.join(', ')}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.removeRealmRoles(this.realm, userId, roles);
this.logger.log(`Roles removed successfully from user: ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to remove roles from user');
}
}
async resetPassword(userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
try {
this.logger.debug(`Resetting password for user: ${userId}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.resetPassword(this.realm, userId, newPassword, temporary);
this.logger.log(`Password reset successfully for user: ${userId}`);
} catch (error: any) {
this.logger.error(`Failed to reset password for user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to reset password');
}
}
async searchUsers(query: string, maxResults: number = 50): Promise<any[]> {
try {
this.logger.debug(`Searching users with query: ${query}`);
const allUsers = await this.keycloakApi.getUsers(this.realm);
const searchLower = query.toLowerCase();
const filteredUsers = allUsers.filter(user =>
user.username?.toLowerCase().includes(searchLower) ||
user.email?.toLowerCase().includes(searchLower) ||
user.firstName?.toLowerCase().includes(searchLower) ||
user.lastName?.toLowerCase().includes(searchLower) ||
user.id?.toLowerCase().includes(searchLower)
);
return filteredUsers.slice(0, maxResults);
} catch (error: any) {
this.logger.error(`Failed to search users: ${error.message}`);
throw new BadRequestException('Failed to search users');
}
}
async getUserCount(): Promise<{ total: number; enabled: number; disabled: number }> {
try {
this.logger.debug('Getting user count statistics');
const allUsers = await this.keycloakApi.getUsers(this.realm);
return {
total: allUsers.length,
enabled: allUsers.filter(user => user.enabled).length,
disabled: allUsers.filter(user => !user.enabled).length
};
} catch (error: any) {
this.logger.error(`Failed to get user count: ${error.message}`);
throw new BadRequestException('Failed to get user statistics');
}
}
async toggleUserStatus(userId: string, enabled: boolean): Promise<any> {
try {
this.logger.debug(`Setting user ${userId} enabled status to: ${enabled}`);
const user = await this.getUserById(userId);
return await this.updateUser(userId, { enabled });
} catch (error: any) {
this.logger.error(`Failed to toggle user status for ${userId}: ${error.message}`);
throw new BadRequestException('Failed to update user status');
}
}
}

23
src/users/users.module.ts Normal file
View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { HttpModule } from '@nestjs/axios';
import { TokenService } from '../auth/services/token.service'
import { ClientCredentialsGuard } from '../auth/guards/client-credentials.guard';
import { UsersService } from './services/users.service'
import { UserController } from './controllers/users.controller'
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
@Module({
imports: [
HttpModule,
JwtModule.register({}),
],
providers: [UsersService, KeycloakApiService, TokenService],
controllers: [UserController],
exports: [UsersService, KeycloakApiService, TokenService, JwtModule],
})
export class UsersModule {}

25
test/app.e2e-spec.ts Normal file
View File

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

9
test/jest-e2e.json Normal file
View File

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@ -0,0 +1,40 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from '../src/auth/services/token.service';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { of } from 'rxjs';
describe('TokenService', () => {
let service: TokenService;
const mockHttp = { post: jest.fn() };
const mockConfig = {
get: (key: string) => {
const map = {
'keycloak.serverUrl': 'https://keycloak-dcb.app.cameleonapp.com',
'keycloak.realm': 'master',
'keycloak.clientId': 'dcb-cc',
'keycloak.clientSecret': 'secret',
'keycloak.tokenBufferSeconds': 30,
};
return map[key];
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{ provide: HttpService, useValue: mockHttp },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<TokenService>(TokenService);
});
it('should acquire a token', async () => {
mockHttp.post.mockReturnValue(of({ data: { access_token: 'abc123', expires_in: 60 } }));
const token = await service.acquireToken();
expect(token).toBe('abc123');
});
});

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}