feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
commit
e5f44ffdc0
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal 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
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
README.md
Normal file
98
README.md
Normal 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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](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
35
eslint.config.mjs
Normal 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
8
nest-cli.json
Normal 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
11490
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
Normal file
87
package.json
Normal 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
10
src/api/api.module.ts
Normal 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 {}
|
||||
33
src/api/controllers/api.controller.ts
Normal file
33
src/api/controllers/api.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
22
src/app.controller.spec.ts.org
Normal file
22
src/app.controller.spec.ts.org
Normal 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
12
src/app.controller.ts.org
Normal 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
95
src/app.module.ts
Normal 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
8
src/app.service.ts.org
Normal 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
21
src/auth/auth.module.ts
Normal 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 {}
|
||||
203
src/auth/controllers/auth.controller.ts
Normal file
203
src/auth/controllers/auth.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/auth/guards/client-credentials.guard.ts
Normal file
56
src/auth/guards/client-credentials.guard.ts
Normal 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) || [];
|
||||
}
|
||||
}
|
||||
68
src/auth/guards/user-auth.guard.ts
Normal file
68
src/auth/guards/user-auth.guard.ts
Normal 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-----`;
|
||||
}
|
||||
}
|
||||
385
src/auth/services/keycloak-api.service.ts
Normal file
385
src/auth/services/keycloak-api.service.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
47
src/auth/services/startup.service.ts
Normal file
47
src/auth/services/startup.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
268
src/auth/services/token.service copy.ts
Normal file
268
src/auth/services/token.service copy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
269
src/auth/services/token.service.ts
Normal file
269
src/auth/services/token.service.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
109
src/config/keycloak.config.ts
Normal file
109
src/config/keycloak.config.ts
Normal 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);
|
||||
8
src/decorators/roles.decorator.ts
Normal file
8
src/decorators/roles.decorator.ts
Normal 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);
|
||||
35
src/filters/keycloak-exception.filter.ts
Normal file
35
src/filters/keycloak-exception.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/health/health.controller.ts
Normal file
32
src/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
19
src/health/health.module.ts
Normal file
19
src/health/health.module.ts
Normal 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
23
src/main.ts
Normal 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();
|
||||
210
src/users/controllers/users.controller.ts
Normal file
210
src/users/controllers/users.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/users/models/roles.enum.ts
Normal file
14
src/users/models/roles.enum.ts
Normal 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
101
src/users/models/user.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
311
src/users/services/users.service.ts
Normal file
311
src/users/services/users.service.ts
Normal 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
23
src/users/users.module.ts
Normal 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
25
test/app.e2e-spec.ts
Normal 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
9
test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
40
test/token.service.spec.ts
Normal file
40
test/token.service.spec.ts
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user