2024-01-05 12:08:27 +05:30

181 lines
7.4 KiB
Kotlin

package com.restapi
import com.fasterxml.jackson.databind.JsonMappingException
import com.restapi.config.*
import com.restapi.config.AppConfig.Companion.appConfig
import com.restapi.config.Auth.validateAuthToken
import com.restapi.controllers.Entities
import com.restapi.domain.AnonSession
import com.restapi.domain.DataNotFoundException
import com.restapi.domain.Session
import com.restapi.domain.Session.currentTenant
import com.restapi.domain.Session.currentUser
import com.restapi.domain.Session.objectMapper
import com.restapi.domain.Session.setAuthorizedUser
import com.restapi.domain.Session.signPayload
import com.restapi.domain.TenantModel
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.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)
//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") {
get("/session") {
//a simple session to keep track of anon users
val at = it.getAuthHeader()
val tenant = Session.database.find(TenantModel::class.java)
.where()
.eq("domain",it.host())
.findOne() ?: throw UnauthorizedResponse()
if(at == null){
//new session
val s = AnonSession().apply {
sessionId = UUID.randomUUID().toString()
firstSeenAt = LocalDateTime.now()
lastSeenAt = LocalDateTime.now()
tenantId = tenant.name
headerMap = it.headerMap()
}
Session.database.save(s)
it.json(s)
} else {
val s = Session.database.find(AnonSession::class.java)
.where()
.eq("sessionId", at)
.findOne() ?: throw UnauthorizedResponse()
Session.database.save(
s.apply {
lastSeenAt = LocalDateTime.now()
headerMap = it.headerMap()
}
)
it.json(s)
}
}
get("/endpoint", Auth::endPoint)
get("/init", Auth::init)
get("/code", Auth::code)
get("/keys", Auth::keys)
post("/refresh", Auth::refreshToken)
}
before("/api/*") { ctx ->
NaiveRateLimit.requestPerTimeUnit(
ctx,
appConfig.rateLimit().getOrDefault(30),
TimeUnit.MINUTES
)
val authToken = ctx.getAuthHeader() ?: throw UnauthorizedResponse()
//there are 2 scenarios, 1) auth user for admin 2) non user for flow, we need to handle both
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 send 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, 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 Context.getAuthHeader() = header("Authorization")
?.replace("Bearer ", "")
?.replace("Bearer: ", "")
?.trim()