diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/database/file/mod.rs | 2 | ||||
-rw-r--r-- | src/frontend/login.rs | 292 | ||||
-rw-r--r-- | src/frontend/mod.rs | 26 | ||||
-rw-r--r-- | src/frontend/templates/mod.rs | 43 | ||||
-rw-r--r-- | src/lib.rs | 20 | ||||
-rw-r--r-- | src/main.rs | 19 | ||||
-rw-r--r-- | src/micropub/post.rs | 2 |
7 files changed, 387 insertions, 17 deletions
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<serde_json::Value>| 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::<Vec<serde_json::Value>>().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<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() != ¶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<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?" } 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<String>, internal_token: Option<String>, + 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<String>, + cookie_secret: String, internal_token: Option<String>, ) -> App<database::FileStorage> { 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<String> = 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<S: Storage>( // 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#"<link href="{url}" rel="webmention">"#) + let pattern = easy_scraper::Pattern::new(r#"<link href="{{url}}" rel="webmention">"#) .expect("Pattern for webmentions couldn't be parsed"); let matches = pattern.matches(&body); if matches.is_empty() { |