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' })
|
const adminRow = page.getByRole('row').filter({ hasText: 'admin' })
|
||||||
await expect(adminRow).toContainText('admin')
|
await expect(adminRow).toContainText('admin')
|
||||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
|
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 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) =
|
private fun Map<String, String>.valueOrDefault(name: String, default: String) =
|
||||||
this[name]?.takeIf { it.isNotBlank() } ?: default
|
this[name]?.takeIf { it.isNotBlank() } ?: default
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class SessionRepository(private val database: Database) {
|
|||||||
FROM sessions
|
FROM sessions
|
||||||
JOIN users ON users.id = sessions.user_id
|
JOIN users ON users.id = sessions.user_id
|
||||||
WHERE sessions.token = ?
|
WHERE sessions.token = ?
|
||||||
|
AND sessions.expired_at IS NULL
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
return database.queryOne(
|
return database.queryOne(
|
||||||
@ -26,4 +27,15 @@ class SessionRepository(private val database: Database) {
|
|||||||
map = { toUser() },
|
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"))
|
.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
|
import io.javalin.http.HttpStatus
|
||||||
|
|
||||||
internal fun Context.requireAdmin(loginService: LoginService): Boolean {
|
internal fun Context.requireAdmin(loginService: LoginService): Boolean {
|
||||||
val token = header("Authorization")?.removePrefix("Bearer ")
|
val token = bearerToken()
|
||||||
if (loginService.userForToken(token)?.role == "admin") return true
|
if (loginService.userForToken(token)?.role == "admin") return true
|
||||||
status(HttpStatus.FORBIDDEN).json(ErrorResponse("Admin access required"))
|
status(HttpStatus.FORBIDDEN).json(ErrorResponse("Admin access required"))
|
||||||
return false
|
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? {
|
internal fun Context.requiredFormParam(name: String): String? {
|
||||||
val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty)
|
val value = formParam(name)?.trim()?.takeIf(String::isNotEmpty)
|
||||||
if (value == null) badRequest("$name is required")
|
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