Merge pull request 'FEAT-addition-security' (#4) from FEAT-addition-security into main

Reviewed-on: https://gitea.com/NickKalar/LMS-APIs/pulls/4
This commit is contained in:
Nick Kalar
2025-08-26 18:38:56 +00:00
7 changed files with 161 additions and 82 deletions

View File

@@ -16,7 +16,7 @@ import io.ktor.server.response.respond
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
fun main(args: Array<String>) { fun main(args: Array<String>) {
embeddedServer(Netty, port = 8080) { embeddedServer(Netty, host = "127.0.0.1", port = 8080) {
install(CORS) { install(CORS) {
anyHost() anyHost()
allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.ContentType)
@@ -39,7 +39,7 @@ fun Application.module() {
} }
install(Authentication) { install(Authentication) {
jwt("auth-jwt") { jwt("general") {
realm = myRealm realm = myRealm
verifier( verifier(
JWT JWT
@@ -55,7 +55,45 @@ fun Application.module() {
} }
} }
challenge { defaultScheme, realm -> challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "${defaultScheme}, $realm Token is not valid or has expired") call.respond(HttpStatusCode.Unauthorized, "$defaultScheme, $realm Token is not valid or has expired")
}
}
jwt("patron"){
realm = myRealm
verifier(
JWT
.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build())
validate { credential ->
if (credential.payload.getClaim("role").asString() != "patron") {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Insufficient permissions to access this resource.")
}
}
jwt("staff"){
realm = myRealm
verifier(
JWT
.require(Algorithm.HMAC256(secret))
.withAudience(audience)
.withIssuer(issuer)
.build())
validate { credential ->
if (credential.payload.getClaim("role").asString() != "staff") {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { _, _ ->
call.respond(HttpStatusCode.Unauthorized, "Insufficient permissions to access this resource.")
} }
} }
} }

View File

@@ -42,7 +42,7 @@ fun Application.configureCollectionItemRoutes(dbConnection: Connection) {
} }
} }
authenticate("auth-jwt") { authenticate("staff") {
post("/items") { post("/items") {
try { try {
val item = call.receive<NewCollectionItem>() val item = call.receive<NewCollectionItem>()

View File

@@ -7,6 +7,7 @@ import codes.kalar.model.NewLibrary
import codes.kalar.service.LibraryService import codes.kalar.service.LibraryService
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
@@ -41,7 +42,10 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) {
} catch (cause: DbElementNotFoundException) { } catch (cause: DbElementNotFoundException) {
call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to find Library.") call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to find Library.")
} catch (cause: NumberFormatException) { } catch (cause: NumberFormatException) {
call.respond(HttpStatusCode.BadRequest, "Unable to parse number format. \"${call.pathParameters["id"]}\" is not a number.") call.respond(
HttpStatusCode.BadRequest,
"Unable to parse number format. \"${call.pathParameters["id"]}\" is not a number."
)
} }
} }
@@ -49,6 +53,7 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) {
// TODO Add search for collection_it where itemID && libraryID // TODO Add search for collection_it where itemID && libraryID
} }
authenticate("staff") {
post("/libraries") { post("/libraries") {
val library = call.receive<NewLibrary>() val library = call.receive<NewLibrary>()
try { try {
@@ -71,7 +76,6 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) {
log.error(cause.message) log.error(cause.message)
call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments")
} }
} }
delete("/libraries") { delete("/libraries") {
@@ -89,5 +93,6 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) {
} }
} }
} }
}
} }

View File

@@ -3,6 +3,7 @@ package codes.kalar.routes
import codes.kalar.exception.DbElementNotFoundException import codes.kalar.exception.DbElementNotFoundException
import codes.kalar.model.User import codes.kalar.model.User
import codes.kalar.service.PatronService import codes.kalar.service.PatronService
import codes.kalar.service.StaffService
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -18,8 +19,10 @@ fun Application.configureLoginRoutes(dbConnection: Connection) {
val secret = environment.config.property("jwt.secret").getString() val secret = environment.config.property("jwt.secret").getString()
val issuer = environment.config.property("jwt.issuer").getString() val issuer = environment.config.property("jwt.issuer").getString()
val audience = environment.config.property("jwt.audience").getString() val audience = environment.config.property("jwt.audience").getString()
val auth0Map = mapOf("secret" to secret, "issuer" to issuer, "audience" to audience)
val patronService = PatronService(dbConnection) val patronService = PatronService(dbConnection)
val staffService = StaffService(dbConnection)
routing { routing {
post("/login") { post("/login") {
try { try {
@@ -28,20 +31,35 @@ fun Application.configureLoginRoutes(dbConnection: Connection) {
val password = user.password val password = user.password
if (patronService.loginPatronByLoginUsername(name, password)) { if (patronService.loginPatronByLoginUsername(name, password)) {
val token = JWT.create() val token = createToken(user.name, "patron", auth0Map)
.withAudience(audience) call.respond(HttpStatusCode.OK, mapOf("token" to token))
.withIssuer(issuer) }
.withClaim("name", name) else if (staffService.loginStaffByLoginUsername(name, password)) {
.withExpiresAt(Date(System.currentTimeMillis() + 160000)) val token = createToken(user.name, "staff", auth0Map)
.sign(Algorithm.HMAC256(secret)) call.respond(HttpStatusCode.OK, mapOf("token" to token))
call.respond(hashMapOf("token" to token)) }
} else { else {
call.respond(HttpStatusCode.Unauthorized, "Invalid login") log.error("Unauthorized use: $name")
call.respond(HttpStatusCode.Unauthorized, mapOf("message" to "Invalid login", "User" to user.name))
} }
} catch (cause: DbElementNotFoundException) { } catch (cause: DbElementNotFoundException) {
call.respond(HttpStatusCode.BadRequest, cause.message ?: "Something went wrong") log.error(cause.message)
call.respond(HttpStatusCode.BadRequest, mapOf("message" to cause.message))
} catch (cause: Exception) {
log.error(cause.message)
call.respond(HttpStatusCode.BadRequest, mapOf("message" to "An unexpected error occurred: ${cause.message}"))
} }
} }
} }
} }
fun createToken(username: String, role: String, auth: Map<String, String>): String {
return JWT.create()
.withAudience(auth["audience"])
.withIssuer(auth["issuer"])
.withClaim("name", username)
.withClaim("role", role)
.withExpiresAt(Date(System.currentTimeMillis() + 160000))
.sign(Algorithm.HMAC256(auth["secret"]))
}

View File

@@ -7,6 +7,7 @@ import codes.kalar.model.Patron
import codes.kalar.service.PatronService import codes.kalar.service.PatronService
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
@@ -42,6 +43,7 @@ fun Application.configurePatronRoutes(dbConnection: Connection) {
} }
} }
authenticate("staff") {
post("/patron") { post("/patron") {
try { try {
val patron = call.receive<NewPatron>() val patron = call.receive<NewPatron>()
@@ -53,19 +55,27 @@ fun Application.configurePatronRoutes(dbConnection: Connection) {
call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid CollectionItem object.") call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid CollectionItem object.")
} }
} }
}
authenticate("general") {
patch("/patron") { patch("/patron") {
try { try {
val patron = call.receive<Patron>() val patron = call.receive<Patron>()
val patchedPatron = patronService.update(patron) val isPatched = patronService.update(patron)
call.respondText("${patron.name} is patched") if (isPatched) {
call.respond(HttpStatusCode.OK, "${patron.name} is patched")
} else {
call.respond(HttpStatusCode.BadRequest, "${patron.name} is not patched")
}
} catch (cause: DbElementInsertionException) { } catch (cause: DbElementInsertionException) {
call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Patron.") call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Patron.")
} catch (cause: ContentTransformationException) { } catch (cause: ContentTransformationException) {
call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid Patron object.")
}
} }
} }
authenticate("staff") {
delete("/patron/{id}") { delete("/patron/{id}") {
try { try {
val id = call.pathParameters["id"]!!.toLong() val id = call.pathParameters["id"]!!.toLong()
@@ -78,4 +88,5 @@ fun Application.configurePatronRoutes(dbConnection: Connection) {
} }
} }
} }
}
} }

View File

@@ -1,6 +1,7 @@
package codes.kalar.routes package codes.kalar.routes
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import java.sql.Connection import java.sql.Connection
@@ -16,6 +17,7 @@ fun Application.configureStaffRoutes(dbConnection: Connection) {
call.respondText(call.parameters["id"]!!) call.respondText(call.parameters["id"]!!)
} }
authenticate("staff") {
post("/staff") { post("/staff") {
} }
@@ -28,4 +30,5 @@ fun Application.configureStaffRoutes(dbConnection: Connection) {
} }
} }
}
} }

View File

@@ -11,13 +11,17 @@ class StaffService(private val connection: Connection) {
private const val SELECT_STAFF_BY_ID = "" private const val SELECT_STAFF_BY_ID = ""
private const val INSERT_STAFF = "" private const val INSERT_STAFF = ""
private const val UPDATE_STAFF_BY_ID = "" private const val UPDATE_STAFF_BY_ID = ""
// In the event are "deleted" erroneously, having a flag set instead of actually removing the entry allows // In the event staff are "deleted" erroneously, having a flag set instead of actually removing the entry allows
// for quick reversal. // for quick reversal.
private const val ARCHIVE_STAFF_BY_ID = "" private const val ARCHIVE_STAFF_BY_ID = ""
} }
suspend fun create() {} suspend fun create() {}
fun loginStaffByLoginUsername(username: String, password: String): Boolean {
return true
}
suspend fun read() {} suspend fun read() {}
suspend fun update() {} suspend fun update() {}