diff options
Diffstat (limited to 'src/frontend/login.rs')
-rw-r--r-- | src/frontend/login.rs | 292 |
1 files changed, 292 insertions, 0 deletions
diff --git a/src/frontend/login.rs b/src/frontend/login.rs new file mode 100644 index 0000000..09fa75f --- /dev/null +++ b/src/frontend/login.rs @@ -0,0 +1,292 @@ +use std::convert::TryInto; +use std::str::FromStr; +use log::{debug, error}; +use rand::Rng; +use http_types::Mime; +use tide::{Request, Response, Result}; +use serde::{Serialize, Deserialize}; +use sha2::{Sha256, Digest}; + +use crate::frontend::{FrontendError, IndiewebEndpoints}; +use crate::{ApplicationState, database::Storage}; +use crate::frontend::templates::Template; + +markup::define! { + LoginPage { + form[method="POST"] { + h1 { "Sign in with your website" } + p { + "Signing in to Kittybox might allow you to view private content " + "intended for your eyes only." + } + + section { + label[for="url"] { "Your website URL" } + input[id="url", name="url", placeholder="https://example.com/"]; + input[type="submit"]; + } + } + } +} + +pub async fn form<S: Storage>(req: Request<ApplicationState<S>>) -> Result { + let owner = req.url().origin().ascii_serialization() + "/"; + let storage = &req.state().storage; + let authorization_endpoint = req.state().authorization_endpoint.to_string(); + let token_endpoint = req.state().token_endpoint.to_string(); + let blog_name = storage.get_setting("site_name", &owner).await.unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = storage.get_channels(&owner).await.unwrap_or_default(); + + Ok(Response::builder(200) + .body(Template { + title: "Sign in with IndieAuth", + blog_name: &blog_name, + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None + }, + feeds, + user: req.session().get("user"), + content: LoginPage {}.to_string(), + }.to_string()) + .content_type("text/html; charset=utf-8") + .build()) +} + +#[derive(Serialize, Deserialize)] +struct LoginForm { + url: String +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthClientState { + /// A random value to protect from CSRF attacks. + nonce: String, + /// The user's initial "me" value. + me: String, + /// Authorization endpoint used. + authorization_endpoint: String +} + + +#[derive(Serialize, Deserialize)] +struct IndieAuthRequestParams { + response_type: String, // can only have "code". TODO make an enum + client_id: String, // always a URL. TODO consider making a URL + redirect_uri: surf::Url, // callback URI for IndieAuth + state: String, // CSRF protection, should include randomness and be passed through + code_challenge: String, // base64-encoded PKCE challenge + code_challenge_method: String, // usually "S256". TODO make an enum + scope: Option<String>, // oAuth2 scopes to grant, + me: surf::Url, // User's entered profile URL + +} + +/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it. +pub async fn handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { + let content_type = req.content_type(); + if content_type.is_none() { + return Err(FrontendError::with_code(400, "Use the login form, Luke.").into()); + } + if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() { + return Err(FrontendError::with_code(400, "Login form results must be a urlencoded form").into()); + } + + let form = req.body_form::<LoginForm>().await?; // FIXME check if it returns 400 or 500 on error + let homepage_uri = surf::Url::parse(&form.url)?; + let http = &req.state().http_client; + + let mut fetch_response = http.get(&homepage_uri).send().await?; + if fetch_response.status() != 200 { + return Err(FrontendError::with_code(500, "Error fetching your authorization endpoint. Check if your website's okay.").into()); + } + + let mut authorization_endpoint: Option<surf::Url> = None; + if let Some(links) = fetch_response.header("Link") { + // NOTE: this is the same Link header parser used in src/micropub/post.rs:459. + // One should refactor it to a function to use independently and improve later + for link in links.iter().flat_map(|i| i.as_str().split(',')) { + debug!("Trying to match {} as authorization_endpoint", link); + let mut split_link = link.split(';'); + + match split_link.next() { + Some(uri) => { + if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) { + debug!("uri: {}", uri); + for prop in split_link { + debug!("prop: {}", prop); + let lowercased = prop.to_ascii_lowercase(); + let trimmed = lowercased.trim(); + if trimmed == "rel=\"authorization_endpoint\"" + || trimmed == "rel=authorization_endpoint" + { + if let Ok(endpoint) = homepage_uri.join(uri) { + debug!("Found authorization endpoint {} for user {}", endpoint, homepage_uri.as_str()); + authorization_endpoint = Some(endpoint); + break; + } + } + } + } + }, + None => continue, + } + } + } + // If the authorization_endpoint is still not found after the Link parsing gauntlet, + // bring out the big guns and parse HTML to find it. + if authorization_endpoint.is_none() { + let body = fetch_response.body_string().await?; + let pattern = easy_scraper::Pattern::new(r#"<link rel="authorization_endpoint" href="{{url}}">"#) + .expect("Cannot parse the pattern for authorization_endpoint"); + let matches = pattern.matches(&body); + debug!("Matches for authorization_endpoint in HTML: {:?}", matches); + if !matches.is_empty() { + if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) { + debug!("Found authorization endpoint {} for user {}", endpoint, homepage_uri.as_str()); + authorization_endpoint = Some(endpoint) + } + } + }; + // If even after this the authorization endpoint is still not found, bail out. + if authorization_endpoint.is_none() { + error!("Couldn't find authorization_endpoint for {}", homepage_uri.as_str()); + return Err(FrontendError::with_code(400, "Your website doesn't support the IndieAuth protocol.").into()); + } + let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap(); + let mut rng = rand::thread_rng(); + let state: String = data_encoding::BASE64URL.encode(serde_urlencoded::to_string(IndieAuthClientState { + nonce: (0..8).map(|_| { + let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len()); + INDIEAUTH_PKCE_CHARSET[idx] as char + }).collect(), + me: homepage_uri.to_string(), + authorization_endpoint: authorization_endpoint.to_string() + })?.as_bytes()); + // PKCE code generation + let code_verifier: String = (0..128).map(|_| { + let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len()); + INDIEAUTH_PKCE_CHARSET[idx] as char + }).collect(); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize()); + + authorization_endpoint.set_query(Some(&serde_urlencoded::to_string(IndieAuthRequestParams { + response_type: "code".to_string(), + client_id: req.url().origin().ascii_serialization(), + redirect_uri: req.url().join("login/callback")?, + state: state.clone(), code_challenge, + code_challenge_method: "S256".to_string(), + scope: Some("profile".to_string()), + me: homepage_uri, + })?)); + + let cookies = vec![ + format!(r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#, state), + format!(r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#, code_verifier) + ]; + + let cookie_header = cookies.iter().map(|i| -> http_types::headers::HeaderValue { + (i as &str).try_into().unwrap() + }).collect::<Vec<_>>(); + + Ok(Response::builder(302) + .header("Location", authorization_endpoint.to_string()) + .header("Set-Cookie", &*cookie_header) + .build()) +} + +const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 1234567890-._~"; + +#[derive(Deserialize)] +struct IndieAuthCallbackResponse { + code: Option<String>, + error: Option<String>, + error_description: Option<String>, + #[allow(dead_code)] + error_uri: Option<String>, + // This needs to be further decoded to receive state back and will always be present + state: String +} + +impl IndieAuthCallbackResponse { + fn is_successful(&self) -> bool { + !self.code.is_none() + } +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthCodeRedeem { + grant_type: String, + code: String, + client_id: String, + redirect_uri: String, + code_verifier: String +} + +#[derive(Serialize, Deserialize)] +struct IndieWebProfile { + name: Option<String>, + url: Option<String>, + email: Option<String>, + photo: Option<String> +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthResponse { + me: String, + scope: Option<String>, + access_token: Option<String>, + token_type: Option<String>, + profile: Option<IndieWebProfile> +} + +/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage. +pub async fn callback<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { + let params: IndieAuthCallbackResponse = req.query()?; + let http: &surf::Client = &req.state().http_client; + let origin = req.url().origin().ascii_serialization(); + + if req.cookie("indieauth_state").unwrap().value() != ¶ms.state { + return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into()); + } + let state: IndieAuthClientState = serde_urlencoded::from_bytes( + &data_encoding::BASE64URL.decode(params.state.as_bytes())? + )?; + + if !params.is_successful() { + return Err(FrontendError::with_code(400, &format!("The authorization endpoint indicated a following error: {:?}: {:?}", ¶ms.error, ¶ms.error_description)).into()) + } + + let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap(); + let mut code_response = http.post(authorization_endpoint) + .body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem { + grant_type: "authorization_code".to_string(), + code: params.code.unwrap().to_string(), + client_id: origin.to_string(), + redirect_uri: origin + "/login/callback", + code_verifier: req.cookie("indieauth_code_verifier").unwrap().value().to_string() + })?) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .send().await?; + + if code_response.status() != 200 { + return Err(FrontendError::with_code(code_response.status(), &format!("Authorization endpoint returned an error when redeeming the code: {}", code_response.body_string().await?)).into()); + } + + let json: IndieAuthResponse = code_response.body_json().await?; + drop(http); + let session = req.session_mut(); + session.insert("user", &json.me)?; + + // TODO redirect to the page user came from + Ok(Response::builder(302) + .header("Location", "/") + .build()) +} |