rusaint/
session.rs

1use std::{
2    borrow::BorrowMut,
3    io::{BufRead, Write},
4    sync::Arc,
5};
6
7use cookie_store::serde::json::{load_all, save_incl_expired_and_nonpersistent};
8use reqwest::{
9    Client,
10    cookie::{CookieStore, Jar},
11    header::{COOKIE, HOST, HeaderValue, SET_COOKIE},
12};
13use reqwest_cookie_store::CookieStoreRwLock;
14use url::Url;
15use wdpe::error::{ClientError, WebDynproError};
16
17use crate::{
18    error::{RusaintError, SsuSsoError},
19    utils::{DEFAULT_USER_AGENT, default_header},
20};
21
22const SSU_USAINT_PORTAL_URL: &str = "https://saint.ssu.ac.kr/irj/portal";
23const SSU_USAINT_SSO_URL: &str = "https://saint.ssu.ac.kr/webSSO/sso.jsp";
24const SMARTID_LOGIN_URL: &str = "https://smartid.ssu.ac.kr/Symtra_sso/smln.asp";
25const SMARTID_LOGIN_FORM_REQUEST_URL: &str = "https://smartid.ssu.ac.kr/Symtra_sso/smln_pcs.asp";
26
27/// u-saint 로그인이 필요한 애플리케이션 사용 시 애플리케이션에 제공하는 세션
28#[derive(Debug, Default)]
29pub struct USaintSession(CookieStoreRwLock);
30
31impl CookieStore for USaintSession {
32    fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, url: &Url) {
33        self.0.set_cookies(cookie_headers, url)
34    }
35
36    fn cookies(&self, url: &Url) -> Option<HeaderValue> {
37        self.0.cookies(url)
38    }
39}
40
41impl USaintSession {
42    /// 익명 세션을 반환합니다. 인증이 필요 없는 애플리케이션에서의 세션 동작과 동일합니다.
43    pub fn anonymous() -> USaintSession {
44        USaintSession(CookieStoreRwLock::default())
45    }
46
47    /// SSO 로그인 토큰과 학번으로 인증된 세션을 반환합니다.
48    pub async fn with_token(id: &str, token: &str) -> Result<USaintSession, RusaintError> {
49        let session_store = Self::anonymous();
50        let client = Client::builder()
51            .user_agent(DEFAULT_USER_AGENT)
52            .build()
53            .unwrap();
54        // Manually include WAF cookies because of bug in reqwest::cookie::Jar
55        let portal = client
56            .get(SSU_USAINT_PORTAL_URL)
57            .headers(default_header())
58            .header(HOST, "saint.ssu.ac.kr".parse::<HeaderValue>().unwrap())
59            .send()
60            .await
61            .map_err(|e| {
62                WebDynproError::from(ClientError::FailedRequest(format!(
63                    "failed to send request: {e}"
64                )))
65            })?;
66        let waf = portal.cookies().find(|cookie| cookie.name() == "WAF");
67
68        session_store.set_cookies(
69            portal
70                .headers()
71                .iter()
72                .filter_map(|header| {
73                    if header.0 == SET_COOKIE {
74                        Some(header.1)
75                    } else {
76                        None
77                    }
78                })
79                .borrow_mut(),
80            portal.url(),
81        );
82
83        if let Some(waf) = waf {
84            let waf_cookie_str = format!("WAF={}; domain=saint.ssu.ac.kr; path=/;", waf.value());
85            session_store
86                .0
87                .write()
88                .unwrap()
89                .parse(
90                    &waf_cookie_str,
91                    &Url::parse("https://saint.ssu.ac.kr").unwrap(),
92                )
93                .unwrap();
94        } else {
95            tracing::warn!("WAF cookie not found in portal response");
96        }
97        let token_cookie_str = format!("sToken={token}; domain=.ssu.ac.kr; path=/; secure");
98        let req = client
99            .get(format!("{SSU_USAINT_SSO_URL}?sToken={token}&sIdno={id}"))
100            .query(&[("sToken", token), ("sIdno", id)])
101            .headers(default_header())
102            .header(
103                COOKIE,
104                session_store
105                    .cookies(&Url::parse("https://saint.ssu.ac.kr").unwrap())
106                    .unwrap(),
107            )
108            .header(COOKIE, token_cookie_str.parse::<HeaderValue>().unwrap())
109            .header(HOST, "saint.ssu.ac.kr".parse::<HeaderValue>().unwrap())
110            .build()
111            .map_err(|e| {
112                WebDynproError::from(ClientError::FailedRequest(format!(
113                    "failed to build request: {e}"
114                )))
115            })?;
116        let res = client.execute(req).await.map_err(|e| {
117            WebDynproError::from(ClientError::FailedRequest(format!(
118                "failed to send request: {e}"
119            )))
120        })?;
121        let mut new_cookies = res.headers().iter().filter_map(|header| {
122            if header.0 == SET_COOKIE {
123                Some(header.1)
124            } else {
125                None
126            }
127        });
128        session_store.set_cookies(&mut new_cookies, res.url());
129        if let Some(sapsso_cookies) = session_store.cookies(res.url()) {
130            let str = sapsso_cookies
131                .to_str()
132                .or(Err(ClientError::NoCookies(res.url().to_string())))
133                .map_err(WebDynproError::from)?;
134            if str.contains("MYSAPSSO2") {
135                Ok(session_store)
136            } else {
137                Err(WebDynproError::from(ClientError::NoSuchCookie(
138                    "MYSAPSSO2".to_string(),
139                )))?
140            }
141        } else {
142            Err(WebDynproError::from(ClientError::NoCookies(
143                res.url().to_string(),
144            )))?
145        }
146    }
147
148    /// 학번과 비밀번호로 인증된 세션을 반환합니다.
149    pub async fn with_password(id: &str, password: &str) -> Result<USaintSession, RusaintError> {
150        let token = obtain_ssu_sso_token(id, password).await?;
151        Self::with_token(id, &token).await
152    }
153
154    /// 현재 세션의 쿠키를 json 형식으로 저장합니다.
155    pub fn save_to_json<W: Write>(&self, writer: &mut W) -> Result<(), RusaintError> {
156        let store = self.0.read().unwrap();
157        save_incl_expired_and_nonpersistent(&store, writer).map_err(|_| {
158            WebDynproError::from(ClientError::NoCookies("Failed to save cookies".to_string()))
159        })?;
160
161        Ok(())
162    }
163
164    /// json 형식으로 저장된 쿠키를 읽어 세션을 생성합니다.
165    pub fn from_json<R: BufRead>(reader: R) -> Result<USaintSession, RusaintError> {
166        let store = load_all(reader).map_err(|_| {
167            WebDynproError::from(ClientError::NoCookies("Failed to load cookies".to_string()))
168        })?;
169        let store = CookieStoreRwLock::new(store);
170        Ok(USaintSession(store))
171    }
172}
173
174/// 학번과 비밀번호를 이용해 SSO 토큰을 발급받습니다.
175pub async fn obtain_ssu_sso_token(id: &str, password: &str) -> Result<String, SsuSsoError> {
176    let jar: Arc<Jar> = Arc::new(Jar::default());
177    let client = Client::builder()
178        .cookie_provider(jar)
179        .cookie_store(true)
180        .user_agent(DEFAULT_USER_AGENT)
181        .build()?;
182    let body = client
183        .get(SMARTID_LOGIN_URL)
184        .headers(default_header())
185        .send()
186        .await?
187        .text()
188        .await?;
189    let (in_tp_bit, rqst_caus_cd) = parse_login_form(&body)?;
190    let params = [
191        ("in_tp_bit", in_tp_bit.as_str()),
192        ("rqst_caus_cd", rqst_caus_cd.as_str()),
193        ("userid", id),
194        ("pwd", password),
195    ];
196    let res = client
197        .post(SMARTID_LOGIN_FORM_REQUEST_URL)
198        .headers(default_header())
199        .form(&params)
200        .send()
201        .await?;
202    let cookie_token = {
203        res.cookies()
204            .find(|cookie| cookie.name() == "sToken" && !cookie.value().is_empty())
205            .map(|cookie| cookie.value().to_string())
206    };
207    let message = if cookie_token.is_none() {
208        let mut content = res.text().await?;
209        let start = content.find("alert(\"").unwrap_or(0);
210        let end = content.find("\");").unwrap_or(content.len());
211        content.truncate(end);
212        let message = content.split_off(start + 7);
213        Some(message)
214    } else {
215        None
216    };
217    cookie_token.ok_or(SsuSsoError::CantFindToken(
218        message.unwrap_or("Internal Error".to_string()),
219    ))
220}
221
222fn parse_login_form(body: &str) -> Result<(String, String), SsuSsoError> {
223    let document = scraper::Html::parse_document(body);
224    let in_tp_bit_selector = scraper::Selector::parse(r#"input[name="in_tp_bit"]"#).unwrap();
225    let rqst_caus_cd_selector = scraper::Selector::parse(r#"input[name="rqst_caus_cd"]"#).unwrap();
226    let in_tp_bit = document
227        .select(&in_tp_bit_selector)
228        .next()
229        .ok_or(SsuSsoError::CantLoadForm)?
230        .value()
231        .attr("value")
232        .ok_or(SsuSsoError::CantLoadForm)?;
233    let rqst_caus_cd = document
234        .select(&rqst_caus_cd_selector)
235        .next()
236        .ok_or(SsuSsoError::CantLoadForm)?
237        .value()
238        .attr("value")
239        .ok_or(SsuSsoError::CantLoadForm)?;
240    Ok((in_tp_bit.to_owned(), rqst_caus_cd.to_owned()))
241}