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:
+269
-4
@@ -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(¤t, &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())),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user