From 5231c8b690271d4b3dee492bceaf90a6a588c989 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Mon, 25 Aug 2025 15:50:42 -0400 Subject: [PATCH 1/5] Added Staff authentication --- .../kotlin/routes/CollectionItemRoutes.kt | 2 +- src/main/kotlin/routes/LibraryRoutes.kt | 75 ++++++++++--------- src/main/kotlin/routes/PatronRoutes.kt | 65 +++++++++------- src/main/kotlin/routes/StaffRoutes.kt | 13 ++-- 4 files changed, 87 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/routes/CollectionItemRoutes.kt b/src/main/kotlin/routes/CollectionItemRoutes.kt index 4fb321e..7d2a40c 100644 --- a/src/main/kotlin/routes/CollectionItemRoutes.kt +++ b/src/main/kotlin/routes/CollectionItemRoutes.kt @@ -42,7 +42,7 @@ fun Application.configureCollectionItemRoutes(dbConnection: Connection) { } } - authenticate("auth-jwt") { + authenticate("staff") { post("/items") { try { val item = call.receive() diff --git a/src/main/kotlin/routes/LibraryRoutes.kt b/src/main/kotlin/routes/LibraryRoutes.kt index 6329a53..5da29bf 100644 --- a/src/main/kotlin/routes/LibraryRoutes.kt +++ b/src/main/kotlin/routes/LibraryRoutes.kt @@ -7,6 +7,7 @@ import codes.kalar.model.NewLibrary import codes.kalar.service.LibraryService import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.auth.authenticate import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -41,7 +42,10 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) { } catch (cause: DbElementNotFoundException) { call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to find Library.") } 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,43 +53,44 @@ fun Application.configureLibraryRoutes(dbConnection: Connection) { // TODO Add search for collection_it where itemID && libraryID } - post("/libraries") { - val library = call.receive() - try { - val id = libraryService.create(library) - call.respondText("${library.name} is posted with the ID: $id") - } catch (cause: DbElementInsertionException) { - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to insert Library.") - } - } - - patch("/libraries") { - try { - val library = call.receive() - val patchedLibrary = libraryService.update(library) - call.respond(HttpStatusCode.OK, patchedLibrary) - } catch (cause: DbElementInsertionException) { - log.error(cause.message) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Library.") - } catch (cause: ContentTransformationException) { - log.error(cause.message) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + authenticate("staff") { + post("/libraries") { + val library = call.receive() + try { + val id = libraryService.create(library) + call.respondText("${library.name} is posted with the ID: $id") + } catch (cause: DbElementInsertionException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to insert Library.") + } } - } + patch("/libraries") { + try { + val library = call.receive() + val patchedLibrary = libraryService.update(library) + call.respond(HttpStatusCode.OK, patchedLibrary) + } catch (cause: DbElementInsertionException) { + log.error(cause.message) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Library.") + } catch (cause: ContentTransformationException) { + log.error(cause.message) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + } + } - delete("/libraries") { - try { - val id = call.parameters["id"]!!.toLong() - log.info("Deleting item with id=$id") - libraryService.delete(id) - call.respondText(":(", status = HttpStatusCode.OK) - } catch (cause: DbElementNotFoundException) { - log.error(cause.message, cause) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") - } catch (cause: NumberFormatException) { - log.error(cause.message, cause) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Invalid ID format") + delete("/libraries") { + try { + val id = call.parameters["id"]!!.toLong() + log.info("Deleting item with id=$id") + libraryService.delete(id) + call.respondText(":(", status = HttpStatusCode.OK) + } catch (cause: DbElementNotFoundException) { + log.error(cause.message, cause) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + } catch (cause: NumberFormatException) { + log.error(cause.message, cause) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Invalid ID format") + } } } } diff --git a/src/main/kotlin/routes/PatronRoutes.kt b/src/main/kotlin/routes/PatronRoutes.kt index ea533ea..c02fb53 100644 --- a/src/main/kotlin/routes/PatronRoutes.kt +++ b/src/main/kotlin/routes/PatronRoutes.kt @@ -7,6 +7,7 @@ import codes.kalar.model.Patron import codes.kalar.service.PatronService import io.ktor.http.* import io.ktor.server.application.* +import io.ktor.server.auth.authenticate import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -42,39 +43,49 @@ fun Application.configurePatronRoutes(dbConnection: Connection) { } } - post("/patron") { - try { - val patron = call.receive() - val id = patronService.create(patron) - call.respondText("Adding ${patron.name} to database with the id of $id", status = HttpStatusCode.OK) - } catch (cause: DbElementInsertionException) { - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") - } catch (cause: ContentTransformationException) { - call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid CollectionItem object.") + authenticate("staff") { + post("/patron") { + try { + val patron = call.receive() + val id = patronService.create(patron) + call.respondText("Adding ${patron.name} to database with the id of $id", status = HttpStatusCode.OK) + } catch (cause: DbElementInsertionException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + } catch (cause: ContentTransformationException) { + call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid CollectionItem object.") + } } } - patch("/patron") { - try { - val patron = call.receive() - val patchedPatron = patronService.update(patron) - call.respondText("${patron.name} is patched") - } catch (cause: DbElementInsertionException) { - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Patron.") - } catch (cause: ContentTransformationException) { - + authenticate("general") { + patch("/patron") { + try { + val patron = call.receive() + val isPatched = patronService.update(patron) + if (isPatched) { + call.respond(HttpStatusCode.OK, "${patron.name} is patched") + } else { + call.respond(HttpStatusCode.BadRequest, "${patron.name} is not patched") + } + } catch (cause: DbElementInsertionException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to update Patron.") + } catch (cause: ContentTransformationException) { + call.respond(HttpStatusCode.BadRequest, "Bad Arguments. Must pass a valid Patron object.") + } } } - delete("/patron/{id}") { - try { - val id = call.pathParameters["id"]!!.toLong() - patronService.delete(id) - call.respond(HttpStatusCode.OK, "Successfully deleted the patron") - } catch (cause: DbElementInsertionException) { - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to delete Patron.") - } catch (cause: NumberFormatException) { - call.respond(HttpStatusCode.BadRequest, cause.message ?: "ID needs to be a number.") + authenticate("staff") { + delete("/patron/{id}") { + try { + val id = call.pathParameters["id"]!!.toLong() + patronService.delete(id) + call.respond(HttpStatusCode.OK, "Successfully deleted the patron") + } catch (cause: DbElementInsertionException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Unable to delete Patron.") + } catch (cause: NumberFormatException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "ID needs to be a number.") + } } } } diff --git a/src/main/kotlin/routes/StaffRoutes.kt b/src/main/kotlin/routes/StaffRoutes.kt index d3a28ba..20a3e1f 100644 --- a/src/main/kotlin/routes/StaffRoutes.kt +++ b/src/main/kotlin/routes/StaffRoutes.kt @@ -1,6 +1,7 @@ package codes.kalar.routes import io.ktor.server.application.* +import io.ktor.server.auth.authenticate import io.ktor.server.response.* import io.ktor.server.routing.* import java.sql.Connection @@ -16,16 +17,18 @@ fun Application.configureStaffRoutes(dbConnection: Connection) { call.respondText(call.parameters["id"]!!) } - post("/staff") { + authenticate("staff") { + post("/staff") { - } + } - patch("/staff") { + patch("/staff") { - } + } - delete("/staff/{id}") { + delete("/staff/{id}") { + } } } } \ No newline at end of file From 1ed96ff4b2469e192fc984ba87d7f70d44451ce2 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Mon, 25 Aug 2025 15:51:19 -0400 Subject: [PATCH 2/5] Added role token creation --- src/main/kotlin/routes/LoginRoutes.kt | 29 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/routes/LoginRoutes.kt b/src/main/kotlin/routes/LoginRoutes.kt index f4e58f5..de6be3e 100644 --- a/src/main/kotlin/routes/LoginRoutes.kt +++ b/src/main/kotlin/routes/LoginRoutes.kt @@ -3,6 +3,7 @@ package codes.kalar.routes import codes.kalar.exception.DbElementNotFoundException import codes.kalar.model.User import codes.kalar.service.PatronService +import codes.kalar.service.StaffService import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import io.ktor.http.HttpStatusCode @@ -18,8 +19,10 @@ fun Application.configureLoginRoutes(dbConnection: Connection) { val secret = environment.config.property("jwt.secret").getString() val issuer = environment.config.property("jwt.issuer").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 staffService = StaffService(dbConnection) routing { post("/login") { try { @@ -28,14 +31,14 @@ fun Application.configureLoginRoutes(dbConnection: Connection) { val password = user.password if (patronService.loginPatronByLoginUsername(name, password)) { - val token = JWT.create() - .withAudience(audience) - .withIssuer(issuer) - .withClaim("name", name) - .withExpiresAt(Date(System.currentTimeMillis() + 160000)) - .sign(Algorithm.HMAC256(secret)) - call.respond(hashMapOf("token" to token)) - } else { + val token = createToken(user.name, "patron", auth0Map) + call.respond(HttpStatusCode.OK, mapOf("token" to token)) + } + else if (staffService.loginStaffByLoginUsername(name, password)) { + val token = createToken(user.name, "staff", auth0Map) + call.respond(HttpStatusCode.OK, mapOf("token" to token)) + } + else { call.respond(HttpStatusCode.Unauthorized, "Invalid login") } } catch (cause: DbElementNotFoundException) { @@ -44,4 +47,14 @@ fun Application.configureLoginRoutes(dbConnection: Connection) { } } +} + +fun createToken(username: String, role: String, auth: Map): 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"])) } \ No newline at end of file From 0f0babe8da42f36191918dc577cbdc283cc4a4da Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Mon, 25 Aug 2025 15:51:49 -0400 Subject: [PATCH 3/5] Added role based authentication --- src/main/kotlin/Application.kt | 44 +++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt index 6538438..b4d125c 100644 --- a/src/main/kotlin/Application.kt +++ b/src/main/kotlin/Application.kt @@ -16,7 +16,7 @@ import io.ktor.server.response.respond import kotlinx.serialization.json.Json fun main(args: Array) { - embeddedServer(Netty, port = 8080) { + embeddedServer(Netty, host = "127.0.0.1", port = 8080) { install(CORS) { anyHost() allowHeader(HttpHeaders.ContentType) @@ -39,7 +39,7 @@ fun Application.module() { } install(Authentication) { - jwt("auth-jwt") { + jwt("general") { realm = myRealm verifier( JWT @@ -55,7 +55,45 @@ fun Application.module() { } } 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.") } } } From f3714b9a18bbafa085eeefaca31891c6b9e83f21 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Tue, 26 Aug 2025 14:21:47 -0400 Subject: [PATCH 4/5] Improved exception handling --- src/main/kotlin/routes/LoginRoutes.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/routes/LoginRoutes.kt b/src/main/kotlin/routes/LoginRoutes.kt index de6be3e..5cafc9b 100644 --- a/src/main/kotlin/routes/LoginRoutes.kt +++ b/src/main/kotlin/routes/LoginRoutes.kt @@ -39,10 +39,15 @@ fun Application.configureLoginRoutes(dbConnection: Connection) { call.respond(HttpStatusCode.OK, mapOf("token" to token)) } 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) { - 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}")) } } From d44a66894dd5f739ec75e7c436703f56a2ca0f81 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Tue, 26 Aug 2025 14:26:57 -0400 Subject: [PATCH 5/5] Added stubbed function for logging in staff --- src/main/kotlin/service/StaffService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/service/StaffService.kt b/src/main/kotlin/service/StaffService.kt index cdf7416..df21e70 100644 --- a/src/main/kotlin/service/StaffService.kt +++ b/src/main/kotlin/service/StaffService.kt @@ -11,13 +11,17 @@ class StaffService(private val connection: Connection) { private const val SELECT_STAFF_BY_ID = "" private const val INSERT_STAFF = "" 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. private const val ARCHIVE_STAFF_BY_ID = "" } suspend fun create() {} + fun loginStaffByLoginUsername(username: String, password: String): Boolean { + return true + } + suspend fun read() {} suspend fun update() {}