use http_types::Mime; use log::{debug, error}; use rand::Rng; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::convert::TryInto; use std::str::FromStr; use crate::frontend::templates::Template; use crate::frontend::{FrontendError, IndiewebEndpoints}; use crate::{database::Storage, ApplicationState}; 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(req: Request>) -> 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, // 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(mut req: Request>) -> 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::().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 = 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#""#) .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::>(); 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, error: Option, error_description: Option, #[allow(dead_code)] error_uri: Option, // 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_some() } } #[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, url: Option, email: Option, photo: Option, } #[derive(Serialize, Deserialize)] struct IndieAuthResponse { me: String, scope: Option, access_token: Option, token_type: Option, profile: Option, } /// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage. pub async fn callback(mut req: Request>) -> 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() != params.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?; 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()) }