diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3e6fa3f..27d4984 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,13 +1,38 @@ - - - + + + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b66b893..f0bd102 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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, + }, ], }) diff --git a/frontend/tests/login.spec.ts b/frontend/tests/login.spec.ts index 13e87fe..8e07af1 100644 --- a/frontend/tests/login.spec.ts +++ b/frontend/tests/login.spec.ts @@ -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() }) diff --git a/frontend/vite.config.mts b/frontend/vite.config.mts index 31b9cf0..d23b907 100644 --- a/frontend/vite.config.mts +++ b/frontend/vite.config.mts @@ -25,7 +25,7 @@ export default defineConfig({ server: { port: 3000, proxy: { - '/api': 'http://localhost:7070', + '/api': process.env.VITE_API_TARGET ?? 'http://localhost:7070', }, }, }) diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 4f38794..5b050d1 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -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 { diff --git a/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt b/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt index c61939b..c25cc21 100644 --- a/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt +++ b/src/main/kotlin/dev/mduchene/bolts/persistence/Database.kt @@ -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 + ) + """ } } diff --git a/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt b/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt index 5c006cc..1be89c5 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/LoginService.kt @@ -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 = 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.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 new file mode 100644 index 0000000..c81fd36 --- /dev/null +++ b/src/main/kotlin/dev/mduchene/bolts/user/SessionRepository.kt @@ -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"), + ) +} diff --git a/src/main/kotlin/dev/mduchene/bolts/user/User.kt b/src/main/kotlin/dev/mduchene/bolts/user/User.kt index e6b96b7..d4400a2 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/User.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/User.kt @@ -4,4 +4,5 @@ data class User( val id: Long, val username: String, val passwordHash: String, + val role: String, ) diff --git a/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt b/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt index 2ae5c88..fc44570 100644 --- a/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt +++ b/src/main/kotlin/dev/mduchene/bolts/user/UserRepository.kt @@ -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 { + 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"), ) }