session expiration / logout

This commit is contained in:
Maxime Duchêne-Savard 2026-07-30 13:54:45 -04:00
parent 63b8aaaafe
commit 604a82b7e8
7 changed files with 102 additions and 1 deletions

View File

@ -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)
})

View File

@ -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<String, String>.valueOrDefault(name: String, default: String) =
this[name]?.takeIf { it.isNotBlank() } ?: default
}

View File

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

View File

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

View File

@ -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")

View File

@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS expired_at TIMESTAMP WITH TIME ZONE;

View File

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