add session table
This commit is contained in:
parent
25ab7a4f58
commit
ca62140e6f
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@ -1,13 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { auth, clearSession } from '@/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const signedIn = computed(() => Boolean(auth.token))
|
||||
|
||||
async function signOut () {
|
||||
clearSession()
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="page-glow" />
|
||||
|
||||
<main class="min-h-screen p-5 flex items-center">
|
||||
<router-view />
|
||||
</main>
|
||||
<div v-if="signedIn" class="app-shell">
|
||||
<aside class="drawer">
|
||||
<div class="brand"><span class="brand-mark">B</span><span>Bolts</span></div>
|
||||
|
||||
<nav aria-label="Main navigation">
|
||||
<router-link v-if="auth.role === 'admin'" to="/users">User Management</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="account">
|
||||
<div><strong>{{ auth.username }}</strong><span>{{ auth.role }}</span></div>
|
||||
<button type="button" @click="signOut">Sign out</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content"><router-view /></main>
|
||||
</div>
|
||||
|
||||
<main v-else class="login-main"><router-view /></main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@ -25,6 +50,24 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
.drawer { position: fixed; inset: 0 auto 0 0; display: flex; width: 16rem; padding: 1.5rem; flex-direction: column; border-right: 1px solid var(--v0-divider); background: var(--v0-surface); }
|
||||
.brand { display: flex; align-items: center; gap: 0.7rem; margin-bottom: 2.5rem; font-size: 1.15rem; font-weight: 700; }
|
||||
.brand-mark { display: grid; width: 2.25rem; height: 2.25rem; place-items: center; border-radius: 0.7rem; color: var(--v0-on-primary); background: var(--v0-primary); }
|
||||
nav a { display: block; padding: 0.8rem 0.9rem; border-radius: 0.65rem; color: var(--v0-on-surface-variant); text-decoration: none; }
|
||||
nav a.router-link-active { color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 12%, transparent); font-weight: 700; }
|
||||
.account { display: flex; margin-top: auto; padding-top: 1rem; align-items: center; justify-content: space-between; border-top: 1px solid var(--v0-divider); }
|
||||
.account div { display: flex; flex-direction: column; }
|
||||
.account span { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: capitalize; }
|
||||
.account button { border: 0; color: var(--v0-primary); background: transparent; cursor: pointer; }
|
||||
.content { width: 100%; min-height: 100vh; margin-left: 16rem; padding: 3rem; }
|
||||
.login-main { display: flex; min-height: 100vh; padding: 1.25rem; align-items: center; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.drawer { width: 12rem; }
|
||||
.content { margin-left: 12rem; padding: 2rem 1.25rem; }
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
|
||||
25
frontend/src/auth.ts
Normal file
25
frontend/src/auth.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
interface StoredSession {
|
||||
username: string
|
||||
role: string
|
||||
token: string
|
||||
}
|
||||
|
||||
const savedSession = sessionStorage.getItem('bolts-session')
|
||||
|
||||
export const auth = reactive<StoredSession>(
|
||||
savedSession
|
||||
? JSON.parse(savedSession) as StoredSession
|
||||
: { username: '', role: '', token: '' },
|
||||
)
|
||||
|
||||
export function saveSession (session: StoredSession) {
|
||||
Object.assign(auth, session)
|
||||
sessionStorage.setItem('bolts-session', JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function clearSession () {
|
||||
Object.assign(auth, { username: '', role: '', token: '' })
|
||||
sessionStorage.removeItem('bolts-session')
|
||||
}
|
||||
@ -1,16 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { saveSession } from '@/auth'
|
||||
|
||||
interface LoginResponse {
|
||||
username: string
|
||||
role: string
|
||||
token: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const authenticatedUser = ref('')
|
||||
|
||||
async function login () {
|
||||
loading.value = true
|
||||
@ -33,8 +37,9 @@
|
||||
}
|
||||
|
||||
const result = await response.json() as LoginResponse
|
||||
authenticatedUser.value = result.username
|
||||
saveSession(result)
|
||||
password.value = ''
|
||||
await router.push(result.role === 'admin' ? '/users' : '/')
|
||||
} catch {
|
||||
errorMessage.value = 'Unable to reach the server. Please try again.'
|
||||
} finally {
|
||||
@ -42,12 +47,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function reset () {
|
||||
authenticatedUser.value = ''
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -60,27 +59,7 @@
|
||||
<span>Bolts</span>
|
||||
</div>
|
||||
|
||||
<div v-if="authenticatedUser" class="success-panel" role="status">
|
||||
<div aria-hidden="true" class="success-icon">
|
||||
✓
|
||||
</div>
|
||||
|
||||
<p class="eyebrow">
|
||||
Signed in
|
||||
</p>
|
||||
|
||||
<h1>Welcome, {{ authenticatedUser }}</h1>
|
||||
|
||||
<p class="supporting">
|
||||
You’re securely connected to your Bolts workspace.
|
||||
</p>
|
||||
|
||||
<button class="secondary-button" type="button" @click="reset">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form v-else class="login-card" @submit.prevent="login">
|
||||
<form class="login-card" @submit.prevent="login">
|
||||
<div class="card-heading">
|
||||
<p class="eyebrow">
|
||||
Welcome back
|
||||
|
||||
78
frontend/src/pages/users.vue
Normal file
78
frontend/src/pages/users.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.token || auth.role !== 'admin') {
|
||||
await router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
})
|
||||
if (!response.ok) throw new Error('User request failed')
|
||||
users.value = await response.json() as User[]
|
||||
} catch {
|
||||
errorMessage.value = 'Unable to load users.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="users-page">
|
||||
<header>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>User Management</h1>
|
||||
<p>View the people who can access this workspace.</p>
|
||||
</header>
|
||||
|
||||
<div class="user-card">
|
||||
<p v-if="loading">Loading users…</p>
|
||||
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Role</th></tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>{{ user.username }}</td>
|
||||
<td><span class="role-badge">{{ user.role }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page { width: 100%; max-width: 70rem; }
|
||||
header { margin-bottom: 2rem; }
|
||||
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
|
||||
header > p:last-child { color: var(--v0-on-surface-variant); }
|
||||
.user-card { overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||
.user-card > p { padding: 1.5rem; }
|
||||
table { width: 100%; border-collapse: collapse; text-align: left; }
|
||||
th, td { padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
|
||||
</style>
|
||||
@ -7,6 +7,7 @@
|
||||
// Composables
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '@/pages/index.vue'
|
||||
import Users from '@/pages/users.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@ -15,6 +16,10 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: Users,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@ -17,6 +17,9 @@ test('logs in with the initialized admin user', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Password', exact: true }).fill('admin')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Welcome, admin' })).toBeVisible()
|
||||
await expect(page).toHaveURL('/users')
|
||||
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible()
|
||||
const adminRow = page.getByRole('row').filter({ hasText: 'admin' })
|
||||
await expect(adminRow).toContainText('admin')
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible()
|
||||
})
|
||||
|
||||
@ -25,7 +25,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:7070',
|
||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:7070',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import dev.mduchene.bolts.persistence.DatabaseConfig
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
import dev.mduchene.bolts.user.SessionRepository
|
||||
import dev.mduchene.bolts.user.UserRepository
|
||||
import io.javalin.Javalin
|
||||
import io.javalin.http.HttpStatus
|
||||
@ -8,7 +9,8 @@ import io.javalin.http.HttpStatus
|
||||
fun main() {
|
||||
val database = Database(DatabaseConfig.fromEnvironment())
|
||||
database.initialize()
|
||||
val loginService = LoginService(UserRepository(database))
|
||||
val users = UserRepository(database)
|
||||
val loginService = LoginService(users, SessionRepository(database))
|
||||
loginService.initializeAdmin()
|
||||
|
||||
Javalin.create { config ->
|
||||
@ -18,17 +20,35 @@ fun main() {
|
||||
val username = ctx.formParam("username").orEmpty()
|
||||
val password = ctx.formParam("password").orEmpty()
|
||||
|
||||
if (loginService.authenticate(username, password)) {
|
||||
val session = loginService.authenticate(username, password)
|
||||
if (session != null) {
|
||||
ctx.contentType("application/json")
|
||||
.result("""{"username":"${username.toJsonString()}"}""")
|
||||
.result(
|
||||
"""{"username":"${session.user.username.toJsonString()}","role":"${session.user.role.toJsonString()}","token":"${session.token}"}""",
|
||||
)
|
||||
} else {
|
||||
ctx.status(HttpStatus.UNAUTHORIZED)
|
||||
.contentType("application/json")
|
||||
.result("""{"message":"Invalid username or password"}""")
|
||||
}
|
||||
}
|
||||
get("/api/users") { ctx ->
|
||||
val token = ctx.header("Authorization")?.removePrefix("Bearer ")
|
||||
val currentUser = loginService.userForToken(token)
|
||||
if (currentUser?.role != "admin") {
|
||||
ctx.status(HttpStatus.FORBIDDEN)
|
||||
.contentType("application/json")
|
||||
.result("""{"message":"Admin access required"}""")
|
||||
return@get
|
||||
}
|
||||
|
||||
val result = users.findAll().joinToString(prefix = "[", postfix = "]") { user ->
|
||||
"""{"id":${user.id},"username":"${user.username.toJsonString()}","role":"${user.role.toJsonString()}"}"""
|
||||
}
|
||||
ctx.contentType("application/json").result(result)
|
||||
}
|
||||
}
|
||||
}.start(7070)
|
||||
}.start(System.getenv("SERVER_PORT")?.toIntOrNull() ?: 7070)
|
||||
}
|
||||
|
||||
private fun String.toJsonString() = buildString {
|
||||
|
||||
@ -11,6 +11,8 @@ class Database(private val config: DatabaseConfig) {
|
||||
getConnection().use { connection ->
|
||||
connection.createStatement().use { statement ->
|
||||
statement.execute(CREATE_USERS_TABLE)
|
||||
statement.execute(ADD_USER_ROLE)
|
||||
statement.execute(CREATE_SESSIONS_TABLE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,5 +25,18 @@ class Database(private val config: DatabaseConfig) {
|
||||
password_hash VARCHAR(255) NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
const val ADD_USER_ROLE = """
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user'
|
||||
"""
|
||||
|
||||
const val CREATE_SESSIONS_TABLE = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token VARCHAR(36) PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,24 +1,37 @@
|
||||
package dev.mduchene.bolts.user
|
||||
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
import java.util.UUID
|
||||
|
||||
class LoginService(
|
||||
private val users: UserRepository,
|
||||
private val sessions: SessionRepository,
|
||||
private val environment: Map<String, String> = System.getenv(),
|
||||
) {
|
||||
data class Session(val token: String, val user: User)
|
||||
|
||||
fun initializeAdmin() {
|
||||
val username = environment.valueOrDefault("ADMIN_USERNAME", "admin")
|
||||
if (users.findByUsername(username) == null) {
|
||||
val existingUser = users.findByUsername(username)
|
||||
if (existingUser == null) {
|
||||
val password = environment.valueOrDefault("ADMIN_PASSWORD", "admin")
|
||||
users.create(username, BCrypt.hashpw(password, BCrypt.gensalt()))
|
||||
users.create(username, BCrypt.hashpw(password, BCrypt.gensalt()), "admin")
|
||||
} else if (existingUser.role != "admin") {
|
||||
users.updateRole(existingUser.id, "admin")
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(username: String, password: String): Boolean {
|
||||
val user = users.findByUsername(username) ?: return false
|
||||
return BCrypt.checkpw(password, user.passwordHash)
|
||||
fun authenticate(username: String, password: String): Session? {
|
||||
val user = users.findByUsername(username) ?: return null
|
||||
if (!BCrypt.checkpw(password, user.passwordHash)) return null
|
||||
|
||||
val token = UUID.randomUUID().toString()
|
||||
sessions.create(token, user.id)
|
||||
return Session(token, user)
|
||||
}
|
||||
|
||||
fun userForToken(token: String?): User? = token?.let(sessions::findUserByToken)
|
||||
|
||||
private fun Map<String, String>.valueOrDefault(name: String, default: String) =
|
||||
this[name]?.takeIf { it.isNotBlank() } ?: default
|
||||
}
|
||||
|
||||
43
src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt
Normal file
43
src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt
Normal file
@ -0,0 +1,43 @@
|
||||
package dev.mduchene.bolts.user
|
||||
|
||||
import dev.mduchene.bolts.persistence.Database
|
||||
import java.sql.ResultSet
|
||||
|
||||
class SessionRepository(private val database: Database) {
|
||||
fun create(token: String, userId: Long) {
|
||||
val sql = "INSERT INTO sessions (token, user_id) VALUES (?, ?)"
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, token)
|
||||
statement.setLong(2, userId)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findUserByToken(token: String): User? {
|
||||
val sql = """
|
||||
SELECT users.id, users.username, users.password_hash, users.role
|
||||
FROM sessions
|
||||
JOIN users ON users.id = sessions.user_id
|
||||
WHERE sessions.token = ?
|
||||
""".trimIndent()
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, token)
|
||||
statement.executeQuery().use { result ->
|
||||
return if (result.next()) result.toUser() else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultSet.toUser() = User(
|
||||
id = getLong("id"),
|
||||
username = getString("username"),
|
||||
passwordHash = getString("password_hash"),
|
||||
role = getString("role"),
|
||||
)
|
||||
}
|
||||
@ -4,4 +4,5 @@ data class User(
|
||||
val id: Long,
|
||||
val username: String,
|
||||
val passwordHash: String,
|
||||
val role: String,
|
||||
)
|
||||
|
||||
@ -4,17 +4,18 @@ import dev.mduchene.bolts.persistence.Database
|
||||
import java.sql.ResultSet
|
||||
|
||||
class UserRepository(private val database: Database) {
|
||||
fun create(username: String, passwordHash: String): User {
|
||||
fun create(username: String, passwordHash: String, role: String): User {
|
||||
val sql = """
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES (?, ?)
|
||||
RETURNING id, username, password_hash
|
||||
INSERT INTO users (username, password_hash, role)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, username, password_hash, role
|
||||
""".trimIndent()
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, username)
|
||||
statement.setString(2, passwordHash)
|
||||
statement.setString(3, role)
|
||||
statement.executeQuery().use { result ->
|
||||
check(result.next()) { "User insert returned no row" }
|
||||
return result.toUser()
|
||||
@ -25,7 +26,7 @@ class UserRepository(private val database: Database) {
|
||||
|
||||
fun findByUsername(username: String): User? {
|
||||
val sql = """
|
||||
SELECT id, username, password_hash
|
||||
SELECT id, username, password_hash, role
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""".trimIndent()
|
||||
@ -40,9 +41,35 @@ class UserRepository(private val database: Database) {
|
||||
}
|
||||
}
|
||||
|
||||
fun findAll(): List<User> {
|
||||
val sql = "SELECT id, username, password_hash, role FROM users ORDER BY username"
|
||||
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.executeQuery().use { result ->
|
||||
return buildList {
|
||||
while (result.next()) add(result.toUser())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRole(id: Long, role: String) {
|
||||
val sql = "UPDATE users SET role = ? WHERE id = ?"
|
||||
database.getConnection().use { connection ->
|
||||
connection.prepareStatement(sql).use { statement ->
|
||||
statement.setString(1, role)
|
||||
statement.setLong(2, id)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ResultSet.toUser() = User(
|
||||
id = getLong("id"),
|
||||
username = getString("username"),
|
||||
passwordHash = getString("password_hash"),
|
||||
role = getString("role"),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user