From e5f21844d82c496f74797ff0e90f9154cd928eb5 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:01:02 -0400 Subject: [PATCH 01/11] Added initial Login Routes --- src/main/kotlin/routes/LoginRoutes.kt | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/kotlin/routes/LoginRoutes.kt diff --git a/src/main/kotlin/routes/LoginRoutes.kt b/src/main/kotlin/routes/LoginRoutes.kt new file mode 100644 index 0000000..aa2e3ae --- /dev/null +++ b/src/main/kotlin/routes/LoginRoutes.kt @@ -0,0 +1,42 @@ +package codes.kalar.routes + +import codes.kalar.model.User +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.* +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import java.util.Date + +fun Application.configureLoginRoutes() { + 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 myRealm = environment.config.property("jwt.realm").getString() + + routing { + post("/login") { + try { + val user = call.receive() + val name = user.name + val password = user.password + + // TODO Check is username exists and password matches + + 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)) + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, e.message ?: "Something went wrong") + } + + } + } +} \ No newline at end of file From 68e034908e8f1cb791b11d3c64ee50597a742d4c Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:01:14 -0400 Subject: [PATCH 02/11] User Class for logging in --- src/main/kotlin/model/User.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/kotlin/model/User.kt diff --git a/src/main/kotlin/model/User.kt b/src/main/kotlin/model/User.kt new file mode 100644 index 0000000..9d75b05 --- /dev/null +++ b/src/main/kotlin/model/User.kt @@ -0,0 +1,9 @@ +package codes.kalar.model + +import kotlinx.serialization.Serializable + +@Serializable +data class User( + val name: String, + val password: String, +) \ No newline at end of file From c6da43b2b8ae35421465ebd6181d979c7d29c6ca Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:02:10 -0400 Subject: [PATCH 03/11] Added Login Routes --- src/main/kotlin/Routing.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/Routing.kt b/src/main/kotlin/Routing.kt index 82536d6..ee3b8c3 100644 --- a/src/main/kotlin/Routing.kt +++ b/src/main/kotlin/Routing.kt @@ -2,6 +2,7 @@ package codes.kalar import codes.kalar.routes.configureCollectionItemRoutes import codes.kalar.routes.configureLibraryRoutes +import codes.kalar.routes.configureLoginRoutes import codes.kalar.routes.configurePatronRoutes import codes.kalar.routes.configureStaffRoutes import io.ktor.http.* @@ -14,6 +15,7 @@ import java.sql.Connection fun Application.configureRouting() { val dbConnection: Connection = connectToPostgres() + configureLoginRoutes() configureCollectionItemRoutes(dbConnection) configurePatronRoutes(dbConnection) configureLibraryRoutes(dbConnection) @@ -24,10 +26,6 @@ fun Application.configureRouting() { call.respondText("Hello World!", status = HttpStatusCode.OK) } - get("/authenticate") { - call.respondText(System.getenv("JWT_DOMAIN") ?: "You seem to be missing something") - } - swaggerUI(path = "swagger", swaggerFile = "src/main/resources/openapi/documentation.yaml") { version="5.27.1" } From a05e48aec191419f4435d73d3bcfe4b1d2194aa9 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:02:18 -0400 Subject: [PATCH 04/11] Added missing field --- src/main/kotlin/model/Patron.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/model/Patron.kt b/src/main/kotlin/model/Patron.kt index 154f15a..bbd6571 100644 --- a/src/main/kotlin/model/Patron.kt +++ b/src/main/kotlin/model/Patron.kt @@ -9,6 +9,7 @@ data class Patron( val hasGoodStanding: Boolean, val feeTotal: Long, val isArchived: Boolean, + val loginUserName: String, val lastLogin: String?, val password: String?, ) @@ -20,6 +21,7 @@ data class NewPatron( val hasGoodStanding: Boolean, val feeTotal: Long, val isArchived: Boolean, + val loginUserName: String, val lastLogin: String?, val password: String?, ) From 3b5ba33151a028ea5122fa7e9cdf2b1460397646 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:05:23 -0400 Subject: [PATCH 05/11] FIRST AUTHENTICATED ROUTES! --- .../kotlin/routes/CollectionItemRoutes.kt | 81 ++++++++++--------- 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/src/main/kotlin/routes/CollectionItemRoutes.kt b/src/main/kotlin/routes/CollectionItemRoutes.kt index bb6b9c7..4fb321e 100644 --- a/src/main/kotlin/routes/CollectionItemRoutes.kt +++ b/src/main/kotlin/routes/CollectionItemRoutes.kt @@ -7,6 +7,7 @@ import codes.kalar.exception.DbElementNotFoundException import codes.kalar.model.NewCollectionItem 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,49 +42,51 @@ fun Application.configureCollectionItemRoutes(dbConnection: Connection) { } } - post("/items") { - try { - val item = call.receive() - val id = itemService.create(item) - call.respondText("Adding ${item.title} 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("auth-jwt") { + post("/items") { + try { + val item = call.receive() + val id = itemService.create(item) + call.respondText("Adding ${item.title} 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("/items") { - try { - val inputItem = call.receive() - itemService.readById(inputItem.id) - itemService.update(inputItem) - call.respondText("Updated ${inputItem.title} to database.", status=HttpStatusCode.OK) - } catch (cause: DbElementNotFoundException) { - log.error(cause.message) - call.respond(HttpStatusCode.NotFound, cause.message ?: "Could not find item in database.") - } catch (cause: DbElementInsertionException) { - log.error(cause.message) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") - } catch (cause: ContentTransformationException) { - log.error(cause.message) - call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + patch("/items") { + try { + val inputItem = call.receive() + itemService.readById(inputItem.id) + itemService.update(inputItem) + call.respondText("Updated ${inputItem.title} to database.", status = HttpStatusCode.OK) + } catch (cause: DbElementNotFoundException) { + log.error(cause.message) + call.respond(HttpStatusCode.NotFound, cause.message ?: "Could not find item in database.") + } catch (cause: DbElementInsertionException) { + log.error(cause.message) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + } catch (cause: ContentTransformationException) { + log.error(cause.message) + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Bad Arguments") + } } - } - delete("/items/{id}") { - try { - val id = call.parameters["id"]!!.toLong() - log.info("Deleting item with id=$id") - itemService.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("/items/{id}") { + try { + val id = call.parameters["id"]!!.toLong() + log.info("Deleting item with id=$id") + itemService.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") + } } } } From a0e59efaef63e87f25605ca7264f8b2858206b2b Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sat, 16 Aug 2025 17:05:46 -0400 Subject: [PATCH 06/11] Initial Authentication with Auth0 --- src/main/kotlin/Application.kt | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/main/kotlin/Application.kt b/src/main/kotlin/Application.kt index 43d91bb..6538438 100644 --- a/src/main/kotlin/Application.kt +++ b/src/main/kotlin/Application.kt @@ -1,12 +1,18 @@ package codes.kalar +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.jwt.jwt import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.response.respond import kotlinx.serialization.json.Json fun main(args: Array) { @@ -20,6 +26,11 @@ fun main(args: Array) { } fun Application.module() { + 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 myRealm = environment.config.property("jwt.realm").getString() + install(ContentNegotiation) { json(Json { prettyPrint = true @@ -27,6 +38,28 @@ fun Application.module() { }) } + install(Authentication) { + jwt("auth-jwt") { + realm = myRealm + verifier( + JWT + .require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build()) + validate { credential -> + if (credential.payload.getClaim("name").asString() != "") { + JWTPrincipal(credential.payload) + } else { + null + } + } + challenge { defaultScheme, realm -> + call.respond(HttpStatusCode.Unauthorized, "${defaultScheme}, $realm Token is not valid or has expired") + } + } + } + configureHTTP() configureSecurity() configureSerialization() From 31195f678fb6205c026a1362fb88731ce317b9be Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sun, 17 Aug 2025 16:34:25 -0400 Subject: [PATCH 07/11] Added basic password validation --- src/main/kotlin/routes/LoginRoutes.kt | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/routes/LoginRoutes.kt b/src/main/kotlin/routes/LoginRoutes.kt index aa2e3ae..5877418 100644 --- a/src/main/kotlin/routes/LoginRoutes.kt +++ b/src/main/kotlin/routes/LoginRoutes.kt @@ -1,6 +1,8 @@ package codes.kalar.routes +import codes.kalar.exception.DbElementNotFoundException import codes.kalar.model.User +import codes.kalar.service.PatronService import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import io.ktor.http.HttpStatusCode @@ -9,14 +11,16 @@ import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.post import io.ktor.server.routing.routing +import java.sql.Connection import java.util.Date -fun Application.configureLoginRoutes() { +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 myRealm = environment.config.property("jwt.realm").getString() + val patronService = PatronService(dbConnection) routing { post("/login") { try { @@ -24,17 +28,19 @@ fun Application.configureLoginRoutes() { val name = user.name val password = user.password - // TODO Check is username exists and password matches - - 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)) - } catch (e: Exception) { - call.respond(HttpStatusCode.BadRequest, e.message ?: "Something went wrong") + 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 { + call.respond(HttpStatusCode.Unauthorized, "Invalid login") + } + } catch (cause: DbElementNotFoundException) { + call.respond(HttpStatusCode.BadRequest, cause.message ?: "Something went wrong") } } From 7fa7cee70e418c9dd5f4b810840e0a1bc20bb6ce Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sun, 17 Aug 2025 16:34:46 -0400 Subject: [PATCH 08/11] Added check for archived patron --- src/main/kotlin/service/PatronService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/service/PatronService.kt b/src/main/kotlin/service/PatronService.kt index 3ad3c60..befa10f 100644 --- a/src/main/kotlin/service/PatronService.kt +++ b/src/main/kotlin/service/PatronService.kt @@ -80,7 +80,7 @@ class PatronService(private val connection: Connection) { statement.setString(1, username) val resultSet = statement.executeQuery() return if (resultSet.next()) { - resultSet.getString("password") == password + resultSet.getString("password") == password && !resultSet.getBoolean("is_archived") } else { throw SQLException() } From a6f021569cc5ac3516110e7abdb6470dbf2fa371 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sun, 17 Aug 2025 16:35:04 -0400 Subject: [PATCH 09/11] passed Postgres connection --- src/main/kotlin/Routing.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/Routing.kt b/src/main/kotlin/Routing.kt index ee3b8c3..b86683e 100644 --- a/src/main/kotlin/Routing.kt +++ b/src/main/kotlin/Routing.kt @@ -15,7 +15,7 @@ import java.sql.Connection fun Application.configureRouting() { val dbConnection: Connection = connectToPostgres() - configureLoginRoutes() + configureLoginRoutes(dbConnection) configureCollectionItemRoutes(dbConnection) configurePatronRoutes(dbConnection) configureLibraryRoutes(dbConnection) @@ -27,7 +27,7 @@ fun Application.configureRouting() { } swaggerUI(path = "swagger", swaggerFile = "src/main/resources/openapi/documentation.yaml") { - version="5.27.1" + version = "5.27.1" } } } From 0c723f8f3fae53fb1993927f98aa2ebfd35faab1 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sun, 17 Aug 2025 16:35:27 -0400 Subject: [PATCH 10/11] Code cleanup --- src/main/kotlin/Security.kt | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/main/kotlin/Security.kt b/src/main/kotlin/Security.kt index 636f74c..049dd68 100644 --- a/src/main/kotlin/Security.kt +++ b/src/main/kotlin/Security.kt @@ -1,28 +1,16 @@ package codes.kalar -import java.io.File -import java.util.Properties import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* -import io.ktor.server.plugins.contentnegotiation.* -import io.ktor.server.plugins.defaultheaders.* -import io.ktor.server.plugins.swagger.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import java.sql.Connection -import java.sql.DriverManager fun Application.configureSecurity() { // Please read the jwt property from the config file if you are using EngineMain val jwtAudience = environment.config.property("jwt.audience").toString() val jwtDomain = environment.config.property("jwt.domain").toString() - val jwtRealm = "ktor sample app" + val jwtRealm = environment.config.property("jwt.realm").toString() val jwtSecret = environment.config.property("jwt.secret").toString() authentication { From daf8daab1e342fbadf1f65f8b7989aabe16537b8 Mon Sep 17 00:00:00 2001 From: Nicholas Kalar Date: Sun, 17 Aug 2025 16:39:14 -0400 Subject: [PATCH 11/11] Code cleanup --- src/main/kotlin/routes/LoginRoutes.kt | 1 - src/main/kotlin/service/PatronService.kt | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/routes/LoginRoutes.kt b/src/main/kotlin/routes/LoginRoutes.kt index 5877418..f4e58f5 100644 --- a/src/main/kotlin/routes/LoginRoutes.kt +++ b/src/main/kotlin/routes/LoginRoutes.kt @@ -18,7 +18,6 @@ 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 myRealm = environment.config.property("jwt.realm").getString() val patronService = PatronService(dbConnection) routing { diff --git a/src/main/kotlin/service/PatronService.kt b/src/main/kotlin/service/PatronService.kt index befa10f..95bc5fa 100644 --- a/src/main/kotlin/service/PatronService.kt +++ b/src/main/kotlin/service/PatronService.kt @@ -21,7 +21,7 @@ class PatronService(private val connection: Connection) { private const val ARCHIVE_PATRON_BY_ID = "UPDATE patron set is_archived = true WHERE id = ?" } - suspend fun create(newPatron: NewPatron): Long { + fun create(newPatron: NewPatron): Long { val statement = connection.prepareStatement(INSERT_PATRON, Statement.RETURN_GENERATED_KEYS) statement.setString(1, newPatron.name) statement.setBoolean(2, true) @@ -42,7 +42,7 @@ class PatronService(private val connection: Connection) { } } - suspend fun readPatronById(id: Long): Patron { + fun readPatronById(id: Long): Patron { try { val statement = connection.prepareStatement(SELECT_PATRON_BY_ID, Statement.RETURN_GENERATED_KEYS) statement.setLong(1, id) @@ -58,7 +58,7 @@ class PatronService(private val connection: Connection) { } } - suspend fun readPatronByLoginUsername(username: String): Patron { + fun readPatronByLoginUsername(username: String): Patron { try { val statement = connection.prepareStatement(SELECT_PATRON_BY_LOGIN_USERNAME) statement.setString(1, username) @@ -74,7 +74,7 @@ class PatronService(private val connection: Connection) { } } - suspend fun loginPatronByLoginUsername(username: String, password: String): Boolean { + fun loginPatronByLoginUsername(username: String, password: String): Boolean { try { val statement = connection.prepareStatement(SELECT_PATRON_BY_LOGIN_USERNAME) statement.setString(1, username) @@ -89,7 +89,7 @@ class PatronService(private val connection: Connection) { } } - suspend fun update(patron: Patron): Boolean { + fun update(patron: Patron): Boolean { val statement = connection.prepareStatement(UPDATE_PATRON_BY_ID) statement.setString(1, patron.name) statement.setBoolean(2, patron.hasGoodStanding) @@ -106,7 +106,7 @@ class PatronService(private val connection: Connection) { } } - suspend fun delete(id: Long) { + fun delete(id: Long) { val statement = connection.prepareStatement(ARCHIVE_PATRON_BY_ID) statement.setLong(1, id) try {