2023-11-13 08:15:19 +05:30

334 lines
15 KiB
Kotlin

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<String>) {
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<AuthTokenResponse>(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<AuthTokenResponse>(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<AuthTokenResponse>(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, String>): 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"
}
}