Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tracing-appender = "0.2"
thiserror = "2"
anyhow = "1"
axum-extra = { version = "0.10", features = ["cookie"] }
base64 = "0.22"
hmac = "0.12"
sha2 = "0.10"
sha1 = "0.10"
Expand Down
1 change: 1 addition & 0 deletions backend/src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod routes;
pub mod token;
pub mod yk_client;
pub mod yk_session;
108 changes: 86 additions & 22 deletions backend/src/auth/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ use std::sync::{Arc, RwLock};

use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::response::{AppendHeaders, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{Value, json};

use super::token;
use super::yk_client::{self, YkClient};
use super::yk_session;
use crate::config::Config;
use crate::error::AppError;
use crate::wechat::client::WechatClient;
Expand Down Expand Up @@ -77,6 +78,19 @@ pub fn extract_sid(headers: &HeaderMap) -> Option<String> {
None
}

/// 取雨课堂凭证:浏览器 HttpOnly cookie(`yk_session`,重启不丢)优先,
/// 内存表兜底(兼容升级前已登录、浏览器还没种上该 cookie 的旧会话)。
pub fn resolve_yk_cookie(st: &AppState, headers: &HeaderMap, user_id: i64) -> Option<String> {
if let Some(c) = yk_session::extract(headers) {
return Some(c);
}
st.sessions
.read()
.expect("session lock poisoned")
.get(&user_id)
.cloned()
}

fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down Expand Up @@ -181,24 +195,28 @@ async fn qr_poll(
}
}

/// 登录成功公共尾部:存会话 → 签发 sid cookie → 返回 user_id(data 可携带额外字段)
/// 登录成功公共尾部:存内存表(兜底)→ 签发 sid cookie 与 yk_session cookie → 返回 user_id
///
/// 双 cookie:`sid`(本站会话,滑动续期)+ `yk_session`(雨课堂凭证,浏览器持久化,
/// 与雨课堂官网把 sessionid 存 HttpOnly cookie 同策;后端重启不再要求重新登录)
fn session_response(st: &AppState, session: yk_client::Session, mut data: Value) -> Response {
st.sessions
.write()
.expect("session lock poisoned")
.insert(session.user_id, session.cookie_header.clone());
let sid = token::issue(
session.user_id,
now_unix() + st.config.cookie_ttl_secs,
&st.config.server_secret,
);
let ttl = st.config.cookie_ttl_secs;
let sid = token::issue(session.user_id, now_unix() + ttl, &st.config.server_secret);
data["user_id"] = json!(session.user_id);
// AppendHeaders:同名 Set-Cookie 需 append(普通数组是 insert 语义,两个 cookie 会互相覆盖)
(
StatusCode::OK,
[(
header::SET_COOKIE,
session_cookie(&sid, st.config.cookie_ttl_secs),
)],
AppendHeaders([
(header::SET_COOKIE, session_cookie(&sid, ttl)),
(
header::SET_COOKIE,
yk_session::set_cookie_header(&session.cookie_header, ttl),
),
]),
Json(json!({ "code": 0, "msg": "ok", "data": data })),
)
.into_response()
Expand All @@ -224,10 +242,13 @@ async fn me(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Result<Respo
}

async fn logout() -> Response {
// 删除本站 cookie:Max-Age=0(雨课堂凭证由会话过期/重启清理)
// 清除本站 cookie(sid 与 yk_session 都置 Max-Age=0;内存表条目由重启/过期自然清理)
(
StatusCode::OK,
[(header::SET_COOKIE, session_cookie("", 0))],
AppendHeaders([
(header::SET_COOKIE, session_cookie("", 0)),
(header::SET_COOKIE, yk_session::clear_cookie_header()),
]),
Json(json!({ "code": 0, "msg": "ok", "data": null })),
)
.into_response()
Expand Down Expand Up @@ -351,7 +372,7 @@ mod tests {
// ---- /api/auth/logout ----

#[tokio::test]
async fn logout_clears_cookie() {
async fn logout_clears_both_cookies() {
let resp = app()
.oneshot(
Request::builder()
Expand All @@ -363,14 +384,17 @@ mod tests {
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let set_cookie = resp
let cookies: Vec<String> = resp
.headers()
.get(header::SET_COOKIE)
.unwrap()
.to_str()
.unwrap();
assert!(set_cookie.starts_with("sid=;"));
assert!(set_cookie.contains("Max-Age=0"));
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
assert_eq!(cookies.len(), 2);
assert!(cookies[0].starts_with("sid=;"));
assert!(cookies[0].contains("Max-Age=0"));
assert!(cookies[1].starts_with("yk_session=;"));
assert!(cookies[1].contains("Max-Age=0"));
}

// ---- /api/auth/login(wiremock 模拟雨课堂上游) ----
Expand Down Expand Up @@ -419,11 +443,51 @@ mod tests {
assert_eq!(v["code"], 0);
assert_eq!(v["data"]["user_id"], 42);

// Set-Cookie 里携带可验证的 sid
// Set-Cookie 里携带可验证的 sid 与 yk_session(雨课堂凭证持久化到浏览器)
// (会话存储断言见下方独立测试)
let _ = v;
}

#[tokio::test]
async fn login_sets_yk_session_cookie_with_rain_credentials() {
let server = mock_upstream_login_ok().await;
let resp = app_with(&server.uri())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"account": "13800000000", "password": "pw", "ticket": "t", "rand": "r"})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let cookies: Vec<String> = resp
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect();
let yk_line = cookies
.iter()
.find(|c| c.starts_with("yk_session="))
.expect("yk_session cookie expected");
assert!(yk_line.contains("HttpOnly"));
assert!(yk_line.contains(&format!("Max-Age={}", 14 * 24 * 60 * 60)));
let value = yk_line
.split(';')
.next()
.unwrap()
.trim_start_matches("yk_session=");
assert_eq!(
yk_session::decode(value).as_deref(),
Some("sessionid=abc123; csrftoken=tok99")
);
}

#[tokio::test]
async fn login_stores_rain_cookies_in_memory() {
let server = mock_upstream_login_ok().await;
Expand Down
131 changes: 131 additions & 0 deletions backend/src/auth/yk_session.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! 雨课堂凭证的浏览器端持久化(HttpOnly cookie `yk_session`)
//!
//! 与雨课堂官网自身同策:登录后把凭证放进浏览器 HttpOnly cookie(官网存 `sessionid`,
//! 我们存其原始 Cookie 头的 base64url 编码)。后端 `sessions` 内存表重启即清空,
//! 凭证落在浏览器后重启不再丢;内存表仅兜底升级前已登录的旧会话。

use axum::http::{HeaderMap, header};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;

/// 本站承载雨课堂凭证的 cookie 名(HttpOnly,页面 JS 不可读)
pub const YK_SESSION_COOKIE: &str = "yk_session";

const COOKIE_ATTRS: &str = "Path=/; HttpOnly; SameSite=Lax";

/// 把雨课堂原始 Cookie 头编码为本站 cookie 值。
/// base64url 无填充:值里不含 `;` / 空格 / `=`,不会被 cookie 语法截断。
pub fn encode(cookie_header: &str) -> String {
URL_SAFE_NO_PAD.encode(cookie_header.as_bytes())
}

/// 解码浏览器 cookie 值回原始 Cookie 头;非法输入按无凭证处理(返回 None,不记日志避免落值)
pub fn decode(value: &str) -> Option<String> {
let bytes = URL_SAFE_NO_PAD.decode(value).ok()?;
String::from_utf8(bytes)
.ok()
.filter(|s| !s.trim().is_empty())
}

/// 登录成功时的 Set-Cookie 行:TTL 与雨课堂 sessionid 对齐(14 天固定,不随访问滑动)
pub fn set_cookie_header(cookie_header: &str, max_age: u64) -> String {
format!(
"{YK_SESSION_COOKIE}={}; {COOKIE_ATTRS}; Max-Age={max_age}",
encode(cookie_header)
)
}

/// 退出登录时清除该 cookie
pub fn clear_cookie_header() -> String {
format!("{YK_SESSION_COOKIE}=; {COOKIE_ATTRS}; Max-Age=0")
}

/// 从请求 Cookie 头提取并解码 `yk_session`;缺失或解码失败统一按无凭证处理
pub fn extract(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
let prefix = format!("{YK_SESSION_COOKIE}=");
for pair in raw.split(';') {
if let Some(value) = pair.trim().strip_prefix(prefix.as_str()) {
return decode(value);
}
}
None
}

#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;

const COOKIE_HEADER: &str = "sessionid=abc123; csrftoken=tok99";

#[test]
fn encode_decode_roundtrip() {
let encoded = encode(COOKIE_HEADER);
// base64url 无填充:不含 cookie 语法敏感字符
assert!(!encoded.contains([';', '=', ' ', '+', '/']));
assert_eq!(decode(&encoded).as_deref(), Some(COOKIE_HEADER));
}

#[test]
fn decode_rejects_garbage() {
assert_eq!(decode("not-base64!!"), None);
assert_eq!(decode(""), None);
// 合法 base64 但内容为空白:视为无凭证
assert_eq!(decode(&encode(" ")), None);
}

#[test]
fn set_cookie_line_has_secure_attrs_and_ttl() {
let line = set_cookie_header(COOKIE_HEADER, 1209600);
assert!(line.starts_with(&format!("{YK_SESSION_COOKIE}=")));
assert!(line.contains("HttpOnly"));
assert!(line.contains("SameSite=Lax"));
assert!(line.contains("Max-Age=1209600"));
// 编码值可从 Set-Cookie 行还原
let value = line
.split(';')
.next()
.unwrap()
.trim_start_matches("yk_session=");
assert_eq!(decode(value).as_deref(), Some(COOKIE_HEADER));
}

#[test]
fn clear_cookie_sets_max_age_zero() {
let line = clear_cookie_header();
assert!(line.starts_with(&format!("{YK_SESSION_COOKIE}=;")));
assert!(line.contains("Max-Age=0"));
assert!(line.contains("HttpOnly"));
}

#[test]
fn extract_parses_from_mixed_cookie_header() {
let mut h = HeaderMap::new();
h.insert(
header::COOKIE,
format!(
"a=1; {}={}; sid=tok",
YK_SESSION_COOKIE,
encode(COOKIE_HEADER)
)
.parse()
.unwrap(),
);
assert_eq!(extract(&h).as_deref(), Some(COOKIE_HEADER));
}

#[test]
fn extract_missing_or_invalid_is_none() {
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "sid=tok; a=1".parse().unwrap());
assert_eq!(extract(&h), None);
assert_eq!(extract(&HeaderMap::new()), None);

h.insert(
header::COOKIE,
format!("{}=!!bad!!", YK_SESSION_COOKIE).parse().unwrap(),
);
assert_eq!(extract(&h), None);
}
}
39 changes: 31 additions & 8 deletions backend/src/courses/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ impl YkClient {
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let body: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let body = parse_upstream_json(resp, "learning_list").await?;
fail_on_error_envelope(&body)?;
Ok(body
.get("data")
Expand All @@ -58,10 +55,7 @@ impl YkClient {
.send()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let envelope: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let envelope = parse_upstream_json(resp, "on-lesson").await?;
let data = map_envelope(envelope)?;
Ok(data
.get("onLessonClassrooms")
Expand All @@ -71,6 +65,35 @@ impl YkClient {
}
}

/// 解析上游 JSON 响应:非 2xx 或解析失败时把 HTTP 状态与响应体片段写入日志,
/// 否则上游的真实返回(如被重定向到登录页的 HTML)会丢失,只看到一句「内部错误」。
/// 只记响应体(上游返回,不含凭证),绝不记录请求侧 cookie。
async fn parse_upstream_json(resp: reqwest::Response, endpoint: &str) -> Result<Value, AppError> {
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::Internal(e.into()))?;
if !status.is_success() {
tracing::warn!(endpoint = %endpoint, status = %status, body = %body_snippet(&text), "上游返回非 2xx");
}
serde_json::from_str(&text).map_err(|e| {
tracing::warn!(endpoint = %endpoint, status = %status, body = %body_snippet(&text), "上游响应不是 JSON");
AppError::Internal(e.into())
})
}

/// 日志用响应体片段:截断到 500 字符,避免刷爆日志
fn body_snippet(text: &str) -> String {
if text.chars().count() <= 500 {
text.to_string()
} else {
let mut s: String = text.chars().take(500).collect();
s.push_str("…(截断)");
s
}
}

/// `/v/course_meta/*` 成功响应无 `code` 字段;仅当带 `code` 且非 0 时判错
fn fail_on_error_envelope(body: &Value) -> Result<(), AppError> {
match body.get("code").and_then(Value::as_i64) {
Expand Down
Loading
Loading