From 5abf42d53dc189744fa048816df9683f59b91bb4 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Wed, 10 Dec 2025 17:09:22 +0330 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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) }) { From 72a2a41ae6252f4dcba2682522f13cc68be328e6 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sun, 28 Dec 2025 14:46:39 +0330 Subject: [PATCH 10/15] Change the request authentication flow in the flow of thirdparty Api access --- .../api/app/controller/APIKeyController.kt | 168 +++++++++--- .../nilin/opex/api/app/data/APIKeyResponse.kt | 14 +- .../opex/api/app/data/AccessTokenResponse.kt | 2 +- .../opex/api/app/data/CreateAPIKeyRequest.kt | 10 +- .../opex/api/app/data/UpdateApiKeyRequest.kt | 13 + .../api/app/interceptor/APIKeyFilterImpl.kt | 125 +++++++-- .../co/nilin/opex/api/app/proxy/AuthProxy.kt | 46 ++++ .../opex/api/app/security/ApiKeyRegistry.kt | 13 + .../app/security/ApiKeySecretCryptoImpl.kt | 49 ++++ .../security/ClientCredentialsTokenService.kt | 53 ++++ .../opex/api/app/security/HmacVerifier.kt | 61 +++++ .../opex/api/app/service/APIKeyServiceImpl.kt | 239 ------------------ .../src/main/resources/application.yml | 6 +- .../nilin/opex/api/core/spi/APIKeyService.kt | 72 ++++-- .../opex/api/core/spi/ApiKeySecretCrypto.kt | 6 + .../co/nilin/opex/api/core/utils/Convertor.kt | 10 + .../ports/postgres/dao/APIKeyRepository.kt | 19 -- .../postgres/dao/ApiKeyRegistryRepository.kt | 9 + .../ports/postgres/impl/APIKeyServiceImpl.kt | 126 +++++++++ .../api/ports/postgres/model/APIKeyModel.kt | 23 -- .../postgres/model/ApiKeyRegistryModel.kt | 33 +++ .../src/main/resources/schema.sql | 24 +- .../src/main/resources/application.yml | 2 +- auth-gateway/docker-compose.yml | 2 +- .../src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- .../gateway/app/service/OrderService.kt | 1 + .../src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- .../src/main/resources/application.yml | 2 +- 31 files changed, 753 insertions(+), 385 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt delete mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt delete mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt delete mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt index e991b78d4..43dc42cd6 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt @@ -1,56 +1,154 @@ package co.nilin.opex.api.app.controller -import co.nilin.opex.api.app.data.APIKeyResponse -import co.nilin.opex.api.app.data.CreateAPIKeyRequest -import co.nilin.opex.api.app.service.APIKeyServiceImpl -import co.nilin.opex.common.security.jwtAuthentication -import org.springframework.security.core.annotation.CurrentSecurityContext -import org.springframework.security.core.context.SecurityContext +import co.nilin.opex.api.app.data.ApiKeyResponse +import co.nilin.opex.api.app.data.CreateApiKeyRequest +import co.nilin.opex.api.app.data.UpdateApiKeyRequest +import co.nilin.opex.common.security.JwtUtils +import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* -import java.security.Principal +import java.security.SecureRandom +import java.util.* @RestController @RequestMapping("/v1/api-key") -class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { +class APIKeyController( + private val apiKeyService: co.nilin.opex.api.core.spi.APIKeyService +) { - @GetMapping - suspend fun getKeys(principal: Principal): List { - return apiKeyService.getKeysByUserId(principal.name) - .map { APIKeyResponse(it.label, it.expirationTime, it.allowedIPs, it.key, it.isEnabled) } + private val rng = SecureRandom() + + private fun generateSecretBase64(bytes: Int = 48): String { + val b = ByteArray(bytes) + rng.nextBytes(b) + return Base64.getEncoder().encodeToString(b) } + private fun canonicalTemplate(): String = "METHOD\nPATH\nQUERY\nBODY_SHA256\nTIMESTAMP_MS" + + private fun headersTemplate(apiKeyId: String): Map = mapOf( + "X-API-KEY" to apiKeyId, + "X-API-SIGNATURE" to "Base64(HMAC-SHA256(secret, canonical))", + "X-API-TIMESTAMP" to "", + "X-API-BODY-SHA256" to " (optional)" + ) + + // Create a new API key. Caller must provide a user access token; we bind the key to that user. Returns one-time secret and usage hints. @PostMapping suspend fun create( - @RequestBody request: CreateAPIKeyRequest, - @CurrentSecurityContext securityContext: SecurityContext - ): Any { - val jwt = securityContext.jwtAuthentication() - val response = apiKeyService.createAPIKey( - jwt.name, - request.label, - request.expiration?.getLocalDateTime(), - request.allowedIPs, - jwt.token.tokenValue + @RequestHeader(name = "Authorization", required = false) authorization: String?, + @RequestBody req: CreateApiKeyRequest + ): ApiKeyResponse { + require(!authorization.isNullOrBlank() && authorization.startsWith("Bearer ")) { "Authorization Bearer user token is required" } + val userToken = authorization.substringAfter("Bearer ").trim() + val (userId, preferredUsername) = parseJwtUser(userToken) + + val apiKeyId = req.apiKeyId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() + val secret = generateSecretBase64() + val stored = apiKeyService.createApiKeyRecord( + apiKeyId = apiKeyId, + label = req.label, + plaintextSecret = secret, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUserId = userId, + keycloakUsername = preferredUsername, + enabled = true + ) + return ApiKeyResponse( + apiKeyId = apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUsername, + secret = secret ) - return object { - val apiKey = response.second.key - val secret = response.first - } } - @PutMapping("/{key}/enable") - suspend fun enableKey(principal: Principal, @PathVariable key: String) { - apiKeyService.changeKeyState(principal.name, key, true) + private fun parseJwtUser(token: String): Pair { + // Decode JWT payload using common JwtUtils (no signature verification here). + val payload = JwtUtils.decodePayload(token) + val sub = payload["sub"] as? String + val preferred = payload["username"] as? String + require(!sub.isNullOrBlank()) { "JWT missing sub" } + return Pair(sub!!, preferred) } - @PutMapping("/{key}/disable") - suspend fun disableKey(principal: Principal, @PathVariable key: String) { - apiKeyService.changeKeyState(principal.name, key, false) + // List all API keys (admin-only) — secret is not returned + @GetMapping + @PreAuthorize("hasAuthority('ROLE_admin')") + suspend fun list(): List = apiKeyService.listApiKeyRecords().stream().map { + ApiKeyResponse( + apiKeyId = it.apiKeyId, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) + }.toList() + + + // Get one API key (admin-only) — secret is not returned + @GetMapping("/{apiKeyId}") + @PreAuthorize("hasAuthority('ROLE_admin')") + suspend fun get(@PathVariable apiKeyId: String): ApiKeyResponse { + val it = apiKeyService.getApiKeyRecord(apiKeyId) ?: throw NoSuchElementException("API key not found: $apiKeyId") + return ApiKeyResponse( + apiKeyId = it.apiKeyId, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) + } + + // Rotate secret (admin-only). Returns new one-time secret + @PostMapping("/{apiKeyId}/rotate") + @PreAuthorize("hasAuthority('ROLE_admin')") + suspend fun rotate(@PathVariable apiKeyId: String): ApiKeyResponse { + val newSecret = generateSecretBase64() + val stored = apiKeyService.rotateApiKeySecret(apiKeyId, newSecret) + return ApiKeyResponse( + apiKeyId = stored.record.apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUserId, + secret = newSecret + ) } - @DeleteMapping("/{key}") - suspend fun deleteKey(principal: Principal, @PathVariable key: String) { - apiKeyService.deleteKey(principal.name, key) + // Update metadata or enable/disable (admin-only) + @PutMapping("/{apiKeyId}") + @PreAuthorize("hasAuthority('ROLE_admin')") + suspend fun update(@PathVariable apiKeyId: String, @RequestBody req: UpdateApiKeyRequest): ApiKeyResponse { + val s = apiKeyService.updateApiKeyRecord( + apiKeyId = apiKeyId, + label = req.label, + enabled = req.enabled, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUsername = req.keycloakUsername + ) + return ApiKeyResponse( + apiKeyId = s.apiKeyId, + label = s.label, + enabled = s.enabled, + allowedIps = s.allowedIps, + allowedEndpoints = s.allowedEndpoints, + keycloakUsername = s.keycloakUserId + ) } + // Delete/revoke (admin-only) + @DeleteMapping("/{apiKeyId}") + @PreAuthorize("hasAuthority('ROLE_admin')") + suspend fun delete(@PathVariable apiKeyId: String) { + apiKeyService.deleteApiKeyRecord(apiKeyId) + } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt index b2832019f..21da2279f 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt @@ -2,10 +2,12 @@ package co.nilin.opex.api.app.data import java.time.LocalDateTime -data class APIKeyResponse( - val label: String, - val expirationTime: LocalDateTime?, - val allowedIPs: String?, - val key: String, - val enabled: Boolean +data class ApiKeyResponse( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String?, + val secret: String? = null, // only present on create/rotate ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt index e0846aa41..7fe91e449 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt @@ -2,6 +2,6 @@ package co.nilin.opex.api.app.data data class AccessTokenResponse( val access_token: String, - val refresh_token: String, + val refresh_token: String?, val expires_in: Long ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt index e84994737..20ca10712 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt @@ -1,7 +1,9 @@ package co.nilin.opex.api.app.data -data class CreateAPIKeyRequest( - val label: String, - val expiration: APIKeyExpiration?, - val allowedIPs: String? +data class CreateApiKeyRequest( + val apiKeyId: String?, + val label: String?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt new file mode 100644 index 000000000..52bfcfbbc --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.data + + + +data class UpdateApiKeyRequest( + val label: String?, + val enabled: Boolean?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? +) + + diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt index e5e802b23..e65c26d63 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt @@ -1,9 +1,11 @@ package co.nilin.opex.api.app.interceptor -import co.nilin.opex.api.app.service.APIKeyServiceImpl +import co.nilin.opex.api.app.security.ClientCredentialsTokenService +import co.nilin.opex.api.app.security.HmacVerifier +import co.nilin.opex.api.core.spi.APIKeyService import co.nilin.opex.api.core.spi.APIKeyFilter import kotlinx.coroutines.reactor.mono -import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.WebFilter @@ -11,30 +13,117 @@ import org.springframework.web.server.WebFilterChain import reactor.core.publisher.Mono @Component -class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFilter, WebFilter { +class APIKeyFilterImpl( + private val apiKeyService: APIKeyService, + private val hmacVerifier: HmacVerifier, + private val clientTokenService: ClientCredentialsTokenService +) : APIKeyFilter, WebFilter { - override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + private val logger = LoggerFactory.getLogger(APIKeyFilterImpl::class.java) + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { val request = exchange.request - val key = request.headers["X-API-KEY"] - val secret = request.headers["X-API-SECRET"] - if (key.isNullOrEmpty() || secret.isNullOrEmpty()) { + val apiKeyId = request.headers["X-API-KEY"]?.firstOrNull() + val signature = request.headers["X-API-SIGNATURE"]?.firstOrNull() + val tsHeader = request.headers["X-API-TIMESTAMP"]?.firstOrNull() + val uri = request.uri + + // HMAC path when signature present + if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) { + return mono { + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp) + null + } + if (!entry.allowedEndpoints.isNullOrEmpty() && ( !entry.allowedEndpoints!!.contains(uri.rawPath))) { + logger.warn("API key {} request to unauthorized resource {}", apiKeyId, uri.rawPath) + null + } else { + val ts = tsHeader.toLongOrNull() + val bodyHash = request.headers["X-API-BODY-SHA256"]?.firstOrNull() + if (ts == null) { + logger.warn("Invalid timestamp header for bot {}", apiKeyId) + null + } else { + val ok = hmacVerifier.verify( + entry.secret, + signature, + HmacVerifier.VerificationInput( + method = request.method.name(), + path = uri.rawPath, + query = uri.rawQuery, + timestampMillis = ts, + bodySha256 = bodyHash + ) + ) + if (!ok) { + logger.warn("Invalid signature for apiKey {}", apiKeyId) + null + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting", apiKeyId) + null + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } + } + } + }.flatMap { updatedExchange -> + if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) + } + } + + // Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret + // against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token. + val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull() + if (apiKeyId.isNullOrBlank() || legacySecret.isNullOrBlank()) { return chain.filter(exchange) } return mono { - val apiKey = apiKeyService.getAPIKey(key[0], secret[0]) - if (apiKey != null && apiKey.isEnabled && apiKey.accessToken != null && !apiKey.isExpired) { - val req = exchange.request.mutate() - .header("Authorization", "Bearer ${apiKey.accessToken}") - .build() - exchange.mutate().request(req).build() - } else null + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key on secret path: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {} (secret path)", apiKeyId, sourceIp) + null + } else if (legacySecret != entry.secret) { + logger.warn("Invalid X-API-SECRET for apiKey {}", apiKeyId) + null + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting (secret path)", apiKeyId) + null + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } }.flatMap { updatedExchange -> - if (updatedExchange != null) - chain.filter(updatedExchange) - else - chain.filter(exchange) + if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) } } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt index 95879afaf..f39c3301f 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt @@ -57,4 +57,50 @@ class AuthProxy( .bodyToMono() .awaitSingle() } + + suspend fun clientCredentials(clientId: String, clientSecret: String, scope: String? = null): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "client_credentials") + val body = if (scope.isNullOrBlank()) form else form.with("scope", scope) + + logger.info("Request client_credentials token for client {}", clientId) + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + + // Exchange a client_credentials access token to a user access token (Token Exchange) + suspend fun exchangeToUser( + clientId: String, + clientSecret: String, + subjectToken: String, + requestedSubjectUserId: String, + audience: String? = null + ): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .with("subject_token", subjectToken) + .with("requested_subject", requestedSubjectUserId) + .with("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + val body = if (audience.isNullOrBlank()) form else form.with("audience", audience) + + logger.info("Token exchange to user {} via client {}", requestedSubjectUserId, clientId) + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt new file mode 100644 index 000000000..2a7e8662e --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.security + +interface ApiKeyRegistry { + data class BotInfo( + val apiKeyId: String, + val hmacSecret: String, + val enabled: Boolean = true, + val allowedIps: Set? = null, + val keycloakUsername: String? = null + ) + + fun find(apiKeyId: String): BotInfo? +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt new file mode 100644 index 000000000..a9cf06cb1 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt @@ -0,0 +1,49 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Component +class ApiKeySecretCryptoImpl( + @Value("\${app.api.crypto.key}") private val base64Key: String +) : ApiKeySecretCrypto { + private val key: SecretKey + private val rng = SecureRandom() + + init { + val decoded = Base64.getDecoder().decode(base64Key) + require(decoded.size == 16 || decoded.size == 24 || decoded.size == 32) { + "app.api.crypto.key must be 128/192/256-bit Base64 key" + } + key = SecretKeySpec(decoded, "AES") + } + + override fun encrypt(plaintext: String): String { + val iv = ByteArray(12) + rng.nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val ivB64 = Base64.getEncoder().encodeToString(iv) + val ctB64 = Base64.getEncoder().encodeToString(ct) + return "$ivB64:$ctB64" + } + + override fun decrypt(ciphertext: String): String { + val parts = ciphertext.split(":") + require(parts.size == 2) { "Invalid encrypted secret format" } + val iv = Base64.getDecoder().decode(parts[0]) + val ct = Base64.getDecoder().decode(parts[1]) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + val pt = cipher.doFinal(ct) + return String(pt, Charsets.UTF_8) + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt new file mode 100644 index 000000000..648a40099 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.app.proxy.AuthProxy +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ClientCredentialsTokenService( + private val authProxy: AuthProxy, + @Value("\${app.auth.api-key-client.id}") + private val clientId: String, + @Value("\${app.auth.api-key-client.secret}") + private val clientSecret: String +) { + private val logger = LoggerFactory.getLogger(ClientCredentialsTokenService::class.java) + + private data class CachedToken(val token: String, val expiresAtMillis: Long) + + // Cache for client_credentials token + @Volatile private var cache: CachedToken? = null + + // Cache for exchanged user tokens per subject (exactly like getBearerToken, keyed only by subject) + private val subjectCache = java.util.concurrent.ConcurrentHashMap() + + suspend fun getBearerToken(): String { + val now = Instant.now().toEpochMilli() + val snap = cache + if (snap != null && snap.expiresAtMillis - 30_000 > now) return snap.token + val resp = authProxy.clientCredentials(clientId, clientSecret) + val expiresAt = now + (resp.expires_in * 1000L) + val bearer = resp.access_token + cache = CachedToken(bearer, expiresAt) + logger.debug("Fetched new client_credentials token; expires at {}", expiresAt) + return bearer + } + + // Convenience: exchange cached client token for a user access token, cached per subject + suspend fun exchangeToUserToken(requestedSubjectUserId: String, audience: String? = null): String { + val now = Instant.now().toEpochMilli() + val cached = subjectCache[requestedSubjectUserId] + if (cached != null && cached.expiresAtMillis - 30_000 > now) return cached.token + + val subjectToken = getBearerToken() + val exchanged = authProxy.exchangeToUser(clientId, clientSecret, subjectToken, requestedSubjectUserId, audience) + val token = exchanged.access_token + val expiresAt = now + (exchanged.expires_in * 1000L) + subjectCache[requestedSubjectUserId] = CachedToken(token, expiresAt) + logger.debug("Exchanged and cached user token for subject {}; expires at {}", requestedSubjectUserId, expiresAt) + return token + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt new file mode 100644 index 000000000..97b6a152c --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt @@ -0,0 +1,61 @@ +package co.nilin.opex.api.app.security + +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.util.Base64 +import kotlin.math.abs + +@org.springframework.stereotype.Component +class HmacVerifier( + private val allowedSkew: Duration = Duration.ofMinutes(5) +) { + data class VerificationInput( + val method: String, + val path: String, + val timestampMillis: Long, + val bodySha256: String? = null, + val query: String? = null + ) + + fun verify(secret: String, signatureBase64: String, input: VerificationInput): Boolean { + // Check timestamp window + val now = Instant.now().toEpochMilli() + if (abs(now - input.timestampMillis) > allowedSkew.toMillis()) return false + + val canonical = canonicalString(input) + val expected = hmacSha256Base64(secret, canonical) + // Constant-time compare + return constantTimeEquals(signatureBase64, expected) + } + + private fun canonicalString(input: VerificationInput): String { + val sb = StringBuilder() + sb.append(input.method.uppercase()).append('\n') + .append(input.path).append('\n') + .append(input.query ?: "").append('\n') + .append(input.bodySha256 ?: "").append('\n') + .append(input.timestampMillis) + return sb.toString() + } + + private fun hmacSha256Base64(secret: String, data: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) + val raw = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + return Base64.getEncoder().encodeToString(raw) + } + + private fun constantTimeEquals(a: String, b: String): Boolean { + val aBytes = a.toByteArray(StandardCharsets.UTF_8) + val bBytes = b.toByteArray(StandardCharsets.UTF_8) + var result = aBytes.size xor bBytes.size + val len = minOf(aBytes.size, bBytes.size) + for (i in 0 until len) { + result = result or (aBytes[i].toInt() xor bBytes[i].toInt()) + } + return result == 0 + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt deleted file mode 100644 index 37458cead..000000000 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt +++ /dev/null @@ -1,239 +0,0 @@ -package co.nilin.opex.api.app.service - -import co.nilin.opex.api.app.proxy.AuthProxy -import co.nilin.opex.api.core.inout.APIKey -import co.nilin.opex.api.core.spi.APIKeyService -import co.nilin.opex.api.ports.postgres.dao.APIKeyRepository -import co.nilin.opex.api.ports.postgres.model.APIKeyModel -import co.nilin.opex.common.OpexError -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.cache.Cache -import org.springframework.cache.CacheManager -import org.springframework.stereotype.Service -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@Service -class APIKeyServiceImpl( - private val apiKeyRepository: APIKeyRepository, - private val authProxy: AuthProxy, - private val cacheManager: CacheManager, - @Value("\${app.auth.api-key-client.secret}") - private val clientSecret: String -) : APIKeyService { - - private val logger = LoggerFactory.getLogger(APIKeyServiceImpl::class.java) - private val refreshLocks = ConcurrentHashMap() - override suspend fun createAPIKey( - userId: String, - label: String, - expirationTime: LocalDateTime?, - allowedIPs: String?, - currentToken: String - ): Pair { - if (apiKeyRepository.countByUserId(userId).awaitFirstOrElse { 0 } >= 10) - throw OpexError.APIKeyLimitReached.exception() - - val secret = generateSecret() - val tokenResponse = authProxy.exchangeToken(clientSecret, currentToken) - val apiKey = apiKeyRepository.save( - APIKeyModel( - null, - userId, - label, - encryptAES(tokenResponse.access_token, secret), - encryptAES(tokenResponse.refresh_token, secret), - expirationTime, - allowedIPs, - tokenExpiration(tokenResponse.expires_in) - ) - ).awaitSingle() - - return Pair( - secret, - with(apiKey) { - APIKey(userId, label, accessToken, expirationTime, allowedIPs, key, isEnabled, isExpired) - } - ) - } - - override suspend fun getAPIKey(key: String, secret: String): APIKey? = coroutineScope { - val apiKey = getFromCache(key) - ?: apiKeyRepository.findByKey(key)?.awaitSingleOrNull()?.apply { putCache(this) } - - with(apiKey) { - if (this != null) { - refreshIfNeeded(this@with, secret) - APIKey( - userId, - label, - decryptAES(accessToken, secret), - expirationTime, - allowedIPs, - key, - isEnabled, - isExpired - ) - } else - null - } - } - - override suspend fun getKeysByUserId(userId: String): List { - return apiKeyRepository.findAllByUserId(userId).collectList().awaitFirstOrElse { emptyList() } - .map { - APIKey( - it.userId, - it.label, - it.accessToken, - it.expirationTime, - it.allowedIPs, - it.key, - it.isEnabled, - it.isExpired - ) - } - } - - override suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) { - val apiKey = apiKeyRepository.findByKey(key)?.awaitSingleOrNull() ?: throw OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKey.isEnabled = isEnabled - apiKeyRepository.save(apiKey).awaitSingle() - } - - override suspend fun deleteKey(userId: String, key: String) { - val apiKey = apiKeyRepository.findByKey(key)?.awaitSingleOrNull() ?: throw OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKeyRepository.delete(apiKey).awaitFirstOrNull() - } - - private suspend fun refreshIfNeeded(apiKey: APIKeyModel, secret: String) { - if (apiKey.isExpired || !apiKey.isEnabled) return - val now = LocalDateTime.now() - - if (apiKey.expirationTime?.isBefore(now) == true) { - logger.info("Expiring api key ${apiKey.key}") - apiKey.isExpired = true - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} is expired") - return - } - - if (apiKey.tokenExpirationTime.isAfter(now)) return - - val mutex = refreshLocks.computeIfAbsent(apiKey.key) { Mutex() } - mutex.withLock { - // Double-check after acquiring the lock to avoid redundant refresh - if (apiKey.tokenExpirationTime.isAfter(LocalDateTime.now())) return - try { - logger.info("Refreshing api key ${apiKey.key} token") - val response = authProxy.refreshToken(clientSecret, decryptAES(apiKey.refreshToken, secret)) - apiKey.accessToken = encryptAES(response.access_token, secret) - apiKey.tokenExpirationTime = tokenExpiration(response.expires_in) - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} token refreshed") - } catch (e: Exception) { - logger.error("Error refreshing api key ${apiKey.key}", e) - } - } - } - - private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { - if (apiKey.isExpired || !apiKey.isEnabled) - return - - try { - val now = LocalDateTime.now() - if (apiKey.expirationTime?.isBefore(now) == true) { - logger.info("Expiring api key ${apiKey.key}") - apiKey.isExpired = true - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} is expired") - return - } - - if (apiKey.tokenExpirationTime.isBefore(now)) { - logger.info("Refreshing api key ${apiKey.key} token") - val response = authProxy.refreshToken(clientSecret, decryptAES(apiKey.refreshToken, secret)) - apiKey.apply { - accessToken = encryptAES(response.access_token, secret) - tokenExpirationTime = tokenExpiration(response.expires_in) - } - apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } - logger.info("API key ${apiKey.key} token refreshed") - } - } catch (e: Exception) { - logger.error("Error checking api key ${apiKey.key}", e) - } - } - - private fun encryptAES(input: String, key: String): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { - init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) - } - val cipherText = cipher.doFinal(input.toByteArray()) - return Base64.getEncoder().encodeToString(cipherText) - } - - private fun decryptAES(cipherText: String, key: String): String { - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { - init(Cipher.DECRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) - } - val plainText = cipher.doFinal(Base64.getDecoder().decode(cipherText)) - return String(plainText) - } - - private fun generateSecret(length: Int = 32): String { - val chars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - return (1..length).map { chars.random() }.joinToString("") - } - - private fun tokenExpiration(expiresInSeconds: Long): LocalDateTime { - val tokenOffsetTime = Date().time + TimeUnit.SECONDS.toMillis(expiresInSeconds) - TimeUnit.MINUTES.toMillis(10) - return LocalDateTime.ofInstant(Instant.ofEpochMilli(tokenOffsetTime), ZoneId.systemDefault()) - } - - private fun getFromCache(key: String): APIKeyModel? { - return getCache()?.get(key)?.get() as APIKeyModel? - } - - private fun putCache(apiKey: APIKeyModel) { - getCache()?.apply { - putIfAbsent(apiKey.key, apiKey) - } - } - - private fun updateCache(apiKey: APIKeyModel) { - getCache()?.apply { - evict(apiKey.key) - put(apiKey.key, apiKey) - } - } - - private fun getCache(): Cache? { - val cache = cacheManager.getCache("apiKey") - if (cache == null) - logger.warn("Could not find cache of apiKey") - return cache - } - -} \ No newline at end of file diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 36932fc1c..386355062 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -111,10 +111,11 @@ app: url: http://opex-bc-gateway auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_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} + id: opex-api-key binance: api-url: https://api1.binance.com trade-volume-calculation-currency: ${TRADE_VOLUME_CALCULATION_CURRENCY:USDT} @@ -123,5 +124,8 @@ app: custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} + api: + crypto: + key: 0e1fd29572ec8c85970d76e3433e96ee swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt index f5329c6fa..580e40a0a 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt @@ -1,24 +1,58 @@ package co.nilin.opex.api.core.spi -import co.nilin.opex.api.core.inout.APIKey -import java.time.LocalDateTime - interface APIKeyService { - suspend fun createAPIKey( - userId: String, - label: String, - expirationTime: LocalDateTime?, - allowedIPs: String?, - currentToken: String - ): Pair - - suspend fun getAPIKey(key: String, secret: String): APIKey? - - suspend fun getKeysByUserId(userId: String): List - - suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) - - suspend fun deleteKey(userId: String, key: String) - + data class ApiKeyRecord( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUserId: String?, + val keycloakUsername: String? + ) + + data class ApiKeyCreateResult( + val secret: String, + val record: ApiKeyRecord + ) + + data class ApiKeyVerification( + val apiKeyId: String, + val secret: String, + val enabled: Boolean, + val allowedEndpoints: Set?, + val allowedIps: Set?, + val keycloakUserId: String? + ) + + suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): ApiKeyCreateResult + + suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): ApiKeyCreateResult + + suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): ApiKeyRecord + + suspend fun getApiKeyRecord(apiKeyId: String): ApiKeyRecord? + + suspend fun listApiKeyRecords(): List + + suspend fun deleteApiKeyRecord(apiKeyId: String) + + suspend fun getApiKeyForVerification(apiKeyId: String): ApiKeyVerification? } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt new file mode 100644 index 000000000..d5e164cbe --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.spi + +interface ApiKeySecretCrypto { + fun encrypt(plaintext: String): String + fun decrypt(ciphertext: String): String +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt new file mode 100644 index 000000000..e86b42ad2 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.utils + +fun Set?.toCsv(): String? = this?.joinToString(",") + +fun String?.toSet(): Set? = this + ?.takeIf { it.isNotBlank() } + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt deleted file mode 100644 index 007c0025c..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.nilin.opex.api.ports.postgres.dao - -import co.nilin.opex.api.ports.postgres.model.APIKeyModel -import org.springframework.data.repository.CrudRepository -import org.springframework.data.repository.reactive.ReactiveCrudRepository -import org.springframework.stereotype.Repository -import reactor.core.publisher.Flux -import reactor.core.publisher.Mono - -@Repository -interface APIKeyRepository : ReactiveCrudRepository { - - fun findAllByUserId(userId: String): Flux - - fun findByKey(key: String): Mono? - - fun countByUserId(userId: String): Mono - -} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt new file mode 100644 index 000000000..4868f26a5 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ApiKeyRegistryRepository : ReactiveCrudRepository { +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt new file mode 100644 index 000000000..8dfd80c1b --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt @@ -0,0 +1,126 @@ +package co.nilin.opex.api.ports.postgres.impl + +import co.nilin.opex.api.core.spi.APIKeyService +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import co.nilin.opex.api.core.utils.toCsv +import co.nilin.opex.api.core.utils.toSet +import co.nilin.opex.api.ports.postgres.dao.ApiKeyRegistryRepository +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class APIKeyServiceImpl( + private val apiKeySecretCrypto: ApiKeySecretCrypto, + private val apiKeyRegistryRepository: ApiKeyRegistryRepository +) : APIKeyService { + + private fun toRecord(e: ApiKeyRegistryModel): APIKeyService.ApiKeyRecord = + APIKeyService.ApiKeyRecord( + apiKeyId = e.apiKeyId, + label = e.label, + enabled = e.enabled, + allowedIps = e.allowedIps.toSet(), + allowedEndpoints = e.allowedEndpoints.toSet(), + keycloakUserId = e.keycloakUserId, + keycloakUsername = e.keycloakUsername + ) + + override suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): APIKeyService.ApiKeyCreateResult { + require(apiKeyId.isNotBlank()) { "apiKeyId is blank" } + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (exists) error("API key already exists: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(plaintextSecret) + val now = LocalDateTime.now() + val entry = ApiKeyRegistryModel( + apiKeyId = apiKeyId, + label = label, + encryptedSecret = enc, + enabled = enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUserId = keycloakUserId, + keycloakUsername = keycloakUsername, + createdAt = now, + updatedAt = now + ) + val saved = apiKeyRegistryRepository.save(entry).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = plaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): APIKeyService.ApiKeyCreateResult { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(newPlaintextSecret) + val updated = existing.copy( + encryptedSecret = enc, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = newPlaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): APIKeyService.ApiKeyRecord { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val updated = existing.copy( + label = label ?: existing.label, + enabled = enabled ?: existing.enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUsername = keycloakUsername ?: existing.keycloakUsername, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return toRecord(saved) + } + + override suspend fun getApiKeyRecord(apiKeyId: String): APIKeyService.ApiKeyRecord? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + return toRecord(e) + } + + override suspend fun listApiKeyRecords(): List = + apiKeyRegistryRepository.findAll().map { toRecord(it) }.collectList().awaitSingle().sortedBy { it.apiKeyId } + + override suspend fun deleteApiKeyRecord(apiKeyId: String) { + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (!exists) error("API key not found: $apiKeyId") + apiKeyRegistryRepository.deleteById(apiKeyId).awaitSingle() + } + + override suspend fun getApiKeyForVerification(apiKeyId: String): APIKeyService.ApiKeyVerification? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + val secret = try { apiKeySecretCrypto.decrypt(e.encryptedSecret) } catch (_: Exception) { return null } + return APIKeyService.ApiKeyVerification( + apiKeyId = apiKeyId, + secret = secret, + enabled = e.enabled, + allowedEndpoints = e.allowedEndpoints.toSet(), + allowedIps = e.allowedIps.toSet(), + keycloakUserId = e.keycloakUserId + ) + } +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt deleted file mode 100644 index f33c9cd65..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -package co.nilin.opex.api.ports.postgres.model - -import org.springframework.data.annotation.Id -import org.springframework.data.relational.core.mapping.Column -import org.springframework.data.relational.core.mapping.Table -import java.time.LocalDateTime -import java.util.* - -@Table("api_key") -data class APIKeyModel( - @Id val id: Long? = null, - val userId: String, - val label: String, - var accessToken: String, - var refreshToken: String, - val expirationTime: LocalDateTime?, - @Column("allowed_ips") - val allowedIPs: String?, - var tokenExpirationTime: LocalDateTime, - val key: String = UUID.randomUUID().toString(), - var isEnabled: Boolean = true, - var isExpired: Boolean = false -) \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt new file mode 100644 index 000000000..e62255ad2 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("api_key_registry") +data class ApiKeyRegistryModel( + @Id + @Column("api_key_id") + val apiKeyId: String, + val label: String?, + @Column("encrypted_secret") + val encryptedSecret: String, + val enabled: Boolean, + @Column("allowed_ips") + val allowedIps: String?, + @Column("allowed_endpoints") + val allowedEndpoints: String?, + @Column("keycloak_user_id") + val keycloakUserId: String?, + @Column("keycloak_username") + val keycloakUsername: String?, + @Column("created_at") + val createdAt: LocalDateTime, + @Column("updated_at") + val updatedAt: LocalDateTime +): Persistable { + override fun getId(): String = apiKeyId + override fun isNew(): Boolean = createdAt == updatedAt +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql index 1d4db9878..e1a77a3bc 100644 --- a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql +++ b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql @@ -6,18 +6,18 @@ CREATE TABLE IF NOT EXISTS symbol_maps alias VARCHAR(72) NOT NULL, UNIQUE (symbol, alias_key, alias) ); +DROP TABLE IF EXISTS api_key; -CREATE TABLE IF NOT EXISTS api_key +CREATE TABLE IF NOT EXISTS api_key_registry ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - label VARCHAR(200) NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expiration_time TIMESTAMP, - allowed_ips TEXT, - token_expiration_time TIMESTAMP NOT NULL, - key VARCHAR(36) NOT NULL UNIQUE, - is_enabled BOOLEAN NOT NULL DEFAULT true, - is_expired BOOLEAN NOT NULL DEFAULT false + api_key_id VARCHAR(128) PRIMARY KEY, + label VARCHAR(200), + encrypted_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_ips TEXT, + allowed_endpoints TEXT, + keycloak_user_id VARCHAR(128), + keycloak_username VARCHAR(256), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT 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 fb2189bd9..798e9dad6 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: ${TOKEN_ISSUER_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/auth-gateway/docker-compose.yml b/auth-gateway/docker-compose.yml index 5246b65ed..58db78f35 100644 --- a/auth-gateway/docker-compose.yml +++ b/auth-gateway/docker-compose.yml @@ -18,7 +18,7 @@ services: command: - start-dev - --import-realm - - --features=admin-fine-grained-authz,token-exchange + - --features=admin-fine-grained-authz,token-exchange,client-policies,client-profiles volumes: - ./keycloak-setup/realms:/opt/keycloak/data/import ports: 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 a3ae81c7f..a8eb6cc2f 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: ${TOKEN_ISSUER_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/market/market-app/src/main/resources/application.yml b/market/market-app/src/main/resources/application.yml index 1d8fa28db..42f5d3622 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: ${TOKEN_ISSUER_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/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt index ec1fba6e0..e866202e9 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt @@ -16,6 +16,7 @@ import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal +import java.math.RoundingMode @Service class OrderService( 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 7f8e263c3..0e75bcc66 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: ${TOKEN_ISSUER_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 630b2d1c3..a45b85b70 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: ${TOKEN_ISSUER_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/resources/application.yml b/profile/profile-app/src/main/resources/application.yml index 5225edd91..cc91c40fb 100644 --- a/profile/profile-app/src/main/resources/application.yml +++ b/profile/profile-app/src/main/resources/application.yml @@ -55,7 +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 + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} kyc: url: lb://opex-kyc/v2/admin/kyc/internal otp: diff --git a/wallet/wallet-app/src/main/resources/application.yml b/wallet/wallet-app/src/main/resources/application.yml index 83c8b6e29..d993574e8 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: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} client-id: none client-secret: none system: From 722171b0c7a3c446ff26d05ff173dff666f08a00 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sun, 28 Dec 2025 14:54:26 +0330 Subject: [PATCH 11/15] Adjust authorization in api controller --- .../co/nilin/opex/api/app/controller/APIKeyController.kt | 6 ------ .../nilin/opex/api/ports/binance/config/SecurityConfig.kt | 2 ++ auth-gateway/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt index 43dc42cd6..04e566341 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt @@ -4,7 +4,6 @@ import co.nilin.opex.api.app.data.ApiKeyResponse import co.nilin.opex.api.app.data.CreateApiKeyRequest import co.nilin.opex.api.app.data.UpdateApiKeyRequest import co.nilin.opex.common.security.JwtUtils -import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import java.security.SecureRandom import java.util.* @@ -76,7 +75,6 @@ class APIKeyController( // List all API keys (admin-only) — secret is not returned @GetMapping - @PreAuthorize("hasAuthority('ROLE_admin')") suspend fun list(): List = apiKeyService.listApiKeyRecords().stream().map { ApiKeyResponse( apiKeyId = it.apiKeyId, @@ -92,7 +90,6 @@ class APIKeyController( // Get one API key (admin-only) — secret is not returned @GetMapping("/{apiKeyId}") - @PreAuthorize("hasAuthority('ROLE_admin')") suspend fun get(@PathVariable apiKeyId: String): ApiKeyResponse { val it = apiKeyService.getApiKeyRecord(apiKeyId) ?: throw NoSuchElementException("API key not found: $apiKeyId") return ApiKeyResponse( @@ -108,7 +105,6 @@ class APIKeyController( // Rotate secret (admin-only). Returns new one-time secret @PostMapping("/{apiKeyId}/rotate") - @PreAuthorize("hasAuthority('ROLE_admin')") suspend fun rotate(@PathVariable apiKeyId: String): ApiKeyResponse { val newSecret = generateSecretBase64() val stored = apiKeyService.rotateApiKeySecret(apiKeyId, newSecret) @@ -125,7 +121,6 @@ class APIKeyController( // Update metadata or enable/disable (admin-only) @PutMapping("/{apiKeyId}") - @PreAuthorize("hasAuthority('ROLE_admin')") suspend fun update(@PathVariable apiKeyId: String, @RequestBody req: UpdateApiKeyRequest): ApiKeyResponse { val s = apiKeyService.updateApiKeyRecord( apiKeyId = apiKeyId, @@ -147,7 +142,6 @@ class APIKeyController( // Delete/revoke (admin-only) @DeleteMapping("/{apiKeyId}") - @PreAuthorize("hasAuthority('ROLE_admin')") suspend fun delete(@PathVariable apiKeyId: String) { apiKeyService.deleteApiKeyRecord(apiKeyId) } 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 b414ea3a8..99e9974f7 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 @@ -58,6 +58,8 @@ class SecurityConfig( .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") .pathMatchers("/opex/v1/market/**").permitAll() .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() + .pathMatchers(HttpMethod.POST,"/v1/api-key").authenticated() + .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") .anyExchange().authenticated() } .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) diff --git a/auth-gateway/docker-compose.yml b/auth-gateway/docker-compose.yml index 58db78f35..5246b65ed 100644 --- a/auth-gateway/docker-compose.yml +++ b/auth-gateway/docker-compose.yml @@ -18,7 +18,7 @@ services: command: - start-dev - --import-realm - - --features=admin-fine-grained-authz,token-exchange,client-policies,client-profiles + - --features=admin-fine-grained-authz,token-exchange volumes: - ./keycloak-setup/realms:/opt/keycloak/data/import ports: From a8319b39a6a90a1a6d3f042fbd4c435f9abd9eaf Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sun, 28 Dec 2025 16:10:59 +0330 Subject: [PATCH 12/15] Read the api crypto key from the valut --- .../co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt | 2 ++ api/api-app/src/main/resources/application.yml | 2 +- docker-compose.yml | 1 + docker-images/vault/workflow-vault.sh | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt index a9cf06cb1..6d8e72be7 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt @@ -1,6 +1,7 @@ package co.nilin.opex.api.app.security import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.security.SecureRandom @@ -16,6 +17,7 @@ class ApiKeySecretCryptoImpl( ) : ApiKeySecretCrypto { private val key: SecretKey private val rng = SecureRandom() + private val logger = LoggerFactory.getLogger(ApiKeySecretCryptoImpl::class.java) init { val decoded = Base64.getDecoder().decode(base64Key) diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 386355062..80a1b7cc9 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -126,6 +126,6 @@ app: base-url: ${CUSTOM_MESSAGE_URL} api: crypto: - key: 0e1fd29572ec8c85970d76e3433e96ee + key: ${api_crypto_key:0e1fd29572ec8c85970d76e3433e96ee} swagger: authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token diff --git a/docker-compose.yml b/docker-compose.yml index 97ae0503b..d45da092e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,7 @@ services: - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-hiopex} - OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET=${OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET} - VANDAR_API_KEY=$VANDAR_API_KEY + - API_CRYPTO_KEY=${API_CRYPTO_KEY} cap_add: - IPC_LOCK deploy: diff --git a/docker-images/vault/workflow-vault.sh b/docker-images/vault/workflow-vault.sh index 5fe4a8b9f..cbe570f75 100755 --- a/docker-images/vault/workflow-vault.sh +++ b/docker-images/vault/workflow-vault.sh @@ -96,7 +96,7 @@ init_secrets() { ## Add secret values vault kv put secret/opex smtppass=${SMTP_PASS} vault kv put secret/opex-accountant dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} - vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} + vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} api_crypto_key=${API_CRYPTO_KEY} vault kv put secret/opex-market dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-bc-gateway dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET} vault kv put secret/opex-eventlog dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} From 31f25c63192053d00ca34d86d11f853ce5ebdb34 Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Mon, 29 Dec 2025 18:30:39 +0330 Subject: [PATCH 13/15] Wrap the otc services behind the api module --- .../api/app/interceptor/APIKeyFilterImpl.kt | 9 +- .../core/inout/analytics/ActivityTotals.kt | 14 ++ .../otc/CurrencyExchangeRatesResponse.kt | 11 + .../opex/api/core/inout/otc/CurrencyPair.kt | 13 ++ .../opex/api/core/inout/otc/CurrencyPrice.kt | 5 + .../opex/api/core/inout/otc/ForbiddenPair.kt | 10 + .../co/nilin/opex/api/core/inout/otc/Rate.kt | 13 ++ .../otc/SetCurrencyExchangeRateRequest.kt | 18 ++ .../nilin/opex/api/core/inout/otc/Symbols.kt | 3 + .../co/nilin/opex/api/core/spi/RateProxy.kt | 33 +++ .../ports/binance/config/SecurityConfig.kt | 103 ++++----- .../controller/CurrencyRatesController.kt | 127 ++++++++++++ .../controller/UserAnalyticsController.kt | 86 ++++++++ .../api/ports/proxy/impl/RateProxyImpl.kt | 196 ++++++++++++++++++ .../opex/wallet/app/config/SecurityConfig.kt | 1 + 15 files changed, 588 insertions(+), 54 deletions(-) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt create mode 100644 api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt index e65c26d63..b3909c2ce 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt @@ -2,8 +2,8 @@ package co.nilin.opex.api.app.interceptor import co.nilin.opex.api.app.security.ClientCredentialsTokenService import co.nilin.opex.api.app.security.HmacVerifier -import co.nilin.opex.api.core.spi.APIKeyService import co.nilin.opex.api.core.spi.APIKeyFilter +import co.nilin.opex.api.core.spi.APIKeyService import kotlinx.coroutines.reactor.mono import org.slf4j.LoggerFactory import org.springframework.stereotype.Component @@ -28,6 +28,7 @@ class APIKeyFilterImpl( val signature = request.headers["X-API-SIGNATURE"]?.firstOrNull() val tsHeader = request.headers["X-API-TIMESTAMP"]?.firstOrNull() val uri = request.uri + val path = "/api" + uri.rawPath // HMAC path when signature present if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) { @@ -43,8 +44,8 @@ class APIKeyFilterImpl( logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp) null } - if (!entry.allowedEndpoints.isNullOrEmpty() && ( !entry.allowedEndpoints!!.contains(uri.rawPath))) { - logger.warn("API key {} request to unauthorized resource {}", apiKeyId, uri.rawPath) + if (!entry.allowedEndpoints.isNullOrEmpty() && (!entry.allowedEndpoints!!.contains(path))) { + logger.warn("API key {} request to unauthorized resource {}", apiKeyId, path) null } else { val ts = tsHeader.toLongOrNull() @@ -58,7 +59,7 @@ class APIKeyFilterImpl( signature, HmacVerifier.VerificationInput( method = request.method.name(), - path = uri.rawPath, + path = path, query = uri.rawQuery, timestampMillis = ts, bodySha256 = bodyHash diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt new file mode 100644 index 000000000..5799b1044 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/analytics/ActivityTotals.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.api.core.inout.analytics + +import java.math.BigDecimal + +/** + * Totals for a single day of user activity (mock values for now). + */ +data class ActivityTotals( + val totalBalance: BigDecimal, + val totalWithdraw: BigDecimal, + val totalDeposit: BigDecimal, + val totalTrade: BigDecimal, + val totalSwap: BigDecimal +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt new file mode 100644 index 000000000..5dfe7fb8e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyExchangeRatesResponse.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class CurrencyExchangeRate( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal +) + +data class CurrencyExchangeRatesResponse(val rates: List) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt new file mode 100644 index 000000000..bb2ec06bc --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPair.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.otc + +import co.nilin.opex.common.OpexError + +data class CurrencyPair( + val sourceSymbol: String, + val destSymbol: String +) { + fun validate() { + if (sourceSymbol == destSymbol) + throw OpexError.SourceIsEqualDest.exception() + } +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt new file mode 100644 index 000000000..ac6536b5e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/CurrencyPrice.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class CurrencyPrice(var currency: String, val buyPrice: BigDecimal? = null, var sellPrice: BigDecimal? = null) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt new file mode 100644 index 000000000..9dbbbf16d --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/ForbiddenPair.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout.otc + +data class ForbiddenPair( + val sourceSymbol: String, + val destinationSymbol: String +) + +data class ForbiddenPairs( + var forbiddenPairs: List? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt new file mode 100644 index 000000000..f1b9f3646 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Rate.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.otc + +import java.math.BigDecimal + +data class Rate( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal +) + +data class Rates( + var rates: List? +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt new file mode 100644 index 000000000..ed2ee64c5 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/SetCurrencyExchangeRateRequest.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.core.inout.otc + +import co.nilin.opex.common.OpexError +import java.math.BigDecimal + +class SetCurrencyExchangeRateRequest( + val sourceSymbol: String, + val destSymbol: String, + val rate: BigDecimal, + var ignoreIfExist: Boolean? = false +) { + fun validate() { + if (rate <= BigDecimal.ZERO) + throw OpexError.InvalidRate.exception() + else if (sourceSymbol == destSymbol) + throw OpexError.SourceIsEqualDest.exception() + } +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt new file mode 100644 index 000000000..2ae155ad7 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/otc/Symbols.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.inout.otc + +data class Symbols(var symbols: List?) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt new file mode 100644 index 000000000..1be97ce42 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateProxy.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.otc.* + +interface RateProxy { + // Rates (writes require admin token) + suspend fun createRate(token: String, request: SetCurrencyExchangeRateRequest) + suspend fun updateRate(token: String, request: SetCurrencyExchangeRateRequest): Rates + suspend fun deleteRate(token: String, sourceSymbol: String, destSymbol: String): Rates + + // Rates (reads are public) + suspend fun fetchRates(): Rates + suspend fun fetchRate(sourceSymbol: String, destSymbol: String): Rate? + + // Forbidden pairs + suspend fun addForbiddenPair(token: String, request: CurrencyPair) + suspend fun deleteForbiddenPair(token: String, sourceSymbol: String, destSymbol: String): ForbiddenPairs + + // Forbidden pairs (read is public) + suspend fun fetchForbiddenPairs(): ForbiddenPairs + + // Transitive symbols + suspend fun addTransitiveSymbols(token: String, symbols: Symbols) + suspend fun deleteTransitiveSymbol(token: String, symbol: String): Symbols + suspend fun deleteTransitiveSymbols(token: String, symbols: Symbols): Symbols + + // Transitive symbols (read is public) + suspend fun fetchTransitiveSymbols(): Symbols + + // Routes and prices (reads are public) + suspend fun fetchRoutes(sourceSymbol: String? = null, destSymbol: String? = null): CurrencyExchangeRatesResponse + suspend fun getPrice(unit: String): List +} 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 99e9974f7..499b644eb 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 @@ -21,72 +21,75 @@ import org.springframework.web.server.WebFilter @EnableWebFluxSecurity @Configuration("binanceSecurityConfig") class SecurityConfig( - private val apiKeyFilter: APIKeyFilter, - @Value("\${app.auth.cert-url}") - private val certUrl: String, - @Value("\${app.auth.iss-url}") - private val issUrl: String + private val apiKeyFilter: APIKeyFilter, + @Value("\${app.auth.cert-url}") + private val certUrl: String, + @Value("\${app.auth.iss-url}") + private val issUrl: String ) { @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.csrf { it.disable() } - .authorizeExchange { - it.pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/v2/api-docs").permitAll() - .pathMatchers("/v3/depth").permitAll() - .pathMatchers("/v3/trades").permitAll() - .pathMatchers("/v3/ticker/**").permitAll() - .pathMatchers("/v3/exchangeInfo").permitAll() - .pathMatchers("/v3/currencyInfo/**").permitAll() - .pathMatchers("/v3/klines").permitAll() - .pathMatchers("/socket").permitAll() - .pathMatchers("/v1/landing/**").permitAll() - .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") + .authorizeExchange { + it.pathMatchers("/actuator/**").permitAll() + .pathMatchers("/swagger-ui/**").permitAll() + .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/v2/api-docs").permitAll() + .pathMatchers("/v3/depth").permitAll() + .pathMatchers("/v3/trades").permitAll() + .pathMatchers("/v3/ticker/**").permitAll() + .pathMatchers("/v3/exchangeInfo").permitAll() + .pathMatchers("/v3/currencyInfo/**").permitAll() + .pathMatchers("/v3/klines").permitAll() + .pathMatchers("/socket").permitAll() + .pathMatchers("/v1/landing/**").permitAll() + .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") - // Opex endpoints - .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") - .pathMatchers("/opex/v1/admin/**").hasAuthority("ROLE_admin") - .pathMatchers("/opex/v1/deposit/**").hasAuthority("DEPOSIT_deposit:write") - .pathMatchers(HttpMethod.POST, "/opex/v1/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.PUT, "/opex/v1/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.POST, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") - .pathMatchers(HttpMethod.PUT, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") - .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") - .pathMatchers("/opex/v1/market/**").permitAll() - .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() - .pathMatchers(HttpMethod.POST,"/v1/api-key").authenticated() - .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") - .anyExchange().authenticated() - } - .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) - .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } - .build() + // Opex endpoints + .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") + .pathMatchers("/opex/v1/admin/**").hasAuthority("ROLE_admin") + .pathMatchers("/opex/v1/deposit/**").hasAuthority("PERM_deposit:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") + .pathMatchers("/opex/v1/market/**").permitAll() + .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() + .pathMatchers(HttpMethod.POST, "/v1/api-key").authenticated() + .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") + .pathMatchers(HttpMethod.PUT, "/opex/v1/otc/rate").hasAnyAuthority("ROLE_admin", "ROLE_rate_bot") + .pathMatchers(HttpMethod.GET, "/opex/v1/otc/**").permitAll() + .pathMatchers("/opex/v1/otc/**").hasAuthority("ROLE_admin") + .anyExchange().authenticated() + } + .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } + .build() } @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) - .webClient(WebClient.create()) - .build() + .webClient(WebClient.create()) + .build() val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) val audienceValidator = AudienceValidator( - setOf( - "ios-app", - "web-app", - "android-app", - "opex-api-key" - ) + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) ) decoder.setJwtValidator( - DelegatingOAuth2TokenValidator( - issuerValidator, - audienceValidator - ) + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) ) return decoder } diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt new file mode 100644 index 000000000..717bfdd61 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/CurrencyRatesController.kt @@ -0,0 +1,127 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.otc.* +import co.nilin.opex.api.core.spi.RateProxy +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping( "/opex/v1/otc") +class CurrencyRatesController( + private val rateProxy: RateProxy +) { + + // Rates + @PostMapping("/rate") + suspend fun createRate( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: SetCurrencyExchangeRateRequest + ) { + request.validate() + rateProxy.createRate(securityContext.jwtAuthentication().tokenValue(), request) + } + + @PutMapping("/rate") + suspend fun updateRate( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: SetCurrencyExchangeRateRequest + ): Rates { + request.validate() + return rateProxy.updateRate(securityContext.jwtAuthentication().tokenValue(), request) + } + + @DeleteMapping("/rate/{sourceSymbol}/{destSymbol}") + suspend fun deleteRate( + @CurrentSecurityContext securityContext: SecurityContext, + @PathVariable sourceSymbol: String, + @PathVariable destSymbol: String + ): Rates { + return rateProxy.deleteRate(securityContext.jwtAuthentication().tokenValue(), sourceSymbol, destSymbol) + } + + @GetMapping("/rate") + suspend fun fetchRates(): Rates { + return rateProxy.fetchRates() + } + + @GetMapping("/rate/{sourceSymbol}/{destSymbol}") + suspend fun fetchRate( + @PathVariable sourceSymbol: String, + @PathVariable destSymbol: String + ): Rate? { + return rateProxy.fetchRate(sourceSymbol, destSymbol) + } + + // Forbidden pairs + @PostMapping("/forbidden-pairs") + suspend fun addForbiddenPair( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: CurrencyPair + ) { + request.validate() + rateProxy.addForbiddenPair(securityContext.jwtAuthentication().tokenValue(), request) + } + + @DeleteMapping("/forbidden-pairs/{sourceSymbol}/{destSymbol}") + suspend fun deleteForbiddenPair( + @CurrentSecurityContext securityContext: SecurityContext, + @PathVariable sourceSymbol: String, + @PathVariable destSymbol: String + ): ForbiddenPairs { + return rateProxy.deleteForbiddenPair(securityContext.jwtAuthentication().tokenValue(), sourceSymbol, destSymbol) + } + + @GetMapping("/forbidden-pairs") + suspend fun fetchForbiddenPairs(): ForbiddenPairs { + return rateProxy.fetchForbiddenPairs() + } + + // Transitive symbols + @PostMapping("/transitive-symbols") + suspend fun addTransitiveSymbols( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody symbols: Symbols + ) { + rateProxy.addTransitiveSymbols(securityContext.jwtAuthentication().tokenValue(), symbols) + } + + @DeleteMapping("/transitive-symbols/{symbol}") + suspend fun deleteTransitiveSymbols( + @CurrentSecurityContext securityContext: SecurityContext, + @PathVariable symbol: String + ): Symbols { + return rateProxy.deleteTransitiveSymbol(securityContext.jwtAuthentication().tokenValue(), symbol) + } + + @DeleteMapping("/transitive-symbols") + suspend fun deleteTransitiveSymbols( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody symbols: Symbols + ): Symbols { + return rateProxy.deleteTransitiveSymbols(securityContext.jwtAuthentication().tokenValue(), symbols) + } + + @GetMapping("/transitive-symbols") + suspend fun fetchTransitiveSymbols(): Symbols { + return rateProxy.fetchTransitiveSymbols() + } + + // Routes and prices + @RequestMapping("/route", method = [RequestMethod.POST, RequestMethod.GET]) + suspend fun fetchRoutes( + @RequestParam("sourceSymbol") sourceSymbol: String? = null, + @RequestParam("destSymbol") destSymbol: String? = null + ): CurrencyExchangeRatesResponse { + return rateProxy.fetchRoutes(sourceSymbol, destSymbol) + } + + @GetMapping("/currency/price") + suspend fun getPrice( + @RequestParam("unit") unit: String + ): List { + return rateProxy.getPrice(unit) + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt new file mode 100644 index 000000000..6c86f7eaf --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserAnalyticsController.kt @@ -0,0 +1,86 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.analytics.ActivityTotals +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.* +import kotlin.random.Random + +@RestController +@RequestMapping("/opex/v1/analytics") +class UserAnalyticsController { + + @GetMapping("/user-activity") + suspend fun userActivity(@CurrentSecurityContext securityContext: SecurityContext): Map { + val jwt = securityContext.jwtAuthentication().token + val uuid = jwt.subject ?: "unknown" + + val zone = ZoneId.systemDefault() + val todayStart = LocalDate.now(zone).atStartOfDay(zone).toInstant() + + val days = 30 + val result = LinkedHashMap(days) + + // Initial balance seeded by user hash + var runningBalance = BigDecimal.valueOf(100 + (uuid.hashCode() and Int.MAX_VALUE) % 900L) + + for (i in (days - 1) downTo 0) { + val dayInstant = todayStart.minusSeconds(86400L * i) + val dayKey = dayInstant.toEpochMilli().toString() + + // deterministic seed per user+day + val seed = deterministicSeed(uuid.hashCode(), dayInstant.toEpochMilli()) + val rnd = Random(seed) + + val base = BigDecimal.valueOf((50 + rnd.nextInt(0, 950)).toLong()) // 50..999 + + val deposit = scaleMoney(base.multiply(BigDecimal.valueOf(rnd.nextDouble(0.0, 5.0)))) + val withdraw = scaleMoney(deposit.multiply(BigDecimal.valueOf(rnd.nextDouble(0.0, 0.9)))) + + val trade = scaleMoney(base.multiply(BigDecimal.valueOf(rnd.nextDouble(0.5, 8.0)))) + val swap = scaleMoney(base.multiply(BigDecimal.valueOf(rnd.nextDouble(0.0, 3.0)))) + + val pnlDrift = scaleMoney(trade.multiply(BigDecimal.valueOf(rnd.nextDouble(-0.01, 0.01)))) + + runningBalance = (runningBalance + deposit - withdraw + pnlDrift).coerceAtLeast(BigDecimal.ZERO) + + result[dayKey] = ActivityTotals( + totalBalance = runningBalance.min(MAX_BALANCE), + totalWithdraw = withdraw, + totalDeposit = deposit, + totalTrade = trade.min(MAX_TRADE), + totalSwap = swap.min(MAX_SWAP) + ) + } + + return result + } + + private fun scaleMoney(v: BigDecimal) = v.setScale(2, RoundingMode.HALF_UP) + + private val MAX_BALANCE = BigDecimal("10000000") + private val MAX_TRADE = BigDecimal("250000") + private val MAX_SWAP = BigDecimal("100000") + + /** + * Simple deterministic seed generator for a user and day. + * Combines user hash and day epoch millis into a reproducible seed. + */ + private fun deterministicSeed(userHash: Int, dayEpochMillis: Long): Long { + var seed = userHash.toLong() * 31 + dayEpochMillis + // simple bit mixing + seed = seed xor (seed shr 33) + seed *= 0xBF58476D1CE4E5BL + seed = seed xor (seed shr 33) + seed *= 0xc4ceb9fe1a85ec5L + seed = seed xor (seed shr 33) + return seed + } + +} diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt new file mode 100644 index 000000000..0258151b9 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/RateProxyImpl.kt @@ -0,0 +1,196 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.inout.otc.* +import co.nilin.opex.api.core.spi.RateProxy +import co.nilin.opex.api.ports.proxy.config.ProxyDispatchers +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.withContext +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToFlux +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@Component +class RateProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : RateProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.wallet.url}") + private lateinit var baseUrl: String + + // Rates + override suspend fun createRate(token: String, request: SetCurrencyExchangeRateRequest) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/rate") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun updateRate(token: String, request: SetCurrencyExchangeRateRequest): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.put() + .uri("$baseUrl/otc/rate") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun deleteRate(token: String, sourceSymbol: String, destSymbol: String): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/rate/$sourceSymbol/$destSymbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchRates(): Rates { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/rate") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchRate(sourceSymbol: String, destSymbol: String): Rate? { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/rate/$sourceSymbol/$destSymbol") + .retrieve() + .bodyToMono() + .awaitFirstOrNull() + } + } + + // Forbidden pairs + override suspend fun addForbiddenPair(token: String, request: CurrencyPair) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/forbidden-pairs") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun deleteForbiddenPair(token: String, sourceSymbol: String, destSymbol: String): ForbiddenPairs { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/forbidden-pairs/$sourceSymbol/$destSymbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchForbiddenPairs(): ForbiddenPairs { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/forbidden-pairs") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + // Transitive symbols + override suspend fun addTransitiveSymbols(token: String, symbols: Symbols) { + withContext(ProxyDispatchers.wallet) { + webClient.post() + .uri("$baseUrl/otc/transitive-symbols") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(symbols)) + .retrieve() + .toBodilessEntity() + .awaitFirstOrElse { null } + } + } + + override suspend fun deleteTransitiveSymbol(token: String, symbol: String): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.delete() + .uri("$baseUrl/otc/transitive-symbols/$symbol") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun deleteTransitiveSymbols(token: String, symbols: Symbols): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.method(HttpMethod.DELETE) + .uri("$baseUrl/otc/transitive-symbols") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(Mono.just(symbols)) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun fetchTransitiveSymbols(): Symbols { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/transitive-symbols") + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + // Routes and prices + override suspend fun fetchRoutes(sourceSymbol: String?, destSymbol: String?): CurrencyExchangeRatesResponse { + var uri = "$baseUrl/otc/route" + if (sourceSymbol != null) uri = "$uri?sourceSymbol=$sourceSymbol" + if (destSymbol != null) uri = "$uri?destSymbol=$destSymbol" + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri(uri) + .retrieve() + .bodyToMono() + .awaitSingle() + } + } + + override suspend fun getPrice(unit: String): List { + return withContext(ProxyDispatchers.wallet) { + webClient.get() + .uri("$baseUrl/otc/currency/price?unit=$unit") + .retrieve() + .bodyToFlux() + .collectList() + .awaitSingle() + } + } +} 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 f9619d437..b040a0b58 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 @@ -60,6 +60,7 @@ class SecurityConfig(private val webClient: WebClient) { //otc .pathMatchers("/admin/**").hasAuthority("ROLE_admin") .pathMatchers(HttpMethod.GET, "/otc/**").permitAll() + .pathMatchers(HttpMethod.PUT, "/otc/rate").hasAnyAuthority("ROLE_rate_bot","ROLE_admin") .pathMatchers(HttpMethod.PUT, "/otc/**").hasAuthority("ROLE_admin") .pathMatchers(HttpMethod.POST, "/otc/**").hasAuthority("ROLE_admin") .pathMatchers(HttpMethod.DELETE, "/otc/**").hasAuthority("ROLE_admin") From 3a7159adf70b009640dd0f5a7abae4aaf7b7e52a Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Mon, 29 Dec 2025 18:39:38 +0330 Subject: [PATCH 14/15] Adjust the code style --- .../api/app/interceptor/APIKeyFilterImpl.kt | 63 ------------------- .../ports/binance/config/SecurityConfig.kt | 54 ++++++++-------- 2 files changed, 27 insertions(+), 90 deletions(-) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt index 37d63a646..a02319314 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt @@ -89,69 +89,6 @@ class APIKeyFilterImpl( } } - // Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret - // against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token. - val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull() - if (apiKeyId.isNullOrBlank() || legacySecret.isNullOrBlank()) { - // HMAC path when signature present - if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) { - return mono { - val entry = apiKeyService.getApiKeyForVerification(apiKeyId) - if (entry == null || !entry.enabled) { - logger.warn("Unknown or disabled API key: {}", apiKeyId) - null - } else { - // Optional IP allowlist - val sourceIp = request.remoteAddress?.address?.hostAddress - if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { - logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp) - null - } - if (!entry.allowedEndpoints.isNullOrEmpty() && (!entry.allowedEndpoints!!.contains(path))) { - logger.warn("API key {} request to unauthorized resource {}", apiKeyId, path) - null - } else { - val ts = tsHeader.toLongOrNull() - val bodyHash = request.headers["X-API-BODY-SHA256"]?.firstOrNull() - if (ts == null) { - logger.warn("Invalid timestamp header for bot {}", apiKeyId) - null - } else { - val ok = hmacVerifier.verify( - entry.secret, - signature, - HmacVerifier.VerificationInput( - method = request.method.name(), - path = path, - query = uri.rawQuery, - timestampMillis = ts, - bodySha256 = bodyHash - ) - ) - if (!ok) { - logger.warn("Invalid signature for apiKey {}", apiKeyId) - null - } else { - val userId = entry.keycloakUserId - if (userId.isNullOrBlank()) { - logger.warn("API key {} has no mapped Keycloak userId; rejecting", apiKeyId) - null - } else { - val bearer = clientTokenService.exchangeToUserToken(userId) - val req = request.mutate() - .header("Authorization", "Bearer $bearer") - .build() - exchange.mutate().request(req).build() - } - } - } - } - } - }.flatMap { updatedExchange -> - if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) - } - } - // Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret // against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token. val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull() 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 fea10906b..499b644eb 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 @@ -31,21 +31,21 @@ class SecurityConfig( @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.csrf { it.disable() } - .authorizeExchange { - it.pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/v2/api-docs").permitAll() - .pathMatchers("/v3/depth").permitAll() - .pathMatchers("/v3/trades").permitAll() - .pathMatchers("/v3/ticker/**").permitAll() - .pathMatchers("/v3/exchangeInfo").permitAll() - .pathMatchers("/v3/currencyInfo/**").permitAll() - .pathMatchers("/v3/klines").permitAll() - .pathMatchers("/socket").permitAll() - .pathMatchers("/v1/landing/**").permitAll() - .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") + .authorizeExchange { + it.pathMatchers("/actuator/**").permitAll() + .pathMatchers("/swagger-ui/**").permitAll() + .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/v2/api-docs").permitAll() + .pathMatchers("/v3/depth").permitAll() + .pathMatchers("/v3/trades").permitAll() + .pathMatchers("/v3/ticker/**").permitAll() + .pathMatchers("/v3/exchangeInfo").permitAll() + .pathMatchers("/v3/currencyInfo/**").permitAll() + .pathMatchers("/v3/klines").permitAll() + .pathMatchers("/socket").permitAll() + .pathMatchers("/v1/landing/**").permitAll() + .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") // Opex endpoints .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") @@ -74,22 +74,22 @@ class SecurityConfig( @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) - .webClient(WebClient.create()) - .build() + .webClient(WebClient.create()) + .build() val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) val audienceValidator = AudienceValidator( - setOf( - "ios-app", - "web-app", - "android-app", - "opex-api-key" - ) + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) ) decoder.setJwtValidator( - DelegatingOAuth2TokenValidator( - issuerValidator, - audienceValidator - ) + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) ) return decoder } From 79ff82039808d293a0728a489b273ba7a0ce0afa Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Mon, 29 Dec 2025 18:41:14 +0330 Subject: [PATCH 15/15] Adjust the code style --- .../ports/binance/config/SecurityConfig.kt | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) 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 499b644eb..972a90826 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 @@ -21,75 +21,75 @@ import org.springframework.web.server.WebFilter @EnableWebFluxSecurity @Configuration("binanceSecurityConfig") class SecurityConfig( - private val apiKeyFilter: APIKeyFilter, - @Value("\${app.auth.cert-url}") - private val certUrl: String, - @Value("\${app.auth.iss-url}") - private val issUrl: String + private val apiKeyFilter: APIKeyFilter, + @Value("\${app.auth.cert-url}") + private val certUrl: String, + @Value("\${app.auth.iss-url}") + private val issUrl: String ) { @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.csrf { it.disable() } - .authorizeExchange { - it.pathMatchers("/actuator/**").permitAll() - .pathMatchers("/swagger-ui/**").permitAll() - .pathMatchers("/swagger-resources/**").permitAll() - .pathMatchers("/v2/api-docs").permitAll() - .pathMatchers("/v3/depth").permitAll() - .pathMatchers("/v3/trades").permitAll() - .pathMatchers("/v3/ticker/**").permitAll() - .pathMatchers("/v3/exchangeInfo").permitAll() - .pathMatchers("/v3/currencyInfo/**").permitAll() - .pathMatchers("/v3/klines").permitAll() - .pathMatchers("/socket").permitAll() - .pathMatchers("/v1/landing/**").permitAll() - .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") + .authorizeExchange { + it.pathMatchers("/actuator/**").permitAll() + .pathMatchers("/swagger-ui/**").permitAll() + .pathMatchers("/swagger-resources/**").permitAll() + .pathMatchers("/v2/api-docs").permitAll() + .pathMatchers("/v3/depth").permitAll() + .pathMatchers("/v3/trades").permitAll() + .pathMatchers("/v3/ticker/**").permitAll() + .pathMatchers("/v3/exchangeInfo").permitAll() + .pathMatchers("/v3/currencyInfo/**").permitAll() + .pathMatchers("/v3/klines").permitAll() + .pathMatchers("/socket").permitAll() + .pathMatchers("/v1/landing/**").permitAll() + .pathMatchers(HttpMethod.POST, "/v3/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") - // Opex endpoints - .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") - .pathMatchers("/opex/v1/admin/**").hasAuthority("ROLE_admin") - .pathMatchers("/opex/v1/deposit/**").hasAuthority("PERM_deposit:write") - .pathMatchers(HttpMethod.POST, "/opex/v1/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.PUT, "/opex/v1/order").hasAuthority("PERM_order:write") - .pathMatchers(HttpMethod.POST, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") - .pathMatchers(HttpMethod.PUT, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") - .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") - .pathMatchers("/opex/v1/market/**").permitAll() - .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() - .pathMatchers(HttpMethod.POST, "/v1/api-key").authenticated() - .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") - .pathMatchers(HttpMethod.PUT, "/opex/v1/otc/rate").hasAnyAuthority("ROLE_admin", "ROLE_rate_bot") - .pathMatchers(HttpMethod.GET, "/opex/v1/otc/**").permitAll() - .pathMatchers("/opex/v1/otc/**").hasAuthority("ROLE_admin") - .anyExchange().authenticated() - } - .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) - .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } - .build() + // Opex endpoints + .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") + .pathMatchers("/opex/v1/admin/**").hasAuthority("ROLE_admin") + .pathMatchers("/opex/v1/deposit/**").hasAuthority("PERM_deposit:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/order").hasAuthority("PERM_order:write") + .pathMatchers(HttpMethod.POST, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers(HttpMethod.PUT, "/opex/v1/withdraw").hasAuthority("PERM_withdraw:write") + .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") + .pathMatchers("/opex/v1/market/**").permitAll() + .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() + .pathMatchers(HttpMethod.POST, "/v1/api-key").authenticated() + .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") + .pathMatchers(HttpMethod.PUT, "/opex/v1/otc/rate").hasAnyAuthority("ROLE_admin", "ROLE_rate_bot") + .pathMatchers(HttpMethod.GET, "/opex/v1/otc/**").permitAll() + .pathMatchers("/opex/v1/otc/**").hasAuthority("ROLE_admin") + .anyExchange().authenticated() + } + .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtAuthenticationConverter(ReactiveCustomJwtConverter()) } } + .build() } @Bean @Throws(Exception::class) fun reactiveJwtDecoder(): ReactiveJwtDecoder? { val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) - .webClient(WebClient.create()) - .build() + .webClient(WebClient.create()) + .build() val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) val audienceValidator = AudienceValidator( - setOf( - "ios-app", - "web-app", - "android-app", - "opex-api-key" - ) + setOf( + "ios-app", + "web-app", + "android-app", + "opex-api-key" + ) ) decoder.setJwtValidator( - DelegatingOAuth2TokenValidator( - issuerValidator, - audienceValidator - ) + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) ) return decoder }