From 1e3d9aa1f1a1dba2bc66c4ed63645fbdd0fa3122 Mon Sep 17 00:00:00 2001 From: "gowthaman.b" Date: Fri, 10 Nov 2023 14:05:58 +0530 Subject: [PATCH] make auth work --- api.http | 9 +- app-sample.properties | 7 +- build.gradle.kts | 2 + src/main/kotlin/com/restapi/Main.kt | 77 +++++++++- .../kotlin/com/restapi/config/AppConfig.kt | 21 +++ src/main/kotlin/com/restapi/config/Auth.kt | 72 ++++++++++ .../kotlin/com/restapi/config/AuthEndpoint.kt | 132 ++++++++++++++++++ .../com/restapi/config/AuthTokenResponse.kt | 23 +++ src/main/kotlin/com/restapi/domain/db.kt | 25 ++-- 9 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/restapi/config/Auth.kt create mode 100644 src/main/kotlin/com/restapi/config/AuthEndpoint.kt create mode 100644 src/main/kotlin/com/restapi/config/AuthTokenResponse.kt diff --git a/api.http b/api.http index 3b4c02a..e10c0a0 100644 --- a/api.http +++ b/api.http @@ -1,17 +1,19 @@ ### create row POST http://localhost:9001/api/vehicle Content-Type: application/json +Authorization: set-auth-token { "data": { - "number": "KA03HD6064" + "number": "KA01MU0556" }, - "uniqueIdentifier": "KA03HD6064" + "uniqueIdentifier": "KA01MU0556" } ### create row, with autogenerated identifier POST http://localhost:9001/api/log Content-Type: application/json +Authorization: set-auth-token { "data": { @@ -26,6 +28,7 @@ GET http://localhost:9001/api/vehicle/KA03HD6064 ### query row POST http://localhost:9001/api/vehicle/query Content-Type: application/json +Authorization: set-auth-token { "sql": "select sys_pk, tenant_id, deleted_on, deleted_by, deleted, version, created_at, modified_at, created_by, modified_by, data, tags, comments, unique_identifier, entity_name from data_model where data ->> 'number' = :number", @@ -37,6 +40,7 @@ Content-Type: application/json ### update field PATCH http://localhost:9001/api/vehicle/KA03HD6064 Content-Type: application/json +Authorization: set-auth-token { "key": "ownerName", @@ -47,6 +51,7 @@ Content-Type: application/json ### upate a row PUT http://localhost:9001/api/vehicle/KA03HD6064 Content-Type: application/json +Authorization: set-auth-token { "number": "KA03HD6064", diff --git a/app-sample.properties b/app-sample.properties index 61b1b23..fc24c72 100644 --- a/app-sample.properties +++ b/app-sample.properties @@ -4,4 +4,9 @@ app.cors.hosts=www.readymixerp.com,app.readymixerp.com app.db.user=postgres app.db.pass=postgres app.db.url=jdbc:postgresql://192.168.64.6/modules_app -app.db.run_migration=true \ No newline at end of file +app.db.run_migration=true +app.iam.url=https://auth.compegence.com +app.iam.realm=forewarn-dev +app.iam.client=forewarn +app.iam.client_redirect_uri=http://localhost:9001/auth/code +app.cache.redis_uri=redis://127.0.0.1:6379/0 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c66d053..f0df4ff 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,9 @@ dependencies { implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.+") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.+") + implementation("org.bitbucket.b_c:jose4j:0.9.3") implementation("org.slf4j:slf4j-simple:2.0.7") + implementation("redis.clients:jedis:5.0.2") api ("net.cactusthorn.config:config-core:0.81") kapt("net.cactusthorn.config:config-compiler:0.81") kapt("io.ebean:kotlin-querybean-generator:13.23.2") diff --git a/src/main/kotlin/com/restapi/Main.kt b/src/main/kotlin/com/restapi/Main.kt index a2e6ee2..35d54ad 100644 --- a/src/main/kotlin/com/restapi/Main.kt +++ b/src/main/kotlin/com/restapi/Main.kt @@ -1,7 +1,12 @@ package com.restapi +import AuthTokenResponse import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.module.kotlin.readValue import com.restapi.config.AppConfig.Companion.appConfig +import com.restapi.config.Auth +import com.restapi.config.Auth.getAuthEndpoint +import com.restapi.config.AuthEndpoint import com.restapi.domain.DataModel import com.restapi.domain.DataNotFoundException import com.restapi.domain.Session @@ -9,6 +14,9 @@ import com.restapi.domain.Session.creatSeq import com.restapi.domain.Session.database import com.restapi.domain.Session.findByEntityAndId import com.restapi.domain.Session.nextUniqId +import com.restapi.domain.Session.objectMapper +import com.restapi.domain.Session.redis +import com.restapi.domain.Session.setAuthorizedUser import io.ebean.CallableSql import io.ebean.DuplicateKeyException import io.ebean.RawSqlBuilder @@ -17,10 +25,18 @@ import io.javalin.apibuilder.ApiBuilder.* import io.javalin.http.* import io.javalin.json.JavalinJackson import org.slf4j.LoggerFactory +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.BodyHandlers +import java.nio.charset.StandardCharsets import java.time.LocalDateTime fun main(args: Array) { val logger = LoggerFactory.getLogger("api") + Javalin .create { cfg -> cfg.http.generateEtags = true @@ -39,7 +55,47 @@ fun main(args: Array) { cfg.jsonMapper(JavalinJackson(Session.objectMapper)) } .routes { - before("/*") { ctx -> + + path("/auth") { + get("/init") { + val endpoint = getAuthEndpoint().authorizationEndpoint + + val redirectUrl = + "$endpoint?response_type=code&client_id=${appConfig.iamClient()}&redirect_uri=${appConfig.iamClientRedirectUri()}&scope=profile&state=1234zyx" + it.redirect(redirectUrl) + } + get("/code") { + + val code = it.queryParam("code") ?: throw BadRequestResponse("not proper") + + val ep = getAuthEndpoint().tokenEndpoint + val client = HttpClient.newHttpClient() + val req = HttpRequest.newBuilder() + .uri(URI.create(ep)) + .POST( + BodyPublishers.ofString( + getFormDataAsString( + mapOf( + "code" to code, + "redirect_uri" to appConfig.iamClientRedirectUri(), + "client_id" to appConfig.iamClient(), + "grant_type" to "authorization_code", + ) + ) + ) + ) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + val message = client.send(req, BodyHandlers.ofString()).body() + val atResponse = objectMapper.readValue(message) + + //lets keep auth token refreshed + redis.sadd("AUTH_TOKEN", message) + it.result(atResponse.accessToken).contentType(ContentType.TEXT_PLAIN) + + } + } + before("/api/*") { ctx -> //validate, auth token //allow only alpha, numeric, hypen, underscore, dot in paths @@ -51,6 +107,12 @@ fun main(args: Array) { throw IllegalArgumentException() } } + + val at = ctx.header("Authorization")?.replace("Bearer ", "")?.replace("Bearer: ", "")?.trim() + ?: throw UnauthorizedResponse() + val pt = Auth.parseAuthToken(authToken = at) + + setAuthorizedUser(pt) } path("/api") { post("/execute/{name}") { @@ -174,4 +236,17 @@ data class Query( val params: Map ) +private fun getFormDataAsString(formData: Map): String { + val formBodyBuilder = StringBuilder() + for ((key, value) in formData) { + if (formBodyBuilder.length > 0) { + formBodyBuilder.append("&") + } + formBodyBuilder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + formBodyBuilder.append("=") + formBodyBuilder.append(URLEncoder.encode(value, StandardCharsets.UTF_8)) + } + return formBodyBuilder.toString() +} + data class PatchValue(val key: String, val value: Any) \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/config/AppConfig.kt b/src/main/kotlin/com/restapi/config/AppConfig.kt index 5cf8687..b857878 100644 --- a/src/main/kotlin/com/restapi/config/AppConfig.kt +++ b/src/main/kotlin/com/restapi/config/AppConfig.kt @@ -5,6 +5,7 @@ import net.cactusthorn.config.core.Default import net.cactusthorn.config.core.Key import net.cactusthorn.config.core.factory.ConfigFactory import net.cactusthorn.config.core.loader.LoadStrategy +import java.util.Optional const val INITIAL_ROLES_JSON = """{ "roles": [] @@ -41,6 +42,26 @@ interface AppConfig { @Key("app.db.run_migration") fun dbRunMigration(): Boolean + @Key("app.iam.url") + fun iamUrl(): String + + @Key("app.iam.realm") + fun iamRealm(): String + + @Key("app.iam.client") + fun iamClient(): String + + @Key("app.iam.client_redirect_uri") + fun iamClientRedirectUri(): String + + @Key("app.iam.client_id") + fun iamClientId(): Optional + + @Key("app.iam.client_secret") + fun iamClientSecret(): Optional + + @Key("app.cache.redis_uri") + fun redisUri(): Optional companion object { val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java) diff --git a/src/main/kotlin/com/restapi/config/Auth.kt b/src/main/kotlin/com/restapi/config/Auth.kt new file mode 100644 index 0000000..f402355 --- /dev/null +++ b/src/main/kotlin/com/restapi/config/Auth.kt @@ -0,0 +1,72 @@ +package com.restapi.config + +import com.fasterxml.jackson.module.kotlin.readValue +import com.restapi.config.AppConfig.Companion.appConfig +import com.restapi.domain.Session +import com.restapi.domain.Session.objectMapper +import io.javalin.http.Context +import io.javalin.http.HandlerType +import io.javalin.http.UnauthorizedResponse +import org.jose4j.jwk.HttpsJwks +import org.jose4j.jwt.consumer.ErrorCodes +import org.jose4j.jwt.consumer.InvalidJwtException +import org.jose4j.jwt.consumer.JwtConsumerBuilder +import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver +import org.slf4j.LoggerFactory +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Function + +object Auth { + + + private val authCache = ConcurrentHashMap() + + fun getAuthEndpoint(): AuthEndpoint { + return authCache.computeIfAbsent("AUTH") { + val wellKnown = + "${appConfig.iamUrl()}/realms/${appConfig.iamRealm()}/.well-known/openid-configuration" + val client = HttpClient.newHttpClient() + + val req = HttpRequest.newBuilder() + .uri(URI.create(wellKnown)) + .GET().build() + + objectMapper.readValue( + client.send(req, HttpResponse.BodyHandlers.ofString()).body() + ) + } + + } + + private val jwtConsumer = JwtConsumerBuilder() + .setRequireExpirationTime() + .setAllowedClockSkewInSeconds(30) + .setRequireSubject() + .setExpectedIssuer(getAuthEndpoint().issuer) + .setExpectedAudience("account") + .setVerificationKeyResolver(HttpsJwksVerificationKeyResolver(HttpsJwks(getAuthEndpoint().jwksUri))) + .build() + + + fun parseAuthToken(authToken: String): AuthUser { + + // Validate the JWT and process it to the Claims + val jwtClaims = jwtConsumer.process(authToken) + val userId = jwtClaims.jwtClaims.claimsMap["preferred_username"] as String + val tenant = jwtClaims.jwtClaims.claimsMap["tenant"] as String + val roles = ((jwtClaims.jwtClaims.claimsMap["realm_access"] as Map)["roles"]) as List + + return AuthUser( + userName = userId, + tenant = tenant, + roles = roles + ) + } + +} + +data class AuthUser(val userName: String, val tenant: String, val roles: List) \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/config/AuthEndpoint.kt b/src/main/kotlin/com/restapi/config/AuthEndpoint.kt new file mode 100644 index 0000000..d3fa2d1 --- /dev/null +++ b/src/main/kotlin/com/restapi/config/AuthEndpoint.kt @@ -0,0 +1,132 @@ +package com.restapi.config + + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AuthEndpoint( + @JsonProperty("acr_values_supported") + val acrValuesSupported: List, + @JsonProperty("authorization_encryption_alg_values_supported") + val authorizationEncryptionAlgValuesSupported: List, + @JsonProperty("authorization_encryption_enc_values_supported") + val authorizationEncryptionEncValuesSupported: List, + @JsonProperty("authorization_endpoint") + val authorizationEndpoint: String, + @JsonProperty("authorization_signing_alg_values_supported") + val authorizationSigningAlgValuesSupported: List, + @JsonProperty("backchannel_authentication_endpoint") + val backchannelAuthenticationEndpoint: String, + @JsonProperty("backchannel_authentication_request_signing_alg_values_supported") + val backchannelAuthenticationRequestSigningAlgValuesSupported: List, + @JsonProperty("backchannel_logout_session_supported") + val backchannelLogoutSessionSupported: Boolean, + @JsonProperty("backchannel_logout_supported") + val backchannelLogoutSupported: Boolean, + @JsonProperty("backchannel_token_delivery_modes_supported") + val backchannelTokenDeliveryModesSupported: List, + @JsonProperty("check_session_iframe") + val checkSessionIframe: String, + @JsonProperty("claim_types_supported") + val claimTypesSupported: List, + @JsonProperty("claims_parameter_supported") + val claimsParameterSupported: Boolean, + @JsonProperty("claims_supported") + val claimsSupported: List, + @JsonProperty("code_challenge_methods_supported") + val codeChallengeMethodsSupported: List, + @JsonProperty("device_authorization_endpoint") + val deviceAuthorizationEndpoint: String, + @JsonProperty("end_session_endpoint") + val endSessionEndpoint: String, + @JsonProperty("frontchannel_logout_session_supported") + val frontchannelLogoutSessionSupported: Boolean, + @JsonProperty("frontchannel_logout_supported") + val frontchannelLogoutSupported: Boolean, + @JsonProperty("grant_types_supported") + val grantTypesSupported: List, + @JsonProperty("id_token_encryption_alg_values_supported") + val idTokenEncryptionAlgValuesSupported: List, + @JsonProperty("id_token_encryption_enc_values_supported") + val idTokenEncryptionEncValuesSupported: List, + @JsonProperty("id_token_signing_alg_values_supported") + val idTokenSigningAlgValuesSupported: List, + @JsonProperty("introspection_endpoint") + val introspectionEndpoint: String, + @JsonProperty("introspection_endpoint_auth_methods_supported") + val introspectionEndpointAuthMethodsSupported: List, + @JsonProperty("introspection_endpoint_auth_signing_alg_values_supported") + val introspectionEndpointAuthSigningAlgValuesSupported: List, + @JsonProperty("issuer") + val issuer: String, + @JsonProperty("jwks_uri") + val jwksUri: String, + @JsonProperty("mtls_endpoint_aliases") + val mtlsEndpointAliases: MtlsEndpointAliases, + @JsonProperty("pushed_authorization_request_endpoint") + val pushedAuthorizationRequestEndpoint: String, + @JsonProperty("registration_endpoint") + val registrationEndpoint: String, + @JsonProperty("request_object_encryption_alg_values_supported") + val requestObjectEncryptionAlgValuesSupported: List, + @JsonProperty("request_object_encryption_enc_values_supported") + val requestObjectEncryptionEncValuesSupported: List, + @JsonProperty("request_object_signing_alg_values_supported") + val requestObjectSigningAlgValuesSupported: List, + @JsonProperty("request_parameter_supported") + val requestParameterSupported: Boolean, + @JsonProperty("request_uri_parameter_supported") + val requestUriParameterSupported: Boolean, + @JsonProperty("require_pushed_authorization_requests") + val requirePushedAuthorizationRequests: Boolean, + @JsonProperty("require_request_uri_registration") + val requireRequestUriRegistration: Boolean, + @JsonProperty("response_modes_supported") + val responseModesSupported: List, + @JsonProperty("response_types_supported") + val responseTypesSupported: List, + @JsonProperty("revocation_endpoint") + val revocationEndpoint: String, + @JsonProperty("revocation_endpoint_auth_methods_supported") + val revocationEndpointAuthMethodsSupported: List, + @JsonProperty("revocation_endpoint_auth_signing_alg_values_supported") + val revocationEndpointAuthSigningAlgValuesSupported: List, + @JsonProperty("scopes_supported") + val scopesSupported: List, + @JsonProperty("subject_types_supported") + val subjectTypesSupported: List, + @JsonProperty("tls_client_certificate_bound_access_tokens") + val tlsClientCertificateBoundAccessTokens: Boolean, + @JsonProperty("token_endpoint") + val tokenEndpoint: String, + @JsonProperty("token_endpoint_auth_methods_supported") + val tokenEndpointAuthMethodsSupported: List, + @JsonProperty("token_endpoint_auth_signing_alg_values_supported") + val tokenEndpointAuthSigningAlgValuesSupported: List, + @JsonProperty("userinfo_encryption_alg_values_supported") + val userinfoEncryptionAlgValuesSupported: List, + @JsonProperty("userinfo_encryption_enc_values_supported") + val userinfoEncryptionEncValuesSupported: List, + @JsonProperty("userinfo_endpoint") + val userinfoEndpoint: String, + @JsonProperty("userinfo_signing_alg_values_supported") + val userinfoSigningAlgValuesSupported: List +) { + data class MtlsEndpointAliases( + @JsonProperty("backchannel_authentication_endpoint") + val backchannelAuthenticationEndpoint: String, + @JsonProperty("device_authorization_endpoint") + val deviceAuthorizationEndpoint: String, + @JsonProperty("introspection_endpoint") + val introspectionEndpoint: String, + @JsonProperty("pushed_authorization_request_endpoint") + val pushedAuthorizationRequestEndpoint: String, + @JsonProperty("registration_endpoint") + val registrationEndpoint: String, + @JsonProperty("revocation_endpoint") + val revocationEndpoint: String, + @JsonProperty("token_endpoint") + val tokenEndpoint: String, + @JsonProperty("userinfo_endpoint") + val userinfoEndpoint: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt b/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt new file mode 100644 index 0000000..7d6ad98 --- /dev/null +++ b/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt @@ -0,0 +1,23 @@ + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.LocalDateTime + +data class AuthTokenResponse( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("expires_in") + val expiresIn: Int, + @JsonProperty("not-before-policy") + val notBeforePolicy: Int, + @JsonProperty("refresh_expires_in") + val refreshExpiresIn: Int, + @JsonProperty("refresh_token") + val refreshToken: String, + @JsonProperty("scope") + val scope: String, + @JsonProperty("session_state") + val sessionState: String, + @JsonProperty("token_type") + val tokenType: String, + val createdAt: LocalDateTime = LocalDateTime.now() +) \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/domain/db.kt b/src/main/kotlin/com/restapi/domain/db.kt index a80867b..52b71c2 100644 --- a/src/main/kotlin/com/restapi/domain/db.kt +++ b/src/main/kotlin/com/restapi/domain/db.kt @@ -6,27 +6,26 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.restapi.config.AppConfig.Companion.appConfig +import com.restapi.config.AuthUser import io.ebean.Database import io.ebean.DatabaseFactory import io.ebean.config.CurrentTenantProvider import io.ebean.config.CurrentUserProvider import io.ebean.config.DatabaseConfig import io.ebean.config.TenantMode +import redis.clients.jedis.JedisPooled import java.util.* - -data class CurrentUser( - val anon: Boolean = true, - val userId: String = "", - val tenantId: String = "" -) +import kotlin.jvm.optionals.getOrDefault object Session { - private val currentUser = object : ThreadLocal() { - override fun initialValue(): CurrentUser { - return CurrentUser() + private val currentUser = object : ThreadLocal() { + override fun initialValue(): AuthUser { + return AuthUser("", "", emptyList()) } } + fun setAuthorizedUser(a: AuthUser) = currentUser.set(a) + private val sc = DatabaseConfig().apply { loadFromProperties(Properties().apply { setProperty("datasource.db.username", appConfig.dbUser()) @@ -35,8 +34,8 @@ object Session { setProperty("ebean.migration.run", appConfig.dbRunMigration().toString()) }) tenantMode = TenantMode.PARTITION - currentTenantProvider = CurrentTenantProvider { currentUser.get().tenantId } - currentUserProvider = CurrentUserProvider { currentUser.get().userId } + currentTenantProvider = CurrentTenantProvider { currentUser.get().tenant } + currentUserProvider = CurrentUserProvider { currentUser.get().userName } } val database: Database = DatabaseFactory.create(sc) @@ -46,7 +45,7 @@ object Session { findAndRegisterModules() } - fun currentUser() = currentUser.get().userId + fun currentUser() = currentUser.get().userName fun Database.findByEntityAndId(entity: String, id: String): DataModel { return find(DataModel::class.java) @@ -68,6 +67,8 @@ object Session { return String.format("%s-%s", entity, "$s".padStart(10, '0')) } + val redis = JedisPooled(appConfig.redisUri().getOrDefault("redis://localhost:6739/0")) + } object DataNotFoundException : Exception() {