about summary refs log tree commit diff
path: root/src/frontend
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
parent913779655c1feddc55d37bebbdd7df87eb7c9c0f (diff)
downloadkittybox-9a9776230ce8d12d305ca8db19cc76f20ae40926.tar.zst
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')
-rw-r--r--src/frontend/login.rs292
-rw-r--r--src/frontend/mod.rs26
-rw-r--r--src/frontend/templates/mod.rs43
3 files changed, 347 insertions, 14 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())
+}
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs
index 5426f7e..76114c5 100644
--- a/src/frontend/mod.rs
+++ b/src/frontend/mod.rs
@@ -1,3 +1,5 @@
+use std::convert::TryInto;
+
 use crate::database::Storage;
 use crate::ApplicationState;
 use log::{error, info};
@@ -6,8 +8,9 @@ use tide::{Next, Request, Response, Result, StatusCode};
 
 static POSTS_PER_PAGE: usize = 20;
 
-mod templates;
+pub mod login;
 
+mod templates;
 use templates::{ErrorPage, MainPage, OnboardingPage, Template};
 
 #[derive(Clone, Serialize, Deserialize)]
@@ -30,11 +33,11 @@ struct FrontendError {
     code: StatusCode,
 }
 impl FrontendError {
-    pub fn with_code(code: StatusCode, msg: &str) -> Self {
+    pub fn with_code<C>(code: C, msg: &str) -> Self where C: TryInto<StatusCode> {
         Self {
             msg: msg.to_string(),
             source: None,
-            code,
+            code: code.try_into().unwrap_or_else(|_| StatusCode::InternalServerError),
         }
     }
     pub fn msg(&self) -> &str {
@@ -227,7 +230,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
     let query = req.query::<QueryParams>()?;
     let authorization_endpoint = req.state().authorization_endpoint.to_string();
     let token_endpoint = req.state().token_endpoint.to_string();
-    let user: Option<String> = None;
+    let user: Option<String> = req.session().get("user");
 
     #[cfg(any(not(debug_assertions), test))]
     let url = req.url();
@@ -260,6 +263,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
                             microsub: None,
                         },
                         feeds: Vec::default(),
+                        user: None,
                         content: OnboardingPage {}.to_string(),
                     }
                     .to_string(),
@@ -288,6 +292,7 @@ pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Resu
                         .get_channels(hcard_url)
                         .await
                         .unwrap_or_else(|_| Vec::default()),
+                    user,
                     content: MainPage {
                         feed: &feed?,
                         card: &card?,
@@ -304,7 +309,7 @@ pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> R
     let query = req.query::<QueryParams>()?;
     let authorization_endpoint = req.state().authorization_endpoint.to_string();
     let token_endpoint = req.state().token_endpoint.to_string();
-    let user: Option<String> = None;
+    let user: Option<String> = req.session().get("user");
 
     // This cannot error out as the URL must be valid. Or there is something horribly wrong
     // and we shouldn't serve this request anyway.
@@ -381,6 +386,7 @@ pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> R
                     .get_channels(&owner)
                     .await
                     .unwrap_or_else(|_| Vec::default()),
+                user,
                 content: template,
             }
             .to_string(),
@@ -415,11 +421,16 @@ where
             .get_channels(&owner)
             .await
             .unwrap_or_else(|_| Vec::default());
+        let user: Option<String> = request.session().get("user");
         let mut res = next.run(request).await;
         let mut code: Option<StatusCode> = None;
+        let mut msg: Option<String> = None;
         if let Some(err) = res.downcast_error::<FrontendError>() {
             code = Some(err.code());
             error!("Error caught while processing request: {}", err.msg());
+            if err.code() == 400 {
+                msg = Some(err.msg().to_string());
+            }
             let mut err: &dyn std::error::Error = err;
             while let Some(e) = err.source() {
                 error!("Caused by: {}", e);
@@ -439,8 +450,9 @@ where
                         webmention: None,
                         microsub: None,
                     },
-                    feeds: feeds,
-                    content: ErrorPage { code }.to_string(),
+                    feeds,
+                    user,
+                    content: ErrorPage { code, msg }.to_string(),
                 }
                 .to_string(),
             );
diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs
index 100e16d..dbc23c9 100644
--- a/src/frontend/templates/mod.rs
+++ b/src/frontend/templates/mod.rs
@@ -25,7 +25,7 @@ mod onboarding;
 pub use onboarding::OnboardingPage;
 
 markup::define! {
-    Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, feeds: Vec<MicropubChannel>, content: String) {
+    Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, feeds: Vec<MicropubChannel>, user: Option<String>, content: String) {
         @markup::doctype()
         html {
             head {
@@ -45,14 +45,22 @@ markup::define! {
                 }
             }
             body {
+                // TODO Somehow compress headerbar into a menu when the screen space is tight
                 nav#headerbar {
                     ul {
-                        // TODO print a list of feeds and allow jumping to them
                         li { a#homepage[href="/"] { @blog_name } }
                         @for feed in feeds.iter() {
                             li { a[href=&feed.uid] { @feed.name } }
                         }
-                        li.shiftright { a#login[href="/login"] { "Login" } }
+                        li.shiftright {
+                            @if user.is_none() {
+                                a#login[href="/login"] { "Sign in" }
+                            } else {
+                                span {
+                                    @user.as_ref().unwrap() " - " a#logout[href="/logout"] { "Sign out" }
+                                }
+                            }
+                        }
                     }
                 }
                 main {
@@ -372,7 +380,7 @@ markup::define! {
         }
         @Feed { feed }
     }
-    ErrorPage(code: StatusCode) {
+    ErrorPage(code: StatusCode, msg: Option<String>) {
         h1 { @format!("HTTP {} {}", code, code.canonical_reason()) }
         @match code {
             StatusCode::Unauthorized => {
@@ -399,11 +407,32 @@ markup::define! {
                 p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" }
 
                 p {
-                    small { "I could brew you some coffee tho if we meet one day... "
-                    small { i { "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!" } } }
+                    small {
+                        "I could brew you some coffee tho if we meet one day... "
+                        small {
+                            i {
+                                "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!"
+                            }
+                        }
+                    }
                 }
             }
-            _ => { p { "It seems like you have found an error. Not to worry, it has already been logged." } }
+            StatusCode::BadRequest => {
+                @if msg.is_none() {
+                    p {
+                        "There was an undescribed error in your request. "
+                        "Please try again later or with a different request."
+                    }
+                } else {
+                    p {
+                        "There was a following error in your request: "
+                        @msg.as_ref().unwrap()
+                    }
+                }
+            }
+            _ => {
+                p { "It seems like you have found an error. Not to worry, it has already been logged." }
+            }
         }
         P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" }