/
mcmare
/
RustAPI
Обзор
Документация
Войти
/
mcmare
/
RustAPI
Код
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
engine/src/webui/auth.rs
125 строк
4 KB
mcmare
Add WebUI: zero-config bootstrap, admin login, browser-based settings/endpoints/scripts management
18 июл 2026, 18:34
18 июл 2026, 18:34
e401c22
Код
Авторство
О чём код?
use anyhow::{Context, Result}; use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; use axum::{ extract::{Request, State}, http::{header, StatusCode}, middleware::Next, response::Response, }; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; const SESSION_COOKIE: &str = "admin_session"; const SESSION_TTL_SECONDS: i64 = 12 * 60 * 60; #[derive(Serialize, Deserialize, Clone)] pub struct AdminCredentials { pub username: String, pub password_hash: String, } fn admin_file(data_dir: &Path) -> PathBuf { data_dir.join("admin.yaml") } pub fn load_admin(data_dir: &Path) -> Result<Option<AdminCredentials>> { let path = admin_file(data_dir); if !path.exists() { return Ok(None); } let text = std::fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; Ok(Some( serde_yaml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?, )) } pub fn save_admin(data_dir: &Path, creds: &AdminCredentials) -> Result<()> { let path = admin_file(data_dir); let text = serde_yaml::to_string(creds)?; std::fs::write(&path, text).with_context(|| format!("failed to write {}", path.display())) } pub fn hash_password(plain: &str) -> Result<String> { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(plain.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| anyhow::anyhow!("failed to hash password: {e}")) } pub fn verify_password(plain: &str, hash: &str) -> bool { let Ok(parsed) = PasswordHash::new(hash) else { return false; }; Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok() } #[derive(Serialize, Deserialize)] struct SessionClaims { sub: String, exp: i64, } pub fn sign_session(username: &str, jwt_secret: &str) -> Result<String> { let claims = SessionClaims { sub: username.to_string(), exp: chrono::Utc::now().timestamp() + SESSION_TTL_SECONDS, }; encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(jwt_secret.as_bytes())) .map_err(|e| anyhow::anyhow!("failed to sign session: {e}")) } fn verify_session(token: &str, jwt_secret: &str) -> bool { decode::<SessionClaims>( token, &DecodingKey::from_secret(jwt_secret.as_bytes()), &Validation::new(Algorithm::HS256), ) .is_ok() } pub fn session_cookie_header(token: &str) -> String { format!("{SESSION_COOKIE}={token}; HttpOnly; Path=/; SameSite=Lax; Max-Age={SESSION_TTL_SECONDS}") } pub fn clear_session_cookie_header() -> String { format!("{SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0") } fn extract_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> { header_value.split(';').map(|s| s.trim()).find_map(|kv| { let (k, v) = kv.split_once('=')?; (k == name).then_some(v) }) } pub fn has_valid_session(headers: &axum::http::HeaderMap, jwt_secret: &str) -> bool { headers .get(header::COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| extract_cookie(v, SESSION_COOKIE)) .is_some_and(|token| verify_session(token, jwt_secret)) } #[derive(Clone)] pub struct AdminAuthState { pub jwt_secret: String, } /// Guards every `/ui/api/*` route except `status`/`setup`/`login` — those three have to /// stay reachable without a session, since a session is exactly what they're for. pub async fn require_admin_session( State(state): State<AdminAuthState>, req: Request, next: Next, ) -> Result<Response, StatusCode> { if has_valid_session(req.headers(), &state.jwt_secret) { Ok(next.run(req).await) } else { Err(StatusCode::UNAUTHORIZED) } }