feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
parent
0418a15343
commit
d43f5921e5
22
.env-sample
22
.env-sample
@ -1,16 +1,28 @@
|
|||||||
# .env
|
# .env-sample
|
||||||
|
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com
|
KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com
|
||||||
KEYCLOAK_REALM=dcb-dev
|
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_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_CLIENT_ID=dcb-user-service-pwd
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET=VS7fDASmxmPOjn0JkhbtNDh7ULm0QGGa
|
KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
|
||||||
KEYCLOAK_AUTH_CLIENT_ID=dcb-user-service-pwd
|
|
||||||
KEYCLOAK_AUTH_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
|
|
||||||
KEYCLOAK_VALIDATION_MODE=offline
|
KEYCLOAK_VALIDATION_MODE=offline
|
||||||
|
|
||||||
KEYCLOAK_TOKEN_BUFFER_SECONDS=30
|
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
|
||||||
|
|
||||||
|
|||||||
186
package-lock.json
generated
186
package-lock.json
generated
@ -10,22 +10,28 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/cache-manager": "^3.0.1",
|
||||||
"@nestjs/common": "^11.1.7",
|
"@nestjs/common": "^11.1.7",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.7",
|
"@nestjs/core": "^11.1.7",
|
||||||
"@nestjs/jwt": "^11.0.1",
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.7",
|
"@nestjs/platform-express": "^11.1.7",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"cache-manager": "^7.2.4",
|
||||||
|
"circuit-breaker-ts": "^0.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jwks-rsa": "^3.2.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"keycloak-connect": "^26.1.1",
|
"keycloak-connect": "^26.1.1",
|
||||||
"nest-keycloak-connect": "^1.10.1",
|
"nest-keycloak-connect": "^1.10.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
},
|
},
|
||||||
@ -727,6 +733,24 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||||
@ -2123,6 +2147,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@lukeed/csprng": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||||
@ -2156,6 +2186,19 @@
|
|||||||
"rxjs": "^7.0.0"
|
"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": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "11.0.10",
|
"version": "11.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.10.tgz",
|
"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"
|
"@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": {
|
"node_modules/@nestjs/platform-express": {
|
||||||
"version": "11.1.7",
|
"version": "11.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz",
|
||||||
@ -2986,7 +3039,6 @@
|
|||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/connect": "*",
|
"@types/connect": "*",
|
||||||
@ -2997,7 +3049,6 @@
|
|||||||
"version": "3.4.38",
|
"version": "3.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@ -3095,7 +3146,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
@ -3209,7 +3259,6 @@
|
|||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
"node_modules/@types/ms": {
|
||||||
@ -3253,21 +3302,18 @@
|
|||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
|
||||||
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
|
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@ -3277,7 +3323,6 @@
|
|||||||
"version": "1.15.9",
|
"version": "1.15.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
|
||||||
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
|
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/http-errors": "*",
|
"@types/http-errors": "*",
|
||||||
@ -3289,7 +3334,6 @@
|
|||||||
"version": "0.17.5",
|
"version": "0.17.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
|
||||||
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/mime": "^1",
|
"@types/mime": "^1",
|
||||||
@ -4810,6 +4854,25 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@ -4989,6 +5052,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/cjs-module-lexer": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz",
|
||||||
@ -8082,6 +8151,15 @@
|
|||||||
"node": ">= 20"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -8220,6 +8298,47 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
@ -8294,6 +8413,11 @@
|
|||||||
"integrity": "sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==",
|
"integrity": "sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@ -8356,6 +8480,12 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@ -8439,6 +8569,34 @@
|
|||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
@ -9162,6 +9320,16 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/passport-strategy": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||||
|
|||||||
@ -21,22 +21,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/cache-manager": "^3.0.1",
|
||||||
"@nestjs/common": "^11.1.7",
|
"@nestjs/common": "^11.1.7",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.1.7",
|
"@nestjs/core": "^11.1.7",
|
||||||
"@nestjs/jwt": "^11.0.1",
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.7",
|
"@nestjs/platform-express": "^11.1.7",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.4.0",
|
"@nestjs/throttler": "^6.4.0",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
"cache-manager": "^7.2.4",
|
||||||
|
"circuit-breaker-ts": "^0.1.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jwks-rsa": "^3.2.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"keycloak-connect": "^26.1.1",
|
"keycloak-connect": "^26.1.1",
|
||||||
"nest-keycloak-connect": "^1.10.1",
|
"nest-keycloak-connect": "^1.10.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-http-bearer": "^1.0.1",
|
"passport-http-bearer": "^1.0.1",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,14 +1,37 @@
|
|||||||
import { Controller, Get, Req, UseGuards, Logger } from '@nestjs/common';
|
import { Controller, Get, Logger } from '@nestjs/common';
|
||||||
import { ClientCredentialsGuard } from '../../auth/guards/client-credentials.guard';
|
import { AuthenticatedUser, Roles, Resource, Scopes } from 'nest-keycloak-connect';
|
||||||
import { Roles } from '../../decorators/roles.decorator';
|
import { RESOURCES } from '../../constants/resouces';
|
||||||
|
import { SCOPES } from '../../constants/scopes';
|
||||||
|
|
||||||
@Controller('api')
|
@Controller('api')
|
||||||
@UseGuards(ClientCredentialsGuard) // applique le guard à tout le controller
|
@Resource(RESOURCES.USER)
|
||||||
export class ApiController {
|
export class ApiController {
|
||||||
private readonly logger = new Logger(ApiController.name);
|
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')
|
@Get('protected')
|
||||||
@Roles('DCB_ADMIN') // ex: uniquement les clients avec DCB_ADMIN peuvent accéder
|
@Scopes(SCOPES.READ)
|
||||||
getProtected() {
|
getProtected() {
|
||||||
this.logger.log('Accessed protected route');
|
this.logger.log('Accessed protected route');
|
||||||
return {
|
return {
|
||||||
@ -17,17 +40,8 @@ export class ApiController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('protected-data')
|
@Get('public')
|
||||||
@Roles('DCB_MANAGER', 'DCB_ADMIN') // plusieurs rôles possibles
|
getPublic() {
|
||||||
getProtectedData(@Req() request: Request) {
|
return { message: 'Accès public' };
|
||||||
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',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,10 +41,9 @@ import { UsersModule } from './users/users.module';
|
|||||||
return {
|
return {
|
||||||
authServerUrl: keycloakConfig.serverUrl,
|
authServerUrl: keycloakConfig.serverUrl,
|
||||||
realm: keycloakConfig.realm,
|
realm: keycloakConfig.realm,
|
||||||
clientId: keycloakConfig.clientId,
|
clientId: keycloakConfig.authClientId,
|
||||||
secret: keycloakConfig.clientSecret,
|
secret: keycloakConfig.authClientSecret,
|
||||||
useNestLogger: true,
|
useNestLogger: true,
|
||||||
bearerOnly: true,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation OFFLINE :
|
* Validation OFFLINE :
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import { KeycloakApiService } from './services/keycloak-api.service';
|
|||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
import { HealthController } from '../health/health.controller';
|
import { HealthController } from '../health/health.controller';
|
||||||
import { UsersService } from '../users/services/users.service';
|
import { UsersService } from '../users/services/users.service';
|
||||||
|
import { KeycloakJwtStrategy } from './services/keycloak.strategy';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -14,8 +17,15 @@ import { UsersService } from '../users/services/users.service';
|
|||||||
HttpModule,
|
HttpModule,
|
||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
],
|
],
|
||||||
providers: [StartupService, TokenService, KeycloakApiService, UsersService],
|
providers: [
|
||||||
|
KeycloakJwtStrategy,
|
||||||
|
JwtAuthGuard,
|
||||||
|
StartupService,
|
||||||
|
TokenService,
|
||||||
|
KeycloakApiService,
|
||||||
|
UsersService
|
||||||
|
],
|
||||||
controllers: [AuthController, HealthController],
|
controllers: [AuthController, HealthController],
|
||||||
exports: [StartupService, TokenService, KeycloakApiService, UsersService, JwtModule],
|
exports: [JwtAuthGuard, StartupService, TokenService, KeycloakApiService, UsersService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -9,21 +9,13 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
|
||||||
AuthenticatedUser,
|
|
||||||
Public,
|
|
||||||
Roles,
|
|
||||||
} from 'nest-keycloak-connect';
|
|
||||||
import { TokenService } from '../services/token.service';
|
import { TokenService } from '../services/token.service';
|
||||||
import { KeycloakApiService } from '../services/keycloak-api.service';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { UsersService } from '../../users/services/users.service';
|
import { UsersService } from '../../users/services/users.service';
|
||||||
|
import * as user from '../../users/models/user';
|
||||||
|
|
||||||
interface LoginDto {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@ -31,7 +23,6 @@ export class AuthController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly keycloakApiService: KeycloakApiService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: UsersService
|
||||||
) {}
|
) {}
|
||||||
@ -39,16 +30,14 @@ export class AuthController {
|
|||||||
/** -------------------------------
|
/** -------------------------------
|
||||||
* LOGIN (Resource Owner Password Credentials)
|
* LOGIN (Resource Owner Password Credentials)
|
||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
|
|
||||||
|
// === AUTHENTIFICATION ===
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(
|
async login(@Body() loginDto: user.LoginDto) {
|
||||||
@Body() loginDto: LoginDto
|
|
||||||
): Promise<{
|
this.logger.log(`User login attempt: ${loginDto.username}`);
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expires_in: number;
|
|
||||||
token_type: string;
|
|
||||||
}> {
|
|
||||||
const { username, password } = loginDto;
|
const { username, password } = loginDto;
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
@ -56,7 +45,8 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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`);
|
this.logger.log(`User "${username}" authenticated successfully`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -66,38 +56,21 @@ export class AuthController {
|
|||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err.message;
|
const msg = err.message || '';
|
||||||
|
if (msg.includes('Account is not fully set up')) {
|
||||||
// Gestion spécifique des erreurs Keycloak
|
|
||||||
if (errorMessage.includes('Account is not fully set up')) {
|
|
||||||
this.logger.warn(`User account not fully set up: "${username}"`);
|
this.logger.warn(`User account not fully set up: "${username}"`);
|
||||||
throw new HttpException(
|
throw new HttpException('Account setup incomplete', HttpStatus.FORBIDDEN);
|
||||||
'Account setup incomplete. Please contact administrator.',
|
|
||||||
HttpStatus.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (msg.includes('Invalid user credentials')) {
|
||||||
if (errorMessage.includes('Invalid user credentials')) {
|
|
||||||
this.logger.warn(`Invalid credentials for user: "${username}"`);
|
this.logger.warn(`Invalid credentials for user: "${username}"`);
|
||||||
throw new HttpException(
|
throw new HttpException('Invalid username or password', HttpStatus.UNAUTHORIZED);
|
||||||
'Invalid username or password',
|
|
||||||
HttpStatus.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (msg.includes('User is disabled')) {
|
||||||
if (errorMessage.includes('User is disabled')) {
|
|
||||||
this.logger.warn(`Disabled user attempted login: "${username}"`);
|
this.logger.warn(`Disabled user attempted login: "${username}"`);
|
||||||
throw new HttpException(
|
throw new HttpException('Account is disabled', HttpStatus.FORBIDDEN);
|
||||||
'Account is disabled',
|
|
||||||
HttpStatus.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
this.logger.warn(`Authentication failed for "${username}": ${msg}`);
|
||||||
this.logger.warn(`Authentication failed for "${username}": ${errorMessage}`);
|
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
|
||||||
throw new HttpException(
|
|
||||||
'Authentication failed',
|
|
||||||
HttpStatus.UNAUTHORIZED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,49 +80,41 @@ export class AuthController {
|
|||||||
@Post('logout')
|
@Post('logout')
|
||||||
async logout(@Req() req: Request) {
|
async logout(@Req() req: Request) {
|
||||||
const token = req.headers['authorization']?.split(' ')[1];
|
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 {
|
try {
|
||||||
const refreshToken = await this.tokenService.getStoredRefreshToken(token);
|
|
||||||
|
// Récupérer le refresh token depuis le UserService
|
||||||
|
const refreshToken = this.tokenService.getUserRefreshToken();
|
||||||
|
|
||||||
if (refreshToken) {
|
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`);
|
this.logger.log(`User logged out successfully`);
|
||||||
return { message: 'Logout successful' };
|
return { message: 'Logout successful' };
|
||||||
} catch (error: any) {
|
} catch (err: any) {
|
||||||
this.logger.error('Logout failed', error);
|
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);
|
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
|
* REFRESH TOKEN
|
||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
@ -157,22 +122,18 @@ export class AuthController {
|
|||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
async refreshToken(@Body() body: { refresh_token: string }) {
|
async refreshToken(@Body() body: { refresh_token: string }) {
|
||||||
const { refresh_token } = body;
|
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 {
|
try {
|
||||||
const tokenResponse = await this.tokenService.refreshToken(refresh_token);
|
const tokenResponse = await this.tokenService.refreshToken(refresh_token);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access_token: tokenResponse.access_token,
|
access_token: tokenResponse.access_token,
|
||||||
refresh_token: tokenResponse.refresh_token,
|
refresh_token: tokenResponse.refresh_token,
|
||||||
expires_in: tokenResponse.expires_in,
|
expires_in: tokenResponse.expires_in,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (err: any) {
|
||||||
this.logger.error('Token refresh failed', error);
|
this.logger.error('Token refresh failed', err);
|
||||||
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
|
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -183,21 +144,15 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async getAuthStatus(@Req() req: Request) {
|
async getAuthStatus(@Req() req: Request) {
|
||||||
const authHeader = req.headers['authorization'];
|
const token = req.headers['authorization']?.replace('Bearer ', '');
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
|
if (token) {
|
||||||
if (authHeader) {
|
|
||||||
try {
|
try {
|
||||||
const token = authHeader.replace('Bearer ', '');
|
|
||||||
isValid = await this.tokenService.validateToken(token);
|
isValid = await this.tokenService.validateToken(token);
|
||||||
} catch (error) {
|
} catch {
|
||||||
this.logger.debug('Token validation failed in status check');
|
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',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Récupère ou rafraîchit le token client
|
|
||||||
const token = await this.tokenService.getToken();
|
|
||||||
request['accessToken'] = token;
|
|
||||||
|
|
||||||
// Décodage JWT avec JwtService (ne vérifie pas la signature côté client)
|
|
||||||
const payload: any = this.jwtService.decode(token);
|
|
||||||
if (!payload) {
|
|
||||||
this.logger.warn('Token could not be decoded');
|
|
||||||
throw new UnauthorizedException('Invalid token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const roles: string[] = payload.realm_access?.roles || [];
|
|
||||||
|
|
||||||
// Vérification facultative des rôles spécifiés via metadata
|
|
||||||
const requiredRoles = this.getRequiredRoles(context);
|
|
||||||
if (requiredRoles.length > 0) {
|
|
||||||
const hasRole = requiredRoles.some(role => roles.includes(role));
|
|
||||||
if (!hasRole) {
|
|
||||||
this.logger.warn(`Access denied, missing required roles: ${requiredRoles}`);
|
|
||||||
throw new ForbiddenException('Insufficient service role');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error('Client credentials guard failed: ' + (err?.message ?? err));
|
|
||||||
if (err instanceof ForbiddenException) throw err;
|
|
||||||
throw new UnauthorizedException('Service authentication failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère les rôles requis définis via un décorateur @Roles() sur le handler.
|
|
||||||
*/
|
|
||||||
private getRequiredRoles(context: ExecutionContext): string[] {
|
|
||||||
const handler = context.getHandler();
|
|
||||||
return Reflect.getMetadata('roles', handler) || [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
5
src/auth/guards/jwt.guard.ts
Normal file
5
src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||||
@ -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<string>('keycloak.publicKey');
|
|
||||||
if (!publicKey) {
|
|
||||||
throw new Error('Keycloak public key not configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifie et décode le token
|
|
||||||
const payload = this.jwtService.verify(token, {
|
|
||||||
algorithms: ['RS256'],
|
|
||||||
publicKey: this.formatPublicKey(publicKey),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Vérifie que le token appartient bien à ton realm Keycloak
|
|
||||||
const expectedIssuer = `${this.configService.get<string>('keycloak.serverUrl')}/realms/${this.configService.get<string>('keycloak.realm')}`;
|
|
||||||
if (payload.iss !== expectedIssuer) {
|
|
||||||
throw new UnauthorizedException('Invalid token issuer');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attache le payload utilisateur à la requête
|
|
||||||
request.user = payload;
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`UserAuthGuard failed: ${err?.message}`);
|
|
||||||
throw new UnauthorizedException('Invalid or expired token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatPublicKey(key: string): string {
|
|
||||||
if (key.includes('BEGIN PUBLIC KEY')) return key;
|
|
||||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
|
import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service'; // Import du TokenService
|
||||||
import jwtDecode from 'jwt-decode';
|
|
||||||
import { KeycloakConfig } from '../../config/keycloak.config';
|
|
||||||
|
|
||||||
interface DecodedToken {
|
export interface KeycloakUser {
|
||||||
sub: string;
|
id?: string;
|
||||||
preferred_username?: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
given_name?: string;
|
firstName?: string;
|
||||||
family_name?: string;
|
lastName?: string;
|
||||||
realm_access?: { roles: string[] };
|
enabled: boolean;
|
||||||
resource_access?: Record<string, { roles: string[] }>;
|
emailVerified: boolean;
|
||||||
scope?: string;
|
attributes?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KeycloakRole {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientRole = 'admin' | 'merchant' | 'support';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KeycloakApiService {
|
export class KeycloakApiService {
|
||||||
private readonly logger = new Logger(KeycloakApiService.name);
|
private readonly logger = new Logger(KeycloakApiService.name);
|
||||||
private readonly keycloakBaseUrl: string;
|
private readonly keycloakBaseUrl: string;
|
||||||
|
private readonly realm: string;
|
||||||
|
private readonly clientId: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
|
private readonly tokenService: TokenService, // Injection du TokenService
|
||||||
) {
|
) {
|
||||||
this.keycloakBaseUrl = this.configService.get<string>('keycloak.serverUrl')
|
this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080';
|
||||||
|| 'https://keycloak-dcb.app.cameleonapp.com';
|
this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master';
|
||||||
|
this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'admin-cli';
|
||||||
}
|
}
|
||||||
|
|
||||||
async request<T>(
|
// ===== 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<T>(
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
url: string,
|
path: string,
|
||||||
data?: any,
|
data?: any
|
||||||
opts?: { timeoutMs?: number },
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = await this.tokenService.getToken();
|
const token = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const url = `${this.keycloakBaseUrl}${path}`;
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
timeout: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let obs;
|
let response: AxiosResponse<T>;
|
||||||
const timeoutMs = opts?.timeoutMs ?? 5000;
|
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
obs = this.httpService.get<T>(url, config);
|
response = await firstValueFrom(this.httpService.get<T>(url, config).pipe(rxjsTimeout(10000)));
|
||||||
break;
|
break;
|
||||||
case 'POST':
|
case 'POST':
|
||||||
obs = this.httpService.post<T>(url, data, config);
|
response = await firstValueFrom(this.httpService.post<T>(url, data, config).pipe(rxjsTimeout(10000)));
|
||||||
break;
|
break;
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
obs = this.httpService.put<T>(url, data, config);
|
response = await firstValueFrom(this.httpService.put<T>(url, data, config).pipe(rxjsTimeout(10000)));
|
||||||
break;
|
break;
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
obs = this.httpService.delete<T>(url, config);
|
response = await firstValueFrom(this.httpService.delete<T>(url, config).pipe(rxjsTimeout(10000)));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported HTTP method: ${method}`);
|
throw new BadRequestException(`Unsupported HTTP method: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const response: AxiosResponse<T> = await firstValueFrom(obs.pipe(rxjsTimeout(timeoutMs)));
|
|
||||||
return response.data;
|
|
||||||
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const error = err as any;
|
|
||||||
const urlWithoutToken = this.sanitizeUrl(url);
|
|
||||||
|
|
||||||
let errorMessage = `Request to ${urlWithoutToken} failed`;
|
|
||||||
|
|
||||||
if (error?.response) {
|
|
||||||
errorMessage += `: ${error.response.status} ${error.response.statusText}`;
|
|
||||||
|
|
||||||
// Gestion spécifique des erreurs 404
|
|
||||||
if (error.response.status === 404) {
|
|
||||||
throw new NotFoundException(`Resource not found: ${urlWithoutToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.error(`HTTP Error for ${method} ${urlWithoutToken}`, {
|
|
||||||
status: error.response.status,
|
|
||||||
data: error.response.data,
|
|
||||||
});
|
|
||||||
} else if (error?.message) {
|
|
||||||
errorMessage += `: ${error.message}`;
|
|
||||||
this.logger.error(`Network error for ${method} ${urlWithoutToken}`);
|
|
||||||
} else {
|
|
||||||
errorMessage += `: Unknown error`;
|
|
||||||
this.logger.error(`Unknown error for ${method} ${urlWithoutToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpException(errorMessage, error?.response?.status || 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sanitizeUrl(url: string): string {
|
|
||||||
return url.replace(/\/[0-9a-fA-F-]{36}\//g, '/***/');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildKeycloakUrl(path: string): string {
|
|
||||||
return `${this.keycloakBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === REALM OPERATIONS ===
|
|
||||||
async getRealmClients(realm: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRealmInfo(realm: string): Promise<any> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}`);
|
|
||||||
return this.request<any>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === USER OPERATIONS ===
|
|
||||||
async getUserById(realm: string, userId: string): Promise<any> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
|
|
||||||
return this.request<any>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeJwt<T>(token: string): T {
|
|
||||||
if (!token) throw new Error('Token is required');
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
if (!payload) throw new Error('Invalid JWT token');
|
|
||||||
const decodedJson = Buffer.from(payload, 'base64').toString('utf-8');
|
|
||||||
return JSON.parse(decodedJson) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Récupère le profil utilisateur.
|
|
||||||
* Offline validation → décodage JWT
|
|
||||||
* Fallback → Keycloak /userinfo
|
|
||||||
*/
|
|
||||||
async getUserProfile(realm: string, accessToken: string): Promise<any> {
|
|
||||||
// --- 1. Décodage du token (offline) ---
|
|
||||||
try {
|
|
||||||
const decoded: DecodedToken = this.decodeJwt<DecodedToken>(accessToken);
|
|
||||||
if (!decoded?.sub) throw new HttpException('Invalid token', 401);
|
|
||||||
|
|
||||||
const resourceRoles = decoded.resource_access?.['dcb-user-service-pwd']?.roles || [];
|
|
||||||
const realmRoles = decoded.realm_access?.roles || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
sub: decoded.sub,
|
|
||||||
username: decoded.preferred_username,
|
|
||||||
email: decoded.email,
|
|
||||||
given_name: decoded.given_name,
|
|
||||||
family_name: decoded.family_name,
|
|
||||||
roles: [...realmRoles, ...resourceRoles],
|
|
||||||
scope: decoded.scope,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.warn(`Offline token decoding failed: ${error.message}. Falling back to /userinfo.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 2. Fallback : Appel Keycloak /userinfo ---
|
|
||||||
try {
|
|
||||||
const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`;
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.get(url, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error.response?.status || 500;
|
this.handleRequestError(error, path);
|
||||||
const data = error.response?.data || error.message;
|
|
||||||
|
|
||||||
this.logger.error(`Failed to fetch user profile from Keycloak: ${status}`, data);
|
|
||||||
if (status === 401) throw new HttpException('Invalid or expired token', 401);
|
|
||||||
|
|
||||||
throw new HttpException('Failed to fetch user profile', status);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsers(realm: string, queryParams?: {
|
// ===== ERROR HANDLING =====
|
||||||
briefRepresentation?: boolean;
|
private handleRequestError(error: any, context: string): never {
|
||||||
email?: string;
|
if (error.response?.status === 404) {
|
||||||
first?: number;
|
throw new NotFoundException(`Resource not found: ${context}`);
|
||||||
firstName?: string;
|
}
|
||||||
lastName?: string;
|
if (error.response?.status === 409) {
|
||||||
max?: number;
|
throw new BadRequestException('User already exists');
|
||||||
search?: string;
|
|
||||||
username?: string;
|
|
||||||
}): Promise<any[]> {
|
|
||||||
let url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
|
|
||||||
|
|
||||||
// Ajouter les paramètres de query s'ils sont fournis
|
|
||||||
if (queryParams) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
Object.entries(queryParams).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
params.append(key, value.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryString = params.toString();
|
|
||||||
if (queryString) {
|
|
||||||
url += `?${queryString}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.request<any[]>('GET', url);
|
this.logger.error(`Keycloak API error in ${context}: ${error.message}`, {
|
||||||
}
|
status: error.response?.status,
|
||||||
|
|
||||||
async createUser(realm: string, user: any): Promise<any> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
|
|
||||||
return this.request<any>('POST', url, user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(realm: string, userId: string, data: any): Promise<any> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
|
|
||||||
return this.request<any>('PUT', url, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(realm: string, userId: string): Promise<void> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
|
|
||||||
await this.request<void>('DELETE', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ROLE OPERATIONS ===
|
|
||||||
async getRealmRoles(realm: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/roles`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserRealmRoles(realm: string, userId: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
|
|
||||||
|
|
||||||
// S'assurer que les rôles sont au format attendu par Keycloak
|
|
||||||
const rolesToAssign = roles.map(role => {
|
|
||||||
if (typeof role === 'string') {
|
|
||||||
return { id: role, name: role };
|
|
||||||
}
|
|
||||||
return role;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.request<void>('POST', url, rolesToAssign);
|
throw new HttpException(
|
||||||
|
error.response?.data?.errorMessage || 'Keycloak operation failed',
|
||||||
|
error.response?.status || 500
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
|
// ===== USER CRUD OPERATIONS =====
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
|
async createUser(userData: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<string> {
|
||||||
|
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,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
// S'assurer que les rôles sont au format attendu par Keycloak
|
await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload);
|
||||||
const rolesToRemove = roles.map(role => {
|
|
||||||
if (typeof role === 'string') {
|
|
||||||
return { id: role, name: role };
|
|
||||||
}
|
|
||||||
return role;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.request<void>('DELETE', url, rolesToRemove);
|
const users = await this.findUserByUsername(userData.username);
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new Error('Failed to create user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[0].id!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === PASSWORD OPERATIONS ===
|
async getUserById(userId: string): Promise<KeycloakUser> {
|
||||||
async resetPassword(realm: string, userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
|
return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`);
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/reset-password`);
|
}
|
||||||
|
|
||||||
|
async getAllUsers(): Promise<KeycloakUser[]> {
|
||||||
|
return this.request('GET', `/admin/realms/${this.realm}/users`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByUsername(username: string): Promise<KeycloakUser[]> {
|
||||||
|
return this.request('GET', `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserByEmail(email: string): Promise<KeycloakUser[]> {
|
||||||
|
return this.request('GET', `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(userId: string, userData: Partial<KeycloakUser>): Promise<void> {
|
||||||
|
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(userId: string): Promise<void> {
|
||||||
|
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CLIENT ROLE OPERATIONS =====
|
||||||
|
async getUserClientRoles(userId: string): Promise<KeycloakRole[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const users = await this.findUserByUsername(username);
|
||||||
|
return users.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async enableUser(userId: string): Promise<void> {
|
||||||
|
await this.updateUser(userId, { enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableUser(userId: string): Promise<void> {
|
||||||
|
await this.updateUser(userId, { enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetPassword(userId: string, newPassword: string): Promise<void> {
|
||||||
const credentials = {
|
const credentials = {
|
||||||
type: 'password',
|
type: 'password',
|
||||||
value: newPassword,
|
value: newPassword,
|
||||||
temporary: temporary,
|
temporary: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.request<void>('PUT', url, credentials);
|
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === GROUP OPERATIONS ===
|
// ===== PRIVATE HELPERS =====
|
||||||
async getUserGroups(realm: string, userId: string): Promise<any[]> {
|
private async getClient(): Promise<any[]> {
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups`);
|
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
|
||||||
return this.request<any[]>('GET', url);
|
if (!clients || clients.length === 0) {
|
||||||
}
|
throw new Error('Client not found');
|
||||||
|
|
||||||
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
|
|
||||||
return this.request<void>('PUT', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
|
|
||||||
return this.request<void>('DELETE', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CLIENT OPERATIONS ===
|
|
||||||
async getClientRoles(realm: string, clientId: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients/${clientId}/roles`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserClientRoles(realm: string, userId: string, clientId: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/clients/${clientId}`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SESSION OPERATIONS ===
|
|
||||||
async getUserSessions(realm: string, userId: string): Promise<any[]> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/sessions`);
|
|
||||||
return this.request<any[]>('GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logoutUser(realm: string, userId: string): Promise<void> {
|
|
||||||
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/logout`);
|
|
||||||
return this.request<void>('POST', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SEARCH OPERATIONS ===
|
|
||||||
async searchUsers(realm: string, search: string, maxResults: number = 50): Promise<any[]> {
|
|
||||||
const users = await this.getUsers(realm, { search, max: maxResults });
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserByUsername(realm: string, username: string): Promise<any> {
|
|
||||||
const users = await this.getUsers(realm, { username, max: 1 });
|
|
||||||
return users.length > 0 ? users[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserByEmail(realm: string, email: string): Promise<any> {
|
|
||||||
const users = await this.getUsers(realm, { email, max: 1 });
|
|
||||||
return users.length > 0 ? users[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === BULK OPERATIONS ===
|
|
||||||
async updateUserAttributes(realm: string, userId: string, attributes: Record<string, any>): Promise<void> {
|
|
||||||
const user = await this.getUserById(realm, userId);
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
...user,
|
|
||||||
attributes: {
|
|
||||||
...user.attributes,
|
|
||||||
...attributes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateUser(realm, userId, updateData);
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableUser(realm: string, userId: string): Promise<void> {
|
|
||||||
await this.updateUser(realm, userId, { enabled: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
async enableUser(realm: string, userId: string): Promise<void> {
|
|
||||||
await this.updateUser(realm, userId, { enabled: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// === HEALTH CHECK ===
|
|
||||||
async checkHealth(): Promise<{ status: string; realm: string }> {
|
|
||||||
try {
|
|
||||||
const realm = this.configService.get<string>('keycloak.realm');
|
|
||||||
|
|
||||||
if (!realm) {
|
|
||||||
throw new Error('Keycloak configuration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test basique de connexion
|
|
||||||
await this.getRealmInfo(realm);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'healthy',
|
|
||||||
realm
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Keycloak health check failed', error);
|
|
||||||
return {
|
|
||||||
status: 'unhealthy',
|
|
||||||
realm: 'unknown'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return clients;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getRole(role: ClientRole, clientId: string): Promise<KeycloakRole> {
|
||||||
|
const roles = await this.request<KeycloakRole[]>('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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
56
src/auth/services/keycloak.strategy.ts
Normal file
56
src/auth/services/keycloak.strategy.ts
Normal file
@ -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<string>('KEYCLOAK_JWKS_URI');
|
||||||
|
const issuer = configService.get<string>('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,30 +1,162 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { KeycloakApiService } from './keycloak-api.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StartupService implements OnModuleInit {
|
export class StartupService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(StartupService.name);
|
private readonly logger = new Logger(StartupService.name);
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
private initializationError: string | null = null;
|
private initializationError: string | null = null;
|
||||||
|
private userToken: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly keycloakApiService: KeycloakApiService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Starting Keycloak connection...');
|
this.logger.log('Starting Keycloak connection tests...');
|
||||||
|
|
||||||
|
const username = this.configService.get<string>('KEYCLOAK_TEST_USER_ADMIN');
|
||||||
|
const password = this.configService.get<string>('KEYCLOAK_TEST_PASSWORD_ADMIN');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
this.initializationError = 'Test username/password not configured';
|
||||||
|
this.logger.error(this.initializationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Test simple : acquisition du token admin
|
// 1. Test d'authentification utilisateur
|
||||||
await this.tokenService.getToken();
|
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.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<void> {
|
||||||
|
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<void> {
|
||||||
|
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) {
|
} catch (error) {
|
||||||
this.initializationError = error.message;
|
this.logger.warn(`⚠️ Test user reconnection failed: ${error.message}`);
|
||||||
this.logger.error('❌ Keycloak connection failed', error);
|
|
||||||
// On ne throw pas l'erreur pour permettre à l'app de démarrer
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,15 +165,20 @@ export class StartupService implements OnModuleInit {
|
|||||||
status: this.isInitialized ? 'healthy' : 'unhealthy',
|
status: this.isInitialized ? 'healthy' : 'unhealthy',
|
||||||
keycloak: {
|
keycloak: {
|
||||||
connected: this.isInitialized,
|
connected: this.isInitialized,
|
||||||
realm: this.configService.get('keycloak.realm'),
|
realm: this.configService.get('KEYCLOAK_REALM'),
|
||||||
serverUrl: this.configService.get('keycloak.serverUrl'),
|
serverUrl: this.configService.get('KEYCLOAK_SERVER_URL'),
|
||||||
},
|
},
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
error: this.initializationError,
|
error: this.initializationError,
|
||||||
|
userToken: this.userToken ? 'available' : 'none',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
isHealthy(): boolean {
|
||||||
return this.isInitialized;
|
return this.isInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserToken(): string | null {
|
||||||
|
return this.userToken;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -7,7 +7,6 @@ import * as jwt from 'jsonwebtoken';
|
|||||||
|
|
||||||
export interface KeycloakTokenResponse {
|
export interface KeycloakTokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
|
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
@ -17,101 +16,72 @@ export interface KeycloakTokenResponse {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
private readonly logger = new Logger(TokenService.name);
|
private readonly logger = new Logger(TokenService.name);
|
||||||
|
private readonly keycloakConfig: KeycloakConfig;
|
||||||
|
|
||||||
private currentToken: string | null = null;
|
// Cache pour le token de service account
|
||||||
private tokenExpiry: Date | null = null;
|
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(
|
constructor(
|
||||||
private configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
) {}
|
) {
|
||||||
|
this.keycloakConfig = this.getKeycloakConfig();
|
||||||
// === POUR L'API ADMIN (KeycloakApiService) - Client Credentials ===
|
|
||||||
async getToken(): Promise<string> {
|
|
||||||
// Si nous avons un token valide, le retourner
|
|
||||||
if (this.currentToken && this.isTokenValid()) {
|
|
||||||
return this.currentToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sinon, acquérir un nouveau token en utilisant client_credentials
|
|
||||||
return await this.acquireClientCredentialsToken();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acquireClientCredentialsToken(): Promise<string> {
|
// === CONFIGURATION ===
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
private getKeycloakConfig(): KeycloakConfig {
|
||||||
|
const config = this.configService.get<KeycloakConfig>('keycloak');
|
||||||
if (!keycloakConfig) {
|
if (!config) {
|
||||||
throw new Error('Keycloak configuration not found');
|
throw new Error('Keycloak configuration not found');
|
||||||
}
|
}
|
||||||
|
return config;
|
||||||
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
|
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('grant_type', 'client_credentials');
|
|
||||||
params.append('client_id', keycloakConfig.adminClientId); // ← Client admin
|
|
||||||
params.append('client_secret', keycloakConfig.adminClientSecret); // ← Secret admin
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stocker le token et sa date d'expiration
|
|
||||||
this.currentToken = response.data.access_token;
|
|
||||||
this.tokenExpiry = new Date(Date.now() + (response.data.expires_in * 1000));
|
|
||||||
|
|
||||||
this.logger.log('Successfully acquired client credentials token');
|
|
||||||
return this.currentToken;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Failed to acquire client token', error.response?.data);
|
|
||||||
throw new Error(error.response?.data?.error_description || 'Failed to acquire client token');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private isTokenValid(): boolean {
|
private getTokenEndpoint(): string {
|
||||||
if (!this.currentToken || !this.tokenExpiry) {
|
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ajouter un buffer de sécurité (30 secondes par défaut)
|
|
||||||
const bufferSeconds = this.configService.get<number>('keycloak.tokenBufferSeconds') || 30;
|
|
||||||
const bufferMs = bufferSeconds * 1000;
|
|
||||||
|
|
||||||
return this.tokenExpiry.getTime() > (Date.now() + bufferMs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === POUR L'AUTHENTIFICATION UTILISATEUR (AuthController) - Password Grant ===
|
// === 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<KeycloakTokenResponse> {
|
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
|
||||||
|
|
||||||
if (!keycloakConfig) {
|
const params = new URLSearchParams({
|
||||||
throw new Error('Keycloak configuration not found');
|
grant_type: 'password',
|
||||||
}
|
client_id: this.keycloakConfig.authClientId,
|
||||||
|
client_secret: this.keycloakConfig.authClientSecret,
|
||||||
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
|
username,
|
||||||
|
password,
|
||||||
const params = new URLSearchParams();
|
});
|
||||||
params.append('grant_type', 'password');
|
|
||||||
params.append('client_id', keycloakConfig.authClientId); // ← Client auth
|
|
||||||
params.append('client_secret', keycloakConfig.authClientSecret); // ← Secret auth
|
|
||||||
params.append('username', username);
|
|
||||||
params.append('password', password);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
|
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'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;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Failed to acquire user token', error.response?.data);
|
this.logger.error('Failed to acquire user token', error.response?.data);
|
||||||
@ -119,30 +89,161 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
|
// === TOKEN STORAGE METHOD ===
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
private storeUserToken(tokenResponse: KeycloakTokenResponse): void {
|
||||||
|
this.userToken = tokenResponse.access_token;
|
||||||
|
this.userRefreshToken = tokenResponse.refresh_token || null;
|
||||||
|
|
||||||
if (!keycloakConfig) {
|
// Calculer la date d'expiration (timestamp actuel + expires_in en secondes)
|
||||||
throw new Error('Keycloak configuration not found');
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
|
this.logger.warn('User token is expired or invalid');
|
||||||
|
// Optionnel : tenter un rafraîchissement automatique ici
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
// === TOKEN VALIDATION ===
|
||||||
params.append('grant_type', 'refresh_token');
|
private isUserTokenValid(): boolean {
|
||||||
params.append('client_id', keycloakConfig.authClientId); // ← Utiliser le client auth pour le refresh
|
if (!this.userToken || !this.userTokenExpiry) {
|
||||||
params.append('client_secret', keycloakConfig.authClientSecret);
|
return false;
|
||||||
params.append('refresh_token', refreshToken);
|
}
|
||||||
|
|
||||||
|
// 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<KeycloakTokenResponse | null> {
|
||||||
|
if (!this.userRefreshToken) {
|
||||||
|
this.logger.warn('No refresh token available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: this.keycloakConfig.authClientId,
|
||||||
|
client_secret: this.keycloakConfig.authClientSecret,
|
||||||
|
refresh_token: this.userRefreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
|
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'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<string | null> {
|
||||||
|
// 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<string> {
|
||||||
|
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<KeycloakTokenResponse>(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<KeycloakTokenResponse> {
|
||||||
|
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<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('Token refreshed successfully');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Token refresh failed', error.response?.data);
|
this.logger.error('Token refresh failed', error.response?.data);
|
||||||
@ -150,28 +251,83 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeToken(token: string): Promise<void> {
|
// === TOKEN VALIDATION ===
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
async validateToken(token: string): Promise<boolean> {
|
||||||
|
const mode = this.keycloakConfig.validationMode || 'online';
|
||||||
|
|
||||||
if (!keycloakConfig) {
|
return mode === 'offline'
|
||||||
throw new Error('Keycloak configuration not found');
|
? this.validateOffline(token)
|
||||||
|
: this.validateOnline(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateOnline(token: string): Promise<boolean> {
|
||||||
|
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`;
|
try {
|
||||||
|
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
jwt.verify(token, formattedKey, {
|
||||||
// Utiliser le client auth pour la révocation (car c'est généralement lié aux tokens utilisateur)
|
algorithms: ['RS256'],
|
||||||
params.append('client_id', keycloakConfig.authClientId);
|
audience: this.keycloakConfig.authClientId,
|
||||||
params.append('client_secret', keycloakConfig.authClientSecret);
|
});
|
||||||
params.append('token', token);
|
|
||||||
|
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<void> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.keycloakConfig.authClientId,
|
||||||
|
client_secret: this.keycloakConfig.authClientSecret,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await firstValueFrom(
|
await firstValueFrom(
|
||||||
this.httpService.post(revokeEndpoint, params, {
|
this.httpService.post(
|
||||||
headers: {
|
`${this.getTokenEndpoint()}/revoke`,
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
params,
|
||||||
},
|
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||||
})
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log('Token revoked successfully');
|
this.logger.log('Token revoked successfully');
|
||||||
@ -181,89 +337,25 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateOffline(token: string): Promise<boolean> {
|
// === SERVICE MANAGEMENT ===
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
clearServiceToken(): void {
|
||||||
|
this.serviceAccountToken = null;
|
||||||
if (!keycloakConfig?.publicKey) {
|
this.serviceTokenExpiry = 0;
|
||||||
this.logger.error('Missing Keycloak public key for offline validation');
|
this.logger.log('Service account token cleared');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getServiceTokenInfo(): {
|
||||||
async validateToken(token: string): Promise<boolean> {
|
hasToken: boolean;
|
||||||
const mode = this.configService.get<string>('keycloak.validationMode') || 'online';
|
expiresIn?: number;
|
||||||
|
} {
|
||||||
if (mode === 'offline') {
|
if (!this.serviceAccountToken) {
|
||||||
return this.validateOffline(token);
|
return { hasToken: false };
|
||||||
} else {
|
|
||||||
return this.validateOnline(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async validateOnline(token: string): Promise<boolean> {
|
|
||||||
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
|
|
||||||
|
|
||||||
if (!keycloakConfig) {
|
|
||||||
throw new Error('Keycloak configuration not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const introspectEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token/introspect`;
|
const expiresIn = Math.max(0, Math.floor((this.serviceTokenExpiry - Date.now()) / 1000));
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('client_id', keycloakConfig.authClientId);
|
|
||||||
params.append('client_secret', keycloakConfig.authClientSecret);
|
|
||||||
params.append('token', token);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post(introspectEndpoint, params, {
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return response.data.active === true;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Online token validation failed', error.response?.data);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async getStoredRefreshToken(accessToken: string): Promise<string | null> {
|
|
||||||
// Implémentez votre logique de stockage des refresh tokens ici
|
|
||||||
// Pour l'instant, retournez null ou implémentez selon vos besoins
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === MÉTHODES UTILITAIRES ===
|
|
||||||
clearToken(): void {
|
|
||||||
this.currentToken = null;
|
|
||||||
this.tokenExpiry = null;
|
|
||||||
this.logger.log('Admin client token cleared from cache');
|
|
||||||
}
|
|
||||||
|
|
||||||
getTokenInfo(): { hasToken: boolean; expiresIn?: number; clientType: string } {
|
|
||||||
if (!this.currentToken || !this.tokenExpiry) {
|
|
||||||
return { hasToken: false, clientType: 'admin' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresIn = this.tokenExpiry.getTime() - Date.now();
|
|
||||||
return {
|
return {
|
||||||
hasToken: true,
|
hasToken: true,
|
||||||
expiresIn: Math.max(0, Math.floor(expiresIn / 1000)), // en secondes
|
expiresIn: expiresIn > 0 ? expiresIn : undefined,
|
||||||
clientType: 'admin'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,8 +6,8 @@ export interface KeycloakConfig {
|
|||||||
realm: string;
|
realm: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
// Client pour l'API Admin (Service Account - client_credentials)
|
// Client pour l'API Admin (Service Account - client_credentials)
|
||||||
adminClientId: string;
|
//adminClientId: string;
|
||||||
adminClientSecret: string;
|
//adminClientSecret: string;
|
||||||
// Client pour l'authentification utilisateur (Password Grant)
|
// Client pour l'authentification utilisateur (Password Grant)
|
||||||
authClientId: string;
|
authClientId: string;
|
||||||
authClientSecret: string;
|
authClientSecret: string;
|
||||||
@ -20,11 +20,11 @@ export default registerAs('keycloak', (): KeycloakConfig => ({
|
|||||||
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
|
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
|
||||||
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
||||||
// Client pour Service Account (API Admin)
|
// Client pour Service Account (API Admin)
|
||||||
adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
|
//adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
|
||||||
adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
|
//adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
|
||||||
// Client pour Password Grant (Authentification utilisateur)
|
// Client pour Password Grant (Authentification utilisateur)
|
||||||
authClientId: process.env.KEYCLOAK_AUTH_CLIENT_ID || 'dcb-user-service-pwd',
|
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-pwd',
|
||||||
authClientSecret: process.env.KEYCLOAK_AUTH_CLIENT_SECRET || '',
|
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
||||||
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
||||||
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
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'
|
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
|
/*KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
|
||||||
.required()
|
.required()
|
||||||
.messages({
|
.messages({
|
||||||
'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required'
|
'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required'
|
||||||
@ -65,20 +65,20 @@ export const keycloakConfigValidationSchema = Joi.object({
|
|||||||
.messages({
|
.messages({
|
||||||
'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required',
|
'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required',
|
||||||
'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty'
|
'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty'
|
||||||
}),
|
}),*/
|
||||||
|
|
||||||
KEYCLOAK_AUTH_CLIENT_ID: Joi.string()
|
KEYCLOAK_CLIENT_ID: Joi.string()
|
||||||
.required()
|
.required()
|
||||||
.messages({
|
.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()
|
.required()
|
||||||
.min(1)
|
.min(1)
|
||||||
.messages({
|
.messages({
|
||||||
'any.required': 'KEYCLOAK_AUTH_CLIENT_SECRET is required',
|
'any.required': 'KEYCLOAK_CLIENT_SECRET is required',
|
||||||
'string.min': 'KEYCLOAK_AUTH_CLIENT_SECRET cannot be empty'
|
'string.min': 'KEYCLOAK_CLIENT_SECRET cannot be empty'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
KEYCLOAK_VALIDATION_MODE: Joi.string()
|
KEYCLOAK_VALIDATION_MODE: Joi.string()
|
||||||
|
|||||||
3
src/constants/resouces.ts
Normal file
3
src/constants/resouces.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const RESOURCES = {
|
||||||
|
USER: 'user',// user resource for /users/* endpoints
|
||||||
|
};
|
||||||
5
src/constants/scopes.ts
Normal file
5
src/constants/scopes.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const SCOPES = {
|
||||||
|
READ: 'read',
|
||||||
|
WRITE: 'write',
|
||||||
|
DELETE: 'delete',
|
||||||
|
};
|
||||||
@ -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);
|
|
||||||
@ -10,201 +10,240 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
||||||
Roles,
|
|
||||||
AuthenticatedUser,
|
|
||||||
Public
|
|
||||||
} from "nest-keycloak-connect";
|
|
||||||
import { UsersService } from "../services/users.service";
|
import { UsersService } from "../services/users.service";
|
||||||
import { User, CreateUserDto, UpdateUserDto, UserResponse } from "../models/user";
|
import * as user from "../models/user";
|
||||||
import { ROLES } from "../models/roles.enum";
|
import { RESOURCES } from '../../constants/resouces';
|
||||||
|
import { SCOPES } from '../../constants/scopes';
|
||||||
|
|
||||||
@Controller("user")
|
@Controller('users')
|
||||||
export class UserController {
|
@Resource(RESOURCES.USER)
|
||||||
private readonly logger = new Logger(UserController.name);
|
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<UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} creating new user: ${payload.username}`);
|
|
||||||
|
|
||||||
|
// === CREATE USER ===
|
||||||
|
@Post()
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<user.UserResponse> {
|
||||||
|
this.logger.log(`Creating new user: ${createUserDto.username}`);
|
||||||
try {
|
try {
|
||||||
const createdUser = await this.userService.createUser(payload);
|
const createdUser = await this.usersService.createUser(createUserDto);
|
||||||
return new UserResponse(createdUser);
|
return createdUser;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to create user: ${error.message}`);
|
this.logger.error(`Failed to create user: ${error.message}`);
|
||||||
throw new HttpException(
|
throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST);
|
||||||
error.message || 'Failed to create user',
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === GET ALL USERS ===
|
||||||
|
@Get()
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async findAllUsers(@Query() query: user.UserQueryDto): Promise<user.PaginatedUserResponse> {
|
||||||
|
this.logger.log('Fetching users list');
|
||||||
|
try {
|
||||||
|
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<user.UserResponse> {
|
||||||
|
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<user.UserResponse> {
|
||||||
|
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')
|
@Put(':id')
|
||||||
@Roles({ roles: [ROLES.UPDATE_USER] })
|
@Scopes(SCOPES.WRITE)
|
||||||
async updateUser(
|
async updateUser(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() payload: UpdateUserDto,
|
@Body() updateUserDto: user.UpdateUserDto
|
||||||
@AuthenticatedUser() user: any
|
): Promise<user.UserResponse> {
|
||||||
): Promise<UserResponse> {
|
this.logger.log(`Updating user: ${id}`);
|
||||||
this.logger.log(`User ${user.sub} updating user: ${id}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedUser = await this.userService.updateUser(id, payload);
|
const updatedUser = await this.usersService.updateUser(id, updateUserDto);
|
||||||
return new UserResponse(updatedUser);
|
return updatedUser;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
||||||
throw new HttpException(
|
throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST);
|
||||||
error.message || 'Failed to update user',
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
// === UPDATE CURRENT USER PROFILE ===
|
||||||
@Roles({ roles: [ROLES.DELETE_USER] })
|
@Put('profile/me')
|
||||||
async deleteUser(
|
async updateCurrentUserProfile(
|
||||||
@Param("id") id: string,
|
@AuthenticatedUser() user: any,
|
||||||
@AuthenticatedUser() user: any
|
@Body() updateUserDto: user.UpdateUserDto
|
||||||
): Promise<{ message: string }> {
|
): Promise<user.UserResponse> {
|
||||||
this.logger.log(`User ${user.sub} deleting user: ${id}`);
|
this.logger.log(`User ${user.sub} updating own profile`);
|
||||||
|
|
||||||
try {
|
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` };
|
return { message: `User ${id} deleted successfully` };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
||||||
throw new HttpException(
|
throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST);
|
||||||
error.message || 'Failed to delete user',
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
// === RESET PASSWORD ===
|
||||||
@Roles({ roles: [ROLES.READ_USERS] })
|
@Put(':id/password')
|
||||||
async findAllUsers(
|
@Scopes(SCOPES.WRITE)
|
||||||
@Query('page') page: number = 1,
|
|
||||||
@Query('limit') limit: number = 10,
|
|
||||||
@Query('search') search: string = '',
|
|
||||||
@Query('enabled') enabled: boolean | string,
|
|
||||||
@AuthenticatedUser() user: any
|
|
||||||
): Promise<{ users: UserResponse[]; total: number }> {
|
|
||||||
this.logger.log(`User ${user.sub} accessing users list`);
|
|
||||||
|
|
||||||
// Convert enabled query param to boolean if provided
|
|
||||||
const enabledFilter = enabled === 'true' ? true :
|
|
||||||
enabled === 'false' ? false : undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.userService.findAllUsers(
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
enabledFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: result.users.map(user => new UserResponse(user)),
|
|
||||||
total: result.total
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
|
||||||
throw new HttpException(
|
|
||||||
error.message || 'Failed to fetch users',
|
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(":id")
|
|
||||||
@Roles({ roles: [ROLES.READ_USERS] })
|
|
||||||
async getUserById(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@AuthenticatedUser() user: any
|
|
||||||
): Promise<UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} accessing profile of user: ${id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userData = await this.userService.getUserById(id);
|
|
||||||
return new UserResponse(userData);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(
|
|
||||||
error.message || 'User not found',
|
|
||||||
HttpStatus.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("profile/me")
|
|
||||||
async getCurrentUserProfile(
|
|
||||||
@AuthenticatedUser() user: any
|
|
||||||
): Promise<UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} accessing own profile`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Utiliser l'ID de l'utilisateur authentifié
|
|
||||||
const userData = await this.userService.getUserById(user.sub);
|
|
||||||
return new UserResponse(userData);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch current user profile: ${error.message}`);
|
|
||||||
throw new HttpException(
|
|
||||||
error.message || 'Failed to fetch user profile',
|
|
||||||
HttpStatus.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/roles")
|
|
||||||
@Roles({ roles: [ROLES.UPDATE_USER] })
|
|
||||||
async assignRoles(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { roles: string[] },
|
|
||||||
@AuthenticatedUser() user: any
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`User ${user.sub} assigning roles to user: ${id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.userService.assignRealmRoles(id, body.roles);
|
|
||||||
return { message: 'Roles assigned successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(
|
|
||||||
error.message || 'Failed to assign roles',
|
|
||||||
HttpStatus.BAD_REQUEST
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put(":id/password")
|
|
||||||
@Roles({ roles: [ROLES.UPDATE_USER] })
|
|
||||||
async resetPassword(
|
async resetPassword(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { password: string; temporary: boolean },
|
@Body() resetPasswordDto: user.ResetPasswordDto
|
||||||
@AuthenticatedUser() user: any
|
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
this.logger.log(`User ${user.sub} resetting password for user: ${id}`);
|
this.logger.log(`Resetting password for user: ${id}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.userService.resetPassword(
|
await this.usersService.resetPassword(resetPasswordDto);
|
||||||
id,
|
|
||||||
body.password,
|
|
||||||
body.temporary ?? true
|
|
||||||
);
|
|
||||||
return { message: 'Password reset successfully' };
|
return { message: 'Password reset successfully' };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
|
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
|
||||||
throw new HttpException(
|
throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST);
|
||||||
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<user.UserResponse[]> {
|
||||||
|
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<user.UserResponse[]> {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,17 +1,15 @@
|
|||||||
|
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class User {
|
export class User {
|
||||||
id?: string;
|
id?: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
firstName?: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName: string;
|
||||||
enabled?: boolean = true;
|
enabled?: boolean = true;
|
||||||
emailVerified?: boolean = false;
|
emailVerified?: boolean = false;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
realmRoles?: string[];
|
clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support)
|
||||||
clientRoles?: Record<string, string[]>;
|
|
||||||
groups?: string[];
|
|
||||||
requiredActions?: string[];
|
|
||||||
credentials?: UserCredentials[];
|
|
||||||
createdTimestamp?: number;
|
createdTimestamp?: number;
|
||||||
|
|
||||||
constructor(partial?: Partial<User>) {
|
constructor(partial?: Partial<User>) {
|
||||||
@ -34,40 +32,103 @@ export class UserCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
password: string;
|
|
||||||
enabled?: boolean = true;
|
|
||||||
emailVerified?: boolean = false;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
realmRoles?: string[];
|
|
||||||
groups?: string[];
|
|
||||||
|
|
||||||
constructor(partial?: Partial<CreateUserDto>) {
|
@IsEmail()
|
||||||
if (partial) {
|
email: string;
|
||||||
Object.assign(this, partial);
|
|
||||||
}
|
@IsString()
|
||||||
}
|
firstName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emailVerified?: boolean = false;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
clientRoles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
username?: string;
|
username?: string;
|
||||||
email?: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
emailVerified?: boolean;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
realmRoles?: string[];
|
|
||||||
groups?: string[];
|
|
||||||
|
|
||||||
constructor(partial?: Partial<UpdateUserDto>) {
|
@IsOptional()
|
||||||
if (partial) {
|
@IsEmail()
|
||||||
Object.assign(this, partial);
|
email?: string;
|
||||||
}
|
|
||||||
}
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emailVerified?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
|
||||||
|
@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 {
|
export class UserResponse {
|
||||||
@ -79,9 +140,7 @@ export class UserResponse {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
realmRoles?: string[];
|
clientRoles: string[]; // Rôles client uniquement
|
||||||
clientRoles?: Record<string, string[]>;
|
|
||||||
groups?: string[];
|
|
||||||
createdTimestamp: number;
|
createdTimestamp: number;
|
||||||
|
|
||||||
constructor(user: any) {
|
constructor(user: any) {
|
||||||
@ -93,9 +152,47 @@ export class UserResponse {
|
|||||||
this.enabled = user.enabled;
|
this.enabled = user.enabled;
|
||||||
this.emailVerified = user.emailVerified;
|
this.emailVerified = user.emailVerified;
|
||||||
this.attributes = user.attributes;
|
this.attributes = user.attributes;
|
||||||
this.realmRoles = user.realmRoles;
|
this.clientRoles = user.clientRoles || [];
|
||||||
this.clientRoles = user.clientRoles;
|
|
||||||
this.groups = user.groups;
|
|
||||||
this.createdTimestamp = user.createdTimestamp;
|
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;
|
||||||
|
}
|
||||||
@ -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 { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
private readonly logger = new Logger(UsersService.name);
|
private readonly logger = new Logger(UsersService.name);
|
||||||
private readonly realm: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly keycloakApi: KeycloakApiService,
|
private readonly keycloakApi: KeycloakApiService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {}
|
||||||
this.realm = this.configService.get<string>('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<any> {
|
private validateClientRoles(roles: string[]): ClientRole[] {
|
||||||
|
return roles.map(role => this.validateClientRole(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTHENTIFICATION UTILISATEUR ===
|
||||||
|
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
|
||||||
|
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === GET USER BY ID ===
|
||||||
|
async getUserById(userId: string): Promise<UserResponse> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Fetching user by ID: ${userId}`);
|
this.logger.debug(`Fetching user by ID: ${userId}`);
|
||||||
const user = await this.keycloakApi.getUserById(this.realm, userId);
|
|
||||||
|
|
||||||
if (!user) {
|
const user = await this.keycloakApi.getUserById(userId);
|
||||||
throw new NotFoundException(`User with ID ${userId} not found`);
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to fetch user ${userId}: ${error.message}`);
|
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`);
|
throw new NotFoundException(`User with ID ${userId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserProfile(accessToken: string): Promise<any> {
|
// === GET PROFILE FROM DECODED TOKEN ===
|
||||||
|
async getUserProfile(decodedToken: any): Promise<UserResponse> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Fetching user profile from token');
|
const profileData = {
|
||||||
const profile = await this.keycloakApi.getUserProfile(this.realm, accessToken);
|
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) {
|
return new UserResponse(profileData);
|
||||||
throw new NotFoundException('User profile not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to fetch user profile: ${error.message}`);
|
this.logger.error(`Failed to create user profile from token: ${error.message}`);
|
||||||
throw new NotFoundException('Failed to fetch user profile');
|
throw new NotFoundException('Failed to create user profile');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === FIND ALL USERS ===
|
||||||
async findAllUsers(
|
async findAllUsers(query: UserQueryDto): Promise<PaginatedUserResponse> {
|
||||||
page: number = 1,
|
|
||||||
limit: number = 10,
|
|
||||||
search: string = '',
|
|
||||||
enabled?: boolean
|
|
||||||
): Promise<{ users: any[]; total: number }> {
|
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Fetching users - page: ${page}, limit: ${limit}, search: ${search}`);
|
let users = await this.keycloakApi.getAllUsers();
|
||||||
|
|
||||||
// Récupérer tous les utilisateurs avec les filtres
|
// Filtre de recherche
|
||||||
let users = await this.keycloakApi.getUsers(this.realm);
|
if (query.search) {
|
||||||
|
const q = query.search.toLowerCase();
|
||||||
// Appliquer les filtres
|
users = users.filter(
|
||||||
if (search) {
|
(u) =>
|
||||||
const searchLower = search.toLowerCase();
|
u.username?.toLowerCase().includes(q) ||
|
||||||
users = users.filter(user =>
|
u.email?.toLowerCase().includes(q) ||
|
||||||
user.username?.toLowerCase().includes(searchLower) ||
|
u.firstName?.toLowerCase().includes(q) ||
|
||||||
user.email?.toLowerCase().includes(searchLower) ||
|
u.lastName?.toLowerCase().includes(q),
|
||||||
user.firstName?.toLowerCase().includes(searchLower) ||
|
|
||||||
user.lastName?.toLowerCase().includes(searchLower)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabled !== undefined) {
|
// Filtre par statut enabled
|
||||||
users = users.filter(user => user.enabled === enabled);
|
if (query.enabled !== undefined) {
|
||||||
|
users = users.filter((u) => u.enabled === query.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination
|
if (query.emailVerified !== undefined) {
|
||||||
const startIndex = (page - 1) * limit;
|
users = users.filter((u) => u.emailVerified === query.emailVerified);
|
||||||
const endIndex = startIndex + limit;
|
}
|
||||||
const paginatedUsers = users.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
return {
|
const page = query.page || 1;
|
||||||
users: paginatedUsers,
|
const limit = query.limit || 10;
|
||||||
total: users.length
|
const startIndex = (page - 1) * limit;
|
||||||
};
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
this.logger.error(`Failed to fetch users: ${error.message}`);
|
||||||
throw new BadRequestException('Failed to fetch users');
|
throw new BadRequestException('Failed to fetch users');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createUser(userData: CreateUserDto): Promise<any> {
|
// === CREATE USER ===
|
||||||
|
async createUser(userData: CreateUserDto): Promise<UserResponse> {
|
||||||
try {
|
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à
|
// Vérifier si l'utilisateur existe déjà
|
||||||
const existingUsers = await this.keycloakApi.getUsers(this.realm);
|
const existingByUsername = await this.keycloakApi.findUserByUsername(userData.username);
|
||||||
const userExists = existingUsers.some(user =>
|
|
||||||
user.username === userData.username || user.email === userData.email
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userExists) {
|
if (userData.email) {
|
||||||
throw new ConflictException('User with this username or email already exists');
|
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
|
if (existingByUsername.length > 0) {
|
||||||
const keycloakUserData = {
|
throw new ConflictException('User with this username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await this.keycloakApi.createUser({
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
firstName: userData.firstName,
|
firstName: userData.firstName,
|
||||||
lastName: userData.lastName,
|
lastName: userData.lastName,
|
||||||
|
password: userData.password,
|
||||||
enabled: userData.enabled ?? true,
|
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);
|
// Attribution automatique de rôles client si fournis
|
||||||
this.logger.log(`User created successfully: ${userData.username}`);
|
if (userData.clientRoles?.length) {
|
||||||
|
const validatedRoles = this.validateClientRoles(userData.clientRoles);
|
||||||
return createdUser;
|
await this.keycloakApi.setClientRoles(userId, validatedRoles);
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user ${userData.username}: ${error.message}`);
|
|
||||||
|
|
||||||
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
throw new BadRequestException('Failed to create user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(id: string, userData: UpdateUserDto): Promise<any> {
|
// === UPDATE USER ===
|
||||||
|
async updateUser(id: string, userData: UpdateUserDto): Promise<UserResponse> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Updating user: ${id}`);
|
|
||||||
|
|
||||||
// Vérifier que l'utilisateur existe
|
// Vérifier que l'utilisateur existe
|
||||||
const existingUser = await this.getUserById(id);
|
await this.keycloakApi.getUserById(id);
|
||||||
|
|
||||||
if (!existingUser) {
|
await this.keycloakApi.updateUser(id, userData);
|
||||||
throw new NotFoundException(`User with ID ${id} not found`);
|
|
||||||
|
// 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 updatedUser = await this.keycloakApi.getUserById(id);
|
||||||
const updateData: any = {};
|
const roles = await this.keycloakApi.getUserClientRoles(id);
|
||||||
|
const clientRoles = roles.map(role => role.name);
|
||||||
|
|
||||||
if (userData.username !== undefined) updateData.username = userData.username;
|
return new UserResponse({ ...updatedUser, clientRoles });
|
||||||
if (userData.email !== undefined) updateData.email = userData.email;
|
|
||||||
if (userData.firstName !== undefined) updateData.firstName = userData.firstName;
|
|
||||||
if (userData.lastName !== undefined) updateData.lastName = userData.lastName;
|
|
||||||
if (userData.enabled !== undefined) updateData.enabled = userData.enabled;
|
|
||||||
if (userData.emailVerified !== undefined) updateData.emailVerified = userData.emailVerified;
|
|
||||||
if (userData.attributes !== undefined) updateData.attributes = userData.attributes;
|
|
||||||
if (userData.realmRoles !== undefined) updateData.realmRoles = userData.realmRoles;
|
|
||||||
if (userData.groups !== undefined) updateData.groups = userData.groups;
|
|
||||||
|
|
||||||
const updatedUser = await this.keycloakApi.updateUser(this.realm, id, updateData);
|
|
||||||
this.logger.log(`User updated successfully: ${id}`);
|
|
||||||
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
||||||
|
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException('Failed to update user');
|
throw new BadRequestException('Failed to update user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(id: string): Promise<void> {
|
// === ASSIGN CLIENT ROLES TO USER ===
|
||||||
|
async assignClientRoles(userId: string, roles: string[]): Promise<{ message: string }> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Deleting user: ${id}`);
|
this.logger.log(`Assigning client roles to user: ${userId}`, roles);
|
||||||
|
|
||||||
// Vérifier que l'utilisateur existe
|
// Vérifier que l'utilisateur existe
|
||||||
const existingUser = await this.getUserById(id);
|
await this.keycloakApi.getUserById(userId);
|
||||||
|
|
||||||
if (!existingUser) {
|
// Valider et assigner les rôles
|
||||||
throw new NotFoundException(`User with ID ${id} not found`);
|
const validatedRoles = this.validateClientRoles(roles);
|
||||||
}
|
await this.keycloakApi.setClientRoles(userId, validatedRoles);
|
||||||
|
|
||||||
await this.keycloakApi.deleteUser(this.realm, id);
|
this.logger.log(`Successfully assigned ${validatedRoles.length} roles to user ${userId}`);
|
||||||
this.logger.log(`User deleted successfully: ${id}`);
|
return { message: 'Roles assigned successfully' };
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
|
||||||
|
|
||||||
if (error instanceof NotFoundException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestException('Failed to delete user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async assignRealmRoles(userId: string, roles: string[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.logger.debug(`Assigning roles to user ${userId}: ${roles.join(', ')}`);
|
|
||||||
|
|
||||||
// Vérifier que l'utilisateur existe
|
|
||||||
await this.getUserById(userId);
|
|
||||||
|
|
||||||
await this.keycloakApi.assignRealmRoles(this.realm, userId, roles);
|
|
||||||
this.logger.log(`Roles assigned successfully to user: ${userId}`);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`);
|
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');
|
throw new BadRequestException('Failed to assign roles to user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeRealmRoles(userId: string, roles: string[]): Promise<void> {
|
// === DELETE USER ===
|
||||||
|
async deleteUser(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Removing roles from user ${userId}: ${roles.join(', ')}`);
|
await this.keycloakApi.getUserById(id);
|
||||||
|
await this.keycloakApi.deleteUser(id);
|
||||||
// Vérifier que l'utilisateur existe
|
|
||||||
await this.getUserById(userId);
|
|
||||||
|
|
||||||
await this.keycloakApi.removeRealmRoles(this.realm, userId, roles);
|
|
||||||
this.logger.log(`Roles removed successfully from user: ${userId}`);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`);
|
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
||||||
throw new BadRequestException('Failed to remove roles from user');
|
if (error instanceof NotFoundException) throw error;
|
||||||
|
throw new BadRequestException('Failed to delete user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetPassword(userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
|
// === RESET PASSWORD ===
|
||||||
|
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Resetting password for user: ${userId}`);
|
await this.keycloakApi.getUserById(resetPasswordDto.userId);
|
||||||
|
await this.keycloakApi.resetPassword(resetPasswordDto.userId, resetPasswordDto.newPassword);
|
||||||
// Vérifier que l'utilisateur existe
|
|
||||||
await this.getUserById(userId);
|
|
||||||
|
|
||||||
await this.keycloakApi.resetPassword(this.realm, userId, newPassword, temporary);
|
|
||||||
this.logger.log(`Password reset successfully for user: ${userId}`);
|
|
||||||
} catch (error: any) {
|
} 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');
|
throw new BadRequestException('Failed to reset password');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchUsers(query: string, maxResults: number = 50): Promise<any[]> {
|
// === ENABLE/DISABLE USER ===
|
||||||
|
async enableUser(userId: string): Promise<void> {
|
||||||
try {
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allUsers = await this.keycloakApi.getUsers(this.realm);
|
async disableUser(userId: string): Promise<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchLower = query.toLowerCase();
|
// === UTILITY METHODS ===
|
||||||
const filteredUsers = allUsers.filter(user =>
|
async userExists(username: string): Promise<boolean> {
|
||||||
user.username?.toLowerCase().includes(searchLower) ||
|
try {
|
||||||
user.email?.toLowerCase().includes(searchLower) ||
|
const users = await this.keycloakApi.findUserByUsername(username);
|
||||||
user.firstName?.toLowerCase().includes(searchLower) ||
|
return users.length > 0;
|
||||||
user.lastName?.toLowerCase().includes(searchLower) ||
|
} catch {
|
||||||
user.id?.toLowerCase().includes(searchLower)
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserClientRoles(userId: string): Promise<string[]> {
|
||||||
|
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<UserResponse[]> {
|
||||||
|
try {
|
||||||
|
const users = await this.keycloakApi.findUserByUsername(username);
|
||||||
|
|
||||||
|
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) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to search users: ${error.message}`);
|
this.logger.error(`Failed to find user by username ${username}: ${error.message}`);
|
||||||
throw new BadRequestException('Failed to search users');
|
throw new BadRequestException('Failed to find user by username');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserCount(): Promise<{ total: number; enabled: number; disabled: number }> {
|
async findUserByEmail(email: string): Promise<UserResponse[]> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Getting user count statistics');
|
const users = await this.keycloakApi.findUserByEmail(email);
|
||||||
|
|
||||||
const allUsers = await this.keycloakApi.getUsers(this.realm);
|
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 {
|
return usersWithRoles;
|
||||||
total: allUsers.length,
|
|
||||||
enabled: allUsers.filter(user => user.enabled).length,
|
|
||||||
disabled: allUsers.filter(user => !user.enabled).length
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get user count: ${error.message}`);
|
this.logger.error(`Failed to find user by email ${email}: ${error.message}`);
|
||||||
throw new BadRequestException('Failed to get user statistics');
|
throw new BadRequestException('Failed to find user by email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleUserStatus(userId: string, enabled: boolean): Promise<any> {
|
// === PRIVATE METHODS ===
|
||||||
|
private decodeJwtToken(token: string): any {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Setting user ${userId} enabled status to: ${enabled}`);
|
const payload = token.split('.')[1];
|
||||||
|
const decoded = JSON.parse(Buffer.from(payload, 'base64').toString());
|
||||||
const user = await this.getUserById(userId);
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
return await this.updateUser(userId, { enabled });
|
this.logger.error('Failed to decode JWT token', error);
|
||||||
} catch (error: any) {
|
throw new BadRequestException('Invalid token format');
|
||||||
this.logger.error(`Failed to toggle user status for ${userId}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to update user status');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,9 +2,8 @@ import { Module } from '@nestjs/common'
|
|||||||
import { JwtModule } from '@nestjs/jwt'
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { TokenService } from '../auth/services/token.service'
|
import { TokenService } from '../auth/services/token.service'
|
||||||
import { ClientCredentialsGuard } from '../auth/guards/client-credentials.guard';
|
|
||||||
import { UsersService } from './services/users.service'
|
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';
|
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
|
||||||
|
|
||||||
|
|
||||||
@ -14,7 +13,7 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service';
|
|||||||
JwtModule.register({}),
|
JwtModule.register({}),
|
||||||
],
|
],
|
||||||
providers: [UsersService, KeycloakApiService, TokenService],
|
providers: [UsersService, KeycloakApiService, TokenService],
|
||||||
controllers: [UserController],
|
controllers: [UsersController],
|
||||||
exports: [UsersService, KeycloakApiService, TokenService, JwtModule],
|
exports: [UsersService, KeycloakApiService, TokenService, JwtModule],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -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>(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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user