diff --git a/frontend/tests/login.spec.ts b/frontend/tests/login.spec.ts index 8e07af1..0bd8348 100644 --- a/frontend/tests/login.spec.ts +++ b/frontend/tests/login.spec.ts @@ -22,4 +22,21 @@ test('logs in with the initialized admin user', async ({ page }) => { const adminRow = page.getByRole('row').filter({ hasText: 'admin' }) await expect(adminRow).toContainText('admin') await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible() + + const token = await page.evaluate(() => { + const session = JSON.parse(sessionStorage.getItem('bolts-session')!) as { token: string } + return session.token + }) + const signOutRequest = page.waitForRequest(request => + request.url().endsWith('/api/signout') && request.method() === 'POST', + ) + await page.getByRole('button', { name: 'Sign out' }).click() + await signOutRequest + + await expect(page).toHaveURL('/') + await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible() + const response = await page.request.get('/api/users', { + headers: { Authorization: `Bearer ${token}` }, + }) + expect(response.status()).toBe(403) }) diff --git a/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt b/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt index 1be89c5..2496351 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt @@ -32,6 +32,8 @@ class LoginService( fun userForToken(token: String?): User? = token?.let(sessions::findUserByToken) + fun signOut(token: String?): Boolean = token?.let(sessions::expire) ?: false + private fun Map.valueOrDefault(name: String, default: String) = this[name]?.takeIf { it.isNotBlank() } ?: default } diff --git a/src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt b/src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt index e703d10..e0cf818 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt @@ -18,6 +18,7 @@ class SessionRepository(private val database: Database) { FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token = ? + AND sessions.expired_at IS NULL """.trimIndent() return database.queryOne( @@ -26,4 +27,15 @@ class SessionRepository(private val database: Database) { map = { toUser() }, ) } + + fun expire(token: String): Boolean = + database.executeUpdate( + """ + UPDATE sessions + SET expired_at = CURRENT_TIMESTAMP + WHERE token = ? AND expired_at IS NULL + """.trimIndent(), + ) { + setString(1, token) + } > 0 } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt index 1ff0782..393aa82 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/AuthController.kt @@ -24,5 +24,13 @@ class AuthController(private val loginService: LoginService) : Controller { .json(ErrorResponse("Invalid username or password")) } } + routes.post("/api/signout") { ctx -> + if (loginService.signOut(ctx.bearerToken())) { + ctx.status(HttpStatus.NO_CONTENT) + } else { + ctx.status(HttpStatus.UNAUTHORIZED) + .json(ErrorResponse("Invalid or expired session")) + } + } } } diff --git a/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt index c6f8e46..83914aa 100644 --- a/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt +++ b/src/main/kotlin/dev/mduchene/bolts/web/HttpSupport.kt @@ -5,12 +5,18 @@ import io.javalin.http.Context import io.javalin.http.HttpStatus internal fun Context.requireAdmin(loginService: LoginService): Boolean { - val token = header("Authorization")?.removePrefix("Bearer ") + val token = bearerToken() if (loginService.userForToken(token)?.role == "admin") return true status(HttpStatus.FORBIDDEN).json(ErrorResponse("Admin access required")) return false } +internal fun Context.bearerToken(): String? = + header("Authorization") + ?.takeIf { it.startsWith("Bearer ") } + ?.removePrefix("Bearer ") + ?.takeIf(String::isNotBlank) + internal fun Context.requiredFormParam(name: String): String? { val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty) if (value == null) badRequest("$name is required") diff --git a/src/main/resources/db/migrations/20260729000000_add_session_expiration.sql b/src/main/resources/db/migrations/20260729000000_add_session_expiration.sql new file mode 100644 index 0000000..98ef26d --- /dev/null +++ b/src/main/resources/db/migrations/20260729000000_add_session_expiration.sql @@ -0,0 +1,2 @@ +ALTER TABLE sessions +ADD COLUMN IF NOT EXISTS expired_at TIMESTAMP WITH TIME ZONE; diff --git a/src/test/kotlin/dev/mduchene/bolts/user/SessionRepositoryTest.kt b/src/test/kotlin/dev/mduchene/bolts/user/SessionRepositoryTest.kt new file mode 100644 index 0000000..f3519c3 --- /dev/null +++ b/src/test/kotlin/dev/mduchene/bolts/user/SessionRepositoryTest.kt @@ -0,0 +1,54 @@ +package dev.mduchene.bolts.user + +import dev.mduchene.bolts.persistence.Database +import dev.mduchene.bolts.persistence.DatabaseConfig +import java.sql.DriverManager +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SessionRepositoryTest { + @Test + fun `expiring a session retains it and invalidates its token`() { + val baseUrl = environmentOrDefault("TEST_DB_URL", "jdbc:postgresql://localhost:5432/postgres") + val username = environmentOrDefault("TEST_DB_USERNAME", "root") + val password = environmentOrDefault("TEST_DB_PASSWORD", "root") + val schema = "session_test_${UUID.randomUUID().toString().replace("-", "")}" + + DriverManager.getConnection(baseUrl, username, password).use { adminConnection -> + adminConnection.createStatement().use { it.execute("CREATE SCHEMA $schema") } + try { + val separator = if ("?" in baseUrl) "&" else "?" + val database = Database( + DatabaseConfig("$baseUrl${separator}currentSchema=$schema", username, password), + ) + database.initialize() + val user = UserRepository(database).create("member", "password-hash", "user") + val sessions = SessionRepository(database) + sessions.create("session-token", user.id) + + assertNotNull(sessions.findUserByToken("session-token")) + assertTrue(sessions.expire("session-token")) + assertNull(sessions.findUserByToken("session-token")) + assertFalse(sessions.expire("session-token")) + assertNotNull( + database.queryOne( + "SELECT expired_at FROM sessions WHERE token = ?", + bind = { setString(1, "session-token") }, + map = { getTimestamp("expired_at") }, + ), + ) + } finally { + adminConnection.createStatement().use { + it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") + } + } + } + } + + private fun environmentOrDefault(name: String, defaultValue: String): String = + System.getenv(name)?.takeIf { it.isNotBlank() } ?: defaultValue +}