From 3cbc26e11c4afbd43671fc165d8554279d0e758d Mon Sep 17 00:00:00 2001 From: newt Date: Mon, 11 Nov 2024 00:37:47 +0000 Subject: [PATCH] feat: persist authentication --- .gitignore | 1 + Cargo.lock | 50 ++++++++++++++++------ Cargo.toml | 2 + readme.md | 2 +- src/helper/echo.rs | 103 +++++++++++++++++++++++++++++---------------- src/main.rs | 51 +++++++++++++++++++--- 6 files changed, 152 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index fedaa2b..7faad5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .env +cookies.cbor diff --git a/Cargo.lock b/Cargo.lock index d289c37..2b9a04a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,12 +184,13 @@ dependencies = [ [[package]] name = "cookie_store" -version = "0.21.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" dependencies = [ "cookie", - "idna 0.5.0", + "document-features", + "idna 1.0.3", "log", "publicsuffix", "serde", @@ -260,12 +261,22 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +dependencies = [ + "litrs", +] + [[package]] name = "echoed" version = "0.1.0" dependencies = [ "async-trait", "color-eyre", + "cookie_store", "crossterm", "email_address", "lazy_static", @@ -274,6 +285,7 @@ dependencies = [ "regex", "reqwest", "reqwest_cookie_store", + "serde_cbor", "thiserror", "tokio", "tui-textarea", @@ -486,6 +498,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + [[package]] name = "hashbrown" version = "0.15.0" @@ -749,16 +767,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - [[package]] name = "idna" version = "1.0.3" @@ -866,6 +874,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "lock_api" version = "0.4.12" @@ -1415,6 +1429,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.214" diff --git a/Cargo.toml b/Cargo.toml index 7e8aa79..7f0e3c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] async-trait = "0.1.83" color-eyre = "0.6.3" +cookie_store = "0.21.1" crossterm = "0.28.1" email_address = "0.2.9" lazy_static = "1.5.0" @@ -15,6 +16,7 @@ ratatui = "0.29.0" regex = "1.11.1" reqwest = { version = "0.12.9", features = ["cookies", "multipart"] } reqwest_cookie_store = { git = "https://git.newty.dev/newt/reqwest_cookie_store_tokio.git" } +serde_cbor = "0.11.2" thiserror = "1.0.66" tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] } tui-textarea = "0.7.0" diff --git a/readme.md b/readme.md index 12e15d3..fce7c9e 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ ## todo -- cookie persistence +- loading screen for checking authentication (cookie persistence) - browsing - downloading lectures - support for other echo360 instances diff --git a/src/helper/echo.rs b/src/helper/echo.rs index 275f910..1531b3b 100644 --- a/src/helper/echo.rs +++ b/src/helper/echo.rs @@ -1,9 +1,8 @@ -use color_eyre::{eyre::eyre, Result}; +use color_eyre::Result; use echoed::{UserData, DEFAULT_ECHO360}; use regex::Regex; use reqwest::multipart::Form; -use reqwest_cookie_store::CookieStore; -use std::sync::{MutexGuard, PoisonError}; +use reqwest_cookie_store::CookieStoreMutex; use thiserror::Error; use crate::State; @@ -20,43 +19,10 @@ pub enum AuthError { InvalidDetails, } -pub async fn authenticate(email: &str, password: &str, state: &mut State) -> Result<(), AuthError> { +async fn save_to_state(state: &mut State) -> Result<(), AuthError> { let base_url = DEFAULT_ECHO360.clone(); let domain = base_url.domain().unwrap(); - // get the csrf token - state.client.get(base_url.clone()).send().await?; - - let csrf_token = state - .cookies - .lock() - .await - .get(domain, "/", "PLAY_SESSION") - .map(|cookie| { - let value = cookie.value().to_string(); - value - .split("&") - .nth(1) - .map(|part| part.split("=").last()) - .flatten() - .map(|x| x.to_string()) - }) - .flatten() - .ok_or(AuthError::Find("CSRF token"))?; - - // authenticate - state - .client - .post(format!("{base_url}login")) - .query(&[("csrfToken", csrf_token)]) - .multipart( - Form::new() - .text("email", email.to_string()) - .text("password", password.to_string()), - ) - .send() - .await?; - // get institution id let insitution_id = state .cookies @@ -107,3 +73,66 @@ pub async fn authenticate(email: &str, password: &str, state: &mut State) -> Res Ok(()) } + +pub async fn authenticate(email: &str, password: &str, state: &mut State) -> Result<(), AuthError> { + let base_url = DEFAULT_ECHO360.clone(); + let domain = base_url.domain().unwrap(); + + // get the csrf token + state.client.get(base_url.clone()).send().await?; + + let csrf_token = state + .cookies + .lock() + .await + .get(domain, "/", "PLAY_SESSION") + .map(|cookie| { + let value = cookie.value().to_string(); + value + .split("&") + .nth(1) + .map(|part| part.split("=").last()) + .flatten() + .map(|x| x.to_string()) + }) + .flatten() + .ok_or(AuthError::Find("CSRF token"))?; + + // authenticate + state + .client + .post(format!("{base_url}login")) + .query(&[("csrfToken", csrf_token)]) + .multipart( + Form::new() + .text("email", email.to_string()) + .text("password", password.to_string()), + ) + .send() + .await?; + + save_to_state(state).await?; + + Ok(()) +} + +pub async fn check_auth(state: &mut State) -> Result { + let html = state + .client + .get(format!("{}courses", DEFAULT_ECHO360.clone())) + .send() + .await? + .text() + .await?; + + // if first name is found, then the user is authenticated + let authenticated = Regex::new(r#"\\"firstName\\":\\"(\w+)\\""#)? + .captures(&html) + .is_some(); + + if authenticated { + save_to_state(state).await?; + } + + Ok(authenticated) +} diff --git a/src/main.rs b/src/main.rs index b5903ad..0d74289 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use color_eyre::Result; +use cookie_store::{Cookie, CookieDomain, CookieStore}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use echoed::UserData; +use echoed::{UserData, DEFAULT_ECHO360}; use ratatui::{ prelude::*, widgets::{BorderType, Borders}, @@ -8,12 +9,13 @@ use ratatui::{ use reqwest::Client; use reqwest_cookie_store::CookieStoreMutex; use std::{ - io::Stdout, + io::{Stdout, Write}, sync::{mpsc as std_mpsc, Arc}, time::{Duration, Instant}, }; use tokio::sync::Mutex; use tui_confirm_dialog::{ConfirmDialog, ConfirmDialogState, Listener}; +use url::Url; use views::ViewContainer; #[macro_use] @@ -39,9 +41,9 @@ pub struct State { } impl State { - fn new() -> Result { + fn new(jar: CookieStoreMutex) -> Result { let (exit_tx, exit_rx) = std_mpsc::channel::(); - let jar = Arc::new(CookieStoreMutex::default()); + let jar = Arc::new(jar); Ok(Self { exit: ConfirmDialogState::default() @@ -57,9 +59,39 @@ impl State { } async fn run(mut terminal: Terminal>) -> Result<()> { - let mut view: ViewContainer = Arc::new(Mutex::new(views::Auth::default())); + // load cookies + let mut file = std::fs::File::open("cookies.cbor").ok(); + let jar = if let Some(file) = file.as_mut() { + let cookies = serde_cbor::from_reader::, _>(file)?; + let mut store = CookieStore::default(); + + for cookie in cookies { + store.insert( + cookie.clone(), + &if let CookieDomain::HostOnly(domain) = cookie.domain { + Url::parse(&format!("https://{}", domain))? + } else { + DEFAULT_ECHO360.clone() + }, + )?; + } + + CookieStoreMutex::new(store) + } else { + CookieStoreMutex::default() + }; + + // check if the cookies provide authentication + let mut state = State::new(jar)?; + let authenticated = helper::echo::check_auth(&mut state).await?; + + let mut view: ViewContainer = if authenticated { + Arc::new(Mutex::new(views::Home::default())) as ViewContainer + } else { + Arc::new(Mutex::new(views::Auth::default())) as ViewContainer + }; + view.lock().await.setup(); - let mut state = State::new()?; let (view_tx, view_rx) = std_mpsc::channel::(); let mut exit_triggered = Instant::now(); exit_triggered -= EXIT_TIMEOUT; @@ -69,6 +101,13 @@ async fn run(mut terminal: Terminal>) -> Result<()> { // check if the app should exit if let Ok((_, exit)) = state.exit_rx.try_recv() { if exit.unwrap_or_default() { + // save cookies + // todo: decide on a place to store this + let mut file = std::fs::File::create("cookies.cbor")?; + let lock = state.cookies.lock().await; + let cookies = lock.iter_unexpired().collect::>(); + let cbor = serde_cbor::to_vec(&cookies)?; + file.write_all(&cbor)?; break; } }