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 e991b78d4..04e566341 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 @@ -1,56 +1,148 @@ 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.common.security.jwtAuthentication -import org.springframework.security.core.annotation.CurrentSecurityContext -import org.springframework.security.core.context.SecurityContext +import co.nilin.opex.api.app.data.ApiKeyResponse +import co.nilin.opex.api.app.data.CreateApiKeyRequest +import co.nilin.opex.api.app.data.UpdateApiKeyRequest +import co.nilin.opex.common.security.JwtUtils import org.springframework.web.bind.annotation.* -import java.security.Principal +import java.security.SecureRandom +import java.util.* @RestController @RequestMapping("/v1/api-key") -class APIKeyController(private val apiKeyService: APIKeyServiceImpl) { +class APIKeyController( + private val apiKeyService: co.nilin.opex.api.core.spi.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) } + private val rng = SecureRandom() + + private fun generateSecretBase64(bytes: Int = 48): String { + val b = ByteArray(bytes) + rng.nextBytes(b) + return Base64.getEncoder().encodeToString(b) } + private fun canonicalTemplate(): String = "METHOD\nPATH\nQUERY\nBODY_SHA256\nTIMESTAMP_MS" + + private fun headersTemplate(apiKeyId: String): Map = mapOf( + "X-API-KEY" to apiKeyId, + "X-API-SIGNATURE" to "Base64(HMAC-SHA256(secret, canonical))", + "X-API-TIMESTAMP" to "", + "X-API-BODY-SHA256" to " (optional)" + ) + + // Create a new API key. Caller must provide a user access token; we bind the key to that user. Returns one-time secret and usage hints. @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.token.tokenValue + @RequestHeader(name = "Authorization", required = false) authorization: String?, + @RequestBody req: CreateApiKeyRequest + ): ApiKeyResponse { + require(!authorization.isNullOrBlank() && authorization.startsWith("Bearer ")) { "Authorization Bearer user token is required" } + val userToken = authorization.substringAfter("Bearer ").trim() + val (userId, preferredUsername) = parseJwtUser(userToken) + + val apiKeyId = req.apiKeyId?.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() + val secret = generateSecretBase64() + val stored = apiKeyService.createApiKeyRecord( + apiKeyId = apiKeyId, + label = req.label, + plaintextSecret = secret, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUserId = userId, + keycloakUsername = preferredUsername, + enabled = true + ) + return ApiKeyResponse( + apiKeyId = apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUsername, + secret = secret ) - 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) + private fun parseJwtUser(token: String): Pair { + // Decode JWT payload using common JwtUtils (no signature verification here). + val payload = JwtUtils.decodePayload(token) + val sub = payload["sub"] as? String + val preferred = payload["username"] as? String + require(!sub.isNullOrBlank()) { "JWT missing sub" } + return Pair(sub!!, preferred) } - @PutMapping("/{key}/disable") - suspend fun disableKey(principal: Principal, @PathVariable key: String) { - apiKeyService.changeKeyState(principal.name, key, false) + // List all API keys (admin-only) — secret is not returned + @GetMapping + suspend fun list(): List = apiKeyService.listApiKeyRecords().stream().map { + ApiKeyResponse( + apiKeyId = it.apiKeyId, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) + }.toList() + + + // Get one API key (admin-only) — secret is not returned + @GetMapping("/{apiKeyId}") + suspend fun get(@PathVariable apiKeyId: String): ApiKeyResponse { + val it = apiKeyService.getApiKeyRecord(apiKeyId) ?: throw NoSuchElementException("API key not found: $apiKeyId") + return ApiKeyResponse( + apiKeyId = it.apiKeyId, + label = it.label, + enabled = it.enabled, + allowedIps = it.allowedIps, + allowedEndpoints = it.allowedEndpoints, + keycloakUsername = it.keycloakUsername, + secret = null + ) + } + + // Rotate secret (admin-only). Returns new one-time secret + @PostMapping("/{apiKeyId}/rotate") + suspend fun rotate(@PathVariable apiKeyId: String): ApiKeyResponse { + val newSecret = generateSecretBase64() + val stored = apiKeyService.rotateApiKeySecret(apiKeyId, newSecret) + return ApiKeyResponse( + apiKeyId = stored.record.apiKeyId, + label = stored.record.label, + enabled = stored.record.enabled, + allowedIps = stored.record.allowedIps, + allowedEndpoints = stored.record.allowedEndpoints, + keycloakUsername = stored.record.keycloakUserId, + secret = newSecret + ) } - @DeleteMapping("/{key}") - suspend fun deleteKey(principal: Principal, @PathVariable key: String) { - apiKeyService.deleteKey(principal.name, key) + // Update metadata or enable/disable (admin-only) + @PutMapping("/{apiKeyId}") + suspend fun update(@PathVariable apiKeyId: String, @RequestBody req: UpdateApiKeyRequest): ApiKeyResponse { + val s = apiKeyService.updateApiKeyRecord( + apiKeyId = apiKeyId, + label = req.label, + enabled = req.enabled, + allowedIps = req.allowedIps, + allowedEndpoints = req.allowedEndpoints, + keycloakUsername = req.keycloakUsername + ) + return ApiKeyResponse( + apiKeyId = s.apiKeyId, + label = s.label, + enabled = s.enabled, + allowedIps = s.allowedIps, + allowedEndpoints = s.allowedEndpoints, + keycloakUsername = s.keycloakUserId + ) } + // Delete/revoke (admin-only) + @DeleteMapping("/{apiKeyId}") + suspend fun delete(@PathVariable apiKeyId: String) { + apiKeyService.deleteApiKeyRecord(apiKeyId) + } } \ 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 b2832019f..21da2279f 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 @@ -2,10 +2,12 @@ package co.nilin.opex.api.app.data import java.time.LocalDateTime -data class APIKeyResponse( - val label: String, - val expirationTime: LocalDateTime?, - val allowedIPs: String?, - val key: String, - val enabled: Boolean +data class ApiKeyResponse( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String?, + val secret: String? = null, // only present on create/rotate ) \ 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 index e0846aa41..7fe91e449 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 @@ -2,6 +2,6 @@ package co.nilin.opex.api.app.data data class AccessTokenResponse( val access_token: String, - val refresh_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 index e84994737..20ca10712 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,7 +1,9 @@ package co.nilin.opex.api.app.data -data class CreateAPIKeyRequest( - val label: String, - val expiration: APIKeyExpiration?, - val allowedIPs: String? +data class CreateApiKeyRequest( + val apiKeyId: String?, + val label: String?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? ) \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt new file mode 100644 index 000000000..52bfcfbbc --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/data/UpdateApiKeyRequest.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.data + + + +data class UpdateApiKeyRequest( + val label: String?, + val enabled: Boolean?, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUsername: String? +) + + 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 e5e802b23..e65c26d63 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 @@ -1,9 +1,11 @@ package co.nilin.opex.api.app.interceptor -import co.nilin.opex.api.app.service.APIKeyServiceImpl +import co.nilin.opex.api.app.security.ClientCredentialsTokenService +import co.nilin.opex.api.app.security.HmacVerifier +import co.nilin.opex.api.core.spi.APIKeyService import co.nilin.opex.api.core.spi.APIKeyFilter import kotlinx.coroutines.reactor.mono -import kotlinx.coroutines.runBlocking +import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.web.server.ServerWebExchange import org.springframework.web.server.WebFilter @@ -11,30 +13,117 @@ import org.springframework.web.server.WebFilterChain import reactor.core.publisher.Mono @Component -class APIKeyFilterImpl(private val apiKeyService: APIKeyServiceImpl) : APIKeyFilter, WebFilter { +class APIKeyFilterImpl( + private val apiKeyService: APIKeyService, + private val hmacVerifier: HmacVerifier, + private val clientTokenService: ClientCredentialsTokenService +) : APIKeyFilter, WebFilter { - override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + private val logger = LoggerFactory.getLogger(APIKeyFilterImpl::class.java) + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { val request = exchange.request - val key = request.headers["X-API-KEY"] - val secret = request.headers["X-API-SECRET"] - if (key.isNullOrEmpty() || secret.isNullOrEmpty()) { + val apiKeyId = request.headers["X-API-KEY"]?.firstOrNull() + val signature = request.headers["X-API-SIGNATURE"]?.firstOrNull() + val tsHeader = request.headers["X-API-TIMESTAMP"]?.firstOrNull() + val uri = request.uri + + // HMAC path when signature present + if (!apiKeyId.isNullOrBlank() && !signature.isNullOrBlank() && !tsHeader.isNullOrBlank()) { + return mono { + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {}", apiKeyId, sourceIp) + null + } + if (!entry.allowedEndpoints.isNullOrEmpty() && ( !entry.allowedEndpoints!!.contains(uri.rawPath))) { + logger.warn("API key {} request to unauthorized resource {}", apiKeyId, uri.rawPath) + null + } else { + val ts = tsHeader.toLongOrNull() + val bodyHash = request.headers["X-API-BODY-SHA256"]?.firstOrNull() + if (ts == null) { + logger.warn("Invalid timestamp header for bot {}", apiKeyId) + null + } else { + val ok = hmacVerifier.verify( + entry.secret, + signature, + HmacVerifier.VerificationInput( + method = request.method.name(), + path = uri.rawPath, + query = uri.rawQuery, + timestampMillis = ts, + bodySha256 = bodyHash + ) + ) + if (!ok) { + logger.warn("Invalid signature for apiKey {}", apiKeyId) + null + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting", apiKeyId) + null + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } + } + } + }.flatMap { updatedExchange -> + if (updatedExchange != null) chain.filter(updatedExchange) else chain.filter(exchange) + } + } + + // Secret-only path with X-API-SECRET (kept as requested). We validate the provided secret + // against the stored HMAC secret for the apiKey, then proceed to exchange to the mapped user token. + val legacySecret = request.headers["X-API-SECRET"]?.firstOrNull() + if (apiKeyId.isNullOrBlank() || legacySecret.isNullOrBlank()) { return chain.filter(exchange) } return mono { - val apiKey = 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() - exchange.mutate().request(req).build() - } else null + val entry = apiKeyService.getApiKeyForVerification(apiKeyId) + if (entry == null || !entry.enabled) { + logger.warn("Unknown or disabled API key on secret path: {}", apiKeyId) + null + } else { + // Optional IP allowlist + val sourceIp = request.remoteAddress?.address?.hostAddress + if (!entry.allowedIps.isNullOrEmpty() && (sourceIp == null || !entry.allowedIps!!.contains(sourceIp))) { + logger.warn("API key {} request from disallowed IP {} (secret path)", apiKeyId, sourceIp) + null + } else if (legacySecret != entry.secret) { + logger.warn("Invalid X-API-SECRET for apiKey {}", apiKeyId) + null + } else { + val userId = entry.keycloakUserId + if (userId.isNullOrBlank()) { + logger.warn("API key {} has no mapped Keycloak userId; rejecting (secret path)", apiKeyId) + null + } else { + val bearer = clientTokenService.exchangeToUserToken(userId) + val req = request.mutate() + .header("Authorization", "Bearer $bearer") + .build() + exchange.mutate().request(req).build() + } + } + } }.flatMap { updatedExchange -> - if (updatedExchange != null) - chain.filter(updatedExchange) - else - chain.filter(exchange) + if (updatedExchange != null) chain.filter(updatedExchange) else 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 index 95879afaf..f39c3301f 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 @@ -57,4 +57,50 @@ class AuthProxy( .bodyToMono() .awaitSingle() } + + suspend fun clientCredentials(clientId: String, clientSecret: String, scope: String? = null): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "client_credentials") + val body = if (scope.isNullOrBlank()) form else form.with("scope", scope) + + logger.info("Request client_credentials token for client {}", clientId) + 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() + } + + // Exchange a client_credentials access token to a user access token (Token Exchange) + suspend fun exchangeToUser( + clientId: String, + clientSecret: String, + subjectToken: String, + requestedSubjectUserId: String, + audience: String? = null + ): AccessTokenResponse { + val form = BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + .with("subject_token", subjectToken) + .with("requested_subject", requestedSubjectUserId) + .with("requested_token_type", "urn:ietf:params:oauth:token-type:access_token") + val body = if (audience.isNullOrBlank()) form else form.with("audience", audience) + + logger.info("Token exchange to user {} via client {}", requestedSubjectUserId, clientId) + 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/security/ApiKeyRegistry.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt new file mode 100644 index 000000000..2a7e8662e --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeyRegistry.kt @@ -0,0 +1,13 @@ +package co.nilin.opex.api.app.security + +interface ApiKeyRegistry { + data class BotInfo( + val apiKeyId: String, + val hmacSecret: String, + val enabled: Boolean = true, + val allowedIps: Set? = null, + val keycloakUsername: String? = null + ) + + fun find(apiKeyId: String): BotInfo? +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt new file mode 100644 index 000000000..6d8e72be7 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ApiKeySecretCryptoImpl.kt @@ -0,0 +1,51 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Component +class ApiKeySecretCryptoImpl( + @Value("\${app.api.crypto.key}") private val base64Key: String +) : ApiKeySecretCrypto { + private val key: SecretKey + private val rng = SecureRandom() + private val logger = LoggerFactory.getLogger(ApiKeySecretCryptoImpl::class.java) + + init { + val decoded = Base64.getDecoder().decode(base64Key) + require(decoded.size == 16 || decoded.size == 24 || decoded.size == 32) { + "app.api.crypto.key must be 128/192/256-bit Base64 key" + } + key = SecretKeySpec(decoded, "AES") + } + + override fun encrypt(plaintext: String): String { + val iv = ByteArray(12) + rng.nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val ivB64 = Base64.getEncoder().encodeToString(iv) + val ctB64 = Base64.getEncoder().encodeToString(ct) + return "$ivB64:$ctB64" + } + + override fun decrypt(ciphertext: String): String { + val parts = ciphertext.split(":") + require(parts.size == 2) { "Invalid encrypted secret format" } + val iv = Base64.getDecoder().decode(parts[0]) + val ct = Base64.getDecoder().decode(parts[1]) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + val pt = cipher.doFinal(ct) + return String(pt, Charsets.UTF_8) + } +} diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt new file mode 100644 index 000000000..648a40099 --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/ClientCredentialsTokenService.kt @@ -0,0 +1,53 @@ +package co.nilin.opex.api.app.security + +import co.nilin.opex.api.app.proxy.AuthProxy +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ClientCredentialsTokenService( + private val authProxy: AuthProxy, + @Value("\${app.auth.api-key-client.id}") + private val clientId: String, + @Value("\${app.auth.api-key-client.secret}") + private val clientSecret: String +) { + private val logger = LoggerFactory.getLogger(ClientCredentialsTokenService::class.java) + + private data class CachedToken(val token: String, val expiresAtMillis: Long) + + // Cache for client_credentials token + @Volatile private var cache: CachedToken? = null + + // Cache for exchanged user tokens per subject (exactly like getBearerToken, keyed only by subject) + private val subjectCache = java.util.concurrent.ConcurrentHashMap() + + suspend fun getBearerToken(): String { + val now = Instant.now().toEpochMilli() + val snap = cache + if (snap != null && snap.expiresAtMillis - 30_000 > now) return snap.token + val resp = authProxy.clientCredentials(clientId, clientSecret) + val expiresAt = now + (resp.expires_in * 1000L) + val bearer = resp.access_token + cache = CachedToken(bearer, expiresAt) + logger.debug("Fetched new client_credentials token; expires at {}", expiresAt) + return bearer + } + + // Convenience: exchange cached client token for a user access token, cached per subject + suspend fun exchangeToUserToken(requestedSubjectUserId: String, audience: String? = null): String { + val now = Instant.now().toEpochMilli() + val cached = subjectCache[requestedSubjectUserId] + if (cached != null && cached.expiresAtMillis - 30_000 > now) return cached.token + + val subjectToken = getBearerToken() + val exchanged = authProxy.exchangeToUser(clientId, clientSecret, subjectToken, requestedSubjectUserId, audience) + val token = exchanged.access_token + val expiresAt = now + (exchanged.expires_in * 1000L) + subjectCache[requestedSubjectUserId] = CachedToken(token, expiresAt) + logger.debug("Exchanged and cached user token for subject {}; expires at {}", requestedSubjectUserId, expiresAt) + return token + } +} \ No newline at end of file diff --git a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt new file mode 100644 index 000000000..97b6a152c --- /dev/null +++ b/api/api-app/src/main/kotlin/co/nilin/opex/api/app/security/HmacVerifier.kt @@ -0,0 +1,61 @@ +package co.nilin.opex.api.app.security + +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import java.util.Base64 +import kotlin.math.abs + +@org.springframework.stereotype.Component +class HmacVerifier( + private val allowedSkew: Duration = Duration.ofMinutes(5) +) { + data class VerificationInput( + val method: String, + val path: String, + val timestampMillis: Long, + val bodySha256: String? = null, + val query: String? = null + ) + + fun verify(secret: String, signatureBase64: String, input: VerificationInput): Boolean { + // Check timestamp window + val now = Instant.now().toEpochMilli() + if (abs(now - input.timestampMillis) > allowedSkew.toMillis()) return false + + val canonical = canonicalString(input) + val expected = hmacSha256Base64(secret, canonical) + // Constant-time compare + return constantTimeEquals(signatureBase64, expected) + } + + private fun canonicalString(input: VerificationInput): String { + val sb = StringBuilder() + sb.append(input.method.uppercase()).append('\n') + .append(input.path).append('\n') + .append(input.query ?: "").append('\n') + .append(input.bodySha256 ?: "").append('\n') + .append(input.timestampMillis) + return sb.toString() + } + + private fun hmacSha256Base64(secret: String, data: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) + val raw = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + return Base64.getEncoder().encodeToString(raw) + } + + private fun constantTimeEquals(a: String, b: String): Boolean { + val aBytes = a.toByteArray(StandardCharsets.UTF_8) + val bBytes = b.toByteArray(StandardCharsets.UTF_8) + var result = aBytes.size xor bBytes.size + val len = minOf(aBytes.size, bBytes.size) + for (i in 0 until len) { + result = result or (aBytes[i].toInt() xor bBytes[i].toInt()) + } + return result == 0 + } +} \ 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 deleted file mode 100644 index 37458cead..000000000 --- a/api/api-app/src/main/kotlin/co/nilin/opex/api/app/service/APIKeyServiceImpl.kt +++ /dev/null @@ -1,239 +0,0 @@ -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.common.OpexError -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.reactive.awaitFirstOrElse -import kotlinx.coroutines.reactive.awaitFirstOrNull -import kotlinx.coroutines.reactor.awaitSingle -import kotlinx.coroutines.reactor.awaitSingleOrNull -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -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.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -@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) - private val refreshLocks = ConcurrentHashMap() - override suspend fun createAPIKey( - userId: String, - label: String, - expirationTime: LocalDateTime?, - allowedIPs: String?, - currentToken: String - ): Pair { - if (apiKeyRepository.countByUserId(userId).awaitFirstOrElse { 0 } >= 10) - throw OpexError.APIKeyLimitReached.exception() - - 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) - ?: apiKeyRepository.findByKey(key)?.awaitSingleOrNull()?.apply { putCache(this) } - - with(apiKey) { - if (this != null) { - refreshIfNeeded(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 OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKey.isEnabled = isEnabled - apiKeyRepository.save(apiKey).awaitSingle() - } - - override suspend fun deleteKey(userId: String, key: String) { - val apiKey = apiKeyRepository.findByKey(key)?.awaitSingleOrNull() ?: throw OpexError.NotFound.exception() - if (apiKey.userId != userId) - throw OpexError.Forbidden.exception() - apiKeyRepository.delete(apiKey).awaitFirstOrNull() - } - - private suspend fun refreshIfNeeded(apiKey: APIKeyModel, secret: String) { - if (apiKey.isExpired || !apiKey.isEnabled) return - 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.isAfter(now)) return - - val mutex = refreshLocks.computeIfAbsent(apiKey.key) { Mutex() } - mutex.withLock { - // Double-check after acquiring the lock to avoid redundant refresh - if (apiKey.tokenExpirationTime.isAfter(LocalDateTime.now())) return - try { - logger.info("Refreshing api key ${apiKey.key} token") - val response = authProxy.refreshToken(clientSecret, decryptAES(apiKey.refreshToken, secret)) - apiKey.accessToken = encryptAES(response.access_token, secret) - apiKey.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 refreshing api key ${apiKey.key}", e) - } - } - } - - private suspend fun checkupAPIKey(apiKey: APIKeyModel, secret: String) { - if (apiKey.isExpired || !apiKey.isEnabled) - 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)) { - 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) - } - } - - private fun updateCache(apiKey: APIKeyModel) { - getCache()?.apply { - evict(apiKey.key) - put(apiKey.key, apiKey) - } - } - - 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 36932fc1c..80a1b7cc9 100644 --- a/api/api-app/src/main/resources/application.yml +++ b/api/api-app/src/main/resources/application.yml @@ -111,10 +111,11 @@ app: url: http://opex-bc-gateway auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} token-url: http://keycloak:8080/realms/opex/protocol/openid-connect/token api-key-client: secret: ${API_KEY_CLIENT_SECRET} + id: opex-api-key binance: api-url: https://api1.binance.com trade-volume-calculation-currency: ${TRADE_VOLUME_CALCULATION_CURRENCY:USDT} @@ -123,5 +124,8 @@ app: custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} + api: + crypto: + key: ${api_crypto_key:0e1fd29572ec8c85970d76e3433e96ee} 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/spi/APIKeyService.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/APIKeyService.kt index f5329c6fa..580e40a0a 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 @@ -1,24 +1,58 @@ 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) - + data class ApiKeyRecord( + val apiKeyId: String, + val label: String?, + val enabled: Boolean, + val allowedIps: Set?, + val allowedEndpoints: Set?, + val keycloakUserId: String?, + val keycloakUsername: String? + ) + + data class ApiKeyCreateResult( + val secret: String, + val record: ApiKeyRecord + ) + + data class ApiKeyVerification( + val apiKeyId: String, + val secret: String, + val enabled: Boolean, + val allowedEndpoints: Set?, + val allowedIps: Set?, + val keycloakUserId: String? + ) + + suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): ApiKeyCreateResult + + suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): ApiKeyCreateResult + + suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): ApiKeyRecord + + suspend fun getApiKeyRecord(apiKeyId: String): ApiKeyRecord? + + suspend fun listApiKeyRecords(): List + + suspend fun deleteApiKeyRecord(apiKeyId: String) + + suspend fun getApiKeyForVerification(apiKeyId: String): ApiKeyVerification? } \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt new file mode 100644 index 000000000..d5e164cbe --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/spi/ApiKeySecretCrypto.kt @@ -0,0 +1,6 @@ +package co.nilin.opex.api.core.spi + +interface ApiKeySecretCrypto { + fun encrypt(plaintext: String): String + fun decrypt(ciphertext: String): String +} \ No newline at end of file diff --git a/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt new file mode 100644 index 000000000..e86b42ad2 --- /dev/null +++ b/api/api-core/src/main/kotlin/co/nilin/opex/api/core/utils/Convertor.kt @@ -0,0 +1,10 @@ +package co.nilin.opex.api.core.utils + +fun Set?.toCsv(): String? = this?.joinToString(",") + +fun String?.toSet(): Set? = this + ?.takeIf { it.isNotBlank() } + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.toSet() 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 b414ea3a8..99e9974f7 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 @@ -58,6 +58,8 @@ class SecurityConfig( .pathMatchers("/opex/v1/voucher").hasAuthority("PERM_voucher:submit") .pathMatchers("/opex/v1/market/**").permitAll() .pathMatchers(HttpMethod.GET, "/opex/v1/market/chain").permitAll() + .pathMatchers(HttpMethod.POST,"/v1/api-key").authenticated() + .pathMatchers("/v1/api-key").hasAuthority("ROLE_admin") .anyExchange().authenticated() } .addFilterBefore(apiKeyFilter as WebFilter, SecurityWebFiltersOrder.AUTHENTICATION) 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 deleted file mode 100644 index 007c0025c..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/APIKeyRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package co.nilin.opex.api.ports.postgres.dao - -import co.nilin.opex.api.ports.postgres.model.APIKeyModel -import org.springframework.data.repository.CrudRepository -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/dao/ApiKeyRegistryRepository.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt new file mode 100644 index 000000000..4868f26a5 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/dao/ApiKeyRegistryRepository.kt @@ -0,0 +1,9 @@ +package co.nilin.opex.api.ports.postgres.dao + +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import org.springframework.data.repository.reactive.ReactiveCrudRepository +import org.springframework.stereotype.Repository + +@Repository +interface ApiKeyRegistryRepository : 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/impl/APIKeyServiceImpl.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt new file mode 100644 index 000000000..8dfd80c1b --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/impl/APIKeyServiceImpl.kt @@ -0,0 +1,126 @@ +package co.nilin.opex.api.ports.postgres.impl + +import co.nilin.opex.api.core.spi.APIKeyService +import co.nilin.opex.api.core.spi.ApiKeySecretCrypto +import co.nilin.opex.api.core.utils.toCsv +import co.nilin.opex.api.core.utils.toSet +import co.nilin.opex.api.ports.postgres.dao.ApiKeyRegistryRepository +import co.nilin.opex.api.ports.postgres.model.ApiKeyRegistryModel +import kotlinx.coroutines.reactive.awaitSingle +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class APIKeyServiceImpl( + private val apiKeySecretCrypto: ApiKeySecretCrypto, + private val apiKeyRegistryRepository: ApiKeyRegistryRepository +) : APIKeyService { + + private fun toRecord(e: ApiKeyRegistryModel): APIKeyService.ApiKeyRecord = + APIKeyService.ApiKeyRecord( + apiKeyId = e.apiKeyId, + label = e.label, + enabled = e.enabled, + allowedIps = e.allowedIps.toSet(), + allowedEndpoints = e.allowedEndpoints.toSet(), + keycloakUserId = e.keycloakUserId, + keycloakUsername = e.keycloakUsername + ) + + override suspend fun createApiKeyRecord( + apiKeyId: String, + label: String?, + plaintextSecret: String, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUserId: String?, + keycloakUsername: String?, + enabled: Boolean + ): APIKeyService.ApiKeyCreateResult { + require(apiKeyId.isNotBlank()) { "apiKeyId is blank" } + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (exists) error("API key already exists: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(plaintextSecret) + val now = LocalDateTime.now() + val entry = ApiKeyRegistryModel( + apiKeyId = apiKeyId, + label = label, + encryptedSecret = enc, + enabled = enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUserId = keycloakUserId, + keycloakUsername = keycloakUsername, + createdAt = now, + updatedAt = now + ) + val saved = apiKeyRegistryRepository.save(entry).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = plaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun rotateApiKeySecret(apiKeyId: String, newPlaintextSecret: String): APIKeyService.ApiKeyCreateResult { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val enc = apiKeySecretCrypto.encrypt(newPlaintextSecret) + val updated = existing.copy( + encryptedSecret = enc, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return APIKeyService.ApiKeyCreateResult( + secret = newPlaintextSecret, + record = toRecord(saved) + ) + } + + override suspend fun updateApiKeyRecord( + apiKeyId: String, + label: String?, + enabled: Boolean?, + allowedIps: Set?, + allowedEndpoints: Set?, + keycloakUsername: String? + ): APIKeyService.ApiKeyRecord { + val existing = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: error("API key not found: $apiKeyId") + val updated = existing.copy( + label = label ?: existing.label, + enabled = enabled ?: existing.enabled, + allowedIps = allowedIps.toCsv(), + allowedEndpoints = allowedEndpoints.toCsv(), + keycloakUsername = keycloakUsername ?: existing.keycloakUsername, + updatedAt = LocalDateTime.now() + ) + val saved = apiKeyRegistryRepository.save(updated).awaitSingle() + return toRecord(saved) + } + + override suspend fun getApiKeyRecord(apiKeyId: String): APIKeyService.ApiKeyRecord? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + return toRecord(e) + } + + override suspend fun listApiKeyRecords(): List = + apiKeyRegistryRepository.findAll().map { toRecord(it) }.collectList().awaitSingle().sortedBy { it.apiKeyId } + + override suspend fun deleteApiKeyRecord(apiKeyId: String) { + val exists = apiKeyRegistryRepository.existsById(apiKeyId).awaitSingle() + if (!exists) error("API key not found: $apiKeyId") + apiKeyRegistryRepository.deleteById(apiKeyId).awaitSingle() + } + + override suspend fun getApiKeyForVerification(apiKeyId: String): APIKeyService.ApiKeyVerification? { + val e = apiKeyRegistryRepository.findById(apiKeyId).awaitSingle() ?: return null + val secret = try { apiKeySecretCrypto.decrypt(e.encryptedSecret) } catch (_: Exception) { return null } + return APIKeyService.ApiKeyVerification( + apiKeyId = apiKeyId, + secret = secret, + enabled = e.enabled, + allowedEndpoints = e.allowedEndpoints.toSet(), + allowedIps = e.allowedIps.toSet(), + keycloakUserId = e.keycloakUserId + ) + } +} \ 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 deleted file mode 100644 index f33c9cd65..000000000 --- a/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/APIKeyModel.kt +++ /dev/null @@ -1,23 +0,0 @@ -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.* - -@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/ApiKeyRegistryModel.kt b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt new file mode 100644 index 000000000..e62255ad2 --- /dev/null +++ b/api/api-ports/api-persister-postgres/src/main/kotlin/co/nilin/opex/api/ports/postgres/model/ApiKeyRegistryModel.kt @@ -0,0 +1,33 @@ +package co.nilin.opex.api.ports.postgres.model + +import org.springframework.data.annotation.Id +import org.springframework.data.domain.Persistable +import org.springframework.data.relational.core.mapping.Column +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime + +@Table("api_key_registry") +data class ApiKeyRegistryModel( + @Id + @Column("api_key_id") + val apiKeyId: String, + val label: String?, + @Column("encrypted_secret") + val encryptedSecret: String, + val enabled: Boolean, + @Column("allowed_ips") + val allowedIps: String?, + @Column("allowed_endpoints") + val allowedEndpoints: String?, + @Column("keycloak_user_id") + val keycloakUserId: String?, + @Column("keycloak_username") + val keycloakUsername: String?, + @Column("created_at") + val createdAt: LocalDateTime, + @Column("updated_at") + val updatedAt: LocalDateTime +): Persistable { + override fun getId(): String = apiKeyId + override fun isNew(): Boolean = createdAt == updatedAt +} \ 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 1d4db9878..e1a77a3bc 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,18 +6,18 @@ CREATE TABLE IF NOT EXISTS symbol_maps alias VARCHAR(72) NOT NULL, UNIQUE (symbol, alias_key, alias) ); +DROP TABLE IF EXISTS api_key; -CREATE TABLE IF NOT EXISTS api_key +CREATE TABLE IF NOT EXISTS api_key_registry ( - id SERIAL PRIMARY KEY, - user_id VARCHAR(36) NOT NULL, - label VARCHAR(200) NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - expiration_time TIMESTAMP, - allowed_ips TEXT, - token_expiration_time TIMESTAMP NOT NULL, - key VARCHAR(36) NOT NULL UNIQUE, - is_enabled BOOLEAN NOT NULL DEFAULT true, - is_expired BOOLEAN NOT NULL DEFAULT false + api_key_id VARCHAR(128) PRIMARY KEY, + label VARCHAR(200), + encrypted_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + allowed_ips TEXT, + allowed_endpoints TEXT, + keycloak_user_id VARCHAR(128), + keycloak_username VARCHAR(256), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL ); diff --git a/auth-gateway/auth-gateway-app/src/main/resources/application.yml b/auth-gateway/auth-gateway-app/src/main/resources/application.yml index fb2189bd9..798e9dad6 100644 --- a/auth-gateway/auth-gateway-app/src/main/resources/application.yml +++ b/auth-gateway/auth-gateway-app/src/main/resources/application.yml @@ -60,7 +60,7 @@ logging: keycloak: url: http://keycloak:8080 cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} realm: opex admin-client: id: "opex-admin" diff --git a/bc-gateway/bc-gateway-app/src/main/resources/application.yml b/bc-gateway/bc-gateway-app/src/main/resources/application.yml index a3ae81c7f..a8eb6cc2f 100644 --- a/bc-gateway/bc-gateway-app/src/main/resources/application.yml +++ b/bc-gateway/bc-gateway-app/src/main/resources/application.yml @@ -116,7 +116,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} client-id: none client-secret: none wallet: diff --git a/docker-compose.yml b/docker-compose.yml index 97ae0503b..d45da092e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,7 @@ services: - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD:-hiopex} - OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET=${OPEX_ADMIN_KEYCLOAK_CLIENT_SECRET} - VANDAR_API_KEY=$VANDAR_API_KEY + - API_CRYPTO_KEY=${API_CRYPTO_KEY} cap_add: - IPC_LOCK deploy: diff --git a/docker-images/vault/workflow-vault.sh b/docker-images/vault/workflow-vault.sh index 5fe4a8b9f..cbe570f75 100755 --- a/docker-images/vault/workflow-vault.sh +++ b/docker-images/vault/workflow-vault.sh @@ -96,7 +96,7 @@ init_secrets() { ## Add secret values vault kv put secret/opex smtppass=${SMTP_PASS} vault kv put secret/opex-accountant dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} - vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} + vault kv put secret/opex-api dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} api_crypto_key=${API_CRYPTO_KEY} vault kv put secret/opex-market dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} vault kv put secret/opex-bc-gateway dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} client_id=${CLIENT_ID} client_secret=${CLIENT_SECRET} vault kv put secret/opex-eventlog dbusername=${DB_USER} dbpassword=${DB_PASS} db_read_only_username=${DB_READ_ONLY_USER} db_read_only_pass=${DB_READ_ONLY_PASS} diff --git a/market/market-app/src/main/resources/application.yml b/market/market-app/src/main/resources/application.yml index 1d8fa28db..42f5d3622 100644 --- a/market/market-app/src/main/resources/application.yml +++ b/market/market-app/src/main/resources/application.yml @@ -92,7 +92,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt index ec1fba6e0..e866202e9 100644 --- a/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt +++ b/matching-gateway/matching-gateway-app/src/main/kotlin/co/nilin/opex/matching/gateway/app/service/OrderService.kt @@ -16,6 +16,7 @@ import co.nilin.opex.matching.gateway.ports.postgres.service.PairSettingService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.math.BigDecimal +import java.math.RoundingMode @Service class OrderService( diff --git a/matching-gateway/matching-gateway-app/src/main/resources/application.yml b/matching-gateway/matching-gateway-app/src/main/resources/application.yml index 7f8e263c3..0e75bcc66 100644 --- a/matching-gateway/matching-gateway-app/src/main/resources/application.yml +++ b/matching-gateway/matching-gateway-app/src/main/resources/application.yml @@ -93,7 +93,7 @@ app: url: lb://opex-accountant auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} custom-message: enabled: ${CUSTOM_MESSAGE_ENABLED:false} base-url: ${CUSTOM_MESSAGE_URL} diff --git a/otp/otp-app/src/main/resources/application.yml b/otp/otp-app/src/main/resources/application.yml index 630b2d1c3..a45b85b70 100644 --- a/otp/otp-app/src/main/resources/application.yml +++ b/otp/otp-app/src/main/resources/application.yml @@ -46,7 +46,7 @@ logging: app: auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} otp: sms: provider: diff --git a/profile/profile-app/src/main/resources/application.yml b/profile/profile-app/src/main/resources/application.yml index 5225edd91..cc91c40fb 100644 --- a/profile/profile-app/src/main/resources/application.yml +++ b/profile/profile-app/src/main/resources/application.yml @@ -55,7 +55,7 @@ app: bank-account: ${ADMIN_APPROVAL_BANK_ACCOUNT:false} auth: cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} kyc: url: lb://opex-kyc/v2/admin/kyc/internal otp: diff --git a/wallet/wallet-app/src/main/resources/application.yml b/wallet/wallet-app/src/main/resources/application.yml index 83c8b6e29..d993574e8 100644 --- a/wallet/wallet-app/src/main/resources/application.yml +++ b/wallet/wallet-app/src/main/resources/application.yml @@ -125,7 +125,7 @@ app: auth: url: lb://opex-auth cert-url: http://keycloak:8080/realms/opex/protocol/openid-connect/certs - iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080}/realms/opex + iss-url: ${TOKEN_ISSUER_URL:http://keycloak:8080/realms/opex} client-id: none client-secret: none system: