start adding signature and encryption

This commit is contained in:
gowthaman.b 2023-11-12 08:25:52 +05:30
parent 1a22043cf2
commit 10813529f2
9 changed files with 254 additions and 64 deletions

View File

@ -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")

View File

@ -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

View File

@ -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<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)
//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<String>) {
.routes {
path("/auth") {
//for testing, development only
get("/init") {
val endpoint = getAuthEndpoint().authorizationEndpoint
@ -110,18 +119,26 @@ fun main(args: Array<String>) {
?.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<String>) {
}
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, String>): 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))

View File

@ -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<String>
@Key("app.security.public_key")
fun publicKey(): Optional<String>
companion object {
val appConfig: AppConfig = ConfigFactory.builder().build().create(AppConfig::class.java)
}

View File

@ -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)
@ -70,3 +63,14 @@ object Auth {
}
data class AuthUser(val userName: String, val tenant: String, val roles: List<String>)
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

View File

@ -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<String, Any>
)
enum class ResultType {
INTEGER, DECIMAL, STRING, DATETIME, ARRAY, OBJECT
}
data class RejectAction(val reason: String)
data class StoredProcedure(val input: Map<String, Any>, val output: Map<String, ResultType> = 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<PatchValue>()
e.data[pv.key] = pv.value;
val pv = ctx.bodyAsClass<Map<String, Any>>()
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<Map<String, Any>>()
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<RejectAction>()
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<Map<String, Any>>()
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<Map<String, Any>>()
val placeholders = (0..params.entries.size + 1).joinToString(",") { "?" }
val sp = ctx.bodyAsClass<StoredProcedure>()
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 {

View File

@ -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,19 +11,131 @@ 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<AuthUser>() {
override fun initialValue(): AuthUser {
return AuthUser("", "", emptyList<String>())
}
}
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())

View File

@ -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<String, Invocable>()
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<String, Any>, logger: Logger): Any {
return getEngine(scriptName).invokeFunction(fnName, params, Session.database, logger)
fun execute(scriptName: String, fnName: String, params: Map<String, Any>): 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())
}
}

View File

@ -2,16 +2,16 @@ import com.restapi.domain.DataModel
import io.ebean.Database
import org.slf4j.Logger
fun execute(d: Map<String, Any>, db: Database, logger: Logger): Map<String, Any> {
fun execute(d: Map<String, Any>, db: Database, logger: Logger, user: String, tenant: String): Map<String, Any> {
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")
}