From 5abf42d53dc189744fa048816df9683f59b91bb4 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Wed, 10 Dec 2025 17:09:22 +0330 Subject: [PATCH 1/9] Update services --- .../opex/auth/controller/AuthController.kt | 13 +++-- .../kotlin/co/nilin/opex/auth/model/Token.kt | 10 +++- .../co/nilin/opex/auth/proxy/KeycloakProxy.kt | 29 ++++++++++- .../nilin/opex/auth/service/TokenService.kt | 52 ++++++++++++------- .../opex/auth/utils/InternalIdGenerator.kt | 16 +++--- 5 files changed, 89 insertions(+), 31 deletions(-) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index 8aaa7a76e..dfffe73ac 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -1,5 +1,6 @@ package co.nilin.opex.auth.controller; +import co.nilin.opex.auth.model.ConfirmPasswordFlowTokenRequest import co.nilin.opex.auth.model.ExternalIdpTokenRequest import co.nilin.opex.auth.model.PasswordFlowTokenRequest import co.nilin.opex.auth.model.RefreshTokenRequest @@ -15,9 +16,15 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/v1/oauth/protocol/openid-connect/") class AuthController(private val tokenService: TokenService) { - @PostMapping("/token") - suspend fun getToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { - val tokenResponse = tokenService.getToken(tokenRequest) + @PostMapping("/token/request") + suspend fun requestGetToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { + val tokenResponse = tokenService.requestGetToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/token/confirm") + suspend fun confirmGetToken(@RequestBody tokenRequest: ConfirmPasswordFlowTokenRequest): ResponseEntity { + val tokenResponse = tokenService.confirmGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt index f6002d91f..7420db08e 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt @@ -7,12 +7,20 @@ data class PasswordFlowTokenRequest( val password: String, val clientId: String, val clientSecret: String?, - val otp: String?, val rememberMe: Boolean = true, val captchaType: CaptchaType? = CaptchaType.INTERNAL, val captchaCode: String?, ) +data class ConfirmPasswordFlowTokenRequest( + val username: String, + val token: String, + val clientId: String, + val clientSecret: String?, + val otp: String, + val rememberMe: Boolean = true, +) + data class RefreshTokenRequest( val clientId: String, val clientSecret: String?, diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt index 82d407946..72bd39c94 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt @@ -65,6 +65,31 @@ class KeycloakProxy( } .awaitBody() } + suspend fun exchangeUserToken( + token: String, + clientId: String, + clientSecret: String?, + targetClientId: String + ): Token { + val userTokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + + return keycloakClient.post() + .uri(userTokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue( + "client_id=${clientId}" + + "&client_secret=${clientSecret}" + + "&grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + + "&subject_token=${token}" + + "&audience=${targetClientId}" + + "&requested_token_type=urn:ietf:params:oauth:token-type:access_token" + ) + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.InvalidUserCredentials.exception() + } + .awaitBody() + } suspend fun checkUserCredentials(user: KeycloakUser, password: String) { keycloakClient.post() @@ -174,7 +199,7 @@ class KeycloakProxy( ).apply { if (username.type == UsernameType.MOBILE) put("mobile", username.value) - put(Attributes.OTP, OTPType.NONE.name) + put(Attributes.OTP, OTPType.EMAIL.name + "," + OTPType.SMS.name) } ).apply { if (username.type == UsernameType.EMAIL) put("email", username.value) } ) @@ -419,7 +444,7 @@ class KeycloakProxy( var internalId: String; var attempts = 0 do { - if (attempts >= 10) { + if (attempts >= 30) { throw OpexError.InternalIdGenerateFailed.exception() } internalId = generateRandomID() diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt index 51d30ff30..cd51ef4bc 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt @@ -6,6 +6,7 @@ import co.nilin.opex.auth.proxy.GoogleProxy import co.nilin.opex.auth.proxy.KeycloakProxy import co.nilin.opex.auth.proxy.OTPProxy import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate import org.springframework.stereotype.Service @Service @@ -15,8 +16,11 @@ class TokenService( private val googleProxy: GoogleProxy, private val captchaHandler: CaptchaHandler, ) { + private val logger by LoggerDelegate() + private val PRE_AUTH_CLIENT_SECRET_KEY = "pY1uVemXFIgVwubP1QM31YxRFh87NRp8" + private val PRE_AUTH_CLIENT_ID = "pre-auth-client" - suspend fun getToken(request: PasswordFlowTokenRequest): TokenResponse { + suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse { captchaHandler.validateCaptchaWithActionCache( username = request.username, captchaCode = request.captchaCode, @@ -26,9 +30,9 @@ class TokenService( val username = Username.create(request.username) val user = keycloakProxy.findUserByUsername(username) ?: throw OpexError.UserNotFound.exception() - val otpType = OTPType.valueOf(user.attributes?.get(Attributes.OTP)?.get(0) ?: OTPType.NONE.name) + val otpTypes = (user.attributes?.get(Attributes.OTP)?.get(0) ?: OTPType.NONE.name).split(",") - if (otpType == OTPType.NONE) { + if (otpTypes.contains(OTPType.NONE.name)) { val token = keycloakProxy.getUserToken( username, request.password, @@ -38,19 +42,29 @@ class TokenService( return TokenResponse(token, null, null) } - if (request.otp.isNullOrBlank()) { - keycloakProxy.checkUserCredentials(user, request.password) - - val requiredOtpTypes = listOf(OTPReceiver(username.value, otpType)) - val res = otpProxy.requestOTP(username.value, requiredOtpTypes) - val receiver = when (otpType) { - OTPType.EMAIL -> user.email - OTPType.SMS -> user.mobile - else -> null - } - return TokenResponse(null, RequiredOTP(otpType, receiver), res.otp) + keycloakProxy.checkUserCredentials(user, request.password) + val usernameType = username.type.otpType + if (!otpTypes.contains((usernameType.name))) throw OpexError.OTPCannotBeRequested.exception() + val requiredOtpTypes = listOf(OTPReceiver(username.value, usernameType)) + val res = otpProxy.requestOTP(username.value, requiredOtpTypes) + val receiver = when (usernameType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null } + val token = keycloakProxy.getUserToken( + username, + request.password, + PRE_AUTH_CLIENT_ID, + PRE_AUTH_CLIENT_SECRET_KEY, + ).apply { if (!request.rememberMe) refreshToken = null } + + return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) + } + + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { + val username = Username.create(request.username) val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) val otpResult = otpProxy.verifyOTP(otpRequest) if (!otpResult.result) { @@ -60,11 +74,11 @@ class TokenService( } } - val token = keycloakProxy.getUserToken( - username, - request.password, - request.clientId, - request.clientSecret + val token = keycloakProxy.exchangeUserToken( + request.token, + PRE_AUTH_CLIENT_ID, + PRE_AUTH_CLIENT_SECRET_KEY, + request.clientId ).apply { if (!request.rememberMe) refreshToken = null } return TokenResponse(token, null, null) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt index 558d874f6..7908e9ef0 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/InternalIdGenerator.kt @@ -1,9 +1,13 @@ package co.nilin.opex.auth.utils -fun generateRandomID(length: Int = 8): String { - val charset = ('0'..'9') + ('a'..'z') - return (1..length) - .map { charset.random() } - .joinToString("") -} +import kotlin.random.Random +fun generateRandomID(): String { + val digits = IntArray(6) { Random.nextInt(0, 10) } + val sum = digits.sum() + val checksum = sum.toString().padStart(2, '0') + return buildString(8) { + digits.forEach { append(it) } + append(checksum) + } +} From 8c8e1f26ebc658fc8fae4af79d6727eaab44c34d Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sat, 13 Dec 2025 20:04:27 +0330 Subject: [PATCH 2/9] Drop address regx column from chain table --- .../src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt | 2 +- .../kotlin/co/nilin/opex/auth/controller/AuthController.kt | 2 +- .../src/main/kotlin/co/nilin/opex/auth/model/Token.kt | 2 +- .../main/kotlin/co/nilin/opex/auth/service/TokenService.kt | 3 ++- .../co/nilin/opex/bcgateway/app/controller/AdminController.kt | 2 +- .../opex/bcgateway/app/controller/CryptoCurrencyController.kt | 2 +- .../main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt | 4 +--- .../nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt | 2 +- .../nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt | 2 +- .../db/migration/V4__drop_address_regex_from_chain.sql | 1 + docker-compose.yml | 4 ++-- 11 files changed, 13 insertions(+), 13 deletions(-) create mode 100644 bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regex_from_chain.sql diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt index 58dacdfa9..e6c0540a6 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ChainInfo.kt @@ -4,5 +4,5 @@ data class ChainInfo( val name: String, val addressTypes: String?, val externalChainScannerUrl: String? = null, - val addressRegx: String? = null + val addressRegex: String? = null ) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index f0e996f28..ecfc6f940 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/v1/oauth/protocol/openid-connect/") class AuthController(private val tokenService: TokenService) { - @PostMapping("/token/request") + @PostMapping("/token") suspend fun requestGetToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { val tokenResponse = tokenService.requestGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt index 7a14e3997..afc573804 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt @@ -20,7 +20,7 @@ data class ConfirmPasswordFlowTokenRequest( val clientSecret: String?, val otp: String, val rememberMe: Boolean = true, -) +): Device() data class RefreshTokenRequest( val clientId: String, diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt index aeb370bce..3bcae4eb3 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt @@ -10,6 +10,7 @@ import co.nilin.opex.auth.proxy.KeycloakProxy import co.nilin.opex.auth.proxy.OTPProxy import co.nilin.opex.common.OpexError import co.nilin.opex.common.security.JwtUtils +import co.nilin.opex.common.utils.LoggerDelegate import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -85,7 +86,7 @@ class TokenService( PRE_AUTH_CLIENT_SECRET_KEY, request.clientId ).apply { if (!request.rememberMe) refreshToken = null } - sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) + sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) return TokenResponse(token, null, null) } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt index 6f4ed3301..106efc4bd 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/AdminController.kt @@ -24,7 +24,7 @@ class AdminController( @GetMapping("/chain") suspend fun getChains(): List { return chainLoader.fetchAllChains() - .map { c -> ChainResponse(c.name, c.addressTypes.map { it.type }.getOrNull(0), c.externalChinScannerUrl, c.addressRegx) } + .map { c -> ChainResponse(c.name, c.addressTypes.map { it.type }.getOrNull(0), c.externalChinScannerUrl, c.addressRegex) } } @PostMapping("/chain") diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt index 32270d342..d74c4d4c2 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/controller/CryptoCurrencyController.kt @@ -70,7 +70,7 @@ class CryptoCurrencyController( @GetMapping("/chain") suspend fun getChains(): List { return chainLoader.fetchAllChains() - .map { c -> ChainResponse(c.name, c.addressTypes.map { it.type }.getOrNull(0), c.externalChinScannerUrl, c.addressRegx) } + .map { c -> ChainResponse(c.name, c.addressTypes.map { it.type }.getOrNull(0), c.externalChinScannerUrl, c.addressTypes.map { it.addressRegex }.getOrNull(0)) } } diff --git a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt index 437703338..1b6c98000 100644 --- a/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt +++ b/bc-gateway/bc-gateway-core/src/main/kotlin/co/nilin/opex/bcgateway/core/model/Chain.kt @@ -3,6 +3,4 @@ package co.nilin.opex.bcgateway.core.model data class Chain( val name: String, val addressTypes: List, - val externalChinScannerUrl: String? = null, - val addressRegx: String? = null -) + val externalChinScannerUrl: String? = null) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt index e75b2fc0b..c14bb6c08 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/impl/ChainHandler.kt @@ -44,7 +44,7 @@ class ChainHandler( .map { AddressType(it.id!!, it.type, it.addressRegex, it.memoRegex) } .toList() - Chain(c.name, addressTypes, c.externalChainScannerUrl, c.addressRegex) + Chain(c.name, addressTypes, c.externalChainScannerUrl) } } diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt index e8a83eadc..9c3d8c4b8 100644 --- a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/kotlin/co/nilin/opex/bcgateway/ports/postgres/model/ChainModel.kt @@ -4,4 +4,4 @@ import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Table @Table("chains") -data class ChainModel(@Id val name: String, val externalChainScannerUrl: String?, val addressRegex: String?) +data class ChainModel(@Id val name: String, val externalChainScannerUrl: String?) diff --git a/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regex_from_chain.sql b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regex_from_chain.sql new file mode 100644 index 000000000..8f8185f28 --- /dev/null +++ b/bc-gateway/bc-gateway-ports/bc-gateway-persister-postgres/src/main/resources/db/migration/V4__drop_address_regex_from_chain.sql @@ -0,0 +1 @@ +Alter table chains drop column address_regex; diff --git a/docker-compose.yml b/docker-compose.yml index 69af407ab..732733d72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -217,7 +217,7 @@ services: postgres-keycloak: <<: *postgres-db volumes: - - keycloak-data:/var/lib/postgresql/data/ + - keycloak-data-new:/var/lib/postgresql/data/ postgres-wallet: <<: *postgres-db volumes: @@ -610,7 +610,7 @@ volumes: accountant-data: eventlog-data: auth-data: - keycloak-data: + keycloak-data-new: wallet-data: market-data: api-data: From 476e48340e69791ebd8fd989ce15f3aa95a757bf Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Mon, 15 Dec 2025 14:16:23 +0330 Subject: [PATCH 3/9] Separate user services --- .../opex/auth/controller/AuthController.kt | 23 +- .../auth/controller/PublicUserController.kt | 23 +- ...UserController.kt => SessionController.kt} | 20 +- .../auth/service/ForgetPasswordService.kt | 70 +++++ .../{TokenService.kt => LoginService.kt} | 11 +- .../nilin/opex/auth/service/LogoutService.kt | 42 +++ .../opex/auth/service/RegisterService.kt | 128 ++++++++++ .../nilin/opex/auth/service/SessionService.kt | 14 + .../opex/auth/service/TempTokenService.kt | 49 ++++ .../co/nilin/opex/auth/service/UserService.kt | 239 ------------------ .../src/main/resources/application.yml | 1 + docker-compose.yml | 1 + 12 files changed, 349 insertions(+), 272 deletions(-) rename auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/{UserController.kt => SessionController.kt} (77%) create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt rename auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/{TokenService.kt => LoginService.kt} (95%) create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt delete mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/UserService.kt diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index ecfc6f940..573d7be06 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -1,39 +1,38 @@ package co.nilin.opex.auth.controller; -import co.nilin.opex.auth.model.ConfirmPasswordFlowTokenRequest -import co.nilin.opex.auth.model.ExternalIdpTokenRequest -import co.nilin.opex.auth.model.PasswordFlowTokenRequest -import co.nilin.opex.auth.model.RefreshTokenRequest -import co.nilin.opex.auth.model.TokenResponse -import co.nilin.opex.auth.service.TokenService +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.service.LoginService import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v1/oauth/protocol/openid-connect/") -class AuthController(private val tokenService: TokenService) { +class AuthController(private val loginService: LoginService) { @PostMapping("/token") suspend fun requestGetToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { - val tokenResponse = tokenService.requestGetToken(tokenRequest) + val tokenResponse = loginService.requestGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/token/confirm") suspend fun confirmGetToken(@RequestBody tokenRequest: ConfirmPasswordFlowTokenRequest): ResponseEntity { - val tokenResponse = tokenService.confirmGetToken(tokenRequest) + val tokenResponse = loginService.confirmGetToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/token-external") suspend fun getToken(@RequestBody tokenRequest: ExternalIdpTokenRequest): ResponseEntity { - val tokenResponse = tokenService.getToken(tokenRequest) + val tokenResponse = loginService.getToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } @PostMapping("/refresh") suspend fun refreshToken(@RequestBody tokenRequest: RefreshTokenRequest): ResponseEntity { - val tokenResponse = tokenService.refreshToken(tokenRequest) + val tokenResponse = loginService.refreshToken(tokenRequest) return ResponseEntity.ok().body(tokenResponse) } } diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt index 2e34b5659..c63d8dfde 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/PublicUserController.kt @@ -1,61 +1,64 @@ package co.nilin.opex.auth.controller import co.nilin.opex.auth.model.* -import co.nilin.opex.auth.service.UserService +import co.nilin.opex.auth.service.ForgetPasswordService +import co.nilin.opex.auth.service.RegisterService import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/v1/user/public") -class PublicUserController(private val userService: UserService) { +class PublicUserController( + private val forgetPasswordService: ForgetPasswordService, + private val registerService: RegisterService +) { //TODO IMPORTANT: remove in production @PostMapping("/register") suspend fun registerUser(@Valid @RequestBody request: RegisterUserRequest): ResponseEntity { - val otpResponse = userService.registerUser(request) + val otpResponse = registerService.registerUser(request) return ResponseEntity.ok().body(otpResponse) } @PostMapping("/register/verify") suspend fun verifyRegister(@RequestBody request: VerifyOTPRequest): ResponseEntity { - val token = userService.verifyRegister(request) + val token = registerService.verifyRegister(request) return ResponseEntity.ok(OTPActionTokenResponse(token)) } @PostMapping("/register/confirm") suspend fun confirmRegister(@RequestBody request: ConfirmRegisterRequest): ResponseEntity { - val loginToken = userService.confirmRegister(request) + val loginToken = registerService.confirmRegister(request) return ResponseEntity.ok(loginToken) } @PostMapping("/register-external") suspend fun registerExternal(@RequestBody request: ExternalIdpUserRegisterRequest): ResponseEntity { - userService.registerExternalIdpUser(request) + registerService.registerExternalIdpUser(request) return ResponseEntity.ok().build() } //TODO IMPORTANT: remove in production @PostMapping("/forget") suspend fun forgetPassword(@RequestBody request: ForgotPasswordRequest): ResponseEntity { - val otpResponse = userService.forgetPassword(request) + val otpResponse = forgetPasswordService.forgetPassword(request) return ResponseEntity.ok().body(otpResponse) } @PostMapping("/forget/verify") suspend fun verifyForget(@RequestBody request: VerifyOTPRequest): ResponseEntity { - val token = userService.verifyForget(request) + val token = forgetPasswordService.verifyForget(request) return ResponseEntity.ok(OTPActionTokenResponse(token)) } @PostMapping("/forget/confirm") suspend fun forgetPassword(@RequestBody request: ConfirmForgetRequest): ResponseEntity { - userService.confirmForget(request) + forgetPasswordService.confirmForget(request) return ResponseEntity.ok().build() } } \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt similarity index 77% rename from auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserController.kt rename to auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt index 9fac5f412..5fe06d1f3 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/SessionController.kt @@ -2,7 +2,9 @@ package co.nilin.opex.auth.controller import co.nilin.opex.auth.data.SessionRequest import co.nilin.opex.auth.data.Sessions -import co.nilin.opex.auth.service.UserService +import co.nilin.opex.auth.service.ForgetPasswordService +import co.nilin.opex.auth.service.LogoutService +import co.nilin.opex.auth.service.SessionService import co.nilin.opex.common.OpexError import co.nilin.opex.common.security.jwtAuthentication import org.springframework.security.core.annotation.CurrentSecurityContext @@ -11,14 +13,18 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/v1/user") -class UserController(private val userService: UserService) { +class SessionController( + private val forgetPasswordService: ForgetPasswordService, + private val logoutService: LogoutService, + private val sessionService: SessionService +) { @PostMapping("/logout") suspend fun logout(@CurrentSecurityContext securityContext: SecurityContext) { val userId = securityContext.jwtAuthentication().name val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? ?: throw OpexError.InvalidToken.exception() - userService.logout(userId, sid) + logoutService.logout(userId, sid) } @PostMapping("/session") @@ -30,13 +36,13 @@ class UserController(private val userService: UserService) { val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? ?: throw OpexError.InvalidToken.exception() sessionRequest.uuid = uuid - return userService.fetchActiveSessions(sessionRequest, sid) + return sessionService.fetchSessions(sessionRequest, sid) } @DeleteMapping("/session/{sessionId}") suspend fun logout(@CurrentSecurityContext securityContext: SecurityContext, @PathVariable sessionId: String) { val uuid = securityContext.authentication.name - userService.logoutSession(uuid, sessionId) + logoutService.logoutSession(uuid, sessionId) } @PostMapping("/session/delete-others") @@ -44,13 +50,13 @@ class UserController(private val userService: UserService) { val uuid = securityContext.authentication.name val sid = securityContext.jwtAuthentication().tokenAttributes["sid"] as String? ?: throw OpexError.InvalidToken.exception() - userService.logoutOthers(uuid, sid) + logoutService.logoutOthers(uuid, sid) } @PostMapping("/session/delete-all") suspend fun logoutAll(@CurrentSecurityContext securityContext: SecurityContext) { val uuid = securityContext.authentication.name - userService.logoutAll(uuid) + logoutService.logoutAll(uuid) } } diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt new file mode 100644 index 000000000..44dac2242 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt @@ -0,0 +1,70 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.ActionType +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.proxy.DeviceManagementProxy +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.proxy.OTPProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.stereotype.Service + +@Service +class ForgetPasswordService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + private val captchaHandler: CaptchaHandler, + private val authEventProducer: AuthEventProducer, + private val deviceManagementProxy: DeviceManagementProxy, + private val tempTokenService: TempTokenService +) { + + private val logger by LoggerDelegate() + + + + suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse { + captchaHandler.validateCaptchaWithActionCache( + username = request.username, + captchaCode = request.captchaCode, + captchaType = request.captchaType, + action = ActionType.FORGET + ) + val uName = Username.create(request.username) + val user = keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", null) + val otpReceiver = OTPReceiver(uName.value, uName.type.otpType) + //TODO IMPORTANT: remove in production + val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver)) + return TempOtpResponse(result.otp, otpReceiver) + } + + suspend fun verifyForget(request: VerifyOTPRequest): String { + val username = Username.create(request.username) + val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) + val otpResult = otpProxy.verifyOTP(otpRequest) + if (!otpResult.result) { + when (otpResult.type) { + OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() + else -> throw OpexError.InvalidOTP.exception() + } + } + return tempTokenService.generateToken(username.value, OTPAction.FORGET) + } + + suspend fun confirmForget(request: ConfirmForgetRequest) { + if (request.newPassword != request.newPasswordConfirmation) + throw OpexError.InvalidPassword.exception() + + val data = tempTokenService.verifyToken(request.token) + if (!data.isValid || data.action != OTPAction.FORGET) + throw OpexError.InvalidRegisterToken.exception() + + val username = Username.create(data.userId) + val user = keycloakProxy.findUserByUsername(username) ?: return + + keycloakProxy.resetPassword(user.id, request.newPassword) + } + + +} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt similarity index 95% rename from auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt rename to auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt index 3bcae4eb3..64441ee42 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TokenService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -11,19 +11,22 @@ import co.nilin.opex.auth.proxy.OTPProxy import co.nilin.opex.common.OpexError import co.nilin.opex.common.security.JwtUtils import co.nilin.opex.common.utils.LoggerDelegate +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.time.LocalDateTime @Service -class TokenService( +class LoginService( private val otpProxy: OTPProxy, private val keycloakProxy: KeycloakProxy, private val googleProxy: GoogleProxy, private val captchaHandler: CaptchaHandler, private val authEventProducer: AuthEventProducer, + @Value("\${app.pre-auth-client-secret}") + private val preAuthClientSecretKey: String, ) { private val logger by LoggerDelegate() - private val PRE_AUTH_CLIENT_SECRET_KEY = "pY1uVemXFIgVwubP1QM31YxRFh87NRp8" + private val PRE_AUTH_CLIENT_ID = "pre-auth-client" suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse { @@ -63,7 +66,7 @@ class TokenService( username, request.password, PRE_AUTH_CLIENT_ID, - PRE_AUTH_CLIENT_SECRET_KEY, + preAuthClientSecretKey, ).apply { if (!request.rememberMe) refreshToken = null } return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) @@ -83,7 +86,7 @@ class TokenService( val token = keycloakProxy.exchangeUserToken( request.token, PRE_AUTH_CLIENT_ID, - PRE_AUTH_CLIENT_SECRET_KEY, + preAuthClientSecretKey, request.clientId ).apply { if (!request.rememberMe) refreshToken = null } sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt new file mode 100644 index 000000000..38e7b2c15 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LogoutService.kt @@ -0,0 +1,42 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.LogoutEvent +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.proxy.KeycloakProxy +import org.springframework.stereotype.Service + +@Service +class LogoutService( + private val keycloakProxy: KeycloakProxy, + private val authEventProducer: AuthEventProducer, +) { + + suspend fun logout(userId: String, sessionId: String) { + keycloakProxy.logoutSession(userId, sessionId) + sendLogoutEvent(userId, sessionId) + } + + suspend fun logoutSession(uuid: String, sessionId: String) { + keycloakProxy.logoutSession(uuid, sessionId) + } + + suspend fun logoutOthers(uuid: String, currentSessionId: String) { + keycloakProxy.logoutOthers(uuid, currentSessionId) + sendLogoutEvent(uuid, currentSessionId, true) + } + + suspend fun logoutAll(uuid: String) { + keycloakProxy.logoutAll(uuid) + } + + + private fun sendLogoutEvent(userId: String, sessionState: String?, others: Boolean? = false) { + authEventProducer.send( + LogoutEvent( + userId, + sessionState, + others + ) + ) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt new file mode 100644 index 000000000..5ccd9afed --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/RegisterService.kt @@ -0,0 +1,128 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.ActionType +import co.nilin.opex.auth.data.Device +import co.nilin.opex.auth.data.LoginEvent +import co.nilin.opex.auth.data.UserCreatedEvent +import co.nilin.opex.auth.data.UserRole +import co.nilin.opex.auth.kafka.AuthEventProducer +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.proxy.GoogleProxy +import co.nilin.opex.auth.proxy.KeycloakProxy +import co.nilin.opex.auth.proxy.OTPProxy +import co.nilin.opex.common.OpexError +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class RegisterService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + private val captchaHandler: CaptchaHandler, + private val googleProxy: GoogleProxy, + private val authProducer: AuthEventProducer, + private val tempTokenService: TempTokenService + + ) { + //TODO IMPORTANT: remove in production + suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse { + captchaHandler.validateCaptchaWithActionCache( + username = request.username, + captchaCode = request.captchaCode, + captchaType = request.captchaType, + action = ActionType.REGISTER + ) + val username = Username.create(request.username) + val userStatus = isUserDuplicate(username) + + val otpType = username.type.otpType + val otpReceiver = OTPReceiver(request.username, otpType) + val res = otpProxy.requestOTP(request.username, listOf(otpReceiver)) + + if (!userStatus) + keycloakProxy.createUser( + username, + request.firstName, + request.lastName, + false + ) + return TempOtpResponse(res.otp, otpReceiver) + } + + suspend fun verifyRegister(request: VerifyOTPRequest): String { + val username = Username.create(request.username) + val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) + val otpResult = otpProxy.verifyOTP(otpRequest) + if (!otpResult.result) { + when (otpResult.type) { + OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() + else -> throw OpexError.InvalidOTP.exception() + } + } + return tempTokenService.generateToken(username.value, OTPAction.REGISTER) + } + + suspend fun confirmRegister(request: ConfirmRegisterRequest): Token? { + val data = tempTokenService.verifyToken(request.token) + if (!data.isValid || data.action != OTPAction.REGISTER) + throw OpexError.InvalidRegisterToken.exception() + + val username = Username.create(data.userId) + val user = keycloakProxy.findUserByUsername(username) + if (user == null || user.enabled) + throw OpexError.BadRequest.exception() + + keycloakProxy.confirmCreateUser(user, request.password) + keycloakProxy.assignRole(user.id, UserRole.LEVEL_1) + + // Send event to let other services know a user just registered + val event = UserCreatedEvent(user.id, user.username, user.email, user.mobile, user.firstName, user.lastName) + authProducer.send(event) + + return if (request.clientId.isNullOrBlank() || request.clientSecret.isNullOrBlank()) + null + else { + val token = keycloakProxy.getUserToken(username, request.password, request.clientId, request.clientSecret) + sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) + return token + } + } + + suspend fun registerExternalIdpUser(externalIdpUserRegisterRequest: ExternalIdpUserRegisterRequest) { + val decodedJWT = googleProxy.validateGoogleToken(externalIdpUserRegisterRequest.idToken) + val email = decodedJWT.getClaim("email").asString() + ?: throw OpexError.GmailNotFoundInToken.exception() + val googleUserId = decodedJWT.getClaim("sub").asString() + ?: throw OpexError.UserIDNotFoundInToken.exception() + + val username = Username.create(email) // Use email as the username + isUserDuplicate(username) + + val userId = keycloakProxy.createExternalIdpUser(email, username, externalIdpUserRegisterRequest.password) + keycloakProxy.linkGoogleIdentity(userId, email, googleUserId) + } + + private fun sendLoginEvent(userId: String, sessionState: String?, request: Device, expiresIn: Int) { + authProducer.send( + LoginEvent( + userId, + sessionState, + request.deviceUuid, + request.appVersion, + request.osVersion, + LocalDateTime.now().plusSeconds(expiresIn.toLong()), + request.os + ) + ) + } + + private suspend fun isUserDuplicate(username: Username): Boolean { + val user = keycloakProxy.findUserByUsername(username) + return if (user == null) + false + else if (!user.enabled) + return true + else + throw OpexError.UserAlreadyExists.exception() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt new file mode 100644 index 000000000..fe167ae37 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/SessionService.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.data.SessionRequest +import co.nilin.opex.auth.data.Sessions +import co.nilin.opex.auth.proxy.DeviceManagementProxy +import org.springframework.stereotype.Service + +@Service +class SessionService (private val deviceManagementProxy: DeviceManagementProxy){ + suspend fun fetchSessions(sessionRequest: SessionRequest, currentSessionId: String): List { + return deviceManagementProxy.getLastSessions(sessionRequest).stream() + .map { if (it.sessionState == currentSessionId) it.apply { isCurrentSession = true } else it }.toList() + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt new file mode 100644 index 000000000..38fcb91d2 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TempTokenService.kt @@ -0,0 +1,49 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.model.OTPAction +import co.nilin.opex.auth.model.TokenData +import co.nilin.opex.common.utils.LoggerDelegate +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import org.springframework.stereotype.Service +import java.security.PrivateKey +import java.security.PublicKey +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +@Service +class TempTokenService( + private val privateKey: PrivateKey, + private val publicKey: PublicKey, +) { + private val logger by LoggerDelegate() + + fun generateToken(userId: String, action: OTPAction): String { + val issuedAt = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()) + val exp = Date.from(LocalDateTime.now().plusMinutes(2).atZone(ZoneId.systemDefault()).toInstant()) + return Jwts.builder() + .issuer("opex-auth") + .claim("userId", userId) + .claim("action", action) + .issuedAt(issuedAt) + .expiration(exp) + .signWith(privateKey) + .compact() + } + + fun verifyToken(token: String): TokenData { + try { + val claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .payload + return TokenData(true, claims["userId"] as String, OTPAction.valueOf(claims["action"] as String)) + } catch (e: JwtException) { + logger.error("Could not verify token", e) + return TokenData(false, "", OTPAction.REGISTER) + } + } + +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/UserService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/UserService.kt deleted file mode 100644 index 3e31c8d7a..000000000 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/UserService.kt +++ /dev/null @@ -1,239 +0,0 @@ -package co.nilin.opex.auth.service - -import co.nilin.opex.auth.data.* -import co.nilin.opex.auth.kafka.AuthEventProducer -import co.nilin.opex.auth.model.* -import co.nilin.opex.auth.proxy.DeviceManagementProxy -import co.nilin.opex.auth.proxy.GoogleProxy -import co.nilin.opex.auth.proxy.KeycloakProxy -import co.nilin.opex.auth.proxy.OTPProxy -import co.nilin.opex.common.OpexError -import co.nilin.opex.common.utils.LoggerDelegate -import io.jsonwebtoken.JwtException -import io.jsonwebtoken.Jwts -import org.springframework.stereotype.Service -import java.security.PrivateKey -import java.security.PublicKey -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.* - -@Service -class UserService( - private val otpProxy: OTPProxy, - private val keycloakProxy: KeycloakProxy, - private val googleProxy: GoogleProxy, - private val privateKey: PrivateKey, - private val publicKey: PublicKey, - private val authProducer: AuthEventProducer, - private val captchaHandler: CaptchaHandler, - private val authEventProducer: AuthEventProducer, - private val deviceManagementProxy: DeviceManagementProxy -) { - - private val logger by LoggerDelegate() - - //TODO IMPORTANT: remove in production - suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse { - captchaHandler.validateCaptchaWithActionCache( - username = request.username, - captchaCode = request.captchaCode, - captchaType = request.captchaType, - action = ActionType.REGISTER - ) - val username = Username.create(request.username) - val userStatus = isUserDuplicate(username) - - val otpType = username.type.otpType - val otpReceiver = OTPReceiver(request.username, otpType) - val res = otpProxy.requestOTP(request.username, listOf(otpReceiver)) - - if (!userStatus) - keycloakProxy.createUser( - username, - request.firstName, - request.lastName, - false - ) - return TempOtpResponse(res.otp, otpReceiver) - } - - suspend fun verifyRegister(request: VerifyOTPRequest): String { - val username = Username.create(request.username) - val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) - val otpResult = otpProxy.verifyOTP(otpRequest) - if (!otpResult.result) { - when (otpResult.type) { - OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() - else -> throw OpexError.InvalidOTP.exception() - } - } - return generateToken(username.value, OTPAction.REGISTER) - } - - suspend fun confirmRegister(request: ConfirmRegisterRequest): Token? { - val data = verifyToken(request.token) - if (!data.isValid || data.action != OTPAction.REGISTER) - throw OpexError.InvalidRegisterToken.exception() - - val username = Username.create(data.userId) - val user = keycloakProxy.findUserByUsername(username) - if (user == null || user.enabled) - throw OpexError.BadRequest.exception() - - keycloakProxy.confirmCreateUser(user, request.password) - keycloakProxy.assignRole(user.id, UserRole.LEVEL_1) - - // Send event to let other services know a user just registered - val event = UserCreatedEvent(user.id, user.username, user.email, user.mobile, user.firstName, user.lastName) - authProducer.send(event) - - return if (request.clientId.isNullOrBlank() || request.clientSecret.isNullOrBlank()) - null - else { - val token = keycloakProxy.getUserToken(username, request.password, request.clientId, request.clientSecret) - sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) - return token - } - } - - suspend fun registerExternalIdpUser(externalIdpUserRegisterRequest: ExternalIdpUserRegisterRequest) { - val decodedJWT = googleProxy.validateGoogleToken(externalIdpUserRegisterRequest.idToken) - val email = decodedJWT.getClaim("email").asString() - ?: throw OpexError.GmailNotFoundInToken.exception() - val googleUserId = decodedJWT.getClaim("sub").asString() - ?: throw OpexError.UserIDNotFoundInToken.exception() - - val username = Username.create(email) // Use email as the username - isUserDuplicate(username) - - val userId = keycloakProxy.createExternalIdpUser(email, username, externalIdpUserRegisterRequest.password) - keycloakProxy.linkGoogleIdentity(userId, email, googleUserId) - } - - suspend fun logout(userId: String, sessionId: String) { - keycloakProxy.logoutSession(userId, sessionId) - sendLogoutEvent(userId, sessionId) - } - - suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse { - captchaHandler.validateCaptchaWithActionCache( - username = request.username, - captchaCode = request.captchaCode, - captchaType = request.captchaType, - action = ActionType.FORGET - ) - val uName = Username.create(request.username) - val user = keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", null) - val otpReceiver = OTPReceiver(uName.value, uName.type.otpType) - //TODO IMPORTANT: remove in production - val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver)) - return TempOtpResponse(result.otp, otpReceiver) - } - - suspend fun verifyForget(request: VerifyOTPRequest): String { - val username = Username.create(request.username) - val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) - val otpResult = otpProxy.verifyOTP(otpRequest) - if (!otpResult.result) { - when (otpResult.type) { - OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() - else -> throw OpexError.InvalidOTP.exception() - } - } - return generateToken(username.value, OTPAction.FORGET) - } - - suspend fun confirmForget(request: ConfirmForgetRequest) { - if (request.newPassword != request.newPasswordConfirmation) - throw OpexError.InvalidPassword.exception() - - val data = verifyToken(request.token) - if (!data.isValid || data.action != OTPAction.FORGET) - throw OpexError.InvalidRegisterToken.exception() - - val username = Username.create(data.userId) - val user = keycloakProxy.findUserByUsername(username) ?: return - - keycloakProxy.resetPassword(user.id, request.newPassword) - } - - suspend fun fetchActiveSessions(sessionRequest: SessionRequest, currentSessionId: String): List { - return deviceManagementProxy.getLastSessions(sessionRequest).stream() - .map { if (it.sessionState == currentSessionId) it.apply { isCurrentSession = true } else it }.toList() - } - - suspend fun logoutSession(uuid: String, sessionId: String) { - keycloakProxy.logoutSession(uuid, sessionId) - } - - suspend fun logoutOthers(uuid: String, currentSessionId: String) { - keycloakProxy.logoutOthers(uuid, currentSessionId) - sendLogoutEvent(uuid, currentSessionId, true) - } - - suspend fun logoutAll(uuid: String) { - keycloakProxy.logoutAll(uuid) - } - - private suspend fun isUserDuplicate(username: Username): Boolean { - val user = keycloakProxy.findUserByUsername(username) - return if (user == null) - false - else if (!user.enabled) - return true - else - throw OpexError.UserAlreadyExists.exception() - } - - private fun generateToken(userId: String, action: OTPAction): String { - val issuedAt = Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()) - val exp = Date.from(LocalDateTime.now().plusMinutes(2).atZone(ZoneId.systemDefault()).toInstant()) - return Jwts.builder() - .issuer("opex-auth") - .claim("userId", userId) - .claim("action", action) - .issuedAt(issuedAt) - .expiration(exp) - .signWith(privateKey) - .compact() - } - - private fun verifyToken(token: String): TokenData { - try { - val claims = Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(token) - .payload - return TokenData(true, claims["userId"] as String, OTPAction.valueOf(claims["action"] as String)) - } catch (e: JwtException) { - logger.error("Could not verify token", e) - return TokenData(false, "", OTPAction.REGISTER) - } - } - - private fun sendLogoutEvent(userId: String, sessionState: String?, others: Boolean? = false) { - authEventProducer.send( - LogoutEvent( - userId, - sessionState, - others - ) - ) - } - - private fun sendLoginEvent(userId: String, sessionState: String?, request: Device, expiresIn: Int) { - authEventProducer.send( - LoginEvent( - userId, - sessionState, - request.deviceUuid, - request.appVersion, - request.osVersion, - LocalDateTime.now().plusSeconds(expiresIn.toLong()), - request.os - ) - ) - } -} diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml index 50db513e2..e7f9a7be4 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -76,3 +76,4 @@ app: custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} + pre-auth-client-secret: ${PRE_AUTH_CLIENT_SECRET} diff --git a/docker-compose.yml b/docker-compose.yml index 732733d72..4c9e9dbf2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -400,6 +400,7 @@ services: - KAFKA_IP_PORT=kafka-1:29092,kafka-2:29092,kafka-3:29092 - CONSUL_HOST=consul - ADMIN_CLIENT_SECRET=${KC_ADMIN_CLIENT_SECRET} + - PRE_AUTH_CLIENT_SECRET= ${KC_PRE_AUTH_CLIENT_SECRET} volumes: - auth-gateway-keys:/app/keys depends_on: From abd24c85e11b82c4dceef5e94b68e7eb403efb7e Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Tue, 16 Dec 2025 18:46:19 +0330 Subject: [PATCH 4/9] Check audience and issuer in any security configuration --- .../src/main/resources/application.yml | 1 + .../ports/binance/config/SecurityConfig.kt | 13 ++++--- .../ports/binance/util/AudienceValidator.kt | 29 +++++++++++++++ .../nilin/opex/auth/config/KeycloakConfig.kt | 1 + .../nilin/opex/auth/config/SecurityConfig.kt | 2 +- .../opex/auth/utils/AudienceValidator.kt | 2 +- .../src/main/resources/application.yml | 1 + .../bcgateway/app/config/SecurityConfig.kt | 37 ++++++++++++++++++- .../bcgateway/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../src/main/resources/application-otc.yml | 1 + .../src/main/resources/application.yml | 1 + .../opex/market/app/config/SecurityConfig.kt | 26 ++++++++++++- .../market/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../src/main/resources/application.yml | 1 + .../gateway/app/config/SecurityConfig.kt | 27 ++++++++++++-- .../gateway/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../src/main/resources/application.yml | 1 + .../opex/otp/app/config/SecurityConfig.kt | 25 ++++++++++++- .../opex/otp/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../src/main/resources/application.yml | 8 ++++ .../opex/profile/app/config/SecurityConfig.kt | 25 ++++++++++++- .../profile/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../opex/wallet/app/config/SecurityConfig.kt | 35 +++++++++++++++++- .../wallet/app/utils/AudienceValidator.kt | 29 +++++++++++++++ .../src/main/resources/application-otc.yml | 1 + .../src/main/resources/application.yml | 1 + 26 files changed, 392 insertions(+), 20 deletions(-) create mode 100644 api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt create mode 100644 bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt create mode 100644 market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt create mode 100644 matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt create mode 100644 otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt create mode 100644 profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt create mode 100644 wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/utils/AudienceValidator.kt diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 9b9d9a00a..1b536a8fb 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -111,6 +111,7 @@ app: url: http://opex-bc-gateway auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex token-url: http://keycloak:8080/realms/opex/protocol/openid-connect/token api-key-client: secret: ${API_KEY_CLIENT_SECRET} diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt index 91e14d102..b414ea3a8 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt @@ -1,7 +1,7 @@ package co.nilin.opex.api.ports.binance.config import co.nilin.opex.api.core.spi.APIKeyFilter -import co.nilin.opex.common.security.ReactiveAudienceValidator +import co.nilin.opex.api.ports.binance.util.AudienceValidator import co.nilin.opex.common.security.ReactiveCustomJwtConverter import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean @@ -23,7 +23,9 @@ import org.springframework.web.server.WebFilter class SecurityConfig( private val apiKeyFilter: APIKeyFilter, @Value("\${app.auth.cert-url}") - private val jwkUrl: String + private val certUrl: String, + @Value("\${app.auth.iss-url}") + private val issUrl: String ) { @Bean @@ -66,11 +68,11 @@ class SecurityConfig( @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() - val issuerValidator = JwtValidators.createDefaultWithIssuer(jwkUrl) - val audienceValidator = ReactiveAudienceValidator( + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( setOf( "ios-app", "web-app", @@ -86,4 +88,5 @@ class SecurityConfig( ) return decoder } + } diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt new file mode 100644 index 000000000..eb6aa7a94 --- /dev/null +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.api.ports.binance.util + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt index 1ed74107a..f947b4d4a 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/KeycloakConfig.kt @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component class KeycloakConfig { lateinit var url: String lateinit var certUrl: String + lateinit var issUrl: String lateinit var realm: String lateinit var adminClient: Client } diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt index 0403fa392..813e1e101 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt @@ -50,7 +50,7 @@ class SecurityConfig( val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(keycloakConfig.certUrl) .webClient(webClient) .build() - val issuerValidator = JwtValidators.createDefaultWithIssuer(keycloakConfig.certUrl) + val issuerValidator = JwtValidators.createDefaultWithIssuer(keycloakConfig.issUrl) val audienceValidator = AudienceValidator( setOf( "ios-app", diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt index aa7a1559e..3208d9324 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/utils/AudienceValidator.kt @@ -12,7 +12,7 @@ class AudienceValidator( override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { val tokenAudiences = jwt.audience - val matched = tokenAudiences.any { it in allowedAudiences } + val matched = tokenAudiences.any() { it in allowedAudiences } return if (matched) { OAuth2TokenValidatorResult.success() diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml index e7f9a7be4..17c8e120a 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -60,6 +60,7 @@ logging: keycloak: url: http://keycloak:8080 cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex realm: opex admin-client: id: "opex-admin" diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt index 5644524a3..eee949f3c 100644 --- a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package co.nilin.opex.bcgateway.app.config +import co.nilin.opex.bcgateway.app.utils.AudienceValidator import co.nilin.opex.bcgateway.app.utils.hasRoleAndLevel import co.nilin.opex.common.security.ReactiveCustomJwtConverter import org.springframework.beans.factory.annotation.Value @@ -8,6 +9,8 @@ import org.springframework.context.annotation.Profile import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -17,7 +20,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean @Profile("!otc") @@ -78,13 +83,41 @@ class SecurityConfig(private val webClient: WebClient) { return http.build() } + + @Bean + @Profile("!otc") @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } + @Bean + @Profile("otc") + @Throws(Exception::class) + fun otcReactiveJwtDecoder(): ReactiveJwtDecoder? { + return NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + } + } diff --git a/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..4104865e9 --- /dev/null +++ b/bc-gateway/bc-gateway-app/src/main/kotlin/co/nilin/opex/bcgateway/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.bcgateway.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml b/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml index ce787c9fa..a0d9063bc 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application-otc.yml @@ -83,6 +83,7 @@ app: auth: url: ${auth_url} cert-url: ${auth_jwk_endpoint} + iss-url: client-id: ${client_id} client-secret: ${client_secret} wallet: diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application.yml b/bc-gateway/bc-gateway-app/src/main/resources/application.yml index e4155f7ac..d0b505f5f 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application.yml @@ -116,6 +116,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex client-id: none client-secret: none wallet: diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt index 376cf5356..bcb8b2a2f 100644 --- a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/config/SecurityConfig.kt @@ -1,11 +1,14 @@ package co.nilin.opex.market.app.config import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.market.app.utils.AudienceValidator import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -15,7 +18,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { @@ -29,11 +34,28 @@ class SecurityConfig(private val webClient: WebClient) { .build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } } diff --git a/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..1de6b907c --- /dev/null +++ b/market/market-app/src/main/kotlin/co/nilin/opex/market/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.market.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/market/market-app/src/main/resources/application.yml b/market/market-app/src/main/resources/application.yml index 6b0188138..b66fb5427 100644 --- a/market/market-app/src/main/resources/application.yml +++ b/market/market-app/src/main/resources/application.yml @@ -92,6 +92,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt index b7bea8a0e..5132c5c21 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/config/SecurityConfig.kt @@ -1,12 +1,15 @@ package co.nilin.opex.matching.gateway.app.config import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.matching.gateway.app.utils.AudienceValidator import co.nilin.opex.matching.gateway.app.utils.hasRole import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -16,8 +19,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String - + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { http.csrf().disable() @@ -35,11 +39,28 @@ class SecurityConfig(private val webClient: WebClient) { return http.build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } } diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..6ce28244d --- /dev/null +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.matching.gateway.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/resources/application.yml b/matching-gateway/matching-gateway-app/src/main/resources/application.yml index dfbbc2f99..28f2c710b 100644 --- a/matching-gateway/matching-gateway-app/src/main/resources/application.yml +++ b/matching-gateway/matching-gateway-app/src/main/resources/application.yml @@ -93,6 +93,7 @@ app: url: lb://opex-accountant auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt index ae5337bd5..26a509525 100644 --- a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/config/SecurityConfig.kt @@ -1,5 +1,6 @@ package co.nilin.opex.otp.app.config +import co.nilin.opex.otp.app.utils.AudienceValidator import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Profile @@ -7,6 +8,8 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -17,7 +20,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { @@ -33,12 +38,28 @@ class SecurityConfig(private val webClient: WebClient) { return http.build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(webClient) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } @Bean diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..2a3c2d9c6 --- /dev/null +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.otp.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/otp/otp-app/src/main/resources/application.yml b/otp/otp-app/src/main/resources/application.yml index 1c7ed0f77..560147ab4 100644 --- a/otp/otp-app/src/main/resources/application.yml +++ b/otp/otp-app/src/main/resources/application.yml @@ -8,6 +8,13 @@ spring: username: ${DB_USER} password: ${DB_PASS} initialization-mode: always + pool: + enabled: true + initial-size: 5 + max-size: 20 + max-idle-time: 60s + validation-query: SELECT 1 + # initialization-mode: always cloud: bootstrap: enabled: true @@ -39,6 +46,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex otp: sms: provider: diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt index cd8677e8a..936bf4368 100644 --- a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/config/SecurityConfig.kt @@ -1,11 +1,14 @@ package co.nilin.opex.profile.app.config import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.profile.app.utils.AudienceValidator import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -15,7 +18,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { @@ -31,11 +36,27 @@ class SecurityConfig { .build() } + @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } } diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..2cd1b0038 --- /dev/null +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.profile.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/config/SecurityConfig.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/config/SecurityConfig.kt index 99abcadbf..f9619d437 100644 --- a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/config/SecurityConfig.kt +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/config/SecurityConfig.kt @@ -1,6 +1,7 @@ package co.nilin.opex.wallet.app.config import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import co.nilin.opex.wallet.app.utils.AudienceValidator import co.nilin.opex.wallet.app.utils.hasRoleAndLevel import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean @@ -8,6 +9,8 @@ import org.springframework.context.annotation.Profile import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator +import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain @@ -18,7 +21,9 @@ import org.springframework.web.reactive.function.client.WebClient class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") - private lateinit var jwkUrl: String + private lateinit var certUrl: String + @Value("\${app.auth.iss-url}") + private lateinit var issUrl: String @Bean @Profile("!otc") @@ -111,11 +116,37 @@ class SecurityConfig(private val webClient: WebClient) { @Bean + @Profile("otc") + @Throws(Exception::class) + fun otcReactiveJwtDecoder(): ReactiveJwtDecoder? { + return NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(WebClient.create()) + .build() + } + + @Bean + @Profile("!otc") @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl) + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder } diff --git a/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/utils/AudienceValidator.kt b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/utils/AudienceValidator.kt new file mode 100644 index 000000000..d5fa9fbdf --- /dev/null +++ b/wallet/wallet-app/src/main/kotlin/co/nilin/opex/wallet/app/utils/AudienceValidator.kt @@ -0,0 +1,29 @@ +package co.nilin.opex.wallet.app.utils + +import org.springframework.security.oauth2.core.OAuth2Error +import org.springframework.security.oauth2.core.OAuth2TokenValidator +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult +import org.springframework.security.oauth2.jwt.Jwt + +class AudienceValidator( + private val allowedAudiences: Set +) : OAuth2TokenValidator { + + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + val tokenAudiences = jwt.audience + + val matched = tokenAudiences.any() { it in allowedAudiences } + + return if (matched) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure( + OAuth2Error( + "invalid_token", + "Invalid audience", + null + ) + ) + } + } +} \ No newline at end of file diff --git a/wallet/wallet-app/src/main/resources/application-otc.yml b/wallet/wallet-app/src/main/resources/application-otc.yml index 5b118364a..47217af27 100644 --- a/wallet/wallet-app/src/main/resources/application-otc.yml +++ b/wallet/wallet-app/src/main/resources/application-otc.yml @@ -82,6 +82,7 @@ app: auth: url: ${AUTH_URL} cert-url: ${AUTH_JWK_ENDPOINT} + iss-url: client-id: ${client_id} client-secret: ${client_secret} system: diff --git a/wallet/wallet-app/src/main/resources/application.yml b/wallet/wallet-app/src/main/resources/application.yml index 3712bafe0..da3db544b 100644 --- a/wallet/wallet-app/src/main/resources/application.yml +++ b/wallet/wallet-app/src/main/resources/application.yml @@ -125,6 +125,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: http://keycloak:8080/realms/opex client-id: none client-secret: none system: From c8ab94ebeed54bfda08ff2c6d7a330de000df6c8 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Wed, 17 Dec 2025 20:03:47 +0330 Subject: [PATCH 5/9] Read the token issuer url from the env --- api/api-app/src/main/resources/application.yml | 2 +- .../main/kotlin/co/nilin/opex/auth/model/Token.kt | 13 +++++++++++++ .../co/nilin/opex/auth/service/LoginService.kt | 3 ++- .../src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- .../main/kotlin/co/nilin/opex/common/OpexError.kt | 1 - docker-compose.yml | 9 ++++++++- .../market-app/src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- otp/otp-app/src/main/resources/application.yml | 2 +- .../profile/app/service/AddressBookManagement.kt | 2 ++ .../profile-app/src/main/resources/application.yml | 1 + .../opex/profile/core/spi/AddressBookPersister.kt | 6 ++++-- .../ports/postgres/dao/AddressBookRepository.kt | 2 ++ .../ports/postgres/imp/AddressBookManagementImp.kt | 4 ++++ .../wallet-app/src/main/resources/application.yml | 2 +- 16 files changed, 43 insertions(+), 12 deletions(-) diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 1b536a8fb..36932fc1c 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -111,7 +111,7 @@ app: url: http://opex-bc-gateway auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex token-url: http://keycloak:8080/realms/opex/protocol/openid-connect/token api-key-client: secret: ${API_KEY_CLIENT_SECRET} diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt index afc573804..817b5a2ef 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt @@ -22,6 +22,13 @@ data class ConfirmPasswordFlowTokenRequest( val rememberMe: Boolean = true, ): Device() +data class ResendOtpRequest( + val username: String, + val token: String, + val clientId: String +) + + data class RefreshTokenRequest( val clientId: String, val clientSecret: String?, @@ -72,4 +79,10 @@ data class TokenResponse( data class RequiredOTP( val type: OTPType, val receiver: String? +) + +data class ResendOtpResponse( + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, ) \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt index 6470faa0a..b1b54ee01 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -76,6 +76,7 @@ class LoginService( return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) } + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { val username = Username.create(request.username) val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) @@ -117,7 +118,7 @@ class LoginService( suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse { val token = keycloakProxy.refreshUserToken(request.refreshToken, request.clientId, request.clientSecret) - sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request,token.expiresIn) + sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) return TokenResponse(token, null, null) } diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml index 17c8e120a..fb2189bd9 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -60,7 +60,7 @@ logging: keycloak: url: http://keycloak:8080 cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex realm: opex admin-client: id: "opex-admin" diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application.yml b/bc-gateway/bc-gateway-app/src/main/resources/application.yml index d0b505f5f..a3ae81c7f 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application.yml @@ -116,7 +116,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex client-id: none client-secret: none wallet: diff --git a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt index a743493fd..8e70e737e 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -194,7 +194,6 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus BankAccountAlreadyExist(13046, "Bank account already exist", HttpStatus.BAD_REQUEST), BankAccountNotFound(13047, "Bank account not found", HttpStatus.NOT_FOUND), AddressBookNotFound(13048, "Address book not found", HttpStatus.NOT_FOUND) - ; override fun code() = this.code diff --git a/docker-compose.yml b/docker-compose.yml index 97807a88f..97ae0503b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,6 @@ services: - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER - KAFKA_LISTENERS=CLIENT://kafka-2:29092,EXTERNAL://kafka-2:9092,CONTROLLER://kafka-2:29093 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CLIENT:PLAINTEXT,EXTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT - networks: - default deploy: @@ -351,6 +350,7 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SYMBOLS=BTC_USDT,ETH_USDT,BTC_IRT,ETH_IRT,USDT_IRT,ETH_BUSD,BTC_BUSD,BNB_BUSD + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} networks: - default depends_on: @@ -404,6 +404,7 @@ services: - CONSUL_HOST=consul - ADMIN_CLIENT_SECRET=${KC_ADMIN_CLIENT_SECRET} - PRE_AUTH_CLIENT_SECRET=${KC_PRE_AUTH_CLIENT_SECRET} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} volumes: - auth-gateway-keys:/app/keys depends_on: @@ -432,6 +433,7 @@ services: - WITHDRAW_OTP_REQUIRED_COUNT=${WITHDRAW_OTP_REQUIRED_COUNT} - WITHDRAW_BANK_ACCOUNT_VALIDATION=${WITHDRAW_BANK_ACCOUNT_VALIDATION} - TOTAL_ASSET_CALCULATION_CURRENCY=${TOTAL_ASSET_CALCULATION_CURRENCY} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - kafka-1 - kafka-2 @@ -456,6 +458,7 @@ services: - BACKEND_USER=${BACKEND_USER} - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - kafka-1 - kafka-2 @@ -484,6 +487,7 @@ services: - TRADE_VOLUME_CALCULATION_CURRENCY=${TRADE_VOLUME_CALCULATION_CURRENCY} - WITHDRAW_VOLUME_CALCULATION_CURRENCY=${WITHDRAW_VOLUME_CALCULATION_CURRENCY} - TOTAL_ASSET_CALCULATION_CURRENCY=${TOTAL_ASSET_CALCULATION_CURRENCY} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - consul - vault @@ -506,6 +510,7 @@ services: - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - ADDRESS_EXP_TIME=100 + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - kafka-1 - kafka-2 @@ -536,6 +541,7 @@ services: - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - consul - postgres-otp @@ -561,6 +567,7 @@ services: - JIBIT_SECRET_KEY=${JIBIT_SECRET_KEY} - ADMIN_APPROVAL_PROFILE_COMPLETION_REQUEST=${ADMIN_APPROVAL_PROFILE_COMPLETION_REQUEST} - ADMIN_APPROVAL_BANK_ACCOUNT=${ADMIN_APPROVAL_BANK_ACCOUNT} + - TOKEN_ISSUER_URL=${KC_ISSUER_URL} depends_on: - kafka-1 - kafka-2 diff --git a/market/market-app/src/main/resources/application.yml b/market/market-app/src/main/resources/application.yml index b66fb5427..1d8fa28db 100644 --- a/market/market-app/src/main/resources/application.yml +++ b/market/market-app/src/main/resources/application.yml @@ -92,7 +92,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/matching-gateway/matching-gateway-app/src/main/resources/application.yml b/matching-gateway/matching-gateway-app/src/main/resources/application.yml index 28f2c710b..7f8e263c3 100644 --- a/matching-gateway/matching-gateway-app/src/main/resources/application.yml +++ b/matching-gateway/matching-gateway-app/src/main/resources/application.yml @@ -93,7 +93,7 @@ app: url: lb://opex-accountant auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/otp/otp-app/src/main/resources/application.yml b/otp/otp-app/src/main/resources/application.yml index 560147ab4..630b2d1c3 100644 --- a/otp/otp-app/src/main/resources/application.yml +++ b/otp/otp-app/src/main/resources/application.yml @@ -46,7 +46,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex otp: sms: provider: diff --git a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt index c09c7080e..61e47ab31 100644 --- a/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt +++ b/profile/profile-app/src/main/kotlin/co/nilin/opex/profile/app/service/AddressBookManagement.kt @@ -13,6 +13,8 @@ class AddressBookManagement( private val addressBookPersister: AddressBookPersister, ) { suspend fun addAddressBook(uuid: String, request: AddAddressBookItemRequest): AddressBookResponse { + addressBookPersister.findSavedAddress(uuid, request.address, request.addressType) + ?.let { return it.toAddressBookResponse() } return addressBookPersister.save( AddressBook( uuid = uuid, diff --git a/profile/profile-app/src/main/resources/application.yml b/profile/profile-app/src/main/resources/application.yml index db119110d..5225edd91 100644 --- a/profile/profile-app/src/main/resources/application.yml +++ b/profile/profile-app/src/main/resources/application.yml @@ -55,6 +55,7 @@ app: bank-account: ${ADMIN_APPROVAL_BANK_ACCOUNT:false} auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex kyc: url: lb://opex-kyc/v2/admin/kyc/internal otp: diff --git a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt index a80470b63..6df4c0a66 100644 --- a/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt +++ b/profile/profile-core/src/main/kotlin/co/nilin/opex/profile/core/spi/AddressBookPersister.kt @@ -4,8 +4,10 @@ import co.nilin.opex.profile.core.data.profile.AddressBook interface AddressBookPersister { - suspend fun save(addressBook: AddressBook) : AddressBook + suspend fun save(addressBook: AddressBook): AddressBook suspend fun findAll(uuid: String): List - suspend fun update(addressBook: AddressBook) : AddressBook + suspend fun update(addressBook: AddressBook): AddressBook suspend fun delete(uuid: String, id: Long) + suspend fun findSavedAddress(uuid: String, address: String, adressType: String): AddressBook? + } \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt index 4c24ba79d..93d4ddaeb 100644 --- a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/dao/AddressBookRepository.kt @@ -6,11 +6,13 @@ import org.springframework.data.r2dbc.repository.Query import org.springframework.data.repository.reactive.ReactiveCrudRepository import org.springframework.stereotype.Repository import reactor.core.publisher.Flux +import reactor.core.publisher.Mono @Repository interface AddressBookRepository : ReactiveCrudRepository { @Query("select * from address_book where uuid = :uuid") suspend fun findAllByUuid(uuid: String): Flux + suspend fun findByUuidAndAddressAndAddressType(uuid: String, address: String, type: String): Mono? } \ No newline at end of file diff --git a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt index 4936c8add..051047cb7 100644 --- a/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt +++ b/profile/profile-ports/profile-postgres/src/main/kotlin/co/nilin/opex/profile/ports/postgres/imp/AddressBookManagementImp.kt @@ -51,4 +51,8 @@ class AddressBookManagementImp( addressBookRepository.deleteById(id).awaitFirstOrNull() else throw OpexError.Forbidden.exception() } + + override suspend fun findSavedAddress(uuid: String, address: String, addressType: String): AddressBook? { + return addressBookRepository.findByUuidAndAddressAndAddressType(uuid, address, addressType)?.awaitFirstOrNull() + } } \ No newline at end of file diff --git a/wallet/wallet-app/src/main/resources/application.yml b/wallet/wallet-app/src/main/resources/application.yml index da3db544b..83c8b6e29 100644 --- a/wallet/wallet-app/src/main/resources/application.yml +++ b/wallet/wallet-app/src/main/resources/application.yml @@ -125,7 +125,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: http://keycloak:8080/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex client-id: none client-secret: none system: From 569ff5af93ace2ee0c95a1baf905e7928f71df00 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sun, 21 Dec 2025 19:40:59 +0330 Subject: [PATCH 6/9] Develop resend otp in login flow --- .../nilin/opex/auth/config/SecurityConfig.kt | 60 ++++++++++++++++--- .../opex/auth/controller/AuthController.kt | 11 ++++ .../kotlin/co/nilin/opex/auth/model/Token.kt | 3 +- .../co/nilin/opex/auth/proxy/KeycloakProxy.kt | 8 +-- .../auth/service/ForgetPasswordService.kt | 2 +- .../nilin/opex/auth/service/LoginService.kt | 27 +++++++-- .../kotlin/co/nilin/opex/common/OpexError.kt | 2 +- 7 files changed, 93 insertions(+), 20 deletions(-) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt index 813e1e101..5b2a71194 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/config/SecurityConfig.kt @@ -1,22 +1,23 @@ package co.nilin.opex.auth.config import co.nilin.opex.auth.utils.AudienceValidator +import jakarta.enterprise.inject.Default import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpMethod +import org.springframework.context.annotation.Primary +import org.springframework.core.annotation.Order +import org.springframework.security.authorization.AuthorizationDecision import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator -import org.springframework.security.oauth2.core.OAuth2Error -import org.springframework.security.oauth2.core.OAuth2TokenValidator -import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult -import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.JwtValidators import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers import org.springframework.web.reactive.function.client.WebClient @EnableWebFluxSecurity @@ -28,10 +29,10 @@ class SecurityConfig( ) { @Bean + @Order(2) fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.csrf { it.disable() } - .authorizeExchange { - it.pathMatchers("/actuator/**").permitAll() + .authorizeExchange {it.pathMatchers("/actuator/**").permitAll() .pathMatchers("/v1/oauth/protocol/openid-connect/**").permitAll() .pathMatchers("/v1/oauth.***").permitAll() .pathMatchers("/v1/user/public/**").permitAll() @@ -42,10 +43,31 @@ class SecurityConfig( .build() } + @Bean + @Order(1) + fun preAuthSecurityChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .securityMatcher( + ServerWebExchangeMatchers.pathMatchers( + "/v1/oauth/protocol/openid-connect/token/resend-otp" + ) + ) + .csrf { it.disable() } + .authorizeExchange { + it.anyExchange().authenticated() + } + .oauth2ResourceServer { it -> + it.jwt { + it.jwtDecoder(preAuthJwtDecoder()) + } + } + .build() + } @Bean @Throws(Exception::class) + @Primary fun reactiveJwtDecoder(): ReactiveJwtDecoder? { val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(keycloakConfig.certUrl) .webClient(webClient) @@ -56,7 +78,29 @@ class SecurityConfig( "ios-app", "web-app", "android-app", - "opex-api-key" + "opex-api-key", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } + + + @Bean("preAuthJwtDecoder") + @Throws(Exception::class) + fun preAuthJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(keycloakConfig.certUrl) + .webClient(webClient) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(keycloakConfig.issUrl) + val audienceValidator = AudienceValidator( + setOf( + "pre-auth-client", ) ) decoder.setJwtValidator( diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index 573d7be06..8f75fbb88 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -3,6 +3,8 @@ package co.nilin.opex.auth.controller; import co.nilin.opex.auth.model.* import co.nilin.opex.auth.service.LoginService import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping @@ -24,6 +26,15 @@ class AuthController(private val loginService: LoginService) { return ResponseEntity.ok().body(tokenResponse) } + @PostMapping("/token/resend-otp") + suspend fun confirmGetToken( + @RequestBody resendOtpRequest: ResendOtpRequest, + @CurrentSecurityContext securityContext: SecurityContext, + ): ResponseEntity { + val response = loginService.resendLoginOtp(resendOtpRequest, securityContext.authentication.name) + return ResponseEntity.ok().body(response) + } + @PostMapping("/token-external") suspend fun getToken(@RequestBody tokenRequest: ExternalIdpTokenRequest): ResponseEntity { val tokenResponse = loginService.getToken(tokenRequest) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt index 817b5a2ef..fe619e620 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/Token.kt @@ -24,7 +24,6 @@ data class ConfirmPasswordFlowTokenRequest( data class ResendOtpRequest( val username: String, - val token: String, val clientId: String ) @@ -50,7 +49,7 @@ data class Token( val expiresIn: Int, // Expiration time of the access token in seconds @JsonProperty("refresh_expires_in") - val refreshExpiresIn: Int?, // Expiration time of the refresh token in seconds + var refreshExpiresIn: Int?, // Expiration time of the refresh token in seconds @JsonProperty("refresh_token") var refreshToken: String?, // The refresh token diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt index 0b1d6b964..af8c13854 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt @@ -1,7 +1,6 @@ package co.nilin.opex.auth.proxy import co.nilin.opex.auth.config.KeycloakConfig -import co.nilin.opex.auth.data.Sessions import co.nilin.opex.auth.data.UserRole import co.nilin.opex.auth.model.* import co.nilin.opex.auth.utils.generateRandomID @@ -65,6 +64,7 @@ class KeycloakProxy( } .awaitBody() } + suspend fun exchangeUserToken( token: String, clientId: String, @@ -82,17 +82,17 @@ class KeycloakProxy( "&grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + "&subject_token=${token}" + "&audience=${targetClientId}" + - "&requested_token_type=urn:ietf:params:oauth:token-type:access_token" + "&scope=offline_access" + + "&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token" ) .retrieve() .onStatus({ it == HttpStatus.valueOf(401) }) { - throw OpexError.InvalidUserCredentials.exception() + throw OpexError.UsernameOrPasswordIsIncorrect.exception() } .awaitBody() } - suspend fun checkUserCredentials(user: KeycloakUser, password: String) { keycloakClient.post() .uri("${keycloakConfig.url}/realms/${keycloakConfig.realm}/password/validate") diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt index 44dac2242..67a42bf0d 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/ForgetPasswordService.kt @@ -32,8 +32,8 @@ class ForgetPasswordService( action = ActionType.FORGET ) val uName = Username.create(request.username) - val user = keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", null) val otpReceiver = OTPReceiver(uName.value, uName.type.otpType) + val user = keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", otpReceiver) //TODO IMPORTANT: remove in production val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver)) return TempOtpResponse(result.otp, otpReceiver) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt index b1b54ee01..bdc7aaca1 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -37,8 +37,8 @@ class LoginService( action = ActionType.LOGIN ) val username = Username.create(request.username) - val user = keycloakProxy.findUserByUsername(username) ?: throw OpexError.UserNotFound.exception() - + val user = + keycloakProxy.findUserByUsername(username) ?: throw OpexError.UsernameOrPasswordIsIncorrect.exception() val otpTypes = (user.attributes?.get(Attributes.OTP)?.get(0) ?: OTPType.NONE.name).split(",") if (otpTypes.contains(OTPType.NONE.name)) { @@ -58,7 +58,10 @@ class LoginService( request.password, PRE_AUTH_CLIENT_ID, preAuthClientSecretKey, - ).apply { if (!request.rememberMe) refreshToken = null } + ).apply { + refreshToken = null + refreshExpiresIn = 0 + } val usernameType = username.type.otpType @@ -76,6 +79,22 @@ class LoginService( return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) } + suspend fun resendLoginOtp(request: ResendOtpRequest, uuid: String): ResendOtpResponse { + val username = Username.create(request.username) + val usernameType = username.type.otpType + val user = keycloakProxy.findUserByUsername(username) ?: throw OpexError.UserNotFound.exception() + if (user.id != uuid) throw OpexError.UnAuthorized.exception() + val requiredOtpTypes = listOf(OTPReceiver(username.value, usernameType)) + val res = otpProxy.requestOTP(request.username, requiredOtpTypes) + val receiver = when (usernameType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } + return ResendOtpResponse(RequiredOTP(usernameType, receiver), res.otp) + + } + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { val username = Username.create(request.username) @@ -107,7 +126,7 @@ class LoginService( try { keycloakProxy.findUserByEmail(email) } catch (e: Exception) { - throw OpexError.UserNotFound.exception() + throw OpexError.UsernameOrPasswordIsIncorrect.exception() } return TokenResponse( keycloakProxy.exchangeGoogleTokenForKeycloakToken( diff --git a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt index 8e70e737e..2e44f81fa 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -56,7 +56,7 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus InvalidToken(5018, "Invalid token", HttpStatus.BAD_REQUEST), InternalIdGenerateFailed(5019, "Internal id generate failed", HttpStatus.INTERNAL_SERVER_ERROR), CaptchaRequired(5020, "Captcha required", HttpStatus.BAD_REQUEST), - + UsernameOrPasswordIsIncorrect(5021, "Username or password is incorrect", HttpStatus.BAD_REQUEST), // code 6000: wallet WalletOwnerNotFound(6001, null, HttpStatus.NOT_FOUND), From e409909b66097b7e8a8424185728156be7674a74 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Tue, 23 Dec 2025 20:42:45 +0330 Subject: [PATCH 7/9] Replace the exchange approach with bootstrap grant type in login flow --- .../opex/auth/controller/AuthController.kt | 2 +- .../co/nilin/opex/auth/proxy/KeycloakProxy.kt | 31 +++++++ .../nilin/opex/auth/service/LoginService.kt | 18 ++-- .../spi/BootstrapTokenGrantAuthenticator.java | 79 +++++++++++++++++ .../BootstrapTokenGrantProviderFactory.java | 86 +++++++++++++++++++ ...ycloak.authentication.AuthenticatorFactory | 1 + 6 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java create mode 100644 auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java create mode 100644 auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt index 8f75fbb88..04d5e12c3 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt @@ -27,7 +27,7 @@ class AuthController(private val loginService: LoginService) { } @PostMapping("/token/resend-otp") - suspend fun confirmGetToken( + suspend fun resendOtp( @RequestBody resendOtpRequest: ResendOtpRequest, @CurrentSecurityContext securityContext: SecurityContext, ): ResponseEntity { diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt index af8c13854..6c1a92748 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt @@ -439,4 +439,35 @@ class KeycloakProxy( return internalId } + + suspend fun getClientBTokenWithBootstrap( + bootstrapToken: String, + clientId: String, + clientSecret: String?, + rememberMe: Boolean + ): Token { + // There is no way to define a custom grant type in keycloak, so we use a password grant with a custom Bootstrap token field, we defined a custom factory to pars this request + val tokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token" + + val token = keycloakClient.post() + .uri(tokenUrl) + .header("Content-Type", "application/x-www-form-urlencoded") + .bodyValue( + "grant_type=password" + + "&client_id=$clientId" + + "&client_secret=$clientSecret" + + "&bootstrap_token=$bootstrapToken" + + "&username=bootstrap_user" + // Required dummy field + "&password=bootstrap_pass" + // Required dummy field + "&scope=offline_access" + ) + .retrieve() + .onStatus({ it == HttpStatus.valueOf(401) }) { + throw OpexError.InvalidUserCredentials.exception() + } + .awaitBody() + + if (!rememberMe) token.refreshToken = null + return token + } } \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt index 427db0ba4..c8ecd9eab 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -106,12 +106,18 @@ class LoginService( } } - val token = keycloakProxy.exchangeUserToken( - request.token, - PRE_AUTH_CLIENT_ID, - preAuthClientSecretKey, - request.clientId - ).apply { if (!request.rememberMe) refreshToken = null } +// val token = keycloakProxy.exchangeUserToken( +// request.token, request.clientId, +// request.clientSecret, +// request.clientId +// ).apply { if (!request.rememberMe) refreshToken = null } + val token = keycloakProxy.getClientBTokenWithBootstrap( + bootstrapToken = request.token, + clientId = request.clientId, + clientSecret = request.clientSecret, + rememberMe = request.rememberMe + ) + sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn) return TokenResponse(token, null, null) diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java new file mode 100644 index 000000000..f81eac098 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/BootstrapTokenGrantAuthenticator.java @@ -0,0 +1,79 @@ +package co.nilin.opex.keycloak.spi; + +import org.keycloak.TokenVerifier; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.*; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.MediaType; +import org.keycloak.representations.AccessToken; + +import java.util.*; + +public class BootstrapTokenGrantAuthenticator implements Authenticator { + + @Override + public void authenticate(AuthenticationFlowContext context) { + MultivaluedMap params = context.getHttpRequest().getDecodedFormParameters(); + String bootstrapTokenString = params.getFirst("bootstrap_token"); + + if (bootstrapTokenString == null || bootstrapTokenString.isEmpty()) { + + System.out.println("No bootstrap token found, skipping to next authenticator."); + + String username = context.getHttpRequest().getDecodedFormParameters().getFirst("username"); + if (username != null) { + UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username); + if (user != null) { + context.setUser(user); // Attach the user so the next step (Password) knows who to check + } + } + context.attempted(); + return; + } + try { + // Parse the JWT to get the user ID (the 'sub' claim) + AccessToken token = TokenVerifier.create(bootstrapTokenString, AccessToken.class).getToken(); + String userId = token.getSubject(); + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + + // Find the actual user from the database + UserModel user = session.users().getUserById(realm, userId); + + if (user == null || !user.isEnabled()) { + sendError(context, "invalid_grant"); + return; + } + + // IMPORTANT: Identify the user and tell Keycloak this step is finished successfully + context.setUser(user); + context.success(); + + } catch (Exception e) { + // This happens if the JWT is malformed or expired + sendError(context, "invalid_grant"); + } + } + + private void sendError(AuthenticationFlowContext context, String errorCode) { + Map errorEntity = new HashMap<>(); + errorEntity.put("error", errorCode); + + Response response = Response.status(Response.Status.BAD_REQUEST) + .entity(errorEntity) + .type(MediaType.APPLICATION_JSON_TYPE) + .build(); + + context.failure(AuthenticationFlowError.UNKNOWN_USER, response); + } + + @Override public void action(AuthenticationFlowContext context) {} + @Override public boolean requiresUser() { return false; } + @Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; } + @Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} + @Override public void close() {} +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java new file mode 100644 index 000000000..7ff747e80 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/java/co/nilin/opex/keycloak/spi/endpoints/BootstrapTokenGrantProviderFactory.java @@ -0,0 +1,86 @@ +package co.nilin.opex.keycloak.spi.endpoints; + +import co.nilin.opex.keycloak.spi.BootstrapTokenGrantAuthenticator; +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import java.util.Collections; +import java.util.List; + +public class BootstrapTokenGrantProviderFactory implements AuthenticatorFactory { + + // 1. Change PROVIDER_ID to a simple name. + // Do not use the URN here, as we are now intercepting the standard password grant. + public static final String PROVIDER_ID = "bootstrap-token-grant"; + + private static final BootstrapTokenGrantAuthenticator SINGLETON = new BootstrapTokenGrantAuthenticator(); + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + // This is the name you will see in the Keycloak Admin Console 'Add Step' list + return "Opex Bootstrap Interceptor"; + } + + @Override + public String getReferenceCategory() { + // Keep this as "grant" so it appears in the Direct Grant flow options + return "grant"; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return new AuthenticationExecutionModel.Requirement[] { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public String getHelpText() { + return "Intercepts standard password grant to exchange a bootstrap_token for full tokens"; + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public int order() { + return 0; + } + + @Override + public List getConfigProperties() { + return Collections.emptyList(); + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} \ No newline at end of file diff --git a/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 000000000..1dd794fa9 --- /dev/null +++ b/auth-gateway/keycloak-setup/spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +co.nilin.opex.keycloak.spi.endpoints.BootstrapTokenGrantProviderFactory From 4746ba7a3657598422cbab766631e258fd72e1fa Mon Sep 17 00:00:00 2001 From: fatemeh-i Date: Wed, 24 Dec 2025 01:12:10 +0330 Subject: [PATCH 8/9] Send login event in direct grant --- .../src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt index c8ecd9eab..833be1b1a 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt @@ -47,6 +47,7 @@ class LoginService( request.clientId, request.clientSecret ).apply { if (!request.rememberMe) refreshToken = null } + sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) return TokenResponse(token, null, null) } From 070c3a7df44e756922aa4a5012083aa8d1fa76e1 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Wed, 24 Dec 2025 13:22:06 +0330 Subject: [PATCH 9/9] Remove offline access scope in normal access token --- .../src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt index 6c1a92748..f983badff 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt @@ -82,7 +82,6 @@ class KeycloakProxy( "&grant_type=urn:ietf:params:oauth:grant-type:token-exchange" + "&subject_token=${token}" + "&audience=${targetClientId}" + - "&scope=offline_access" + "&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token" ) .retrieve() @@ -458,8 +457,7 @@ class KeycloakProxy( "&client_secret=$clientSecret" + "&bootstrap_token=$bootstrapToken" + "&username=bootstrap_user" + // Required dummy field - "&password=bootstrap_pass" + // Required dummy field - "&scope=offline_access" + "&password=bootstrap_pass" // Required dummy field ) .retrieve() .onStatus({ it == HttpStatus.valueOf(401) }) {