use crate::client::{self, PORTAL_BASE};
use anyhow::{anyhow, Context, Result};
use colored::Colorize;
use pkuinfo_common::{
credential,
iaaa::{self, IaaaConfig},
session::{Session, Store},
};
pub const APP_NAME: &str = "campuscard";
const PORTAL_APP_ID: &str = "portal2017";
const PORTAL_REDIRECT: &str = "https://portal.pku.edu.cn/portal2017/ssoLogin.do";
fn iaaa_config() -> IaaaConfig {
IaaaConfig {
app_id: PORTAL_APP_ID.to_string(),
redirect_url: PORTAL_REDIRECT.to_string(),
}
}
pub async fn login_with_password(username: Option<&str>) -> Result<()> {
let store = Store::new(APP_NAME)?;
check_existing_session(&store)?;
let cred = credential::resolve_credential(username)?;
let simple_client = client::build_simple()?;
let config = iaaa_config();
let iaaa_token = {
let otp_code = pkuinfo_common::otp::get_current_otp(store.config_dir())?;
if otp_code.is_some() {
println!("{} 已自动填入手机令牌", "[otp]".cyan());
}
iaaa::login_password(
&simple_client,
&config,
&cred.username,
&cred.password,
otp_code.as_deref(),
)
.await?
};
complete_login(&store, &iaaa_token.token, &cred.username).await
}
pub async fn login_with_qrcode(qr_mode: pkuinfo_common::qr::QrDisplayMode) -> Result<()> {
let store = Store::new(APP_NAME)?;
check_existing_session(&store)?;
let simple_client = client::build_simple()?;
let config = iaaa_config();
let iaaa_token =
iaaa::login_qrcode(&simple_client, &config, store.config_dir(), qr_mode).await?;
complete_login(&store, &iaaa_token.token, "").await
}
async fn complete_login(store: &Store, iaaa_token: &str, username: &str) -> Result<()> {
let no_redirect = client::build_no_redirect()?;
println!("{} 登录门户...", "[1/3]".green());
let rand_val: f64 = rand::random();
let sso_url = format!("{PORTAL_REDIRECT}?_rand={rand_val:.20}&token={iaaa_token}");
let resp = no_redirect
.get(&sso_url)
.send()
.await
.context("门户 SSO 请求失败")?;
let status = resp.status();
if !status.is_success() && !status.is_redirection() {
return Err(anyhow!("门户 SSO 登录失败: HTTP {status}"));
}
let _ = resp.bytes().await?;
println!("{} 获取校园卡授权...", "[2/3]".green());
let redirect_url = format!("{PORTAL_BASE}/util/redirectToCard.do");
let resp = no_redirect
.get(&redirect_url)
.send()
.await
.context("redirectToCard 请求失败")?;
let location = get_location(&resp, "redirectToCard")?;
let _ = resp.bytes().await?;
println!("{} 获取登录凭证...", "[3/3]".green());
let resp = no_redirect
.get(&location)
.send()
.await
.context("berserker-auth 请求失败")?;
let auth_location = get_location(&resp, "berserker-auth")?;
let _ = resp.bytes().await?;
let jwt = extract_jwt(&auth_location)?;
let mut session = Session::new(jwt.clone());
session.expires_at = Some(chrono::Utc::now().timestamp() + 24 * 3600);
if !username.is_empty() {
session.uid = Some(username.to_string());
}
store.save_session(&session)?;
println!();
println!("{} 校园卡登录成功!", "[done]".green().bold());
println!(" 配置目录 = {}", store.config_dir().display());
Ok(())
}
fn get_location(resp: &reqwest::Response, step: &str) -> Result<String> {
resp.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.ok_or_else(|| anyhow!("{step} 未返回重定向(HTTP {})", resp.status()))
}
fn extract_jwt(url: &str) -> Result<String> {
let parsed = reqwest::Url::parse(url).context("解析重定向 URL 失败")?;
for (k, v) in parsed.query_pairs() {
if k == "synjones-auth" {
return Ok(v.to_string());
}
}
Err(anyhow!("重定向 URL 中未找到 synjones-auth 参数"))
}
fn check_existing_session(store: &Store) -> Result<()> {
if let Some(old) = store.load_session()? {
if !old.is_expired() {
println!("{} 检测到已有登录会话,继续将覆盖。", "[info]".cyan(),);
}
}
Ok(())
}
pub fn status() -> Result<()> {
let store = Store::new(APP_NAME)?;
match store.load_session()? {
Some(s) => {
println!("{} 已登录", "●".green());
println!(
" 创建时间 = {}",
s.created_at.format("%Y-%m-%d %H:%M:%S UTC")
);
if let Some(uid) = &s.uid {
println!(" 用户名 = {uid}");
}
println!(" 配置目录 = {}", store.config_dir().display());
}
None => {
println!("{} 未登录。运行 `campuscard login` 开始。", "○".red());
}
}
Ok(())
}
pub fn logout() -> Result<()> {
let store = Store::new(APP_NAME)?;
store.clear()?;
println!("{} 已清除本地会话", "✓".green());
Ok(())
}
pub fn load_jwt() -> Result<String> {
let store = Store::new(APP_NAME)?;
let session = store
.load_session()?
.ok_or_else(|| anyhow!("未登录。请先运行 `campuscard login`"))?;
if session.is_expired() {
return Err(anyhow!("会话已过期。请重新运行 `campuscard login`"));
}
Ok(session.token)
}