feat: persist authentication

This commit is contained in:
newt 2024-11-11 00:37:47 +00:00
parent 9a88ae0012
commit 3cbc26e11c
6 changed files with 152 additions and 57 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
.env
cookies.cbor

50
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -2,7 +2,7 @@
## todo
- cookie persistence
- loading screen for checking authentication (cookie persistence)
- browsing
- downloading lectures
- support for other echo360 instances

View file

@ -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<bool> {
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)
}

View file

@ -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<Self> {
fn new(jar: CookieStoreMutex) -> Result<Self> {
let (exit_tx, exit_rx) = std_mpsc::channel::<Listener>();
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<CrosstermBackend<Stdout>>) -> 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::<Vec<Cookie>, _>(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::<Command>();
let mut exit_triggered = Instant::now();
exit_triggered -= EXIT_TIMEOUT;
@ -69,6 +101,13 @@ async fn run(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> 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::<Vec<_>>();
let cbor = serde_cbor::to_vec(&cookies)?;
file.write_all(&cbor)?;
break;
}
}