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