use std::{
borrow::BorrowMut,
io::{BufRead, Write},
sync::Arc,
};
use cookie_store::serde::json::{load_all, save_incl_expired_and_nonpersistent};
use reqwest::{
Client,
cookie::{CookieStore, Jar},
header::{COOKIE, HOST, HeaderValue, SET_COOKIE},
};
use reqwest_cookie_store::CookieStoreRwLock;
use url::Url;
use wdpe::error::{ClientError, WebDynproError};
use crate::{
error::{RusaintError, SsuSsoError},
utils::{DEFAULT_USER_AGENT, default_header},
};
const SSU_USAINT_PORTAL_URL: &str = "https://saint.ssu.ac.kr/irj/portal";
const SSU_USAINT_SSO_URL: &str = "https://saint.ssu.ac.kr/webSSO/sso.jsp";
const SMARTID_LOGIN_URL: &str = "https://smartid.ssu.ac.kr/Symtra_sso/smln.asp";
const SMARTID_LOGIN_FORM_REQUEST_URL: &str = "https://smartid.ssu.ac.kr/Symtra_sso/smln_pcs.asp";
#[derive(Debug, Default)]
pub struct USaintSession(CookieStoreRwLock);
impl CookieStore for USaintSession {
fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &Url) {
self.0.set_cookies(cookie_headers, url)
}
fn cookies(&self, url: &Url) -> Option<HeaderValue> {
self.0.cookies(url)
}
}
impl USaintSession {
pub fn anonymous() -> USaintSession {
USaintSession(CookieStoreRwLock::default())
}
pub async fn with_token(id: &str, token: &str) -> Result<USaintSession, RusaintError> {
let session_store = Self::anonymous();
let client = Client::builder()
.user_agent(DEFAULT_USER_AGENT)
.build()
.unwrap();
let portal = client
.get(SSU_USAINT_PORTAL_URL)
.headers(default_header())
.header(HOST, "saint.ssu.ac.kr".parse::<HeaderValue>().unwrap())
.send()
.await
.map_err(|e| {
WebDynproError::from(ClientError::FailedRequest(format!(
"failed to send request: {e}"
)))
})?;
let waf = portal.cookies().find(|cookie| cookie.name() == "WAF");
session_store.set_cookies(
portal
.headers()
.iter()
.filter_map(|header| {
if header.0 == SET_COOKIE {
Some(header.1)
} else {
None
}
})
.borrow_mut(),
portal.url(),
);
if let Some(waf) = waf {
let waf_cookie_str = format!("WAF={}; domain=saint.ssu.ac.kr; path=/;", waf.value());
session_store
.0
.write()
.unwrap()
.parse(
&waf_cookie_str,
&Url::parse("https://saint.ssu.ac.kr").unwrap(),
)
.unwrap();
} else {
tracing::warn!("WAF cookie not found in portal response");
}
let token_cookie_str = format!("sToken={token}; domain=.ssu.ac.kr; path=/; secure");
let req = client
.get(format!("{SSU_USAINT_SSO_URL}?sToken={token}&sIdno={id}"))
.headers(default_header())
.header(
COOKIE,
session_store
.cookies(&Url::parse("https://saint.ssu.ac.kr").unwrap())
.unwrap(),
)
.header(COOKIE, token_cookie_str.parse::<HeaderValue>().unwrap())
.header(HOST, "saint.ssu.ac.kr".parse::<HeaderValue>().unwrap())
.build()
.map_err(|e| {
WebDynproError::from(ClientError::FailedRequest(format!(
"failed to build request: {e}"
)))
})?;
let res = client.execute(req).await.map_err(|e| {
WebDynproError::from(ClientError::FailedRequest(format!(
"failed to send request: {e}"
)))
})?;
let mut new_cookies = res.headers().iter().filter_map(|header| {
if header.0 == SET_COOKIE {
Some(header.1)
} else {
None
}
});
session_store.set_cookies(&mut new_cookies, res.url());
if let Some(sapsso_cookies) = session_store.cookies(res.url()) {
let str = sapsso_cookies
.to_str()
.or(Err(ClientError::NoCookies(res.url().to_string())))
.map_err(WebDynproError::from)?;
if str.contains("MYSAPSSO2") {
Ok(session_store)
} else {
Err(WebDynproError::from(ClientError::NoSuchCookie(
"MYSAPSSO2".to_string(),
)))?
}
} else {
Err(WebDynproError::from(ClientError::NoCookies(
res.url().to_string(),
)))?
}
}
pub async fn with_password(id: &str, password: &str) -> Result<USaintSession, RusaintError> {
let token = obtain_ssu_sso_token(id, password).await?;
Self::with_token(id, &token).await
}
pub fn save_to_json<W: Write>(&self, writer: &mut W) -> Result<(), RusaintError> {
let store = self.0.read().unwrap();
save_incl_expired_and_nonpersistent(&store, writer).map_err(|_| {
WebDynproError::from(ClientError::NoCookies("Failed to save cookies".to_string()))
})?;
Ok(())
}
pub fn from_json<R: BufRead>(reader: R) -> Result<USaintSession, RusaintError> {
let store = load_all(reader).map_err(|_| {
WebDynproError::from(ClientError::NoCookies("Failed to load cookies".to_string()))
})?;
let store = CookieStoreRwLock::new(store);
Ok(USaintSession(store))
}
}
pub async fn obtain_ssu_sso_token(id: &str, password: &str) -> Result<String, SsuSsoError> {
let jar: Arc<Jar> = Arc::new(Jar::default());
let client = Client::builder()
.cookie_provider(jar)
.cookie_store(true)
.user_agent(DEFAULT_USER_AGENT)
.build()?;
let body = client
.get(SMARTID_LOGIN_URL)
.headers(default_header())
.send()
.await?
.text()
.await?;
let (in_tp_bit, rqst_caus_cd) = parse_login_form(&body)?;
let params = [
("in_tp_bit", in_tp_bit.as_str()),
("rqst_caus_cd", rqst_caus_cd.as_str()),
("userid", id),
("pwd", password),
];
let res = client
.post(SMARTID_LOGIN_FORM_REQUEST_URL)
.headers(default_header())
.form(¶ms)
.send()
.await?;
let cookie_token = {
res.cookies()
.find(|cookie| cookie.name() == "sToken" && !cookie.value().is_empty())
.map(|cookie| cookie.value().to_string())
};
let message = if cookie_token.is_none() {
let mut content = res.text().await?;
let start = content.find("alert(\"").unwrap_or(0);
let end = content.find("\");").unwrap_or(content.len());
content.truncate(end);
let message = content.split_off(start + 7);
Some(message)
} else {
None
};
cookie_token.ok_or(SsuSsoError::CantFindToken(
message.unwrap_or("Internal Error".to_string()),
))
}
fn parse_login_form(body: &str) -> Result<(String, String), SsuSsoError> {
let document = wdpe::scraper::Html::parse_document(body);
let in_tp_bit_selector = wdpe::scraper::Selector::parse(r#"input[name="in_tp_bit"]"#).unwrap();
let rqst_caus_cd_selector =
wdpe::scraper::Selector::parse(r#"input[name="rqst_caus_cd"]"#).unwrap();
let in_tp_bit = document
.select(&in_tp_bit_selector)
.next()
.ok_or(SsuSsoError::CantLoadForm)?
.value()
.attr("value")
.ok_or(SsuSsoError::CantLoadForm)?;
let rqst_caus_cd = document
.select(&rqst_caus_cd_selector)
.next()
.ok_or(SsuSsoError::CantLoadForm)?
.value()
.attr("value")
.ok_or(SsuSsoError::CantLoadForm)?;
Ok((in_tp_bit.to_owned(), rqst_caus_cd.to_owned()))
}