about summary refs log tree commit diff
path: root/src/frontend/login.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2021-12-06 20:39:04 +0300
committerVika <vika@fireburn.ru>2021-12-06 20:42:50 +0300
commit9a9776230ce8d12d305ca8db19cc76f20ae40926 (patch)
tree26d1e166ae68825e55084b43c52afcb446521b11 /src/frontend/login.rs
parent913779655c1feddc55d37bebbdd7df87eb7c9c0f (diff)
Added support for IndieAuth client sign in
This will allow readers to view private posts intended just for them.

Additionally fixed bugs in patterns due to which webmentions might not
have been sent.
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())
+}