From 9a9776230ce8d12d305ca8db19cc76f20ae40926 Mon Sep 17 00:00:00 2001 From: Vika Date: Mon, 6 Dec 2021 20:39:04 +0300 Subject: 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. --- src/database/file/mod.rs | 2 +- src/frontend/login.rs | 292 ++++++++++++++++++++++++++++++++++++++++++ src/frontend/mod.rs | 26 +++- src/frontend/templates/mod.rs | 43 ++++++- src/lib.rs | 20 ++- src/main.rs | 19 +++ src/micropub/post.rs | 2 +- 7 files changed, 387 insertions(+), 17 deletions(-) create mode 100644 src/frontend/login.rs (limited to 'src') diff --git a/src/database/file/mod.rs b/src/database/file/mod.rs index 4fb7f47..d556f46 100644 --- a/src/database/file/mod.rs +++ b/src/database/file/mod.rs @@ -482,11 +482,11 @@ impl Storage for FileStorage { // Hack to unwrap the Option and sieve out broken links // Broken links return None, and Stream::filter_map skips Nones. .try_filter_map(|post: Option| async move { Ok(post) }) + .try_filter_map(|post| async move { Ok(filter_post(post, user)) }) .and_then(|mut post| async move { hydrate_author(&mut post, user, self).await; Ok(post) }) - .try_filter_map(|post| async move { Ok(filter_post(post, user)) }) .take(limit); match posts.try_collect::>().await { 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(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_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, + 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() != ¶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()) +} 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(code: C, msg: &str) -> Self where C: TryInto { 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(mut req: Request>) -> Resu let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); - let user: Option = None; + let user: Option = req.session().get("user"); #[cfg(any(not(debug_assertions), test))] let url = req.url(); @@ -260,6 +263,7 @@ pub async fn mainpage(mut req: Request>) -> Resu microsub: None, }, feeds: Vec::default(), + user: None, content: OnboardingPage {}.to_string(), } .to_string(), @@ -288,6 +292,7 @@ pub async fn mainpage(mut req: Request>) -> 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(mut req: Request>) -> R let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); - let user: Option = None; + let user: Option = 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(mut req: Request>) -> 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 = request.session().get("user"); let mut res = next.run(request).await; let mut code: Option = None; + let mut msg: Option = None; if let Some(err) = res.downcast_error::() { 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, content: String) { + Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, feeds: Vec, user: Option, 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) { 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!!!~ >.. { 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?" } diff --git a/src/lib.rs b/src/lib.rs index 817bda7..eb915c2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ where authorization_endpoint: surf::Url, media_endpoint: Option, internal_token: Option, + cookie_secret: String, http_client: surf::Client, storage: StorageBackend, } @@ -48,6 +49,13 @@ where .with(frontend::ErrorHandlerMiddleware {}) .get(frontend::mainpage) .post(frontend::onboarding_receiver); + app.at("/login") + .with(frontend::ErrorHandlerMiddleware {}) + .get(frontend::login::form) + .post(frontend::login::handler); + app.at("/login/callback") + .with(frontend::ErrorHandlerMiddleware {}) + .get(frontend::login::callback); app.at("/static/*path") .with(frontend::ErrorHandlerMiddleware {}) .get(frontend::handle_static); @@ -64,7 +72,14 @@ where app.at("/metrics").get(metrics::gather); app.with(metrics::InstrumentationMiddleware {}); - + app.with( + tide::sessions::SessionMiddleware::new( + tide::sessions::CookieStore::new(), + &app.state().cookie_secret.as_bytes() + ) + .with_cookie_name("kittybox_session") + .without_save_unchanged() + ); app } @@ -93,6 +108,7 @@ pub async fn get_app_with_file( authorization_endpoint: surf::Url, backend_uri: String, media_endpoint: Option, + cookie_secret: String, internal_token: Option, ) -> App { let folder = backend_uri.strip_prefix("file://").unwrap(); @@ -102,6 +118,7 @@ pub async fn get_app_with_file( media_endpoint, authorization_endpoint, internal_token, + cookie_secret, storage: database::FileStorage::new(path).await.unwrap(), http_client: surf::Client::new(), }); @@ -128,6 +145,7 @@ pub async fn get_app_with_test_file( authorization_endpoint: Url::parse("https://indieauth.com/auth").unwrap(), storage: backend.clone(), internal_token: None, + cookie_secret: "1234567890abcdefghijklmnopqrstuvwxyz".to_string(), http_client: surf::Client::new(), }); (tempdir, backend, equip_app(app)) diff --git a/src/main.rs b/src/main.rs index aec3be0..4f5f9ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,24 @@ async fn main() -> Result<(), std::io::Error> { let internal_token: Option = env::var("KITTYBOX_INTERNAL_TOKEN").ok(); + let cookie_secret: String = match env::var("COOKIE_SECRET").ok() { + Some(value) => value, + None => { + if let Some(filename) = env::var("COOKIE_SECRET_FILE").ok() { + use async_std::io::ReadExt; + + let mut file = async_std::fs::File::open(filename).await?; + let mut temp_string = String::new(); + file.read_to_string(&mut temp_string).await?; + + temp_string + } else { + error!("COOKIE_SECRET or COOKIE_SECRET_FILE is not set, will not be able to log in users securely!"); + std::process::exit(1); + } + } + }; + let host = env::var("SERVE_AT") .ok() .unwrap_or_else(|| "0.0.0.0:8080".to_string()); @@ -73,6 +91,7 @@ async fn main() -> Result<(), std::io::Error> { authorization_endpoint, backend_uri, media_endpoint, + cookie_secret, internal_token, ) .await; diff --git a/src/micropub/post.rs b/src/micropub/post.rs index 070c822..c465a6f 100644 --- a/src/micropub/post.rs +++ b/src/micropub/post.rs @@ -483,7 +483,7 @@ async fn post_process_new_post( // TODO: Replace this function once the MF2 parser is ready // A compliant parser's output format includes rels, // we could just find a Webmention one in there - let pattern = easy_scraper::Pattern::new(r#""#) + let pattern = easy_scraper::Pattern::new(r#""#) .expect("Pattern for webmentions couldn't be parsed"); let matches = pattern.matches(&body); if matches.is_empty() { -- cgit 1.4.1