diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt index e4fe77f13..85d3a389a 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/AppConfig.kt @@ -41,15 +41,17 @@ class AppConfig { @Bean fun orderManager( pairConfigLoader: PairConfigLoader, + userLevelLoader: UserLevelLoader, financialActionPersister: FinancialActionPersister, financeActionLoader: FinancialActionLoader, orderPersister: OrderPersister, tempEventPersister: TempEventPersister, tempEventRepublisher: TempEventRepublisher, - richOrderPublisher: RichOrderPublisher + richOrderPublisher: RichOrderPublisher, ): OrderManager { return OrderManagerImpl( pairConfigLoader, + userLevelLoader, financialActionPersister, financeActionLoader, orderPersister, diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt index b78692c49..20d751529 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/config/InitializeService.kt @@ -2,6 +2,7 @@ package co.nilin.opex.accountant.app.config import co.nilin.opex.accountant.ports.postgres.dao.PairConfigRepository import co.nilin.opex.accountant.ports.postgres.dao.PairFeeConfigRepository +import co.nilin.opex.accountant.ports.postgres.dao.UserLevelRepository import co.nilin.opex.accountant.ports.postgres.model.PairFeeConfigModel import co.nilin.opex.utility.preferences.Preferences import kotlinx.coroutines.reactor.awaitSingleOrNull @@ -15,13 +16,19 @@ import javax.annotation.PostConstruct @DependsOn("postgresConfig") class InitializeService( private val pairConfigRepository: PairConfigRepository, - private val pairFeeConfigRepository: PairFeeConfigRepository + private val pairFeeConfigRepository: PairFeeConfigRepository, + private val userLevelRepository: UserLevelRepository, ) { + @Autowired private lateinit var preferences: Preferences @PostConstruct fun init() = runBlocking { + preferences.userLevels.forEach { + userLevelRepository.insert(it).awaitSingleOrNull() + } + preferences.markets.map { val pair = it.pair ?: "${it.leftSide}_${it.rightSide}" val leftSideCurrency = preferences.currencies.first { c -> it.leftSide == c.symbol } diff --git a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/OrderListener.kt b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/OrderListener.kt index 456cd1a79..52f95ef56 100644 --- a/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/OrderListener.kt +++ b/accountant/accountant-app/src/main/kotlin/co/nilin/opex/accountant/app/listener/OrderListener.kt @@ -30,7 +30,8 @@ class OrderListener(private val orderManager: OrderManager) : OrderSubmitRequest event.quantity, event.direction, event.matchConstraint, - event.orderType + event.orderType, + event.userLevel ) ) } diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt index b1ca47f66..9c840bf73 100644 --- a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImpl.kt @@ -15,6 +15,7 @@ import java.time.LocalDateTime open class OrderManagerImpl( private val pairConfigLoader: PairConfigLoader, + private val userLevelLoader: UserLevelLoader, private val financialActionPersister: FinancialActionPersister, private val financeActionLoader: FinancialActionLoader, private val orderPersister: OrderPersister, @@ -32,7 +33,13 @@ open class OrderManagerImpl( } else { submitOrderEvent.pair.rightSideName } - val pairFeeConfig = pairConfigLoader.load(submitOrderEvent.pair.toString(), submitOrderEvent.direction, "") + + val level = userLevelLoader.load(submitOrderEvent.uuid) + val pairFeeConfig = pairConfigLoader.load( + submitOrderEvent.pair.toString(), + submitOrderEvent.direction, + level + ) val makerFee = pairFeeConfig.makerFee * BigDecimal.ONE //user level formula val takerFee = pairFeeConfig.takerFee * BigDecimal.ONE //user level formula @@ -70,7 +77,7 @@ open class OrderManagerImpl( pairFeeConfig.pairConfig.leftSideFraction, pairFeeConfig.pairConfig.rightSideFraction, submitOrderEvent.uuid, - "", + submitOrderEvent.userLevel, submitOrderEvent.direction, submitOrderEvent.matchConstraint, submitOrderEvent.orderType, diff --git a/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt new file mode 100644 index 000000000..b11b250bb --- /dev/null +++ b/accountant/accountant-core/src/main/kotlin/co/nilin/opex/accountant/core/spi/UserLevelLoader.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.accountant.core.spi + +interface UserLevelLoader { + + suspend fun load(uuid: String): String + +} \ No newline at end of file diff --git a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt index 66193ff06..201fdc255 100644 --- a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt +++ b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/OrderManagerImplTest.kt @@ -33,9 +33,11 @@ internal class OrderManagerImplTest { private val tempEventPersister = mockk() private val pairConfigLoader = mockk() private val richOrderPublisher = mockk() + private val userLevelLoader = mockk() private val orderManager = OrderManagerImpl( pairConfigLoader, + userLevelLoader, financialActionPersister, financialActionLoader, orderPersister, @@ -50,6 +52,7 @@ internal class OrderManagerImplTest { coEvery { tempEventPersister.saveTempEvent(any(), any()) } returns any() coEvery { financialActionLoader.findLast(any(), any()) } returns null coEvery { financialActionPersister.persist(any()) } returnsArgument (0) + coEvery { userLevelLoader.load(any()) } returns "*" } @Test @@ -68,7 +71,7 @@ internal class OrderManagerImplTest { ) coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, "") + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) } returns PairFeeConfig( pairConfig, submitOrderEvent.direction.toString(), @@ -124,7 +127,7 @@ internal class OrderManagerImplTest { ) coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, "") + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) } returns PairFeeConfig( pairConfig, submitOrderEvent.direction.toString(), diff --git a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt index 34de594b0..4e577b3cb 100644 --- a/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt +++ b/accountant/accountant-core/src/test/kotlin/co/nilin/opex/accountant/core/service/TradeManagerImplTest.kt @@ -26,9 +26,11 @@ internal class TradeManagerImplTest { private val tempEventPersister = mockk() private val richOrderPublisher = mockk() private val richTradePublisher = mockk() + private val userLevelLoader = mockk() private val orderManager = OrderManagerImpl( pairConfigLoader, + userLevelLoader, financialActionPersister, financeActionLoader, orderPersister, @@ -48,10 +50,11 @@ internal class TradeManagerImplTest { init { coEvery { tempEventPersister.loadTempEvents(any()) } returns emptyList() - coEvery { orderPersister.save(any()) } returnsArgument(0) - coEvery { financeActionLoader.findLast(any(),any()) } returns null + coEvery { orderPersister.save(any()) } returnsArgument (0) + coEvery { financeActionLoader.findLast(any(), any()) } returns null coEvery { richOrderPublisher.publish(any()) } returns Unit coEvery { richTradePublisher.publish(any()) } returns Unit + coEvery { userLevelLoader.load(any()) } returns "*" } @Test @@ -184,7 +187,7 @@ internal class TradeManagerImplTest { takerFee: BigDecimal ) { coEvery { - pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, "") + pairConfigLoader.load(pair.toString(), submitOrderEvent.direction, any()) } returns PairFeeConfig( pairConfig, submitOrderEvent.direction.toString(), @@ -210,7 +213,7 @@ internal class TradeManagerImplTest { orderPairFeeConfig.pairConfig.leftSideFraction, orderPairFeeConfig.pairConfig.rightSideFraction, submitOrderEvent.uuid, - "", + submitOrderEvent.userLevel, submitOrderEvent.direction, submitOrderEvent.matchConstraint, submitOrderEvent.orderType, diff --git a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderSubmitRequest.kt b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderSubmitRequest.kt index 849158e22..6790172ae 100644 --- a/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderSubmitRequest.kt +++ b/accountant/accountant-ports/accountant-eventlistener-kafka/src/main/kotlin/co/nilin/opex/accountant/ports/kafka/listener/inout/OrderSubmitRequest.kt @@ -15,4 +15,5 @@ data class OrderSubmitRequest( val direction: OrderDirection, val matchConstraint: MatchConstraint, val orderType: OrderType, + val userLevel: String ) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt new file mode 100644 index 000000000..9156013b6 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelMapperRepository.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.ports.postgres.model.UserLevelMapperModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono + +@Repository +interface UserLevelMapperRepository : ReactiveCrudRepository { + + fun findByUuid(uuid: String): Mono + +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt new file mode 100644 index 000000000..a64ce628d --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/dao/UserLevelRepository.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.accountant.ports.postgres.dao + +import co.nilin.opex.accountant.ports.postgres.model.UserLevelModel +import org.springframework.data.r2dbc.repository.Query +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Mono + +@Repository +interface UserLevelRepository : ReactiveCrudRepository { + + @Query("insert into user_level (level) values (:level) on conflict do nothing") + fun insert(level: String): Mono + +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt index 52c2c2ee2..8bef85947 100644 --- a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/PairConfigLoaderImpl.kt @@ -5,6 +5,7 @@ import co.nilin.opex.accountant.core.model.PairFeeConfig import co.nilin.opex.accountant.core.spi.PairConfigLoader import co.nilin.opex.accountant.ports.postgres.dao.PairConfigRepository import co.nilin.opex.accountant.ports.postgres.dao.PairFeeConfigRepository +import co.nilin.opex.accountant.ports.postgres.dao.UserLevelMapperRepository import co.nilin.opex.accountant.ports.postgres.model.PairConfigModel import co.nilin.opex.accountant.ports.postgres.model.PairFeeConfigModel import co.nilin.opex.matching.engine.core.model.OrderDirection @@ -19,8 +20,8 @@ import java.math.BigDecimal @Component class PairConfigLoaderImpl( - val pairConfigRepository: PairConfigRepository, - val pairFeeConfigRepository: PairFeeConfigRepository + private val pairConfigRepository: PairConfigRepository, + private val pairFeeConfigRepository: PairFeeConfigRepository ) : PairConfigLoader { override suspend fun loadPairConfigs(): List { diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt new file mode 100644 index 000000000..f18ca588b --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/impl/UserLevelLoaderImpl.kt @@ -0,0 +1,15 @@ +package co.nilin.opex.accountant.ports.postgres.impl + +import co.nilin.opex.accountant.core.spi.UserLevelLoader +import co.nilin.opex.accountant.ports.postgres.dao.UserLevelMapperRepository +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.springframework.stereotype.Component + +@Component +class UserLevelLoaderImpl(private val userLevelMapperRepository: UserLevelMapperRepository) : UserLevelLoader { + + override suspend fun load(uuid: String): String { + val mapper = userLevelMapperRepository.findByUuid(uuid).awaitSingleOrNull() + return mapper?.userLevel ?: "*" + } +} \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt new file mode 100644 index 000000000..a8677d8dc --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelMapperModel.kt @@ -0,0 +1,11 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("user_level_mapper") +data class UserLevelMapperModel( + @Id val id: Long, + val uuid: String, + val userLevel: String +) \ No newline at end of file diff --git a/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt new file mode 100644 index 000000000..2e9af91b0 --- /dev/null +++ b/accountant/accountant-ports/accountant-persister-postgres/src/main/kotlin/co/nilin/opex/accountant/ports/postgres/model/UserLevelModel.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.accountant.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Table + +@Table("user_level") +data class UserLevelModel(@Id val level: String) \ No newline at end of file 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 5b2303ce5..762694d1a 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 @@ -54,10 +54,12 @@ CREATE TABLE IF NOT EXISTS pair_config right_side_wallet_symbol VARCHAR(36) NOT NULL, left_side_fraction DECIMAL NOT NULL, right_side_fraction DECIMAL NOT NULL, - UNIQUE ( - left_side_wallet_symbol, - right_side_wallet_symbol - ) + UNIQUE (left_side_wallet_symbol, right_side_wallet_symbol) +); + +CREATE TABLE IF NOT EXISTS user_level +( + level VARCHAR(36) PRIMARY KEY ); CREATE TABLE IF NOT EXISTS pair_fee_config @@ -65,12 +67,19 @@ CREATE TABLE IF NOT EXISTS pair_fee_config id SERIAL PRIMARY KEY, pair_config_id VARCHAR(72) NOT NULL REFERENCES pair_config (pair), direction VARCHAR(36) NOT NULL, - user_level VARCHAR(36) NOT NULL, + user_level VARCHAR(36) NOT NULL REFERENCES user_level (level), maker_fee DECIMAL NOT NULL, taker_fee DECIMAL NOT NULL, UNIQUE (direction, user_level, pair_config_id) ); +CREATE TABLE IF NOT EXISTS user_level_mapper +( + id SERIAL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + user_level VARCHAR(36) NOT NULL REFERENCES user_level (level) +); + CREATE TABLE IF NOT EXISTS temp_events ( id SERIAL PRIMARY KEY, diff --git a/api/api-app/pom.xml b/api/api-app/pom.xml index 06d27bcd6..1d3cde562 100644 --- a/api/api-app/pom.xml +++ b/api/api-app/pom.xml @@ -27,6 +27,10 @@ org.springframework.boot spring-boot-starter + + org.springframework.boot + spring-boot-starter-cache + co.nilin.opex.utility.log logging-handler diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt index ba5850a24..c323a9e19 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/ApiApp.kt @@ -2,6 +2,7 @@ package co.nilin.opex.api.app import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.cache.annotation.EnableCaching import org.springframework.context.annotation.ComponentScan import springfox.documentation.swagger2.annotations.EnableSwagger2 diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt new file mode 100644 index 000000000..5127560d8 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.app.config + +import org.springframework.cache.CacheManager +import org.springframework.cache.annotation.EnableCaching +import org.springframework.cache.concurrent.ConcurrentMapCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableCaching +class CacheConfig { + + @Bean + fun apiKeyCacheManager(): CacheManager { + return ConcurrentMapCacheManager("apiKey") + } + +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt new file mode 100644 index 000000000..ab8bcd624 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt @@ -0,0 +1,64 @@ +package co.nilin.opex.api.app.controller + +import co.nilin.opex.api.app.data.APIKeyResponse +import co.nilin.opex.api.app.data.CreateAPIKeyRequest +import co.nilin.opex.api.app.service.APIKeyServiceImpl +import co.nilin.opex.api.ports.binance.util.jwtAuthentication +import co.nilin.opex.api.ports.binance.util.tokenValue +import org.springframework.security.core.annotation.CurrentSecurityContext +import org.springframework.security.core.context.SecurityContext +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.security.Principal + +@RestController +@RequestMapping("/v1/api-key") +class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { + + @GetMapping + suspend fun getKeys(principal: Principal): List { + return apiKeyService.getKeysByUserId(principal.name) + .map { APIKeyResponse(it.label, it.expirationTime, it.allowedIPs, it.key, it.isEnabled) } + } + + @PostMapping + suspend fun create( + @RequestBody request: CreateAPIKeyRequest, + @CurrentSecurityContext securityContext: SecurityContext + ): Any { + val jwt = securityContext.jwtAuthentication() + val response = apiKeyService.createAPIKey( + jwt.name, + request.label, + request.expiration?.getLocalDateTime(), + request.allowedIPs, + jwt.tokenValue() + ) + return object { + val apiKey = response.second.key + val secret = response.first + } + } + + @PutMapping("/{key}/enable") + suspend fun enableKey(principal: Principal, @PathVariable key: String) { + apiKeyService.changeKeyState(principal.name, key, true) + } + + @PutMapping("/{key}/disable") + suspend fun disableKey(principal: Principal, @PathVariable key: String) { + apiKeyService.changeKeyState(principal.name, key, false) + } + + @DeleteMapping("/{key}") + suspend fun deleteKey(principal: Principal, @PathVariable key: String) { + apiKeyService.deleteKey(principal.name, key) + } + +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyExpiration.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyExpiration.kt new file mode 100644 index 000000000..94e9f8650 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyExpiration.kt @@ -0,0 +1,22 @@ +package co.nilin.opex.api.app.data + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import java.util.concurrent.TimeUnit + +enum class APIKeyExpiration(private val unit: TimeUnit, private val duration: Long) { + + ONE_MONTH(TimeUnit.DAYS, 30), + THREE_MONTHS(TimeUnit.DAYS, 90), + SIX_MONTHS(TimeUnit.DAYS, 180), + ONE_YEAR(TimeUnit.DAYS, 365); + + private fun getDate() = Date(Date().time + unit.toMillis(duration)) + + fun getLocalDateTime(): LocalDateTime = with(Instant.ofEpochMilli(getDate().time)) { + LocalDateTime.ofInstant(this, ZoneId.systemDefault()) + } + +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt new file mode 100644 index 000000000..dfe733f83 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt @@ -0,0 +1,12 @@ +package co.nilin.opex.api.app.data + +import java.time.LocalDateTime +import java.util.* + +data class APIKeyResponse( + val label: String, + val expirationTime: LocalDateTime?, + val allowedIPs: String?, + val key: String, + val enabled: Boolean +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt new file mode 100644 index 000000000..e0846aa41 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.app.data + +data class AccessTokenResponse( + val access_token: String, + val refresh_token: String, + val expires_in: Long +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt new file mode 100644 index 000000000..e84994737 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt @@ -0,0 +1,7 @@ +package co.nilin.opex.api.app.data + +data class CreateAPIKeyRequest( + val label: String, + val expiration: APIKeyExpiration?, + val allowedIPs: String? +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt new file mode 100644 index 000000000..f015a0ffd --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt @@ -0,0 +1,34 @@ +package co.nilin.opex.api.app.interceptor + +import co.nilin.opex.api.app.service.APIKeyServiceImpl +import co.nilin.opex.api.core.spi.APIKeyFilter +import kotlinx.coroutines.runBlocking +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +@Component +class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFilter, WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val request = exchange.request + val key = request.headers["X-API-KEY"] + if (!key.isNullOrEmpty()) { + val secret = request.headers["X-API-SECRET"] + if (secret.isNullOrEmpty()) + return chain.filter(exchange) + + val apiKey = runBlocking { apiKeyService.getAPIKey(key[0], secret[0]) } + if (apiKey != null && apiKey.isEnabled && apiKey.accessToken != null && !apiKey.isExpired) { + val req = exchange.request.mutate() + .header("Authorization", "Bearer ${apiKey.accessToken}") + .build() + return chain.filter(exchange.mutate().request(req).build()) + } + } + return chain.filter(exchange) + } + +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt new file mode 100644 index 000000000..5de6ae557 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt @@ -0,0 +1,58 @@ +package co.nilin.opex.api.app.proxy + +import co.nilin.opex.api.app.data.AccessTokenResponse +import kotlinx.coroutines.reactor.awaitSingle +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +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.bodyToMono + +@Component +class AuthProxy( + private val client: WebClient, + @Value("\${app.auth.token-url}") + private val tokenUrl: String +) { + + private val logger = LoggerFactory.getLogger(AuthProxy::class.java) + + suspend fun exchangeToken(clientSecret: String, token: String): AccessTokenResponse { + val body = BodyInserters.fromFormData("client_id", "opex-api-key") + .with("client_secret", clientSecret) + .with("subject_token", token) + .with("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .with("scope", "offline_access") + + logger.info("Request token exchange for user") + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } + + suspend fun refreshToken(clientSecret: String, refreshToken: String): AccessTokenResponse { + val body = BodyInserters.fromFormData("client_id", "opex-api-key") + .with("client_secret", clientSecret) + .with("refresh_token", refreshToken) + .with("grant_type", "refresh_token") + + logger.info("Refreshing token") + return client.post() + .uri(tokenUrl) + .accept(MediaType.APPLICATION_JSON) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .retrieve() + .onStatus({ t -> t.isError }, { it.createException() }) + .bodyToMono() + .awaitSingle() + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt new file mode 100644 index 000000000..93e1f42d2 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt @@ -0,0 +1,210 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.proxy.AuthProxy +import co.nilin.opex.api.core.inout.APIKey +import co.nilin.opex.api.core.spi.APIKeyService +import co.nilin.opex.api.ports.postgres.dao.APIKeyRepository +import co.nilin.opex.api.ports.postgres.model.APIKeyModel +import co.nilin.opex.utility.error.data.OpexError +import co.nilin.opex.utility.error.data.OpexException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.cache.Cache +import org.springframework.cache.CacheManager +import org.springframework.stereotype.Service +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* +import java.util.concurrent.TimeUnit +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.math.log + +@Service +class APIKeyServiceImpl( + private val apiKeyRepository: APIKeyRepository, + private val authProxy: AuthProxy, + private val cacheManager: CacheManager, + @Value("\${app.auth.api-key-client.secret}") + private val clientSecret: String +) : APIKeyService { + + private val logger = LoggerFactory.getLogger(APIKeyServiceImpl::class.java) + + override suspend fun createAPIKey( + userId: String, + label: String, + expirationTime: LocalDateTime?, + allowedIPs: String?, + currentToken: String + ): Pair { + if (apiKeyRepository.countByUserId(userId).awaitFirstOrElse { 0 } >= 10) + throw OpexException(OpexError.APIKeyLimitReached) + + val secret = generateSecret() + val tokenResponse = authProxy.exchangeToken(clientSecret, currentToken) + val apiKey = apiKeyRepository.save( + APIKeyModel( + null, + userId, + label, + encryptAES(tokenResponse.access_token, secret), + encryptAES(tokenResponse.refresh_token, secret), + expirationTime, + allowedIPs, + tokenExpiration(tokenResponse.expires_in) + ) + ).awaitSingle() + + return Pair( + secret, + with(apiKey) { + APIKey(userId, label, accessToken, expirationTime, allowedIPs, key, isEnabled, isExpired) + } + ) + } + + override suspend fun getAPIKey(key: String, secret: String): APIKey? = coroutineScope { + val apiKey = getFromCache(key)?.also { logger.info("Got apiKey from cache") } + ?: apiKeyRepository.findByKey(key).awaitSingleOrNull()?.apply { putCache(this) } + + with(apiKey) { + if (this != null) { + launch { checkupAPIKey(this@with, secret) } + APIKey( + userId, + label, + decryptAES(accessToken, secret), + expirationTime, + allowedIPs, + key, + isEnabled, + isExpired + ) + } else + null + } + } + + override suspend fun getKeysByUserId(userId: String): List { + return apiKeyRepository.findAllByUserId(userId).collectList().awaitFirstOrElse { emptyList() } + .map { + APIKey( + it.userId, + it.label, + it.accessToken, + it.expirationTime, + it.allowedIPs, + it.key, + it.isEnabled, + it.isExpired + ) + } + } + + override suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) { + val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() ?: throw OpexException(OpexError.NotFound) + if (apiKey.userId != userId) + throw OpexException(OpexError.Forbidden) + apiKey.isEnabled = isEnabled + apiKeyRepository.save(apiKey).awaitSingle() + } + + override suspend fun deleteKey(userId: String, key: String) { + val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() ?: throw OpexException(OpexError.NotFound) + if (apiKey.userId != userId) + throw OpexException(OpexError.Forbidden) + apiKeyRepository.delete(apiKey).awaitFirstOrNull() + } + + private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { + if (apiKey.isExpired || !apiKey.isEnabled) + return + + logger.info("Checking up api key...") + try { + val now = LocalDateTime.now() + if (apiKey.expirationTime?.isBefore(now) == true) { + logger.info("Expiring api key ${apiKey.key}") + apiKey.isExpired = true + apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } + logger.info("API key ${apiKey.key} is expired") + return + } + + if (apiKey.tokenExpirationTime.isBefore(now)) { + logger.info("Refreshing api key ${apiKey.key} token") + val response = authProxy.refreshToken(clientSecret, decryptAES(apiKey.refreshToken, secret)) + apiKey.apply { + accessToken = encryptAES(response.access_token, secret) + tokenExpirationTime = tokenExpiration(response.expires_in) + } + apiKeyRepository.save(apiKey).awaitSingle().apply { updateCache(this) } + logger.info("API key ${apiKey.key} token refreshed") + } + } catch (e: Exception) { + logger.error("Error checking api key ${apiKey.key}", e) + } + } + + private fun encryptAES(input: String, key: String): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) + } + val cipherText = cipher.doFinal(input.toByteArray()) + return Base64.getEncoder().encodeToString(cipherText) + } + + private fun decryptAES(cipherText: String, key: String): String { + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(key.toByteArray(), "AES"), IvParameterSpec(ByteArray(16))) + } + val plainText = cipher.doFinal(Base64.getDecoder().decode(cipherText)) + return String(plainText) + } + + private fun generateSecret(length: Int = 32): String { + val chars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + return (1..length).map { chars.random() }.joinToString("") + } + + private fun tokenExpiration(expiresInSeconds: Long): LocalDateTime { + val tokenOffsetTime = Date().time + TimeUnit.SECONDS.toMillis(expiresInSeconds) - TimeUnit.MINUTES.toMillis(10) + return LocalDateTime.ofInstant(Instant.ofEpochMilli(tokenOffsetTime), ZoneId.systemDefault()) + } + + private fun getFromCache(key: String): APIKeyModel? { + return getCache()?.get(key)?.get() as APIKeyModel? + } + + private fun putCache(apiKey: APIKeyModel) { + getCache()?.apply { + putIfAbsent(apiKey.key, apiKey) + logger.info("Added to cache") + } + } + + private fun updateCache(apiKey: APIKeyModel) { + getCache()?.apply { + evict(apiKey.key) + put(apiKey.key, apiKey) + logger.info("Cache updated") + } + } + + private fun getCache(): Cache? { + val cache = cacheManager.getCache("apiKey") + if (cache == null) + logger.warn("Could not find cache of apiKey") + return cache + } + +} \ No newline at end of file diff --git a/api/api-app/src/main/resources/application.yml b/api/api-app/src/main/resources/application.yml index 929405bd4..e78bde202 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -57,6 +57,9 @@ app: url: lb://opex-bc-gateway auth: cert-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/certs + token-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/token + api-key-client: + secret: ${API_KEY_CLIENT_SECRET} binance: api-url: https://api1.binance.com swagger.authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/APIKey.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/APIKey.kt new file mode 100644 index 000000000..4868c4667 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/APIKey.kt @@ -0,0 +1,14 @@ +package co.nilin.opex.api.core.inout + +import java.time.LocalDateTime + +data class APIKey( + val userId: String, + val label: String, + val accessToken: String?, + val expirationTime: LocalDateTime?, + val allowedIPs: String?, + val key: String, + val isEnabled: Boolean, + val isExpired: Boolean +) \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyFilter.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyFilter.kt new file mode 100644 index 000000000..523abccc8 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyFilter.kt @@ -0,0 +1,3 @@ +package co.nilin.opex.api.core.spi + +interface APIKeyFilter \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt new file mode 100644 index 000000000..f5329c6fa --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt @@ -0,0 +1,24 @@ +package co.nilin.opex.api.core.spi + +import co.nilin.opex.api.core.inout.APIKey +import java.time.LocalDateTime + +interface APIKeyService { + + suspend fun createAPIKey( + userId: String, + label: String, + expirationTime: LocalDateTime?, + allowedIPs: String?, + currentToken: String + ): Pair + + suspend fun getAPIKey(key: String, secret: String): APIKey? + + suspend fun getKeysByUserId(userId: String): List + + suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) + + suspend fun deleteKey(userId: String, key: String) + +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt index 973ad9b58..906aec319 100644 --- a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/MatchingGatewayProxy.kt @@ -16,6 +16,7 @@ interface MatchingGatewayProxy { direction: OrderDirection, matchConstraint: MatchConstraint?, orderType: MatchingOrderType, + userLevel: String, token: String? ): OrderSubmitResult? 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 e029af4e8..114008c6e 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/config/SecurityConfig.kt @@ -1,13 +1,17 @@ package co.nilin.opex.api.ports.binance.config +import co.nilin.opex.api.core.spi.APIKeyFilter +import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder import org.springframework.security.config.web.server.ServerHttpSecurity import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.WebFilter @EnableWebFluxSecurity class SecurityConfig(private val webClient: WebClient) { @@ -15,6 +19,9 @@ class SecurityConfig(private val webClient: WebClient) { @Value("\${app.auth.cert-url}") private lateinit var jwkUrl: String + @Autowired + private lateinit var apiKeyFilter: APIKeyFilter + @Bean fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain? { http.csrf().disable() @@ -34,6 +41,7 @@ class SecurityConfig(private val webClient: WebClient) { .pathMatchers("/**").hasAuthority("SCOPE_trust") .anyExchange().authenticated() .and() + .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .oauth2ResourceServer() .jwt() return http.build() diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt index f26f81bc6..4edfd83d2 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/controller/AccountController.kt @@ -98,7 +98,9 @@ class AccountController( quantity ?: BigDecimal.ZERO, side.asOrderDirection(), timeInForce?.asMatchConstraint(), - type.asMatchingOrderType(), securityContext.jwtAuthentication().tokenValue() + type.asMatchingOrderType(), + "*", + securityContext.jwtAuthentication().tokenValue() ) return NewOrderResponse( symbol, diff --git a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt index 3cd8a43ca..dc63da1e5 100644 --- a/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt +++ b/api/api-ports/api-binance-rest/src/main/kotlin/co/nilin/opex/api/ports/binance/util/SecurityExtension.kt @@ -1,5 +1,6 @@ package co.nilin.opex.api.ports.binance.util +import com.nimbusds.jose.shaded.json.JSONArray import org.springframework.security.core.context.SecurityContext import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken @@ -9,4 +10,10 @@ fun SecurityContext.jwtAuthentication(): JwtAuthenticationToken { fun JwtAuthenticationToken.tokenValue(): String { return this.token.tokenValue +} + +fun JwtAuthenticationToken.roles(): List { + val list = arrayListOf() + (token.claims["roles"] as JSONArray?)?.forEach { list.add(it as String) } + return list } \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt new file mode 100644 index 000000000..21fa5d9fc --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt @@ -0,0 +1,18 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.APIKeyModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +@Repository +interface APIKeyRepository : ReactiveCrudRepository { + + fun findAllByUserId(userId: String): Flux + + fun findByKey(key: String): Mono + + fun countByUserId(userId: String): Mono + +} \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt new file mode 100644 index 000000000..77f4bf4c5 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt @@ -0,0 +1,23 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime +import java.util.UUID + +@Table("api_key") +data class APIKeyModel( + @Id val id: Long? = null, + val userId: String, + val label: String, + var accessToken: String, + var refreshToken: String, + val expirationTime: LocalDateTime?, + @Column("allowed_ips") + val allowedIPs: String?, + var tokenExpirationTime: LocalDateTime, + val key: String = UUID.randomUUID().toString(), + var isEnabled: Boolean = true, + var isExpired: Boolean = false +) \ No newline at end of file diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/SymbolMapModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/SymbolMapModel.kt index e72ed124a..68077129c 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/SymbolMapModel.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/SymbolMapModel.kt @@ -1,6 +1,5 @@ package co.nilin.opex.api.ports.postgres.model - import org.springframework.data.annotation.Id import org.springframework.data.relational.core.mapping.Table diff --git a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql index cf27d5df0..1d4db9878 100644 --- a/api/api-ports/api-persister-postgres/src/main/resources/schema.sql +++ b/api/api-ports/api-persister-postgres/src/main/resources/schema.sql @@ -6,3 +6,18 @@ CREATE TABLE IF NOT EXISTS symbol_maps alias VARCHAR(72) NOT NULL, UNIQUE (symbol, alias_key, alias) ); + +CREATE TABLE IF NOT EXISTS api_key +( + id SERIAL PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + label VARCHAR(200) NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expiration_time TIMESTAMP, + allowed_ips TEXT, + token_expiration_time TIMESTAMP NOT NULL, + key VARCHAR(36) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT true, + is_expired BOOLEAN NOT NULL DEFAULT false +); diff --git a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/CreateOrderRequest.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/CreateOrderRequest.kt index 4aabaccbd..4e523d96d 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/CreateOrderRequest.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/data/CreateOrderRequest.kt @@ -12,5 +12,6 @@ data class CreateOrderRequest( val quantity: BigDecimal, val direction: OrderDirection, val matchConstraint: MatchConstraint?, - val orderType: MatchingOrderType + val orderType: MatchingOrderType, + val userLevel: String ) \ 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/MatchingGatewayProxyImpl.kt b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt index 85b186928..1f5a9b2ee 100644 --- a/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt +++ b/api/api-ports/api-proxy-rest/src/main/kotlin/co/nilin/opex/api/ports/proxy/impl/MatchingGatewayProxyImpl.kt @@ -35,15 +35,17 @@ class MatchingGatewayProxyImpl(private val client: WebClient) : MatchingGatewayP direction: OrderDirection, matchConstraint: MatchConstraint?, orderType: MatchingOrderType, + userLevel: String, token: String? ): OrderSubmitResult? { logger.info("calling matching-gateway order create") + val body = CreateOrderRequest(uuid, pair, price, quantity, direction, matchConstraint, orderType, userLevel) return client.post() .uri(URI.create("$baseUrl/order")) .accept(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer $token") - .body(Mono.just(CreateOrderRequest(uuid, pair, price, quantity, direction, matchConstraint, orderType))) + .body(Mono.just(body)) .retrieve() .onStatus({ t -> t.isError }, { it.createException() }) .bodyToMono() diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application.yml b/bc-gateway/bc-gateway-app/src/main/resources/application.yml index 6b71ce2eb..52bb87857 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application.yml @@ -40,11 +40,14 @@ spring: prefer-ip-address: true config: import: vault://secret/${spring.application.name} + codec: + max-in-memory-size: 20MB logging: level: org.apache.kafka: ERROR co.nilin: DEBUG -swagger.authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token +swagger: + authUrl: ${SWAGGER_AUTH_URL:https://api.opex.dev/auth}/realms/opex/protocol/openid-connect/token app: auth: cert-url: lb://opex-auth/auth/realms/opex/protocol/openid-connect/certs diff --git a/docker-compose.yml b/docker-compose.yml index 5802590f3..4505e47a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -357,6 +357,7 @@ services: - VAULT_HOST=vault - SWAGGER_AUTH_URL=$KEYCLOAK_FRONTEND_URL - PREFERENCES=$PREFERENCES + - API_KEY_CLIENT_SECRET=$API_KEY_CLIENT_SECRET configs: - preferences.yml depends_on: diff --git a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/events/SubmitOrderEvent.kt b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/events/SubmitOrderEvent.kt index 71b2e1a17..d7fa42f06 100644 --- a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/events/SubmitOrderEvent.kt +++ b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/eventh/events/SubmitOrderEvent.kt @@ -15,7 +15,8 @@ class SubmitOrderEvent( var remainedQuantity: Long = 0, var direction: OrderDirection = OrderDirection.ASK, var matchConstraint: MatchConstraint = MatchConstraint.GTC, - var orderType: OrderType = OrderType.LIMIT_ORDER + var orderType: OrderType = OrderType.LIMIT_ORDER, + val userLevel: String = "" ) : CoreEvent(pair), OneOrderEvent { override fun ouid(): String { diff --git a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderSubmitRequest.kt b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderSubmitRequest.kt index d1519366f..e8c0e2fb2 100644 --- a/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderSubmitRequest.kt +++ b/matching-engine/matching-engine-core/src/main/kotlin/co/nilin/opex/matching/engine/core/inout/OrderSubmitRequest.kt @@ -5,37 +5,15 @@ import co.nilin.opex.matching.engine.core.model.OrderDirection import co.nilin.opex.matching.engine.core.model.OrderType import co.nilin.opex.matching.engine.core.model.Pair -class OrderSubmitRequest() { - - lateinit var ouid: String - lateinit var uuid: String - var orderId: Long? = null - lateinit var pair: Pair - var price: Long = 0 - var quantity: Long = 0 - var direction: OrderDirection = OrderDirection.BID - var matchConstraint: MatchConstraint = MatchConstraint.GTC - var orderType: OrderType = OrderType.LIMIT_ORDER - - constructor( - ouid: String, - uuid: String, - orderId: Long?, - pair: Pair, - price: Long, - quantity: Long, - direction: OrderDirection, - matchConstraint: MatchConstraint, - orderType: OrderType - ) : this() { - this.ouid = ouid - this.uuid = uuid - this.orderId = orderId - this.pair = pair - this.price = price - this.quantity = quantity - this.direction = direction - this.matchConstraint = matchConstraint - this.orderType = orderType - } -} \ No newline at end of file +class OrderSubmitRequest( + var ouid: String, + var uuid: String, + var pair: Pair, + var orderId: Long? = null, + var price: Long = 0, + var quantity: Long = 0, + var direction: OrderDirection = OrderDirection.BID, + var matchConstraint: MatchConstraint = MatchConstraint.GTC, + var orderType: OrderType = OrderType.LIMIT_ORDER, + var userLevel: String = "" +) \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/inout/CreateOrderRequest.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/inout/CreateOrderRequest.kt index ddcb5f569..136b8a998 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/inout/CreateOrderRequest.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/inout/CreateOrderRequest.kt @@ -12,5 +12,6 @@ data class CreateOrderRequest( val quantity: BigDecimal, val direction: OrderDirection, val matchConstraint: MatchConstraint, - val orderType: OrderType + val orderType: OrderType, + val userLevel: String ) \ No newline at end of file diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt index 24672f38f..05b2f0fa5 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt @@ -66,7 +66,8 @@ class OrderService( .longValueExact(), createOrderRequest.direction, createOrderRequest.matchConstraint, - createOrderRequest.orderType + createOrderRequest.orderType, + createOrderRequest.userLevel ) return orderSubmitter.submit(orderSubmitRequest) } diff --git a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt index 03ca32c9d..db4aaba58 100644 --- a/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt +++ b/matching-gateway/matching-gateway-app/src/test/kotlin/co/nilin/opex/matching/gateway/app/service/sample/Samples.kt @@ -30,7 +30,8 @@ object VALID { BigDecimal.valueOf(0.001), OrderDirection.ASK, MatchConstraint.GTC, - OrderType.LIMIT_ORDER + OrderType.LIMIT_ORDER, + "*" ) val CREATE_ORDER_REQUEST_BID = CREATE_ORDER_REQUEST_ASK.copy(direction = OrderDirection.BID) diff --git a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderSubmitRequest.kt b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderSubmitRequest.kt index a295e5ba2..d6c5eb05e 100644 --- a/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderSubmitRequest.kt +++ b/matching-gateway/matching-gateway-port/matching-gateway-submitter-kafka/src/main/kotlin/co/nilin/opex/matching/gateway/ports/kafka/submitter/inout/OrderSubmitRequest.kt @@ -14,6 +14,7 @@ data class OrderSubmitRequest( val direction: OrderDirection, val matchConstraint: MatchConstraint, val orderType: OrderType, + val userLevel: String, val ouid: String = UUID.randomUUID().toString(), val orderId: Long? = null, ) \ No newline at end of file diff --git a/preferences-demo.yml b/preferences-demo.yml index b57dd1784..ba6c25320 100644 --- a/preferences-demo.yml +++ b/preferences-demo.yml @@ -225,6 +225,9 @@ userLimits: system: walletTitle: system walletLevel: basic +userLevels: + - "*" + - "nofee" auth: whitelist: enabled: false diff --git a/preferences-dev.yml b/preferences-dev.yml index b598baf63..d8d616c99 100644 --- a/preferences-dev.yml +++ b/preferences-dev.yml @@ -258,6 +258,9 @@ userLimits: dailyCount: 100 monthlyTotal: 30000 monthlyCount: 3000 +userLevels: + - "*" + - "nofee" system: walletTitle: system walletLevel: basic diff --git a/user-management/keycloak-gateway/src/main/resources/opex-realm.json b/user-management/keycloak-gateway/src/main/resources/opex-realm.json index fbd2d1cc9..aa525c414 100644 --- a/user-management/keycloak-gateway/src/main/resources/opex-realm.json +++ b/user-management/keycloak-gateway/src/main/resources/opex-realm.json @@ -56,15 +56,6 @@ "containerId": "opex", "attributes": {} }, - { - "id": "6061c17a-30fb-4d17-9414-8e20e61520ce", - "name": "admin_system", - "description": "Admins responsible for system's settings and operations", - "composite": false, - "clientRole": false, - "containerId": "opex", - "attributes": {} - }, { "id": "470642d4-8042-4eef-8146-cd8e5dc0c346", "name": "user_anonymous", @@ -125,6 +116,15 @@ "containerId": "opex", "attributes": {} }, + { + "id": "6061c17a-30fb-4d17-9414-8e20e61520ce", + "name": "admin_system", + "description": "Admins responsible for system's settings and operations", + "composite": false, + "clientRole": false, + "containerId": "opex", + "attributes": {} + }, { "id": "fee989a8-c92e-4889-9507-c37809d8f876", "name": "user_kyc", @@ -363,6 +363,7 @@ "attributes": {} } ], + "opex-api-key": [], "security-admin-console": [], "admin-cli": [], "account-console": [], @@ -671,6 +672,13 @@ "uma_authorization" ] }, + { + "client": "opex-api-key", + "roles": [ + "user_basic", + "user_kyc" + ] + }, { "client": "web-app", "roles": [ @@ -1285,6 +1293,65 @@ "microprofile-jwt" ] }, + { + "id": "d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "clientId": "opex-api-key", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "**********", + "redirectUris": [ + "*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "access.token.lifespan": "43200", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "trust", + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, { "id": "6a4bfbd0-576d-4778-af56-56f876647355", "clientId": "realm-management", @@ -1494,6 +1561,37 @@ "name": "map-roles-composite" } ] + }, + { + "name": "client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "Client", + "ownerManagedAccess": false, + "attributes": {}, + "_id": "753388dc-ff2f-48af-94b5-549c020d9493", + "uris": [], + "scopes": [ + { + "name": "view" + }, + { + "name": "map-roles-client-scope" + }, + { + "name": "configure" + }, + { + "name": "map-roles" + }, + { + "name": "manage" + }, + { + "name": "token-exchange" + }, + { + "name": "map-roles-composite" + } + ] } ], "policies": [ @@ -1507,6 +1605,16 @@ "clients": "[\"opex-admin\",\"account-console\"]" } }, + { + "id": "5edfc919-8dde-440b-86f4-802be17d55a4", + "name": "opex-api-exchange", + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "clients": "[\"web-app\"]" + } + }, { "id": "40160771-c030-4d2f-964d-886b6d574ba0", "name": "manage.permission.client.13d76feb-d762-4409-bb84-7a75bc395a61", @@ -1804,6 +1912,84 @@ "resources": "[\"client.resource.fb5f91c4-42fa-4769-b45d-febef22b4976\"]", "scopes": "[\"token-exchange\"]" } + }, + { + "id": "67c10cce-61d0-47ae-8d00-2f6da8ee67f9", + "name": "manage.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"manage\"]" + } + }, + { + "id": "19001b70-32e9-4582-ba10-b809d7a897c1", + "name": "configure.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"configure\"]" + } + }, + { + "id": "3cde330d-f57d-4e18-8c21-732733b70863", + "name": "view.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"view\"]" + } + }, + { + "id": "852537cc-a25f-454a-ba3d-b0bbc719ea9c", + "name": "map-roles.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles\"]" + } + }, + { + "id": "cc79158e-68ff-4de5-ab80-959a4e62b0d6", + "name": "map-roles-client-scope.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles-client-scope\"]" + } + }, + { + "id": "d6ba0206-d138-42a6-8ade-2b101181b791", + "name": "map-roles-composite.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"map-roles-composite\"]" + } + }, + { + "id": "ba5cbeab-c4f8-4103-bfc0-936b096abeb5", + "name": "token-exchange.permission.client.d2f0f1b6-46b7-4678-842a-8c67524ea2da", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "resources": "[\"client.resource.d2f0f1b6-46b7-4678-842a-8c67524ea2da\"]", + "scopes": "[\"token-exchange\"]", + "applyPolicies": "[\"opex-api-exchange\"]" + } } ], "scopes": [ @@ -2781,14 +2967,14 @@ "subComponents": {}, "config": { "allowed-protocol-mapper-types": [ - "saml-user-property-mapper", - "oidc-usermodel-attribute-mapper", - "saml-user-attribute-mapper", - "oidc-full-name-mapper", - "saml-role-list-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", - "oidc-sha256-pairwise-sub-mapper" + "oidc-full-name-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper" ] } } diff --git a/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt b/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt index c5188b1e6..5cea6f33b 100644 --- a/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt +++ b/utility/error-handler/src/main/kotlin/co/nilin/opex/utility/error/data/OpexError.kt @@ -53,6 +53,7 @@ enum class OpexError(val code: Int, val message: String?, val status: HttpStatus InvalidPriceChangeDuration(7005, "Valid durations: [24h, 7d, 1m]", HttpStatus.BAD_REQUEST), CancelOrderNotAllowed(7006, "Canceling this order is not allowed", HttpStatus.FORBIDDEN), InvalidInterval(7007, "Invalid interval", HttpStatus.BAD_REQUEST), + APIKeyLimitReached(7007, "Reached API key limit. Maximum number of API key is 10", HttpStatus.BAD_REQUEST), // code 8000: bc-gateway ReservedAddressNotAvailable(8001, "No reserved address available", HttpStatus.BAD_REQUEST), diff --git a/utility/preferences/src/main/kotlin/co/nilin/opex/utility/preferences/Preferences.kt b/utility/preferences/src/main/kotlin/co/nilin/opex/utility/preferences/Preferences.kt index 543bd25d1..4da1bf3f7 100644 --- a/utility/preferences/src/main/kotlin/co/nilin/opex/utility/preferences/Preferences.kt +++ b/utility/preferences/src/main/kotlin/co/nilin/opex/utility/preferences/Preferences.kt @@ -6,6 +6,7 @@ data class Preferences( var currencies: List = emptyList(), var markets: List = emptyList(), var userLimits: List = emptyList(), + var userLevels: List = emptyList(), var system: System = System(), val auth: Auth = Auth() )