session expiration / logout
This commit is contained in:
parent
63b8aaaafe
commit
604a82b7e8
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS expired_at TIMESTAMP WITH TIME ZONE;
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user