diff --git a/.env-sample b/.env-sample index 93069d0..d304c28 100644 --- a/.env-sample +++ b/.env-sample @@ -1,16 +1,28 @@ -# .env +# .env-sample NODE_ENV=development PORT=3000 KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com KEYCLOAK_REALM=dcb-dev + +KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs +KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev + KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB -KEYCLOAK_ADMIN_CLIENT_ID=dcb-user-service-cc -KEYCLOAK_ADMIN_CLIENT_SECRET=VS7fDASmxmPOjn0JkhbtNDh7ULm0QGGa -KEYCLOAK_AUTH_CLIENT_ID=dcb-user-service-pwd -KEYCLOAK_AUTH_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2 +KEYCLOAK_CLIENT_ID=dcb-user-service-pwd +KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2 KEYCLOAK_VALIDATION_MODE=offline -KEYCLOAK_TOKEN_BUFFER_SECONDS=30 \ No newline at end of file +KEYCLOAK_TOKEN_BUFFER_SECONDS=30 + +KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin +KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 + +KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant +KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025 + +KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support +KEYCLOAK_TEST_PASSWORD=@BOSupport2025 + diff --git a/package-lock.json b/package-lock.json index 4060610..af96286 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,22 +10,28 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/axios": "^4.0.1", + "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.1.7", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.7", "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.7", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.4.0", "axios": "^1.12.2", + "cache-manager": "^7.2.4", + "circuit-breaker-ts": "^0.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "helmet": "^8.1.0", + "jwks-rsa": "^3.2.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", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, @@ -727,6 +733,24 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cacheable/utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.1.0.tgz", + "integrity": "sha512-ZdxfOiaarMqMj+H7qwlt5EBKWaeGihSYVHdQv5lUsbn8MJJOTW82OIwirQ39U5tMZkNvy3bQE+ryzC+xTAb9/g==", + "license": "MIT", + "dependencies": { + "keyv": "^5.5.3" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", + "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2123,6 +2147,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2156,6 +2186,19 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.0.1.tgz", + "integrity": "sha512-4UxTnR0fsmKL5YDalU2eLFVnL+OBebWUpX+hEduKGncrVKH4PPNoiRn1kXyOCjmzb0UvWgqubpssNouc8e0MCw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0", + "cache-manager": ">=6", + "keyv": ">=5", + "rxjs": "^7.8.1" + } + }, "node_modules/@nestjs/cli": { "version": "11.0.10", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.10.tgz", @@ -2487,6 +2530,16 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz", @@ -2986,7 +3039,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -2997,7 +3049,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3095,7 +3146,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -3209,7 +3259,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -3253,21 +3302,18 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3277,7 +3323,6 @@ "version": "1.15.9", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -3289,7 +3334,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -4810,6 +4854,25 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.4.tgz", + "integrity": "sha512-skmhkqXjPCBmrb70ctEx4zwFk7vb0RdFXlVGYWnFZ8pKvkzdFrFFKSJ1IaKduGfkryHOJvb7q2PkGmonmL+UGw==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.1.0", + "keyv": "^5.5.3" + } + }, + "node_modules/cache-manager/node_modules/keyv": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz", + "integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4989,6 +5052,12 @@ "node": ">=8" } }, + "node_modules/circuit-breaker-ts": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/circuit-breaker-ts/-/circuit-breaker-ts-0.1.0.tgz", + "integrity": "sha512-egw3mLBRGtncvnwqLBcCN2KI8H9B7mcSMgk2vibxh7Z9egtCcLRsMXVDOGl2ZUE/z2aHdu+8ldmWiZeg+b2XHQ==", + "license": "MIT" + }, "node_modules/cjs-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", @@ -8082,6 +8151,15 @@ "node": ">= 20" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8220,6 +8298,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.24.tgz", + "integrity": "sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -8294,6 +8413,11 @@ "integrity": "sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==", "license": "MIT" }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8356,6 +8480,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8439,6 +8569,34 @@ "yallist": "^3.0.2" } }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -9162,6 +9320,16 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/package.json b/package.json index 0a1dd28..e11d051 100644 --- a/package.json +++ b/package.json @@ -21,22 +21,28 @@ }, "dependencies": { "@nestjs/axios": "^4.0.1", + "@nestjs/cache-manager": "^3.0.1", "@nestjs/common": "^11.1.7", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.7", "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.7", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.4.0", "axios": "^1.12.2", + "cache-manager": "^7.2.4", + "circuit-breaker-ts": "^0.1.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "helmet": "^8.1.0", + "jwks-rsa": "^3.2.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", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, diff --git a/src.zip b/src.zip new file mode 100644 index 0000000..17c13d7 Binary files /dev/null and b/src.zip differ diff --git a/src/api/controllers/api.controller.ts b/src/api/controllers/api.controller.ts index 17563a8..bb93a22 100644 --- a/src/api/controllers/api.controller.ts +++ b/src/api/controllers/api.controller.ts @@ -1,14 +1,37 @@ -import { Controller, Get, Req, UseGuards, Logger } from '@nestjs/common'; -import { ClientCredentialsGuard } from '../../auth/guards/client-credentials.guard'; -import { Roles } from '../../decorators/roles.decorator'; +import { Controller, Get, Logger } from '@nestjs/common'; +import { AuthenticatedUser, Roles, Resource, Scopes } from 'nest-keycloak-connect'; +import { RESOURCES } from '../../constants/resouces'; +import { SCOPES } from '../../constants/scopes'; @Controller('api') -@UseGuards(ClientCredentialsGuard) // applique le guard à tout le controller +@Resource(RESOURCES.USER) export class ApiController { private readonly logger = new Logger(ApiController.name); + @Get('secure') + @Scopes(SCOPES.READ) + getSecure(@AuthenticatedUser() user: any) { + this.logger.log(`User ${user?.preferred_username} accessed /secure`); + return { + message: 'Accès autorisé', + user, + }; + } + + @Get('token-details') + @Scopes(SCOPES.READ) + tokenDetails(@AuthenticatedUser() user: any) { + return { + username: user.preferred_username, + client_id: user.client_id, + realmRoles: user.realm_access?.roles || [], + resourceRoles: user.resource_access?.[user.client_id]?.roles || [], + }; + } + + @Get('protected') - @Roles('DCB_ADMIN') // ex: uniquement les clients avec DCB_ADMIN peuvent accéder + @Scopes(SCOPES.READ) getProtected() { this.logger.log('Accessed protected route'); return { @@ -17,17 +40,8 @@ export class ApiController { }; } - @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', - }; + @Get('public') + getPublic() { + return { message: 'Accès public' }; } } diff --git a/src/app.module.ts b/src/app.module.ts index 976ea40..93da586 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -41,10 +41,9 @@ import { UsersModule } from './users/users.module'; return { authServerUrl: keycloakConfig.serverUrl, realm: keycloakConfig.realm, - clientId: keycloakConfig.clientId, - secret: keycloakConfig.clientSecret, + clientId: keycloakConfig.authClientId, + secret: keycloakConfig.authClientSecret, useNestLogger: true, - bearerOnly: true, /** * Validation OFFLINE : diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index e0b6caa..1d6fc3c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -7,6 +7,9 @@ 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'; +import { KeycloakJwtStrategy } from './services/keycloak.strategy'; +import { JwtAuthGuard } from './guards/jwt.guard'; + @Module({ @@ -14,8 +17,15 @@ import { UsersService } from '../users/services/users.service'; HttpModule, JwtModule.register({}), ], - providers: [StartupService, TokenService, KeycloakApiService, UsersService], + providers: [ + KeycloakJwtStrategy, + JwtAuthGuard, + StartupService, + TokenService, + KeycloakApiService, + UsersService + ], controllers: [AuthController, HealthController], - exports: [StartupService, TokenService, KeycloakApiService, UsersService, JwtModule], + exports: [JwtAuthGuard, StartupService, TokenService, KeycloakApiService, UsersService, JwtModule], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 7aa49de..b45695b 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -9,21 +9,13 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; -import { - AuthenticatedUser, - Public, - Roles, -} from 'nest-keycloak-connect'; +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'; +import * as user from '../../users/models/user'; -interface LoginDto { - username: string; - password: string; -} @Controller('auth') export class AuthController { @@ -31,7 +23,6 @@ export class AuthController { constructor( private readonly tokenService: TokenService, - private readonly keycloakApiService: KeycloakApiService, private readonly configService: ConfigService, private readonly usersService: UsersService ) {} @@ -39,16 +30,14 @@ export class AuthController { /** ------------------------------- * LOGIN (Resource Owner Password Credentials) * ------------------------------- */ + + // === AUTHENTIFICATION === @Public() @Post('login') - async login( - @Body() loginDto: LoginDto - ): Promise<{ - access_token: string; - refresh_token?: string; - expires_in: number; - token_type: string; - }> { + async login(@Body() loginDto: user.LoginDto) { + + this.logger.log(`User login attempt: ${loginDto.username}`); + const { username, password } = loginDto; if (!username || !password) { @@ -56,7 +45,8 @@ export class AuthController { } try { - const tokenResponse = await this.tokenService.acquireUserToken(username, password); + // Appel au UserService pour l'authentification + const tokenResponse = await this.usersService.authenticateUser(loginDto); this.logger.log(`User "${username}" authenticated successfully`); return { @@ -66,38 +56,21 @@ export class AuthController { 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')) { + const msg = err.message || ''; + if (msg.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 - ); + throw new HttpException('Account setup incomplete', HttpStatus.FORBIDDEN); } - - if (errorMessage.includes('Invalid user credentials')) { + if (msg.includes('Invalid user credentials')) { this.logger.warn(`Invalid credentials for user: "${username}"`); - throw new HttpException( - 'Invalid username or password', - HttpStatus.UNAUTHORIZED - ); + throw new HttpException('Invalid username or password', HttpStatus.UNAUTHORIZED); } - - if (errorMessage.includes('User is disabled')) { + if (msg.includes('User is disabled')) { this.logger.warn(`Disabled user attempted login: "${username}"`); - throw new HttpException( - 'Account is disabled', - HttpStatus.FORBIDDEN - ); + throw new HttpException('Account is disabled', HttpStatus.FORBIDDEN); } - - this.logger.warn(`Authentication failed for "${username}": ${errorMessage}`); - throw new HttpException( - 'Authentication failed', - HttpStatus.UNAUTHORIZED - ); + this.logger.warn(`Authentication failed for "${username}": ${msg}`); + throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED); } } @@ -107,49 +80,41 @@ export class AuthController { @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); - } + if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST); try { - const refreshToken = await this.tokenService.getStoredRefreshToken(token); + + // Récupérer le refresh token depuis le UserService + const refreshToken = this.tokenService.getUserRefreshToken(); + if (refreshToken) { - await this.tokenService.revokeToken(refreshToken); + try { + // Appel au TokenService pour révoquer le token + await this.tokenService.revokeToken(refreshToken); + this.logger.log('Refresh token revoked successfully'); + } catch (revokeError) { + this.logger.warn('Failed to revoke refresh token, continuing with local logout', revokeError); + } } + // Nettoyer les tokens stockés dans UserService + await this.tokenService.clearUserToken(); + this.logger.log(`User logged out successfully`); return { message: 'Logout successful' }; - } catch (error: any) { - this.logger.error('Logout failed', error); + } catch (err: any) { + this.logger.error('Logout failed', err); + + // En cas d'erreur, nettoyer quand même les tokens dans UserService + await this.tokenService.clearUserToken(); + + if (err instanceof HttpException) { + throw err; + } 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 * ------------------------------- */ @@ -157,22 +122,18 @@ export class AuthController { @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); - } + 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); + } catch (err: any) { + this.logger.error('Token refresh failed', err); throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED); } } @@ -183,21 +144,15 @@ export class AuthController { @Public() @Get('status') async getAuthStatus(@Req() req: Request) { - const authHeader = req.headers['authorization']; + const token = req.headers['authorization']?.replace('Bearer ', ''); let isValid = false; - - if (authHeader) { + if (token) { try { - const token = authHeader.replace('Bearer ', ''); isValid = await this.tokenService.validateToken(token); - } catch (error) { + } catch { this.logger.debug('Token validation failed in status check'); } } - - return { - authenticated: isValid, - status: isValid ? 'Token is valid' : 'Token is invalid or expired', - }; + return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' }; } -} \ No newline at end of file +} diff --git a/src/auth/guards/client-credentials.guard.ts b/src/auth/guards/client-credentials.guard.ts deleted file mode 100644 index 31f41ad..0000000 --- a/src/auth/guards/client-credentials.guard.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 { - const request = context.switchToHttp().getRequest(); - - 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) || []; - } -} diff --git a/src/auth/guards/jwt.guard.ts b/src/auth/guards/jwt.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/auth/guards/jwt.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/guards/user-auth.guard.ts b/src/auth/guards/user-auth.guard.ts deleted file mode 100644 index 5166eb2..0000000 --- a/src/auth/guards/user-auth.guard.ts +++ /dev/null @@ -1,68 +0,0 @@ -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('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('keycloak.serverUrl')}/realms/${this.configService.get('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-----`; - } -} diff --git a/src/auth/services/keycloak-api.service.ts b/src/auth/services/keycloak-api.service.ts index b23e7c2..405de54 100644 --- a/src/auth/services/keycloak-api.service.ts +++ b/src/auth/services/keycloak-api.service.ts @@ -1,385 +1,246 @@ -import { Injectable, Logger, HttpException, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, HttpException, NotFoundException, BadRequestException } 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'; +import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs'; +import { TokenService } from './token.service'; // Import du TokenService -interface DecodedToken { - sub: string; - preferred_username?: string; +export interface KeycloakUser { + id?: string; + username: string; email?: string; - given_name?: string; - family_name?: string; - realm_access?: { roles: string[] }; - resource_access?: Record; - scope?: string; + firstName?: string; + lastName?: string; + enabled: boolean; + emailVerified: boolean; + attributes?: Record; } +export interface KeycloakRole { + id: string; + name: string; + description?: string; +} + +export type ClientRole = 'admin' | 'merchant' | 'support'; + @Injectable() export class KeycloakApiService { private readonly logger = new Logger(KeycloakApiService.name); private readonly keycloakBaseUrl: string; + private readonly realm: string; + private readonly clientId: string; constructor( private readonly httpService: HttpService, - private readonly tokenService: TokenService, private readonly configService: ConfigService, + private readonly tokenService: TokenService, // Injection du TokenService ) { - this.keycloakBaseUrl = this.configService.get('keycloak.serverUrl') - || 'https://keycloak-dcb.app.cameleonapp.com'; + this.keycloakBaseUrl = this.configService.get('KEYCLOAK_SERVER_URL') || 'http://localhost:8080'; + this.realm = this.configService.get('KEYCLOAK_REALM') || 'master'; + this.clientId = this.configService.get('KEYCLOAK_CLIENT_ID') || 'admin-cli'; } - async request( + // ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR ===== + async authenticateUser(username: string, password: string) { + return this.tokenService.acquireUserToken(username, password); + } + + // ===== CORE REQUEST METHOD (pour opérations admin) ===== + private async request( method: 'GET' | 'POST' | 'PUT' | 'DELETE', - url: string, - data?: any, - opts?: { timeoutMs?: number }, + path: string, + data?: any ): Promise { - const token = await this.tokenService.getToken(); + const token = await this.tokenService.acquireServiceAccountToken(); + const url = `${this.keycloakBaseUrl}${path}`; + const config = { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, + timeout: 10000, }; try { - let obs; - const timeoutMs = opts?.timeoutMs ?? 5000; + let response: AxiosResponse; switch (method) { case 'GET': - obs = this.httpService.get(url, config); + response = await firstValueFrom(this.httpService.get(url, config).pipe(rxjsTimeout(10000))); break; case 'POST': - obs = this.httpService.post(url, data, config); + response = await firstValueFrom(this.httpService.post(url, data, config).pipe(rxjsTimeout(10000))); break; case 'PUT': - obs = this.httpService.put(url, data, config); + response = await firstValueFrom(this.httpService.put(url, data, config).pipe(rxjsTimeout(10000))); break; case 'DELETE': - obs = this.httpService.delete(url, config); + response = await firstValueFrom(this.httpService.delete(url, config).pipe(rxjsTimeout(10000))); break; default: - throw new Error(`Unsupported HTTP method: ${method}`); + throw new BadRequestException(`Unsupported HTTP method: ${method}`); } - - const response: AxiosResponse = 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); + } catch (error: any) { + this.handleRequestError(error, path); } } - private sanitizeUrl(url: string): string { - return url.replace(/\/[0-9a-fA-F-]{36}\//g, '/***/'); + // ===== ERROR HANDLING ===== + private handleRequestError(error: any, context: string): never { + if (error.response?.status === 404) { + throw new NotFoundException(`Resource not found: ${context}`); + } + if (error.response?.status === 409) { + throw new BadRequestException('User already exists'); + } + + this.logger.error(`Keycloak API error in ${context}: ${error.message}`, { + status: error.response?.status, + }); + + throw new HttpException( + error.response?.data?.errorMessage || 'Keycloak operation failed', + error.response?.status || 500 + ); } - private buildKeycloakUrl(path: string): string { - return `${this.keycloakBaseUrl}${path.startsWith('/') ? path : `/${path}`}`; - } + // ===== USER CRUD OPERATIONS ===== + async createUser(userData: { + username: string; + email: string; + firstName: string; + lastName: string; + password: string; + enabled?: boolean; + }): Promise { + const userPayload = { + username: userData.username, + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + enabled: userData.enabled ?? true, + emailVerified: true, + credentials: [{ + type: 'password', + value: userData.password, + temporary: false, + }], + }; - // === REALM OPERATIONS === - async getRealmClients(realm: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients`); - return this.request('GET', url); - } - - async getRealmInfo(realm: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}`); - return this.request('GET', url); + await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload); + + const users = await this.findUserByUsername(userData.username); + if (users.length === 0) { + throw new Error('Failed to create user'); + } + + return users[0].id!; } - - // === USER OPERATIONS === - async getUserById(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`); - return this.request('GET', url); + async getUserById(userId: string): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); } - private decodeJwt(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; + async getAllUsers(): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users`); } - /** - * Récupère le profil utilisateur. - * Offline validation → décodage JWT - * Fallback → Keycloak /userinfo - */ - async getUserProfile(realm: string, accessToken: string): Promise { - // --- 1. Décodage du token (offline) --- + async findUserByUsername(username: string): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`); + } + + async findUserByEmail(email: string): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`); + } + + async updateUser(userId: string, userData: Partial): Promise { + return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData); + } + + async deleteUser(userId: string): Promise { + return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); + } + + // ===== CLIENT ROLE OPERATIONS ===== + async getUserClientRoles(userId: string): Promise { + const clients = await this.getClient(); + return this.request('GET', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`); + } + + async assignClientRole(userId: string, role: ClientRole): Promise { + const clients = await this.getClient(); + const targetRole = await this.getRole(role, clients[0].id); + + return this.request('POST', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]); + } + + async removeClientRole(userId: string, role: ClientRole): Promise { + const clients = await this.getClient(); + const targetRole = await this.getRole(role, clients[0].id); + + return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]); + } + + async setClientRoles(userId: string, roles: ClientRole[]): Promise { + const currentRoles = await this.getUserClientRoles(userId); + if (currentRoles.length > 0) { + const clients = await this.getClient(); + await this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, currentRoles); + } + + for (const role of roles) { + await this.assignClientRole(userId, role); + } + } + + // ===== UTILITY METHODS ===== + async userExists(username: string): Promise { try { - const decoded: DecodedToken = this.decodeJwt(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); + const users = await this.findUserByUsername(username); + return users.length > 0; + } catch { + return false; } } - async getUsers(realm: string, queryParams?: { - briefRepresentation?: boolean; - email?: string; - first?: number; - firstName?: string; - lastName?: string; - max?: number; - search?: string; - username?: string; - }): Promise { - 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('GET', url); + async enableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: true }); } - async createUser(realm: string, user: any): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`); - return this.request('POST', url, user); + async disableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: false }); } - async updateUser(realm: string, userId: string, data: any): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`); - return this.request('PUT', url, data); - } - - async deleteUser(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`); - await this.request('DELETE', url); - } - - // === ROLE OPERATIONS === - async getRealmRoles(realm: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/roles`); - return this.request('GET', url); - } - - async getUserRealmRoles(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`); - return this.request('GET', url); - } - - async assignRealmRoles(realm: string, userId: string, roles: any[]): Promise { - 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('POST', url, rolesToAssign); - } - - async removeRealmRoles(realm: string, userId: string, roles: any[]): Promise { - 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('DELETE', url, rolesToRemove); - } - - // === PASSWORD OPERATIONS === - async resetPassword(realm: string, userId: string, newPassword: string, temporary: boolean = true): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/reset-password`); - + async resetPassword(userId: string, newPassword: string): Promise { const credentials = { type: 'password', value: newPassword, - temporary: temporary, + temporary: false, }; - return this.request('PUT', url, credentials); + return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials); } - // === GROUP OPERATIONS === - async getUserGroups(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups`); - return this.request('GET', url); - } - - async addUserToGroup(realm: string, userId: string, groupId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`); - return this.request('PUT', url); - } - - async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`); - return this.request('DELETE', url); - } - - // === CLIENT OPERATIONS === - async getClientRoles(realm: string, clientId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients/${clientId}/roles`); - return this.request('GET', url); - } - - async getUserClientRoles(realm: string, userId: string, clientId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/clients/${clientId}`); - return this.request('GET', url); - } - - // === SESSION OPERATIONS === - async getUserSessions(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/sessions`); - return this.request('GET', url); - } - - async logoutUser(realm: string, userId: string): Promise { - const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/logout`); - return this.request('POST', url); - } - - // === SEARCH OPERATIONS === - async searchUsers(realm: string, search: string, maxResults: number = 50): Promise { - const users = await this.getUsers(realm, { search, max: maxResults }); - return users; - } - - async getUserByUsername(realm: string, username: string): Promise { - const users = await this.getUsers(realm, { username, max: 1 }); - return users.length > 0 ? users[0] : null; - } - - async getUserByEmail(realm: string, email: string): Promise { - 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): Promise { - 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 { - await this.updateUser(realm, userId, { enabled: false }); - } - - async enableUser(realm: string, userId: string): Promise { - await this.updateUser(realm, userId, { enabled: true }); - } - - // === HEALTH CHECK === - async checkHealth(): Promise<{ status: string; realm: string }> { - try { - const realm = this.configService.get('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' - }; + // ===== PRIVATE HELPERS ===== + private async getClient(): Promise { + const clients = await this.request('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`); + if (!clients || clients.length === 0) { + throw new Error('Client not found'); } + return clients; } + private async getRole(role: ClientRole, clientId: string): Promise { + const roles = await this.request('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`); + const targetRole = roles.find(r => r.name === role); + + if (!targetRole) { + throw new BadRequestException(`Role '${role}' not found`); + } + return targetRole; + } } \ No newline at end of file diff --git a/src/auth/services/keycloak.strategy.ts b/src/auth/services/keycloak.strategy.ts new file mode 100644 index 0000000..9c386de --- /dev/null +++ b/src/auth/services/keycloak.strategy.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import * as jwksRsa from 'jwks-rsa'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') { + private readonly logger = new Logger(KeycloakJwtStrategy.name); + + constructor(private configService: ConfigService) { + const jwksUri = configService.get('KEYCLOAK_JWKS_URI'); + const issuer = configService.get('KEYCLOAK_ISSUER'); + + if (!jwksUri || !issuer) { + throw new Error('Missing Keycloak configuration (KEYCLOAK_JWKS_URI / KEYCLOAK_ISSUER)'); + } + + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKeyProvider: jwksRsa.passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri, + }), + issuer, + algorithms: ['RS256'], + }); + } + + async validate(payload: any) { + // Récupération des rôles du realm + const realmRoles: string[] = payload.realm_access?.roles || []; + + // Récupération des rôles du client dans resource_access + const clientId = payload.client_id; + const resourceRoles: string[] = + payload.resource_access?.[clientId]?.roles || []; + + // Fusion et suppression des doublons + const roles = Array.from(new Set([...realmRoles, ...resourceRoles])); + + this.logger.verbose(`User ${payload.preferred_username} roles: ${JSON.stringify(roles)}`); + + return { + sub: payload.sub, + preferred_username: payload.preferred_username, + email: payload.email, + client_id: clientId, + roles, + realmRoles, + resourceRoles, + }; + } +} diff --git a/src/auth/services/startup.service.ts b/src/auth/services/startup.service.ts index e8bcb8b..5174521 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -1,30 +1,162 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { TokenService } from './token.service'; import { ConfigService } from '@nestjs/config'; +import { KeycloakApiService } from './keycloak-api.service'; @Injectable() export class StartupService implements OnModuleInit { private readonly logger = new Logger(StartupService.name); private isInitialized = false; private initializationError: string | null = null; + private userToken: string | null = null; constructor( private readonly tokenService: TokenService, + private readonly keycloakApiService: KeycloakApiService, private readonly configService: ConfigService, ) {} async onModuleInit() { - this.logger.log('Starting Keycloak connection...'); - + this.logger.log('Starting Keycloak connection tests...'); + + const username = this.configService.get('KEYCLOAK_TEST_USER_ADMIN'); + const password = this.configService.get('KEYCLOAK_TEST_PASSWORD_ADMIN'); + + if (!username || !password) { + this.initializationError = 'Test username/password not configured'; + this.logger.error(this.initializationError); + return; + } + try { - // Test simple : acquisition du token admin - await this.tokenService.getToken(); + // 1. Test d'authentification utilisateur + const tokenResponse = await this.keycloakApiService.authenticateUser(username, password); + this.userToken = tokenResponse.access_token; + this.logger.log('✅ User authentication test passed'); + + // 2. Test des opérations CRUD admin + //await this.testAdminOperations(); + + // 3. Test des opérations avec le nouveau mot de passe + //await this.testUserOperationsWithNewPassword(); + this.isInitialized = true; - this.logger.log('✅ Keycloak connection established successfully'); + this.logger.log('✅ All CRUD operations tested successfully'); + + } catch (error: any) { + this.initializationError = error.message || error; + this.logger.error('❌ Keycloak connection or method test failed', error); + } + } + + private async testAdminOperations(): Promise { + this.logger.log('Testing admin CRUD operations...'); + + let testUserId = ''; + const usernameTest = 'startup-test-user'; + + // Vérifier si l'utilisateur existe + let testUser = await this.keycloakApiService.findUserByUsername(usernameTest); + if (!testUser || testUser.length === 0) { + // Créer l'utilisateur si inexistant + const newUser = { + username: usernameTest, + email: 'startup-test@example.com', + firstName: 'Startup', + lastName: 'Test', + password: 'TempPassword123!', + enabled: true, + }; + testUserId = await this.keycloakApiService.createUser(newUser); + this.logger.log(`✅ Test user "${usernameTest}" created with ID: ${testUserId}`); + } else { + testUserId = testUser[0].id!; + this.logger.log(`✅ Using existing test user ID: ${testUserId}`); + } + + if (!testUserId) { + throw new Error('Unable to create or fetch test user'); + } + + // Récupérer l'utilisateur par ID + const userById = await this.keycloakApiService.getUserById(testUserId); + this.logger.log(`✅ User fetched by ID: ${userById.username}`); + + // Modifier l'utilisateur + await this.keycloakApiService.updateUser(testUserId, { firstName: 'UpdatedStartup' }); + this.logger.log(`✅ User updated: ${testUserId}`); + + // Opérations sur les rôles + const userRoles = await this.keycloakApiService.getUserClientRoles(testUserId); + this.logger.log(`✅ User client roles: ${userRoles.length}`); + + // Assigner un rôle client + await this.keycloakApiService.assignClientRole(testUserId, 'merchant'); + this.logger.log(`✅ Assigned merchant role to user: ${testUserId}`); + + // Vérifier les rôles après assignation + const rolesAfterAssign = await this.keycloakApiService.getUserClientRoles(testUserId); + this.logger.log(`✅ User roles after assignment: ${rolesAfterAssign.map(r => r.name).join(', ')}`); + + // Retirer le rôle + await this.keycloakApiService.removeClientRole(testUserId, 'merchant'); + this.logger.log(`✅ Removed merchant role from user: ${testUserId}`); + + // Tester l'assignation multiple de rôles + await this.keycloakApiService.setClientRoles(testUserId, ['admin', 'support']); + this.logger.log(`✅ Set multiple roles for user: admin, support`); + + // Vérifier les rôles après assignation multiple + const finalRoles = await this.keycloakApiService.getUserClientRoles(testUserId); + this.logger.log(`✅ Final user roles: ${finalRoles.map(r => r.name).join(', ')}`); + + // Réinitialisation du mot de passe + const newPassword = 'NewStartupPass123!'; + await this.keycloakApiService.resetPassword(testUserId, newPassword); + this.logger.log(`✅ Password reset for user: ${testUserId}`); + + // Tester enable/disable user + await this.keycloakApiService.disableUser(testUserId); + this.logger.log(`✅ User disabled: ${testUserId}`); + + // Vérifier que l'utilisateur est désactivé + const disabledUser = await this.keycloakApiService.getUserById(testUserId); + this.logger.log(`✅ User enabled status: ${disabledUser.enabled}`); + + // Réactiver l'utilisateur + await this.keycloakApiService.enableUser(testUserId); + this.logger.log(`✅ User enabled: ${testUserId}`); + + // Tester la recherche d'utilisateurs + const userByEmail = await this.keycloakApiService.findUserByEmail('startup-test@example.com'); + this.logger.log(`✅ User found by email: ${userByEmail ? userByEmail[0]?.username : 'none'}`); + + // Vérifier si l'utilisateur existe + const userExists = await this.keycloakApiService.userExists(usernameTest); + this.logger.log(`✅ User exists check: ${userExists}`); + + // Récupérer tous les utilisateurs + const allUsers = await this.keycloakApiService.getAllUsers(); + this.logger.log(`✅ All users count: ${allUsers.length}`); + + // Cleanup optionnel - Supprimer l'utilisateur de test + // await this.keycloakApiService.deleteUser(testUserId); + // this.logger.log(`✅ Test user deleted: ${testUserId}`); + } + + private async testUserOperationsWithNewPassword(): Promise { + this.logger.log('Testing user operations with new password...'); + + const usernameTest = 'startup-test-user'; + const newPassword = 'NewStartupPass123!'; + + try { + // Tester la reconnexion avec le nouveau mot de passe + const newTokenResponse = await this.keycloakApiService.authenticateUser(usernameTest, newPassword); + this.logger.log(`✅ Test user reconnected successfully with new password`); + this.logger.log(`✅ New token acquired for test user`); } 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 + this.logger.warn(`⚠️ Test user reconnection failed: ${error.message}`); } } @@ -33,15 +165,20 @@ export class StartupService implements OnModuleInit { status: this.isInitialized ? 'healthy' : 'unhealthy', keycloak: { connected: this.isInitialized, - realm: this.configService.get('keycloak.realm'), - serverUrl: this.configService.get('keycloak.serverUrl'), + realm: this.configService.get('KEYCLOAK_REALM'), + serverUrl: this.configService.get('KEYCLOAK_SERVER_URL'), }, timestamp: new Date(), error: this.initializationError, + userToken: this.userToken ? 'available' : 'none', }; } isHealthy(): boolean { return this.isInitialized; } + + getUserToken(): string | null { + return this.userToken; + } } \ No newline at end of file diff --git a/src/auth/services/token.service.ts b/src/auth/services/token.service.ts index 66af6c5..236d939 100644 --- a/src/auth/services/token.service.ts +++ b/src/auth/services/token.service.ts @@ -7,7 +7,6 @@ import * as jwt from 'jsonwebtoken'; export interface KeycloakTokenResponse { access_token: string; - refresh_token?: string; expires_in: number; token_type: string; @@ -17,101 +16,72 @@ export interface KeycloakTokenResponse { @Injectable() export class TokenService { private readonly logger = new Logger(TokenService.name); + private readonly keycloakConfig: KeycloakConfig; - private currentToken: string | null = null; - private tokenExpiry: Date | null = null; + // Cache pour le token de service account + private serviceAccountToken: string | null = null; + private serviceTokenExpiry: number = 0; + // === TOKEN STORAGE === + private userToken: string | null = null; + private userTokenExpiry: Date | null = null; + private userRefreshToken: string | null = null; + constructor( - private configService: ConfigService, - private httpService: HttpService, - ) {} - - // === POUR L'API ADMIN (KeycloakApiService) - Client Credentials === - async getToken(): Promise { - // 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 readonly configService: ConfigService, + private readonly httpService: HttpService, + ) { + this.keycloakConfig = this.getKeycloakConfig(); } - private async acquireClientCredentialsToken(): Promise { - const keycloakConfig = this.configService.get('keycloak'); - - if (!keycloakConfig) { + // === CONFIGURATION === + private getKeycloakConfig(): KeycloakConfig { + const config = this.configService.get('keycloak'); + if (!config) { 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(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'); - } + return config; } - 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('keycloak.tokenBufferSeconds') || 30; - const bufferMs = bufferSeconds * 1000; - - return this.tokenExpiry.getTime() > (Date.now() + bufferMs); + private getTokenEndpoint(): string { + return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`; } - // === POUR L'AUTHENTIFICATION UTILISATEUR (AuthController) - Password Grant === + // === CACHE MANAGEMENT === + private isServiceTokenValid(): boolean { + if (!this.serviceAccountToken) return false; + + const bufferMs = 30000; // 30 seconds buffer + return Date.now() < this.serviceTokenExpiry - bufferMs; + } + + private setServiceToken(token: string, expiresIn: number): void { + this.serviceAccountToken = token; + this.serviceTokenExpiry = Date.now() + (expiresIn * 1000); + } + + // === TOKEN ACQUISITION === async acquireUserToken(username: string, password: string): Promise { - const keycloakConfig = this.configService.get('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); + const params = new URLSearchParams({ + grant_type: 'password', + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + username, + password, + }); try { const response = await firstValueFrom( - this.httpService.post(tokenEndpoint, params, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + this.httpService.post(this.getTokenEndpoint(), params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) ); - this.logger.log(`User token acquired successfully for: ${username}`); + // Stocker le token et ses métadonnées + this.storeUserToken(response.data); + + this.logger.log(`User token acquired for: ${username}`); return response.data; } catch (error: any) { this.logger.error('Failed to acquire user token', error.response?.data); @@ -119,30 +89,161 @@ export class TokenService { } } - async refreshToken(refreshToken: string): Promise { - const keycloakConfig = this.configService.get('keycloak'); + // === TOKEN STORAGE METHOD === + private storeUserToken(tokenResponse: KeycloakTokenResponse): void { + this.userToken = tokenResponse.access_token; + this.userRefreshToken = tokenResponse.refresh_token || null; - if (!keycloakConfig) { - throw new Error('Keycloak configuration not found'); + // Calculer la date d'expiration (timestamp actuel + expires_in en secondes) + const expiresInMs = tokenResponse.expires_in * 1000; + this.userTokenExpiry = new Date(Date.now() + expiresInMs); + + this.logger.log('User token stored successfully'); + } + + // === GET USER TOKEN === + getUserToken(): string | null { + if (this.isUserTokenValid()) { + return this.userToken; + } + + this.logger.warn('User token is expired or invalid'); + // Optionnel : tenter un rafraîchissement automatique ici + return null; + } + + // === TOKEN VALIDATION === + private isUserTokenValid(): boolean { + if (!this.userToken || !this.userTokenExpiry) { + return false; + } + + // Ajouter une marge de sécurité (30 secondes) pour éviter les tokens sur le point d'expirer + const safetyMargin = 30 * 1000; // 30 secondes en millisecondes + return new Date() < new Date(this.userTokenExpiry.getTime() - safetyMargin); + } + + // === REFRESH TOKEN === + async refreshUserToken(): Promise { + if (!this.userRefreshToken) { + this.logger.warn('No refresh token available'); + return null; } - 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); + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + refresh_token: this.userRefreshToken, + }); try { const response = await firstValueFrom( - this.httpService.post(tokenEndpoint, params, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + this.httpService.post(this.getTokenEndpoint(), params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) ); + // Mettre à jour le token stocké + this.storeUserToken(response.data); + + this.logger.log('User token refreshed successfully'); + return response.data; + } catch (error: any) { + this.logger.error('Failed to refresh user token', error.response?.data); + this.clearUserToken(); // Nettoyer les tokens invalides + throw new Error(error.response?.data?.error_description || 'Token refresh failed'); + } + } + + // === CLEAR USER TOKEN === + clearUserToken(): void { + this.userToken = null; + this.userTokenExpiry = null; + this.userRefreshToken = null; + this.logger.log('User token cleared'); + } + + // === CHECK TOKEN STATUS === + isUserAuthenticated(): boolean { + return this.isUserTokenValid(); + } + + // === GET TOKEN EXPIRY === + getUserTokenExpiry(): Date | null { + return this.userTokenExpiry; + } + + // === GET REFRESH TOKEN === + getUserRefreshToken(): string | null { + return this.userRefreshToken; + } + + // === GET TOKEN WITH AUTO-REFRESH === + async getValidUserToken(): Promise { + // Si le token est valide, le retourner + if (this.isUserTokenValid()) { + return this.userToken; + } + + // Si le token a expiré mais on a un refresh token, tenter de le rafraîchir + if (this.userRefreshToken) { + try { + const newToken = await this.refreshUserToken(); + return newToken?.access_token || null; + } catch (error) { + this.logger.error('Auto-refresh failed, user needs to reauthenticate'); + return null; + } + } + + return null; + } + + async acquireServiceAccountToken(): Promise { + if (this.isServiceTokenValid()) { + return this.serviceAccountToken!; + } + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(this.getTokenEndpoint(), params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + + this.setServiceToken(response.data.access_token, response.data.expires_in); + this.logger.log('Service account token acquired'); + + return this.serviceAccountToken!; + } catch (error: any) { + this.logger.error('Failed to acquire service account token', error.response?.data); + throw new Error('Service account authentication failed'); + } + } + + async refreshToken(refreshToken: string): Promise { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + refresh_token: refreshToken, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(this.getTokenEndpoint(), params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + + this.logger.log('Token refreshed successfully'); return response.data; } catch (error: any) { this.logger.error('Token refresh failed', error.response?.data); @@ -150,28 +251,83 @@ export class TokenService { } } - async revokeToken(token: string): Promise { - const keycloakConfig = this.configService.get('keycloak'); + // === TOKEN VALIDATION === + async validateToken(token: string): Promise { + const mode = this.keycloakConfig.validationMode || 'online'; - if (!keycloakConfig) { - throw new Error('Keycloak configuration not found'); + return mode === 'offline' + ? this.validateOffline(token) + : this.validateOnline(token); + } + + private async validateOnline(token: string): Promise { + const params = new URLSearchParams({ + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + token, + }); + + try { + const response = await firstValueFrom( + this.httpService.post( + `${this.getTokenEndpoint()}/introspect`, + 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; + } + } + + private validateOffline(token: string): boolean { + if (!this.keycloakConfig.publicKey) { + this.logger.error('Missing public key for offline validation'); + return false; } - 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 { + const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`; + + jwt.verify(token, formattedKey, { + algorithms: ['RS256'], + audience: this.keycloakConfig.authClientId, + }); + + return true; + } catch (error: any) { + this.logger.error('Offline token validation failed:', error.message); + return false; + } + } + + // === TOKEN UTILITIES === + decodeToken(token: string): any { + try { + return jwt.decode(token); + } catch (error: any) { + this.logger.error('Failed to decode token', error.message); + throw new Error('Invalid token format'); + } + } + + async revokeToken(token: string): Promise { + const params = new URLSearchParams({ + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + token, + }); try { await firstValueFrom( - this.httpService.post(revokeEndpoint, params, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }) + this.httpService.post( + `${this.getTokenEndpoint()}/revoke`, + params, + { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } + ) ); this.logger.log('Token revoked successfully'); @@ -181,89 +337,25 @@ export class TokenService { } } - async validateOffline(token: string): Promise { - const keycloakConfig = this.configService.get('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; - } + // === SERVICE MANAGEMENT === + clearServiceToken(): void { + this.serviceAccountToken = null; + this.serviceTokenExpiry = 0; + this.logger.log('Service account token cleared'); } - - async validateToken(token: string): Promise { - const mode = this.configService.get('keycloak.validationMode') || 'online'; - - if (mode === 'offline') { - return this.validateOffline(token); - } else { - return this.validateOnline(token); - } - } - - private async validateOnline(token: string): Promise { - const keycloakConfig = this.configService.get('keycloak'); - - if (!keycloakConfig) { - throw new Error('Keycloak configuration not found'); + getServiceTokenInfo(): { + hasToken: boolean; + expiresIn?: number; + } { + if (!this.serviceAccountToken) { + return { hasToken: false }; } - 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 { - // 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(); + const expiresIn = Math.max(0, Math.floor((this.serviceTokenExpiry - Date.now()) / 1000)); return { hasToken: true, - expiresIn: Math.max(0, Math.floor(expiresIn / 1000)), // en secondes - clientType: 'admin' + expiresIn: expiresIn > 0 ? expiresIn : undefined, }; } -} +} \ No newline at end of file diff --git a/src/config/keycloak.config.ts b/src/config/keycloak.config.ts index a47a8e8..57d3c04 100644 --- a/src/config/keycloak.config.ts +++ b/src/config/keycloak.config.ts @@ -6,8 +6,8 @@ export interface KeycloakConfig { realm: string; publicKey?: string; // Client pour l'API Admin (Service Account - client_credentials) - adminClientId: string; - adminClientSecret: string; + //adminClientId: string; + //adminClientSecret: string; // Client pour l'authentification utilisateur (Password Grant) authClientId: string; authClientSecret: string; @@ -20,11 +20,11 @@ export default registerAs('keycloak', (): KeycloakConfig => ({ 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 || '', + //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 || '', + authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-pwd', + authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '', validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online', tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30, })); @@ -53,7 +53,7 @@ export const keycloakConfigValidationSchema = Joi.object({ 'any.required': 'KEYCLOAK_PUBLIC_KEY is required' }), - KEYCLOAK_ADMIN_CLIENT_ID: Joi.string() + /*KEYCLOAK_ADMIN_CLIENT_ID: Joi.string() .required() .messages({ 'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required' @@ -65,20 +65,20 @@ export const keycloakConfigValidationSchema = Joi.object({ .messages({ 'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required', 'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty' - }), + }),*/ - KEYCLOAK_AUTH_CLIENT_ID: Joi.string() + KEYCLOAK_CLIENT_ID: Joi.string() .required() .messages({ - 'any.required': 'KEYCLOAK_AUTH_CLIENT_ID is required' + 'any.required': 'KEYCLOAK_CLIENT_ID is required' }), - KEYCLOAK_AUTH_CLIENT_SECRET: Joi.string() + KEYCLOAK_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' + 'any.required': 'KEYCLOAK_CLIENT_SECRET is required', + 'string.min': 'KEYCLOAK_CLIENT_SECRET cannot be empty' }), KEYCLOAK_VALIDATION_MODE: Joi.string() diff --git a/src/constants/resouces.ts b/src/constants/resouces.ts new file mode 100644 index 0000000..28bc9c0 --- /dev/null +++ b/src/constants/resouces.ts @@ -0,0 +1,3 @@ +export const RESOURCES = { + USER: 'user',// user resource for /users/* endpoints +}; diff --git a/src/constants/scopes.ts b/src/constants/scopes.ts new file mode 100644 index 0000000..a8b095d --- /dev/null +++ b/src/constants/scopes.ts @@ -0,0 +1,5 @@ +export const SCOPES = { + READ: 'read', + WRITE: 'write', + DELETE: 'delete', +}; diff --git a/src/decorators/roles.decorator.ts b/src/decorators/roles.decorator.ts deleted file mode 100644 index e2a75ba..0000000 --- a/src/decorators/roles.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -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); diff --git a/src/users/controllers/users.controller.ts b/src/users/controllers/users.controller.ts index 2f51f7c..cdd32a4 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/controllers/users.controller.ts @@ -10,201 +10,240 @@ import { Logger, HttpException, HttpStatus, + UseGuards, } from "@nestjs/common"; -import { - Roles, - AuthenticatedUser, - Public -} from "nest-keycloak-connect"; +import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect"; import { UsersService } from "../services/users.service"; -import { User, CreateUserDto, UpdateUserDto, UserResponse } from "../models/user"; -import { ROLES } from "../models/roles.enum"; +import * as user from "../models/user"; +import { RESOURCES } from '../../constants/resouces'; +import { SCOPES } from '../../constants/scopes'; -@Controller("user") -export class UserController { - private readonly logger = new Logger(UserController.name); +@Controller('users') +@Resource(RESOURCES.USER) +export class UsersController { + private readonly logger = new Logger(UsersController.name); - constructor(private readonly userService: UsersService) {} + constructor(private readonly usersService: UsersService) {} - @Post("create") - @Roles({ roles: [ROLES.CREATE_USER] }) - async createUser( - @Body() payload: CreateUserDto, - @AuthenticatedUser() user: any - ): Promise { - this.logger.log(`User ${user.sub} creating new user: ${payload.username}`); - + // === CREATE USER === + @Post() + @Scopes(SCOPES.WRITE) + async createUser(@Body() createUserDto: user.CreateUserDto): Promise { + this.logger.log(`Creating new user: ${createUserDto.username}`); try { - const createdUser = await this.userService.createUser(payload); - return new UserResponse(createdUser); + const createdUser = await this.usersService.createUser(createUserDto); + return 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 - ); + 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 { - this.logger.log(`User ${user.sub} updating user: ${id}`); - + // === GET ALL USERS === + @Get() + @Scopes(SCOPES.READ) + async findAllUsers(@Query() query: user.UserQueryDto): Promise { + this.logger.log('Fetching users list'); try { - const updatedUser = await this.userService.updateUser(id, payload); - return new UserResponse(updatedUser); + const result = await this.usersService.findAllUsers(query); + return result; + } 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 USER BY ID === + @Get(':id') + @Scopes(SCOPES.READ) + async getUserById(@Param('id') id: string): Promise { + this.logger.log(`Fetching user by ID: ${id}`); + try { + const user = await this.usersService.getUserById(id); + return user; + } 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 CURRENT USER PROFILE === + @Get('profile/me') + @Scopes(SCOPES.READ) + async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise { + this.logger.log(`User ${user.sub} accessing own profile`); + try { + const userProfile = await this.usersService.getUserProfile(user); + return userProfile; + } catch (error: any) { + this.logger.error(`Failed to fetch user profile: ${error.message}`); + throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND); + } + } + + // === UPDATE USER === + @Put(':id') + @Scopes(SCOPES.WRITE) + async updateUser( + @Param('id') id: string, + @Body() updateUserDto: user.UpdateUserDto + ): Promise { + this.logger.log(`Updating user: ${id}`); + try { + const updatedUser = await this.usersService.updateUser(id, updateUserDto); + return 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 - ); + 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}`); - + // === UPDATE CURRENT USER PROFILE === + @Put('profile/me') + async updateCurrentUserProfile( + @AuthenticatedUser() user: any, + @Body() updateUserDto: user.UpdateUserDto + ): Promise { + this.logger.log(`User ${user.sub} updating own profile`); try { - await this.userService.deleteUser(id); + // Un utilisateur ne peut mettre à jour que son propre profil + const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto); + return updatedUser; + } catch (error: any) { + this.logger.error(`Failed to update user profile: ${error.message}`); + throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST); + } + } + + // === DELETE USER === + @Delete(':id') + @Scopes(SCOPES.DELETE) + async deleteUser(@Param('id') id: string): Promise<{ message: string }> { + this.logger.log(`Deleting user: ${id}`); + try { + await this.usersService.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 - ); + 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 { - 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 { - 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] }) + // === RESET PASSWORD === + @Put(':id/password') + @Scopes(SCOPES.WRITE) async resetPassword( @Param('id') id: string, - @Body() body: { password: string; temporary: boolean }, - @AuthenticatedUser() user: any + @Body() resetPasswordDto: user.ResetPasswordDto ): Promise<{ message: string }> { - this.logger.log(`User ${user.sub} resetting password for user: ${id}`); - + this.logger.log(`Resetting password for user: ${id}`); try { - await this.userService.resetPassword( - id, - body.password, - body.temporary ?? true - ); + await this.usersService.resetPassword(resetPasswordDto); 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 - ); + throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST); + } + } + + // === ENABLE USER === + @Put(':id/enable') + @Scopes(SCOPES.WRITE) + async enableUser(@Param('id') id: string): Promise<{ message: string }> { + this.logger.log(`Enabling user: ${id}`); + try { + await this.usersService.enableUser(id); + return { message: 'User enabled successfully' }; + } catch (error: any) { + this.logger.error(`Failed to enable user ${id}: ${error.message}`); + throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST); + } + } + + // === DISABLE USER === + @Put(':id/disable') + @Scopes(SCOPES.WRITE) + async disableUser(@Param('id') id: string): Promise<{ message: string }> { + this.logger.log(`Disabling user: ${id}`); + try { + await this.usersService.disableUser(id); + return { message: 'User disabled successfully' }; + } catch (error: any) { + this.logger.error(`Failed to disable user ${id}: ${error.message}`); + throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST); + } + } + + // === CHECK USER EXISTS === + @Get('check/:username') + @Scopes(SCOPES.READ) + async userExists(@Param('username') username: string): Promise<{ exists: boolean }> { + this.logger.log(`Checking if user exists: ${username}`); + try { + const exists = await this.usersService.userExists(username); + return { exists }; + } catch (error: any) { + this.logger.error(`Failed to check if user exists ${username}: ${error.message}`); + throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST); + } + } + + // === SEARCH USERS BY USERNAME === + @Get('search/username/:username') + @Scopes(SCOPES.READ) + async findUserByUsername(@Param('username') username: string): Promise { + this.logger.log(`Searching users by username: ${username}`); + try { + const users = await this.usersService.findUserByUsername(username); + return users; + } catch (error: any) { + this.logger.error(`Failed to search users by username ${username}: ${error.message}`); + throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); + } + } + + // === SEARCH USERS BY EMAIL === + @Get('search/email/:email') + @Scopes(SCOPES.READ) + async findUserByEmail(@Param('email') email: string): Promise { + this.logger.log(`Searching users by email: ${email}`); + try { + const users = await this.usersService.findUserByEmail(email); + return users; + } catch (error: any) { + this.logger.error(`Failed to search users by email ${email}: ${error.message}`); + throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); + } + } + + // === GET USER ROLES === + @Get(':id/roles') + @Scopes(SCOPES.READ) + async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> { + this.logger.log(`Fetching roles for user: ${id}`); + try { + const roles = await this.usersService.getUserClientRoles(id); + return { roles }; + } catch (error: any) { + this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`); + throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST); + } + } + + // === ASSIGN CLIENT ROLES TO USER === + @Put(':id/roles') + @Scopes(SCOPES.WRITE) + async assignClientRoles( + @Param('id') id: string, + @Body() assignRolesDto: { roles: string[] } + ): Promise<{ message: string }> { + this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles); + try { + await this.usersService.assignClientRoles(id, assignRolesDto.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); } } } \ No newline at end of file diff --git a/src/users/models/user.ts b/src/users/models/user.ts index bb0b7c7..292d8d6 100644 --- a/src/users/models/user.ts +++ b/src/users/models/user.ts @@ -1,17 +1,15 @@ +import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator'; + export class User { id?: string; username: string; email: string; - firstName?: string; - lastName?: string; + firstName: string; + lastName: string; enabled?: boolean = true; emailVerified?: boolean = false; attributes?: Record; - realmRoles?: string[]; - clientRoles?: Record; - groups?: string[]; - requiredActions?: string[]; - credentials?: UserCredentials[]; + clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support) createdTimestamp?: number; constructor(partial?: Partial) { @@ -34,40 +32,103 @@ export class UserCredentials { } export class CreateUserDto { + @IsString() + @MinLength(3) username: string; - email: string; - firstName?: string; - lastName?: string; - password: string; - enabled?: boolean = true; - emailVerified?: boolean = false; - attributes?: Record; - realmRoles?: string[]; - groups?: string[]; - constructor(partial?: Partial) { - if (partial) { - Object.assign(this, partial); - } - } + @IsEmail() + email: string; + + @IsString() + firstName: string; + + @IsString() + lastName: string; + + @IsString() + @MinLength(8) + password: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean = true; + + @IsOptional() + @IsBoolean() + emailVerified?: boolean = false; + + @IsOptional() + attributes?: Record; + + @IsOptional() + @IsArray() + clientRoles?: string[]; } export class UpdateUserDto { + @IsOptional() + @IsString() username?: string; - email?: string; - firstName?: string; - lastName?: string; - enabled?: boolean; - emailVerified?: boolean; - attributes?: Record; - realmRoles?: string[]; - groups?: string[]; - constructor(partial?: Partial) { - if (partial) { - Object.assign(this, partial); - } - } + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + firstName?: string; + + @IsOptional() + @IsString() + lastName?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsBoolean() + emailVerified?: boolean; + + @IsOptional() + attributes?: Record; + + @IsOptional() + @IsArray() + clientRoles?: string[]; +} + +export class UserQueryDto { + @IsOptional() + page?: number = 1; + + @IsOptional() + limit?: number = 10; + + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsBoolean() + emailVerified?: boolean; +} + +export class ResetPasswordDto { + @IsString() + userId: string; + + @IsString() + @MinLength(8) + newPassword: string; + + @IsOptional() + @IsBoolean() + temporary?: boolean = false; } export class UserResponse { @@ -79,9 +140,7 @@ export class UserResponse { enabled: boolean; emailVerified: boolean; attributes?: Record; - realmRoles?: string[]; - clientRoles?: Record; - groups?: string[]; + clientRoles: string[]; // Rôles client uniquement createdTimestamp: number; constructor(user: any) { @@ -93,9 +152,47 @@ export class UserResponse { 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.clientRoles = user.clientRoles || []; this.createdTimestamp = user.createdTimestamp; } +} + +// Interface pour les réponses paginées +export class PaginatedUserResponse { + users: UserResponse[]; + total: number; + page: number; + limit: number; + totalPages: number; + + constructor(users: UserResponse[], total: number, page: number, limit: number) { + this.users = users; + this.total = total; + this.page = page; + this.limit = limit; + this.totalPages = Math.ceil(total / limit); + } +} + +export class AssignRolesDto { + @IsArray() + @IsString({ each: true }) + roles: string[]; +} + +// Types pour les rôles client +export type ClientRole = 'admin' | 'merchant' | 'support'; + +// Interface pour l'authentification +export interface LoginDto { + username: string; + password: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + scope?: string; } \ No newline at end of file diff --git a/src/users/services/users.service.ts b/src/users/services/users.service.ts index 59f3265..eed9864 100644 --- a/src/users/services/users.service.ts +++ b/src/users/services/users.service.ts @@ -1,311 +1,358 @@ -import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +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'; +import { + CreateUserDto, + UpdateUserDto, + UserResponse, + PaginatedUserResponse, + ResetPasswordDto, + UserQueryDto, + LoginDto, + TokenResponse, + ClientRole +} 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('keycloak.realm')!; + ) {} + + // === VALIDATION DES ROLES === + private validateClientRole(role: string): ClientRole { + const validRoles: ClientRole[] = ['admin', 'merchant', 'support']; + if (!validRoles.includes(role as ClientRole)) { + throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`); + } + return role as ClientRole; } - async getUserById(userId: string): Promise { + private validateClientRoles(roles: string[]): ClientRole[] { + return roles.map(role => this.validateClientRole(role)); + } + + // === AUTHENTIFICATION UTILISATEUR === + async authenticateUser(loginDto: LoginDto): Promise { + return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password); + } + + // === GET USER BY ID === + async getUserById(userId: string): Promise { 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`); - } + const user = await this.keycloakApi.getUserById(userId); + if (!user) throw new NotFoundException(`User with ID ${userId} not found`); - return user; + const roles = await this.keycloakApi.getUserClientRoles(userId); + const clientRoles = roles.map(role => role.name); + + return new UserResponse({ ...user, clientRoles }); } catch (error: any) { this.logger.error(`Failed to fetch user ${userId}: ${error.message}`); - - if (error instanceof NotFoundException) { - throw error; - } - + if (error instanceof NotFoundException) throw error; throw new NotFoundException(`User with ID ${userId} not found`); } } - async getUserProfile(accessToken: string): Promise { + // === GET PROFILE FROM DECODED TOKEN === + async getUserProfile(decodedToken: any): Promise { try { - this.logger.debug('Fetching user profile from token'); - const profile = await this.keycloakApi.getUserProfile(this.realm, accessToken); + const profileData = { + id: decodedToken.sub, + username: decodedToken.preferred_username || decodedToken.username, + email: decodedToken.email, + firstName: decodedToken.given_name || decodedToken.firstName, + lastName: decodedToken.family_name || decodedToken.lastName, + enabled: true, + emailVerified: decodedToken.email_verified || false, + attributes: decodedToken.attributes || {}, + clientRoles: decodedToken.resource_access ? + Object.values(decodedToken.resource_access).flatMap((client: any) => client.roles || []) : [], + realmRoles: decodedToken.realm_access?.roles || [], + createdTimestamp: decodedToken.iat ? decodedToken.iam * 1000 : Date.now() + }; - if (!profile) { - throw new NotFoundException('User profile not found'); - } - - return profile; + return new UserResponse(profileData); } catch (error: any) { - this.logger.error(`Failed to fetch user profile: ${error.message}`); - throw new NotFoundException('Failed to fetch user profile'); + this.logger.error(`Failed to create user profile from token: ${error.message}`); + throw new NotFoundException('Failed to create user profile'); } } - - async findAllUsers( - page: number = 1, - limit: number = 10, - search: string = '', - enabled?: boolean - ): Promise<{ users: any[]; total: number }> { + // === FIND ALL USERS === + async findAllUsers(query: UserQueryDto): Promise { 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) + let users = await this.keycloakApi.getAllUsers(); + + // Filtre de recherche + if (query.search) { + const q = query.search.toLowerCase(); + users = users.filter( + (u) => + u.username?.toLowerCase().includes(q) || + u.email?.toLowerCase().includes(q) || + u.firstName?.toLowerCase().includes(q) || + u.lastName?.toLowerCase().includes(q), ); } - - if (enabled !== undefined) { - users = users.filter(user => user.enabled === enabled); + + // Filtre par statut enabled + if (query.enabled !== undefined) { + users = users.filter((u) => u.enabled === query.enabled); } - - // Pagination + + if (query.emailVerified !== undefined) { + users = users.filter((u) => u.emailVerified === query.emailVerified); + } + + const page = query.page || 1; + const limit = query.limit || 10; const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedUsers = users.slice(startIndex, endIndex); - - return { - users: paginatedUsers, - total: users.length - }; + const paginatedUsers = users.slice(startIndex, startIndex + limit); + + const usersWithRoles = await Promise.all( + paginatedUsers.map(async (u) => { + try { + const roles = await this.keycloakApi.getUserClientRoles(u.id!); + const clientRoles = roles.map(role => role.name); + return new UserResponse({ ...u, clientRoles }); + } catch (error) { + this.logger.warn(`Failed to fetch roles for user ${u.id}: ${error.message}`); + return new UserResponse({ ...u, clientRoles: [] }); + } + }), + ); + + return new PaginatedUserResponse(usersWithRoles, users.length, page, limit); } catch (error: any) { this.logger.error(`Failed to fetch users: ${error.message}`); throw new BadRequestException('Failed to fetch users'); } } - async createUser(userData: CreateUserDto): Promise { + // === CREATE USER === + async createUser(userData: CreateUserDto): Promise { 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 - ); + const existingByUsername = await this.keycloakApi.findUserByUsername(userData.username); - if (userExists) { - throw new ConflictException('User with this username or email already exists'); + if (userData.email) { + const existingByEmail = await this.keycloakApi.findUserByEmail(userData.email); + + if (existingByEmail.length > 0) { + throw new ConflictException('User with this email already exists'); + } } - - // Préparer les données pour Keycloak - const keycloakUserData = { + + if (existingByUsername.length > 0) { + throw new ConflictException('User with this username already exists'); + } + + const userId = await this.keycloakApi.createUser({ username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, + password: userData.password, 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; + }); + + // Attribution automatique de rôles client si fournis + if (userData.clientRoles?.length) { + const validatedRoles = this.validateClientRoles(userData.clientRoles); + await this.keycloakApi.setClientRoles(userId, validatedRoles); } - + + const createdUser = await this.keycloakApi.getUserById(userId); + const roles = await this.keycloakApi.getUserClientRoles(userId); + const clientRoles = roles.map(role => role.name); + + return new UserResponse({ ...createdUser, clientRoles }); + } catch (error: any) { + this.logger.error(`Failed to create user: ${error.message}`); + if (error instanceof ConflictException || error instanceof BadRequestException) throw error; throw new BadRequestException('Failed to create user'); } } - async updateUser(id: string, userData: UpdateUserDto): Promise { + // === UPDATE USER === + async updateUser(id: string, userData: UpdateUserDto): Promise { try { - this.logger.debug(`Updating user: ${id}`); - // Vérifier que l'utilisateur existe - const existingUser = await this.getUserById(id); + await this.keycloakApi.getUserById(id); - if (!existingUser) { - throw new NotFoundException(`User with ID ${id} not found`); + await this.keycloakApi.updateUser(id, userData); + + // Mettre à jour les rôles si fournis + if (userData.clientRoles) { + const validatedRoles = this.validateClientRoles(userData.clientRoles); + await this.keycloakApi.setClientRoles(id, validatedRoles); } - - // 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; + + const updatedUser = await this.keycloakApi.getUserById(id); + const roles = await this.keycloakApi.getUserClientRoles(id); + const clientRoles = roles.map(role => role.name); + + return new UserResponse({ ...updatedUser, clientRoles }); } catch (error: any) { this.logger.error(`Failed to update user ${id}: ${error.message}`); - - if (error instanceof NotFoundException) { - throw error; - } - + if (error instanceof NotFoundException || error instanceof BadRequestException) throw error; throw new BadRequestException('Failed to update user'); } } - async deleteUser(id: string): Promise { + // === ASSIGN CLIENT ROLES TO USER === + async assignClientRoles(userId: string, roles: string[]): Promise<{ message: string }> { try { - this.logger.debug(`Deleting user: ${id}`); + this.logger.log(`Assigning client roles to user: ${userId}`, roles); // Vérifier que l'utilisateur existe - const existingUser = await this.getUserById(id); + await this.keycloakApi.getUserById(userId); - 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'); - } - } + // Valider et assigner les rôles + const validatedRoles = this.validateClientRoles(roles); + await this.keycloakApi.setClientRoles(userId, validatedRoles); - async assignRealmRoles(userId: string, roles: string[]): Promise { - 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}`); + this.logger.log(`Successfully assigned ${validatedRoles.length} roles to user ${userId}`); + return { message: 'Roles assigned successfully' }; } catch (error: any) { this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`); + if (error instanceof NotFoundException || error instanceof BadRequestException) throw error; throw new BadRequestException('Failed to assign roles to user'); } } - async removeRealmRoles(userId: string, roles: string[]): Promise { + // === DELETE USER === + async deleteUser(id: string): Promise { 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}`); + await this.keycloakApi.getUserById(id); + await this.keycloakApi.deleteUser(id); } catch (error: any) { - this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`); - throw new BadRequestException('Failed to remove roles from user'); + this.logger.error(`Failed to delete user ${id}: ${error.message}`); + if (error instanceof NotFoundException) throw error; + throw new BadRequestException('Failed to delete user'); } } - async resetPassword(userId: string, newPassword: string, temporary: boolean = true): Promise { + // === RESET PASSWORD === + async resetPassword(resetPasswordDto: ResetPasswordDto): Promise { 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}`); + await this.keycloakApi.getUserById(resetPasswordDto.userId); + await this.keycloakApi.resetPassword(resetPasswordDto.userId, resetPasswordDto.newPassword); } catch (error: any) { - this.logger.error(`Failed to reset password for user ${userId}: ${error.message}`); + this.logger.error(`Failed to reset password for user ${resetPasswordDto.userId}: ${error.message}`); + if (error instanceof NotFoundException) throw error; throw new BadRequestException('Failed to reset password'); } } - async searchUsers(query: string, maxResults: number = 50): Promise { + // === ENABLE/DISABLE USER === + async enableUser(userId: string): Promise { try { - this.logger.debug(`Searching users with query: ${query}`); + await this.keycloakApi.enableUser(userId); + } catch (error: any) { + this.logger.error(`Failed to enable user ${userId}: ${error.message}`); + throw new BadRequestException('Failed to enable user'); + } + } + + async disableUser(userId: string): Promise { + try { + await this.keycloakApi.disableUser(userId); + } catch (error: any) { + this.logger.error(`Failed to disable user ${userId}: ${error.message}`); + throw new BadRequestException('Failed to disable user'); + } + } + + // === UTILITY METHODS === + async userExists(username: string): Promise { + try { + const users = await this.keycloakApi.findUserByUsername(username); + return users.length > 0; + } catch { + return false; + } + } + + async getUserClientRoles(userId: string): Promise { + try { + const roles = await this.keycloakApi.getUserClientRoles(userId); + return roles.map(role => role.name); + } catch (error: any) { + this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`); + throw new BadRequestException('Failed to get user client roles'); + } + } + + async findUserByUsername(username: string): Promise { + try { + const users = await this.keycloakApi.findUserByUsername(username); - 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) + const usersWithRoles = await Promise.all( + users.map(async (user) => { + try { + const roles = await this.keycloakApi.getUserClientRoles(user.id!); + const clientRoles = roles.map(role => role.name); + return new UserResponse({ ...user, clientRoles }); + } catch (error) { + this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`); + return new UserResponse({ ...user, clientRoles: [] }); + } + }) ); - - return filteredUsers.slice(0, maxResults); + + return usersWithRoles; } catch (error: any) { - this.logger.error(`Failed to search users: ${error.message}`); - throw new BadRequestException('Failed to search users'); + this.logger.error(`Failed to find user by username ${username}: ${error.message}`); + throw new BadRequestException('Failed to find user by username'); } } - async getUserCount(): Promise<{ total: number; enabled: number; disabled: number }> { + async findUserByEmail(email: string): Promise { try { - this.logger.debug('Getting user count statistics'); + const users = await this.keycloakApi.findUserByEmail(email); - 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 - }; + const usersWithRoles = await Promise.all( + users.map(async (user) => { + try { + const roles = await this.keycloakApi.getUserClientRoles(user.id!); + const clientRoles = roles.map(role => role.name); + return new UserResponse({ ...user, clientRoles }); + } catch (error) { + this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`); + return new UserResponse({ ...user, clientRoles: [] }); + } + }) + ); + + return usersWithRoles; } catch (error: any) { - this.logger.error(`Failed to get user count: ${error.message}`); - throw new BadRequestException('Failed to get user statistics'); + this.logger.error(`Failed to find user by email ${email}: ${error.message}`); + throw new BadRequestException('Failed to find user by email'); } } - async toggleUserStatus(userId: string, enabled: boolean): Promise { + // === PRIVATE METHODS === + private decodeJwtToken(token: string): 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'); + const payload = token.split('.')[1]; + const decoded = JSON.parse(Buffer.from(payload, 'base64').toString()); + return decoded; + } catch (error) { + this.logger.error('Failed to decode JWT token', error); + throw new BadRequestException('Invalid token format'); } } } \ No newline at end of file diff --git a/src/users/users.module.ts b/src/users/users.module.ts index ec25583..9800aaf 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -2,9 +2,8 @@ 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 { UsersController } from './controllers/users.controller' import { KeycloakApiService } from '../auth/services/keycloak-api.service'; @@ -14,7 +13,7 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service'; JwtModule.register({}), ], providers: [UsersService, KeycloakApiService, TokenService], - controllers: [UserController], + controllers: [UsersController], exports: [UsersService, KeycloakApiService, TokenService, JwtModule], }) export class UsersModule {} diff --git a/test/token.service.spec.ts b/test/token.service.spec.ts deleted file mode 100644 index e36c983..0000000 --- a/test/token.service.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -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); - }); - - 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'); - }); -});