From eb672317138b0aef9761871ea77f4a5f41bc25c9 Mon Sep 17 00:00:00 2001
From: Fatemeh imani <46007372+fatemeh-i@users.noreply.github.com>
Date: Thu, 13 Aug 2026 17:58:43 +0200
Subject: [PATCH 1/3] Dev
---
device-management/pom.xml | 2 +-
pom.xml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/device-management/pom.xml b/device-management/pom.xml
index 0923d7cdd..e57f71f5c 100644
--- a/device-management/pom.xml
+++ b/device-management/pom.xml
@@ -22,7 +22,7 @@
2.1.0
3.4.2
2024.0.0
- 1.2.26
+ 1.2.27
diff --git a/pom.xml b/pom.xml
index e49130f86..b05355151 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
1.9.0
2.7.6
2021.0.5
- 1.2.26
+ 1.2.27
1.0.8
true
1.0.1-beta.38
From 918e931b382f9fcd887b2ddd86e8184baf725975 Mon Sep 17 00:00:00 2001
From: Amir Rajabi <34955519+AmirRajabii@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:34:47 +0330
Subject: [PATCH 2/3] Optimize financial action operations and implement
two-factor authentication (#721)
---
.../scheduler/FinancialActionsArchiveJob.kt | 19 +-
.../postgres/dao/FinancialActionRepository.kt | 26 +-
.../impl/FinancialActionLoaderImpl.kt | 8 +-
.../impl/FinancialActionPersisterImpl.kt | 2 +-
.../src/main/resources/schema.sql | 9 +
.../ports/postgres/FAPersisterImplTest.kt | 29 ++
.../co/nilin/opex/api/core/inout/OTPType.kt | 2 +-
.../nilin/opex/api/core/inout/PairCategory.kt | 7 +
.../opex/api/core/inout/PairInfoResponse.kt | 3 +
.../nilin/opex/api/core/inout/PairSetting.kt | 4 +
.../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 +
.../ports/opex/controller/MarketController.kt | 12 +-
.../controller/UserTwoFactorController.kt | 259 ++++++++++++++++++
.../api/ports/proxy/impl/AuthProxyImpl.kt | 101 +++++++
.../auth/controller/PublicUserController.kt | 42 ++-
.../controller/UserTwoFactorController.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 | 58 +++-
.../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 | 16 +-
wallet/wallet-app/pom.xml | 5 +
.../exc/ConcurrentBalanceChangException.kt | 2 +-
.../wallet/core/model/PersistedTransaction.kt | 6 +
.../core/service/TransferManagerImpl.kt | 77 +++++-
.../wallet/core/spi/TransactionManager.kt | 1 +
.../core/service/TransferManagerImplTest.kt | 69 +++++
.../postgres/dao/TransactionRepository.kt | 3 +
.../postgres/impl/TransactionManagerImpl.kt | 26 +-
43 files changed, 1444 insertions(+), 116 deletions(-)
create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/PairCategory.kt
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
create mode 100644 auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.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
create mode 100644 wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/PersistedTransaction.kt
diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/FinancialActionsArchiveJob.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/FinancialActionsArchiveJob.kt
index f0d26804b..a6acea9e8 100644
--- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/FinancialActionsArchiveJob.kt
+++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/scheduler/FinancialActionsArchiveJob.kt
@@ -25,15 +25,26 @@ class FinancialActionsArchiveJob(
@Value("\${app.fi-action.archive.batch-size:1000}")
private var batchSize: Int = 1000
+ @Value("\${app.fi-action.archive.max-batches-per-run:20}")
+ private var maxBatchesPerRun: Int = 20
+
@Scheduled(fixedDelayString = "\${app.fi-action.archive.fixed-delay-ms:300000}", initialDelay = 60000)
fun archiveProcessedActions() {
- if (!enabled || batchSize <= 0 || retentionDays <= 0) return
+ if (!enabled || batchSize <= 0 || retentionDays <= 0 || maxBatchesPerRun <= 0) return
runBlocking {
val before = LocalDateTime.now().minusDays(retentionDays)
- val archived = financialActionPersister.archiveProcessedActions(before, batchSize)
- if (archived > 0) {
- log.info("Archived $archived processed financial actions older than $before")
+ var totalArchived = 0
+ var shouldContinue = true
+ repeat(maxBatchesPerRun) {
+ if (!shouldContinue) return@repeat
+ val archived = financialActionPersister.archiveProcessedActions(before, batchSize)
+ totalArchived += archived
+ if (archived < batchSize) shouldContinue = false
+ }
+
+ if (totalArchived > 0) {
+ log.info("Archived $totalArchived processed financial actions older than $before")
}
}
}
diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt
index 16dbeddf4..86b7fb960 100644
--- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt
+++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/FinancialActionRepository.kt
@@ -9,7 +9,6 @@ import org.springframework.data.repository.query.Param
import org.springframework.data.repository.reactive.ReactiveCrudRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
-import java.math.BigDecimal
import java.time.LocalDateTime
@Repository
@@ -22,13 +21,23 @@ interface FinancialActionRepository : ReactiveCrudRepository
- @Query("select count(1) from fi_actions fi where fi.sender = :uuid and fi.symbol = :symbol and fi.event_type = :eventType and fi.status != :status")
- fun countByUuidAndSymbolAndEventTypeAndStatusNot(
+ @Query(
+ """
+ select exists(
+ select 1
+ from fi_actions fi
+ where fi.sender = :uuid
+ and fi.symbol = :symbol
+ and fi.event_type = :eventType
+ and fi.status <> 'PROCESSED'
+ )
+ """
+ )
+ fun existsUnprocessedBySenderAndSymbolAndEventType(
@Param("uuid") uuid: String,
@Param("symbol") symbol: String,
- @Param("eventType") eventType: String,
- @Param("status") financialActionStatus: FinancialActionStatus
- ): Mono
+ @Param("eventType") eventType: String
+ ): Mono
@Query("select * from fi_actions fi where status != :status")
fun findByStatusNot(@Param("status") status: String, paging: Pageable): Flow
@@ -69,6 +78,11 @@ interface FinancialActionRepository : ReactiveCrudRepository 'PROCESSED'
+ )
order by create_date
limit :limit
),
diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionLoaderImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionLoaderImpl.kt
index 1fb65e088..c4e281c2c 100644
--- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionLoaderImpl.kt
+++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionLoaderImpl.kt
@@ -15,7 +15,6 @@ import kotlinx.coroutines.reactive.awaitFirstOrElse
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
-import java.math.BigDecimal
import java.time.LocalDateTime
@Component
@@ -48,12 +47,11 @@ class FinancialActionLoaderImpl(
}
override suspend fun countUnprocessed(userUuid: String, symbol: String, eventType: String): Long {
- return financialActionRepository.countByUuidAndSymbolAndEventTypeAndStatusNot(
+ return if (financialActionRepository.existsUnprocessedBySenderAndSymbolAndEventType(
userUuid,
symbol,
- eventType,
- FinancialActionStatus.PROCESSED
- ).awaitFirstOrElse { BigDecimal.ZERO }.toLong()
+ eventType
+ ).awaitFirstOrElse { false }) 1L else 0L
}
override suspend fun loadFinancialAction(id: Long?): FinancialAction? {
diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt
index 12292c2e6..e5412a6e7 100644
--- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt
+++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/FinancialActionPersisterImpl.kt
@@ -103,7 +103,7 @@ class FinancialActionPersisterImpl(
faRetryRepository.scheduleNext(
id!!,
retries + 1,
- LocalDateTime.now().plusSeconds(retries * delayMultiplier * delaySeconds),
+ LocalDateTime.now().plusSeconds((retries + 1L) * delayMultiplier * delaySeconds),
giveUp
).awaitSingleOrNull()
diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql b/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql
index 8b7b1b772..994e76d3d 100644
--- a/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql
+++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/resources/schema.sql
@@ -51,6 +51,15 @@ CREATE INDEX IF NOT EXISTS idx_fi_actions_status ON fi_actions (status);
CREATE INDEX IF NOT EXISTS idx_fi_actions_pointer ON fi_actions (pointer);
CREATE INDEX IF NOT EXISTS idx_fi_actions_status_create_date ON fi_actions (status, create_date);
CREATE INDEX IF NOT EXISTS idx_fi_actions_parent_status ON fi_actions (parent_id, status);
+CREATE INDEX IF NOT EXISTS idx_fi_actions_unprocessed_lookup
+ ON fi_actions (sender, symbol, event_type)
+ WHERE status <> 'PROCESSED';
+CREATE INDEX IF NOT EXISTS idx_fi_actions_archive_candidates
+ ON fi_actions (create_date, id)
+ WHERE status = 'PROCESSED';
+CREATE INDEX IF NOT EXISTS idx_fi_actions_unprocessed_children_by_parent
+ ON fi_actions (parent_id)
+ WHERE status <> 'PROCESSED';
ALTER TABLE fi_actions
ADD COLUMN IF NOT EXISTS category_name VARCHAR(36);
diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/FAPersisterImplTest.kt b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/FAPersisterImplTest.kt
index 38c794ffd..ea63bfebf 100644
--- a/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/FAPersisterImplTest.kt
+++ b/accountant/accountant-ports/accountant-persister-postgres/src/test/kotlin/co/nilin/opex/accountant/ports/postgres/FAPersisterImplTest.kt
@@ -9,10 +9,14 @@ import co.nilin.opex.accountant.ports.postgres.model.FinancialActionModel
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
+import io.mockk.slot
+import co.nilin.opex.accountant.ports.postgres.model.FinancialActionRetryModel
import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
+import java.time.LocalDateTime
@Suppress("ReactiveStreamsUnusedPublisher")
class FAPersisterImplTest {
@@ -48,4 +52,29 @@ class FAPersisterImplTest {
}
}
+ @Test
+ fun givenRetryableAction_whenUpdateWithError_thenScheduleUsesBackoffDelay(): Unit = runBlocking {
+ val retryModel = FinancialActionRetryModel(
+ faId = Valid.fa.id!!,
+ nextRunTime = LocalDateTime.now(),
+ retries = 0,
+ isResolved = false,
+ hasGivenUp = false,
+ id = 10
+ )
+ val nextRunSlot = slot()
+
+ coEvery { faRetryRepository.findByFaId(Valid.fa.id!!) } returns Mono.just(retryModel)
+ coEvery { faRetryRepository.scheduleNext(eq(10), eq(1), capture(nextRunSlot), eq(false)) } returns Mono.empty()
+ coEvery { financialActionRepository.updateStatus(eq(Valid.fa.id!!), eq(FinancialActionStatus.RETRYING)) } returns Mono.empty()
+ coEvery { faErrorRepository.save(any()) } returns Mono.empty()
+
+ val before = LocalDateTime.now()
+ faPersister.updateWithError(Valid.fa, "ERR", "message", null)
+
+ coVerify(exactly = 1) { faRetryRepository.scheduleNext(eq(10), eq(1), any(), eq(false)) }
+ assertTrue(nextRunSlot.isCaptured)
+ assertTrue(nextRunSlot.captured.isAfter(before.plusSeconds(10)))
+ }
+
}
\ No newline at end of file
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/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-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/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
+
)
}
}
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/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/UserTwoFactorController.kt b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.kt
new file mode 100644
index 000000000..653dbf726
--- /dev/null
+++ b/auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/UserTwoFactorController.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 UserTwoFactorController(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..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
@@ -155,6 +156,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 +200,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 +213,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 +418,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 +444,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..8864eaa72
--- /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: String,
+) {
+ 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("$appName : ${user.username}", 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("$appName : ${user.username}", 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 -> 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..c804e40c1 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..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
@@ -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,19 @@ 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 = totp?.secret
+ ?.takeIf { it.isNotBlank() }
+ ?.let { secret -> generateUri(userId, config.issuer, secret, totp.label) }
+ ?: ""
+ return TOTPQueryResponse(
+ userId = totp?.userId ?: userId,
+ isEnabled = totp?.isEnabled ?: false,
+ isActivated = totp?.isActivated ?: false,
+ uri = generatedUri
+ )
}
private suspend fun generateSecret(): String {
diff --git a/wallet/wallet-app/pom.xml b/wallet/wallet-app/pom.xml
index 9e1e23288..13dd14714 100644
--- a/wallet/wallet-app/pom.xml
+++ b/wallet/wallet-app/pom.xml
@@ -251,6 +251,11 @@
5.4.0
test
+
+ com.zaxxer
+ HikariCP
+ test
+
diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/exc/ConcurrentBalanceChangException.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/exc/ConcurrentBalanceChangException.kt
index edf52fad0..c8ab6b465 100644
--- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/exc/ConcurrentBalanceChangException.kt
+++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/exc/ConcurrentBalanceChangException.kt
@@ -1,3 +1,3 @@
package co.nilin.opex.wallet.core.exc
-class ConcurrentBalanceChangException(override val message: String?) : Exception()
\ No newline at end of file
+class ConcurrentBalanceChangException(override val message: String?) : RuntimeException()
\ No newline at end of file
diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/PersistedTransaction.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/PersistedTransaction.kt
new file mode 100644
index 000000000..439062dec
--- /dev/null
+++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/model/PersistedTransaction.kt
@@ -0,0 +1,6 @@
+package co.nilin.opex.wallet.core.model
+
+data class PersistedTransaction(
+ val id: Long,
+ val transaction: Transaction
+)
diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt
index b03fb97ed..8fc6cd9dc 100644
--- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt
+++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImpl.kt
@@ -7,6 +7,7 @@ import co.nilin.opex.wallet.core.inout.TransferResultDetailed
import co.nilin.opex.wallet.core.model.*
import co.nilin.opex.wallet.core.spi.*
import org.slf4j.LoggerFactory
+import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@@ -25,6 +26,8 @@ class TransferManagerImpl(
@Transactional
override suspend fun transfer(transferCommand: TransferCommand): TransferResultDetailed {
+ resolveIdempotentTransfer(transferCommand)?.let { return it }
+
//pre transfer hook (dispatch pre transfer event)
val srcWallet = transferCommand.sourceWallet
val srcWalletOwner = srcWallet.owner
@@ -53,20 +56,26 @@ class TransferManagerImpl(
if (!walletManager.isDepositAllowed(destWallet, amountToTransfer))
throw OpexError.DepositLimitExceeded.exception()
+ val tx = try {
+ transactionManager.save(
+ Transaction(
+ srcWallet,
+ destWallet,
+ transferCommand.amount.amount,
+ amountToTransfer,
+ transferCommand.description,
+ transferCommand.transferRef,
+ transferCommand.transferCategory,
+ LocalDateTime.now()
+ )
+ )
+ } catch (e: DuplicateKeyException) {
+ resolveIdempotentTransfer(transferCommand)?.let { return it }
+ throw e
+ }
+
walletManager.decreaseBalance(srcWallet, transferCommand.amount.amount)
walletManager.increaseBalance(destWallet, amountToTransfer)
- val tx = transactionManager.save(
- Transaction(
- srcWallet,
- destWallet,
- transferCommand.amount.amount,
- amountToTransfer,
- transferCommand.description,
- transferCommand.transferRef,
- transferCommand.transferCategory,
- LocalDateTime.now()
- )
- )
//TODO make tx long by default
createUserTX(transferCommand, tx)
@@ -93,6 +102,50 @@ class TransferManagerImpl(
)
}
+ private suspend fun resolveIdempotentTransfer(transferCommand: TransferCommand): TransferResultDetailed? {
+ val transferRef = transferCommand.transferRef ?: return null
+ val persistedTransaction = transactionManager.findTransactionByTransferRef(transferRef) ?: return null
+ val existingTxId = persistedTransaction.id
+ val existingTransaction = persistedTransaction.transaction
+
+ if (!matchesIdempotentTransfer(transferCommand, existingTransaction)) {
+ throw OpexError.BadRequest.exception("transferRef=$transferRef already exists with different parameters")
+ }
+
+ logger.info("Idempotent transfer hit for transferRef={}", transferRef)
+ return buildIdempotentResult(existingTransaction, existingTxId)
+ }
+
+ private fun matchesIdempotentTransfer(transferCommand: TransferCommand, existingTransaction: Transaction): Boolean {
+ return transferCommand.sourceWallet.id == existingTransaction.sourceWallet.id &&
+ transferCommand.destWallet.id == existingTransaction.destWallet.id &&
+ transferCommand.amount == Amount(existingTransaction.sourceWallet.currency, existingTransaction.sourceAmount) &&
+ transferCommand.destAmount == Amount(existingTransaction.destWallet.currency, existingTransaction.destAmount) &&
+ transferCommand.transferCategory == existingTransaction.transferCategory &&
+ transferCommand.description == existingTransaction.description
+ }
+
+ private fun buildIdempotentResult(existingTransaction: Transaction, existingTxId: Long): TransferResultDetailed {
+ val srcWallet = existingTransaction.sourceWallet
+ val destWallet = existingTransaction.destWallet
+ return TransferResultDetailed(
+ TransferResult(
+ Date().time,
+ srcWallet.owner.uuid,
+ srcWallet.type,
+ srcWallet.balance,
+ srcWallet.balance,
+ Amount(srcWallet.currency, existingTransaction.sourceAmount),
+ destWallet.owner.uuid,
+ destWallet.type,
+ Amount(destWallet.currency, existingTransaction.destAmount),
+ srcWallet.id,
+ destWallet.id,
+ ),
+ existingTxId.toString()
+ )
+ }
+
private suspend fun createUserTX(command: TransferCommand, txId: Long) {
val currency = command.amount.currency.symbol
val amount = command.amount.amount
diff --git a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/spi/TransactionManager.kt b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/spi/TransactionManager.kt
index 9f646d823..d8fff39c0 100644
--- a/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/spi/TransactionManager.kt
+++ b/wallet/wallet-core/src/main/kotlin/co/nilin/opex/wallet/core/spi/TransactionManager.kt
@@ -6,6 +6,7 @@ import java.time.LocalDateTime
interface TransactionManager {
suspend fun save(transaction: Transaction): Long
+ suspend fun findTransactionByTransferRef(transferRef: String): PersistedTransaction?
suspend fun findDepositTransactions(
uuid: String,
diff --git a/wallet/wallet-core/src/test/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImplTest.kt b/wallet/wallet-core/src/test/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImplTest.kt
index 0fc406bfa..08320d369 100644
--- a/wallet/wallet-core/src/test/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImplTest.kt
+++ b/wallet/wallet-core/src/test/kotlin/co/nilin/opex/wallet/core/service/TransferManagerImplTest.kt
@@ -1,15 +1,20 @@
package co.nilin.opex.wallet.core.service
+import co.nilin.opex.common.OpexError
import co.nilin.opex.wallet.core.model.Amount
+import co.nilin.opex.wallet.core.model.Transaction
import co.nilin.opex.wallet.core.service.sample.VALID
import co.nilin.opex.wallet.core.spi.*
import io.mockk.MockKException
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
+import java.math.BigDecimal
private class TransferManagerImplTest {
private val walletOwnerManager: WalletOwnerManager = mockk()
@@ -169,4 +174,68 @@ private class TransferManagerImplTest {
}
}.isNotInstanceOf(MockKException::class.java)
}
+
+ @Test
+ fun givenExistingTransferRef_whenTransfer_thenReturnIdempotentSuccessWithoutBalanceChanges(): Unit = runBlocking {
+ val command = VALID.TRANSFER_COMMAND.copy(transferRef = "accountant:fiActions:abc")
+ coEvery { transactionManager.findTransactionByTransferRef(eq(command.transferRef!!)) } returns co.nilin.opex.wallet.core.model.PersistedTransaction(
+ 100L,
+ Transaction(
+ VALID.SOURCE_WALLET,
+ VALID.DEST_WALLET,
+ command.amount.amount,
+ command.destAmount.amount,
+ command.description,
+ command.transferRef,
+ command.transferCategory,
+ java.time.LocalDateTime.now()
+ )
+ )
+
+ val result = transferManager.transfer(command)
+
+ assertThat(result.tx).isEqualTo("100")
+ assertThat(result.transferResult.sourceUuid).isEqualTo(command.sourceWallet.owner.uuid)
+ assertThat(result.transferResult.destUuid).isEqualTo(command.destWallet.owner.uuid)
+
+ coVerify(exactly = 0) { walletManager.decreaseBalance(any(), any()) }
+ coVerify(exactly = 0) { walletManager.increaseBalance(any(), any()) }
+ coVerify(exactly = 0) { transactionManager.save(any()) }
+ coVerify(exactly = 0) { walletListener.onDeposit(any(), any(), any(), any(), any()) }
+ coVerify(exactly = 0) { walletListener.onWithdraw(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun givenExistingTransferRefWithDifferentParams_whenTransfer_thenThrowBadRequest(): Unit = runBlocking {
+ val command = VALID.TRANSFER_COMMAND.copy(
+ transferRef = "accountant:fiActions:abc",
+ destWallet = VALID.DEST_WALLET.copy(id = 999L),
+ amount = Amount(VALID.CURRENCY, BigDecimal("0.75")),
+ destAmount = Amount(VALID.CURRENCY, BigDecimal("0.75"))
+ )
+ coEvery { transactionManager.findTransactionByTransferRef(eq(command.transferRef!!)) } returns co.nilin.opex.wallet.core.model.PersistedTransaction(
+ 100L,
+ Transaction(
+ VALID.SOURCE_WALLET,
+ VALID.DEST_WALLET,
+ VALID.TRANSFER_COMMAND.amount.amount,
+ VALID.TRANSFER_COMMAND.destAmount.amount,
+ VALID.TRANSFER_COMMAND.description,
+ VALID.TRANSFER_COMMAND.transferRef,
+ VALID.TRANSFER_COMMAND.transferCategory,
+ java.time.LocalDateTime.now()
+ )
+ )
+
+ val ex = Assertions.assertThrows(co.nilin.opex.utility.error.data.OpexException::class.java) {
+ runBlocking {
+ transferManager.transfer(command)
+ }
+ }
+
+ assertThat(ex.error).isEqualTo(OpexError.BadRequest)
+ coVerify(exactly = 0) { walletManager.decreaseBalance(any(), any()) }
+ coVerify(exactly = 0) { walletManager.increaseBalance(any(), any()) }
+ coVerify(exactly = 0) { transactionManager.save(any()) }
+ }
}
diff --git a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/dao/TransactionRepository.kt b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/dao/TransactionRepository.kt
index f47ca1312..0eba0f408 100644
--- a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/dao/TransactionRepository.kt
+++ b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/dao/TransactionRepository.kt
@@ -16,6 +16,9 @@ import java.time.LocalDateTime
@Repository
interface TransactionRepository : ReactiveCrudRepository {
+ @Query("select * from transaction where transfer_ref = :transferRef limit 1")
+ fun findByTransferRef(transferRef: String): Mono
+
@Query(
"""
SELECT count(1) cnt, COALESCE(sum(source_amount), 0) total
diff --git a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TransactionManagerImpl.kt b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TransactionManagerImpl.kt
index 7e7ec45e0..d60f33884 100644
--- a/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TransactionManagerImpl.kt
+++ b/wallet/wallet-ports/wallet-persister-postgres/src/main/kotlin/co/nilin/opex/wallet/ports/postgres/impl/TransactionManagerImpl.kt
@@ -2,12 +2,14 @@ package co.nilin.opex.wallet.ports.postgres.impl
import co.nilin.opex.wallet.core.model.*
import co.nilin.opex.wallet.core.spi.TransactionManager
+import co.nilin.opex.wallet.core.spi.WalletManager
import co.nilin.opex.wallet.ports.postgres.dao.CurrencyRepositoryV2
import co.nilin.opex.wallet.ports.postgres.dao.TransactionRepository
import co.nilin.opex.wallet.ports.postgres.model.TransactionModel
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.reactive.awaitFirstOrElse
import kotlinx.coroutines.reactive.awaitSingle
+import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@@ -17,6 +19,7 @@ import java.time.ZoneId
class TransactionManagerImpl(
private val transactionRepository: TransactionRepository,
private val currencyRepositoryV2: CurrencyRepositoryV2,
+ private val walletManager: WalletManager,
private val objectMapper: ObjectMapper
) : TransactionManager {
private val logger = LoggerFactory.getLogger(TransactionManagerImpl::class.java)
@@ -36,6 +39,26 @@ class TransactionManagerImpl(
).awaitSingle().id!!
}
+ override suspend fun findTransactionByTransferRef(transferRef: String): PersistedTransaction? {
+ val transaction = transactionRepository.findByTransferRef(transferRef).awaitSingleOrNull() ?: return null
+ val sourceWallet = walletManager.findWalletById(transaction.sourceWallet) ?: return null
+ val destWallet = walletManager.findWalletById(transaction.destWallet) ?: return null
+
+ return PersistedTransaction(
+ transaction.id!!,
+ Transaction(
+ sourceWallet,
+ destWallet,
+ transaction.sourceAmount,
+ transaction.destAmount,
+ transaction.description,
+ transaction.transferRef,
+ transaction.transferCategory,
+ transaction.transactionDate
+ )
+ )
+ }
+
override suspend fun findDepositTransactions(
uuid: String,
@@ -148,6 +171,3 @@ class TransactionManagerImpl(
.collectList().awaitFirstOrElse { emptyList() }
}
}
-
-
-
From 485043c812d711f45a8929cf7008162f29813011 Mon Sep 17 00:00:00 2001
From: fatemeh imanipour
Date: Wed, 19 Aug 2026 16:20:03 +0330
Subject: [PATCH 3/3] Enhance market overview data
---
.../ports/proxy/impl/MarketDataProxyImpl.kt | 2 +-
.../ports/postgres/dao/TradeRepository.kt | 69 ++++++-------
.../postgres/impl/MarketQueryHandlerImpl.kt | 62 +++++++++---
.../src/main/resources/schema.sql | 1 +
.../postgres/impl/MarketQueryHandlerTest.kt | 96 ++++++++++++++++++-
5 files changed, 174 insertions(+), 56 deletions(-)
diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt
index 41782c441..3791ef458 100644
--- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt
+++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MarketDataProxyImpl.kt
@@ -56,7 +56,7 @@ class MarketDataProxyImpl(@Qualifier("generalWebClient") private val webClient:
.onStatus({ t -> t.isError }, { it.createException() })
.bodyToMono()
.awaitSingleOrNull()
- ?: PriceChange(symbol, openTime = Date().time, closeTime = interval.getTime())
+ ?: PriceChange(symbol, openTime = interval.getTime(), closeTime = Date().time)
}
}
diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt
index 517db2d74..8c6cd0a5a 100644
--- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt
+++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/dao/TradeRepository.kt
@@ -189,26 +189,21 @@ interface TradeRepository : ReactiveCrudRepository {
select symbol,
(select matched_price from last_trade where symbol=t.symbol) - (select matched_price from first_trade where symbol=t.symbol) as price_change,
((((select matched_price from last_trade where symbol=t.symbol) - (select matched_price from first_trade where symbol=t.symbol))/(select matched_price from first_trade where symbol=t.symbol))*100) as price_change_percent,
- (sum(matched_quantity)/sum(matched_price)) as weighted_avg_price,
+ (sum(matched_price * matched_quantity)/nullif(sum(matched_quantity), 0)) as weighted_avg_price,
(select matched_price from last_trade where symbol=t.symbol) as last_price,
(select matched_quantity from last_trade where symbol=t.symbol) as last_qty,
(
- select price from orders
+ select max(price) from orders
inner join open_orders oo on orders.ouid = oo.ouid
where create_date > :date and symbol=t.symbol and side='BID'
- order by create_date desc limit 1
) as bid_price,
(
- select price from orders
+ select min(price) from orders
inner join open_orders oo on orders.ouid = oo.ouid
where create_date > :date and symbol=t.symbol and side='ASK'
- order by create_date desc limit 1
) as ask_price,
(
- select price from orders
- inner join open_orders oo on orders.ouid = oo.ouid
- where create_date > :date and symbol=t.symbol
- order by create_date desc limit 1
+ select matched_price from first_trade where symbol=t.symbol
) as open_price,
max(matched_price) as high_price,
min(matched_price) as low_price,
@@ -230,26 +225,21 @@ interface TradeRepository : ReactiveCrudRepository {
select symbol,
(select matched_price from last_trade) - (select matched_price from first_trade) as price_change,
((((select matched_price from last_trade) - (select matched_price from first_trade))/(select matched_price from first_trade))*100) as price_change_percent,
- (sum(matched_quantity)/sum(matched_price)) as weighted_avg_price,
+ (sum(matched_price * matched_quantity)/nullif(sum(matched_quantity), 0)) as weighted_avg_price,
(select matched_price from last_trade) as last_price,
(select matched_quantity from last_trade) as last_qty,
(
- select price from orders
+ select max(price) from orders
inner join open_orders oo on orders.ouid = oo.ouid
where create_date > :date and symbol=t.symbol and side='BID'
- order by create_date desc limit 1
) as bid_price,
(
- select price from orders
+ select min(price) from orders
inner join open_orders oo on orders.ouid = oo.ouid
where create_date > :date and symbol=t.symbol and side='ASK'
- order by create_date desc limit 1
) as ask_price,
(
- select price from orders
- inner join open_orders oo on orders.ouid = oo.ouid
- where create_date > :date and symbol=t.symbol
- order by create_date desc limit 1
+ select matched_price from first_trade
) as open_price,
max(matched_price) as high_price,
min(matched_price) as low_price,
@@ -350,29 +340,35 @@ interface TradeRepository : ReactiveCrudRepository {
:interval::INTERVAL
)
),
+ limited_intervals AS (
+ SELECT *
+ FROM intervals
+ ORDER BY start_time DESC
+ LIMIT :limit
+ ),
first_trade AS (
- SELECT DISTINCT ON (f.start_time)
- f.start_time,
- f.end_time,
+ SELECT DISTINCT ON (i.start_time)
+ i.start_time,
+ i.end_time,
t.matched_price AS open_price
- FROM intervals f
+ FROM limited_intervals i
LEFT JOIN trades t
- ON t.create_date >= f.start_time
- AND t.create_date < f.end_time
+ ON t.create_date >= i.start_time
+ AND t.create_date < i.end_time
AND t.symbol = :symbol
- ORDER BY f.start_time, t.create_date
+ ORDER BY i.start_time, t.create_date
),
last_trade AS (
- SELECT DISTINCT ON (f.start_time)
- f.start_time,
- f.end_time,
+ SELECT DISTINCT ON (i.start_time)
+ i.start_time,
+ i.end_time,
t.matched_price AS close_price
- FROM intervals f
+ FROM limited_intervals i
LEFT JOIN trades t
- ON t.create_date >= f.start_time
- AND t.create_date < f.end_time
+ ON t.create_date >= i.start_time
+ AND t.create_date < i.end_time
AND t.symbol = :symbol
- ORDER BY f.start_time, t.create_date DESC
+ ORDER BY i.start_time, t.create_date DESC
),
ohlcv AS (
SELECT
@@ -384,7 +380,7 @@ interface TradeRepository : ReactiveCrudRepository {
lt.close_price AS close,
SUM(t.matched_quantity) AS volume,
COUNT(t.id) AS trades
- FROM intervals i
+ FROM limited_intervals i
LEFT JOIN trades t
ON t.create_date >= i.start_time
AND t.create_date < i.end_time
@@ -396,12 +392,7 @@ interface TradeRepository : ReactiveCrudRepository {
GROUP BY i.start_time, i.end_time, ft.open_price, lt.close_price
)
SELECT *
- FROM (
- SELECT *
- FROM ohlcv
- ORDER BY open_time DESC
- limit :limit
- ) sub
+ FROM ohlcv
ORDER BY open_time ASC
"""
)
diff --git a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt
index fe8673002..5bce4f092 100644
--- a/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt
+++ b/market/market-ports/market-persister-postgres/src/main/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerImpl.kt
@@ -21,6 +21,7 @@ import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
+import java.time.temporal.ChronoUnit
import java.util.*
@@ -35,19 +36,23 @@ class MarketQueryHandlerImpl(
override suspend fun getTradeTickerData(interval: Interval): List {
return redisCacheHelper.getOrElse("tradeTickerData:${interval.label}", 2.minutes()) {
+ val closeTime = Date().time
+ val openTime = interval.getTime()
tradeRepository.tradeTicker(interval.getLocalDateTime())
.collectList()
.awaitFirstOrElse { emptyList() }
- .map { it.asPriceChangeResponse(Date().time, interval.getTime()) }
+ .map { it.asPriceChangeResponse(openTime, closeTime) }
}
}
override suspend fun getTradeTickerDateBySymbol(symbol: String, interval: Interval): PriceChange? {
val cacheId = "tradeTickerData:$symbol:${interval.label}"
return redisCacheHelper.getOrElse(cacheId, 2.minutes()) {
+ val closeTime = Date().time
+ val openTime = interval.getTime()
tradeRepository.tradeTickerBySymbol(symbol, interval.getLocalDateTime())
.awaitSingleOrNull()
- ?.asPriceChangeResponse(Date().time, interval.getTime())
+ ?.asPriceChangeResponse(openTime, closeTime)
}
}
@@ -283,21 +288,22 @@ class MarketQueryHandlerImpl(
endTime: Long?,
limit: Int,
): List {
- val st = if (startTime == null)
- tradeRepository.findFirstByCreateDate().awaitSingleOrNull()?.createDate ?: LocalDateTime.now()
+ val intervalStep = parseIntervalStep(interval)
+ val latestTradeDate = if (startTime == null || endTime == null)
+ tradeRepository.findLastByCreateDate().awaitSingleOrNull()?.createDate
else
- with(Instant.ofEpochMilli(startTime)) {
- LocalDateTime.ofInstant(this, ZoneId.systemDefault())
- }
-
- val et = if (endTime == null)
- tradeRepository.findLastByCreateDate().awaitSingleOrNull()?.createDate ?: LocalDateTime.now()
- else
- with(Instant.ofEpochMilli(endTime)) {
- LocalDateTime.ofInstant(this, ZoneId.systemDefault())
- }
+ null
+ val fallbackDate = latestTradeDate ?: LocalDateTime.now()
+ val startDate = startTime?.asLocalDateTime() ?: when {
+ endTime != null -> shiftByIntervals(endTime.asLocalDateTime(), intervalStep, -(limit - 1).toLong())
+ else -> shiftByIntervals(fallbackDate, intervalStep, -(limit - 1).toLong())
+ }
+ val endDate = endTime?.asLocalDateTime() ?: when {
+ startTime != null -> shiftByIntervals(startDate, intervalStep, (limit - 1).toLong())
+ else -> fallbackDate
+ }
- return tradeRepository.candleData(symbol, interval, st, et, limit)
+ return tradeRepository.candleData(symbol, interval, startDate, endDate, limit)
.collectList()
.awaitFirstOrElse { emptyList() }
.map {
@@ -457,6 +463,32 @@ class MarketQueryHandlerImpl(
count ?: 0
)
+ private fun Long.asLocalDateTime(): LocalDateTime = with(Instant.ofEpochMilli(this)) {
+ LocalDateTime.ofInstant(this, ZoneId.systemDefault())
+ }
+
+ private fun parseIntervalStep(interval: String): Pair {
+ val parts = interval.trim().split(Regex("\\s+"), limit = 2)
+ val amount = parts.firstOrNull()?.toLongOrNull()
+ ?: throw IllegalArgumentException("Invalid interval amount: $interval")
+ val unit = when (parts.getOrNull(1)?.uppercase(Locale.US)?.removeSuffix("S")) {
+ "MINUTE" -> ChronoUnit.MINUTES
+ "HOUR" -> ChronoUnit.HOURS
+ "DAY" -> ChronoUnit.DAYS
+ else -> throw IllegalArgumentException("Unsupported interval unit: $interval")
+ }
+ return amount to unit
+ }
+
+ private fun shiftByIntervals(
+ dateTime: LocalDateTime,
+ intervalStep: Pair,
+ intervals: Long,
+ ): LocalDateTime {
+ val (amount, unit) = intervalStep
+ return dateTime.plus(intervals * amount, unit)
+ }
+
private fun Long.approximate(): Long {
if (this < 10)
return this
diff --git a/market/market-ports/market-persister-postgres/src/main/resources/schema.sql b/market/market-ports/market-persister-postgres/src/main/resources/schema.sql
index 294115909..2395840f1 100644
--- a/market/market-ports/market-persister-postgres/src/main/resources/schema.sql
+++ b/market/market-ports/market-persister-postgres/src/main/resources/schema.sql
@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS trades
);
CREATE INDEX IF NOT EXISTS idx_trades_symbol on trades (symbol);
CREATE INDEX IF NOT EXISTS idx_trades_create_date on trades (create_date);
+CREATE INDEX IF NOT EXISTS idx_trades_symbol_create_date on trades (symbol, create_date);
ALTER TABLE trades
ALTER COLUMN id TYPE BIGINT,
diff --git a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt
index bb25377ae..b0d479467 100644
--- a/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt
+++ b/market/market-ports/market-persister-postgres/src/test/kotlin/co/nilin/opex/market/ports/postgres/impl/MarketQueryHandlerTest.kt
@@ -1,5 +1,6 @@
package co.nilin.opex.market.ports.postgres.impl
+import co.nilin.opex.common.utils.Interval
import co.nilin.opex.market.core.inout.MarketTrade
import co.nilin.opex.market.core.inout.Order
import co.nilin.opex.market.core.inout.OrderDirection
@@ -8,7 +9,10 @@ import co.nilin.opex.market.ports.postgres.dao.OrderRepository
import co.nilin.opex.market.ports.postgres.dao.OrderStatusRepository
import co.nilin.opex.market.ports.postgres.dao.TradeRepository
import co.nilin.opex.market.ports.postgres.impl.sample.VALID
+import co.nilin.opex.market.ports.postgres.model.CandleInfoData
import co.nilin.opex.market.ports.postgres.model.LastPrice
+import co.nilin.opex.market.ports.postgres.model.TradeModel
+import co.nilin.opex.market.ports.postgres.model.TradeTickerData
import co.nilin.opex.market.ports.postgres.util.RedisCacheHelper
import io.mockk.coEvery
import io.mockk.every
@@ -18,6 +22,8 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
+import java.math.BigDecimal
+import java.time.LocalDateTime
class MarketQueryHandlerTest {
private val orderRepository = mockk()
@@ -132,5 +138,93 @@ class MarketQueryHandlerTest {
assertThat(marketTradeResponses?.count()).isEqualTo(1)
assertThat(marketTradeResponses?.first()).isEqualTo(VALID.MARKET_TRADE_RESPONSE)
}
-}
+ @Test
+ fun givenTickerData_whenTradeTickerRequested_thenTickerTimeWindowIsOrderedCorrectly(): Unit = runBlocking {
+ val tradeTickerData = TradeTickerData(
+ VALID.ETH_USDT,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.ONE,
+ BigDecimal.TEN,
+ BigDecimal.ONE,
+ BigDecimal.TEN,
+ 1L,
+ 2L,
+ 3L
+ )
+ coEvery {
+ redisCacheHelper.getOrElse>(
+ eq("tradeTickerData:${Interval.TwentyFourHours.label}"),
+ any(),
+ any()
+ )
+ } coAnswers {
+ thirdArg List>().invoke()
+ }
+ every { tradeRepository.tradeTicker(any()) } returns Flux.just(tradeTickerData)
+
+ val priceChanges = marketQueryHandler.getTradeTickerData(Interval.TwentyFourHours)
+
+ assertThat(priceChanges).hasSize(1)
+ assertThat(priceChanges.first().openTime).isLessThanOrEqualTo(priceChanges.first().closeTime)
+ }
+
+ @Test
+ fun givenMissingCandleBounds_whenGetCandleInfo_thenOnlyLatestIntervalsAreRequested(): Unit = runBlocking {
+ val latestTradeDate = LocalDateTime.of(2024, 1, 1, 10, 15)
+ val expectedStartDate = latestTradeDate.minusHours(2)
+ val latestTrade = TradeModel(
+ 1L,
+ 1L,
+ VALID.ETH_USDT,
+ "ETH",
+ "USDT",
+ BigDecimal.TEN,
+ BigDecimal.ONE,
+ BigDecimal.TEN,
+ BigDecimal.TEN,
+ BigDecimal.ZERO,
+ BigDecimal.ZERO,
+ "ETH",
+ "USDT",
+ latestTradeDate,
+ "maker",
+ "taker",
+ "maker-user",
+ "taker-user",
+ latestTradeDate
+ )
+ val candleInfo = CandleInfoData(
+ expectedStartDate,
+ expectedStartDate.plusHours(1),
+ BigDecimal.ONE,
+ BigDecimal.TWO,
+ BigDecimal.TWO,
+ BigDecimal.ONE,
+ BigDecimal.TEN,
+ 1
+ )
+ coEvery { tradeRepository.findLastByCreateDate() } returns Mono.just(latestTrade)
+ coEvery {
+ tradeRepository.candleData(
+ VALID.ETH_USDT,
+ "1 HOURS",
+ expectedStartDate,
+ latestTradeDate,
+ 3
+ )
+ } returns Flux.just(candleInfo)
+
+ val candles = marketQueryHandler.getCandleInfo(VALID.ETH_USDT, "1 HOURS", null, null, 3)
+
+ assertThat(candles).hasSize(1)
+ assertThat(candles.first().openTime).isEqualTo(expectedStartDate)
+ assertThat(candles.first().closeTime).isEqualTo(expectedStartDate.plusHours(1))
+ }
+}