From 10813529f254fe123e03041f3f35d920cf941c8d Mon Sep 17 00:00:00 2001 From: "gowthaman.b" Date: Sun, 12 Nov 2023 08:25:52 +0530 Subject: [PATCH] start adding signature and encryption --- build.gradle.kts | 2 + .../kotlin/com/restapi/AppAccessManager.kt | 2 + src/main/kotlin/com/restapi/Main.kt | 52 ++++---- .../kotlin/com/restapi/config/AppConfig.kt | 14 +++ src/main/kotlin/com/restapi/config/Auth.kt | 24 ++-- .../com/restapi/controllers/Entities.kt | 86 ++++++++++--- src/main/kotlin/com/restapi/domain/db.kt | 116 +++++++++++++++++- .../kotlin/com/restapi/integ/Scripting.kt | 16 +-- src/main/resources/scripts/vehicle.kts | 6 +- 9 files changed, 254 insertions(+), 64 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 78d7499..76b961a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation("redis.clients:jedis:5.0.2") implementation("org.jetbrains.kotlin:kotlin-scripting-jsr223:1.9.20") implementation("org.jetbrains.kotlin:kotlin-script-runtime:1.9.20") + implementation("org.bouncycastle:bcprov-jdk18on:1.76") + implementation("org.bouncycastle:bcpkix-jdk18on:1.76") api ("net.cactusthorn.config:config-core:0.81") kapt("net.cactusthorn.config:config-compiler:0.81") kapt("io.ebean:kotlin-querybean-generator:13.23.2") diff --git a/src/main/kotlin/com/restapi/AppAccessManager.kt b/src/main/kotlin/com/restapi/AppAccessManager.kt index 813fdc6..f59c7d8 100644 --- a/src/main/kotlin/com/restapi/AppAccessManager.kt +++ b/src/main/kotlin/com/restapi/AppAccessManager.kt @@ -1,6 +1,8 @@ package com.restapi import com.restapi.config.AppConfig.Companion.appConfig +import com.restapi.config.Role +import com.restapi.config.Roles import com.restapi.domain.EntityModel import io.javalin.http.Context import io.javalin.http.Handler diff --git a/src/main/kotlin/com/restapi/Main.kt b/src/main/kotlin/com/restapi/Main.kt index 5a4a5fb..73be2e7 100644 --- a/src/main/kotlin/com/restapi/Main.kt +++ b/src/main/kotlin/com/restapi/Main.kt @@ -3,11 +3,15 @@ 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.parseAuthToken +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.objectMapper import com.restapi.domain.Session.redis import com.restapi.domain.Session.setAuthorizedUser @@ -19,7 +23,6 @@ import io.javalin.http.* import io.javalin.http.util.NaiveRateLimit import io.javalin.http.util.RateLimitUtil import io.javalin.json.JavalinJackson -import io.javalin.security.RouteRole import org.jose4j.jwt.consumer.InvalidJwtException import org.slf4j.LoggerFactory import java.net.URI @@ -34,6 +37,11 @@ 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) //ratelimit based on IP Only RateLimitUtil.keyFunction = { ctx -> ctx.header("X-Forwarded-For")?.split(",")?.get(0) ?: ctx.ip() } @@ -58,6 +66,7 @@ fun main(args: Array) { .routes { path("/auth") { + //for testing, development only get("/init") { val endpoint = getAuthEndpoint().authorizationEndpoint @@ -110,18 +119,26 @@ fun main(args: Array) { ?.replace("Bearer: ", "") ?.trim() ?: throw UnauthorizedResponse() - setAuthorizedUser(parseAuthToken(authToken = authToken)) + setAuthorizedUser(validateAuthToken(authToken = authToken)) + + if(appConfig.enforcePayloadEncryption()){ + //todo: decrypt the request from user + } + } + after("/api/*") { + + it.header("X-Signature", Session.sign(it.body())) + + if(appConfig.enforcePayloadEncryption()){ + //todo:, encrypt and set the response back to user + } + } - 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) path("/api") { - post("/execute/{name}", Entities::executeStoredProcedure, Roles(adminRole, Role.DbOps)) - post("/script/{name}", Entities::executeScript, Roles(adminRole, Role.DbOps)) + 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)) @@ -191,23 +208,10 @@ fun main(args: Array) { } -enum class Action { - CREATE, VIEW, UPDATE, DELETE, APPROVE, ADMIN -} - -sealed class Role { - open class Standard(vararg val action: Action) : Role() - data object Entity : Role() - data object DbOps : Role() -} - -open class Roles(vararg val roles: Role) : RouteRole - - private fun getFormDataAsString(formData: Map): String { val formBodyBuilder = StringBuilder() for ((key, value) in formData) { - if (formBodyBuilder.length > 0) { + if (formBodyBuilder.isNotEmpty()) { formBodyBuilder.append("&") } formBodyBuilder.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) diff --git a/src/main/kotlin/com/restapi/config/AppConfig.kt b/src/main/kotlin/com/restapi/config/AppConfig.kt index 9e09776..9aed7a2 100644 --- a/src/main/kotlin/com/restapi/config/AppConfig.kt +++ b/src/main/kotlin/com/restapi/config/AppConfig.kt @@ -22,6 +22,10 @@ interface AppConfig { @Default("false") fun corsEnabled(): Boolean + @Key("app.name") + @Default("RestAPI") + fun appName(): String + @Key("app.port") @Default("9001") fun portNumber(): Int @@ -73,9 +77,19 @@ interface AppConfig { @Default("true") fun enforceRoleRestriction(): Boolean + @Key("app.security.enforce_payload_encryption") + @Default("false") + fun enforcePayloadEncryption(): Boolean + @Key("app.scripts.path") fun scriptsPath(): String + @Key("app.security.private_key") + fun privateKey(): Optional + + @Key("app.security.public_key") + fun publicKey(): Optional + companion object { val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java) } diff --git a/src/main/kotlin/com/restapi/config/Auth.kt b/src/main/kotlin/com/restapi/config/Auth.kt index f402355..3cf58c7 100644 --- a/src/main/kotlin/com/restapi/config/Auth.kt +++ b/src/main/kotlin/com/restapi/config/Auth.kt @@ -2,23 +2,16 @@ 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.Context -import io.javalin.http.HandlerType -import io.javalin.http.UnauthorizedResponse +import io.javalin.security.RouteRole import org.jose4j.jwk.HttpsJwks -import org.jose4j.jwt.consumer.ErrorCodes -import org.jose4j.jwt.consumer.InvalidJwtException import org.jose4j.jwt.consumer.JwtConsumerBuilder import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver -import org.slf4j.LoggerFactory import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.util.concurrent.ConcurrentHashMap -import java.util.function.Function object Auth { @@ -52,7 +45,7 @@ object Auth { .build() - fun parseAuthToken(authToken: String): AuthUser { + fun validateAuthToken(authToken: String): AuthUser { // Validate the JWT and process it to the Claims val jwtClaims = jwtConsumer.process(authToken) @@ -69,4 +62,15 @@ object Auth { } -data class AuthUser(val userName: String, val tenant: String, val roles: List) \ No newline at end of file +data class AuthUser(val userName: String, val tenant: String, val roles: List) +enum class Action { + CREATE, VIEW, UPDATE, DELETE, APPROVE, ADMIN +} + +sealed class Role { + open class Standard(vararg val action: Action) : Role() + data object Entity : Role() + data object DbOps : Role() +} + +open class Roles(vararg val roles: Role) : RouteRole \ No newline at end of file diff --git a/src/main/kotlin/com/restapi/controllers/Entities.kt b/src/main/kotlin/com/restapi/controllers/Entities.kt index ac00875..d376eb5 100644 --- a/src/main/kotlin/com/restapi/controllers/Entities.kt +++ b/src/main/kotlin/com/restapi/controllers/Entities.kt @@ -1,9 +1,7 @@ package com.restapi.controllers -import com.restapi.domain.ApprovalStatus -import com.restapi.domain.DataModel -import com.restapi.domain.EntityModel -import com.restapi.domain.Session +import com.restapi.domain.* +import com.restapi.domain.Session.currentUser import com.restapi.domain.Session.database import com.restapi.domain.Session.findByEntityAndId import com.restapi.integ.Scripting @@ -14,17 +12,24 @@ import io.javalin.http.Context import io.javalin.http.NotFoundResponse import io.javalin.http.bodyAsClass import org.slf4j.LoggerFactory +import java.sql.Types import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.format.DateTimeFormatter -data class PatchValue(val key: String, val value: Any) data class Query( val sql: String, val params: Map ) +enum class ResultType { + INTEGER, DECIMAL, STRING, DATETIME, ARRAY, OBJECT +} + +data class RejectAction(val reason: String) +data class StoredProcedure(val input: Map, val output: Map = hashMapOf()) + object Entities { private val logger = LoggerFactory.getLogger("Entities") fun delete(ctx: Context) { @@ -37,47 +42,90 @@ object Entities { fun patch(ctx: Context) { val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) - val pv = ctx.bodyAsClass() - e.data[pv.key] = pv.value; + val pv = ctx.bodyAsClass>() + pv.forEach { (key, value) -> + e.data[key] = value; + } + e.update() } fun update(ctx: Context) { + val purgeExisting = ctx.queryParam("purge")?.toBooleanStrictOrNull() == true val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + val newData = ctx.bodyAsClass>() + if (purgeExisting) { + e.data.clear(); + } e.data.putAll(newData) + e.update() } fun action(ctx: Context) {} - fun approve(ctx: Context) {} - fun reject(ctx: Context) {} + fun approve(ctx: Context) { + approveOrReject(ctx, ApprovalStatus.APPROVED) + } + + fun reject(ctx: Context) { + approveOrReject(ctx, ApprovalStatus.REJECTED) + } + + private fun approveOrReject(ctx: Context, rejected: ApprovalStatus) { + val e = database.findByEntityAndId(ctx.pathParam("entity"), ctx.pathParam("id")) + val reject = ctx.bodyAsClass() + e.approvalStatus = rejected + e.comments.add(Comments(text = reject.reason, by = currentUser())) + e.save() + } fun executeScript(ctx: Context) { val name = ctx.pathParam("name") + val file = ctx.pathParam("file") val params = ctx.bodyAsClass>() ctx.json( - Scripting. - execute(name, "execute", params, logger) + Scripting.execute(file, name, params) ) } fun executeStoredProcedure(ctx: Context) { val name = ctx.pathParam("name") - val params = ctx.bodyAsClass>() - val placeholders = (0..params.entries.size + 1).joinToString(",") { "?" } + val sp = ctx.bodyAsClass() + + val inputParams = sp.input.entries.toList() + val outputParams = sp.output.entries.toList() + + val placeholders = (0..inputParams.size + 1).joinToString(",") { "?" } val sql = "{call $name($placeholders)}" val cs: CallableSql = database.createCallableSql(sql) - params.entries.forEachIndexed { index, entry -> + inputParams.forEachIndexed { index, entry -> cs.setParameter(index + 1, entry.value) } - cs.setParameter(params.entries.size + 1, Session.currentTenant()) + cs.setParameter(inputParams.size + 1, Session.currentTenant()) + + outputParams.forEachIndexed { idx, entry -> + when (entry.value) { + ResultType.INTEGER -> cs.registerOut(idx + 1, Types.INTEGER) + ResultType.DECIMAL -> cs.registerOut(idx + 1, Types.DOUBLE) + ResultType.STRING -> cs.registerOut(idx + 1, Types.VARCHAR) + ResultType.DATETIME -> cs.registerOut(idx + 1, Types.DATE) + ResultType.ARRAY -> cs.registerOut(idx + 1, Types.ARRAY) + ResultType.OBJECT -> cs.registerOut(idx + 1, Types.JAVA_OBJECT) + } + + } val done = database.execute(cs) + val output = outputParams.mapIndexed { index, entry -> + Pair(entry.key, cs.getObject(index + 1)) + }.toMap() + ctx.json( mapOf( - "done" to done + "done" to done, + "output" to output ) ) } @@ -204,7 +252,7 @@ object Entities { } } if (!setupEntity.preSaveScript.isNullOrEmpty()) { - val ok = Scripting.preSave(setupEntity.preSaveScript!!, "preSave", this, logger) as Boolean + val ok = Scripting.execute(setupEntity.preSaveScript!!, "preSave", this) as Boolean if (!ok) { throw BadRequestResponse("PreSave Failed") } @@ -219,6 +267,10 @@ object Entities { } } ) + + if (setupEntity != null && !setupEntity.postSaveScript.isNullOrEmpty()) { + Scripting.execute(setupEntity.postSaveScript!!, "postSave", dataModel) + } } private fun isValidDate(f: String) = try { diff --git a/src/main/kotlin/com/restapi/domain/db.kt b/src/main/kotlin/com/restapi/domain/db.kt index 9d5f5d8..3634249 100644 --- a/src/main/kotlin/com/restapi/domain/db.kt +++ b/src/main/kotlin/com/restapi/domain/db.kt @@ -1,9 +1,7 @@ package com.restapi.domain import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.Module import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.restapi.config.AppConfig.Companion.appConfig import com.restapi.config.AuthUser @@ -13,26 +11,138 @@ import io.ebean.config.CurrentTenantProvider import io.ebean.config.CurrentUserProvider import io.ebean.config.DatabaseConfig import io.ebean.config.TenantMode +import org.bouncycastle.openssl.jcajce.JcaPEMWriter +import org.bouncycastle.util.io.pem.PemReader +import org.jose4j.jwk.PublicJsonWebKey +import org.jose4j.jwk.RsaJsonWebKey +import org.jose4j.jwk.RsaJwkGenerator +import org.jose4j.jws.AlgorithmIdentifiers +import org.jose4j.jws.JsonWebSignature +import org.slf4j.LoggerFactory import redis.clients.jedis.JedisPooled +import java.io.StringReader +import java.io.StringWriter +import java.security.KeyFactory +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec import java.util.* import kotlin.jvm.optionals.getOrDefault object Session { + private val KEY_ID = "${appConfig.appName()}-KEY" + private val logger = LoggerFactory.getLogger("session") private val currentUser = object : ThreadLocal() { override fun initialValue(): AuthUser { return AuthUser("", "", emptyList()) } } + fun setAuthorizedUser(a: AuthUser) = currentUser.set(a) + //if not passed in ENV, then we shall generate and print + private fun makeRsaJsonWebKey(publicKey: String, privateKey: String): RsaJsonWebKey { + + val newPublicKey = readPublicKey(publicKey) + val newPrivateKey = readPrivateKey(privateKey) + val rsa = PublicJsonWebKey.Factory.newPublicJwk(newPublicKey) as RsaJsonWebKey + rsa.privateKey = newPrivateKey + rsa.keyId = KEY_ID + return rsa + } + + private val keyFactory = KeyFactory.getInstance("RSA") + + private fun readPublicKey(file: String): RSAPublicKey { + StringReader(file).use { keyReader -> + PemReader(keyReader).use { pemReader -> + val pemObject = pemReader.readPemObject() + val content = pemObject.content + val pubKeySpec = X509EncodedKeySpec(content) + return keyFactory.generatePublic(pubKeySpec) as RSAPublicKey + } + } + } + + private fun readPrivateKey(file: String): RSAPrivateKey { + StringReader(file).use { keyReader -> + PemReader(keyReader).use { pemReader -> + val pemObject = pemReader.readPemObject() + val content = pemObject.content + val privKeySpec = PKCS8EncodedKeySpec(content) + return keyFactory.generatePrivate(privKeySpec) as RSAPrivateKey + } + } + } + + private val keypair: RsaJsonWebKey by lazy { + if (appConfig.privateKey().isPresent && appConfig.publicKey().isPresent) { + makeRsaJsonWebKey( + appConfig.publicKey().get(), + appConfig.privateKey().get() + ) + + } else { + RsaJwkGenerator.generateJwk(2048).apply { + keyId = KEY_ID + logger.warn("Save this PrivateKey/PublicKey and pass it in the Config File from next time") + StringWriter().use { s -> + JcaPEMWriter(s).use { p -> + p.writeObject(privateKey) + } + logger.warn("Private Key\n${s.toString()}") + + } + + StringWriter().use { s -> + JcaPEMWriter(s).use { p -> + p.writeObject(publicKey) + } + + logger.warn("Public Key\n${s.toString()}") + } + } + } + + } + + fun sign(payload: String): String { + + // Create a new JsonWebSignature + val jws = JsonWebSignature() + + + // Set the payload, or signed content, on the JWS object + jws.setPayload(payload) + + + // Set the signature algorithm on the JWS that will integrity protect the payload + jws.algorithmHeaderValue = AlgorithmIdentifiers.RSA_PSS_USING_SHA512 + + + // Set the signing key on the JWS + // Note that your application will need to determine where/how to get the key + // and here we just use an example from the JWS spec + jws.setKey(keypair.privateKey) + + + // Sign the JWS and produce the compact serialization or complete JWS representation, which + // is a string consisting of three dot ('.') separated base64url-encoded + // parts in the form Header.Payload.Signature + return jws.getCompactSerialization() + + + } + private val sc = DatabaseConfig().apply { loadFromProperties(Properties().apply { setProperty("datasource.db.username", appConfig.dbUser()) setProperty("datasource.db.password", appConfig.dbPass()) setProperty("datasource.db.url", appConfig.dbUrl()) setProperty("ebean.migration.run", appConfig.dbRunMigration().toString()) - if(appConfig.seedSqlFile().isPresent){ + if (appConfig.seedSqlFile().isPresent) { setProperty("ebean.ddl.seedSql", appConfig.seedSqlFile().get()) } }) diff --git a/src/main/kotlin/com/restapi/integ/Scripting.kt b/src/main/kotlin/com/restapi/integ/Scripting.kt index 16ea840..8755f9b 100644 --- a/src/main/kotlin/com/restapi/integ/Scripting.kt +++ b/src/main/kotlin/com/restapi/integ/Scripting.kt @@ -2,8 +2,10 @@ package com.restapi.integ import com.restapi.config.AppConfig.Companion.appConfig import com.restapi.domain.DataModel -import com.restapi.domain.Session -import org.slf4j.Logger +import com.restapi.domain.Session.currentTenant +import com.restapi.domain.Session.currentUser +import com.restapi.domain.Session.database +import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.ConcurrentHashMap import javax.script.Invocable @@ -12,18 +14,18 @@ import javax.script.ScriptEngineManager object Scripting { private val engineMap = ConcurrentHashMap() - + private val logger = LoggerFactory.getLogger("script") private fun getEngine(scriptName: String) = engineMap.computeIfAbsent(scriptName) { val engine = ScriptEngineManager().getEngineByExtension("kts")!! engine.eval(File(appConfig.scriptsPath(), scriptName).reader()) engine as Invocable } - fun execute(scriptName: String, fnName: String, params: Map, logger: Logger): Any { - return getEngine(scriptName).invokeFunction(fnName, params, Session.database, logger) + fun execute(scriptName: String, fnName: String, params: Map): Any { + return getEngine(scriptName).invokeFunction(fnName, params, database, logger, currentUser(), currentTenant()) } - fun preSave(scriptName: String, fnName: String, data: DataModel, logger: Logger): Any { - return getEngine(scriptName).invokeFunction(fnName, data, Session.database, logger) + fun execute(scriptName: String, fnName: String, dataModel: DataModel): Any { + return getEngine(scriptName).invokeFunction(fnName, dataModel, database, logger, currentUser(), currentTenant()) } } \ No newline at end of file diff --git a/src/main/resources/scripts/vehicle.kts b/src/main/resources/scripts/vehicle.kts index 7caa371..47b43ca 100644 --- a/src/main/resources/scripts/vehicle.kts +++ b/src/main/resources/scripts/vehicle.kts @@ -2,16 +2,16 @@ import com.restapi.domain.DataModel import io.ebean.Database import org.slf4j.Logger -fun execute(d: Map, db: Database, logger: Logger): Map { +fun execute(d: Map, db: Database, logger: Logger, user: String, tenant: String): Map { println("execute on $d") return d } -fun preSave(d: DataModel, db: Database, logger: Logger): Boolean { +fun preSave(d: DataModel, db: Database, logger: Logger, user: String, tenant: String): Boolean { logger.warn("PreSave $d") return true } -fun postSave(d: DataModel, db: Database, logger: Logger) { +fun postSave(d: DataModel, db: Database, logger: Logger, user: String, tenant: String) { println("PostSave $d") } \ No newline at end of file