From 526277b16f165cc9dfceb4d4aa9981c922ff7cdc Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Mon, 17 Aug 2026 16:22:37 +0330 Subject: [PATCH 1/4] Implement Two-Factor Authentication --- .../auth/controller/PublicUserController.kt | 42 ++- .../UserTwoFactorConfigController.kt | 239 ++++++++++++++++++ .../kotlin/co/nilin/opex/auth/model/OTP.kt | 2 +- .../kotlin/co/nilin/opex/auth/model/TOTP.kt | 28 ++ .../co/nilin/opex/auth/model/TwoFactor.kt | 15 ++ .../co/nilin/opex/auth/model/UserRegister.kt | 2 - .../co/nilin/opex/auth/proxy/KeycloakProxy.kt | 57 ++++- .../co/nilin/opex/auth/proxy/OTPProxy.kt | 54 +++- .../auth/service/ForgetPasswordService.kt | 12 +- .../nilin/opex/auth/service/LoginService.kt | 143 ++++++++--- .../opex/auth/service/RegisterService.kt | 25 +- .../auth/service/TwoFactorConfigService.kt | 185 ++++++++++++++ .../src/main/resources/application.yml | 1 + .../kotlin/co/nilin/opex/common/OpexError.kt | 1 + docker-compose.yml | 1 + .../opex/otp/app/controller/TOTPController.kt | 7 +- .../opex/otp/app/model/TOTPQueryResponse.kt | 1 + .../nilin/opex/otp/app/service/TOTPService.kt | 14 +- 18 files changed, 752 insertions(+), 77 deletions(-) create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TOTP.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TwoFactor.kt create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt 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 7b48faeaa..a7fd2cffb 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 @@ -49,6 +49,26 @@ Allowed values: return ResponseEntity.ok().body(otpResponse) } + @PostMapping("/register/resend-otp") + @Operation( + summary = "Resend registration OTP", + description = """POST /v1/user/public/register/resend-otp. +Security: Public endpoint. No Bearer token is required. + +Behavior: Resends the registration OTP.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) + suspend fun resendRegistrationOtp(@Valid @RequestBody request: ResendOtpRequest): ResponseEntity { + val otpResponse = registerService.resendRegistrationOtp(request) + return ResponseEntity.ok().body(otpResponse) + } + @PostMapping("/register/verify") @Operation( summary = "Verify registration OTP", @@ -140,6 +160,26 @@ Allowed values: return ResponseEntity.ok().body(otpResponse) } + @PostMapping("/forget/resend-otp") + @Operation( + summary = "Resend forgot-password OTP", + description = """POST /v1/user/public/forget/resend-otp. +Security: Public endpoint. No Bearer token is required. + +Behavior: Resends the forgot-password OTP.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Successful response.", + content = [Content(mediaType = "application/json", schema = Schema(type = "object"))] + ) + ] + ) + suspend fun resendForgetOtp(@Valid @RequestBody request: ResendOtpRequest): ResponseEntity { + val otpResponse = forgetPasswordService.resendForgetOtp(request) + return ResponseEntity.ok().body(otpResponse) + } + @PostMapping("/forget/verify") @Operation( summary = "Verify forgot-password OTP", @@ -184,4 +224,4 @@ Response body: No response body.""", 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/UserTwoFactorConfigController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt new file mode 100644 index 000000000..26681d47c --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt @@ -0,0 +1,239 @@ +package co.nilin.opex.auth.controller + +import co.nilin.opex.auth.model.* +import co.nilin.opex.auth.service.TwoFactorConfigService +import io.swagger.v3.oas.annotations.Operation +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 org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/v1/user/2fa") +@Tag( + name = "User Two-Factor Configuration", + description = "Endpoints for managing user two-factor authentication (2FA) settings and TOTP setup." +) +class UserTwoFactorConfigController(private val twoFactorConfigService: TwoFactorConfigService) { + + + @GetMapping + @Operation( + summary = "Get current two-factor authentication configuration", + description = """ +GET /v1/2fa + +Security: Bearer token is required. + +Behavior: +Retrieves the currently active two-factor authentication (2FA) method for the authenticated user. + +Possible return values: +- NONE: Two-factor authentication is disabled. +- EMAIL: 2FA via Email OTP is active. +- SMS: 2FA via SMS OTP is active. +- TOTP: 2FA via Authenticator App (Time-based OTP) is active. +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor configuration retrieved successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = OTPType::class) + ) + ] + ) + ] + ) + suspend fun getTwoFactorConfig(@CurrentSecurityContext securityContext: SecurityContext): OTPType { + return twoFactorConfigService.getTwoFactorConfig(securityContext.authentication.name) + } + + @PostMapping("/enable/request") + @Operation( + summary = "Request enabling two-factor authentication", description = """ +POST /v1/2fa/enable/request. + +Security: Bearer token is required. + +Behavior: +Starts the two-factor authentication enable flow for the authenticated user. + +Allowed values: +- method: EMAIL, SMS, TOTP + +Response: +- EMAIL/SMS: Returns the OTP receiver information. An OTP is sent to the selected receiver. +- TOTP: Returns the TOTP setup URI (otpauth://...) to be used for QR code generation or manual setup. +""", responses = [ApiResponse( + responseCode = "200", description = "Two-factor enable request created successfully.", content = [Content( + mediaType = "application/json", schema = Schema(implementation = TwoFactorResponse::class) + )] + )] + ) + suspend fun requestEnableTwoFactor( + @RequestBody request: TwoFactorRequest, @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = twoFactorConfigService.requestEnableTwoFactor( + request.method, securityContext.authentication.name + ) + return ResponseEntity.ok(response) + } + + @PostMapping("/enable/confirm") + @Operation( + summary = "Confirm enabling two-factor authentication", description = """ +POST /v1/2fa/enable/confirm. + +Security: Bearer token is required. + +Behavior: +Confirm the two-factor authentication enable flow for the authenticated user. + +Allowed values: +- method: EMAIL, SMS, TOTP +- otp : String + +Response: +- Returns the otp result. +""", responses = [ApiResponse( + responseCode = "200", description = "Two-factor authentication enabled successfully.", content = [Content( + mediaType = "application/json", schema = Schema(implementation = OTPVerifyResponse::class) + )] + )] + ) + suspend fun confirmEnableTwoFactor( + @RequestBody request: ConfirmTwoFactorRequest, @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = twoFactorConfigService.confirmEnableTwoFactor( + request.method, + request.otp, + securityContext.authentication.name + ) + return ResponseEntity.ok(response) + } + + @PostMapping("/disable/request") + @Operation( + summary = "Request disabling two-factor authentication", description = """ +POST /v1/2fa/disable/request. + +Security: Bearer token is required. + +Behavior: +Starts the two-factor authentication disable flow for the authenticated user. + +Allowed values: +- method: EMAIL, SMS, TOTP + +Response: +- EMAIL/SMS: Returns the OTP receiver information. An OTP is sent to the selected receiver. +- TOTP: Returns the TOTP code. +""", responses = [ApiResponse( + responseCode = "200", description = "Two-factor enable request created successfully.", content = [Content( + mediaType = "application/json", schema = Schema(implementation = TwoFactorResponse::class) + )] + )] + ) + suspend fun requestDisableTwoFactor( + @RequestBody request: TwoFactorRequest, @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = twoFactorConfigService.requestDisableTwoFactor( + request.method, securityContext.authentication.name + ) + return ResponseEntity.ok(response) + } + + @PostMapping("/disable/confirm") + @Operation( + summary = "Confirm disabling two-factor authentication", description = """ +POST /v1/two-factor/disable/confirm. + +Security: Bearer token is required. + +Behavior: +Confirm the two-factor authentication enable flow for the authenticated user. + +Allowed values: +- method: EMAIL, SMS, TOTP +- otp : String + +Response: +- Returns the otp result. +""", responses = [ApiResponse( + responseCode = "200", description = "Two-factor authentication enabled successfully.", content = [Content( + mediaType = "application/json", schema = Schema(implementation = OTPVerifyResponse::class) + )] + )] + ) + suspend fun confirmDisableTwoFactor( + @RequestBody request: ConfirmTwoFactorRequest, @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = twoFactorConfigService.confirmDisableTwoFactor( + request.method, + request.otp, + securityContext.authentication.name + ) + return ResponseEntity.ok(response) + + } + + @PostMapping("/totp/setup") + @Operation( + summary = "Setup TOTP (Authenticator App)", + description = """ +POST /v1/user/2fa/totp/setup + +Security: Bearer token is required. + +Behavior: +Generates secret key and setup URL (otpauth://) for setting up Authenticator app (e.g. Google Authenticator). +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "TOTP setup credentials generated successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SetupTOTPResponse::class) + ) + ] + ) + ] + ) + suspend fun setupTOTP(@CurrentSecurityContext securityContext: SecurityContext): SetupTOTPResponse { + return twoFactorConfigService.setupTOTP(securityContext.authentication.name) + } + + @PostMapping("/totp/verify") + @Operation( + summary = "Verify TOTP setup code", + description = """ +POST /v1/user/2fa/totp/verify + +Security: Bearer token is required. + +Behavior: +Verifies the generated TOTP code during the initial authenticator setup phase. +""", + responses = [ + ApiResponse( + responseCode = "200", + description = "TOTP setup code verified successfully." + ) + ] + ) + suspend fun verifyTOTPSetup( + @CurrentSecurityContext securityContext: SecurityContext, + @RequestBody request: TOTPCode + ) { + return twoFactorConfigService.verifyTOTPSetup(securityContext.authentication.name, request.code) + } +} \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt index 3cf756c22..feae637b0 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/OTP.kt @@ -28,7 +28,7 @@ data class OTPVerifyResponse( data class TempOtpResponse(val otp: String?, val otpReceiver: OTPReceiver?) enum class OTPAction { - REGISTER, FORGET, NONE + REGISTER, LOGIN, FORGET, NONE, TWO_FACTOR } enum class OTPResultType { diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TOTP.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TOTP.kt new file mode 100644 index 000000000..55c171611 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TOTP.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.auth.model + +data class SetupTOTPRequest( + val userId: String, + val label: String? +) + +data class SetupTOTPResponse( + val uri: String +) + +data class VerifyTOTPRequest( + val userId: String, + val code: String +) + +data class VerifyTOTPResponse(val result: Boolean) + +data class TOTPQueryResponse( + val userId: String, + val isEnabled: Boolean, + val isActivated: Boolean, + val uri : String +) + +data class TOTPCode( + val code: String +) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TwoFactor.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TwoFactor.kt new file mode 100644 index 000000000..bbcf880a7 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/TwoFactor.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.auth.model + +import com.fasterxml.jackson.annotation.JsonInclude + +data class TwoFactorRequest( + val method: OTPType, +) + +data class ConfirmTwoFactorRequest( + val method: OTPType, + val otp: String, +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class TwoFactorResponse(val otp: String?, val otpReceiver: OTPReceiver?) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt index 1b5359d0c..1e809a95d 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/model/UserRegister.kt @@ -4,8 +4,6 @@ import co.nilin.opex.auth.data.Device data class RegisterUserRequest( val username: String, - val firstName: String? = null, - val lastName: String? = null, val captchaType: CaptchaType? = CaptchaType.INTERNAL, val captchaCode: 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 5fe5e21b7..5e1d650c0 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 @@ -155,6 +155,28 @@ class KeycloakProxy( return users[0].id } + suspend fun findUserByUuid(uuid: String): KeycloakUser? { + return try { + opexRealm.users() + .get(uuid) + .toRepresentation() + .let { representation -> + KeycloakUser( + id = representation.id, + username = representation.username, + email = representation.email, + firstName = representation.firstName, + lastName = representation.lastName, + emailVerified = representation.isEmailVerified, + enabled = representation.isEnabled, + attributes = representation.attributes + ) + } + } catch (e: Exception) { + null + } + } + suspend fun findUserByUsername(username: Username): KeycloakUser? { val users = findUserByAttribute(username.asAttribute()) return if (users.isEmpty()) null else users[0] @@ -177,8 +199,6 @@ class KeycloakProxy( suspend fun createUser( username: Username, - firstName: String?, - lastName: String?, enabled: Boolean ) { val keycloakUrl = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users" @@ -192,15 +212,13 @@ class KeycloakProxy( hashMapOf( "username" to internalID, "emailVerified" to enabled, - "firstName" to firstName, - "lastName" to lastName, "enabled" to enabled, "attributes" to hashMapOf( "kycLevel" to "0" ).apply { if (username.type == UsernameType.MOBILE) put("mobile", username.value) - put(Attributes.OTP, OTPType.EMAIL.name + "," + OTPType.SMS.name) + put(Attributes.OTP, OTPType.NONE.name) } ).apply { if (username.type == UsernameType.EMAIL) put("email", username.value) } ) @@ -399,6 +417,20 @@ class KeycloakProxy( } } + suspend fun updateOtpConfig( + userId: String, + otpConfig: String + ) { + updateUserFields( + userId = userId, + updates = mapOf( + "attributes" to mapOf( + Attributes.OTP to otpConfig + ) + ) + ) + } + private suspend fun updateUserFields(userId: String, updates: Map) { val url = "${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/$userId" @@ -411,7 +443,20 @@ class KeycloakProxy( .toMutableMap() updates.forEach { (key, value) -> - existingUser[key] = value + if (key == "attributes" && value is Map<*, *>) { + val currentAttributes = (existingUser["attributes"] as? Map) + ?.toMutableMap() ?: mutableMapOf() + + value.forEach { (attrKey, attrValue) -> + if (attrKey is String && attrValue != null) { + currentAttributes[attrKey] = attrValue + } + } + + existingUser["attributes"] = currentAttributes + } else { + existingUser[key] = value + } } keycloakClient.put() diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt index 3a81f4486..dd8f9ddfa 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/OTPProxy.kt @@ -1,9 +1,6 @@ package co.nilin.opex.auth.proxy -import co.nilin.opex.auth.model.OTPReceiver -import co.nilin.opex.auth.model.OTPVerifyRequest -import co.nilin.opex.auth.model.OTPVerifyResponse -import co.nilin.opex.auth.model.TempOtpResponse +import co.nilin.opex.auth.model.* import kotlinx.coroutines.reactive.awaitSingle import org.springframework.beans.factory.annotation.Qualifier import org.springframework.http.MediaType @@ -11,17 +8,19 @@ import org.springframework.stereotype.Component import org.springframework.web.reactive.function.BodyInserters import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBody -import org.springframework.web.reactive.function.client.toEntity @Component class OTPProxy(@Qualifier("otpWebClient") private val webClient: WebClient) { - //TODO IMPORTANT: remove in production - - suspend fun requestOTP(userId: String, receivers: List): TempOtpResponse { + suspend fun requestOTP( + userId: String, + receivers: List, + otpAction: OTPAction? = null + ): TempOtpResponse { val request = object { val userId = userId val receivers = receivers + val action = otpAction } return webClient.post().uri("/otp") @@ -47,4 +46,43 @@ class OTPProxy(@Qualifier("otpWebClient") private val webClient: WebClient) { .retrieve() .awaitBody() } + + // ---------------- TOTP ---------------- + + suspend fun setupTOTP(userId: String, label: String): SetupTOTPResponse { + return webClient.post() + .uri("/totp/setup") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(SetupTOTPRequest(userId, label)) + .retrieve() + .awaitBody() + } + + suspend fun verifyTOTPSetup(userId: String, code: String) { + webClient.post() + .uri("/totp/setup/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(VerifyTOTPRequest(userId, code)) + .retrieve() + .toBodilessEntity() + .awaitSingle() + } + + suspend fun verifyTOTP(userId: String, code: String): VerifyTOTPResponse { + return webClient.post() + .uri("/totp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(VerifyTOTPRequest(userId, code)) + .retrieve() + .awaitBody() + } + + suspend fun queryTOTP(userId: String): TOTPQueryResponse { + return webClient.get() + .uri("/totp/query/$userId") + .retrieve() + .awaitBody() + } + + } \ No newline at end of file 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 7856531b3..7aff156c6 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,7 +32,15 @@ class ForgetPasswordService( 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)) + val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver),OTPAction.FORGET) + return TempOtpResponse(result.otp, otpReceiver) + } + + suspend fun resendForgetOtp(request: ResendOtpRequest): TempOtpResponse { + val uName = Username.create(request.username) + val otpReceiver = OTPReceiver(uName.value, uName.type.otpType) + keycloakProxy.findUserByUsername(uName) ?: return TempOtpResponse("", otpReceiver) + val result = otpProxy.requestOTP(uName.value, listOf(otpReceiver),OTPAction.FORGET) return TempOtpResponse(result.otp, otpReceiver) } @@ -64,4 +72,4 @@ class ForgetPasswordService( } -} +} \ 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 c41750589..585c516d3 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 @@ -34,18 +34,23 @@ class LoginService( request.captchaCode, request.captchaType ?: CaptchaType.INTERNAL ) + val username = Username.create(request.username) - val user = - keycloakProxy.findUserByUsername(username) ?: throw OpexError.UsernameOrPasswordIsIncorrect.exception() - val otpTypes = (user.attributes?.get(Attributes.OTP)?.get(0) ?: OTPType.NONE.name).split(",") + val user = keycloakProxy.findUserByUsername(username) + ?: throw OpexError.UsernameOrPasswordIsIncorrect.exception() + + val otpType = user.attributes?.get(Attributes.OTP)?.firstOrNull() + ?.let { runCatching { OTPType.valueOf(it) }.getOrNull() } + ?: OTPType.NONE - if (otpTypes.contains(OTPType.NONE.name)) { + if (otpType == OTPType.NONE) { val token = keycloakProxy.getUserToken( username, request.password, request.clientId, request.clientSecret ).apply { if (!request.rememberMe) refreshToken = null } + sendLoginEvent(user.id, token.sessionState, request, token.expiresIn) return TokenResponse(token, null, null) } @@ -56,61 +61,114 @@ class LoginService( username, request.password, PRE_AUTH_CLIENT_ID, - preAuthClientSecretKey, + preAuthClientSecretKey ).apply { refreshToken = null refreshExpiresIn = 0 } + return when (otpType) { + OTPType.EMAIL, OTPType.SMS -> { + val destination = when (otpType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } ?: throw OpexError.BadRequest.exception() + + val requiredOtpTypes = listOf(OTPReceiver(destination, otpType)) + val res = otpProxy.requestOTP(destination, requiredOtpTypes, OTPAction.LOGIN) + + TokenResponse( + token = token, + otp = RequiredOTP(otpType, destination), + otpCode = res.otp + ) + } - 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 - } - - + OTPType.TOTP -> { + TokenResponse( + token = token, + otp = RequiredOTP(OTPType.TOTP, user.id), + otpCode = null + ) + } - return TokenResponse(token, RequiredOTP(usernameType, receiver), res.otp) + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() + } } 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() + 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) - } + return when (val otpType = user.currentOtpMethod) { + OTPType.EMAIL, OTPType.SMS -> { + val destination = when (otpType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } ?: throw OpexError.BadRequest.exception() + + val requiredOtpTypes = listOf(OTPReceiver(destination, otpType)) + val res = otpProxy.requestOTP(destination, requiredOtpTypes, OTPAction.LOGIN) + + ResendOtpResponse( + otp = RequiredOTP(otpType, destination), + otpCode = res.otp + ) + } + OTPType.TOTP -> { + ResendOtpResponse( + otp = RequiredOTP(OTPType.TOTP, user.id), + otpCode = null + ) + } + + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() + } + } 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) { - when (otpResult.type) { - OTPResultType.EXPIRED -> throw OpexError.ExpiredOTP.exception() - else -> throw OpexError.InvalidOTP.exception() + val user = keycloakProxy.findUserByUsername(username) + ?: throw OpexError.UserNotFound.exception() + + when (val otpType = user.currentOtpMethod) { + OTPType.EMAIL, OTPType.SMS -> { + val destination = when (otpType) { + OTPType.EMAIL -> user.email + OTPType.SMS -> user.mobile + else -> null + } ?: throw OpexError.BadRequest.exception() + + val otpRequest = OTPVerifyRequest( + userId = destination, + otpCodes = listOf(OTPCode(request.otp, otpType)) + ) + val otpResult = otpProxy.verifyOTP(otpRequest) + + if (!otpResult.result) { + throw when (otpResult.type) { + OTPResultType.EXPIRED -> OpexError.ExpiredOTP.exception() + else -> OpexError.InvalidOTP.exception() + } + } + } + + OTPType.TOTP -> { + val totpResult = otpProxy.verifyTOTP(userId = user.id, code = request.otp) + if (!totpResult.result) { + throw OpexError.InvalidTOTPCode.exception() + } } + + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() } -// 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, @@ -123,6 +181,13 @@ class LoginService( return TokenResponse(token, null, null) } + // --- Helper Extension --- + private val KeycloakUser.currentOtpMethod: OTPType + get() = attributes?.get(Attributes.OTP) + ?.firstOrNull() + ?.let { runCatching { OTPType.valueOf(it) }.getOrNull() } + ?: OTPType.NONE + suspend fun getToken(tokenRequest: ExternalIdpTokenRequest): TokenResponse { val idToken = tokenRequest.idToken val decodedJWT = googleProxy.validateGoogleToken(idToken) 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 index 5d2799d20..fa3ce1506 100644 --- 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 @@ -31,24 +31,24 @@ class RegisterService( request.captchaType ?: CaptchaType.INTERNAL ) 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), OTPAction.REGISTER) + return TempOtpResponse(res.otp, otpReceiver) + } + suspend fun resendRegistrationOtp(request: ResendOtpRequest): TempOtpResponse { + val username = Username.create(request.username) + isUserDuplicate(username) val otpType = username.type.otpType val otpReceiver = OTPReceiver(request.username, otpType) - val res = otpProxy.requestOTP(request.username, listOf(otpReceiver)) -// todo we have to check for duplication usernames after verifying the register otp - if (!userStatus) - keycloakProxy.createUser( - username, - request.firstName, - request.lastName, - false - ) + val res = otpProxy.requestOTP(request.username, listOf(otpReceiver),OTPAction.REGISTER) return TempOtpResponse(res.otp, otpReceiver) } suspend fun verifyRegister(request: VerifyOTPRequest): String { val username = Username.create(request.username) + val userStatus = isUserDuplicate(username) val otpRequest = OTPVerifyRequest(username.value, listOf(OTPCode(request.otp, username.type.otpType))) val otpResult = otpProxy.verifyOTP(otpRequest) if (!otpResult.result) { @@ -57,6 +57,11 @@ class RegisterService( else -> throw OpexError.InvalidOTP.exception() } } + if (!userStatus) + keycloakProxy.createUser( + username, + false + ) return tempTokenService.generateToken(username.value, OTPAction.REGISTER) } diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt new file mode 100644 index 000000000..bc8416357 --- /dev/null +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt @@ -0,0 +1,185 @@ +package co.nilin.opex.auth.service + +import co.nilin.opex.auth.model.* +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.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class TwoFactorConfigService( + private val otpProxy: OTPProxy, + private val keycloakProxy: KeycloakProxy, + @Value("\${app.name}") + private val appName: Boolean, +) { + private val logger by LoggerDelegate() + + suspend fun getTwoFactorConfig(uuid: String): OTPType = + getUserByUuid(uuid).currentOtpMethod + + suspend fun requestEnableTwoFactor(method: OTPType, uuid: String): TwoFactorResponse { + validateMethod(method) + val user = getUserByUuid(uuid) + + if (user.currentOtpMethod != OTPType.NONE) { + throw OpexError.InvalidOTPType.exception() + } + + return when (method) { + OTPType.EMAIL, OTPType.SMS -> sendOtpRequest(user, method) + OTPType.TOTP -> { + val totpConfig = otpProxy.queryTOTP(uuid) + if (!totpConfig.isActivated || !totpConfig.isEnabled) { + throw OpexError.TOTPSetupIncomplete.exception() + } + TwoFactorResponse(otp = null, otpReceiver = OTPReceiver(uuid, OTPType.TOTP)) + } + + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() + } + } + + suspend fun confirmEnableTwoFactor( + method: OTPType, + otpCode: String, + uuid: String + ): OTPVerifyResponse { + val user = getUserByUuid(uuid) + val result = verifyTwoFactorCode(user, method, otpCode) + keycloakProxy.updateOtpConfig(uuid, method.name) + return result + } + + suspend fun requestDisableTwoFactor(method: OTPType, uuid: String): TwoFactorResponse { + validateMethod(method) + val user = getUserByUuid(uuid) + if (user.currentOtpMethod == OTPType.NONE || user.currentOtpMethod != method) { + throw OpexError.InvalidOTPType.exception() + } + return when (method) { + OTPType.EMAIL, OTPType.SMS -> sendOtpRequest(user, method) + OTPType.TOTP -> TwoFactorResponse(otp = null, otpReceiver = OTPReceiver(uuid, OTPType.TOTP)) + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() + } + } + + suspend fun confirmDisableTwoFactor( + method: OTPType, + otpCode: String, + uuid: String + ): OTPVerifyResponse { + val user = getUserByUuid(uuid) + val result = verifyTwoFactorCode(user, method, otpCode) + + keycloakProxy.updateOtpConfig(uuid, OTPType.NONE.name) + return result + } + + suspend fun setupTOTP(uuid: String): SetupTOTPResponse { + val user = getUserByUuid(uuid) + val totpResponse = otpProxy.queryTOTP(uuid) + return when { + !totpResponse.isEnabled && !totpResponse.isActivated -> otpProxy.setupTOTP( + uuid, + "$appName : ${user.username}" + ) + + totpResponse.isEnabled && !totpResponse.isActivated -> SetupTOTPResponse(totpResponse.uri) + else -> throw OpexError.BadRequest.exception() + } + } + + suspend fun verifyTOTPSetup(uuid: String, code: String) { + val totpConfig = otpProxy.queryTOTP(uuid) + if (totpConfig.isActivated || !totpConfig.isEnabled) { + throw OpexError.TOTPAlreadyRegistered.exception() + } + otpProxy.verifyTOTPSetup(uuid, code) + keycloakProxy.updateOtpConfig(uuid, OTPType.TOTP.name) + } + + // --- Private Helper Methods --- + + private suspend fun verifyTwoFactorCode( + user: KeycloakUser, + method: OTPType, + otpCode: String + ): OTPVerifyResponse { + validateMethod(method) + + return when (method) { + OTPType.EMAIL, OTPType.SMS -> verifyOTP(user, method, otpCode) + OTPType.TOTP -> { + val totpResponse = otpProxy.verifyTOTP(userId = user.id, code = otpCode) + if (!totpResponse.result) throw OpexError.InvalidTOTPCode.exception() + OTPVerifyResponse(result = true, type = OTPResultType.VALID) + } + + OTPType.NONE -> throw OpexError.InvalidOTPType.exception() + } + } + + private suspend fun getUserByUuid(uuid: String): KeycloakUser = + keycloakProxy.findUserByUuid(uuid) ?: throw OpexError.NotFound.exception() + + private fun validateMethod(method: OTPType) { + if (method == OTPType.NONE) throw OpexError.InvalidOTPType.exception() + } + + private suspend fun sendOtpRequest(user: KeycloakUser, method: OTPType): TwoFactorResponse { + val destination = user.getDestinationFor(method) + val receiver = OTPReceiver(destination, method) + + val response = otpProxy.requestOTP( + destination, + listOf(receiver), + OTPAction.TWO_FACTOR + ) + + return TwoFactorResponse( + otp = response.otp, + otpReceiver = receiver + ) + } + + private suspend fun verifyOTP( + user: KeycloakUser, + method: OTPType, + otpCode: String + ): OTPVerifyResponse { + val destination = user.getDestinationFor(method) + + val result = otpProxy.verifyOTP( + OTPVerifyRequest( + userId = destination, + otpCodes = listOf(OTPCode(otpCode, method)) + ) + ) + + if (!result.result) { + throw when (result.type) { + OTPResultType.EXPIRED -> OpexError.ExpiredOTP.exception() + else -> OpexError.InvalidOTP.exception() + } + } + + return result + } + + // --- Extensions --- + + private val KeycloakUser.currentOtpMethod: OTPType + get() = attributes?.get(Attributes.OTP) + ?.firstOrNull() + ?.let { runCatching { OTPType.valueOf(it) }.getOrNull() } + ?: OTPType.NONE + + private fun KeycloakUser.getDestinationFor(method: OTPType): String = when (method) { + OTPType.EMAIL -> email + OTPType.SMS -> mobile + else -> null + } ?: throw OpexError.BadRequest.exception() +} \ No newline at end of file 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 04ceb4f9e..9ba2e23de 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -67,6 +67,7 @@ keycloak: secret: ${ADMIN_CLIENT_SECRET} google-client-id: ${GOOGLE_CLIENT_ID} app: + name: ${APP_NAME:Opex} otp: url: http://opex-otp/v1 captcha: 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 27e99a3e5..ef2ad4892 100644 --- a/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt +++ b/common/src/main/kotlin/co/nilin/opex/common/OpexError.kt @@ -152,6 +152,7 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus TOTPSetupIncomplete(12006, "TOTP setup is incomplete", HttpStatus.BAD_REQUEST), TOTPAlreadyRegistered(12007, "User already registered for TOTP", HttpStatus.BAD_REQUEST), OTPDisabled(12008, "OTP for this receiver type is disabled", HttpStatus.INTERNAL_SERVER_ERROR), + InvalidOTPType(12009, "Invalid OTP type", HttpStatus.BAD_REQUEST), //code 12000 profile diff --git a/docker-compose.yml b/docker-compose.yml index 1c7d6e075..0a76fed17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -428,6 +428,7 @@ services: - SWAGGER_AUTH_AUTHORITY=${SWAGGER_AUTH_AUTHORITY} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - OPEN_API_SERVER_URL=${OPEN_API_SERVER_URL_AUTH} + - APP_NAME=$APP_NAME volumes: - auth-gateway-keys:/app/keys depends_on: diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt index 115db992e..7b769fb57 100644 --- a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/controller/TOTPController.kt @@ -37,12 +37,7 @@ class TOTPController(private val service: TOTPService) { @GetMapping("/query/{userId}") suspend fun query(@PathVariable userId: String): TOTPQueryResponse { - val totp = service.findTOTP(userId) - return TOTPQueryResponse( - totp?.userId ?: userId, - totp?.isEnabled ?: false, - totp?.isActivated ?: false, - ) + return service.findTOTP(userId) } @DeleteMapping diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt index 81dfa98b5..476f470dc 100644 --- a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/model/TOTPQueryResponse.kt @@ -4,4 +4,5 @@ data class TOTPQueryResponse( val userId: String, val isEnabled: Boolean, val isActivated: Boolean, + val uri : String ) diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt index 7ee56a0eb..3214b963a 100644 --- a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt @@ -2,6 +2,7 @@ package co.nilin.opex.otp.app.service import co.nilin.opex.common.OpexError import co.nilin.opex.otp.app.model.TOTP +import co.nilin.opex.otp.app.model.TOTPQueryResponse import co.nilin.opex.otp.app.repository.TOTPConfigRepository import co.nilin.opex.otp.app.repository.TOTPRepository import dev.samstevens.totp.code.DefaultCodeGenerator @@ -55,8 +56,17 @@ class TOTPService( } } - suspend fun findTOTP(userId: String): TOTP? { - return repository.findByUserId(userId) + suspend fun findTOTP(userId: String): TOTPQueryResponse { + val totp = repository.findByUserId(userId) + val config = configRepository.findOne() + val generatedUri = generateUri(userId, config.issuer, totp?.secret ?: "", totp?.label) + + return TOTPQueryResponse( + userId = totp?.userId ?: userId, + isEnabled = totp?.isEnabled ?: false, + isActivated = totp?.isActivated ?: false, + uri = generatedUri + ) } private suspend fun generateSecret(): String { From 4e59ee329b6260cc08ef6ffe1b253d268465438d Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Mon, 17 Aug 2026 17:36:49 +0330 Subject: [PATCH 2/4] add services to api module --- .../co/nilin/opex/api/core/inout/OTPType.kt | 2 +- .../co/nilin/opex/api/core/inout/TOTP.kt | 28 ++ .../co/nilin/opex/api/core/inout/TwoFactor.kt | 15 + .../co/nilin/opex/api/core/spi/AuthProxy.kt | 14 + .../ports/binance/config/SecurityConfig.kt | 1 + .../controller/UserTwoFactorController.kt | 259 ++++++++++++++++++ .../api/ports/proxy/impl/AuthProxyImpl.kt | 101 +++++++ ...ntroller.kt => UserTwoFactorController.kt} | 2 +- .../co/nilin/opex/auth/proxy/KeycloakProxy.kt | 1 + .../auth/service/TwoFactorConfigService.kt | 4 +- docker-compose.yml | 2 +- .../nilin/opex/otp/app/service/TOTPService.kt | 6 +- 12 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TOTP.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TwoFactor.kt create mode 100644 api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserTwoFactorController.kt rename auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/{UserTwoFactorConfigController.kt => UserTwoFactorController.kt} (98%) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt index 45d19c8e3..2fce50306 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/OTPType.kt @@ -2,5 +2,5 @@ package co.nilin.opex.api.core.inout enum class OTPType { - SMS, EMAIL, + SMS, EMAIL, TOTP, NONE } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TOTP.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TOTP.kt new file mode 100644 index 000000000..8a51cd4ab --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TOTP.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.api.core.inout + +data class SetupTOTPRequest( + val userId: String, + val label: String? +) + +data class SetupTOTPResponse( + val uri: String +) + +data class VerifyTOTPRequest( + val userId: String, + val code: String +) + +data class VerifyTOTPResponse(val result: Boolean) + +data class TOTPQueryResponse( + val userId: String, + val isEnabled: Boolean, + val isActivated: Boolean, + val uri : String +) + +data class TOTPCode( + val code: String +) diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TwoFactor.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TwoFactor.kt new file mode 100644 index 000000000..b3f057e2a --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/TwoFactor.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.api.core.inout + +import com.fasterxml.jackson.annotation.JsonInclude + +data class TwoFactorRequest( + val method: OTPType, +) + +data class ConfirmTwoFactorRequest( + val method: OTPType, + val otp: String, +) + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class TwoFactorResponse(val otp: String?, val otpReceiver: OTPReceiver?) 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 fb1d6c3f1..f273b4824 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 @@ -1,5 +1,11 @@ package co.nilin.opex.api.core.spi +import co.nilin.opex.api.core.inout.ConfirmTwoFactorRequest +import co.nilin.opex.api.core.inout.OTPType +import co.nilin.opex.api.core.inout.SetupTOTPResponse +import co.nilin.opex.api.core.inout.TOTPCode +import co.nilin.opex.api.core.inout.TwoFactorRequest +import co.nilin.opex.api.core.inout.TwoFactorResponse import co.nilin.opex.api.core.inout.auth.* interface AuthProxy { @@ -22,4 +28,12 @@ interface AuthProxy { suspend fun logoutOthers(token: String) suspend fun logoutAll(token: String) + suspend fun getTwoFactorConfig(token: String): OTPType + suspend fun requestEnableTwoFactor(request: TwoFactorRequest, token: String): TwoFactorResponse + suspend fun confirmEnableTwoFactor(request: ConfirmTwoFactorRequest, token: String): OTPVerifyResponse + suspend fun requestDisableTwoFactor(request: TwoFactorRequest, token: String): TwoFactorResponse + suspend fun confirmDisableTwoFactor(request: ConfirmTwoFactorRequest, token: String): OTPVerifyResponse + suspend fun setupTOTP(token: String): SetupTOTPResponse + suspend fun verifyTOTPSetup(request: TOTPCode, token: String) + } \ 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 4d005fdb5..8203fff40 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 @@ -106,6 +106,7 @@ class SecurityConfig( // Opex endpoints .pathMatchers("/opex/v1/oauth/protocol/openid-connect/**").permitAll() .pathMatchers("/opex/v1/oauth.***").permitAll() + .pathMatchers("/opex/v1/user/2fa/**").authenticated() .pathMatchers("/opex/v1/user/public/**").permitAll() .pathMatchers("/opex/v1/user/update/**").permitAll() .pathMatchers("/v1/deposit/webhook").permitAll() diff --git a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserTwoFactorController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserTwoFactorController.kt new file mode 100644 index 000000000..34b7e5528 --- /dev/null +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/UserTwoFactorController.kt @@ -0,0 +1,259 @@ +package co.nilin.opex.api.ports.opex.controller + +import co.nilin.opex.api.core.inout.ConfirmTwoFactorRequest +import co.nilin.opex.api.core.inout.OTPType +import co.nilin.opex.api.core.inout.SetupTOTPResponse +import co.nilin.opex.api.core.inout.TOTPCode +import co.nilin.opex.api.core.inout.TwoFactorRequest +import co.nilin.opex.api.core.inout.TwoFactorResponse +import co.nilin.opex.api.core.inout.auth.* +import co.nilin.opex.api.core.spi.AuthProxy +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 +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.* + +@RestController +@RequestMapping("/opex/v1/user/2fa") +@Tag( + name = "User Two-Factor Configuration", + description = "Endpoints for managing user two-factor authentication (2FA) settings and TOTP setup." +) +@SecurityRequirement(name = "bearerAuth") +class UserTwoFactorController(private val authProxy: AuthProxy) { + + @GetMapping + @Operation( + summary = "Get current two-factor authentication configuration", + description = """GET /opex/v1/user/2fa. +Security: Bearer token is required. + +Behavior: Retrieves the currently active two-factor authentication (2FA) method for the authenticated user. +Possible return values: NONE, EMAIL, SMS, TOTP.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor configuration retrieved successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = OTPType::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun getTwoFactorConfig( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.getTwoFactorConfig(securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/enable/request") + @Operation( + summary = "Request enabling two-factor authentication", + description = """POST /opex/v1/user/2fa/enable/request. +Security: Bearer token is required. + +Behavior: Starts the two-factor authentication enable flow for the authenticated user. +Allowed values: +- method: EMAIL, SMS, TOTP""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor enable request created successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = TwoFactorResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun requestEnableTwoFactor( + @RequestBody request: TwoFactorRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.requestEnableTwoFactor(request, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/enable/confirm") + @Operation( + summary = "Confirm enabling two-factor authentication", + description = """POST /opex/v1/user/2fa/enable/confirm. +Security: Bearer token is required. + +Behavior: Confirms and activates the two-factor authentication enable flow for the authenticated user.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor authentication enabled successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = OTPVerifyResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun confirmEnableTwoFactor( + @RequestBody request: ConfirmTwoFactorRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.confirmEnableTwoFactor(request, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/disable/request") + @Operation( + summary = "Request disabling two-factor authentication", + description = """POST /opex/v1/user/2fa/disable/request. +Security: Bearer token is required. + +Behavior: Starts the two-factor authentication disable flow for the authenticated user.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor disable request created successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = TwoFactorResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun requestDisableTwoFactor( + @RequestBody request: TwoFactorRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.requestDisableTwoFactor(request, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/disable/confirm") + @Operation( + summary = "Confirm disabling two-factor authentication", + description = """POST /opex/v1/user/2fa/disable/confirm. +Security: Bearer token is required. + +Behavior: Confirms and disables two-factor authentication for the authenticated user.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "Two-factor authentication disabled successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = OTPVerifyResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun confirmDisableTwoFactor( + @RequestBody request: ConfirmTwoFactorRequest, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.confirmDisableTwoFactor(request, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/totp/setup") + @Operation( + summary = "Setup TOTP (Authenticator App)", + description = """POST /opex/v1/user/2fa/totp/setup. +Security: Bearer token is required. + +Behavior: Generates secret key and setup URL (otpauth://...) for setting up Authenticator app (e.g., Google Authenticator).""", + responses = [ + ApiResponse( + responseCode = "200", + description = "TOTP setup credentials generated successfully.", + content = [ + Content( + mediaType = "application/json", + schema = Schema(implementation = SetupTOTPResponse::class) + ) + ] + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun setupTOTP( + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + val response = authProxy.setupTOTP(securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok(response) + } + + @PostMapping("/totp/verify") + @Operation( + summary = "Verify TOTP setup code", + description = """POST /opex/v1/user/2fa/totp/verify. +Security: Bearer token is required. + +Behavior: Verifies the generated TOTP code during the initial authenticator setup phase.""", + responses = [ + ApiResponse( + responseCode = "200", + description = "TOTP setup code verified successfully." + ), + ApiResponse( + responseCode = "401", + description = "Unauthorized. Bearer token is missing, invalid, or expired.", + content = [Content()] + ) + ] + ) + suspend fun verifyTOTPSetup( + @RequestBody request: TOTPCode, + @Parameter(hidden = true) @CurrentSecurityContext securityContext: SecurityContext + ): ResponseEntity { + authProxy.verifyTOTPSetup(request, securityContext.jwtAuthentication().tokenValue()) + return ResponseEntity.ok().build() + } +} \ No newline at end of file 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 index 8f702d2a4..358a34dd4 100644 --- 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 @@ -1,5 +1,11 @@ package co.nilin.opex.api.ports.proxy.impl +import co.nilin.opex.api.core.inout.ConfirmTwoFactorRequest +import co.nilin.opex.api.core.inout.OTPType +import co.nilin.opex.api.core.inout.SetupTOTPResponse +import co.nilin.opex.api.core.inout.TOTPCode +import co.nilin.opex.api.core.inout.TwoFactorRequest +import co.nilin.opex.api.core.inout.TwoFactorResponse import co.nilin.opex.api.core.inout.auth.* import co.nilin.opex.api.core.spi.AuthProxy import co.nilin.opex.common.OpexError @@ -223,4 +229,99 @@ class AuthProxyImpl(@Qualifier("generalWebClient") private val webClient: WebCli } .awaitBodilessEntity() } + + override suspend fun getTwoFactorConfig(token: String): OTPType { + return webClient.get() + .uri("$baseUrl/v1/user/2fa") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to get 2fa config") } + } + + override suspend fun requestEnableTwoFactor( + request: TwoFactorRequest, + token: String + ): TwoFactorResponse { + return webClient.post() + .uri("$baseUrl/v1/user/2fa/enable/request") + .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 request enable 2fa") } + } + + override suspend fun confirmEnableTwoFactor( + request: ConfirmTwoFactorRequest, + token: String + ): OTPVerifyResponse { + return webClient.post() + .uri("$baseUrl/v1/user/2fa/enable/confirm") + .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 confirm enable 2fa") } + } + + override suspend fun requestDisableTwoFactor( + request: TwoFactorRequest, + token: String + ): TwoFactorResponse { + return webClient.post() + .uri("$baseUrl/v1/user/2fa/disable/request") + .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 request disable 2fa") } + } + + override suspend fun confirmDisableTwoFactor( + request: ConfirmTwoFactorRequest, + token: String + ): OTPVerifyResponse { + return webClient.post() + .uri("$baseUrl/v1/user/2fa/disable/confirm") + .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 confirm disable 2fa") } + } + + override suspend fun setupTOTP(token: String): SetupTOTPResponse { + return webClient.post() + .uri("$baseUrl/v1/user/2fa/totp/setup") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitFirstOrElse { throw OpexError.BadRequest.exception("Failed to setup TOTP") } + } + + override suspend fun verifyTOTPSetup(request: TOTPCode, token: String) { + webClient.post() + .uri("$baseUrl/v1/user/2fa/totp/verify") + .accept(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer $token") + .body(Mono.just(request)) + .retrieve() + .onStatus({ it.isError }) { response -> + response.createException() + } + .awaitBodilessEntity() + } } \ No newline at end of file diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.kt similarity index 98% rename from auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt rename to auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.kt index 26681d47c..653dbf726 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorConfigController.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.kt @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.* name = "User Two-Factor Configuration", description = "Endpoints for managing user two-factor authentication (2FA) settings and TOTP setup." ) -class UserTwoFactorConfigController(private val twoFactorConfigService: TwoFactorConfigService) { +class UserTwoFactorController(private val twoFactorConfigService: TwoFactorConfigService) { @GetMapping 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 5e1d650c0..b55d35766 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 @@ -6,6 +6,7 @@ import co.nilin.opex.auth.model.* import co.nilin.opex.auth.utils.generateRandomID import co.nilin.opex.common.OpexError import co.nilin.opex.common.utils.LoggerDelegate +import jakarta.ws.rs.NotFoundException import kotlinx.coroutines.reactive.awaitFirstOrElse import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt index bc8416357..e2280115a 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt @@ -13,7 +13,7 @@ class TwoFactorConfigService( private val otpProxy: OTPProxy, private val keycloakProxy: KeycloakProxy, @Value("\${app.name}") - private val appName: Boolean, + private val appName: String, ) { private val logger by LoggerDelegate() @@ -87,7 +87,7 @@ class TwoFactorConfigService( "$appName : ${user.username}" ) - totpResponse.isEnabled && !totpResponse.isActivated -> SetupTOTPResponse(totpResponse.uri) + totpResponse.isEnabled -> SetupTOTPResponse(totpResponse.uri) else -> throw OpexError.BadRequest.exception() } } diff --git a/docker-compose.yml b/docker-compose.yml index 0a76fed17..c804e40c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -428,7 +428,7 @@ services: - SWAGGER_AUTH_AUTHORITY=${SWAGGER_AUTH_AUTHORITY} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - OPEN_API_SERVER_URL=${OPEN_API_SERVER_URL_AUTH} - - APP_NAME=$APP_NAME + - APP_NAME=${APP_NAME} volumes: - auth-gateway-keys:/app/keys depends_on: diff --git a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt index 3214b963a..c85669665 100644 --- a/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt +++ b/otp/otp-app/src/main/kotlin/co/nilin/opex/otp/app/service/TOTPService.kt @@ -59,8 +59,10 @@ class TOTPService( suspend fun findTOTP(userId: String): TOTPQueryResponse { val totp = repository.findByUserId(userId) val config = configRepository.findOne() - val generatedUri = generateUri(userId, config.issuer, totp?.secret ?: "", totp?.label) - + val generatedUri = totp?.secret + ?.takeIf { it.isNotBlank() } + ?.let { secret -> generateUri(userId, config.issuer, secret, totp.label) } + ?: "" return TOTPQueryResponse( userId = totp?.userId ?: userId, isEnabled = totp?.isEnabled ?: false, From 9fba8e85d1b683b84602be8e6434bd55a50c962b Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Mon, 17 Aug 2026 18:12:39 +0330 Subject: [PATCH 3/4] Update TwoFactorConfigService.kt --- .../co/nilin/opex/auth/service/TwoFactorConfigService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt index e2280115a..8864eaa72 100644 --- a/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt +++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/TwoFactorConfigService.kt @@ -35,7 +35,7 @@ class TwoFactorConfigService( if (!totpConfig.isActivated || !totpConfig.isEnabled) { throw OpexError.TOTPSetupIncomplete.exception() } - TwoFactorResponse(otp = null, otpReceiver = OTPReceiver(uuid, OTPType.TOTP)) + TwoFactorResponse(otp = null, otpReceiver = OTPReceiver("$appName : ${user.username}", OTPType.TOTP)) } OTPType.NONE -> throw OpexError.InvalidOTPType.exception() @@ -61,7 +61,7 @@ class TwoFactorConfigService( } return when (method) { OTPType.EMAIL, OTPType.SMS -> sendOtpRequest(user, method) - OTPType.TOTP -> TwoFactorResponse(otp = null, otpReceiver = OTPReceiver(uuid, OTPType.TOTP)) + OTPType.TOTP -> TwoFactorResponse(otp = null, otpReceiver = OTPReceiver("$appName : ${user.username}", OTPType.TOTP)) OTPType.NONE -> throw OpexError.InvalidOTPType.exception() } } From 9e1db317be4dfb2d196b4e094310cce244628194 Mon Sep 17 00:00:00 2001 From: Amir Rajabi Date: Mon, 17 Aug 2026 18:50:42 +0330 Subject: [PATCH 4/4] hot fix --- .../co/nilin/opex/api/core/inout/PairCategory.kt | 7 +++++++ .../co/nilin/opex/api/core/inout/PairInfoResponse.kt | 3 +++ .../co/nilin/opex/api/core/inout/PairSetting.kt | 4 ++++ .../api/ports/opex/controller/MarketController.kt | 12 ++++++------ 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairCategory.kt diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairCategory.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairCategory.kt new file mode 100644 index 000000000..b6b92ce16 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairCategory.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.core.inout + +enum class PairCategory { + REAL_ASSET_TOKEN, + FIAT, + CRYPTO +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt index d6c00fe0e..1fed02103 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairInfoResponse.kt @@ -10,4 +10,7 @@ data class PairInfoResponse( val minOrder : BigDecimal, val maxOrder : BigDecimal, val orderTypes : String, + val internalChart: Boolean, + val globalChart: Boolean, + val categories: List = emptyList() ) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt index b3d7d5946..28f3b951d 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairSetting.kt @@ -10,4 +10,8 @@ class PairSetting( val maxOrder : BigDecimal, val orderTypes : String, val updateDate: LocalDateTime? = null, + val internalChart: Boolean, + val globalChart: Boolean, + val categories: List = emptyList() + ) \ 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/MarketController.kt b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt index 290c55827..e309a0a4e 100644 --- a/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt +++ b/api/api-ports/api-opex-rest/src/main/kotlin/co/nilin/opex/api/ports/opex/controller/MarketController.kt @@ -18,11 +18,7 @@ import io.swagger.v3.oas.annotations.tags.Tag import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.springframework.beans.factory.annotation.Value -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.math.BigDecimal import java.time.ZoneId @@ -104,7 +100,11 @@ Response body: isAvailable = isAvailable, minOrder = minOrder, maxOrder = maxOrder, - orderTypes = orderTypes + orderTypes = orderTypes, + internalChart = internalChart, + globalChart = globalChart, + categories = categories + ) } }