use axum::extract::{Request, State}; use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; use crate::state::AppState; const COOKIE_NAME: &str = "lazyboy_session"; #[derive(Clone)] pub struct AuthConfig { token: Option, session_value: Option, secure_cookie: bool, } impl AuthConfig { pub fn from_env() -> Self { let token = std::env::var("LAZYBOY_APP_TOKEN") .ok() .filter(|value| !value.trim().is_empty()); let session_value = token.as_ref().map(|value| { let mut hasher = Sha256::new(); hasher.update(b"lazyboy-session-v1:"); hasher.update(value.as_bytes()); hex::encode(hasher.finalize()) }); let secure_cookie = std::env::var("LAZYBOY_SECURE_COOKIE") .map(|value| matches!(value.as_str(), "1" | "true" | "yes")) .unwrap_or(false); Self { token, session_value, secure_cookie, } } pub fn enabled(&self) -> bool { self.token.is_some() } pub fn strong_enough_for_network(&self) -> bool { self.token .as_ref() .map(|token| token.as_bytes().len() >= 32 && token != "dev-token") .unwrap_or(false) } fn valid_token(&self, supplied: &str) -> bool { self.token .as_ref() .map(|expected| constant_time_eq(expected.as_bytes(), supplied.as_bytes())) .unwrap_or(true) } fn valid_session(&self, headers: &HeaderMap) -> bool { let Some(expected) = &self.session_value else { return true; }; cookie_value(headers, COOKIE_NAME) .map(|supplied| constant_time_eq(expected.as_bytes(), supplied.as_bytes())) .unwrap_or(false) } fn session_cookie(&self) -> Option { self.session_value.as_ref().map(|value| { format!( "{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800{}", if self.secure_cookie { "; Secure" } else { "" } ) }) } } fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } let mut difference = 0u8; for (left, right) in left.iter().zip(right) { difference |= left ^ right; } difference == 0 } fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get(header::COOKIE)? .to_str() .ok()? .split(';') .filter_map(|part| part.trim().split_once('=')) .find_map(|(key, value)| (key == name).then_some(value)) } #[derive(Deserialize)] struct LoginInput { token: String, } pub fn public_router(state: AppState) -> Router { Router::new() .route("/api/session", axum::routing::get(session).post(login).delete(logout)) .with_state(state) } async fn session(State(state): State, headers: HeaderMap) -> Json { Json(json!({ "authenticated": state.auth.valid_session(&headers), "required": state.auth.enabled() })) } async fn login( State(state): State, Json(input): Json, ) -> Result)> { if !state.auth.valid_token(&input.token) { return Err(( StatusCode::UNAUTHORIZED, Json(json!({"message": "存取 token 不正確"})), )); } let mut response = Json(json!({"ok": true})).into_response(); if let Some(cookie) = state.auth.session_cookie() { response.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_str(&cookie).map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"message": "無法建立 session"})), ) })?, ); } Ok(response) } async fn logout() -> Response { let mut response = Json(json!({"ok": true})).into_response(); response.headers_mut().insert( header::SET_COOKIE, HeaderValue::from_static( "lazyboy_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", ), ); response } pub async fn require_auth( State(state): State, request: Request, next: Next, ) -> Response { if state.auth.valid_session(request.headers()) { next.run(request).await } else { ( StatusCode::UNAUTHORIZED, Json(json!({"message": "請先登入"})), ) .into_response() } } #[cfg(test)] mod tests { use super::constant_time_eq; #[test] fn token_comparison_requires_exact_value() { assert!(constant_time_eq(b"correct", b"correct")); assert!(!constant_time_eq(b"correct", b"wrong")); assert!(!constant_time_eq(b"correct", b"correct-longer")); } }