From 8c9ad853566d99e3936f213f7b0c89d9d933aeec Mon Sep 17 00:00:00 2001 From: Peyman Date: Mon, 24 Oct 2022 12:49:00 +0330 Subject: [PATCH 1/5] Starting API key --- .../opex/api/app/data/AccessTokenResponse.kt | 6 ++ .../co/nilin/opex/api/app/proxy/AuthProxy.kt | 57 +++++++++++++++++++ .../opex/api/app/service/APIKeyService.kt | 54 ++++++++++++++++++ .../src/main/resources/application.yml | 3 + .../ports/postgres/dao/APIKeyRepository.kt | 9 +++ .../opex/api/ports/postgres/model/APIKey.kt | 17 ++++++ .../ports/postgres/model/SymbolMapModel.kt | 1 - 7 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt create mode 100644 api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt 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..2a804e5d0 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/AccessTokenResponse.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.app.data + +data class AccessTokenResponse( + val access_token: String, + val refresh_token: String, +) \ 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..2cc874b8c --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/proxy/AuthProxy.kt @@ -0,0 +1,57 @@ +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") + + 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", "urn:ietf:params:oauth:grant-type:token-exchange") + + 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/APIKeyService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt new file mode 100644 index 000000000..2bb7933fb --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt @@ -0,0 +1,54 @@ +package co.nilin.opex.api.app.service + +import co.nilin.opex.api.app.proxy.AuthProxy +import co.nilin.opex.api.ports.postgres.dao.APIKeyRepository +import co.nilin.opex.api.ports.postgres.model.APIKey +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import reactor.util.function.Tuple2 +import java.time.LocalDateTime +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Service +class APIKeyService( + private val apiKeyRepository: APIKeyRepository, + private val authProxy: AuthProxy, + @Value +) { + + fun createAPIKey( + userId: String, + label: String, + expirationTime: LocalDateTime, + allowedIPs: String, + currentToken: String + ): Pair { + val secret = generateSecret() + val accessToken = authProxy.exchangeToken(clien) + } + + 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("") + } + +} \ 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-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..007d13f7d --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.APIKey +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface APIKeyRepository : ReactiveCrudRepository { +} \ 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/APIKey.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt new file mode 100644 index 000000000..a75046d20 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt @@ -0,0 +1,17 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime +import java.util.UUID + +@Table("api_key") +data class APIKey( + val id: Long? = null, + val userId: String, + val label: String, + val accessToken: String, + val refreshToken: String, + val expirationTime: LocalDateTime, + val allowedIPs: String?, + val key: String = UUID.randomUUID().toString() +) \ 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 From e43441fcfce6f08d6e9ddd65d941a1db4ccd72c9 Mon Sep 17 00:00:00 2001 From: Peyman Date: Mon, 24 Oct 2022 17:09:20 +0330 Subject: [PATCH 2/5] Adding filter --- .../api/app/controller/APIKeyController.kt | 64 +++++++++++++++++++ .../nilin/opex/api/app/data/APIKeyResponse.kt | 12 ++++ .../opex/api/app/data/CreateAPIKeyRequest.kt | 9 +++ .../opex/api/app/interceptor/APIKeyFilter.kt | 28 ++++++++ .../opex/api/app/service/APIKeyService.kt | 54 +++++++++++++++- .../ports/postgres/dao/APIKeyRepository.kt | 9 +++ .../opex/api/ports/postgres/model/APIKey.kt | 3 +- docker-compose.yml | 1 + .../opex/utility/error/data/OpexError.kt | 1 + 9 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/controller/APIKeyController.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt 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..d663f397f --- /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.APIKeyService +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("/api-key") +class APIKeyController(private val apiKeyService: APIKeyService) { + + @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.expirationTime, + request.allowedIPs, + jwt.tokenValue() + ) + return object { + val secret = response.first + val apiKey = response.second.key + } + } + + @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/APIKeyResponse.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyResponse.kt new file mode 100644 index 000000000..7c03a4881 --- /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/CreateAPIKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt new file mode 100644 index 000000000..5d29e71ed --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/CreateAPIKeyRequest.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.app.data + +import java.time.LocalDateTime + +data class CreateAPIKeyRequest( + val label: String, + val expirationTime: LocalDateTime, + val allowedIPs: String +) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt new file mode 100644 index 000000000..1f15817a2 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt @@ -0,0 +1,28 @@ +package co.nilin.opex.api.app.interceptor + +import co.nilin.opex.api.app.service.APIKeyService +import kotlinx.coroutines.runBlocking +import org.springframework.web.filter.GenericFilterBean +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse +import javax.servlet.http.HttpServletRequest + +class APIKeyFilter(private val apiKeyService: APIKeyService) : GenericFilterBean() { + + override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) { + val request = servletRequest as HttpServletRequest + val apiKey = request.getHeader("X-API-KEY") + if (!apiKey.isNullOrEmpty()) { + val secret = request.getHeader("X-API-SECRET") + if (secret.isNullOrEmpty()) + filterChain.doFilter(servletRequest, servletResponse) + + val accessToken = runBlocking { apiKeyService.getAccessToken(apiKey, secret) } + + } + } + + +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt index 2bb7933fb..8c7c6077b 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt @@ -3,6 +3,11 @@ package co.nilin.opex.api.app.service import co.nilin.opex.api.app.proxy.AuthProxy import co.nilin.opex.api.ports.postgres.dao.APIKeyRepository import co.nilin.opex.api.ports.postgres.model.APIKey +import co.nilin.opex.utility.error.data.OpexError +import co.nilin.opex.utility.error.data.OpexException +import kotlinx.coroutines.reactive.awaitFirstOrElse +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.reactor.awaitSingleOrNull import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import reactor.util.function.Tuple2 @@ -16,18 +21,61 @@ import javax.crypto.spec.SecretKeySpec class APIKeyService( private val apiKeyRepository: APIKeyRepository, private val authProxy: AuthProxy, - @Value + @Value("\${app.auth.api-key-client.secret}") + private val clientSecret: String ) { - fun createAPIKey( + 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 accessToken = authProxy.exchangeToken(clien) + val tokenResponse = authProxy.exchangeToken(clientSecret, currentToken) + val apiKey = apiKeyRepository.save( + APIKey( + null, + userId, + label, + encryptAES(tokenResponse.access_token, secret), + encryptAES(tokenResponse.refresh_token, secret), + expirationTime, + allowedIPs + ) + ).awaitSingle() + return Pair(secret, apiKey) + } + + suspend fun getAccessToken(key: String, secret: String): String? { + val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() + return if (apiKey == null) + null + else + decryptAES(apiKey.accessToken, secret) + } + + suspend fun getKeysByUserId(userId: String): List { + return apiKeyRepository.findAllByUserId(userId).collectList().awaitFirstOrElse { emptyList() } + } + + 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() + } + + 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 fun encryptAES(input: String, key: String): String { 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 index 007d13f7d..96107615e 100644 --- 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 @@ -3,7 +3,16 @@ package co.nilin.opex.api.ports.postgres.dao import co.nilin.opex.api.ports.postgres.model.APIKey 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/APIKey.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt index a75046d20..da0e38b20 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt @@ -13,5 +13,6 @@ data class APIKey( val refreshToken: String, val expirationTime: LocalDateTime, val allowedIPs: String?, - val key: String = UUID.randomUUID().toString() + val key: String = UUID.randomUUID().toString(), + var isEnabled: Boolean = true ) \ No newline at end of file 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), From f657ffccbf739b58794487c742d1c6c4e461cd66 Mon Sep 17 00:00:00 2001 From: Peyman Date: Tue, 25 Oct 2022 17:40:32 +0330 Subject: [PATCH 3/5] Finish api key filter filter --- .../api/app/controller/APIKeyController.kt | 13 ++-- .../opex/api/app/data/APIKeyExpiration.kt | 22 +++++++ .../nilin/opex/api/app/data/APIKeyResponse.kt | 2 +- .../opex/api/app/data/CreateAPIKeyRequest.kt | 6 +- .../opex/api/app/interceptor/APIKeyFilter.kt | 28 -------- .../api/app/interceptor/APIKeyFilterImpl.kt | 34 ++++++++++ ...{APIKeyService.kt => APIKeyServiceImpl.kt} | 64 ++++++++++++++----- .../co/nilin/opex/api/core/inout/APIKey.kt | 14 ++++ .../nilin/opex/api/core/spi/APIKeyFilter.kt | 3 + .../nilin/opex/api/core/spi/APIKeyService.kt | 26 ++++++++ .../ports/binance/config/SecurityConfig.kt | 8 +++ .../ports/postgres/dao/APIKeyRepository.kt | 8 +-- .../model/{APIKey.kt => APIKeyModel.kt} | 9 ++- .../src/main/resources/schema.sql | 13 ++++ 14 files changed, 190 insertions(+), 60 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/APIKeyExpiration.kt delete mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt rename api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/{APIKeyService.kt => APIKeyServiceImpl.kt} (64%) create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/inout/APIKey.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyFilter.kt create mode 100644 api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt rename api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/{APIKey.kt => APIKeyModel.kt} (61%) 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 index d663f397f..b275b8ca2 100644 --- 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 @@ -2,7 +2,7 @@ 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.APIKeyService +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 @@ -19,7 +19,12 @@ import java.security.Principal @RestController @RequestMapping("/api-key") -class APIKeyController(private val apiKeyService: APIKeyService) { +class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { + + @GetMapping("/test") + fun test(principal: Principal): String { + return principal.name + } @GetMapping suspend fun getKeys(principal: Principal): List { @@ -36,13 +41,13 @@ class APIKeyController(private val apiKeyService: APIKeyService) { val response = apiKeyService.createAPIKey( jwt.name, request.label, - request.expirationTime, + request.expiration?.getLocalDateTime(), request.allowedIPs, jwt.tokenValue() ) return object { - val secret = response.first val apiKey = response.second.key + val secret = response.first } } 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 index 7c03a4881..dfe733f83 100644 --- 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 @@ -5,7 +5,7 @@ import java.util.* data class APIKeyResponse( val label: String, - val expirationTime: LocalDateTime, + val expirationTime: LocalDateTime?, val allowedIPs: String?, val key: String, val enabled: Boolean 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 index 5d29e71ed..e84994737 100644 --- 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 @@ -1,9 +1,7 @@ package co.nilin.opex.api.app.data -import java.time.LocalDateTime - data class CreateAPIKeyRequest( val label: String, - val expirationTime: LocalDateTime, - val allowedIPs: 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/APIKeyFilter.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt deleted file mode 100644 index 1f15817a2..000000000 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package co.nilin.opex.api.app.interceptor - -import co.nilin.opex.api.app.service.APIKeyService -import kotlinx.coroutines.runBlocking -import org.springframework.web.filter.GenericFilterBean -import org.springframework.web.filter.OncePerRequestFilter -import javax.servlet.FilterChain -import javax.servlet.ServletRequest -import javax.servlet.ServletResponse -import javax.servlet.http.HttpServletRequest - -class APIKeyFilter(private val apiKeyService: APIKeyService) : GenericFilterBean() { - - override fun doFilter(servletRequest: ServletRequest, servletResponse: ServletResponse, filterChain: FilterChain) { - val request = servletRequest as HttpServletRequest - val apiKey = request.getHeader("X-API-KEY") - if (!apiKey.isNullOrEmpty()) { - val secret = request.getHeader("X-API-SECRET") - if (secret.isNullOrEmpty()) - filterChain.doFilter(servletRequest, servletResponse) - - val accessToken = runBlocking { apiKeyService.getAccessToken(apiKey, secret) } - - } - } - - -} \ 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..55aad4d7f --- /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]) } ?: return chain.filter(exchange) + val accessToken = apiKeyService.decryptToken(secret[0], apiKey) ?: return chain.filter(exchange) + val req = exchange.request.mutate() + .header("Authorization", "Bearer $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/service/APIKeyService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt similarity index 64% rename from api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt rename to api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt index 8c7c6077b..43f4d466f 100644 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyService.kt +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt @@ -1,16 +1,18 @@ 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.APIKey +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.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.stereotype.Service -import reactor.util.function.Tuple2 import java.time.LocalDateTime import java.util.* import javax.crypto.Cipher @@ -18,18 +20,20 @@ import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec @Service -class APIKeyService( +class APIKeyServiceImpl( private val apiKeyRepository: APIKeyRepository, private val authProxy: AuthProxy, @Value("\${app.auth.api-key-client.secret}") private val clientSecret: String -) { +) : APIKeyService { - suspend fun createAPIKey( + private val logger = LoggerFactory.getLogger(APIKeyServiceImpl::class.java) + + override suspend fun createAPIKey( userId: String, label: String, - expirationTime: LocalDateTime, - allowedIPs: String, + expirationTime: LocalDateTime?, + allowedIPs: String?, currentToken: String ): Pair { if (apiKeyRepository.countByUserId(userId).awaitFirstOrElse { 0 } >= 10) @@ -38,7 +42,7 @@ class APIKeyService( val secret = generateSecret() val tokenResponse = authProxy.exchangeToken(clientSecret, currentToken) val apiKey = apiKeyRepository.save( - APIKey( + APIKeyModel( null, userId, label, @@ -48,22 +52,50 @@ class APIKeyService( allowedIPs ) ).awaitSingle() - return Pair(secret, apiKey) + return Pair( + secret, + with(apiKey) { + APIKey(userId, label, accessToken, refreshToken, expirationTime, allowedIPs, key, isEnabled) + } + ) } - suspend fun getAccessToken(key: String, secret: String): String? { + override suspend fun getAPIKey(key: String): APIKey? { val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() - return if (apiKey == null) - null - else + return with(apiKey) { + if (this != null) + APIKey(userId, label, accessToken, refreshToken, expirationTime, allowedIPs, key, isEnabled) + else null + } + } + + override fun decryptToken(secret: String, apiKey: APIKey): String? { + return try { decryptAES(apiKey.accessToken, secret) + } catch (e: Exception) { + logger.error("Unable to decrypt token") + logger.error(e.stackTraceToString()) + null + } } - suspend fun getKeysByUserId(userId: String): List { + override suspend fun getKeysByUserId(userId: String): List { return apiKeyRepository.findAllByUserId(userId).collectList().awaitFirstOrElse { emptyList() } + .map { + APIKey( + it.userId, + it.label, + it.accessToken, + it.refreshToken, + it.expirationTime, + it.allowedIPs, + it.key, + it.isEnabled + ) + } } - suspend fun changeKeyState(userId: String, key: String, isEnabled: Boolean) { + 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) @@ -71,7 +103,7 @@ class APIKeyService( apiKeyRepository.save(apiKey).awaitSingle() } - suspend fun deleteKey(userId: String, key: String) { + 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) 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..6f79204d9 --- /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 refreshToken: String, + val expirationTime: LocalDateTime?, + val allowedIPs: String?, + val key: String, + var isEnabled: 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..b93d18537 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt @@ -0,0 +1,26 @@ +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): APIKey? + + fun decryptToken(secret: String, apiKey: APIKey): String? + + 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 index 96107615e..21fa5d9fc 100644 --- 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 @@ -1,17 +1,17 @@ package co.nilin.opex.api.ports.postgres.dao -import co.nilin.opex.api.ports.postgres.model.APIKey +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 { +interface APIKeyRepository : ReactiveCrudRepository { - fun findAllByUserId(userId: String): Flux + fun findAllByUserId(userId: String): Flux - fun findByKey(key: String): Mono + fun findByKey(key: String): Mono fun countByUserId(userId: String): Mono diff --git a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt similarity index 61% rename from api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt rename to api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt index da0e38b20..b44d6038c 100644 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKey.kt +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt @@ -1,17 +1,20 @@ 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 APIKey( - val id: Long? = null, +data class APIKeyModel( + @Id val id: Long? = null, val userId: String, val label: String, val accessToken: String, val refreshToken: String, - val expirationTime: LocalDateTime, + val expirationTime: LocalDateTime?, + @Column("allowed_ips") val allowedIPs: String?, val key: String = UUID.randomUUID().toString(), var isEnabled: Boolean = true 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..bfd64e363 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,16 @@ 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, + key VARCHAR(36) NOT NULL UNIQUE, + is_enabled BOOLEAN NOT NULL DEFAULT true +); From ad80860ce15ccac25c62c50f8138f7476d9abb78 Mon Sep 17 00:00:00 2001 From: Peyman Date: Wed, 26 Oct 2022 14:53:40 +0330 Subject: [PATCH 4/5] Start caching --- api/api-app/pom.xml | 4 + .../kotlin/co/nilin/opex/api/app/ApiApp.kt | 2 + .../opex/api/app/data/AccessTokenResponse.kt | 1 + .../api/app/interceptor/APIKeyFilterImpl.kt | 14 +-- .../co/nilin/opex/api/app/proxy/AuthProxy.kt | 1 + .../opex/api/app/service/APIKeyServiceImpl.kt | 85 ++++++++++++++----- .../co/nilin/opex/api/core/inout/APIKey.kt | 6 +- .../nilin/opex/api/core/spi/APIKeyService.kt | 4 +- .../api/ports/postgres/model/APIKeyModel.kt | 8 +- 9 files changed, 90 insertions(+), 35 deletions(-) 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..2e45258af 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,10 +2,12 @@ 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 @SpringBootApplication +@EnableCaching @ComponentScan("co.nilin.opex") class ApiApp 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 index 2a804e5d0..e0846aa41 100644 --- 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 @@ -3,4 +3,5 @@ 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/interceptor/APIKeyFilterImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/interceptor/APIKeyFilterImpl.kt index 55aad4d7f..f015a0ffd 100644 --- 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 @@ -20,13 +20,13 @@ class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFil if (secret.isNullOrEmpty()) return chain.filter(exchange) - val apiKey = runBlocking { apiKeyService.getAPIKey(key[0]) } ?: return chain.filter(exchange) - val accessToken = apiKeyService.decryptToken(secret[0], apiKey) ?: return chain.filter(exchange) - val req = exchange.request.mutate() - .header("Authorization", "Bearer $accessToken") - .build() - - return chain.filter(exchange.mutate().request(req).build()) + 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) } 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 index 2cc874b8c..23c4fac39 100644 --- 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 @@ -24,6 +24,7 @@ class AuthProxy( .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() 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 index 43f4d466f..2fb733f47 100644 --- 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 @@ -7,14 +7,19 @@ 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.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 @@ -49,33 +54,37 @@ class APIKeyServiceImpl( encryptAES(tokenResponse.access_token, secret), encryptAES(tokenResponse.refresh_token, secret), expirationTime, - allowedIPs + allowedIPs, + tokenExpiration(tokenResponse.expires_in) ) ).awaitSingle() + return Pair( secret, with(apiKey) { - APIKey(userId, label, accessToken, refreshToken, expirationTime, allowedIPs, key, isEnabled) + APIKey(userId, label, accessToken, expirationTime, allowedIPs, key, isEnabled, isExpired) } ) } - override suspend fun getAPIKey(key: String): APIKey? { + override suspend fun getAPIKey(key: String, secret: String): APIKey? = coroutineScope { val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() - return with(apiKey) { - if (this != null) - APIKey(userId, label, accessToken, refreshToken, expirationTime, allowedIPs, key, isEnabled) - else null - } - } - - override fun decryptToken(secret: String, apiKey: APIKey): String? { - return try { - decryptAES(apiKey.accessToken, secret) - } catch (e: Exception) { - logger.error("Unable to decrypt token") - logger.error(e.stackTraceToString()) - null + with(apiKey) { + if (this != null) { + launch { checkupAPIKey(this@with, secret) } + logger.info("Returning api key data") + APIKey( + userId, + label, + decryptToken(accessToken, secret), + expirationTime, + allowedIPs, + key, + isEnabled, + isExpired + ) + } else + null } } @@ -86,11 +95,11 @@ class APIKeyServiceImpl( it.userId, it.label, it.accessToken, - it.refreshToken, it.expirationTime, it.allowedIPs, it.key, - it.isEnabled + it.isEnabled, + it.isExpired ) } } @@ -110,6 +119,39 @@ class APIKeyServiceImpl( apiKeyRepository.delete(apiKey).awaitSingle() } + private fun decryptToken(secret: String, token: String): String? { + return try { + decryptAES(token, secret) + } catch (e: Exception) { + logger.error("Unable to decrypt token") + logger.error(e.stackTraceToString()) + null + } + } + + private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { + if (apiKey.isExpired || !apiKey.isEnabled) + return + + logger.info("Checking up api key...") + val now = LocalDateTime.now() + if (apiKey.expirationTime?.isBefore(now) == true) { + apiKey.isExpired = true + apiKeyRepository.save(apiKey).awaitSingle() + logger.info("API key ${apiKey.key} is expired") + return + } + + if (apiKey.tokenExpirationTime.isBefore(now)) { + val response = authProxy.refreshToken(clientSecret, decryptToken(secret, apiKey.refreshToken)!!) + apiKey.apply { + accessToken = encryptAES(response.refresh_token, secret) + tokenExpirationTime = tokenExpiration(response.expires_in) + } + apiKeyRepository.save(apiKey).awaitSingle() + } + } + 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))) @@ -131,4 +173,9 @@ class APIKeyServiceImpl( 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()) + } + } \ No newline at end of file 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 index 6f79204d9..4868c4667 100644 --- 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 @@ -5,10 +5,10 @@ import java.time.LocalDateTime data class APIKey( val userId: String, val label: String, - val accessToken: String, - val refreshToken: String, + val accessToken: String?, val expirationTime: LocalDateTime?, val allowedIPs: String?, val key: String, - var isEnabled: Boolean + 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/APIKeyService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt index b93d18537..f5329c6fa 100644 --- 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 @@ -13,9 +13,7 @@ interface APIKeyService { currentToken: String ): Pair - suspend fun getAPIKey(key: String): APIKey? - - fun decryptToken(secret: String, apiKey: APIKey): String? + suspend fun getAPIKey(key: String, secret: String): APIKey? suspend fun getKeysByUserId(userId: String): List 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 index b44d6038c..74f6add36 100644 --- 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 @@ -11,11 +11,13 @@ data class APIKeyModel( @Id val id: Long? = null, val userId: String, val label: String, - val accessToken: String, - val refreshToken: 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 isEnabled: Boolean = true, + var isExpired: Boolean = true ) \ No newline at end of file From ac862dc94bbf88513f2120b18ea70b9049ca9872 Mon Sep 17 00:00:00 2001 From: Peyman Date: Wed, 26 Oct 2022 18:40:49 +0330 Subject: [PATCH 5/5] Finish caching --- .../kotlin/co/nilin/opex/api/app/ApiApp.kt | 1 - .../nilin/opex/api/app/config/CacheConfig.kt | 18 +++++ .../api/app/controller/APIKeyController.kt | 7 +- .../co/nilin/opex/api/app/proxy/AuthProxy.kt | 2 +- .../opex/api/app/service/APIKeyServiceImpl.kt | 80 +++++++++++++------ .../api/ports/postgres/model/APIKeyModel.kt | 2 +- .../src/main/resources/schema.sql | 20 ++--- 7 files changed, 86 insertions(+), 44 deletions(-) create mode 100644 api/api-app/src/main/kotlin/co/nilin/opex/api/app/config/CacheConfig.kt 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 2e45258af..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 @@ -7,7 +7,6 @@ import org.springframework.context.annotation.ComponentScan import springfox.documentation.swagger2.annotations.EnableSwagger2 @SpringBootApplication -@EnableCaching @ComponentScan("co.nilin.opex") class ApiApp 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 index b275b8ca2..ab8bcd624 100644 --- 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 @@ -18,14 +18,9 @@ import org.springframework.web.bind.annotation.RestController import java.security.Principal @RestController -@RequestMapping("/api-key") +@RequestMapping("/v1/api-key") class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { - @GetMapping("/test") - fun test(principal: Principal): String { - return principal.name - } - @GetMapping suspend fun getKeys(principal: Principal): List { return apiKeyService.getKeysByUserId(principal.name) 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 index 23c4fac39..5de6ae557 100644 --- 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 @@ -42,7 +42,7 @@ class AuthProxy( val body = BodyInserters.fromFormData("client_id", "opex-api-key") .with("client_secret", clientSecret) .with("refresh_token", refreshToken) - .with("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .with("grant_type", "refresh_token") logger.info("Refreshing token") return client.post() 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 index 2fb733f47..7e23ba483 100644 --- 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 @@ -14,6 +14,8 @@ 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 @@ -23,11 +25,13 @@ 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 { @@ -68,15 +72,16 @@ class APIKeyServiceImpl( } override suspend fun getAPIKey(key: String, secret: String): APIKey? = coroutineScope { - val apiKey = apiKeyRepository.findByKey(key).awaitSingleOrNull() + 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) } - logger.info("Returning api key data") APIKey( userId, label, - decryptToken(accessToken, secret), + decryptAES(accessToken, secret), expirationTime, allowedIPs, key, @@ -119,36 +124,33 @@ class APIKeyServiceImpl( apiKeyRepository.delete(apiKey).awaitSingle() } - private fun decryptToken(secret: String, token: String): String? { - return try { - decryptAES(token, secret) - } catch (e: Exception) { - logger.error("Unable to decrypt token") - logger.error(e.stackTraceToString()) - null - } - } - private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { if (apiKey.isExpired || !apiKey.isEnabled) return logger.info("Checking up api key...") - val now = LocalDateTime.now() - if (apiKey.expirationTime?.isBefore(now) == true) { - apiKey.isExpired = true - apiKeyRepository.save(apiKey).awaitSingle() - logger.info("API key ${apiKey.key} is expired") - return - } + 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)) { - val response = authProxy.refreshToken(clientSecret, decryptToken(secret, apiKey.refreshToken)!!) - apiKey.apply { - accessToken = encryptAES(response.refresh_token, secret) - tokenExpirationTime = tokenExpiration(response.expires_in) + 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") } - apiKeyRepository.save(apiKey).awaitSingle() + } catch (e: Exception) { + logger.error("Error checking api key ${apiKey.key}", e) } } @@ -178,4 +180,30 @@ class APIKeyServiceImpl( 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-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 index 74f6add36..77f4bf4c5 100644 --- 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 @@ -19,5 +19,5 @@ data class APIKeyModel( var tokenExpirationTime: LocalDateTime, val key: String = UUID.randomUUID().toString(), var isEnabled: Boolean = true, - var isExpired: Boolean = true + var isExpired: Boolean = false ) \ No newline at end of file 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 bfd64e363..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 @@ -9,13 +9,15 @@ CREATE TABLE IF NOT EXISTS symbol_maps 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, - key VARCHAR(36) NOT NULL UNIQUE, - is_enabled BOOLEAN NOT NULL DEFAULT true + 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 );