AstroResearch/src/api/auth.rs
Asfmq 2c8d0b8f8b feat: LAMOST DR12-14 与子版本体系接入、观测层安全加固与并发异步化
- LAMOST 新增 DR12/13/14 及子版本(v0/v1.0/v1.1/v2.0)维度,Internal 发布标记需登录认证并前端灰显,release×subtype 交叉约束下沉至 capabilities 统一声明
- ObservationFetcher trait 扩展版本/认证/交叉约束能力声明,version 参数贯穿 client→service→API→Agent tool→前端全链路
- 安全:observation cache SQL 全参数绑定 + LIKE 转义、cone_cache_hash 加长度前缀防碰撞、DESI survey/program 白名单防穿越
- 异步化:persist/cached_files_total_size/maybe_persist_tool_result迁移到 tokio::fs;cancelled_runs 与 session_permission_checkers改用 DashMap;auth 读锁优先 + 60s 节流
- Gaia 去 native-tls 改禁用连接池规避 UnexpectedEof,reqwest 移除 native-tls feature
- 重构:Source/ProductType from_str 集中解析、download 模块拆分为 try_download_pdf/html、AgentRuntime::init 抽取共享逻辑
- 部署:新增 deploy.sh 一键打包推送脚本、catch-panic 启用
2026-07-07 21:16:46 +08:00

315 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/api/auth.rs
use axum::{
extract::State,
http::{header, header::HeaderMap, header::HeaderValue, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::error::{ApiResult, AppError};
use super::AppState;
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub password: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub status: String,
pub bookmarklet_key: String,
}
// 辅助函数:从 Cookie 字符串中解析出 session_id 的值
fn parse_session_cookie(cookie_str: &str) -> Option<String> {
for cookie in cookie_str.split(';') {
let mut parts = cookie.splitn(2, '=');
if let (Some(name), Some(value)) = (parts.next(), parts.next()) {
if name.trim() == "session_id" {
return Some(value.trim().to_string());
}
}
}
None
}
/// 从请求头中提取认证 Token。
/// 优先从 Authorization: Bearer 头读取,其次从 Cookie 中的 session_id 读取。
pub fn extract_auth_token_from_headers(headers: &HeaderMap) -> Option<String> {
// 1. Authorization: Bearer <token>
if let Some(auth_str) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
if let Some(rest) = auth_str.strip_prefix("Bearer ") {
return Some(rest.to_string());
}
}
// 2. Cookie: session_id=<token>
if let Some(cookie_header) = headers.get(header::COOKIE) {
if let Ok(cookie_str) = cookie_header.to_str() {
return parse_session_cookie(cookie_str);
}
}
None
}
/// 恒定时间密码校验,防止时序侧信道攻击。
///
/// 普通 `==` 在字节不匹配时立即返回,攻击者可通过测量响应时间逐字节猜测密码。
/// `ct_eq` 无论是否匹配都遍历全部字节,耗时恒定,消除时序差异。
///
/// 长度不等时不能直接返回 false会泄露长度信息先用虚假比较平衡耗时。
fn verify_password(input: &str, expected: &str) -> bool {
use subtle::ConstantTimeEq;
let input_bytes = input.as_bytes();
let expected_bytes = expected.as_bytes();
if input_bytes.len() != expected_bytes.len() {
// 长度不同:做一次虚假比较平衡耗时,再返回 false
let _ = input_bytes.ct_eq(input_bytes);
return false;
}
input_bytes.ct_eq(expected_bytes).into()
}
// 辅助函数清除过期的会话记录24小时未活跃即过期
fn prune_expired_sessions(
sessions: &mut std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>,
) {
let now = chrono::Utc::now();
let expiry_duration = chrono::Duration::hours(24);
sessions.retain(|_, last_active| {
if let Ok(dur) = now.signed_duration_since(*last_active).to_std() {
dur < expiry_duration
.to_std()
.unwrap_or(std::time::Duration::from_secs(86400))
} else {
true // 尚未到达或时钟回拨,保留
}
});
}
/// 计算浏览器书签专用的永久 API 密钥(基于 admin_password + salt 的 SHA-256 哈希)
fn get_bookmarklet_api_key(password: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
hasher.update(b"_bookmarklet_salt_key");
format!("{:x}", hasher.finalize())
}
/// 最大会话数上限,超过后拒绝新登录以防止内存耗尽
const MAX_SESSIONS: usize = 1000;
// 登录接口:验证密码成功后,写入 HttpOnly Cookie
pub async fn login(
State(state): State<Arc<AppState>>,
_headers: axum::http::header::HeaderMap,
axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
Json(body): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
// 使用真实连接 IP 做限速X-Forwarded-For 可伪造,不应用于安全判断)
let client_ip = addr.ip().to_string();
// 检查速率限制5 次失败 / 5 分钟窗口
const MAX_FAILURES: u32 = 5;
const WINDOW_SECS: u64 = 300;
const MAX_RATE_LIMITER_ENTRIES: usize = 10000;
{
let mut limiter = state.login_rate_limiter.lock().await;
// 批量清理过期条目,防止内存无限增长
limiter.retain(|_, (_, first_fail)| first_fail.elapsed().as_secs() < WINDOW_SECS);
// 容量保护:超过上限时直接清空(比 O(n log n) 排序更高效,且不影响安全性)
if limiter.len() > MAX_RATE_LIMITER_ENTRIES {
limiter.clear();
}
if let Some((count, first_fail)) = limiter.get(&client_ip) {
let elapsed = first_fail.elapsed().as_secs();
if elapsed < WINDOW_SECS && *count >= MAX_FAILURES {
let remaining = WINDOW_SECS - elapsed;
return Err(AppError::unauthorized(format!(
"登录尝试过于频繁,请在 {} 秒后重试",
remaining
)));
}
}
}
let password = body
.password
.ok_or_else(|| AppError::bad_request("缺少密码字段"))?;
if password.is_empty() {
return Err(AppError::bad_request("密码不能为空"));
}
if verify_password(&password, &state.config.admin_password) {
// 成功:清除该 IP 的失败记录
state.login_rate_limiter.lock().await.remove(&client_ip);
let token = uuid::Uuid::new_v4().to_string();
// 异常安全地记录活跃会话及其当前时间
{
let mut sessions = state.sessions.write().await;
// 先清理过期会话
prune_expired_sessions(&mut sessions);
// 检查会话上限
if sessions.len() >= MAX_SESSIONS {
return Err(AppError::unauthorized(format!(
"活跃会话数已达上限 ({}), 请稍后重试",
MAX_SESSIONS
)));
}
sessions.insert(token.clone(), chrono::Utc::now());
}
// 构建 Set-Cookie 报头
// 全局统一使用 SameSite=Lax 且不带 Secure以完美支持 HTTP、HTTPS 以及局域网 IP 访问
let cookie_value = format!(
"session_id={}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400",
token
);
let mut headers = HeaderMap::new();
if let Ok(val) = HeaderValue::from_str(&cookie_value) {
headers.insert(header::SET_COOKIE, val);
}
let bookmarklet_key = get_bookmarklet_api_key(&state.config.admin_password);
Ok((
headers,
Json(LoginResponse {
status: "ok".to_string(),
bookmarklet_key,
}),
))
} else {
// 失败:记录该 IP 的失败次数
let mut limiter = state.login_rate_limiter.lock().await;
let entry = limiter
.entry(client_ip)
.or_insert((0, std::time::Instant::now()));
entry.0 += 1;
Err(AppError::unauthorized("密码错误"))
}
}
// 登出接口:清除内存会话并使 Cookie 失效
pub async fn logout(
State(state): State<Arc<AppState>>,
req: Request<axum::body::Body>,
) -> ApiResult<impl IntoResponse> {
let token_to_remove = extract_auth_token_from_headers(req.headers());
if let Some(ref token) = token_to_remove {
state.sessions.write().await.remove(token);
}
// 通过 Max-Age=0 清除浏览器端的 Cookie
let mut headers = HeaderMap::new();
let delete_cookie = "session_id=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0";
if let Ok(val) = HeaderValue::from_str(delete_cookie) {
headers.insert(header::SET_COOKIE, val);
}
Ok((headers, Json(serde_json::json!({ "status": "ok" }))))
}
/// 鉴权状态检查接口。
///
/// 注:此端点由 `auth_middleware` 保护——只有携带有效会话 Token 的请求才能到达此处,
/// 因此始终返回 `authenticated: true`。如果中间件被移除,此端点将无条件返回已认证。
pub async fn check_auth() -> Result<impl IntoResponse, StatusCode> {
Ok(Json(serde_json::json!({ "authenticated": true })))
}
// 鉴权验证中间件
pub async fn auth_middleware(
State(state): State<Arc<AppState>>,
req: axum::extract::Request,
next: Next,
) -> Response {
let token = extract_auth_token_from_headers(req.headers());
let mut is_valid = false;
if let Some(ref tok) = token {
// 1. 优先校验内存中的动态 Session
// 先用读锁快速检查会话是否存在,并获取其最后活跃时间
let mut last_active_opt = None;
{
let sessions = state.sessions.read().await;
if let Some(last_active) = sessions.get(tok) {
is_valid = true;
last_active_opt = Some(*last_active);
}
}
// 会话存在时,如果最后活跃时间超过 60 秒,则获取写锁更新最后活跃时间 + 过期清理
if is_valid {
let needs_update = match last_active_opt {
Some(last_active) => {
if let Ok(dur) = chrono::Utc::now()
.signed_duration_since(last_active)
.to_std()
{
dur.as_secs() > 60
} else {
true // 时钟回拨等异常情况,强制更新
}
}
None => true,
};
if needs_update {
let mut sessions = state.sessions.write().await;
// 顺便执行过期会话自动清理垃圾收集
prune_expired_sessions(&mut sessions);
if let Some(last_active) = sessions.get_mut(tok) {
*last_active = chrono::Utc::now();
}
}
}
// 2. 校验书签专属永久 API 密钥(严格隔离限制:仅允许用于特定的书签导入和同步路径)
if !is_valid {
let path = req.uri().path();
if path == "/api/upload" || path == "/api/active_bibcode" {
let expected_key = get_bookmarklet_api_key(&state.config.admin_password);
if *tok == expected_key {
is_valid = true;
}
}
}
}
if is_valid {
next.run(req).await
} else {
StatusCode::UNAUTHORIZED.into_response()
}
}
#[cfg(test)]
mod tests {
use super::verify_password;
#[test]
fn test_verify_password_correct() {
assert!(verify_password("secret123", "secret123"));
assert!(verify_password("", ""));
}
#[test]
fn test_verify_password_wrong() {
assert!(!verify_password("secret123", "secret456"));
assert!(!verify_password("secret", "secret123"));
assert!(!verify_password("secret123", "secret"));
assert!(!verify_password("wrong", ""));
}
}