From d43f5921e516670bc8d315c811e1dc1301f8cc5d Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 27 Oct 2025 18:13:37 +0000 Subject: [PATCH] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- .env-sample | 24 +- package-lock.json | 186 +++++++- package.json | 6 + src.zip | Bin 0 -> 29293 bytes src/api/controllers/api.controller.ts | 48 +- src/app.module.ts | 5 +- src/auth/auth.module.ts | 14 +- src/auth/controllers/auth.controller.ts | 147 ++---- src/auth/guards/client-credentials.guard.ts | 56 --- src/auth/guards/jwt.guard.ts | 5 + src/auth/guards/user-auth.guard.ts | 68 --- src/auth/services/keycloak-api.service.ts | 485 +++++++------------- src/auth/services/keycloak.strategy.ts | 56 +++ src/auth/services/startup.service.ts | 157 ++++++- src/auth/services/token.service.ts | 464 +++++++++++-------- src/config/keycloak.config.ts | 26 +- src/constants/resouces.ts | 3 + src/constants/scopes.ts | 5 + src/decorators/roles.decorator.ts | 8 - src/users/controllers/users.controller.ts | 367 ++++++++------- src/users/models/user.ts | 177 +++++-- src/users/services/users.service.ts | 465 ++++++++++--------- src/users/users.module.ts | 5 +- test/token.service.spec.ts | 40 -- 24 files changed, 1575 insertions(+), 1242 deletions(-) create mode 100644 src.zip delete mode 100644 src/auth/guards/client-credentials.guard.ts create mode 100644 src/auth/guards/jwt.guard.ts delete mode 100644 src/auth/guards/user-auth.guard.ts create mode 100644 src/auth/services/keycloak.strategy.ts create mode 100644 src/constants/resouces.ts create mode 100644 src/constants/scopes.ts delete mode 100644 src/decorators/roles.decorator.ts delete mode 100644 test/token.service.spec.ts 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 0000000000000000000000000000000000000000..17c13d75c7378b67c03106246abaf43a77899d7d GIT binary patch literal 29293 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 BF(v>2 literal 0 HcmV?d00001 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'); - }); -});