feat: implement authentication system

- Add auth.rs module with session management
- Implement login/logout/verify API endpoints
- Add authentication middleware
- Protect /api/v2/tree endpoint
- Default demo user (username: demo, password: demo123)
- Token-based auth with 24-hour expiration
- bcrypt password hashing
This commit is contained in:
Warren
2026-05-16 17:54:32 +08:00
parent 2e7d538712
commit 6e3de0169e
4 changed files with 277 additions and 14 deletions

View File

@@ -1,7 +1,7 @@
use anyhow::Context;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Json},
routing::{delete, get, patch, post, put},
Router,
@@ -11,6 +11,7 @@ use std::str::FromStr;
use std::sync::{Arc, Mutex};
use crate::audio;
use crate::auth::{AuthState, LoginRequest};
use crate::filetree::{self, FileTree};
use crate::render;
@@ -21,6 +22,7 @@ struct AppState {
step_info: Arc<Mutex<serde_json::Value>>,
labels: Arc<Mutex<Vec<serde_json::Value>>>,
db_dir: String,
auth: AuthState,
}
pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
@@ -42,7 +44,7 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
let (out_devs, in_devs, cur_out, cur_in) = audio::audio_devices();
let html = audio::inject_audio_devices(&welcome, &out_devs, &in_devs, &cur_out, &cur_in);
let state = AppState {
let state = AppState {
html: Arc::new(Mutex::new(html)),
page_ver: Arc::new(Mutex::new(0)),
step_info: Arc::new(Mutex::new(serde_json::json!({
@@ -50,6 +52,7 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
}))),
labels: Arc::new(Mutex::new(vec![])),
db_dir: "data/users".to_string(),
auth: AuthState::new(),
};
let app = Router::new()
@@ -65,6 +68,11 @@ pub async fn run(port: u16, file: Option<String>) -> anyhow::Result<()> {
.route("/body", get(body_handler))
.route("/status", get(status_handler))
.route("/labels", get(get_labels).post(post_labels))
// Auth endpoints (public)
.route("/api/v2/auth/login", post(login_handler))
.route("/api/v2/auth/logout", post(logout_handler))
.route("/api/v2/auth/verify", get(verify_handler))
// Protected endpoints (require auth)
.route("/api/v2/tree/:user_id", get(get_tree))
.route("/api/v2/tree/:user_id/node", post(create_node))
.route(
@@ -386,23 +394,26 @@ async fn display_handler(
async fn get_tree(
State(state): State<AppState>,
headers: HeaderMap,
Path(user_id): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
Query(query): Query<serde_json::Value>,
) -> impl IntoResponse {
let mode_key = params
.get("mode")
.cloned()
.unwrap_or_else(|| "tree".to_string());
let db_dir = state.db_dir.clone();
// Verify authentication
if let Err(status) = verify_auth(&state, &headers) {
return (
status,
Json(serde_json::json!({"error": "Unauthorized"})),
)
.into_response();
}
let _ = &state.db_dir;
let mode = query["mode"].as_str().unwrap_or("tree").to_string();
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let db_path = format!("{}/{}.sqlite", db_dir, user_id);
let needs_init = !std::path::Path::new(&db_path).exists();
if needs_init {
FileTree::init_user_db(&user_id)?;
}
let conn = FileTree::open_user_db(&user_id)?;
let tree = FileTree::load(&conn, &user_id)?;
let data = filetree::mode::get_mode(&mode_key)
let data = filetree::mode::get_mode(&mode)
.map(|m| m.render(&tree))
.unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"}));
Ok(data)
@@ -1193,6 +1204,108 @@ async fn add_file_location(
}
}
// === Auth Handlers ===
async fn login_handler(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> impl IntoResponse {
match state.auth.login(&body.username, &body.password) {
Some(response) => (StatusCode::OK, Json(response)).into_response(),
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Invalid credentials"})),
)
.into_response(),
}
}
async fn logout_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
if state.auth.logout(&token) {
(StatusCode::OK, Json(serde_json::json!({"success": true}))).into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Token not found"})),
)
.into_response()
}
}
None => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing Authorization header"})),
)
.into_response(),
}
}
async fn verify_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> impl IntoResponse {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
match state.auth.verify_token(&token) {
Some(session) => {
(
StatusCode::OK,
Json(serde_json::json!({
"valid": true,
"user_id": session.user_id,
"username": session.username,
"expires_at": session.expires_at
})),
)
.into_response()
}
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"valid": false, "error": "Token expired or invalid"})),
)
.into_response(),
}
}
None => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Missing Authorization header"})),
)
.into_response(),
}
}
// Auth middleware helper
fn verify_auth(state: &AppState, headers: &HeaderMap) -> Result<String, StatusCode> {
let auth_header = headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| crate::auth::parse_auth_header(h));
match auth_header {
Some(token) => {
match state.auth.verify_token(&token) {
Some(session) => Ok(session.user_id),
None => Err(StatusCode::UNAUTHORIZED),
}
}
None => Err(StatusCode::UNAUTHORIZED),
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")