feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)

- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表
- 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流
- 会话只存 token 的 sha256;改密清空全部会话
- 前端:/login 独立壳(首次=设置密码),页头登录/退出切换
- 测试:37 passed(迁移 + 登录/会话 4 例)
- 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
This commit is contained in:
stash
2026-10-29 12:41:03 +08:00
parent 1aac8bcbe7
commit dea7781b86
14 changed files with 1222 additions and 46 deletions
+269 -4
View File
@@ -1,10 +1,10 @@
use axum::{
Json, Router,
extract::{OriginalUri, Path, State},
http::{HeaderValue, Method, StatusCode, header},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::sse::{Event, KeepAlive, Sse},
response::{Html, IntoResponse, Response},
routing::get,
routing::{get, patch, post},
};
use include_dir::{Dir, include_dir};
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
@@ -36,6 +36,41 @@ pub struct AppState {
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
/// 登录失败计数(单用户本地场景,一个进程一份就够)
pub login_guard: Arc<std::sync::Mutex<LoginGuard>>,
}
/// 连续登录失败 5 次 → 锁 60 秒。
#[derive(Debug, Default)]
pub struct LoginGuard {
failures: u32,
locked_until: Option<std::time::Instant>,
}
impl LoginGuard {
fn locked(&mut self) -> bool {
if let Some(until) = self.locked_until {
if std::time::Instant::now() < until {
return true;
}
self.failures = 0;
self.locked_until = None;
}
false
}
fn record_failure(&mut self) {
self.failures += 1;
if self.failures >= 5 {
self.locked_until = Some(std::time::Instant::now() + Duration::from_secs(60));
self.failures = 0;
}
}
fn reset(&mut self) {
self.failures = 0;
self.locked_until = None;
}
}
pub struct ServerHandle {
@@ -124,9 +159,12 @@ fn not_found_page(path: &str) -> Response {
/// 到底是路由拼错了还是文件真的不存在。
const SPA_PREFIXES: &[&str] = &["/c/"];
/// 精确匹配的前端页面(没有实体文件,返回入口页)
const SPA_PAGES: &[&str] = &["/login", "/admin"];
/// 是否为前端路由路径(没有实体文件,应该返回入口页)。
fn is_spa_path(path: &str) -> bool {
SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
SPA_PAGES.contains(&path) || SPA_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
}
/// `ServeDir` 的兜底:前端路由回落到入口页,其余路径仍是 404 页面。
@@ -275,8 +313,228 @@ fn project_event(projects: Vec<AppProject>) -> Event {
})
}
// ---------------- 登录 / 会话 ----------------
const SESSION_COOKIE: &str = "stash_session";
#[derive(serde::Deserialize)]
struct PasswordBody {
password: String,
}
#[derive(serde::Deserialize)]
struct SetupBody {
password: String,
username: Option<String>,
}
#[derive(serde::Deserialize)]
struct ChangePasswordBody {
current: String,
next: String,
}
fn cookie_token(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
raw.split(';')
.filter_map(|part| part.trim().split_once('='))
.find(|(key, _)| *key == SESSION_COOKIE)
.map(|(_, value)| value.to_string())
}
fn session_cookie(token: &str) -> String {
format!(
"{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
crate::db::auth::SESSION_TTL_SECS
)
}
fn clear_cookie() -> String {
format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0")
}
fn api_error(status: StatusCode, message: &str) -> Response {
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
fn with_cookie(mut response: Response, cookie: String) -> Response {
if let Ok(value) = HeaderValue::from_str(&cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
/// 同源校验:跨站请求带 Origin 且 host 不匹配时拒绝。
/// 和 Cookie 的 `SameSite=Strict` 互为补充,不依赖浏览器实现。
fn same_origin(headers: &HeaderMap) -> bool {
let Some(origin) = headers
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
else {
return true;
};
let Some(host) = headers.get(header::HOST).and_then(|value| value.to_str().ok()) else {
return false;
};
origin.ends_with(host)
}
fn login_locked(state: &AppState) -> bool {
state
.login_guard
.lock()
.map(|mut guard| guard.locked())
.unwrap_or(true)
}
fn login_failed(state: &AppState) {
if let Ok(mut guard) = state.login_guard.lock() {
guard.record_failure();
}
}
fn login_ok(state: &AppState) {
if let Ok(mut guard) = state.login_guard.lock() {
guard.reset();
}
}
async fn issue_token(state: &AppState) -> Result<String, Response> {
let db = Arc::clone(&state.db);
tokio::task::spawn_blocking(move || db.issue_session())
.await
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))?
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))
}
async fn read_session(state: &AppState, headers: &HeaderMap) -> Result<crate::db::SessionState, Response> {
let token = cookie_token(headers);
let db = Arc::clone(&state.db);
tokio::task::spawn_blocking(move || db.auth_state(token.as_deref()))
.await
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))?
.map_err(|error| api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()))
}
/// GET /api/auth/session
async fn auth_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
match read_session(&state, &headers).await {
Ok(session) => Json(session).into_response(),
Err(response) => response,
}
}
/// POST /api/auth/setup —— 只在还没有管理员时可用
async fn auth_setup(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<SetupBody>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
let username = body.username.unwrap_or_else(|| "admin".into());
let password = body.password;
let db = Arc::clone(&state.db);
let created = tokio::task::spawn_blocking(move || db.setup_admin(&username, &password)).await;
match created {
Ok(Ok(())) => match issue_token(&state).await {
Ok(token) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
session_cookie(&token),
),
Err(response) => response,
},
Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/auth/login
async fn auth_login(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PasswordBody>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
if login_locked(&state) {
return api_error(StatusCode::TOO_MANY_REQUESTS, "尝试过多,请一分钟后再试");
}
let password = body.password;
let db = Arc::clone(&state.db);
let verified = tokio::task::spawn_blocking(move || db.login(&password)).await;
match verified {
Ok(Ok(Some(_username))) => {
login_ok(&state);
match issue_token(&state).await {
Ok(token) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
session_cookie(&token),
),
Err(response) => response,
}
}
Ok(Ok(None)) => {
login_failed(&state);
api_error(StatusCode::UNAUTHORIZED, "密码错误")
}
Ok(Err(error)) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// POST /api/auth/logout
async fn auth_logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) = cookie_token(&headers) {
let db = Arc::clone(&state.db);
let _ = tokio::task::spawn_blocking(move || db.logout(&token)).await;
}
with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
clear_cookie(),
)
}
/// PATCH /api/auth/password —— 必须已登录,成功后所有会话失效
async fn auth_password(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<ChangePasswordBody>,
) -> Response {
if !same_origin(&headers) {
return api_error(StatusCode::FORBIDDEN, "跨站请求被拒绝");
}
match read_session(&state, &headers).await {
Ok(session) if session.authenticated => {}
Ok(_) => return api_error(StatusCode::UNAUTHORIZED, "请先登录"),
Err(response) => return response,
}
let db = Arc::clone(&state.db);
let (current, next) = (body.current, body.next);
let changed =
tokio::task::spawn_blocking(move || db.change_password(&current, &next)).await;
match changed {
Ok(Ok(true)) => with_cookie(
Json(serde_json::json!({ "ok": true })).into_response(),
clear_cookie(),
),
Ok(Ok(false)) => api_error(StatusCode::UNAUTHORIZED, "当前密码不正确"),
Ok(Err(error)) => api_error(StatusCode::BAD_REQUEST, &error.to_string()),
Err(error) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()),
}
}
/// 组装路由:API + 内嵌前端 + 托管目录。
///
/// 单独抽出来是为了测试能直接对 `Router` 发请求:`start` 要绑定端口、
/// 起监听任务,没法在单元测试里用。
fn build_router(state: AppState) -> Router {
@@ -296,6 +554,11 @@ fn build_router(state: AppState) -> Router {
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog/events", get(catalog_events))
.route("/api/auth/session", get(auth_session))
.route("/api/auth/setup", post(auth_setup))
.route("/api/auth/login", post(auth_login))
.route("/api/auth/logout", post(auth_logout))
.route("/api/auth/password", patch(auth_password))
.route("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))
@@ -333,6 +596,7 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
config: Arc::new(config.clone()),
project_updates,
db,
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
};
let app = build_router(shared_state);
@@ -399,6 +663,7 @@ mod tests {
config: Arc::new(config),
project_updates,
db: Arc::new(Db::in_memory()),
login_guard: Arc::new(std::sync::Mutex::new(LoginGuard::default())),
})
}