From 43279e56bfad71950cff7bd2df19a11e71bae2e4 Mon Sep 17 00:00:00 2001 From: "gowthaman.b" Date: Mon, 13 Nov 2023 21:11:53 +0530 Subject: [PATCH] refractor things --- src/main/kotlin/com/restapi/Main.kt | 220 +----------------- src/main/kotlin/com/restapi/config/Auth.kt | 160 ++++++++++++- .../com/restapi/config/AuthTokenResponse.kt | 2 +- .../kotlin/com/restapi/config/Exceptions.kt | 66 ++++++ 4 files changed, 237 insertions(+), 211 deletions(-) create mode 100644 src/main/kotlin/com/restapi/config/Exceptions.kt diff --git a/src/main/kotlin/com/restapi/Main.kt b/src/main/kotlin/com/restapi/Main.kt index 37fbf5f..304f35b 100644 --- a/src/main/kotlin/com/restapi/Main.kt +++ b/src/main/kotlin/com/restapi/Main.kt @@ -1,22 +1,14 @@ package com.restapi -import AuthTokenResponse import com.fasterxml.jackson.databind.JsonMappingException -import com.fasterxml.jackson.module.kotlin.readValue -import com.restapi.config.Action +import com.restapi.config.* import com.restapi.config.AppConfig.Companion.appConfig -import com.restapi.config.Auth.getAuthEndpoint import com.restapi.config.Auth.validateAuthToken -import com.restapi.config.Role -import com.restapi.config.Roles import com.restapi.controllers.Entities import com.restapi.domain.DataNotFoundException -import com.restapi.domain.Session import com.restapi.domain.Session.currentTenant -import com.restapi.domain.Session.currentToken import com.restapi.domain.Session.currentUser import com.restapi.domain.Session.objectMapper -import com.restapi.domain.Session.redis import com.restapi.domain.Session.setAuthorizedUser import com.restapi.domain.Session.signPayload import io.ebean.DataIntegrityException @@ -29,13 +21,6 @@ import io.javalin.http.util.RateLimitUtil import io.javalin.json.JavalinJackson import org.jose4j.jwt.consumer.InvalidJwtException 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.security.MessageDigest import java.time.LocalDateTime import java.util.* @@ -50,7 +35,6 @@ fun main(args: Array) { val createRole = Role.Standard(Action.CREATE) val updateRole = Role.Standard(Action.UPDATE) val approveOrRejectRole = Role.Standard(Action.APPROVE) - val AUTH_TOKEN = "AUTH_TOKEN_V2" //todo, create roles in keycloak based on entity and actions @@ -76,137 +60,11 @@ fun main(args: Array) { } .routes { path("/auth") { - //for testing, development only - get("/endpoint") { - it.json(getAuthEndpoint()) - } - 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 redirectUri = it.queryParam("redirectUrl") ?: appConfig.iamClientRedirectUri() - val iamClient = it.queryParam("client") ?: appConfig.iamClient() - - val ep = getAuthEndpoint().tokenEndpoint - val httpClient = HttpClient.newHttpClient() - val req = HttpRequest.newBuilder() - .uri(URI.create(ep)) - .POST( - BodyPublishers.ofString( - getFormDataAsString( - mapOf( - "code" to code, - "redirect_uri" to redirectUri, - "client_id" to iamClient, - "grant_type" to "authorization_code", - ) - ) - ) - ) - .header("Content-Type", "application/x-www-form-urlencoded") - .build() - - val message = httpClient.send(req, BodyHandlers.ofString()).body() - val atResponse = objectMapper.readValue(message) - val parsed = validateAuthToken(atResponse.accessToken) - - //keep track of this for renewal when asked by client - redis.lpush( - "$AUTH_TOKEN${parsed.userName}", - objectMapper.writeValueAsString( - atResponse.copy( - createdAt = LocalDateTime.now() - ) - ) - ) - it.result(atResponse.accessToken).contentType(ContentType.TEXT_PLAIN) - - } - get("/keys") { - //for the UI to validate signature and response - it.json( - Session.jwk() - ) - } - - post("/refresh") { ctx -> - //refresh authToken - val authToken = ctx.header("Authorization") - ?.replace("Bearer ", "") - ?.replace("Bearer: ", "") - ?.trim() ?: throw UnauthorizedResponse() - - val authUser = validateAuthToken(authToken, skipValidate = true) - val client = ctx.queryParam("client") ?: throw BadRequestResponse("client not sent") - val redirectUri = ctx.queryParam("redirectUri") ?: throw BadRequestResponse("redirectUri not sent") - - val key = "$AUTH_TOKEN${authUser.userName}" - val found = redis.llen(key) - logger.warn("for user ${authUser.userName}, found from redis, $key => $found entries") - val foundOldAt = (0..found) - .mapNotNull { redis.lindex(key, it) } - .map { objectMapper.readValue(it) } - .firstOrNull { it.accessToken == authToken } - ?: throw BadRequestResponse("authToken not found in cache") - - val createdAt = foundOldAt.createdAt ?: throw BadRequestResponse("created at is missing") - val expiresAt = createdAt.plusSeconds(foundOldAt.expiresIn + 0L) - val rtExpiresAt = createdAt.plusSeconds(foundOldAt.refreshExpiresIn + 0L) - - val now = LocalDateTime.now() - logger.warn("can we refresh the token for ${authUser.userName}, created = $createdAt expires = $expiresAt, refresh Till = $rtExpiresAt") - - //we can refresh if at is expired, but we still have time for refresh - if (expiresAt.isBefore(now) && now.isBefore(rtExpiresAt)) { - logger.warn("We can refresh the token for ${authUser.userName}, expires = $expiresAt, refresh Till = $rtExpiresAt") - val ep = getAuthEndpoint().tokenEndpoint - val httpClient = HttpClient.newHttpClient() - val req = HttpRequest.newBuilder() - .uri(URI.create(ep)) - .POST( - BodyPublishers.ofString( - getFormDataAsString( - mapOf( - "refresh_token" to foundOldAt.refreshToken, - "redirect_uri" to redirectUri, - "client_id" to client, - "grant_type" to "refresh_token", - ) - ) - ) - ) - .header("Content-Type", "application/x-www-form-urlencoded") - .build() - val message = httpClient.send(req, BodyHandlers.ofString()).body() - val atResponse = objectMapper.readValue(message) - val parsed = validateAuthToken(atResponse.accessToken) - - redis.lpush( - "AUTH_TOKEN_${parsed.userName}", - objectMapper.writeValueAsString( - atResponse.copy(createdAt = LocalDateTime.now()) - ) - ) - - ctx.result(atResponse.accessToken).contentType(ContentType.TEXT_PLAIN) - } else { - //at is still valid - if (expiresAt.isAfter(now)) { - logger.warn("Still valid, the token for ${authUser.userName}, will expire at $expiresAt") - ctx.result(foundOldAt.accessToken).contentType(ContentType.TEXT_PLAIN) - } else { - //we have exceeded the refresh time, so we shall ask the user to login again - logger.warn("We can't refresh the token for ${authUser.userName}, as refresh-time [$rtExpiresAt] is expired") - throw UnauthorizedResponse() - } - } - } + get("/endpoint", Auth::endPoint) + get("/init", Auth::init) + get("/code", Auth::code) + get("/keys", Auth::keys) + post("/refresh", Auth::refreshToken) } before("/api/*") { ctx -> @@ -264,67 +122,13 @@ fun main(args: Array) { patch("/{entity}/{id}", Entities::patch, Roles(adminRole, updateRole)) delete("/{entity}/{id}", Entities::delete, Roles(adminRole, Role.Standard(Action.DELETE))) } - - - } - .exception(DuplicateKeyException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "Duplicate Data" - ) - ).status(HttpStatus.CONFLICT) - } - .exception(DataIntegrityException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "References Missing" - ) - ).status(HttpStatus.EXPECTATION_FAILED) - } - .exception(DataNotFoundException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "Data Not Found" - ) - ).status(HttpStatus.NOT_FOUND) - } - .exception(IllegalArgumentException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "Incorrect Data" - ) - ).status(HttpStatus.BAD_REQUEST) - } - .exception(JsonMappingException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "Incorrect Data" - ) - ).status(HttpStatus.BAD_REQUEST) - } - .exception(InvalidJwtException::class.java) { e, ctx -> - logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) - ctx.json( - mapOf( - "error" to "Login required" - ) - ).status(HttpStatus.UNAUTHORIZED) } + .exception(DuplicateKeyException::class.java, Exceptions.dupKeyExceptionHandler) + .exception(DataIntegrityException::class.java, Exceptions.dataIntegrityException) + .exception(DataNotFoundException::class.java, Exceptions.dataNotFoundException) + .exception(IllegalArgumentException::class.java,Exceptions.illegalArgumentException) + .exception(JsonMappingException::class.java, Exceptions.jsonMappingException) + .exception(InvalidJwtException::class.java, Exceptions.invalidJwtException) .start(appConfig.portNumber()) } - -private fun getFormDataAsString(formData: Map): String { - - return formData.entries.joinToString("&") { - val key = URLEncoder.encode(it.key, StandardCharsets.UTF_8) - val value = URLEncoder.encode(it.value, StandardCharsets.UTF_8) - "$key=$value" - } -} - diff --git a/src/main/kotlin/com/restapi/config/Auth.kt b/src/main/kotlin/com/restapi/config/Auth.kt index 9149de3..c86be02 100644 --- a/src/main/kotlin/com/restapi/config/Auth.kt +++ b/src/main/kotlin/com/restapi/config/Auth.kt @@ -2,23 +2,43 @@ 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.BadRequestResponse +import io.javalin.http.ContentType +import io.javalin.http.Context +import io.javalin.http.UnauthorizedResponse import io.javalin.security.RouteRole import org.jose4j.jwk.HttpsJwks import org.jose4j.jwt.consumer.JwtConsumerBuilder import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver +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.HttpResponse +import java.nio.charset.StandardCharsets import java.time.LocalDateTime import java.time.ZoneId import java.util.* import java.util.concurrent.ConcurrentHashMap + +const val AUTH_TOKEN = "AUTH_TOKEN_V2" + +private fun getFormDataAsString(formData: Map): String { + + return formData.entries.joinToString("&") { + val key = URLEncoder.encode(it.key, StandardCharsets.UTF_8) + val value = URLEncoder.encode(it.value, StandardCharsets.UTF_8) + "$key=$value" + } +} + object Auth { - + private val logger = LoggerFactory.getLogger("Auth") private val authCache = ConcurrentHashMap() fun getAuthEndpoint(): AuthEndpoint { @@ -69,9 +89,145 @@ object Auth { ) } + fun keys(ctx: Context) { + ctx.json(Session.jwk()) + } + + fun endPoint(ctx: Context) { + ctx.json(getAuthEndpoint()) + } + + fun init(ctx: Context) { + val endpoint = getAuthEndpoint().authorizationEndpoint + + val redirectUrl = + "$endpoint?response_type=code&client_id=${appConfig.iamClient()}&redirect_uri=${appConfig.iamClientRedirectUri()}&scope=profile&state=1234zyx" + ctx.redirect(redirectUrl) + } + + fun code(ctx: Context) { + val code = ctx.queryParam("code") ?: throw BadRequestResponse("not proper") + val redirectUri = ctx.queryParam("redirectUrl") ?: appConfig.iamClientRedirectUri() + val iamClient = ctx.queryParam("client") ?: appConfig.iamClient() + + val ep = getAuthEndpoint().tokenEndpoint + val httpClient = HttpClient.newHttpClient() + val req = HttpRequest.newBuilder() + .uri(URI.create(ep)) + .POST( + HttpRequest.BodyPublishers.ofString( + getFormDataAsString( + mapOf( + "code" to code, + "redirect_uri" to redirectUri, + "client_id" to iamClient, + "grant_type" to "authorization_code", + ) + ) + ) + ) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + + val message = httpClient.send(req, HttpResponse.BodyHandlers.ofString()).body() + val atResponse = objectMapper.readValue(message) + val parsed = validateAuthToken(atResponse.accessToken) + + //keep track of this for renewal when asked by client + Session.redis.lpush( + "$AUTH_TOKEN${parsed.userName}", + objectMapper.writeValueAsString( + atResponse.copy( + createdAt = LocalDateTime.now() + ) + ) + ) + ctx.result(atResponse.accessToken).contentType(ContentType.TEXT_PLAIN) + } + + fun refreshToken(ctx: Context) { + //refresh authToken + val authToken = ctx.header("Authorization") + ?.replace("Bearer ", "") + ?.replace("Bearer: ", "") + ?.trim() ?: throw UnauthorizedResponse() + + val authUser = validateAuthToken(authToken, skipValidate = true) + val client = ctx.queryParam("client") ?: throw BadRequestResponse("client not sent") + val redirectUri = ctx.queryParam("redirectUri") ?: throw BadRequestResponse("redirectUri not sent") + + val key = "$AUTH_TOKEN${authUser.userName}" + val found = Session.redis.llen(key) + logger.warn("for user ${authUser.userName}, found from redis, $key => $found entries") + val foundOldAt = (0..found) + .mapNotNull { Session.redis.lindex(key, it) } + .map { objectMapper.readValue(it) } + .firstOrNull { it.accessToken == authToken } + ?: throw BadRequestResponse("authToken not found in cache") + + val createdAt = foundOldAt.createdAt ?: throw BadRequestResponse("created at is missing") + val expiresAt = createdAt.plusSeconds(foundOldAt.expiresIn + 0L) + val rtExpiresAt = createdAt.plusSeconds(foundOldAt.refreshExpiresIn + 0L) + + val now = LocalDateTime.now() + logger.warn("can we refresh the token for ${authUser.userName}, created = $createdAt expires = $expiresAt, refresh Till = $rtExpiresAt") + + //we can refresh if at is expired, but we still have time for refresh + if (expiresAt.isBefore(now) && now.isBefore(rtExpiresAt)) { + logger.warn("We can refresh the token for ${authUser.userName}, expires = $expiresAt, refresh Till = $rtExpiresAt") + val ep = getAuthEndpoint().tokenEndpoint + val httpClient = HttpClient.newHttpClient() + val req = HttpRequest.newBuilder() + .uri(URI.create(ep)) + .POST( + HttpRequest.BodyPublishers.ofString( + getFormDataAsString( + mapOf( + "refresh_token" to foundOldAt.refreshToken, + "redirect_uri" to redirectUri, + "client_id" to client, + "grant_type" to "refresh_token", + ) + ) + ) + ) + .header("Content-Type", "application/x-www-form-urlencoded") + .build() + val message = httpClient.send(req, HttpResponse.BodyHandlers.ofString()).body() + val atResponse = objectMapper.readValue(message) + val parsed = validateAuthToken(atResponse.accessToken) + + Session.redis.lpush( + "AUTH_TOKEN_${parsed.userName}", + objectMapper.writeValueAsString( + atResponse.copy(createdAt = LocalDateTime.now()) + ) + ) + + ctx.result(atResponse.accessToken).contentType(ContentType.TEXT_PLAIN) + } else { + //at is still valid + if (expiresAt.isAfter(now)) { + logger.warn("Still valid, the token for ${authUser.userName}, will expire at $expiresAt") + ctx.result(foundOldAt.accessToken).contentType(ContentType.TEXT_PLAIN) + } else { + //we have exceeded the refresh time, so we shall ask the user to login again + logger.warn("We can't refresh the token for ${authUser.userName}, as refresh-time [$rtExpiresAt] is expired") + throw UnauthorizedResponse() + } + } + } + } -data class AuthUser(val userName: String, val tenant: String, val roles: List, val token: String, val expiry: LocalDateTime) +data class AuthUser( + val userName: String, + val tenant: String, + val roles: List, + val token: String, + val expiry: LocalDateTime +) + enum class Action { CREATE, VIEW, UPDATE, DELETE, APPROVE, ADMIN } diff --git a/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt b/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt index ca8c410..1bf52e1 100644 --- a/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt +++ b/src/main/kotlin/com/restapi/config/AuthTokenResponse.kt @@ -1,4 +1,4 @@ - +package com.restapi.config import com.fasterxml.jackson.annotation.JsonProperty import java.time.LocalDateTime diff --git a/src/main/kotlin/com/restapi/config/Exceptions.kt b/src/main/kotlin/com/restapi/config/Exceptions.kt new file mode 100644 index 0000000..49a3424 --- /dev/null +++ b/src/main/kotlin/com/restapi/config/Exceptions.kt @@ -0,0 +1,66 @@ +package com.restapi.config + +import com.fasterxml.jackson.databind.JsonMappingException +import com.restapi.domain.DataNotFoundException +import io.ebean.DataIntegrityException +import io.ebean.DuplicateKeyException +import io.javalin.http.ExceptionHandler +import io.javalin.http.HttpStatus +import org.jose4j.jwt.consumer.InvalidJwtException +import org.slf4j.LoggerFactory + +object Exceptions { + private val logger = LoggerFactory.getLogger("Exception") + val dupKeyExceptionHandler = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "Duplicate Data" + ) + ).status(HttpStatus.CONFLICT) + } + + val dataIntegrityException = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "References Missing" + ) + ).status(HttpStatus.EXPECTATION_FAILED) + } + val dataNotFoundException = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "Data Not Found" + ) + ).status(HttpStatus.NOT_FOUND) + } + val illegalArgumentException = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "Incorrect Data" + ) + ).status(HttpStatus.BAD_REQUEST) + } + + val jsonMappingException = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "Incorrect Json" + ) + ).status(HttpStatus.BAD_REQUEST) + } + + val invalidJwtException = ExceptionHandler { e, ctx -> + logger.warn("while processing ${ctx.path()}, exception ${e.message}", e) + ctx.json( + mapOf( + "error" to "Login required" + ) + ).status(HttpStatus.UNAUTHORIZED) + } + +} \ No newline at end of file