From d812b1cc5f6bf2992c09abd564dea3c3e3368a5d Mon Sep 17 00:00:00 2001 From: fatemeh imanipour Date: Sat, 9 May 2026 16:17:00 +0330 Subject: [PATCH 1/3] Add interceptors dependency to all related modules --- accountant/accountant-app/pom.xml | 4 ++++ accountant/pom.xml | 5 +++++ auth-gateway/auth-gateway-app/pom.xml | 4 ++++ bc-gateway/bc-gateway-app/pom.xml | 8 ++++++++ device-management/device-management-app/pom.xml | 4 ++++ device-management/pom.xml | 5 +++++ docker-compose.yml | 4 ++-- matching-gateway/matching-gateway-app/pom.xml | 4 ++++ otp/otp-app/pom.xml | 4 ++++ profile/pom.xml | 5 +++++ profile/profile-app/pom.xml | 4 ++++ user-management/pom.xml | 5 +++++ 12 files changed, 54 insertions(+), 2 deletions(-) diff --git a/accountant/accountant-app/pom.xml b/accountant/accountant-app/pom.xml index 67649ff75..f56aee06a 100644 --- a/accountant/accountant-app/pom.xml +++ b/accountant/accountant-app/pom.xml @@ -51,6 +51,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + org.springframework.cloud spring-cloud-starter-vault-config diff --git a/accountant/pom.xml b/accountant/pom.xml index 6eded39f6..edce57a88 100644 --- a/accountant/pom.xml +++ b/accountant/pom.xml @@ -82,6 +82,11 @@ error-handler ${error-hanlder.version} + + co.nilin.opex.utility + interceptors + ${interceptor.version} + org.springframework.cloud spring-cloud-dependencies diff --git a/auth-gateway/auth-gateway-app/pom.xml b/auth-gateway/auth-gateway-app/pom.xml index 0d31ffddd..0ff3a5ade 100644 --- a/auth-gateway/auth-gateway-app/pom.xml +++ b/auth-gateway/auth-gateway-app/pom.xml @@ -80,6 +80,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + com.auth0 java-jwt diff --git a/bc-gateway/bc-gateway-app/pom.xml b/bc-gateway/bc-gateway-app/pom.xml index 2bdccb667..0046a178b 100644 --- a/bc-gateway/bc-gateway-app/pom.xml +++ b/bc-gateway/bc-gateway-app/pom.xml @@ -138,6 +138,14 @@ micrometer-registry-prometheus runtime + + co.nilin.opex.utility + interceptors + + + co.nilin.opex.utility + error-handler + diff --git a/device-management/device-management-app/pom.xml b/device-management/device-management-app/pom.xml index 82e41ff4e..ff9b70048 100644 --- a/device-management/device-management-app/pom.xml +++ b/device-management/device-management-app/pom.xml @@ -78,6 +78,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + com.zaxxer HikariCP diff --git a/device-management/pom.xml b/device-management/pom.xml index d24596637..4800bfa7c 100644 --- a/device-management/pom.xml +++ b/device-management/pom.xml @@ -56,6 +56,11 @@ error-handler ${error-hanlder.version} + + co.nilin.opex.utility + interceptors + ${interceptor.version} + co.nilin.opex.device-management device-management-core diff --git a/docker-compose.yml b/docker-compose.yml index 63535e6e2..b9078536e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -546,8 +546,8 @@ services: - SMTP_USER=${SMTP_USER} - SMTP_PASS=${SMTP_PASS} - SMTP_FROM=${SMTP_FROM} - - SMTP_SOCKS_HOST=${SMTP_SOCKS_HOST} - - SMTP_SOCKS_PORT=${SMTP_SOCKS_PORT} + - SMTP_PROXY_HOST=${SMTP_PROXY_HOST} + - SMTP_PROXY_PORT=${SMTP_PROXY_PORT} - SMTP_PROXY_ENABLED=${SMTP_PROXY_ENABLED} - TOKEN_ISSUER_URL=${KC_ISSUER_URL} - OTP_CODE_RESPONSE_ENABLED=${OTP_CODE_RESPONSE_ENABLED} diff --git a/matching-gateway/matching-gateway-app/pom.xml b/matching-gateway/matching-gateway-app/pom.xml index 24e94ab58..86d029320 100644 --- a/matching-gateway/matching-gateway-app/pom.xml +++ b/matching-gateway/matching-gateway-app/pom.xml @@ -77,6 +77,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + io.mockk mockk diff --git a/otp/otp-app/pom.xml b/otp/otp-app/pom.xml index 1d3d72124..49cb4f0c0 100644 --- a/otp/otp-app/pom.xml +++ b/otp/otp-app/pom.xml @@ -87,6 +87,10 @@ co.nilin.opex.utility error-handler + + co.nilin.opex.utility + interceptors + io.mockk mockk diff --git a/profile/pom.xml b/profile/pom.xml index 7ac615097..ffd3ab564 100644 --- a/profile/pom.xml +++ b/profile/pom.xml @@ -66,6 +66,11 @@ logging-handler ${logging-handler.version} + + co.nilin.opex.utility + error-handler + ${error-hanlder.version} + co.nilin.opex.utility interceptors diff --git a/profile/profile-app/pom.xml b/profile/profile-app/pom.xml index 685459a22..2b2412044 100644 --- a/profile/profile-app/pom.xml +++ b/profile/profile-app/pom.xml @@ -98,6 +98,10 @@ org.springframework.cloud spring-cloud-starter-vault-config + + co.nilin.opex.utility + error-handler + co.nilin.opex.utility interceptors diff --git a/user-management/pom.xml b/user-management/pom.xml index 0c0b0c5a4..fbb1146c0 100644 --- a/user-management/pom.xml +++ b/user-management/pom.xml @@ -49,6 +49,11 @@ error-handler ${error-hanlder.version} + + co.nilin.opex.utility + interceptors + ${interceptor.version} + co.nilin.opex.auth user-management-core From 8d1ec6f0089c12f059ea3db966302d8f8e803cd1 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Wed, 22 Jul 2026 13:09:08 +0330 Subject: [PATCH 2/3] add auth services to api module and apply rate limit --- .../opex/api/app/config/RateLimitConfig.kt | 41 +--- .../api/app/impl/ManualRateLimiterImpl.kt | 35 +++ .../src/main/resources/application.yml | 4 +- .../core/inout/ManualRateLimitGroupType.kt | 5 + .../opex/api/core/inout/auth/Attribute.kt | 13 + .../nilin/opex/api/core/inout/auth/Captcha.kt | 5 + .../nilin/opex/api/core/inout/auth/Device.kt | 8 + .../co/nilin/opex/api/core/inout/auth/OTP.kt | 33 +++ .../nilin/opex/api/core/inout/auth/Session.kt | 27 +++ .../api/core/inout/auth/SessionRequest.kt | 10 + .../nilin/opex/api/core/inout/auth/Token.kt | 88 +++++++ .../opex/api/core/inout/auth/UserRegister.kt | 63 +++++ .../co/nilin/opex/api/core/spi/AuthProxy.kt | 25 ++ .../api/core/spi/ManualRateLimiterService.kt | 12 + .../api/core/spi/RateLimitConfigService.kt | 1 + .../ports/binance/config/SecurityConfig.kt | 4 + api/api-ports/api-opex-rest/pom.xml | 4 + .../ports/opex/controller/AuthController.kt | 163 +++++++++++++ .../opex/controller/PublicAuthController.kt | 197 +++++++++++++++ .../opex/controller/SessionController.kt | 144 +++++++++++ .../postgres/impl/RateLimitConfigImpl.kt | 10 +- .../api/ports/proxy/impl/AuthProxyImpl.kt | 226 ++++++++++++++++++ docker-compose.yml | 1 + otp/otp-app/src/main/resources/schema.sql | 4 +- 24 files changed, 1083 insertions(+), 40 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt create mode 100644 api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt index e11c77efa..fd9167451 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/RateLimitConfig.kt @@ -4,7 +4,6 @@ import co.nilin.opex.api.app.service.RateLimitCoordinatorService import co.nilin.opex.api.core.spi.RateLimitConfigService import co.nilin.opex.common.OpexError import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatus import org.springframework.security.core.context.ReactiveSecurityContextHolder import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange @@ -45,7 +44,6 @@ class RateLimitConfig( ) } - private fun applyRateLimitIfAuthenticated( exchange: ServerWebExchange, chain: WebFilterChain, @@ -56,17 +54,13 @@ class RateLimitConfig( .mapNotNull { it.authentication } .filter { it.isAuthenticated } .flatMap { auth -> - if (auth != null && !auth.name.isNullOrBlank()) - applyRateLimit( - auth.name, exchange, chain, groupId - ) - else - chain.filter(exchange) + applyRateLimit(auth.name, exchange, chain, groupId) } - + .switchIfEmpty( + chain.filter(exchange) + ) } - private fun applyRateLimit( identity: String, exchange: ServerWebExchange, @@ -87,34 +81,9 @@ class RateLimitConfig( ) return if (result.blocked) { - tooManyRequests( - exchange, - identity, - exchange.request.uri.path, - exchange.request.method.name(), - result.retryAfterSeconds - ) + throw OpexError.RateLimit.exception() } else { chain.filter(exchange) } } - - //TODO should throw opex error - private fun tooManyRequests( - exchange: ServerWebExchange, - identity: String, - url: String, - method: String, - retryAfterSeconds: Int - ): Mono { - logger.info("Rate limit exceeded ($identity) -- $method:$url") -// exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS - throw OpexError.RateLimit.exception() -// return exchange.response.writeWith( -// Mono.just( -// exchange.response.bufferFactory() -// .wrap("Rate limit exceeded ($identity) -- $method:$url -- Retry-After, $retryAfterSeconds".toByteArray()) -// ) -// ) - } } \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt new file mode 100644 index 000000000..40d6b9f0d --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/impl/ManualRateLimiterImpl.kt @@ -0,0 +1,35 @@ +package co.nilin.opex.api.app.impl + +import co.nilin.opex.api.app.service.RateLimitCoordinatorService +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.spi.ManualRateLimiterService +import co.nilin.opex.api.core.spi.RateLimitConfigService +import co.nilin.opex.common.OpexError +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange + +@Component +class ManualRateLimiterImpl( + private val rateLimitConfig: RateLimitConfigService, + private val coordinator: RateLimitCoordinatorService +) : ManualRateLimiterService { + + override fun check( + identity: String, + group: ManualRateLimitGroupType, + exchange: ServerWebExchange + ) { + val group = rateLimitConfig.getGroup(group.name) ?: return + val result = coordinator.check( + identity = identity, + groupId = group.id!!, + maxRequests = group.requestCount, + windowSeconds = group.requestWindowSeconds, + apiPath = exchange.request.uri.path, + apiMethod = exchange.request.method.name() + ) + if (result.blocked) { + throw OpexError.RateLimit.exception() + } + } +} \ 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 61f39109e..1d538daa0 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -130,7 +130,9 @@ app: storage: url: http://storage config: - url: http://opex-config + url: http://opex-config + auth-gateway: + url: http://opex-auth-gateway auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt new file mode 100644 index 000000000..110844a65 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/ManualRateLimitGroupType.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout + +enum class ManualRateLimitGroupType { + VERIFY_OTP, +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt new file mode 100644 index 000000000..f04ab4739 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Attribute.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.core.inout.auth + +data class Attribute( + val key: String, + val value: String +) + +object Attributes { + + const val EMAIL = "email" + const val MOBILE = "mobile" + const val OTP = "otpConfig" +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt new file mode 100644 index 000000000..c428bcb6e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Captcha.kt @@ -0,0 +1,5 @@ +package co.nilin.opex.api.core.inout.auth + +enum class CaptchaType { + INTERNAL, ARCAPTCHA, HCAPTCHA +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt new file mode 100644 index 000000000..7162681b9 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Device.kt @@ -0,0 +1,8 @@ +package co.nilin.opex.api.core.inout.auth + +open class Device { + var deviceUuid: String?=null + var appVersion: String?=null + var osVersion: String?=null + var os: Os?=null +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt new file mode 100644 index 000000000..91d46420e --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/OTP.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.core.inout.auth + +import co.nilin.opex.api.core.inout.OTPType + +data class OTPReceiver( + val receiver: String, + val type: OTPType, +) + +data class OTPCode( + val code: String, + val otpType: OTPType, +) + +data class OTPVerifyRequest( + val userId: String, + val otpCodes: List +) + +data class OTPVerifyResponse( + val result: Boolean, + val type: OTPResultType +) + +data class TempOtpResponse(val otp: String?, val otpReceiver: OTPReceiver?) + +enum class OTPAction { + REGISTER, FORGET, NONE +} + +enum class OTPResultType { + VALID, EXPIRED, INCORRECT, INVALID +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt new file mode 100644 index 000000000..19b255363 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Session.kt @@ -0,0 +1,27 @@ +package co.nilin.opex.api.core.inout.auth + +import java.time.LocalDateTime + +data class Sessions( + val deviceUuid: String?, + val os: Os?, + val osVersion: String?, + val appVersion: String?, + val firstLoginDate: LocalDateTime?, + val lastLoginDate: LocalDateTime?, + val sessionState: String?, + val sessionStatus: SessionStatus?, + val sessionCreateDate: LocalDateTime?, + val sessionExpireDate: LocalDateTime?, + var isCurrentSession: Boolean?=false +) + +enum class SessionStatus { + ACTIVE, + EXPIRED, + TERMINATED +} + +enum class Os { + ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +} diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt new file mode 100644 index 000000000..85a001484 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/SessionRequest.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.inout.auth + +data class SessionRequest( + var uuid: String? = null, + val limit: Int = 10, + val offset: Int = 0, + val ascendingByTime: Boolean = false, + val os: Os? = null, + val status: SessionStatus? = null +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt new file mode 100644 index 000000000..6a346f78a --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/Token.kt @@ -0,0 +1,88 @@ +package co.nilin.opex.api.core.inout.auth + +import co.nilin.opex.api.core.inout.OTPType +import co.nilin.opex.api.core.inout.auth.CaptchaType +import com.fasterxml.jackson.annotation.JsonProperty + +data class PasswordFlowTokenRequest( + val username: String, + val password: String, + val clientId: String, + val clientSecret: String?, + val rememberMe: Boolean = true, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +):Device() + +data class ConfirmPasswordFlowTokenRequest( + val username: String, + val token: String, + val clientId: String, + val clientSecret: String?, + val otp: String, + val rememberMe: Boolean = true, +): Device() + +data class ResendOtpRequest( + val username: String, + val clientId: String +) + + +data class RefreshTokenRequest( + val clientId: String, + val clientSecret: String?, + val refreshToken: String +):Device() + +data class ExternalIdpTokenRequest( + val idToken: String, + val accessToken: String, + val idp: String, + val otpVerifyRequest: OTPVerifyRequest? +):Device() + +data class Token( + @JsonProperty("access_token") + val accessToken: String, // The access token + + @JsonProperty("expires_in") + val expiresIn: Int, // Expiration time of the access token in seconds + + @JsonProperty("refresh_expires_in") + var refreshExpiresIn: Int?, // Expiration time of the refresh token in seconds + + @JsonProperty("refresh_token") + var refreshToken: String?, // The refresh token + + @JsonProperty("token_type") + val tokenType: String?, // Type of token (usually "Bearer") + + @JsonProperty("not-before-policy") + val notBeforePolicy: Int?, // Timestamp indicating when the token becomes valid + + @JsonProperty("session_state") + val sessionState: String?, // Session state (optional) + + @JsonProperty("scope") + val scope: String? // Scopes associated with the token + +) + +data class TokenResponse( + val token: Token?, + val otp: RequiredOTP?, + //TODO IMPORTANT: remove in production + val otpCode: String?, +) + +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/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt new file mode 100644 index 000000000..a847316aa --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/auth/UserRegister.kt @@ -0,0 +1,63 @@ +package co.nilin.opex.api.core.inout.auth + +data class RegisterUserRequest( + val username: String, + val firstName: String? = null, + val lastName: String? = null, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) + +data class VerifyOTPRequest( + val username: String, + val otp: String, +) + +data class OTPActionTokenResponse( + val token: String, +) + +data class ConfirmRegisterRequest( + val password: String, + val token: String, + val clientId: String?, + val clientSecret: String?, +) : Device() + +data class TokenData( + val isValid: Boolean, + val userId: String, + val action: OTPAction, +) + +data class ExternalIdpUserRegisterRequest( + val idToken: String, + val idp: String, + val password: String, + val otpVerifyRequest: OTPVerifyRequest?, +) + +data class KeycloakUser( + val id: String, + val username: String, + val email: String?, + val firstName: String?, + val lastName: String?, + val emailVerified: Boolean, + val enabled: Boolean, + val attributes: Map>?, +) { + val mobile: String? = attributes?.get(Attributes.MOBILE)?.get(0) +} + +data class ConfirmForgetRequest( + val newPassword: String, + val newPasswordConfirmation: String, + val token: String, +) + +data class ForgotPasswordRequest( + val username: String, + val captchaType: CaptchaType? = CaptchaType.INTERNAL, + val captchaCode: String, +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt new file mode 100644 index 000000000..0fbf13dce --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt @@ -0,0 +1,25 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.auth.* + +interface AuthProxy { + + suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse + suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse + suspend fun resendLoginOtp(request: ResendOtpRequest, uuid: String): ResendOtpResponse + suspend fun getToken(request: ExternalIdpTokenRequest): TokenResponse + suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse + suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse + suspend fun verifyRegister(request: VerifyOTPRequest): OTPActionTokenResponse + suspend fun confirmRegister(request: ConfirmRegisterRequest): Token + suspend fun registerExternalIdpUser(request: ExternalIdpUserRegisterRequest): TokenResponse + suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse + suspend fun verifyForget(request: VerifyOTPRequest): OTPActionTokenResponse + suspend fun confirmForget(request: ConfirmForgetRequest) + suspend fun logout(token: String) + suspend fun logout(sessionId: String, token: String) + suspend fun getSessions(request: SessionRequest, token: String): List + suspend fun logoutOthers(token: String) + suspend fun logoutAll(token: String) + +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt new file mode 100644 index 000000000..9858315d1 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ManualRateLimiterService.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import org.springframework.web.server.ServerWebExchange + +interface ManualRateLimiterService { + fun check( + identity: String, + group: ManualRateLimitGroupType, + exchange: ServerWebExchange + ) +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt index c8fc9fe2b..20971f307 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/RateLimitConfigService.kt @@ -7,6 +7,7 @@ import co.nilin.opex.api.core.inout.RateLimitPenalty interface RateLimitConfigService { suspend fun loadConfig() fun getGroup(groupId: Long): RateLimitGroup? + fun getGroup(groupName: String): RateLimitGroup? fun getPenalties(groupId: Long): List fun getEndpoints(): List } \ No newline at end of file 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 9f982eb9a..ec58598c6 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 @@ -86,6 +86,10 @@ class SecurityConfig( .pathMatchers(HttpMethod.DELETE, "/v3/order").hasAuthority("PERM_order:write") // Opex endpoints + .pathMatchers("/opex/v1/oauth/protocol/openid-connect/**").permitAll() + .pathMatchers("/opex/v1/oauth.***").permitAll() + .pathMatchers("/opex/v1/user/public/**").permitAll() + .pathMatchers("/opex/v1/user/update/**").permitAll() .pathMatchers("/v1/deposit/webhook").permitAll() .pathMatchers("/opex/v1/admin/transactions/**").hasAnyAuthority("ROLE_monitoring", "ROLE_admin") .pathMatchers("/opex/v1/storage/**").permitAll() diff --git a/api/api-ports/api-opex-rest/pom.xml b/api/api-ports/api-opex-rest/pom.xml index 22b37a60d..6b533adf5 100644 --- a/api/api-ports/api-opex-rest/pom.xml +++ b/api/api-ports/api-opex-rest/pom.xml @@ -71,6 +71,10 @@ springdoc-openapi-starter-webflux-ui 2.8.14 + + org.springframework.boot + spring-boot-starter-validation + diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt new file mode 100644 index 000000000..1103e951d --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt @@ -0,0 +1,163 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.api.core.spi.ManualRateLimiterService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +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 +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ServerWebExchange + +@RestController +@RequestMapping("/opex/v1/oauth/protocol/openid-connect/") +@Tag( + name = "Auth Gateway - Token", + description = "Token, OTP confirmation, external IdP token, and refresh-token operations." +) +class AuthController(private val authProxy: AuthProxy, private val manualRateLimiterService: ManualRateLimiterService) { + @PostMapping("/token") + @Operation( + summary = "Request token", + description = """POST /opex/v1/oauth/protocol/openid-connect/token. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts password-flow login. If OTP is required, the response contains OTP metadata instead of a final access token. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) + suspend fun requestGetToken(@RequestBody tokenRequest: PasswordFlowTokenRequest): ResponseEntity { + val tokenResponse = authProxy.requestGetToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/token/confirm") + @Operation( + summary = "Confirm token request", + description = """POST /opex/v1/oauth/protocol/openid-connect/token/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `otp` and the pre-auth `token` returned by the token request flow are required. +Behavior: Completes password-flow login after OTP verification.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) + suspend fun confirmGetToken( + @RequestBody tokenRequest: ConfirmPasswordFlowTokenRequest, + @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(tokenRequest.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val tokenResponse = authProxy.confirmGetToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/token/resend-otp") + @Operation( + summary = "Resend login OTP", + description = """POST /opex/v1/oauth/protocol/openid-connect/token/resend-otp. +Security: Bearer pre-auth token required. + +Behavior: Resends the OTP for an in-progress login flow. +Source of values: Use the pre-auth token returned by the token request flow.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = ResendOtpResponse::class) + )] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + ) + ] + ) + suspend fun resendOtp( + @RequestBody resendOtpRequest: ResendOtpRequest, + @Parameter(hidden = true) + @CurrentSecurityContext securityContext: SecurityContext, + ): ResponseEntity { + val response = authProxy.resendLoginOtp(resendOtpRequest, securityContext.authentication.name) + return ResponseEntity.ok().body(response) + } + + @PostMapping("/token-external") + @Operation( + summary = "Request token by external IdP", + description = """POST /opex/v1/oauth/protocol/openid-connect/token-external. +Security: Public endpoint. No Bearer token is required. + +Behavior: Exchanges an external identity-provider token for an Opex token. OTP verification data may be required depending on the account state.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) + suspend fun getToken(@RequestBody tokenRequest: ExternalIdpTokenRequest): ResponseEntity { + val tokenResponse = authProxy.getToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } + + @PostMapping("/refresh") + @Operation( + summary = "Refresh token", + description = """POST /opex/v1/oauth/protocol/openid-connect/refresh. +Security: Public endpoint. No Bearer token is required. + +Validation: `refreshToken` and `clientId` are required. +Behavior: Issues a new access token from a valid refresh token.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) + suspend fun refreshToken(@RequestBody tokenRequest: RefreshTokenRequest): ResponseEntity { + val tokenResponse = authProxy.refreshToken(tokenRequest) + return ResponseEntity.ok().body(tokenResponse) + } +} \ No newline at end of file diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt new file mode 100644 index 000000000..868d2208c --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/PublicAuthController.kt @@ -0,0 +1,197 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ManualRateLimitGroupType +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.api.core.spi.ManualRateLimiterService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +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.RestController +import org.springframework.web.server.ServerWebExchange + +@RestController +@RequestMapping("/opex/v1/user/public") +@Tag( + name = "Auth Gateway - Public User", + description = "Public registration and password-recovery operations." +) +class PublicAuthController( + private val authProxy: AuthProxy, + private val manualRateLimiterService: ManualRateLimiterService +) { + + + @PostMapping("/register") + @Operation( + summary = "Register user", + description = """POST /opex/v1/user/public/register. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts the registration flow and sends OTP if required. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) + suspend fun registerUser(@Valid @RequestBody request: RegisterUserRequest): ResponseEntity { + val otpResponse = authProxy.registerUser(request) + return ResponseEntity.ok().body(otpResponse) + } + + @PostMapping("/register/verify") + @Operation( + summary = "Verify registration OTP", + description = """POST /opex/v1/user/public/register/verify. +Security: Public endpoint. No Bearer token is required. + +Validation: `username` and `otp` are required. +Behavior: Returns an action token used to confirm registration.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = OTPActionTokenResponse::class) + )] + ) + ] + ) + suspend fun verifyRegister( + @RequestBody request: VerifyOTPRequest, @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(request.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val response = authProxy.verifyRegister(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/register/confirm") + @Operation( + summary = "Confirm registration", + description = """POST /opex/v1/user/public/register/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `password` and registration action `token` are required. +Behavior: Completes registration and returns login token data.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = TokenResponse::class) + )] + ) + ] + ) + suspend fun confirmRegister(@RequestBody request: ConfirmRegisterRequest): ResponseEntity { + val loginToken = authProxy.confirmRegister(request) + return ResponseEntity.ok(loginToken) + } + + @PostMapping("/register-external") + @Operation( + summary = "Register external IdP user", + description = """POST /opex/v1/user/public/register-external. +Security: Public endpoint. No Bearer token is required. + +Behavior: Registers a user from an external identity provider token. +Response body: No response body.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ) + ] + ) + suspend fun registerExternal(@RequestBody request: ExternalIdpUserRegisterRequest): ResponseEntity { + authProxy.registerExternalIdpUser(request) + return ResponseEntity.ok().build() + } + + @PostMapping("/forget") + @Operation( + summary = "Forgot password", + description = """POST /opex/v1/user/public/forget. +Security: Public endpoint. No Bearer token is required. + +Behavior: Starts password-recovery flow and sends OTP if required. +Allowed values: +- captchaType: INTERNAL, ARCAPTCHA, HCAPTCHA.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) + suspend fun forgetPassword(@RequestBody request: ForgotPasswordRequest): ResponseEntity { + val otpResponse = authProxy.forgetPassword(request) + return ResponseEntity.ok().body(otpResponse) + } + + @PostMapping("/forget/verify") + @Operation( + summary = "Verify forgot-password OTP", + description = """POST /opex/v1/user/public/forget/verify. +Security: Public endpoint. No Bearer token is required. + +Validation: `username` and `otp` are required. +Behavior: Returns an action token used to confirm password reset.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content( + mediaType = "application/json", + schema = Schema(implementation = OTPActionTokenResponse::class) + )] + ) + ] + ) + suspend fun verifyForget( + @RequestBody request: VerifyOTPRequest, + @Parameter(hidden = true) exchange: ServerWebExchange + ): ResponseEntity { + manualRateLimiterService.check(request.username, ManualRateLimitGroupType.VERIFY_OTP, exchange) + val response = authProxy.verifyForget(request) + return ResponseEntity.ok(response) + } + + @PostMapping("/forget/confirm") + @Operation( + summary = "Confirm forgot-password flow", + description = """POST /opex/v1/user/public/forget/confirm. +Security: Public endpoint. No Bearer token is required. + +Validation: `newPassword`, `newPasswordConfirmation`, and password-reset action `token` are required. +Response body: No response body.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response. No response body.", + content = [Content()] + ) + ] + ) + suspend fun forgetPassword(@RequestBody request: ConfirmForgetRequest): ResponseEntity { + authProxy.confirmForget(request) + return ResponseEntity.ok().build() + } +} diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt new file mode 100644 index 000000000..db6bcce48 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/SessionController.kt @@ -0,0 +1,144 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.auth.SessionRequest +import co.nilin.opex.api.core.inout.auth.Sessions +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.security.jwtAuthentication +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.media.ArraySchema +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/opex/v1/user") +@Tag( + name = "Auth Gateway - Sessions", description = "Authenticated user session and logout operations." +) +class SessionController(private val authProxy: AuthProxy) { + + @PostMapping("/logout") + @Operation( + summary = "Logout current session", description = """POST /opex/v1/user/logout. +Security: Bearer user-token required. + +Behavior: Terminates the current session using the `sid` claim from the JWT. +Response body: No response body.""", security = [SecurityRequirement(name = "bearerAuth")], responses = [ApiResponse( + responseCode = "200", description = "Successful response. No response body.", content = [Content()] + ), ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + )] + ) + suspend fun logout( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ) { + val userId = securityContext.jwtAuthentication().name + authProxy.logout(userId) + } + + @PostMapping("/session") + @Operation( + summary = "List user sessions", + description = """POST /opex/v1/user/session. +Security: Bearer user-token required. + +Behavior: Returns user sessions for the authenticated user. `uuid` in the request body is overwritten by the authenticated user id. +Allowed values: +- os: ANDROID, IOS, MOBILE_WEB, DESKTOP_WEB +- status: ACTIVE, EXPIRED, TERMINATED.""", + security = [SecurityRequirement(name = "bearerAuth")], + responses = [ApiResponse( + responseCode = "200", description = "Successful response.", content = [Content( + mediaType = "application/json", array = ArraySchema(schema = Schema(implementation = Sessions::class)) + )] + ), ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + )] + ) + suspend fun getSessions( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody sessionRequest: SessionRequest + ): List { + val uuid = securityContext.authentication.name + sessionRequest.uuid = uuid + return authProxy.getSessions(sessionRequest, uuid) + } + + @DeleteMapping("/session/{sessionId}") + @Operation( + summary = "Logout one session", description = """DELETE /opex/v1/user/session/{sessionId}. +Security: Bearer user-token required. + +Behavior: Terminates one user session by id. +Response body: No response body.""", security = [SecurityRequirement(name = "bearerAuth")], parameters = [Parameter( + name = "sessionId", `in` = ParameterIn.PATH, required = true, description = "Session id to terminate." + )], responses = [ApiResponse( + responseCode = "200", description = "Successful response. No response body.", content = [Content()] + ), ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + )] + ) + suspend fun logout( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, + @PathVariable sessionId: String + ) { + val uuid = securityContext.authentication.name + authProxy.logout(uuid, sessionId) + } + + @PostMapping("/session/delete-others") + @Operation( + summary = "Logout other sessions", description = """POST /opex/v1/user/session/delete-others. +Security: Bearer user-token required. + +Behavior: Terminates all sessions except the current JWT session. +Response body: No response body.""", security = [SecurityRequirement(name = "bearerAuth")], responses = [ApiResponse( + responseCode = "200", description = "Successful response. No response body.", content = [Content()] + ), ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + )] + ) + suspend fun logoutOthers( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ) { + val uuid = securityContext.authentication.name + authProxy.logoutOthers(uuid) + } + + @PostMapping("/session/delete-all") + @Operation( + summary = "Logout all sessions", description = """POST /opex/v1/user/session/delete-all. +Security: Bearer user-token required. + +Behavior: Terminates all sessions for the authenticated user. +Response body: No response body.""", security = [SecurityRequirement(name = "bearerAuth")], responses = [ApiResponse( + responseCode = "200", description = "Successful response. No response body.", content = [Content()] + ), ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired. No response body.", + content = [Content()] + )] + ) + suspend fun logoutAll( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ) { + val uuid = securityContext.authentication.name + authProxy.logoutAll(uuid) + } +} diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt index 4f21a7139..ee5175324 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/RateLimitConfigImpl.kt @@ -21,13 +21,20 @@ class RateLimitConfigImpl( ) : RateLimitConfigService { private val groupCache = mutableMapOf() + private val groupNameCache = mutableMapOf() private val penaltyCache = mutableMapOf>() private val endpointCache = mutableListOf() override suspend fun loadConfig() { val groups = groupRepo.findByEnabledTrue().collectList().awaitFirstOrElse { emptyList() } groupCache.clear() - groups.forEach { groupCache[it.id!!] = it.toRateLimitGroup() } + groupNameCache.clear() + + groups.forEach { + val group = it.toRateLimitGroup() + groupCache[it.id!!] = group + groupNameCache[group.name] = group + } penaltyCache.clear() groups.forEach { group -> @@ -42,6 +49,7 @@ class RateLimitConfigImpl( } override fun getGroup(groupId: Long): RateLimitGroup? = groupCache[groupId] + override fun getGroup(groupName: String): RateLimitGroup? = groupNameCache[groupName] override fun getPenalties(groupId: Long): List = penaltyCache[groupId] ?: emptyList() override fun getEndpoints(): List = endpointCache diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt new file mode 100644 index 000000000..ca831aa51 --- /dev/null +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/AuthProxyImpl.kt @@ -0,0 +1,226 @@ +package co.nilin.opex.api.ports.proxy.impl + +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +import co.nilin.opex.common.OpexError +import co.nilin.opex.common.utils.LoggerDelegate +import kotlinx.coroutines.reactive.awaitFirstOrElse +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpHeaders +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.awaitBodilessEntity +import org.springframework.web.reactive.function.client.body +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono + +@Component +class AuthProxyImpl(@Qualifier("generalWebClient") private val webClient: WebClient) : AuthProxy { + + private val logger by LoggerDelegate() + + @Value("\${app.auth-gateway.url}") + private lateinit var baseUrl: String + + override suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get token") } + } + + override suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token/confirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to confirm token") } + } + + override suspend fun resendLoginOtp( + request: ResendOtpRequest, + token: String + ): ResendOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token/resend-otp") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to resend otp") } + } + + override suspend fun getToken(request: ExternalIdpTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/token-external") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get token") } + } + + override suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/oauth/protocol/openid-connect/refresh") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to refresh token") } + } + + override suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to register user") } + } + + override suspend fun verifyRegister(request: VerifyOTPRequest): OTPActionTokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register/verify") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to verify register") } + } + + override suspend fun confirmRegister(request: ConfirmRegisterRequest): Token { + return webClient.post() + .uri("$baseUrl/v1/user/public/register/ccofirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to confirm register") } + } + + override suspend fun registerExternalIdpUser(request: ExternalIdpUserRegisterRequest): TokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/register-external") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to register user") } + } + + override suspend fun forgetPassword(request: ForgotPasswordRequest): TempOtpResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/forget") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to forget password") } + } + + override suspend fun verifyForget(request: VerifyOTPRequest): OTPActionTokenResponse { + return webClient.post() + .uri("$baseUrl/v1/user/public/forget/verify") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to verify forget password") } + } + + override suspend fun confirmForget(request: ConfirmForgetRequest) { + webClient.post() + .uri("$baseUrl/v1/user/public/forget/confirm") + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(request)) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logout(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/logout") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logout(sessionId: String, token: String) { + webClient.delete() + .uri("$baseUrl/v1/user/session/$sessionId") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun getSessions( + request: SessionRequest, + token: String + ): List { + return webClient.post() + .uri("$baseUrl/v1/user/session") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono>() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get sessions") } + } + + override suspend fun logoutOthers(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/session/delete-others") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } + + override suspend fun logoutAll(token: String) { + webClient.post() + .uri("$baseUrl/v1/user/session/delete-all") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1874f04f3..1c7d6e075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -593,6 +593,7 @@ services: - CUSTOM_MESSAGE_URL=${CUSTOM_MESSAGE_URL} - CUSTOM_MESSAGE_ENABLED=${CUSTOM_MESSAGE_ENABLED} - CUSTOM_USER_LANGUAGE_ENABLED=${CUSTOM_USER_LANGUAGE_ENABLED} + - DEFAULT_SMS_PROVIDER=${DEFAULT_SMS_PROVIDER} depends_on: - consul - postgres-otp diff --git a/otp/otp-app/src/main/resources/schema.sql b/otp/otp-app/src/main/resources/schema.sql index 51e84040b..7d043adb1 100644 --- a/otp/otp-app/src/main/resources/schema.sql +++ b/otp/otp-app/src/main/resources/schema.sql @@ -61,14 +61,14 @@ insert into totp_config values (true, 128, 'Opex') on conflict do nothing; -CREATE TABLE sms_provider_route ( +CREATE TABLE if not exists sms_provider_route ( id BIGSERIAL PRIMARY KEY, prefix VARCHAR(32) NOT NULL, provider VARCHAR(64) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE ); -CREATE TABLE sms_provider ( +CREATE TABLE if not exists sms_provider ( id VARCHAR(64) PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT TRUE, base_url TEXT NOT NULL, From 4083d9abd554277511c18786f3c25e187fe21ea2 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Wed, 22 Jul 2026 17:09:11 +0330 Subject: [PATCH 3/3] Add OAuth2 pre-auth flow for OTP resend Implements a separate OAuth2 security chain for the resend-otp endpoint using pre-authentication validation. Changes include: - Added configurable Keycloak base URL via app.auth.url property - New preAuthSecurityChain() with dedicated JWT decoder for pre-auth clients - Separate preAuthJwtDecoder() for validating "pre-auth-client" audience - Improved token extraction using jwtAuthentication().tokenValue() - Parameter rename from uuid to token for clarity - Adjusted Keycloak connection pool and timeout settings --- .../opex/api/app/config/WebClientConfig.kt | 18 ++++++--- .../src/main/resources/application.yml | 1 + .../co/nilin/opex/api/core/spi/AuthProxy.kt | 2 +- .../ports/binance/config/SecurityConfig.kt | 40 +++++++++++++++++++ .../ports/opex/controller/AuthController.kt | 4 +- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt index 8ab556228..a89032080 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/WebClientConfig.kt @@ -3,6 +3,7 @@ package co.nilin.opex.api.app.config import co.nilin.opex.common.utils.LanguageUtils.getDefaultUserLanguage import io.netty.channel.ChannelOption import io.netty.handler.logging.LogLevel +import org.springframework.beans.factory.annotation.Value import org.springframework.cloud.client.ServiceInstance import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction @@ -21,7 +22,11 @@ import reactor.netty.transport.logging.AdvancedByteBufFormat import java.time.Duration @Configuration -class WebClientConfig(private val logbook: Logbook) { +class WebClientConfig( + private val logbook: Logbook, + @Value("\${app.auth.url}") + private val url: String, +) { private val provider = ConnectionProvider.builder("apiPool") .maxConnections(150) .pendingAcquireMaxCount(100) @@ -57,17 +62,17 @@ class WebClientConfig(private val logbook: Logbook) { @Bean("keycloakWebClient") fun keycloakWebClient(logbook: Logbook): WebClient { - val provider = ConnectionProvider.builder("apiKeycloakPool") - .maxConnections(150) + val provider = ConnectionProvider.builder("keycloakPool") + .maxConnections(100) .maxIdleTime(Duration.ofSeconds(30)) .maxLifeTime(Duration.ofMinutes(2)) - .pendingAcquireTimeout(Duration.ofSeconds(5)) + .pendingAcquireTimeout(Duration.ofSeconds(60)) .evictInBackground(Duration.ofMinutes(1)) .build() val client = HttpClient.create(provider) - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) - .responseTimeout(Duration.ofSeconds(5)) + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .responseTimeout(Duration.ofSeconds(10)) .keepAlive(true) .doOnConnected { it.addHandlerLast(LogbookClientHandler(logbook)) } @@ -75,6 +80,7 @@ class WebClientConfig(private val logbook: Logbook) { return WebClient.builder() .clientConnector(ReactorClientHttpConnector(client)) + .baseUrl(url) .build() } diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 1d538daa0..ec0a0b34f 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -134,6 +134,7 @@ app: auth-gateway: url: http://opex-auth-gateway auth: + 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} token-url: http://keycloak:8080/realms/opex/protocol/openid-connect/token diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt index 0fbf13dce..fb1d6c3f1 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/AuthProxy.kt @@ -6,7 +6,7 @@ interface AuthProxy { suspend fun requestGetToken(request: PasswordFlowTokenRequest): TokenResponse suspend fun confirmGetToken(request: ConfirmPasswordFlowTokenRequest): TokenResponse - suspend fun resendLoginOtp(request: ResendOtpRequest, uuid: String): ResendOtpResponse + suspend fun resendLoginOtp(request: ResendOtpRequest, token: String): ResendOtpResponse suspend fun getToken(request: ExternalIdpTokenRequest): TokenResponse suspend fun refreshToken(request: RefreshTokenRequest): TokenResponse suspend fun registerUser(request: RegisterUserRequest): TempOtpResponse 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 ec58598c6..4d005fdb5 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 @@ -3,9 +3,11 @@ package co.nilin.opex.api.ports.binance.config import co.nilin.opex.api.core.spi.APIKeyFilter import co.nilin.opex.api.ports.binance.util.AudienceValidator import co.nilin.opex.common.security.ReactiveCustomJwtConverter +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary import org.springframework.core.annotation.Order import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity @@ -28,6 +30,7 @@ class SecurityConfig( private val certUrl: String, @Value("\${app.auth.iss-url}") private val issUrl: String, + @Qualifier("keycloakWebClient") private val webClient: WebClient, ) { @Value("\${swagger.auth.enabled:false}") private var swaggerAuthEnabled: Boolean = false @@ -66,6 +69,21 @@ class SecurityConfig( @Bean @Order(1) + fun preAuthSecurityChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .securityMatcher( + ServerWebExchangeMatchers.pathMatchers( + "/opex/v1/oauth/protocol/openid-connect/token/resend-otp" + ) + ) + .csrf { it.disable() } + .authorizeExchange { it.anyExchange().authenticated() } + .oauth2ResourceServer { it.jwt { jwt -> jwt.jwtDecoder(preAuthJwtDecoder()) } } + .build() + } + + @Bean + @Order(2) fun apiSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http.csrf { it.disable() } @@ -122,6 +140,7 @@ class SecurityConfig( @Bean @Throws(Exception::class) + @Primary fun reactiveJwtDecoder(): ReactiveJwtDecoder? { val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) .webClient(WebClient.create()) @@ -144,5 +163,26 @@ class SecurityConfig( return decoder } + @Bean("preAuthJwtDecoder") + @Throws(Exception::class) + fun preAuthJwtDecoder(): ReactiveJwtDecoder? { + val decoder = NimbusReactiveJwtDecoder.withJwkSetUri(certUrl) + .webClient(webClient) + .build() + val issuerValidator = JwtValidators.createDefaultWithIssuer(issUrl) + val audienceValidator = AudienceValidator( + setOf( + "pre-auth-client", + ) + ) + decoder.setJwtValidator( + DelegatingOAuth2TokenValidator( + issuerValidator, + audienceValidator + ) + ) + return decoder + } + } diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt index 1103e951d..425ba12d9 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/AuthController.kt @@ -4,6 +4,8 @@ import co.nilin.opex.api.core.inout.ManualRateLimitGroupType import co.nilin.opex.api.core.inout.auth.* import co.nilin.opex.api.core.spi.AuthProxy import co.nilin.opex.api.core.spi.ManualRateLimiterService +import co.nilin.opex.api.ports.opex.util.jwtAuthentication +import co.nilin.opex.api.ports.opex.util.tokenValue import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.media.Content @@ -110,7 +112,7 @@ Source of values: Use the pre-auth token returned by the token request flow.""", @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext, ): ResponseEntity { - val response = authProxy.resendLoginOtp(resendOtpRequest, securityContext.authentication.name) + val response = authProxy.resendLoginOtp(resendOtpRequest, securityContext.jwtAuthentication().tokenValue()) return ResponseEntity.ok().body(response) }