From cf8c944d42d726bdc3e9a0648289d41207deda31 Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Wed, 17 Jan 2024 13:08:48 +0000 Subject: [PATCH] Refactor login so that Client does it all. This sets up to allow login to happen within a Tui context, and also, transfer seamlessly into a logged-in session once you've finished it. --- src/auth.rs | 2 +- src/client.rs | 155 ++++++++++++++++++++++++++++++++++++++------ src/login.rs | 176 +++++--------------------------------------------- src/tui.rs | 11 +++- 4 files changed, 161 insertions(+), 183 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 9388f64..1423d45 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -26,7 +26,7 @@ impl std::fmt::Display for AuthError { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct AuthConfig { pub account_id: Option, pub username: Option, diff --git a/src/client.rs b/src/client.rs index 6c1fac6..79d753e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,8 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::File; use std::io::{IoSlice, Read, Write}; -use super::auth::{AuthConfig, AuthError}; -use super::config::ConfigLocation; +use super::auth::AuthConfig; use super::posting::Post; use super::types::*; @@ -109,7 +108,7 @@ pub enum AccountFlag { } pub struct Client { - auth: AuthConfig, + pub auth: AuthConfig, client: reqwest::blocking::Client, accounts: HashMap, statuses: HashMap, @@ -123,7 +122,6 @@ pub struct Client { #[derive(Debug, PartialEq, Eq, Clone)] pub enum ClientError { - Auth(AuthError), // message InternalError(String), // message UrlParseError(String, String), // url, message UrlError(String, String), // url, message @@ -132,12 +130,6 @@ pub enum ClientError { impl super::TopLevelErrorCandidate for ClientError {} -impl From for ClientError { - fn from(err: AuthError) -> Self { - ClientError::Auth(err) - } -} - impl From for ClientError { fn from(err: reqwest::Error) -> Self { match err.url() { @@ -155,9 +147,6 @@ impl std::fmt::Display for ClientError { f: &mut std::fmt::Formatter<'_>, ) -> Result<(), std::fmt::Error> { match self { - ClientError::Auth(ref autherr) => { - write!(f, "unable to read authentication: {}", autherr) - } ClientError::InternalError(ref msg) => { write!(f, "internal failure: {}", msg) } @@ -456,10 +445,17 @@ pub fn execute_and_log_request( Ok((rsp, log)) } +const REDIRECT_MAGIC_STRING: &str = "urn:ietf:wg:oauth:2.0:oob"; + +pub enum AppTokenType<'a> { + ClientCredentials, + AuthorizationCode(&'a str), +} + impl Client { - pub fn new(cfgloc: &ConfigLocation) -> Result { + pub fn new(auth: AuthConfig) -> Result { Ok(Client { - auth: AuthConfig::load(cfgloc)?, + auth, client: reqwest_client()?, accounts: HashMap::new(), statuses: HashMap::new(), @@ -610,15 +606,19 @@ impl Client { self.polls.insert(poll.id.to_string(), poll.clone()); } - pub fn account_by_id(&mut self, id: &str) -> Result { - // If we're fetching our own account, we do it via the + fn account_by_id_internal( + &mut self, + id: Option<&str>, + ) -> Result { + // If we're fetching our own account, or we don't know our + // account id yet because we're still logging in, we use the // verify_credentials request, which gets the extra // information. This also means we must repeat the request if // we've already received a copy of our own account details // via some other API and it doesn't contain the extra // information. - if let Some(ac) = self.accounts.get(id) { + if let Some(ac) = id.and_then(|id| self.accounts.get(id)) { if Some(&ac.id) != self.auth.account_id.as_ref() || ac.source.is_some() { @@ -626,10 +626,10 @@ impl Client { } } - let req = if Some(id) == self.auth.account_id.as_deref() { + let req = if id == self.auth.account_id.as_deref() { Req::get(&format!("api/v1/accounts/verify_credentials")) } else { - Req::get(&format!("api/v1/accounts/{id}")) + Req::get(&format!("api/v1/accounts/{}", id.unwrap())) }; let (url, rsp) = self.api_request(req)?; let rspstatus = rsp.status(); @@ -643,7 +643,7 @@ impl Client { } } }?; - if ac.id != id { + if id.is_some_and(|id| ac.id != id) { return Err(ClientError::UrlError( url, format!("request returned wrong account id {}", &ac.id), @@ -653,6 +653,16 @@ impl Client { Ok(ac) } + pub fn account_by_id(&mut self, id: &str) -> Result { + self.account_by_id_internal(Some(id)) + } + + pub fn verify_account_credentials( + &mut self, + ) -> Result { + self.account_by_id_internal(None) + } + pub fn poll_by_id(&mut self, id: &str) -> Result { if let Some(st) = self.polls.get(id) { return Ok(st.clone()); @@ -1482,4 +1492,107 @@ impl Client { self.cache_account(&ac); Ok(()) } + + pub fn register_client(&mut self) -> Result { + let req = Req::post("/api/v1/apps") + .param("redirect_uris", REDIRECT_MAGIC_STRING) + .param("client_name", "Mastodonochrome") + .param("scopes", "read write push") + .param("website", "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/"); + let (url, rsp) = self.api_request(req)?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url, rspstatus.to_string())) + } else { + let app: Application = match serde_json::from_str(&rsp.text()?) { + Ok(app) => Ok(app), + Err(e) => Err(ClientError::UrlError(url, e.to_string())), + }?; + Ok(app) + } + } + + pub fn get_app_token( + &mut self, + app: &Application, + toktype: AppTokenType, + ) -> Result { + let client_id = match &app.client_id { + Some(id) => Ok(id), + None => Err(ClientError::InternalError( + "registering application did not return a client id" + .to_owned(), + )), + }?; + let client_secret = match &app.client_secret { + Some(id) => Ok(id), + None => Err(ClientError::InternalError( + "registering application did not return a client secret" + .to_owned(), + )), + }?; + + let req = Req::post("/oauth/token") + .param("redirect_uri", REDIRECT_MAGIC_STRING) + .param("client_id", client_id) + .param("client_secret", client_secret); + let req = match toktype { + AppTokenType::ClientCredentials => { + req.param("grant_type", "client_credentials") + } + AppTokenType::AuthorizationCode(code) => req + .param("grant_type", "authorization_code") + .param("code", code) + .param("scope", "read write push"), + }; + let (url, rsp) = self.api_request(req)?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url, rspstatus.to_string())) + } else { + let tok: Token = match serde_json::from_str(&rsp.text()?) { + Ok(tok) => Ok(tok), + Err(e) => Err(ClientError::UrlError(url, e.to_string())), + }?; + Ok(tok) + } + } + + pub fn verify_app_credentials( + &mut self, + ) -> Result { + let req = Req::get("api/v1/apps/verify_credentials"); + let (url, rsp) = self.api_request(req)?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url, rspstatus.to_string())) + } else { + let app: Application = match serde_json::from_str(&rsp.text()?) { + Ok(app) => Ok(app), + Err(e) => Err(ClientError::UrlError(url, e.to_string())), + }?; + Ok(app) + } + } + + pub fn get_auth_url( + &self, + app: &Application, + ) -> Result { + let client_id = match &app.client_id { + Some(id) => Ok(id), + None => Err(ClientError::InternalError( + "registering application did not return a client id" + .to_owned(), + )), + }?; + + let (_urlstr, url) = Req::get("/oauth/authorize") + .param("redirect_uri", REDIRECT_MAGIC_STRING) + .param("client_id", client_id) + .param("scope", "read write push") + .param("response_type", "code") + .url(self.auth.instance_url.as_deref().expect("should have set up an instance URL before calling get_auth_url"))?; + Ok(url.to_string()) + } } diff --git a/src/login.rs b/src/login.rs index 487c6a3..f17514a 100644 --- a/src/login.rs +++ b/src/login.rs @@ -3,155 +3,11 @@ use std::fs::File; use std::io::Write; use super::auth::{AuthConfig, AuthError}; -use super::client::{ - execute_and_log_request, reqwest_client, ClientError, Req, -}; +use super::client::{Client, ClientError, AppTokenType}; use super::config::ConfigLocation; -use super::types::{Account, Application, Instance, Token}; +use super::types::{Account, Application, Instance}; use super::TopLevelError; -struct Login { - instance_url: String, - client: reqwest::blocking::Client, - logfile: Option, -} - -enum AppTokenType<'a> { - ClientCredentials, - AuthorizationCode(&'a str), -} -use AppTokenType::*; - -const REDIRECT_MAGIC_STRING: &str = "urn:ietf:wg:oauth:2.0:oob"; - -impl Login { - fn new( - instance_url: &str, - logfile: Option, - ) -> Result { - Ok(Login { - instance_url: instance_url.to_owned(), - client: reqwest_client()?, - logfile, - }) - } - - fn execute_request( - &mut self, - req: reqwest::blocking::RequestBuilder, - ) -> Result { - let (rsp, log) = execute_and_log_request(&self.client, req.build()?)?; - log.write_to(&mut self.logfile); - Ok(rsp) - } - - fn register_client(&mut self) -> Result { - let (url, req) = Req::post("/api/v1/apps") - .param("redirect_uris", REDIRECT_MAGIC_STRING) - .param("client_name", "Mastodonochrome") - .param("scopes", "read write push") - .param("website", "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/") - .build(&self.instance_url, &self.client, None)?; - let rsp = self.execute_request(req)?; - let rspstatus = rsp.status(); - if !rspstatus.is_success() { - Err(ClientError::UrlError(url, rspstatus.to_string())) - } else { - let app: Application = match serde_json::from_str(&rsp.text()?) { - Ok(app) => Ok(app), - Err(e) => Err(ClientError::UrlError(url, e.to_string())), - }?; - Ok(app) - } - } - - fn get_token( - &mut self, - app: &Application, - toktype: AppTokenType, - ) -> Result { - let client_id = match &app.client_id { - Some(id) => Ok(id), - None => Err(ClientError::InternalError( - "registering application did not return a client id" - .to_owned(), - )), - }?; - let client_secret = match &app.client_secret { - Some(id) => Ok(id), - None => Err(ClientError::InternalError( - "registering application did not return a client secret" - .to_owned(), - )), - }?; - - let req = Req::post("/oauth/token") - .param("redirect_uri", REDIRECT_MAGIC_STRING) - .param("client_id", client_id) - .param("client_secret", client_secret); - let req = match toktype { - ClientCredentials => req.param("grant_type", "client_credentials"), - AuthorizationCode(code) => req - .param("grant_type", "authorization_code") - .param("code", code) - .param("scope", "read write push"), - }; - let (url, req) = req.build(&self.instance_url, &self.client, None)?; - let rsp = self.execute_request(req)?; - let rspstatus = rsp.status(); - if !rspstatus.is_success() { - Err(ClientError::UrlError(url, rspstatus.to_string())) - } else { - let tok: Token = match serde_json::from_str(&rsp.text()?) { - Ok(tok) => Ok(tok), - Err(e) => Err(ClientError::UrlError(url, e.to_string())), - }?; - Ok(tok) - } - } - - fn get serde::Deserialize<'a>>( - &mut self, - path: &str, - token: &str, - ) -> Result { - let (url, req) = Req::get(path).build( - &self.instance_url, - &self.client, - Some(token), - )?; - let rsp = self.execute_request(req)?; - let rspstatus = rsp.status(); - if !rspstatus.is_success() { - Err(ClientError::UrlError(url, rspstatus.to_string())) - } else { - let tok: T = match serde_json::from_str(&rsp.text()?) { - Ok(tok) => Ok(tok), - Err(e) => Err(ClientError::UrlError(url, e.to_string())), - }?; - Ok(tok) - } - } - - fn get_auth_url(&self, app: &Application) -> Result { - let client_id = match &app.client_id { - Some(id) => Ok(id), - None => Err(ClientError::InternalError( - "registering application did not return a client id" - .to_owned(), - )), - }?; - - let (_urlstr, url) = Req::get("/oauth/authorize") - .param("redirect_uri", REDIRECT_MAGIC_STRING) - .param("client_id", client_id) - .param("scope", "read write push") - .param("response_type", "code") - .url(&self.instance_url)?; - Ok(url.to_string()) - } -} - pub fn login( cfgloc: &ConfigLocation, instance_url: &str, @@ -181,16 +37,19 @@ pub fn login( }?; let instance_url = url.as_str().trim_end_matches('/'); - let mut login = Login::new(instance_url, logfile)?; + let mut client = Client::new(AuthConfig::default())?; + client.set_logfile(logfile); + client.set_writable(true); + client.auth.instance_url = Some(instance_url.to_owned()); // Register the client and get its details - let app = login.register_client()?; - let app_token = login.get_token(&app, ClientCredentials)?; - let _app: Application = login - .get("/api/v1/apps/verify_credentials", &app_token.access_token)?; + let app = client.register_client()?; + let app_token = client.get_app_token(&app, AppTokenType::ClientCredentials)?; + client.auth.user_token = Some(app_token.access_token); + let _app: Application = client.verify_app_credentials()?; // Get the URL the user will have to visit - let url = login.get_auth_url(&app)?; + let url = client.get_auth_url(&app)?; // Print it println!("Log in to the website {instance_url}/"); @@ -211,13 +70,10 @@ pub fn login( let code = code.trim_end(); // Use that code to get the final user access token - let user_token = login.get_token(&app, AuthorizationCode(code))?; - let account: Account = login.get( - "/api/v1/accounts/verify_credentials", - &user_token.access_token, - )?; - let instance: Instance = - login.get("/api/v2/instance", &user_token.access_token)?; + let token = client.get_app_token(&app, AppTokenType::AuthorizationCode(code))?; + client.auth.user_token = Some(token.access_token.clone()); + let account: Account = client.verify_account_credentials()?; + let instance: Instance = client.instance()?; println!(); println!( @@ -234,7 +90,7 @@ pub fn login( instance_domain: Some(instance.domain), client_id: Some(app.client_id.unwrap()), client_secret: Some(app.client_secret.unwrap()), - user_token: Some(user_token.access_token), + user_token: Some(token.access_token), }; let mut json = serde_json::to_string_pretty(&auth).unwrap(); diff --git a/src/tui.rs b/src/tui.rs index 4f83d05..66e8cde 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -20,6 +20,7 @@ use std::time::Duration; use unicode_width::UnicodeWidthStr; use super::activity_stack::*; +use super::auth::{AuthConfig, AuthError}; use super::client::{ Client, ClientError, FeedExtend, FeedId, StreamId, StreamUpdate, }; @@ -226,6 +227,13 @@ impl From for TuiError { } } } +impl From for TuiError { + fn from(err: AuthError) -> Self { + TuiError { + message: format!("unable to read authentication: {}", err) + } + } +} impl From for TuiError { fn from(err: std::sync::mpsc::RecvError) -> Self { TuiError { @@ -269,7 +277,8 @@ impl Tui { } }); - let mut client = Client::new(cfgloc)?; + let auth = AuthConfig::load(cfgloc)?; + let mut client = Client::new(auth)?; client.set_writable(!readonly); client.set_logfile(logfile); -- 2.30.2