unilim-cas 1.0.1

A purrfect CAS authentication wrapper for Unilim.
Documentation
use super::html::{Page, TwoFaMethods};
use super::{COOKIE, CAS, Error, HOST, PERSIST_COOKIE, Result, totp};
use rikka::{Redirect, Request};
use std::collections::BTreeMap;

/// in-progress authentication awaiting a 2fa challenge.
///
/// returned by [`CAS::initialize`]. when [`PendingAuth::solved`] is already
/// true there is no challenge to solve. otherwise use
/// [`PendingAuth::solve_with_totp`], or [`PendingAuth::send_email_code`]
/// followed by [`PendingAuth::solve_with_email_code`]. finally,
/// [`PendingAuth::finish`] establishes the [`CAS`] session.
#[derive(Debug, Clone)]
pub struct PendingAuth {
    fields: BTreeMap<String, String>,
    /// whether the challenge is already solved.
    pub solved: bool,
    /// whether the email code method is offered.
    pub is_email_available: bool,
    /// whether the totp method is offered.
    pub is_totp_available: bool,
}

impl PendingAuth {
    /// build from the html returned by the initial credentials submission -
    /// done by [`CAS::initialize`].
    pub fn from_html(html: &str) -> Self {
        let page = Page::parse(html);
        let solved = page.form_action() == Some("/registerbrowser");

        let methods = if solved {
            TwoFaMethods::default()
        } else {
            page.twofa_methods()
        };

        Self {
            fields: page.hidden_fields(),
            solved,
            is_email_available: methods.email,
            is_totp_available: methods.totp,
        }
    }

    /// request an email code by selecting the `mail` 2fa method.
    pub async fn send_email_code(&mut self) -> Result<()> {
        self.use_method("mail").await
    }

    /// solve the challenge with an email code.
    pub async fn solve_with_email_code(&mut self, code: &str) -> Result<()> {
        self.solve("mail2fcheck", code).await
    }

    /// solve the challenge with a totp code.
    pub async fn solve_with_totp(&mut self, code: &str) -> Result<()> {
        self.use_method("totp").await?;
        self.solve("totp2fcheck", code).await
    }

    /// register the browser to enable persistence and return the established
    /// [`CAS`] session.
    ///
    /// fails when the challenge is not solved yet. the returned session
    /// holds the values needed by [`CAS::restore`].
    pub async fn finish(&mut self) -> Result<CAS> {
        if !self.solved {
            return Err(Error::Api("2fa not solved".into()));
        }

        let key = self
            .fields
            .get("totpsecret")
            .map(|secret| secret.to_uppercase())
            .ok_or_else(|| Error::Api("'totpsecret' is not available".into()))?;

        self.fields
            .insert("fg".to_owned(), format!("TOTP_{}", totp::generate(&key)?));

        let response = Request::builder(format!("{HOST}/registerbrowser"))
            .post()
            .redirect(Redirect::Manual)
            .form(self.body())
            .send()
            .await?;

        match (
            response.set_cookie_value(COOKIE),
            response.set_cookie_value(PERSIST_COOKIE),
        ) {
            (Some(cookie), Some(connection)) => Ok(CAS::new(cookie, connection, key)),
            _ => Err(Error::Api("bad auth".into())),
        }
    }

    /// select a 2fa method, either `mail` or `totp`.
    async fn use_method(&mut self, choice: &str) -> Result<()> {
        self.fields.insert("sf".to_owned(), choice.to_owned());
        self.submit("2fchoice").await
    }

    /// submit a code to `/{method}`.
    async fn solve(&mut self, method: &str, code: &str) -> Result<()> {
        self.fields.insert("code".to_owned(), code.to_owned());
        self.fields
            .insert("stayconnected".to_owned(), "1".to_owned());
        self.submit(method).await?;
        self.solved = true;
        Ok(())
    }

    /// post the current fields to `/{route}` and re-extract them from the
    /// returned page.
    async fn submit(&mut self, route: &str) -> Result<()> {
        let response = Request::builder(format!("{HOST}/{route}"))
            .post()
            .form(self.body())
            .send()
            .await?;

        self.fields = Page::parse(&response.text()).hidden_fields();
        Ok(())
    }

    fn body(&self) -> Vec<(String, String)> {
        self.fields
            .iter()
            .map(|(key, value)| (key.clone(), value.clone()))
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::PendingAuth;

    fn fixture(name: &str) -> String {
        std::fs::read_to_string(format!("{}/tests/{name}", env!("CARGO_MANIFEST_DIR")))
            .expect("fixture should exist")
    }

    #[test]
    fn parses_2fa_choice_form() {
        let auth = PendingAuth::from_html(&fixture("2fchoice.html"));
        assert!(!auth.solved);
        assert!(auth.is_email_available);
        assert!(auth.is_totp_available);
    }

    #[test]
    fn code_entry_pages_offer_no_method_choice() {
        for name in ["mail2fcheck.html", "totp2fcheck.html"] {
            let auth = PendingAuth::from_html(&fixture(name));
            assert!(!auth.solved, "{name} should not be solved");
            assert!(!auth.is_email_available, "{name}");
            assert!(!auth.is_totp_available, "{name}");
        }
    }
}