添加扫码登录功能 (#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:
@@ -123,3 +123,8 @@ pub struct UpdateVideoSourceRequest {
|
||||
pub struct DefaultPathRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PollQrcodeRequest {
|
||||
pub qrcode_key: String,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal file
34
crates/bili_sync/src/api/routes/login/mod.rs
Normal 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, ¶ms.qrcode_key).await?,
|
||||
))
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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!("轮询超时")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user