添加扫码登录功能 (#601)

* feat: 添加扫码登录功能,支持生成二维码并轮询登录状态

* feat: 增强扫码登录功能的测试,完善二维码生成与状态轮询的文档注释

* refactor: 后端改动之:1)拆分 login 到 credential 中;2)扫码登录和刷新凭据时复用相同的 extract 函数;3)精简注释。

* refactor: 前端改动之:1)扫码在单独的弹窗页处理;2)不同 status 下采用相同布局,避免状态变化导致布局跳动

* format

---------

Co-authored-by: zkl <i@zkl2333.com>
This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2026-01-11 12:59:48 +08:00
committed by GitHub
parent 64eecaa822
commit 5944298f10
15 changed files with 5915 additions and 50 deletions

View File

@@ -123,3 +123,8 @@ pub struct UpdateVideoSourceRequest {
pub struct DefaultPathRequest {
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct PollQrcodeRequest {
pub qrcode_key: String,
}

View File

@@ -3,6 +3,7 @@ use bili_sync_entity::*;
use sea_orm::{DerivePartialModel, FromQueryResult};
use serde::Serialize;
use crate::bilibili::{PollStatus, Qrcode};
use crate::utils::status::{PageStatus, VideoStatus};
#[derive(Serialize)]
@@ -213,3 +214,7 @@ pub struct VideoSourceDetail {
pub struct UpdateVideoSourceResponse {
pub rule_display: Option<String>,
}
pub type GenerateQrcodeResponse = Qrcode;
pub type PollQrcodeResponse = PollStatus;

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use anyhow::Result;
use axum::Router;
use axum::extract::{Extension, Query};
use axum::routing::{get, post};
use crate::api::request::PollQrcodeRequest;
use crate::api::response::{GenerateQrcodeResponse, PollQrcodeResponse};
use crate::api::wrapper::{ApiError, ApiResponse};
use crate::bilibili::{BiliClient, Credential};
pub(super) fn router() -> Router {
Router::new()
.route("/login/qrcode/generate", post(generate_qrcode))
.route("/login/qrcode/poll", get(poll_qrcode))
}
/// 生成扫码登录二维码
pub async fn generate_qrcode(
Extension(bili_client): Extension<Arc<BiliClient>>,
) -> Result<ApiResponse<GenerateQrcodeResponse>, ApiError> {
Ok(ApiResponse::ok(Credential::generate_qrcode(&bili_client.client).await?))
}
/// 轮询扫码登录状态
pub async fn poll_qrcode(
Extension(bili_client): Extension<Arc<BiliClient>>,
Query(params): Query<PollQrcodeRequest>,
) -> Result<ApiResponse<PollQrcodeResponse>, ApiError> {
Ok(ApiResponse::ok(
Credential::poll_qrcode(&bili_client.client, &params.qrcode_key).await?,
))
}

View File

@@ -12,6 +12,7 @@ use crate::config::VersionedConfig;
mod config;
mod dashboard;
mod login;
mod me;
mod task;
mod video_sources;
@@ -25,6 +26,7 @@ pub fn router() -> Router {
"/api",
config::router()
.merge(me::router())
.merge(login::router())
.merge(video_sources::router())
.merge(videos::router())
.merge(dashboard::router())

View File

@@ -9,7 +9,7 @@ use rsa::sha2::Sha256;
use rsa::{Oaep, RsaPublicKey};
use serde::{Deserialize, Serialize};
use crate::bilibili::{Client, Validate};
use crate::bilibili::{BiliError, Client, Validate};
const MIXIN_KEY_ENC_TAB: [usize; 64] = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38,
@@ -17,6 +17,13 @@ const MIXIN_KEY_ENC_TAB: [usize; 64] = [
20, 34, 44, 52,
];
mod qrcode_status_code {
pub const SUCCESS: i64 = 0;
pub const NOT_SCANNED: i64 = 86101;
pub const SCANNED_UNCONFIRMED: i64 = 86090;
pub const EXPIRED: i64 = 86038;
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Credential {
pub sessdata: String,
@@ -32,6 +39,28 @@ pub struct WbiImg {
pub(crate) sub_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Qrcode {
pub url: String,
pub qrcode_key: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum PollStatus {
Success {
credential: Credential,
},
Pending {
message: String,
#[serde(default)]
scanned: bool,
},
Expired {
message: String,
},
}
impl WbiImg {
pub fn into_mixin_key(self) -> Option<String> {
let key = match (get_filename(self.img_url.as_str()), get_filename(self.sub_url.as_str())) {
@@ -56,6 +85,78 @@ impl Credential {
Ok(serde_json::from_value(res["data"]["wbi_img"].take())?)
}
pub async fn generate_qrcode(client: &Client) -> Result<Qrcode> {
let mut res = client
.request(
Method::GET,
"https://passport.bilibili.com/x/passport-login/web/qrcode/generate",
None,
)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
Ok(serde_json::from_value(res["data"].take())?)
}
pub async fn poll_qrcode(client: &Client, qrcode_key: &str) -> Result<PollStatus> {
let mut resp = client
.request(
Method::GET,
"https://passport.bilibili.com/x/passport-login/web/qrcode/poll",
None,
)
.query(&[("qrcode_key", qrcode_key)])
.send()
.await?
.error_for_status()?;
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let code = json["data"]["code"].as_i64().context("missing 'code' field in data")?;
match code {
qrcode_status_code::SUCCESS => {
let mut credential = Self::extract(headers, json)?;
credential.buvid3 = Self::get_buvid3(client).await?;
Ok(PollStatus::Success { credential })
}
qrcode_status_code::NOT_SCANNED => Ok(PollStatus::Pending {
message: "未扫描".to_owned(),
scanned: false,
}),
qrcode_status_code::SCANNED_UNCONFIRMED => Ok(PollStatus::Pending {
message: "已扫描,请在手机上确认登录".to_owned(),
scanned: true,
}),
qrcode_status_code::EXPIRED => Ok(PollStatus::Expired {
message: "二维码已过期".to_owned(),
}),
_ => {
bail!(BiliError::InvalidResponse(json.to_string()));
}
}
}
/// 获取 buvid3 浏览器指纹
///
/// 参考 https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/buvid3_4.md
async fn get_buvid3(client: &Client) -> Result<String> {
let resp = client
.request(Method::GET, "https://api.bilibili.com/x/web-frontend/getbuvid", None)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?
.validate()?;
resp["data"]["buvid"]
.as_str()
.context("missing 'buvid' field in data")
.map(|s| s.to_string())
}
/// 检查凭据是否有效
pub async fn need_refresh(&self, client: &Client) -> Result<bool> {
let res = client
@@ -124,7 +225,7 @@ JNrRuoEUXpabUzGB8QIDAQAB
}
async fn get_new_credential(&self, client: &Client, csrf: &str) -> Result<Credential> {
let mut res = client
let mut resp = client
.request(
Method::POST,
"https://passport.bilibili.com/x/passport-login/web/cookie/refresh",
@@ -141,37 +242,10 @@ JNrRuoEUXpabUzGB8QIDAQAB
.send()
.await?
.error_for_status()?;
// 必须在 .json 前取出 headers否则 res 会被消耗
let headers = std::mem::take(res.headers_mut());
let res = res.json::<serde_json::Value>().await?.validate()?;
let set_cookies = headers.get_all(header::SET_COOKIE);
let mut credential = Self {
buvid3: self.buvid3.clone(),
..Self::default()
};
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
let cookies: Vec<Cookie> = set_cookies
.iter()
.filter_map(|x| x.to_str().ok())
.filter_map(|x| Cookie::parse(x).ok())
.filter(|x| required_cookies.contains(x.name()))
.collect();
ensure!(
cookies.len() == required_cookies.len(),
"not all required cookies found"
);
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
_ => unreachable!(),
}
}
match res["data"]["refresh_token"].as_str() {
Some(token) => credential.ac_time_value = token.to_string(),
None => bail!("refresh_token not found"),
}
let headers = std::mem::take(resp.headers_mut());
let json = resp.json::<serde_json::Value>().await?.validate()?;
let mut credential = Self::extract(headers, json)?;
credential.buvid3 = self.buvid3.clone();
Ok(credential)
}
@@ -195,6 +269,36 @@ JNrRuoEUXpabUzGB8QIDAQAB
.validate()?;
Ok(())
}
/// 解析 header 和 json获取除 buvid3 字段外全部填充的 Credential
fn extract(headers: header::HeaderMap, json: serde_json::Value) -> Result<Credential> {
let mut credential = Credential::default();
let required_cookies = HashSet::from(["SESSDATA", "bili_jct", "DedeUserID"]);
let cookies: Vec<Cookie> = headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|x| x.to_str().ok())
.filter_map(|x| Cookie::parse(x).ok())
.filter(|x| required_cookies.contains(x.name()))
.collect();
ensure!(
cookies.len() == required_cookies.len(),
"not all required cookies found"
);
for cookie in cookies {
match cookie.name() {
"SESSDATA" => credential.sessdata = cookie.value().to_string(),
"bili_jct" => credential.bili_jct = cookie.value().to_string(),
"DedeUserID" => credential.dedeuserid = cookie.value().to_string(),
_ => unreachable!(),
}
}
match json["data"]["refresh_token"].as_str() {
Some(token) => credential.ac_time_value = token.to_string(),
None => bail!("refresh_token not found"),
}
Ok(credential)
}
}
// 用指定的 pattern 正则表达式在 doc 中查找,返回第一个匹配的捕获组
@@ -246,4 +350,94 @@ mod tests {
"bar=%E4%BA%94%E4%B8%80%E5%9B%9B&baz=1919810&foo=one%20one%20four"
);
}
#[test]
fn test_extract_credential_success() {
let mut headers = header::HeaderMap::new();
headers.append(
header::SET_COOKIE,
"SESSDATA=test_sessdata; Path=/; Domain=bilibili.com".parse().unwrap(),
);
headers.append(
header::SET_COOKIE,
"bili_jct=test_jct; Path=/; Domain=bilibili.com".parse().unwrap(),
);
headers.append(
header::SET_COOKIE,
"DedeUserID=123456; Path=/; Domain=bilibili.com".parse().unwrap(),
);
let json = serde_json::json!({
"data": {
"refresh_token": "test_refresh_token"
}
});
let credential = Credential::extract(headers, json).unwrap();
assert_eq!(credential.sessdata, "test_sessdata");
assert_eq!(credential.bili_jct, "test_jct");
assert_eq!(credential.dedeuserid, "123456");
assert_eq!(credential.ac_time_value, "test_refresh_token");
assert!(credential.buvid3.is_empty());
}
#[test]
fn test_extract_credential_missing_sessdata() {
let headers = header::HeaderMap::new();
let json = serde_json::json!({
"data": {
"refresh_token": "test_refresh_token"
}
});
assert!(Credential::extract(headers, json).is_err());
}
#[test]
fn test_extract_credential_missing_refresh_token() {
let mut headers = header::HeaderMap::new();
headers.append(header::SET_COOKIE, "SESSDATA=test_sessdata".parse().unwrap());
headers.append(header::SET_COOKIE, "bili_jct=test_jct".parse().unwrap());
headers.append(header::SET_COOKIE, "DedeUserID=123456".parse().unwrap());
let json = serde_json::json!({
"data": {}
});
assert!(Credential::extract(headers, json).is_err());
}
#[ignore = "requires manual testing with real QR code scan"]
#[tokio::test]
async fn test_qrcode_login_flow() -> Result<()> {
let client = Client::new();
// 1. 生成二维码
let qr_response = Credential::generate_qrcode(&client).await?;
println!("二维码 URL: {}", qr_response.url);
println!("qrcode_key: {}", qr_response.qrcode_key);
println!("\n请使用 B 站 APP 扫描二维码...\n");
// 2. 轮询登录状态(最多轮询 90 次,每 2 秒一次,共 180 秒)
for i in 1..=90 {
println!("{} 次轮询...", i);
let status = Credential::poll_qrcode(&client, &qr_response.qrcode_key).await?;
match status {
PollStatus::Success { credential } => {
println!("\n登录成功!");
println!("SESSDATA: {}", credential.sessdata);
println!("bili_jct: {}", credential.bili_jct);
println!("buvid3: {}", credential.buvid3);
println!("DedeUserID: {}", credential.dedeuserid);
println!("ac_time_value: {}", credential.ac_time_value);
return Ok(());
}
PollStatus::Pending { message, scanned } => {
println!("状态: {}, 已扫描: {}", message, scanned);
}
PollStatus::Expired { message } => {
println!("\n二维码已过期: {}", message);
anyhow::bail!("二维码过期");
}
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
bail!("轮询超时")
}
}

View File

@@ -4,8 +4,8 @@ use thiserror::Error;
pub enum BiliError {
#[error("response missing 'code' or 'message' field, full response: {0}")]
InvalidResponse(String),
#[error("API returned error code {0}, message: {1}, full response: {2}")]
ErrorResponse(i64, String, String),
#[error("API returned error code {0}, full response: {1}")]
ErrorResponse(i64, String),
#[error("risk control triggered by server, full response: {0}")]
RiskControlOccurred(String),
#[error("no video streams available (may indicate risk control)")]

View File

@@ -2,13 +2,13 @@ use std::borrow::Cow;
use std::sync::Arc;
pub use analyzer::{BestStream, FilterOption};
use anyhow::{Result, bail, ensure};
use anyhow::{Context, Result, bail, ensure};
use arc_swap::ArcSwapOption;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
pub use client::{BiliClient, Client};
pub use collection::{Collection, CollectionItem, CollectionType};
pub use credential::Credential;
pub use credential::{Credential, PollStatus, Qrcode};
pub use danmaku::DanmakuOption;
pub use dynamic::Dynamic;
pub use error::BiliError;
@@ -51,17 +51,13 @@ impl Validate for serde_json::Value {
type Output = serde_json::Value;
fn validate(self) -> Result<Self::Output> {
let (code, msg) = match (self["code"].as_i64(), self["message"].as_str()) {
(Some(code), Some(msg)) => (code, msg),
_ => bail!(BiliError::InvalidResponse(self.to_string())),
};
let code = self["code"]
.as_i64()
.with_context(|| BiliError::InvalidResponse(self.to_string()))?;
if code == -352 || !self["data"]["v_voucher"].is_null() {
bail!(BiliError::RiskControlOccurred(self.to_string()));
}
ensure!(
code == 0,
BiliError::ErrorResponse(code, msg.to_owned(), self.to_string())
);
ensure!(code == 0, BiliError::ErrorResponse(code, self.to_string()));
Ok(self)
}
}

View File

@@ -133,7 +133,7 @@ pub async fn fetch_video_details(
"获取视频 {} - {} 的详细信息失败,错误为:{:#}",
&video_model.bvid, &video_model.name, e
);
if let Some(BiliError::ErrorResponse(-404, _, _)) = e.downcast_ref::<BiliError>() {
if let Some(BiliError::ErrorResponse(-404, _)) = e.downcast_ref::<BiliError>() {
let mut video_active_model: bili_sync_entity::video::ActiveModel = video_model.into();
video_active_model.valid = Set(false);
video_active_model.save(connection).await?;