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..7e23ba483
--- /dev/null
+++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt
@@ -0,0 +1,209 @@
+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.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).awaitSingle()
+ }
+
+ 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-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 2484dd894..d23bedf46 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-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..9285767ce 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 NOT NULL,
+ 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/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/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),