feat(auth): login, roles, user mgmt, setup wizard, and session guard

- New skald-setup crate: interactive first-run wizard that creates the
  admin user, prompts for encryption choice and password
- Auth system: session-based login/logout with cookie, guard middleware
- Roles API: CRUD for data-driven roles, seeded on first boot
- Users management API: create, list, edit, delete users
- Setup state API: check if first admin has been created
- Frontend: login-page, setup-page, users-page, roles-page, profile-page
  components with corresponding CSS
- Topbar: avatar dropdown with profile link and logout
- Sidebar: nav entries for Users and Roles (admin only)
- Page shell CSS: layout support for the new pages
- build.sh: builds both skald and skald-setup binaries
- run.sh: runs skald-setup before the server loop
- CLAUDE.md: updated workspace layout and build/run docs
This commit is contained in:
2026-07-10 19:19:25 +01:00
parent 178a38357e
commit 7dd77d4ef4
36 changed files with 2660 additions and 27 deletions
+192
View File
@@ -0,0 +1,192 @@
use std::sync::Arc;
use axum::{
Json,
extract::Extension,
extract::State,
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use skald_core::auth::COOKIE_NAME;
use skald_core::skald::Skald;
use super::guard::AuthUser;
use super::ApiError;
// ── POST /api/auth/login ─────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResult {
pub ok: bool,
}
/// Authenticates the user, unlocks their database (pool stays open until
/// restart), mints a session token, and sets it as an `HttpOnly` cookie.
pub async fn login(
State(skald): State<Arc<Skald>>,
Json(body): Json<LoginBody>,
) -> Result<Response, ApiError> {
let token = skald
.sessions()
.login(&body.username, &body.password)
.await
.map_err(|_| {
ApiError::bad_request("Invalid username or password")
})?;
let cookie = format!(
"{COOKIE_NAME}={token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=2592000"
);
Ok((
StatusCode::OK,
[(header::SET_COOKIE, cookie)],
Json(LoginResult { ok: true }),
)
.into_response())
}
// ── GET /api/auth/me ─────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct MeResponse {
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
}
/// Returns the authenticated user's profile, or 401 if no valid session.
pub async fn me(
State(skald): State<Arc<Skald>>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let token = match extract_session_token(&headers) {
Some(t) => t,
None => return Ok(StatusCode::UNAUTHORIZED.into_response()),
};
let user_id = match skald.sessions().user_of(&token) {
Some(id) => id,
None => return Ok(StatusCode::UNAUTHORIZED.into_response()),
};
let user = skald
.users()
.get(&user_id)
.await?
.ok_or_else(|| ApiError::not_found("user not found"))?;
Ok(Json(MeResponse {
username: user.username,
display_name: user.display_name,
role_id: user.role_id,
})
.into_response())
}
// ── POST /api/auth/logout ────────────────────────────────────────────────────
pub async fn logout(
State(skald): State<Arc<Skald>>,
headers: HeaderMap,
) -> Response {
if let Some(token) = extract_session_token(&headers) {
skald.sessions().logout(&token);
}
// Clear the cookie.
let expired = format!(
"{COOKIE_NAME}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0"
);
(
StatusCode::OK,
[(header::SET_COOKIE, expired)],
Json(serde_json::json!({ "ok": true })),
)
.into_response()
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Parses the `Cookie` header and extracts the session token.
fn extract_session_token(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for pair in raw.split(';') {
let pair = pair.trim();
if let Some(val) = pair.strip_prefix(&format!("{COOKIE_NAME}=")) {
return Some(val.to_string());
}
}
None
}
// ── PUT /api/auth/profile — update display name ──────────────────────────────
#[derive(Deserialize)]
pub struct UpdateProfileBody {
pub display_name: Option<String>,
}
pub async fn update_profile(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<UpdateProfileBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = skald
.users()
.get(&auth.user_id)
.await?
.ok_or_else(|| ApiError::not_found("user not found"))?;
skald_core::db::users::rename(
skald.db(),
&auth.user_id,
&user.username,
body.display_name.as_deref(),
)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── POST /api/auth/change-password ───────────────────────────────────────────
#[derive(Deserialize)]
pub struct ChangePasswordBody {
pub current_password: Option<String>,
pub new_password: String,
}
pub async fn change_password(
State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<ChangePasswordBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
if body.new_password.is_empty() {
return Err(ApiError::bad_request("new password must not be empty"));
}
skald
.users()
.change_password(
&auth.user_id,
body.current_password.as_deref(),
Some(&body.new_password),
)
.await
.map_err(|e| match e {
skald_core::users::AuthError::WrongPassword => {
ApiError::bad_request("Current password is incorrect")
}
other => ApiError::bad_request(other.to_string()),
})?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+82
View File
@@ -0,0 +1,82 @@
//! Authentication gate for `/api` — deny by default.
//!
//! Applied as a layer over the whole API router, so **every** endpoint requires
//! a valid session cookie except the handful named in [`is_public`]. A new route
//! is therefore protected the moment it is added, with no extra step; opening it
//! up is the deliberate act.
//!
//! Kept separate from the `auth` handlers on purpose: this is the cross-cutting
//! gate, they are the login/logout endpoints. On a hit it resolves the session
//! once and injects [`AuthUser`] into the request, so downstream handlers — and
//! the per-user pool routing that will land with the multi-user split — read the
//! identity without re-parsing the cookie.
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use skald_core::auth::COOKIE_NAME;
use skald_core::skald::Skald;
/// The authenticated user, injected into request extensions by [`require_auth`]
/// for every gated endpoint. This is where per-user pool routing will read the
/// id once the database split (blueprint §5.1) reaches the call sites.
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user_id: String,
}
/// Endpoints reachable without a session. Everything else under `/api` needs a
/// valid cookie.
///
/// - `auth/login` — the bootstrap: you cannot have a session before it.
/// - `auth/logout` — idempotent; tolerating an already-expired session is kinder
/// than answering 401 to someone trying to log out.
/// - `auth/me` — the canonical "am I logged in?" probe; it answers 401 itself.
/// - `setup/status` / `setup/user` — called before the first user exists, and
/// `create_user` already refuses once one does.
fn is_public(path: &str) -> bool {
// The layer sits on the inner router, so the path arrives without the `/api`
// nest prefix — but strip it defensively so a move of the layer can't
// silently open everything up.
let p = path.strip_prefix("/api").unwrap_or(path);
matches!(
p,
"/auth/login" | "/auth/logout" | "/auth/me" | "/setup/status" | "/setup/user"
)
}
/// Extracts the session token from the `Cookie` header.
fn session_token(headers: &axum::http::HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
raw.split(';')
.filter_map(|pair| pair.trim().strip_prefix(&format!("{COOKIE_NAME}=")))
.next()
.map(str::to_owned)
}
/// The gate. Public paths pass through untouched; every other request must carry
/// a cookie that maps to a live session, or it gets 401 before reaching a
/// handler.
pub async fn require_auth(
State(skald): State<Arc<Skald>>,
mut req: Request,
next: Next,
) -> Response {
if is_public(req.uri().path()) {
return next.run(req).await;
}
match session_token(req.headers()).and_then(|t| skald.sessions().user_of(&t)) {
Some(user_id) => {
req.extensions_mut().insert(AuthUser { user_id });
next.run(req).await
}
None => StatusCode::UNAUTHORIZED.into_response(),
}
}
+21
View File
@@ -1,10 +1,12 @@
pub mod agents;
pub mod auth;
pub mod commands;
pub mod config;
pub mod approval;
pub mod cron;
pub mod dev;
pub mod file_watch;
pub mod guard;
pub mod stats;
pub mod files;
pub mod image_generate_models;
@@ -15,12 +17,15 @@ pub mod mcp;
pub mod mcp_media;
pub mod plugins;
pub mod projects;
pub mod roles;
pub mod run_context;
pub mod sessions;
pub mod setup;
pub mod transcribe_audio;
pub mod transcribe_models;
pub mod tts_models;
pub mod uploads;
pub mod users_mgmt;
pub mod ws;
pub mod ws_session;
@@ -44,6 +49,15 @@ pub fn router() -> Router<Arc<Skald>> {
// Custom slash commands (file-based, read-only listing for autocomplete + /help)
.route("/commands", get(commands::list))
.route("/sessions", get(sessions::list_sessions).post(sessions::create))
// First-run setup
.route("/setup/status", get(setup::status))
.route("/setup/user", post(setup::create_user))
// Auth
.route("/auth/login", post(auth::login))
.route("/auth/me", get(auth::me))
.route("/auth/logout", post(auth::logout))
.route("/auth/profile", put(auth::update_profile))
.route("/auth/change-password", post(auth::change_password))
.route("/sessions/{id}", get(sessions::get_session_detail))
.route("/web/messages", get(sessions::web_messages))
.route("/{source}/messages", get(sessions::source_messages))
@@ -130,6 +144,13 @@ pub fn router() -> Router<Arc<Skald>> {
// Plugins
.route("/plugins", get(plugins::list))
.route("/plugins/{id}", put(plugins::update))
// Roles
.route("/roles", get(roles::list).post(roles::create))
.route("/roles/{id}", put(roles::update).delete(roles::delete))
// User management
.route("/users", get(users_mgmt::list).post(users_mgmt::create))
.route("/users/{id}", put(users_mgmt::update).delete(users_mgmt::delete))
.route("/users/{id}/password", post(users_mgmt::reset_password))
// Images (generated by image_generate tool)
.route("/images/{task_id}", get(images::get_image))
// MCP tool-result media (images/audio/files returned by MCP servers)
+78
View File
@@ -0,0 +1,78 @@
use std::sync::Arc;
use axum::{Json, extract::{Path, State}};
use serde::Deserialize;
use skald_core::db::roles::{self, ADMIN_ROLE_ID, Role};
use skald_core::skald::Skald;
use super::ApiError;
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<Role>>, ApiError> {
let roles = roles::list(skald.db()).await?;
Ok(Json(roles))
}
#[derive(Deserialize)]
pub struct CreateBody {
pub id: String,
pub label: String,
pub permission_group: String,
pub attrs: Option<String>,
}
pub async fn create(
State(skald): State<Arc<Skald>>,
Json(body): Json<CreateBody>,
) -> Result<Json<Role>, ApiError> {
let id = body.id.trim();
if id.is_empty() || id == ADMIN_ROLE_ID {
return Err(ApiError::bad_request("invalid role id"));
}
if body.label.trim().is_empty() {
return Err(ApiError::bad_request("label must not be empty"));
}
roles::insert(skald.db(), id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
.await?;
let role = roles::get(skald.db(), id).await?.ok_or_else(|| ApiError::not_found("role not found after insert"))?;
Ok(Json(role))
}
#[derive(Deserialize)]
pub struct UpdateBody {
pub label: String,
pub permission_group: String,
pub attrs: Option<String>,
}
pub async fn update(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Result<Json<Role>, ApiError> {
if id == ADMIN_ROLE_ID {
return Err(ApiError::bad_request("the built-in admin role cannot be modified"));
}
let ok = roles::update(skald.db(), &id, body.label.trim(), &body.permission_group, body.attrs.as_deref())
.await?;
if !ok {
return Err(ApiError::not_found("role not found"));
}
let role = roles::get(skald.db(), &id).await?.ok_or_else(|| ApiError::not_found("role not found after update"))?;
Ok(Json(role))
}
pub async fn delete(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
if id == ADMIN_ROLE_ID {
return Err(ApiError::bad_request("the built-in admin role cannot be deleted"));
}
let count = roles::user_count(skald.db(), &id).await?;
if count > 0 {
return Err(ApiError::bad_request(format!("{count} user(s) still assigned to this role")));
}
roles::delete(skald.db(), &id).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+64
View File
@@ -0,0 +1,64 @@
use std::sync::Arc;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use skald_core::skald::Skald;
use super::ApiError;
// ── GET /api/setup/status ────────────────────────────────────────────────────
//
// `needs_setup` is true when no user has ever been created. The frontend uses
// this to decide whether to show the first-run setup screen.
#[derive(Serialize)]
pub struct SetupStatus {
pub needs_setup: bool,
}
pub async fn status(State(skald): State<Arc<Skald>>) -> Result<Json<SetupStatus>, ApiError> {
let count = skald.users().count().await?;
Ok(Json(SetupStatus { needs_setup: count == 0 }))
}
// ── POST /api/setup/user — create the first (admin) user ────────────────────
#[derive(Deserialize)]
pub struct CreateUserBody {
pub username: String,
pub password: String,
#[serde(default)]
pub encrypted: bool,
}
#[derive(Serialize)]
pub struct CreateUserResult {
pub user_id: String,
}
pub async fn create_user(
State(skald): State<Arc<Skald>>,
Json(body): Json<CreateUserBody>,
) -> Result<Json<CreateUserResult>, ApiError> {
// Guard: the setup endpoint is only available before the first user exists.
let count = skald.users().count().await?;
if count > 0 {
return Err(ApiError::bad_request("setup is already complete"));
}
let username = body.username.trim();
if username.is_empty() {
return Err(ApiError::bad_request("username must not be empty"));
}
if body.password.is_empty() {
return Err(ApiError::bad_request("password must not be empty"));
}
let id = skald
.users()
.register_user(username, None, "admin", Some(&body.password), body.encrypted)
.await?;
Ok(Json(CreateUserResult { user_id: id }))
}
+120
View File
@@ -0,0 +1,120 @@
use std::sync::Arc;
use axum::{Json, extract::{Path, State}};
use serde::{Deserialize, Serialize};
use skald_core::db::users::UserSummary;
use skald_core::skald::Skald;
use super::ApiError;
// ── GET /api/users ───────────────────────────────────────────────────────────
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<UserSummary>>, ApiError> {
let users = skald.users().list().await?;
Ok(Json(users))
}
// ── POST /api/users ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CreateUserBody {
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
pub password: String,
#[serde(default)]
pub encrypted: bool,
}
#[derive(Serialize)]
pub struct CreatedUser {
pub id: String,
}
pub async fn create(
State(skald): State<Arc<Skald>>,
Json(body): Json<CreateUserBody>,
) -> Result<Json<CreatedUser>, ApiError> {
let username = body.username.trim();
if username.is_empty() {
return Err(ApiError::bad_request("username must not be empty"));
}
if body.password.is_empty() {
return Err(ApiError::bad_request("password must not be empty"));
}
let id = skald
.users()
.register_user(username, body.display_name.as_deref(), &body.role_id, Some(&body.password), body.encrypted)
.await?;
Ok(Json(CreatedUser { id }))
}
// ── PUT /api/users/:id ───────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct UpdateUserBody {
pub username: String,
pub display_name: Option<String>,
pub role_id: String,
#[serde(default)]
pub active: bool,
}
pub async fn update(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
Json(body): Json<UpdateUserBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
let username = body.username.trim();
if username.is_empty() {
return Err(ApiError::bad_request("username must not be empty"));
}
skald_core::db::users::update_profile(
skald.db(),
&id,
username,
body.display_name.as_deref(),
&body.role_id,
)
.await?;
// active is separate because it's a boolean flip
skald_core::db::users::set_active(skald.db(), &id, body.active).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── DELETE /api/users/:id ────────────────────────────────────────────────────
pub async fn delete(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
skald.users().delete_user(&id).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
// ── POST /api/users/:id/password ─────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ResetPasswordBody {
pub password: String,
}
pub async fn reset_password(
State(skald): State<Arc<Skald>>,
Path(id): Path<String>,
Json(body): Json<ResetPasswordBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
if body.password.is_empty() {
return Err(ApiError::bad_request("password must not be empty"));
}
// change_password with no old password and a new one resets it. For
// encrypted users the admin must supply the old password; this endpoint
// is for cleartext users or for admin-initiated resets of cleartext
// accounts. For encrypted users, the user should change their own password.
skald.users().change_password(&id, None, Some(&body.password)).await
.map_err(|e| ApiError::bad_request(e.to_string()))?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+10 -1
View File
@@ -70,10 +70,19 @@ impl WebServer {
skald: Arc<Skald>,
plugin_routers: Vec<(String, Router)>,
) -> Router {
// Gate the API behind a session cookie (deny by default; see
// `api::guard`). The layer sits on the inner router so it runs before the
// `/api` prefix is nested away, and it carries its own state so it works
// before `with_state` resolves the router's.
let api = api::router().layer(axum::middleware::from_fn_with_state(
Arc::clone(&skald),
api::guard::require_auth,
));
// Resolve the app state first so the resulting `Router<()>` can host the
// stateless plugin routers via `nest`.
let mut router = Router::new()
.nest("/api", api::router())
.nest("/api", api)
.with_state(skald);
for (id, plugin_router) in plugin_routers {
router = router.nest(&format!("/api/plugin/{id}"), plugin_router);