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) { 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()