about summary refs log tree commit diff
path: root/src/frontend/login.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/login.rs')
-rw-r--r--src/frontend/login.rs292
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() != &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: {:?}: {:?}", &params.error, &params.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())
+}