- LICENSE (MIT), CHANGELOG for 0.1.0, README overhaul - docker-compose.release.yml pulling the registry image - Dockerfile cross-compiles the server with cargo-zigbuild for amd64 and arm64; the terminal client is no longer built into the image - Server, install script and settings point to a source install of the terminal client when no prebuilt binaries are available (/info: cli_builds) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
769 lines
32 KiB
Rust
769 lines
32 KiB
Rust
//! Users, sign-in (local passwords + OpenID Connect) and access control.
|
||
|
||
use crate::api::{ApiError, ApiResult};
|
||
use crate::app::App;
|
||
use crate::config::expand_env;
|
||
use crate::store::User;
|
||
use anyhow::{Context, Result, anyhow, bail};
|
||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||
use axum::extract::{Path, Query, Request, State};
|
||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||
use axum::middleware::Next;
|
||
use axum::response::{IntoResponse, Redirect, Response};
|
||
use axum::{Extension, Json};
|
||
use base64::Engine;
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
use sha2::{Digest, Sha256};
|
||
use std::sync::Arc;
|
||
|
||
pub const COOKIE: &str = "codecraft_session";
|
||
const MIN_PASSWORD: usize = 8;
|
||
|
||
// --- primitives ---------------------------------------------------------------
|
||
|
||
pub fn random_token(bytes: usize) -> String {
|
||
let mut buf = vec![0u8; bytes];
|
||
rand::fill(&mut buf[..]);
|
||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
|
||
}
|
||
|
||
pub fn sha256_hex(s: &str) -> String {
|
||
Sha256::digest(s.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
|
||
}
|
||
|
||
pub fn hash_password(password: &str) -> Result<String> {
|
||
let mut salt = [0u8; 16];
|
||
rand::fill(&mut salt);
|
||
let hash = Argon2::default()
|
||
.hash_password_with_salt(password.as_bytes(), &salt)
|
||
.map_err(|e| anyhow!("hashing failed: {e}"))?;
|
||
Ok(hash.to_string())
|
||
}
|
||
|
||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||
Argon2::default().verify_password(password.as_bytes(), hash).is_ok()
|
||
}
|
||
|
||
fn valid_username(name: &str) -> bool {
|
||
!name.is_empty()
|
||
&& name.len() <= 64
|
||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||
&& !name.starts_with('.')
|
||
}
|
||
|
||
/// Usernames become directory names; derive a safe one from an IdP claim.
|
||
fn sanitize_username(raw: &str) -> String {
|
||
let local = raw.split('@').next().unwrap_or(raw);
|
||
let s: String = local
|
||
.chars()
|
||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c.to_ascii_lowercase() } else { '_' })
|
||
.collect();
|
||
s.trim_start_matches('.').chars().take(64).collect()
|
||
}
|
||
|
||
// --- cookies & middleware -------------------------------------------------------
|
||
|
||
fn cookie_secure(app: &App, headers: &HeaderMap) -> bool {
|
||
match app.config().auth.cookie_secure.as_str() {
|
||
"true" => true,
|
||
"false" => false,
|
||
_ => {
|
||
app.config().auth.public_url.starts_with("https://")
|
||
|| headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) == Some("https")
|
||
}
|
||
}
|
||
}
|
||
|
||
fn session_cookie(app: &App, headers: &HeaderMap, token: &str, max_age_secs: i64) -> HeaderValue {
|
||
let secure = if cookie_secure(app, headers) { "; Secure" } else { "" };
|
||
HeaderValue::from_str(&format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}{secure}"))
|
||
.expect("valid cookie")
|
||
}
|
||
|
||
pub fn token_from_headers(headers: &HeaderMap) -> Option<String> {
|
||
if let Some(t) = headers
|
||
.get(header::AUTHORIZATION)
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.strip_prefix("Bearer "))
|
||
{
|
||
return Some(t.trim().to_string());
|
||
}
|
||
headers
|
||
.get_all(header::COOKIE)
|
||
.iter()
|
||
.filter_map(|v| v.to_str().ok())
|
||
.flat_map(|c| c.split(';'))
|
||
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(String::from))
|
||
}
|
||
|
||
/// Client address: the reverse proxy's X-Forwarded-For (the port is bound to localhost).
|
||
pub fn client_ip(headers: &HeaderMap) -> String {
|
||
headers
|
||
.get("x-forwarded-for")
|
||
.and_then(|v| v.to_str().ok())
|
||
.and_then(|v| v.split(',').next())
|
||
.or_else(|| headers.get("x-real-ip").and_then(|v| v.to_str().ok()))
|
||
.unwrap_or("local")
|
||
.trim()
|
||
.to_string()
|
||
}
|
||
|
||
const MAX_FAILURES: usize = 5;
|
||
const LOCK_MS: i64 = 15 * 60 * 1000;
|
||
|
||
/// True if this ip or username has too many recent failures.
|
||
fn locked(app: &App, keys: &[String]) -> bool {
|
||
let now = crate::types::now_ms();
|
||
let mut map = app.login_failures.lock().unwrap();
|
||
keys.iter().any(|k| {
|
||
let v = map.entry(k.clone()).or_default();
|
||
v.retain(|t| now - t < LOCK_MS);
|
||
v.len() >= MAX_FAILURES
|
||
})
|
||
}
|
||
|
||
fn record_failure(app: &App, keys: &[String]) {
|
||
let now = crate::types::now_ms();
|
||
let mut map = app.login_failures.lock().unwrap();
|
||
for k in keys {
|
||
map.entry(k.clone()).or_default().push(now);
|
||
}
|
||
}
|
||
|
||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||
let token = token_from_headers(headers)?;
|
||
let hash = sha256_hex(&token);
|
||
if token.starts_with(API_TOKEN_PREFIX) {
|
||
return app.store.user_by_api_token(&hash).ok().flatten();
|
||
}
|
||
app.store.user_by_token(&hash).ok().flatten()
|
||
}
|
||
|
||
/// Prefix of personal API tokens (CLI, scripts).
|
||
pub const API_TOKEN_PREFIX: &str = "cc_";
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct NewApiToken {
|
||
name: String,
|
||
/// Days until it expires; None = never.
|
||
days: Option<u32>,
|
||
/// "full" (default) or "read" (GET requests only).
|
||
scope: Option<String>,
|
||
}
|
||
|
||
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
|
||
}
|
||
|
||
/// Create a token; the plain value is returned once and only stored hashed.
|
||
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
|
||
let name = b.name.trim();
|
||
if name.is_empty() || name.len() > 80 {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
|
||
}
|
||
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||
let prefix: String = token.chars().take(10).collect();
|
||
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
|
||
let scope = match b.scope.as_deref() {
|
||
Some("read") => "read",
|
||
None | Some("full") => "full",
|
||
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
|
||
};
|
||
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
|
||
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
|
||
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
|
||
}
|
||
|
||
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
|
||
let ok = app.store.api_token_delete(&user.id, &id)?;
|
||
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
|
||
Ok(Json(serde_json::json!({"ok": ok})))
|
||
}
|
||
|
||
/// Paths reachable without a login.
|
||
fn is_public(path: &str) -> bool {
|
||
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||
.iter()
|
||
.any(|p| path.ends_with(p))
|
||
}
|
||
|
||
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
|
||
if is_public(req.uri().path()) {
|
||
return next.run(req).await;
|
||
}
|
||
match current_user(&app, req.headers()) {
|
||
Some(user) => {
|
||
// Read-only API tokens: no state-changing requests.
|
||
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
|
||
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
|
||
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
|
||
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
|
||
}
|
||
// Tokens cannot manage tokens.
|
||
if req.uri().path().contains("/account/tokens") {
|
||
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
|
||
}
|
||
}
|
||
req.extensions_mut().insert(user);
|
||
next.run(req).await
|
||
}
|
||
None => ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response(),
|
||
}
|
||
}
|
||
|
||
pub fn require_admin(user: &User) -> Result<(), ApiError> {
|
||
if user.is_admin() { Ok(()) } else { Err(ApiError(StatusCode::FORBIDDEN, "admin only".into())) }
|
||
}
|
||
|
||
fn issue_login(app: &App, headers: &HeaderMap, user: &User) -> Result<HeaderValue> {
|
||
let token = random_token(32);
|
||
let days = app.config().auth.session_days.max(1) as i64;
|
||
let ua = headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("");
|
||
app.store.add_token(&sha256_hex(&token), &user.id, crate::types::now_ms() + days * 86_400_000, &ua.chars().take(200).collect::<String>())?;
|
||
Ok(session_cookie(app, headers, &token, days * 86_400))
|
||
}
|
||
|
||
// --- bootstrap ------------------------------------------------------------------
|
||
|
||
/// Create the first admin from the environment, or generate a setup code.
|
||
pub fn bootstrap(app: &App) -> Result<()> {
|
||
if app.store.user_count()? > 0 {
|
||
return Ok(());
|
||
}
|
||
if let (Ok(user), Ok(pass)) = (std::env::var("CODECRAFT_ADMIN_USER"), std::env::var("CODECRAFT_ADMIN_PASSWORD")) {
|
||
if valid_username(&user) && pass.len() >= MIN_PASSWORD {
|
||
let u = app.store.create_user(&user, &user, "admin", Some(&hash_password(&pass)?), None)?;
|
||
let n = app.store.adopt_orphans(&u.id)?;
|
||
tracing::info!("created admin '{user}' from the environment ({n} existing sessions assigned)");
|
||
return Ok(());
|
||
}
|
||
tracing::warn!("CODECRAFT_ADMIN_USER/PASSWORD ignored: invalid username or password shorter than {MIN_PASSWORD}");
|
||
}
|
||
let code = random_token(9);
|
||
tracing::warn!("no users yet – open the web UI and create the first admin with setup code: {code}");
|
||
*app.setup_code.lock().unwrap() = Some(code);
|
||
Ok(())
|
||
}
|
||
|
||
// --- endpoints: session -------------------------------------------------------
|
||
|
||
pub async fn info(State(app): State<Arc<App>>, headers: HeaderMap) -> Json<Value> {
|
||
let cfg = app.config();
|
||
let user = current_user(&app, &headers);
|
||
Json(json!({
|
||
"name": "CodeCraft",
|
||
"version": crate::update::VERSION,
|
||
"commit": crate::update::commit(),
|
||
"update": if user.as_ref().is_some_and(|u| u.is_admin()) { app.update.latest.lock().unwrap().clone() } else { None },
|
||
"setup_required": app.setup_code.lock().unwrap().is_some(),
|
||
"cli_builds": crate::api::cli_builds_available(),
|
||
"oidc": if cfg.auth.oidc.enabled { json!({"label": cfg.auth.oidc.button_label}) } else { Value::Null },
|
||
"user": user,
|
||
}))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct LoginBody {
|
||
username: String,
|
||
password: String,
|
||
}
|
||
|
||
pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<LoginBody>) -> ApiResult<Response> {
|
||
let ip = client_ip(&headers);
|
||
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||
if locked(&app, &keys) {
|
||
app.store.audit(None, body.username.trim(), "login.blocked", "", &ip);
|
||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||
}
|
||
let found = app.store.user_by_name(body.username.trim())?;
|
||
// Verify even for unknown users so timing does not reveal which names exist.
|
||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||
record_failure(&app, &keys);
|
||
app.store.audit(None, body.username.trim(), "login.failed", "", &ip);
|
||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||
};
|
||
app.audit(Some(&user), "login", "password", &ip);
|
||
let cookie = issue_login(&app, &headers, &user)?;
|
||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CliLoginBody {
|
||
username: String,
|
||
password: String,
|
||
/// Shown in the token name ("CLI on <hostname>").
|
||
hostname: Option<String>,
|
||
}
|
||
|
||
/// Command line login: checks the password (same rate limit as the web login)
|
||
/// and creates a personal API token for this machine.
|
||
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
|
||
let ip = client_ip(&headers);
|
||
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||
if locked(&app, &keys) {
|
||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||
}
|
||
let found = app.store.user_by_name(body.username.trim())?;
|
||
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||
record_failure(&app, &keys);
|
||
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
|
||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||
};
|
||
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
|
||
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
|
||
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||
let prefix: String = token.chars().take(10).collect();
|
||
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
|
||
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
|
||
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
|
||
}
|
||
|
||
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
|
||
if let Some(t) = token_from_headers(&headers) {
|
||
let _ = app.store.delete_token(&sha256_hex(&t));
|
||
}
|
||
([(header::SET_COOKIE, session_cookie(&app, &headers, "", 0))], Json(json!({"ok": true}))).into_response()
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct SetupBody {
|
||
code: String,
|
||
username: String,
|
||
password: String,
|
||
}
|
||
|
||
pub async fn setup(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<SetupBody>) -> ApiResult<Response> {
|
||
let expected = app.setup_code.lock().unwrap().clone();
|
||
let Some(expected) = expected else {
|
||
return Err(ApiError(StatusCode::CONFLICT, "setup already completed".into()));
|
||
};
|
||
let ip = client_ip(&headers);
|
||
let keys = [format!("ip:{ip}")];
|
||
if locked(&app, &keys) {
|
||
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many attempts – try again in 15 minutes".into()));
|
||
}
|
||
if body.code.trim() != expected {
|
||
record_failure(&app, &keys);
|
||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid setup code".into()));
|
||
}
|
||
let name = body.username.trim();
|
||
if !valid_username(name) {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username".into()));
|
||
}
|
||
if body.password.len() < MIN_PASSWORD {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||
}
|
||
let user = app.store.create_user(name, name, "admin", Some(&hash_password(&body.password)?), None)?;
|
||
app.store.adopt_orphans(&user.id)?;
|
||
*app.setup_code.lock().unwrap() = None;
|
||
app.audit(Some(&user), "setup", "first admin created", &ip);
|
||
let cookie = issue_login(&app, &headers, &user)?;
|
||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct PasswordBody {
|
||
current: Option<String>,
|
||
new: String,
|
||
}
|
||
|
||
/// Change the own password (OIDC-only users may set one without `current`).
|
||
pub async fn change_password(State(app): State<Arc<App>>, Extension(user): Extension<User>, headers: HeaderMap, Json(body): Json<PasswordBody>) -> ApiResult {
|
||
if body.new.len() < MIN_PASSWORD {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||
}
|
||
if let Some((_, Some(hash))) = app.store.user_by_name(&user.username)? {
|
||
if !verify_password(body.current.as_deref().unwrap_or(""), &hash) {
|
||
return Err(ApiError(StatusCode::UNAUTHORIZED, "current password is wrong".into()));
|
||
}
|
||
}
|
||
app.store.update_user(&user.id, None, None, None, Some(&hash_password(&body.new)?), None)?;
|
||
// Sign out all other devices.
|
||
app.store.delete_user_tokens(&user.id)?;
|
||
let _ = issue_login(&app, &headers, &user)?;
|
||
Ok(Json(json!({"ok": true})))
|
||
}
|
||
|
||
// --- endpoints: user management (admin) ------------------------------------------
|
||
|
||
pub async fn list_users(Extension(me): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||
require_admin(&me)?;
|
||
Ok(Json(json!(app.store.users()?)))
|
||
}
|
||
|
||
/// Minimal user list for sharing dialogs (all signed-in users).
|
||
pub async fn directory(State(app): State<Arc<App>>) -> ApiResult {
|
||
let list: Vec<Value> = app
|
||
.store
|
||
.users()?
|
||
.into_iter()
|
||
.filter(|u| !u.disabled)
|
||
.map(|u| json!({"id": u.id, "username": u.username, "display_name": u.display_name}))
|
||
.collect();
|
||
Ok(Json(json!(list)))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CreateUserBody {
|
||
username: String,
|
||
#[serde(default)]
|
||
display_name: String,
|
||
password: Option<String>,
|
||
#[serde(default = "default_role")]
|
||
role: String,
|
||
}
|
||
|
||
fn default_role() -> String {
|
||
"user".into()
|
||
}
|
||
|
||
fn check_role(role: &str) -> Result<(), ApiError> {
|
||
if role == "admin" || role == "user" { Ok(()) } else { Err(ApiError(StatusCode::BAD_REQUEST, "role must be admin or user".into())) }
|
||
}
|
||
|
||
pub async fn create_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Json(body): Json<CreateUserBody>) -> ApiResult {
|
||
require_admin(&me)?;
|
||
let name = body.username.trim();
|
||
if !valid_username(name) {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid username (letters, digits, - _ .)".into()));
|
||
}
|
||
check_role(&body.role)?;
|
||
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
|
||
Some(p) if p.len() < MIN_PASSWORD => {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||
}
|
||
Some(p) => Some(hash_password(p)?),
|
||
None => None,
|
||
};
|
||
if app.store.user_by_name(name)?.is_some() {
|
||
return Err(ApiError(StatusCode::CONFLICT, "username already exists".into()));
|
||
}
|
||
let display = if body.display_name.trim().is_empty() { name } else { body.display_name.trim() };
|
||
let user = app.store.create_user(name, display, &body.role, hash.as_deref(), None)?;
|
||
app.audit(Some(&me), "user.create", format!("{} ({})", user.username, user.role), "");
|
||
Ok(Json(json!(user)))
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct UpdateUserBody {
|
||
display_name: Option<String>,
|
||
role: Option<String>,
|
||
disabled: Option<bool>,
|
||
password: Option<String>,
|
||
/// Monthly cost limit; a negative value removes it.
|
||
monthly_quota: Option<f64>,
|
||
}
|
||
|
||
pub async fn update_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>, Json(body): Json<UpdateUserBody>) -> ApiResult {
|
||
require_admin(&me)?;
|
||
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
|
||
if let Some(r) = &body.role {
|
||
check_role(r)?;
|
||
}
|
||
let loses_admin = target.is_admin() && !target.disabled && (body.role.as_deref().is_some_and(|r| r != "admin") || body.disabled == Some(true));
|
||
if loses_admin && app.store.admin_count()? <= 1 {
|
||
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be demoted or disabled".into()));
|
||
}
|
||
let hash = match body.password.as_deref().filter(|p| !p.is_empty()) {
|
||
Some(p) if p.len() < MIN_PASSWORD => {
|
||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("password must have at least {MIN_PASSWORD} characters")));
|
||
}
|
||
Some(p) => Some(hash_password(p)?),
|
||
None => None,
|
||
};
|
||
app.store.update_user(&id, body.display_name.as_deref(), body.role.as_deref(), body.disabled, hash.as_deref(), None)?;
|
||
if let Some(q) = body.monthly_quota {
|
||
app.store.set_user_quota(&id, (q >= 0.0).then_some(q))?;
|
||
}
|
||
let mut changes = Vec::new();
|
||
if let Some(r) = &body.role { changes.push(format!("role={r}")); }
|
||
if let Some(d) = body.disabled { changes.push(format!("disabled={d}")); }
|
||
if hash.is_some() { changes.push("password".into()); }
|
||
if let Some(q) = body.monthly_quota { changes.push(format!("quota={q}")); }
|
||
app.audit(Some(&me), "user.update", format!("{}: {}", target.username, changes.join(", ")), "");
|
||
if hash.is_some() {
|
||
app.store.delete_user_tokens(&id)?;
|
||
}
|
||
Ok(Json(json!(app.store.user(&id)?)))
|
||
}
|
||
|
||
pub async fn delete_user(Extension(me): Extension<User>, State(app): State<Arc<App>>, Path(id): Path<String>) -> ApiResult {
|
||
require_admin(&me)?;
|
||
let target = app.store.user(&id)?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "user not found".into()))?;
|
||
if target.id == me.id {
|
||
return Err(ApiError(StatusCode::CONFLICT, "you cannot delete yourself".into()));
|
||
}
|
||
if target.is_admin() && app.store.admin_count()? <= 1 {
|
||
return Err(ApiError(StatusCode::CONFLICT, "the last admin cannot be deleted".into()));
|
||
}
|
||
// Sessions stay (statistics); they become visible to admins only.
|
||
app.store.delete_user(&id)?;
|
||
app.audit(Some(&me), "user.delete", &target.username, "");
|
||
Ok(Json(json!({"ok": true})))
|
||
}
|
||
|
||
// --- OpenID Connect -----------------------------------------------------------------
|
||
|
||
pub struct OidcPending {
|
||
nonce: String,
|
||
verifier: String,
|
||
created: std::time::Instant,
|
||
}
|
||
|
||
async fn discovery(app: &App) -> Result<Value> {
|
||
let cfg = app.config();
|
||
let issuer = expand_env(&cfg.auth.oidc.issuer);
|
||
if let Some((iss, doc)) = app.oidc_discovery.lock().unwrap().clone() {
|
||
if iss == issuer {
|
||
return Ok(doc);
|
||
}
|
||
}
|
||
let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
|
||
let doc: Value = crate::provider::http_client().get(&url).send().await?.error_for_status()?.json().await?;
|
||
*app.oidc_discovery.lock().unwrap() = Some((issuer, doc.clone()));
|
||
Ok(doc)
|
||
}
|
||
|
||
fn redirect_uri(app: &App) -> Result<String> {
|
||
let base = app.config().auth.public_url.trim_end_matches('/').to_string();
|
||
if base.is_empty() {
|
||
bail!("auth.public_url must be set for single sign-on");
|
||
}
|
||
Ok(format!("{base}/api/auth/oidc/callback"))
|
||
}
|
||
|
||
fn error_page(message: &str) -> Response {
|
||
let msg = message.replace('&', "&").replace('<', "<").replace('>', ">");
|
||
(
|
||
StatusCode::FORBIDDEN,
|
||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||
format!("<!doctype html><meta charset=utf-8><title>CodeCraft</title><body style=\"font-family:sans-serif;padding:2em\"><h2>Sign-in failed</h2><p>{msg}</p><p><a href=\"/\">Back</a></p>"),
|
||
)
|
||
.into_response()
|
||
}
|
||
|
||
pub async fn oidc_start(State(app): State<Arc<App>>) -> Response {
|
||
match oidc_start_inner(&app).await {
|
||
Ok(url) => Redirect::to(&url).into_response(),
|
||
Err(e) => error_page(&format!("{e:#}")),
|
||
}
|
||
}
|
||
|
||
async fn oidc_start_inner(app: &App) -> Result<String> {
|
||
let cfg = app.config();
|
||
if !cfg.auth.oidc.enabled {
|
||
bail!("single sign-on is not enabled");
|
||
}
|
||
let doc = discovery(app).await?;
|
||
let endpoint = doc["authorization_endpoint"].as_str().context("no authorization_endpoint")?;
|
||
let state = random_token(24);
|
||
let nonce = random_token(24);
|
||
let verifier = random_token(48);
|
||
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
|
||
{
|
||
let mut pending = app.oidc_pending.lock().unwrap();
|
||
pending.retain(|_, p| p.created.elapsed().as_secs() < 600);
|
||
pending.insert(state.clone(), OidcPending { nonce: nonce.clone(), verifier, created: std::time::Instant::now() });
|
||
}
|
||
let mut url = reqwest::Url::parse(endpoint)?;
|
||
url.query_pairs_mut()
|
||
.append_pair("response_type", "code")
|
||
.append_pair("client_id", &expand_env(&cfg.auth.oidc.client_id))
|
||
.append_pair("redirect_uri", &redirect_uri(app)?)
|
||
.append_pair("scope", &cfg.auth.oidc.scopes.join(" "))
|
||
.append_pair("state", &state)
|
||
.append_pair("nonce", &nonce)
|
||
.append_pair("code_challenge", &challenge)
|
||
.append_pair("code_challenge_method", "S256");
|
||
Ok(url.to_string())
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
pub struct CallbackQuery {
|
||
code: Option<String>,
|
||
state: Option<String>,
|
||
error: Option<String>,
|
||
error_description: Option<String>,
|
||
}
|
||
|
||
pub async fn oidc_callback(State(app): State<Arc<App>>, headers: HeaderMap, Query(q): Query<CallbackQuery>) -> Response {
|
||
if let Some(e) = q.error {
|
||
return error_page(&format!("{e}: {}", q.error_description.unwrap_or_default()));
|
||
}
|
||
match oidc_callback_inner(&app, q.code.unwrap_or_default(), q.state.unwrap_or_default()).await {
|
||
Ok(user) => match { app.audit(Some(&user), "login", "oidc", &client_ip(&headers)); issue_login(&app, &headers, &user) } {
|
||
Ok(cookie) => ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response(),
|
||
Err(e) => error_page(&format!("{e:#}")),
|
||
},
|
||
Err(e) => {
|
||
tracing::warn!("oidc sign-in failed: {e:#}");
|
||
error_page(&format!("{e:#}"))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn decode_jwt_claims(jwt: &str) -> Result<Value> {
|
||
let payload = jwt.split('.').nth(1).context("malformed id_token")?;
|
||
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||
.decode(payload.trim_end_matches('='))
|
||
.context("malformed id_token payload")?;
|
||
Ok(serde_json::from_slice(&bytes)?)
|
||
}
|
||
|
||
fn claim_list(v: &Value) -> Vec<String> {
|
||
match v {
|
||
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(String::from)).collect(),
|
||
Value::String(s) => s.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(),
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
async fn oidc_callback_inner(app: &App, code: String, state: String) -> Result<User> {
|
||
let cfg = app.config();
|
||
let oc = &cfg.auth.oidc;
|
||
let pending = app.oidc_pending.lock().unwrap().remove(&state).context("unknown or expired sign-in attempt – please try again")?;
|
||
let doc = discovery(app).await?;
|
||
let token_endpoint = doc["token_endpoint"].as_str().context("no token_endpoint")?;
|
||
let client_id = expand_env(&oc.client_id);
|
||
let resp = crate::provider::http_client()
|
||
.post(token_endpoint)
|
||
.form(&[
|
||
("grant_type", "authorization_code"),
|
||
("code", code.as_str()),
|
||
("redirect_uri", redirect_uri(app)?.as_str()),
|
||
("client_id", client_id.as_str()),
|
||
("client_secret", expand_env(&oc.client_secret).as_str()),
|
||
("code_verifier", pending.verifier.as_str()),
|
||
])
|
||
.send()
|
||
.await?;
|
||
if !resp.status().is_success() {
|
||
bail!("token exchange failed: HTTP {} {}", resp.status(), resp.text().await.unwrap_or_default().chars().take(300).collect::<String>());
|
||
}
|
||
let tokens: Value = resp.json().await?;
|
||
// The id_token comes straight from the token endpoint over TLS, which
|
||
// authenticates the issuer (OIDC Core 3.1.3.7); we still check its claims.
|
||
let mut claims = decode_jwt_claims(tokens["id_token"].as_str().context("no id_token in response")?)?;
|
||
let issuer = expand_env(&oc.issuer);
|
||
if claims["iss"].as_str().map(|s| s.trim_end_matches('/')) != Some(issuer.trim_end_matches('/')) {
|
||
bail!("id_token issuer mismatch");
|
||
}
|
||
let aud_ok = match &claims["aud"] {
|
||
Value::String(a) => *a == client_id,
|
||
Value::Array(a) => a.iter().any(|x| x.as_str() == Some(&client_id)),
|
||
_ => false,
|
||
};
|
||
if !aud_ok {
|
||
bail!("id_token audience mismatch");
|
||
}
|
||
if claims["nonce"].as_str() != Some(pending.nonce.as_str()) {
|
||
bail!("id_token nonce mismatch");
|
||
}
|
||
if claims["exp"].as_i64().unwrap_or(0) < chrono::Utc::now().timestamp() {
|
||
bail!("id_token expired");
|
||
}
|
||
// Groups and names are often only in the userinfo response.
|
||
if let (Some(ep), Some(at)) = (doc["userinfo_endpoint"].as_str(), tokens["access_token"].as_str()) {
|
||
if let Ok(r) = crate::provider::http_client().get(ep).bearer_auth(at).send().await {
|
||
if let Ok(info) = r.json::<Value>().await {
|
||
if info["sub"] == claims["sub"] {
|
||
for (k, v) in info.as_object().cloned().unwrap_or_default() {
|
||
if claims.get(&k).is_none() {
|
||
claims[&k] = v;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let sub = claims["sub"].as_str().context("no sub claim")?.to_string();
|
||
let groups = claim_list(&claims[&oc.groups_claim]);
|
||
if !oc.allowed_group.is_empty() && !groups.contains(&oc.allowed_group) {
|
||
bail!("your account is not allowed to use CodeCraft");
|
||
}
|
||
let is_admin = !oc.admin_group.is_empty() && groups.contains(&oc.admin_group);
|
||
let display = claims["name"].as_str().unwrap_or("").to_string();
|
||
|
||
let user = if let Some(u) = app.store.user_by_oidc(&sub)? {
|
||
u
|
||
} else {
|
||
let raw = claims[&oc.username_claim]
|
||
.as_str()
|
||
.or_else(|| claims["preferred_username"].as_str())
|
||
.or_else(|| claims["email"].as_str())
|
||
.unwrap_or(&sub);
|
||
let name = sanitize_username(raw);
|
||
if !valid_username(&name) {
|
||
bail!("cannot derive a valid username from the '{}' claim", oc.username_claim);
|
||
}
|
||
match app.store.user_by_name(&name)? {
|
||
Some((existing, _)) if oc.link_by_username && existing.oidc_sub_free() => {
|
||
app.store.update_user(&existing.id, None, None, None, None, Some(&sub))?;
|
||
existing
|
||
}
|
||
Some(_) => bail!("the username '{name}' is already taken by a local account"),
|
||
None if oc.auto_create => {
|
||
let first = app.store.user_count()? == 0;
|
||
let role = if is_admin || first { "admin" } else { "user" };
|
||
let u = app.store.create_user(&name, if display.is_empty() { &name } else { &display }, role, None, Some(&sub))?;
|
||
if first {
|
||
app.store.adopt_orphans(&u.id)?;
|
||
*app.setup_code.lock().unwrap() = None;
|
||
}
|
||
u
|
||
}
|
||
None => bail!("no CodeCraft account exists for '{name}'"),
|
||
}
|
||
};
|
||
if user.disabled {
|
||
bail!("this account is disabled");
|
||
}
|
||
// Keep the role in sync with the IdP group (never removing the last admin).
|
||
if !oc.admin_group.is_empty() && user.is_admin() != is_admin && (is_admin || app.store.admin_count()? > 1) {
|
||
app.store.update_user(&user.id, None, Some(if is_admin { "admin" } else { "user" }), None, None, None)?;
|
||
}
|
||
Ok(app.store.user(&user.id)?.context("user vanished")?)
|
||
}
|
||
|
||
impl User {
|
||
fn oidc_sub_free(&self) -> bool {
|
||
!self.oidc
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn password_roundtrip() {
|
||
let h = hash_password("correct horse").unwrap();
|
||
assert!(verify_password("correct horse", &h));
|
||
assert!(!verify_password("wrong", &h));
|
||
}
|
||
|
||
#[test]
|
||
fn usernames() {
|
||
assert!(valid_username("zink"));
|
||
assert!(!valid_username("../etc"));
|
||
assert!(!valid_username(".hidden"));
|
||
assert_eq!(sanitize_username("Richard.Zink@example.org"), "richard.zink");
|
||
assert_eq!(sanitize_username("a/b c"), "a_b_c");
|
||
}
|
||
|
||
#[test]
|
||
fn jwt_claims() {
|
||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"1","aud":"x"}"#);
|
||
let v = decode_jwt_claims(&format!("h.{payload}.s")).unwrap();
|
||
assert_eq!(v["sub"], "1");
|
||
}
|
||
}
|