Files
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

1116 lines
39 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Json, Path, State};
use axum::http::{HeaderMap, header};
use axum::middleware::{from_fn, from_fn_with_state};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Router};
use serde::Deserialize;
use nomifun_api_types::{
ApiResponse, AuthStatusResponse, ChangePasswordRequest, CreateUserRequest, LoginRequest, LoginResponse,
PublicUser, QrLoginRequest, RefreshResponse, RefreshTokenRequest, UpdateUserRequest, UserInfoResponse,
UserListItem, WebuiChangePasswordRequest, WebuiChangeUsernameRequest, WebuiChangeUsernameResponse,
WebuiGenerateQrTokenResponse, WebuiResetPasswordResponse, WsTokenResponse,
};
use nomifun_common::AppError;
use nomifun_common::constants::COOKIE_MAX_AGE_DAYS;
use nomifun_db::{IUserRepository, models::User};
use crate::extract::extract_token_from_headers;
use crate::middleware::{AuthState, CurrentUser, auth_middleware, require_admin_middleware};
use crate::trust::require_local_trust_middleware;
use crate::password::{dummy_password_hash, generate_password, hash_password, verify_password_timed};
use crate::qr_token::QrTokenStore;
use crate::rate_limit::{
RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware, authenticated_action_rate_limit_middleware,
};
use crate::validation::{validate_password, validate_username};
use crate::{CookieConfig, JwtService};
/// Shared state for all auth route handlers.
#[derive(Clone)]
pub struct AuthRouterState {
pub jwt_service: Arc<JwtService>,
pub user_repo: Arc<dyn IUserRepository>,
pub cookie_config: Arc<CookieConfig>,
pub qr_token_store: Arc<QrTokenStore>,
}
#[derive(Debug, Deserialize)]
struct CreateInternalUserRequest {
username: String,
password_hash: String,
}
#[derive(Debug, Deserialize)]
struct SetSystemUserCredentialsRequest {
username: String,
password_hash: String,
}
#[derive(Debug, Deserialize)]
struct UpdatePasswordHashRequest {
password_hash: String,
}
#[derive(Debug, Deserialize)]
struct UpdateUsernameRequest {
username: String,
}
#[derive(Debug, Deserialize)]
struct UpdateJwtSecretRequest {
jwt_secret: String,
}
/// Build the auth router with all endpoints and middleware layers.
///
/// Returns a `Router` with these endpoints:
/// - `POST /login`
/// - `POST /api/auth/setup` (one-time first-run admin creation)
/// - `POST /logout`
/// - `GET /api/auth/status`
/// - `GET /api/auth/user`
/// - `POST /api/auth/change-password`
/// - `POST /api/auth/refresh`
/// - `GET /api/ws-token`
/// - `POST /api/auth/qr-login`
/// - `GET /qr-login`
/// - `GET /api/users` (admin-only)
/// - `POST /api/users` (admin-only)
/// - `GET /api/users/:id` (admin-only)
/// - `PUT /api/users/:id` (admin-only)
/// - `DELETE /api/users/:id` (admin-only)
/// - `POST /api/webui/change-password` (local-only)
/// - `POST /api/webui/change-username` (local-only)
/// - `POST /api/webui/reset-password` (local-only)
/// - `POST /api/webui/generate-qr-token` (local-only)
pub fn auth_routes(state: AuthRouterState) -> Router {
let auth_limiter = Arc::new(RateLimiter::auth());
let api_limiter = Arc::new(RateLimiter::api());
let action_limiter = Arc::new(RateLimiter::authenticated_action());
// Start periodic cleanup for rate limiters
let cleanup_interval = Duration::from_secs(60);
auth_limiter.start_cleanup_task(cleanup_interval);
api_limiter.start_cleanup_task(cleanup_interval);
action_limiter.start_cleanup_task(cleanup_interval);
let auth_state = AuthState {
jwt_service: state.jwt_service.clone(),
user_repo: state.user_repo.clone(),
};
// Auth rate limited routes (login, setup, qr-login)
let auth_rate_limited = Router::new()
.route("/login", post(login_handler))
.route("/api/auth/setup", post(setup_handler))
.route("/api/auth/qr-login", post(qr_login_handler))
.route_layer(from_fn_with_state(auth_limiter, auth_rate_limit_middleware))
.with_state(state.clone());
// Truly public, no-auth route: first-run/login status probe.
let api_public = Router::new()
.route("/api/auth/status", get(status_handler))
.route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware))
.with_state(state.clone());
// Local-only credential/internal routes. These have NO auth middleware, so
// they are gated by `require_local_trust_middleware`: only the local desktop
// client (which presents the per-boot trust secret, resolved upstream by
// `trust_resolve_middleware` into a `LocalTrusted` marker) may reach them.
let api_local_only = Router::new()
.route(
"/api/auth/internal/users",
get(list_internal_users_handler).post(create_internal_user_handler),
)
.route("/api/auth/internal/users/system", get(get_system_user_handler))
.route(
"/api/auth/internal/users/system/credentials",
post(set_system_user_credentials_handler),
)
.route(
"/api/auth/internal/users/by-username/{username}",
get(find_user_by_username_handler),
)
.route("/api/auth/internal/users/{id}", get(find_user_by_id_handler))
.route(
"/api/auth/internal/users/{id}/password",
post(update_user_password_hash_handler),
)
.route(
"/api/auth/internal/users/{id}/username",
post(update_user_username_handler),
)
.route(
"/api/auth/internal/users/{id}/jwt-secret",
post(update_user_jwt_secret_handler),
)
.route(
"/api/auth/internal/users/{id}/last-login",
post(update_user_last_login_handler),
)
// WebUI admin credential endpoints — local desktop client only.
.route("/api/webui/change-password", post(webui_change_password_handler))
.route("/api/webui/change-username", post(webui_change_username_handler))
.route("/api/webui/reset-password", post(webui_reset_password_handler))
.route("/api/webui/generate-qr-token", post(webui_generate_qr_token_handler))
.route_layer(from_fn(require_local_trust_middleware))
.route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware))
.with_state(state.clone());
// Authenticated routes: api limiter -> auth -> action limiter
// route_layer order: last added = outermost (first to process)
let authenticated = Router::new()
.route("/logout", post(logout_handler))
.route("/api/auth/user", get(user_handler))
.route("/api/auth/change-password", post(change_password_handler))
.route("/api/ws-token", get(ws_token_handler))
.route_layer(from_fn_with_state(
action_limiter.clone(),
authenticated_action_rate_limit_middleware,
))
.route_layer(from_fn_with_state(auth_state.clone(), auth_middleware))
.route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware))
.with_state(state.clone());
// Admin-only user management routes
let admin_routes = Router::new()
.route("/api/users", get(list_users_handler).post(create_user_handler))
.route("/api/users/{id}", get(get_user_handler).put(update_user_handler).delete(delete_user_handler))
.route_layer(from_fn(require_admin_middleware))
.route_layer(from_fn_with_state(
action_limiter.clone(),
authenticated_action_rate_limit_middleware,
))
.route_layer(from_fn_with_state(auth_state, auth_middleware))
.route_layer(from_fn_with_state(api_limiter.clone(), api_rate_limit_middleware))
.with_state(state.clone());
// API + action limited routes (token in body, no auth middleware)
let api_action_limited = Router::new()
.route("/api/auth/refresh", post(refresh_handler))
.route_layer(from_fn_with_state(
action_limiter,
authenticated_action_rate_limit_middleware,
))
.route_layer(from_fn_with_state(api_limiter, api_rate_limit_middleware))
.with_state(state);
// Static page (no middleware)
let static_routes = Router::new().route("/qr-login", get(qr_login_page));
Router::new()
.merge(auth_rate_limited)
.merge(api_public)
.merge(api_local_only)
.merge(authenticated)
.merge(admin_routes)
.merge(api_action_limited)
.merge(static_routes)
}
// ---------------------------------------------------------------------------
// POST /login
// ---------------------------------------------------------------------------
async fn login_handler(
State(state): State<AuthRouterState>,
body: Result<Json<LoginRequest>, JsonRejection>,
) -> Result<Response, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Input length validation (per API spec)
if req.username.len() > 32 {
return Err(AppError::BadRequest("Username must not exceed 32 characters".into()));
}
if req.password.len() > 128 {
return Err(AppError::BadRequest("Password must not exceed 128 characters".into()));
}
// Look up user; run dummy verify on miss to prevent timing attacks
let user = state
.user_repo
.find_by_username(&req.username)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
let (found_user, password_valid) = match user {
Some(u) if u.password_hash.trim().is_empty() => {
// Seeded user with no password yet (first-run local mode).
// Treat as invalid credentials; run dummy verify for timing symmetry
// and to avoid bcrypt error on empty hash leaking as a 500.
let _ = verify_password_timed(&req.password, dummy_password_hash()).await;
(None, false)
}
Some(u) => {
let valid = verify_password_timed(&req.password, &u.password_hash).await?;
(Some(u), valid)
}
None => {
// Prevent user enumeration via timing
let _ = verify_password_timed(&req.password, dummy_password_hash()).await;
(None, false)
}
};
if !password_valid {
return Err(AppError::Unauthorized("Invalid username or password".into()));
}
let user = found_user.ok_or_else(|| AppError::Unauthorized("Invalid username or password".into()))?;
let token = state
.jwt_service
.sign(&user.id, &user.username, &user.role)
.map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?;
// Update last login (best-effort)
if let Err(e) = state.user_repo.update_last_login(&user.id).await {
tracing::warn!("Failed to update last login for {}: {e}", user.id);
}
let cookie = state.cookie_config.build_session_cookie(&token);
let resp = LoginResponse::new(
PublicUser {
id: user.id,
username: user.username,
role: user.role,
},
token,
);
Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response())
}
// ---------------------------------------------------------------------------
// POST /logout
// ---------------------------------------------------------------------------
async fn logout_handler(State(state): State<AuthRouterState>, headers: HeaderMap) -> Result<Response, AppError> {
if let Some(token) = extract_token_from_headers(&headers) {
state.jwt_service.blacklist_token(&token);
}
let cookie = state.cookie_config.clear_session_cookie();
let resp = ApiResponse::message("Logged out successfully");
Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response())
}
// ---------------------------------------------------------------------------
// GET /api/auth/status
// ---------------------------------------------------------------------------
async fn status_handler(
State(state): State<AuthRouterState>,
headers: HeaderMap,
) -> Result<Json<AuthStatusResponse>, AppError> {
let has_users = state
.user_repo
.has_users()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
let user_count = state
.user_repo
.count_users()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
// Check authentication without requiring it, extract role if authenticated
let (is_authenticated, is_admin) = extract_token_from_headers(&headers)
.and_then(|token| state.jwt_service.verify(&token).ok())
.map(|payload| (true, payload.role == "admin"))
.unwrap_or((false, false));
Ok(Json(AuthStatusResponse {
success: true,
needs_setup: !has_users,
user_count: user_count as u64,
is_authenticated,
is_admin: Some(is_admin),
}))
}
// ---------------------------------------------------------------------------
// POST /api/auth/setup — one-time first-run admin creation
// ---------------------------------------------------------------------------
/// Create the initial admin account on a fresh install, then log them in.
///
/// Available ONLY while the install is uninitialised: the very first visitor's
/// chosen username + password become the admin credentials, and the response
/// sets the session cookie so they are immediately logged in. The write is an
/// atomic conditional UPDATE (only matches the empty-password system user), so
/// even two concurrent first-run requests cannot both win — the loser gets
/// `409 Conflict` and never overwrites the winner's account.
///
/// Reuses [`LoginRequest`]/[`LoginResponse`] (same `{username, password}` shape
/// and `{success, user, token}` reply as `/login`). CSRF-exempt like `/login`
/// (see `csrf::csrf_middleware`) and behind the auth rate limiter.
///
/// SECURITY: there is a brief first-run window before setup completes where any
/// client reaching the port could claim the admin. Operators who need to close
/// it can pre-seed with `NOMIFUN_ADMIN_PASSWORD` (see
/// `nomifun_app::bootstrap::ensure_admin_credentials`), which makes this return
/// 409 from the first boot.
async fn setup_handler(
State(state): State<AuthRouterState>,
body: Result<Json<LoginRequest>, JsonRejection>,
) -> Result<Response, AppError> {
// One-time only: refuse once any real (non-empty-password) user exists.
let has_users = state
.user_repo
.has_users()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
if has_users {
return Err(AppError::Conflict("Admin account already initialized".into()));
}
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let username = req.username.trim().to_owned();
validate_username(&username).map_err(|e| AppError::BadRequest(e.to_string()))?;
validate_password(&req.password).map_err(|e| AppError::BadRequest(e.to_string()))?;
// Hash on a blocking thread (bcrypt is CPU-bound), mirroring change-password.
let password = req.password.clone();
let password_hash = tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| AppError::Internal(format!("Task join error: {e}")))??;
// Atomically claim the uninitialised admin slot. The conditional UPDATE is
// the authoritative one-time gate: if a concurrent request already set the
// credentials, this writes 0 rows and we return 409 instead of clobbering
// the winner. (The has_users() check above is just a cheap pre-reject.)
let provisioned = state
.user_repo
.set_system_user_credentials_if_uninitialized(&username, &password_hash)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
if !provisioned {
return Err(AppError::Conflict("Admin account already initialized".into()));
}
let user = state
.user_repo
.get_primary_webui_user()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
.ok_or_else(|| AppError::Internal("Admin user missing after setup".into()))?;
let token = state
.jwt_service
.sign(&user.id, &user.username, "admin")
.map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?;
if let Err(e) = state.user_repo.update_last_login(&user.id).await {
tracing::warn!("Failed to update last login for {}: {e}", user.id);
}
let cookie = state.cookie_config.build_session_cookie(&token);
let resp = LoginResponse::new(
PublicUser {
id: user.id,
username: user.username,
role: "admin".to_string(),
},
token,
);
tracing::info!("first-run setup: initial admin account created");
Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response())
}
// ---------------------------------------------------------------------------
// Local-only internal user routes
// ---------------------------------------------------------------------------
async fn list_internal_users_handler(
State(state): State<AuthRouterState>,
) -> Result<Json<ApiResponse<Vec<User>>>, AppError> {
let users = state.user_repo.list_users().await?;
Ok(Json(ApiResponse::ok(users)))
}
async fn get_system_user_handler(
State(state): State<AuthRouterState>,
) -> Result<Json<ApiResponse<Option<User>>>, AppError> {
let user = state.user_repo.get_system_user().await?;
Ok(Json(ApiResponse::ok(user)))
}
async fn find_user_by_username_handler(
State(state): State<AuthRouterState>,
Path(username): Path<String>,
) -> Result<Json<ApiResponse<Option<User>>>, AppError> {
let user = state.user_repo.find_by_username(&username).await?;
Ok(Json(ApiResponse::ok(user)))
}
async fn find_user_by_id_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<Option<User>>>, AppError> {
let user = state.user_repo.find_by_id(&id).await?;
Ok(Json(ApiResponse::ok(user)))
}
async fn create_internal_user_handler(
State(state): State<AuthRouterState>,
body: Result<Json<CreateInternalUserRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<User>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let user = state.user_repo.create_user(&req.username, &req.password_hash).await?;
Ok(Json(ApiResponse::ok(user)))
}
async fn set_system_user_credentials_handler(
State(state): State<AuthRouterState>,
body: Result<Json<SetSystemUserCredentialsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.user_repo
.set_system_user_credentials(&req.username, &req.password_hash)
.await?;
Ok(Json(ApiResponse::ok(())))
}
async fn update_user_password_hash_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
body: Result<Json<UpdatePasswordHashRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.user_repo.update_password(&id, &req.password_hash).await?;
Ok(Json(ApiResponse::ok(())))
}
async fn update_user_username_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
body: Result<Json<UpdateUsernameRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.user_repo.update_username(&id, &req.username).await?;
Ok(Json(ApiResponse::ok(())))
}
async fn update_user_jwt_secret_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
body: Result<Json<UpdateJwtSecretRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.user_repo.update_jwt_secret(&id, &req.jwt_secret).await?;
Ok(Json(ApiResponse::ok(())))
}
async fn update_user_last_login_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
state.user_repo.update_last_login(&id).await?;
Ok(Json(ApiResponse::ok(())))
}
// ---------------------------------------------------------------------------
// GET /api/auth/user
// ---------------------------------------------------------------------------
async fn user_handler(Extension(user): Extension<CurrentUser>) -> Json<UserInfoResponse> {
Json(UserInfoResponse {
success: true,
user: PublicUser {
id: user.id,
username: user.username,
role: user.role,
},
})
}
// ---------------------------------------------------------------------------
// POST /api/auth/change-password
// ---------------------------------------------------------------------------
async fn change_password_handler(
State(state): State<AuthRouterState>,
Extension(current_user): Extension<CurrentUser>,
body: Result<Json<ChangePasswordRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Validate new password strength
validate_password(&req.new_password)?;
// Fetch user record
let user = state
.user_repo
.find_by_id(&current_user.id)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
// Verify current password
let valid = verify_password_timed(&req.current_password, &user.password_hash).await?;
if !valid {
return Err(AppError::Unauthorized("Current password is incorrect".into()));
}
// Hash new password on blocking thread
let password = req.new_password.clone();
let new_hash = tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| AppError::Internal(format!("Task join error: {e}")))??;
// Persist new password hash
state
.user_repo
.update_password(&current_user.id, &new_hash)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
// Rotate JWT secret to invalidate all sessions
let new_secret = state
.jwt_service
.rotate_secret()
.map_err(|e| AppError::Internal(format!("Secret rotation error: {e}")))?;
// Persist new secret to database
state
.user_repo
.update_jwt_secret(&current_user.id, &new_secret)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
Ok(Json(ApiResponse::message("Password changed successfully")))
}
// ---------------------------------------------------------------------------
// POST /api/auth/refresh
// ---------------------------------------------------------------------------
async fn refresh_handler(
State(state): State<AuthRouterState>,
body: Result<Json<RefreshTokenRequest>, JsonRejection>,
) -> Result<Json<RefreshResponse>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let payload = state
.jwt_service
.verify(&req.token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let new_token = state
.jwt_service
.sign(&payload.user_id, &payload.username, &payload.role)
.map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?;
Ok(Json(RefreshResponse {
success: true,
token: new_token,
}))
}
// ---------------------------------------------------------------------------
// GET /api/ws-token
// ---------------------------------------------------------------------------
async fn ws_token_handler(
State(state): State<AuthRouterState>,
Extension(current_user): Extension<CurrentUser>,
headers: HeaderMap,
) -> Result<Json<WsTokenResponse>, AppError> {
// Reuse the existing session token for WebSocket connections
let token = extract_token_from_headers(&headers).ok_or_else(|| AppError::Unauthorized("No token found".into()))?;
// Ensure user still exists
state
.user_repo
.find_by_id(&current_user.id)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?;
// Cookie max age in milliseconds
let expires_in = u64::from(COOKIE_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
Ok(Json(WsTokenResponse {
success: true,
ws_token: token,
expires_in,
}))
}
// ---------------------------------------------------------------------------
// POST /api/auth/qr-login
// ---------------------------------------------------------------------------
async fn qr_login_handler(
State(state): State<AuthRouterState>,
body: Result<Json<QrLoginRequest>, JsonRejection>,
) -> Result<Response, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Validate and consume QR token (one-time use)
state.qr_token_store.validate_and_consume(&req.qr_token)?;
// Get primary WebUI user for QR login
let user = state
.user_repo
.get_primary_webui_user()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
.ok_or_else(|| AppError::Internal("No primary user configured".into()))?;
let token = state
.jwt_service
.sign(&user.id, &user.username, &user.role)
.map_err(|e| AppError::Internal(format!("Token signing error: {e}")))?;
// Update last login (best-effort)
if let Err(e) = state.user_repo.update_last_login(&user.id).await {
tracing::warn!("Failed to update last login for {}: {e}", user.id);
}
let cookie = state.cookie_config.build_session_cookie(&token);
let resp = LoginResponse::new(
PublicUser {
id: user.id,
username: user.username,
role: user.role,
},
token,
);
Ok(([(header::SET_COOKIE, cookie)], Json(resp)).into_response())
}
// ---------------------------------------------------------------------------
// GET /qr-login (static HTML page)
// ---------------------------------------------------------------------------
async fn qr_login_page() -> Html<&'static str> {
Html(QR_LOGIN_HTML)
}
const QR_LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QR Login - Nomi</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; justify-content: center;
align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
.card { background: white; padding: 2rem; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
.status { margin-top: 1rem; color: #666; }
.error { color: #d32f2f; }
.success { color: #388e3c; }
</style>
</head>
<body>
<div class="card">
<h1>Nomi</h1>
<p id="status" class="status">Processing login...</p>
</div>
<script>
(function() {
var el = document.getElementById('status');
var params = new URLSearchParams(window.location.search);
var token = params.get('token');
if (!token) {
el.textContent = 'Error: No token provided';
el.className = 'status error';
return;
}
function verifyAppShellThenRedirect() {
fetch('/?nomifun_spa_shell_check=1', {
method: 'GET',
cache: 'no-store',
credentials: 'same-origin'
})
.then(function(r) {
if (!r.ok) {
throw new Error('HTTP ' + r.status);
}
window.location.replace('/#/guid');
})
.catch(function(err) {
el.textContent = 'Login succeeded, but WebUI app shell is not reachable: ' + err.message;
el.className = 'status error';
});
}
fetch('/api/auth/qr-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ qr_token: token })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
el.textContent = 'Login successful! Redirecting...';
el.className = 'status success';
try {
sessionStorage.setItem('nomifun:qr-login-resume', JSON.stringify({
at: Date.now(),
user: data.user
}));
} catch (e) {}
setTimeout(verifyAppShellThenRedirect, 600);
} else {
el.textContent = 'Login failed: ' + (data.error || 'Unknown error');
el.className = 'status error';
}
})
.catch(function(err) {
el.textContent = 'Error: ' + err.message;
el.className = 'status error';
});
})();
</script>
</body>
</html>"#;
// ---------------------------------------------------------------------------
// WebUI admin credential endpoints (local-only)
// ---------------------------------------------------------------------------
/// Random password length for `/api/webui/reset-password`.
const RESET_PASSWORD_LEN: usize = 16;
/// Resolve the WebUI admin user, falling back to NotFound when absent.
async fn resolve_webui_admin(user_repo: &dyn IUserRepository) -> Result<User, AppError> {
user_repo
.get_primary_webui_user()
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?
.ok_or_else(|| AppError::NotFound("No WebUI admin user configured".into()))
}
// ---------------------------------------------------------------------------
// POST /api/webui/change-password
// ---------------------------------------------------------------------------
async fn webui_change_password_handler(
State(state): State<AuthRouterState>,
body: Result<Json<WebuiChangePasswordRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
validate_password(&req.new_password)?;
let user = resolve_webui_admin(&*state.user_repo).await?;
let password = req.new_password;
let new_hash = tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| AppError::Internal(format!("Task join error: {e}")))??;
state
.user_repo
.update_password(&user.id, &new_hash)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
Ok(Json(ApiResponse::message("Password changed successfully")))
}
// ---------------------------------------------------------------------------
// POST /api/webui/change-username
// ---------------------------------------------------------------------------
async fn webui_change_username_handler(
State(state): State<AuthRouterState>,
body: Result<Json<WebuiChangeUsernameRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<WebuiChangeUsernameResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let trimmed = req.new_username.trim().to_owned();
validate_username(&trimmed)?;
let user = resolve_webui_admin(&*state.user_repo).await?;
if user.username != trimmed {
state
.user_repo
.update_username(&user.id, &trimmed)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
}
Ok(Json(ApiResponse::ok(WebuiChangeUsernameResponse { username: trimmed })))
}
// ---------------------------------------------------------------------------
// POST /api/webui/reset-password
// ---------------------------------------------------------------------------
async fn webui_reset_password_handler(
State(state): State<AuthRouterState>,
) -> Result<Json<ApiResponse<WebuiResetPasswordResponse>>, AppError> {
let user = resolve_webui_admin(&*state.user_repo).await?;
let new_password = generate_password(RESET_PASSWORD_LEN);
let password_for_hash = new_password.clone();
let new_hash = tokio::task::spawn_blocking(move || hash_password(&password_for_hash))
.await
.map_err(|e| AppError::Internal(format!("Task join error: {e}")))??;
state
.user_repo
.update_password(&user.id, &new_hash)
.await
.map_err(|e| AppError::Internal(format!("Database error: {e}")))?;
Ok(Json(ApiResponse::ok(WebuiResetPasswordResponse { new_password })))
}
// ---------------------------------------------------------------------------
// POST /api/webui/generate-qr-token
// ---------------------------------------------------------------------------
async fn webui_generate_qr_token_handler(
State(state): State<AuthRouterState>,
) -> Result<Json<ApiResponse<WebuiGenerateQrTokenResponse>>, AppError> {
let (token, expires_at_ms) = state.qr_token_store.generate_with_expiry();
Ok(Json(ApiResponse::ok(WebuiGenerateQrTokenResponse {
token,
expires_at_ms,
})))
}
// ---------------------------------------------------------------------------
// Admin-only user management handlers
// ---------------------------------------------------------------------------
/// GET /api/users — list all users (admin-only)
async fn list_users_handler(
State(state): State<AuthRouterState>,
) -> Result<Json<ApiResponse<Vec<UserListItem>>>, AppError> {
let users = state.user_repo.list_users().await?;
let user_list: Vec<UserListItem> = users
.into_iter()
.map(|u| UserListItem {
id: u.id,
username: u.username,
role: u.role,
email: u.email,
created_at: u.created_at,
last_login: u.last_login,
})
.collect();
Ok(Json(ApiResponse::ok(user_list)))
}
/// POST /api/users — create a new user (admin-only)
async fn create_user_handler(
State(state): State<AuthRouterState>,
Extension(current_user): Extension<CurrentUser>,
body: Result<Json<CreateUserRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<UserListItem>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Validate username and password
let username = req.username.trim().to_owned();
validate_username(&username)?;
validate_password(&req.password)?;
// Validate role
let role = req.role.to_lowercase();
if role != "admin" && role != "user" {
return Err(AppError::BadRequest("Role must be 'admin' or 'user'".into()));
}
// Hash password
let password = req.password.clone();
let password_hash = tokio::task::spawn_blocking(move || hash_password(&password))
.await
.map_err(|e| AppError::Internal(format!("Task join error: {e}")))?
.map_err(|e| AppError::Internal(format!("Password hashing error: {e}")))?;
// Create user with specified role
let user = state.user_repo.create_user_with_role(&username, &password_hash, &role).await?;
tracing::info!(admin_id = %current_user.id, new_user_id = %user.id, username = %username, role = %role, "Admin created new user");
Ok(Json(ApiResponse::ok(UserListItem {
id: user.id,
username: user.username,
role: user.role,
email: req.email,
created_at: user.created_at,
last_login: user.last_login,
})))
}
/// GET /api/users/:id — get a single user (admin-only)
async fn get_user_handler(
State(state): State<AuthRouterState>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<UserListItem>>, AppError> {
let user = state
.user_repo
.find_by_id(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(ApiResponse::ok(UserListItem {
id: user.id,
username: user.username,
role: user.role,
email: user.email,
created_at: user.created_at,
last_login: user.last_login,
})))
}
/// PUT /api/users/:id — update a user (admin-only)
async fn update_user_handler(
State(state): State<AuthRouterState>,
Extension(current_user): Extension<CurrentUser>,
Path(id): Path<String>,
body: Result<Json<UpdateUserRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<UserListItem>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
// Prevent admin from modifying themselves
if id == current_user.id {
return Err(AppError::BadRequest("Cannot modify your own account".into()));
}
// Fetch existing user
let mut user = state
.user_repo
.find_by_id(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
// Update username if provided
if let Some(username) = req.username {
let username = username.trim().to_owned();
validate_username(&username)?;
state.user_repo.update_username(&id, &username).await?;
user.username = username;
}
// Update role if provided
if let Some(role) = req.role {
let role = role.to_lowercase();
if role != "admin" && role != "user" {
return Err(AppError::BadRequest("Role must be 'admin' or 'user'".into()));
}
// Check if trying to demote the last admin
if user.role == "admin" && role == "user" {
let admin_count = state
.user_repo
.list_users()
.await?
.iter()
.filter(|u| u.role == "admin")
.count();
if admin_count <= 1 {
return Err(AppError::BadRequest("Cannot remove the last admin".into()));
}
}
state.user_repo.update_role(&id, &role).await?;
user.role = role;
}
// Update email if provided
if let Some(email) = req.email {
user.email = if email.trim().is_empty() { None } else { Some(email.trim().to_owned()) };
}
tracing::info!(admin_id = %current_user.id, user_id = %id, "Admin updated user");
// Fetch updated user
let updated_user = state
.user_repo
.find_by_id(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(ApiResponse::ok(UserListItem {
id: updated_user.id,
username: updated_user.username,
role: updated_user.role,
email: updated_user.email,
created_at: updated_user.created_at,
last_login: updated_user.last_login,
})))
}
/// DELETE /api/users/:id — delete a user (admin-only)
async fn delete_user_handler(
State(state): State<AuthRouterState>,
Extension(current_user): Extension<CurrentUser>,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
// Prevent admin from deleting themselves
if id == current_user.id {
return Err(AppError::BadRequest("Cannot delete your own account".into()));
}
// Fetch user to check role
let user = state
.user_repo
.find_by_id(&id)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
// Check if trying to delete the last admin
if user.role == "admin" {
let admin_count = state
.user_repo
.list_users()
.await?
.iter()
.filter(|u| u.role == "admin")
.count();
if admin_count <= 1 {
return Err(AppError::BadRequest("Cannot delete the last admin".into()));
}
}
// Delete user
state.user_repo.delete_user(&id).await?;
tracing::info!(admin_id = %current_user.id, user_id = %id, "Admin deleted user");
Ok(Json(ApiResponse::success()))
}