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),