feat: persist authentication
This commit is contained in:
parent
9a88ae0012
commit
3cbc26e11c
6 changed files with 152 additions and 57 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
/target
|
||||
.env
|
||||
cookies.cbor
|
||||
|
|
50
Cargo.lock
generated
50
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## todo
|
||||
|
||||
- cookie persistence
|
||||
- loading screen for checking authentication (cookie persistence)
|
||||
- browsing
|
||||
- downloading lectures
|
||||
- support for other echo360 instances
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
51
src/main.rs
51
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<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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue