Merge pull request 'LMS-27 Auth0 Integration' (#3) from FEAT-auth0-Integration into main

Reviewed-on: https://gitea.com/NickKalar/LMS-APIs/pulls/3
This commit is contained in:
Nick Kalar
2025-08-17 20:41:30 +00:00
7 changed files with 142 additions and 64 deletions

View File

@@ -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<String>) {
@@ -20,6 +26,11 @@ fun main(args: Array<String>) {
}
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()

View File

@@ -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(dbConnection)
configureCollectionItemRoutes(dbConnection)
configurePatronRoutes(dbConnection)
configureLibraryRoutes(dbConnection)
@@ -24,12 +26,8 @@ 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"
version = "5.27.1"
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
package codes.kalar.model
import kotlinx.serialization.Serializable
@Serializable
data class User(
val name: String,
val password: String,
)

View File

@@ -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<NewCollectionItem>()
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<NewCollectionItem>()
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<CollectionItem>()
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<CollectionItem>()
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")
}
}
}
}

View File

@@ -0,0 +1,47 @@
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
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.sql.Connection
import java.util.Date
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 patronService = PatronService(dbConnection)
routing {
post("/login") {
try {
val user = call.receive<User>()
val name = user.name
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 {
call.respond(HttpStatusCode.Unauthorized, "Invalid login")
}
} catch (cause: DbElementNotFoundException) {
call.respond(HttpStatusCode.BadRequest, cause.message ?: "Something went wrong")
}
}
}
}

View File

@@ -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,13 +74,13 @@ 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)
val resultSet = statement.executeQuery()
return if (resultSet.next()) {
resultSet.getString("password") == password
resultSet.getString("password") == password && !resultSet.getBoolean("is_archived")
} else {
throw SQLException()
}
@@ -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 {