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.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 import io.ebean.DuplicateKeyException import io.javalin.Javalin import io.javalin.apibuilder.ApiBuilder.* import io.javalin.http.* import io.javalin.http.util.NaiveRateLimit 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.* import java.util.concurrent.TimeUnit import kotlin.jvm.optionals.getOrDefault fun main(args: Array) { val logger = LoggerFactory.getLogger("api") val adminRole = Role.Standard(Action.ADMIN) val viewRole = Role.Standard(Action.VIEW) 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 //ratelimit based on IP Only RateLimitUtil.keyFunction = { ctx -> ctx.header("X-Forwarded-For")?.split(",")?.get(0) ?: ctx.ip() } Javalin .create { cfg -> cfg.http.generateEtags = true if (appConfig.corsEnabled()) { cfg.plugins.enableCors { container -> container.add { it.allowHost( "http://localhost:5173", *appConfig.corsHosts().toTypedArray() ) } } } cfg.http.defaultContentType = ContentType.JSON cfg.compression.gzipOnly() cfg.jsonMapper(JavalinJackson(objectMapper)) cfg.accessManager(AppAccessManager()) } .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) 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() } } } } before("/api/*") { ctx -> NaiveRateLimit.requestPerTimeUnit( ctx, appConfig.rateLimit().getOrDefault(30), TimeUnit.MINUTES ) val authToken = ctx.header("Authorization") ?.replace("Bearer ", "") ?.replace("Bearer: ", "") ?.trim() ?: throw UnauthorizedResponse() setAuthorizedUser(validateAuthToken(authToken = authToken)) if (appConfig.enforcePayloadEncryption()) { //todo: decrypt the request from user } } after("/api/*") { val md = MessageDigest.getInstance("SHA-512") md.update((it.result() ?: "").toByteArray()) val aMessageDigest = md.digest() val outEncoded: String = Base64.getEncoder().encodeToString(aMessageDigest) it.header("X-Checksum", outEncoded) it.header("X-Signature", signPayload(outEncoded)) if (appConfig.enforcePayloadEncryption()) { //todo:, encrypt and set the response back to user } } path("/api") { post("/audit/{action}") { logger.warn("User ${currentUser()} of tenant ${currentTenant()} has performed ${it.pathParam("action")} @ ${LocalDateTime.now()}") it.json(mapOf("status" to true)) } post("/script/database/{name}", Entities::executeStoredProcedure, Roles(adminRole, Role.DbOps)) post("/script/{file}/{name}", Entities::executeScript, Roles(adminRole, Role.DbOps)) get("/{entity}/{id}", Entities::view, Roles(adminRole, viewRole)) post("/{entity}/query/{id}", Entities::sqlQueryById, Roles(adminRole, viewRole)) post("/{entity}/query", Entities::sqlQueryRaw, Roles(adminRole, viewRole)) post("/{entity}", Entities::create, Roles(adminRole, createRole)) put("/{entity}/approve/{id}", Entities::approve, Roles(adminRole, approveOrRejectRole)) put("/{entity}/reject/{id}", Entities::reject, Roles(adminRole, approveOrRejectRole)) put("/{entity}/{action}/{id}", Entities::action, Roles(adminRole, Role.Entity)) put("/{entity}/{id}", Entities::update, Roles(adminRole, updateRole)) 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) } .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" } }