将 session cookie 改为后端 HttpOnly 设置

- login 通过 Set-Cookie 响应头设置 HttpOnly cookie
- get_current_user 从请求 Cookie header 读取 token,匹配具体 session
- logout 通过 Set-Cookie 清除 cookie,并删除对应 session
- 移除前端 document.cookie 操作代码

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xfy 2026-05-26 23:54:58 +08:00
parent e358f2af7d
commit 0be0719fdb
5 changed files with 78 additions and 39 deletions

1
Cargo.lock generated
View File

@ -4629,6 +4629,7 @@ dependencies = [
"dioxus",
"dotenvy",
"getrandom 0.2.17",
"http",
"js-sys",
"pulldown-cmark",
"rand 0.8.6",

View File

@ -17,6 +17,7 @@ pulldown-cmark = "0.13"
dotenvy = { version = "0.15", optional = true }
rand = { version = "0.8", features = ["getrandom"] }
getrandom = { version = "0.2", features = ["js"] }
http = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["Document", "Window", "HtmlDocument", "Storage", "Element", "DomTokenList", "MediaQueryList", "HtmlScriptElement"] }

View File

@ -1,6 +1,8 @@
#![allow(clippy::unused_unit, deprecated, unused_imports)]
use dioxus::prelude::*;
#[cfg(feature = "server")]
use http::header::{HeaderValue, SET_COOKIE};
use crate::auth::{password, session};
use crate::db::pool::DB_POOL;
@ -34,6 +36,23 @@ fn validate_password(password: &str) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "server")]
fn parse_session_token(cookie_header: &str) -> Option<&str> {
cookie_header
.split(';')
.map(|s| s.trim())
.find_map(|pair| {
let mut parts = pair.splitn(2, '=');
let name = parts.next()?.trim();
let value = parts.next()?.trim();
if name == "session" {
Some(value)
} else {
None
}
})
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AuthResponse {
pub success: bool,
@ -171,6 +190,16 @@ pub async fn login(
.await
.map_err(|e| ServerFnError::new(format!("创建 session 失败: {}", e)))?;
let cookie = format!(
"session={token}; HttpOnly; Path=/; Max-Age={}; SameSite=Lax",
30 * 24 * 60 * 60
);
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
if let Ok(value) = HeaderValue::try_from(cookie.as_str()) {
ctx.add_response_header(SET_COOKIE, value);
}
}
Ok(AuthResponse {
success: true,
message: "登录成功".to_string(),
@ -180,15 +209,40 @@ pub async fn login(
#[server(Logout, "/api")]
pub async fn logout() -> Result<AuthResponse, ServerFnError> {
let token = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
parts
.headers
.get("cookie")
.and_then(|h| h.to_str().ok())
.and_then(parse_session_token)
.map(|s| s.to_string())
} else {
None
};
let client = DB_POOL
.get()
.await
.map_err(|e| ServerFnError::new(format!("数据库连接失败: {}", e)))?;
client
.execute("DELETE FROM sessions WHERE expires_at < NOW()", &[])
.await
.map_err(|e| ServerFnError::new(format!("清理 session 失败: {}", e)))?;
// 清除 cookie
if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
ctx.add_response_header(
SET_COOKIE,
HeaderValue::from_static(
"session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax",
),
);
}
// 删除当前 session
if let Some(t) = token {
client
.execute("DELETE FROM sessions WHERE token = $1", &[&t])
.await
.map_err(|e| ServerFnError::new(format!("删除 session 失败: {}", e)))?;
}
Ok(AuthResponse {
success: true,
@ -204,6 +258,22 @@ pub struct CurrentUserResponse {
#[server(GetCurrentUser, "/api")]
pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
let token = if let Some(ctx) = dioxus::fullstack::FullstackContext::current() {
let parts = ctx.parts_mut();
parts
.headers
.get("cookie")
.and_then(|h| h.to_str().ok())
.and_then(parse_session_token)
.map(|s| s.to_string())
} else {
None
};
let Some(token) = token else {
return Ok(CurrentUserResponse { user: None });
};
let client = DB_POOL
.get()
.await
@ -214,10 +284,8 @@ pub async fn get_current_user() -> Result<CurrentUserResponse, ServerFnError> {
"SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.expires_at > NOW()
ORDER BY s.created_at DESC
LIMIT 1",
&[],
WHERE s.token = $1 AND s.expires_at > NOW()",
&[&token],
)
.await
.map_err(|e| ServerFnError::new(format!("查询失败: {}", e)))?;

View File

@ -1,8 +1,5 @@
use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
use crate::api::auth::{get_current_user, logout};
use crate::components::header::{Header, NavItemConfig};
use crate::components::footer::Footer;
@ -42,16 +39,6 @@ pub fn AdminLayout(children: Element) -> Element {
let nav = nav;
spawn(async move {
let _ = logout().await;
#[cfg(target_arch = "wasm32")]
{
let cookie = "session=; path=/; max-age=0";
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
let _ = document.dyn_into::<web_sys::HtmlDocument>()
.map(|d| d.set_cookie(cookie));
}
}
}
let _ = nav.push("/login");
});
},

View File

@ -1,8 +1,5 @@
use dioxus::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
use crate::api::auth::{login, AuthResponse};
#[component]
@ -24,21 +21,6 @@ pub fn LoginPage() -> Element {
token: Some(_token),
..
}) => {
#[cfg(target_arch = "wasm32")]
{
let cookie = format!(
"session={}; path=/; max-age={}; SameSite=Lax",
_token,
30 * 24 * 60 * 60
);
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
let _ = document
.dyn_into::<web_sys::HtmlDocument>()
.map(|d| d.set_cookie(&cookie));
}
}
}
let _ = dioxus::router::navigator().push("/admin");
}
Ok(AuthResponse {