From 82ce110e3e13b04399483e60c596486a654bcb7d Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Wed, 3 Jan 2024 08:19:37 +0000 Subject: [PATCH] Implement the login procedure, without saving. Currently, the JSON-serialised AuthConfig is written to stdout, just to prove during development that it works. Next step is to save it to where it ought to live. --- src/client.rs | 150 +++++++++++++++++++++------------------ src/lib.rs | 1 + src/login.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 15 +++- src/types.rs | 10 +++ 5 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 src/login.rs diff --git a/src/client.rs b/src/client.rs index 44c9070..4b7d4de 100644 --- a/src/client.rs +++ b/src/client.rs @@ -100,14 +100,14 @@ impl std::fmt::Display for ClientError { // Our own struct to collect the pieces of an HTTP request before we // pass it on to reqwests. Allows incremental adding of request parameters. -struct Req { +pub struct Req { method: reqwest::Method, url_suffix: String, parameters: Vec<(String, String)>, } impl Req { - fn get(url_suffix: &str) -> Self { + pub fn get(url_suffix: &str) -> Self { Req { method: reqwest::Method::GET, url_suffix: url_suffix.to_owned(), @@ -115,7 +115,7 @@ impl Req { } } - fn post(url_suffix: &str) -> Self { + pub fn post(url_suffix: &str) -> Self { Req { method: reqwest::Method::POST, url_suffix: url_suffix.to_owned(), @@ -123,15 +123,43 @@ impl Req { } } - fn param(mut self, key: &str, value: T) -> Self + pub fn param(mut self, key: &str, value: T) -> Self where T: ReqParam { self.parameters.push((key.to_owned(), value.param_value())); self } + + pub fn url(&self, base_url: &str) -> Result<(String, Url), ClientError> { + let urlstr = base_url.to_owned() + &self.url_suffix; + let parsed = if self.parameters.is_empty() { + Url::parse(&urlstr) + } else { + Url::parse_with_params(&urlstr, self.parameters.iter()) + }; + let url = match parsed { + Ok(url) => Ok(url), + Err(e) => Err(ClientError::UrlParseError( + urlstr.clone(), e.to_string())), + }?; + Ok((urlstr, url)) + } + + pub fn build(self, base_url: &str, client: &reqwest::blocking::Client, + bearer_token: Option<&str>) -> + Result<(String, reqwest::blocking::RequestBuilder), ClientError> + { + let (urlstr, url) = self.url(base_url)?; + let req = client.request(self.method, url); + let req = match bearer_token { + Some(tok) => req.bearer_auth(tok), + None => req, + }; + Ok((urlstr, req)) + } } -trait ReqParam { +pub trait ReqParam { fn param_value(self) -> String; } @@ -168,11 +196,56 @@ pub enum FeedExtend { Initial, Past, Future } +pub fn reqwest_client() -> Result { + // We turn off cross-site HTTP redirections in our client + // objects. That's because in general it doesn't do any good + // for a Mastodon API endpoint to redirect to another host: + // most requests have to be authenticated, and if you're + // redirected to another host, that host (by normal reqwest + // policy for cross-site redirections) won't receive your auth + // header. So we might as well reject the redirection in the + // first place. + // + // An exception is when setting up streaming connections, + // because those _can_ redirect to another host - but in that + // case, the target host can be known in advance (via the + // streaming URL in the instance configuration), and also, we + // still can't let _reqwest_ quietly follow the redirection, + // because it will strip off the auth. So we follow it + // ourselves manually, vet the target URL, and put the auth + // back on. + let no_xsite = reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else if let Some(prev_url) = attempt.previous().last() { + let next_url = attempt.url(); + if (prev_url.host(), prev_url.port()) != + (next_url.host(), next_url.port()) + { + // Stop and pass the 3xx response back to the + // caller, rather than throwing a fatal error. + // That way, the streaming setup can implement its + // special case. + attempt.stop() + } else { + attempt.follow() + } + } else { + panic!("confusing redirect with no previous URLs!"); + } + }); + + let client = reqwest::blocking::Client::builder() + .redirect(no_xsite) + .build()?; + Ok(client) +} + impl Client { pub fn new(cfgloc: &ConfigLocation) -> Result { Ok(Client { auth: AuthConfig::load(cfgloc)?, - client: Self::build_client()?, + client: reqwest_client()?, accounts: HashMap::new(), statuses: HashMap::new(), notifications: HashMap::new(), @@ -182,52 +255,6 @@ impl Client { }) } - fn build_client() -> Result - { - // We turn off cross-site HTTP redirections in our client - // objects. That's because in general it doesn't do any good - // for a Mastodon API endpoint to redirect to another host: - // most requests have to be authenticated, and if you're - // redirected to another host, that host (by normal reqwest - // policy for cross-site redirections) won't receive your auth - // header. So we might as well reject the redirection in the - // first place. - // - // An exception is when setting up streaming connections, - // because those _can_ redirect to another host - but in that - // case, the target host can be known in advance (via the - // streaming URL in the instance configuration), and also, we - // still can't let _reqwest_ quietly follow the redirection, - // because it will strip off the auth. So we follow it - // ourselves manually, vet the target URL, and put the auth - // back on. - let no_xsite = reqwest::redirect::Policy::custom(|attempt| { - if attempt.previous().len() > 10 { - attempt.error("too many redirects") - } else if let Some(prev_url) = attempt.previous().last() { - let next_url = attempt.url(); - if (prev_url.host(), prev_url.port()) != - (next_url.host(), next_url.port()) - { - // Stop and pass the 3xx response back to the - // caller, rather than throwing a fatal error. - // That way, the streaming setup can implement its - // special case. - attempt.stop() - } else { - attempt.follow() - } - } else { - panic!("confusing redirect with no previous URLs!"); - } - }); - - let client = reqwest::blocking::Client::builder() - .redirect(no_xsite) - .build()?; - Ok(client) - } - pub fn set_writable(&mut self, permit: bool) { self.permit_write = permit; } @@ -251,21 +278,8 @@ impl Client { "Non-GET request attempted in readonly mode".to_string())); } - let urlstr = self.auth.instance_url.clone() + "/api/" + - &req.url_suffix; - let parsed = if req.parameters.is_empty() { - Url::parse(&urlstr) - } else { - Url::parse_with_params(&urlstr, req.parameters.iter()) - }; - let url = match parsed { - Ok(url) => Ok(url), - Err(e) => Err(ClientError::UrlParseError( - urlstr.clone(), e.to_string())), - }?; - - Ok((urlstr, client.request(req.method, url) - .bearer_auth(&self.auth.user_token))) + let base_url = self.auth.instance_url.to_owned() + "/api/"; + req.build(&base_url, client, Some(&self.auth.user_token)) } fn api_request(&self, req: Req) -> @@ -619,7 +633,7 @@ impl Client { }; let method = req.method.clone(); // to reuse for redirects below - let client = Self::build_client()?; + let client = reqwest_client()?; let (url, mut req) = self.api_request_cl(&client, req)?; let mut rsp = req.send()?; if rsp.status().is_redirection() { diff --git a/src/lib.rs b/src/lib.rs index fc8379d..5cf1fa8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod types; +pub mod login; pub mod auth; pub mod config; pub mod html; diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..66c730e --- /dev/null +++ b/src/login.rs @@ -0,0 +1,190 @@ +use reqwest::Url; +use std::io::Write; + +use super::auth::AuthConfig; +use super::client::{reqwest_client, Req, ClientError}; +use super::types::{Account, Application, Instance, Token}; + +struct Login { + instance_url: String, + client: reqwest::blocking::Client, +} + +enum AppTokenType<'a> { + ClientCredentials, + AuthorizationCode(&'a str), +} +use AppTokenType::*; + +const REDIRECT_MAGIC_STRING: &'static str = "urn:ietf:wg:oauth:2.0:oob"; + +impl Login { + fn new(instance_url: &str) -> Result { + Ok(Login { + instance_url: instance_url.to_owned(), + client: reqwest_client()?, + }) + } + + fn register_client(&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 = req.send()?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url.clone(), rspstatus.to_string())) + } else { + let app: Application = match serde_json::from_str(&rsp.text()?) { + Ok(app) => Ok(app), + Err(e) => Err(ClientError::UrlError( + url.clone(), e.to_string())), + }?; + Ok(app) + } + } + + fn get_token(&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 = req.send()?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url.clone(), rspstatus.to_string())) + } else { + let tok: Token = match serde_json::from_str(&rsp.text()?) { + Ok(tok) => Ok(tok), + Err(e) => Err(ClientError::UrlError( + url.clone(), e.to_string())), + }?; + Ok(tok) + } + } + + fn get serde::Deserialize<'a>>(&self, path: &str, token: &str) -> + Result + { + let (url, req) = Req::get(path) + .build(&self.instance_url, &self.client, Some(token))?; + let rsp = req.send()?; + let rspstatus = rsp.status(); + if !rspstatus.is_success() { + Err(ClientError::UrlError(url.clone(), rspstatus.to_string())) + } else { + let tok: T = match serde_json::from_str(&rsp.text()?) { + Ok(tok) => Ok(tok), + Err(e) => Err(ClientError::UrlError( + url.clone(), 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(instance_url: &str) -> Result<(), ClientError> { + // Ergonomics: parse the URL, adding a default https:// scheme if + // it's just a bare hostname + let urlstr = match instance_url.find('/') { + Some(_) => instance_url.to_owned(), + None => format!("https://{instance_url}"), + }; + let url = match Url::parse(&urlstr) { + Ok(url) => Ok(url), + Err(e) => Err(ClientError::UrlParseError( + urlstr.clone(), e.to_string())), + }?; + let instance_url = url.as_str().trim_end_matches('/'); + + let login = Login::new(instance_url)?; + + // 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)?; + + // Get the URL the user will have to visit + let url = login.get_auth_url(&app)?; + + // Print it + println!("Log in to the website {instance_url}/"); + println!("and then visit this URL to authorise Mastodonochrome:"); + println!(""); + println!("{}", url); + println!(""); + println!("If you click \"Authorise\" on that page, you should receive a response code."); + print!("Enter that code here: "); + std::io::stdout().flush().unwrap(); // FIXME + + // Read the user's response + let code = { + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf).unwrap(); // FIXME + buf + }; + 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)?; + + println!(""); + println!("Successfully logged in as {}@{}", account.id, instance.domain); + + // Save everything! + let auth = AuthConfig { + account_id: account.id, + username: account.username, + instance_url: instance_url.to_owned(), + instance_domain: instance.domain, + client_id: app.client_id.unwrap(), + client_secret: app.client_secret.unwrap(), + user_token: user_token.access_token, + }; + println!("JSON: {}", serde_json::to_string_pretty(&auth).unwrap()); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 65b07db..eb5334e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,10 @@ use clap::Parser; use std::fmt::Display; use std::process::ExitCode; +use mastodonochrome::client::ClientError; use mastodonochrome::config::{ConfigLocation, ConfigError}; use mastodonochrome::tui::{Tui, TuiError}; +use mastodonochrome::login::login; #[derive(Parser, Debug)] struct Args { @@ -11,9 +13,14 @@ struct Args { #[arg(short, long)] config: Option, - // Read-only mode: the client prevents accidental posting. + /// Read-only mode: the client prevents accidental posting. #[arg(short, long, action(clap::ArgAction::SetTrue))] readonly: bool, + + /// Log in to a server, instead of running the main user interface. + /// Provide the top-level URL of the instance website. + #[arg(long, conflicts_with("readonly"))] + login: Option, } #[derive(Debug)] @@ -45,6 +52,7 @@ impl From for TopLevelError { impl TopLevelErrorCandidate for ConfigError {} impl TopLevelErrorCandidate for TuiError {} +impl TopLevelErrorCandidate for ClientError {} impl TopLevelErrorCandidate for clap::error::Error { // clap prints its own "error: " fn get_prefix() -> String { "".to_owned() } @@ -56,7 +64,10 @@ fn main_inner() -> Result<(), TopLevelError> { None => ConfigLocation::default()?, Some(dir) => ConfigLocation::from_pathbuf(dir), }; - Tui::run(&cfgloc, cli.readonly)?; + match cli.login { + None => Tui::run(&cfgloc, cli.readonly)?, + Some(ref server) => login(server)?, + } Ok(()) } diff --git a/src/types.rs b/src/types.rs index b1b9c77..0449611 100644 --- a/src/types.rs +++ b/src/types.rs @@ -118,6 +118,16 @@ pub struct Account { pub struct Application { pub name: String, pub website: Option, + pub client_id: Option, + pub client_secret: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct Token { + pub access_token: String, + pub token_type: String, + pub scope: String, + pub created_at: u64, // Unix timestamp } #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, EnumIter)] -- 2.30.2