From e43313210269b8e48fe35b17ac416c9ba88ae4f3 Mon Sep 17 00:00:00 2001 From: Vika Date: Sun, 18 Aug 2024 00:30:15 +0300 Subject: feat: logins!! yes you can finally sign in this is also supposed to show private posts intended for you! maybe i can also reveal my email to those who sign in! :3 --- src/login.rs | 355 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 338 insertions(+), 17 deletions(-) (limited to 'src/login.rs') diff --git a/src/login.rs b/src/login.rs index 7f0314f..fd8fe05 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,19 +1,281 @@ -use axum_extra::extract::cookie; +use std::borrow::Cow; + +use futures_util::FutureExt; +use axum::{extract::{FromRef, Host, OriginalUri, Query, State}, http::HeaderValue, response::IntoResponse, Form}; +use axum_extra::{extract::{cookie::{self, Cookie}, CookieJar, SignedCookieJar}, headers::Header, TypedHeader}; +use hyper::{header::{CACHE_CONTROL, LOCATION}, StatusCode}; +use kittybox_frontend_renderer::{Template, LoginPage, LogoutPage}; +use kittybox_indieauth::{AuthorizationResponse, Error, GrantType, PKCEVerifier, Scope, Scopes}; + +use crate::database::Storage; /// Show a login page. -async fn get() { - todo!() +async fn get( + State(db): State, + Host(host): Host +) -> impl axum::response::IntoResponse { + let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); + + let (blogname, channels) = tokio::join!( + db.get_setting::(&hcard_url) + .map(Result::unwrap_or_default), + + db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) + ); + ( + StatusCode::OK, + [( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static(r#"text/html; charset="utf-8""#), + )], + Template { + title: "Sign in with your website", + blog_name: blogname.as_ref(), + feeds: channels, + user: None, + content: LoginPage {}.to_string() + }.to_string() + ) +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +struct LoginForm { + url: url::Url } /// Accept login and start the IndieAuth dance. -async fn post() { - todo!() +#[tracing::instrument] +async fn post( + Host(host): Host, + mut cookies: SignedCookieJar, + State(http): State, + Form(form): Form, +) -> axum::response::Response { + let code_verifier = kittybox_indieauth::PKCEVerifier::new(); + + cookies = cookies.add( + Cookie::build(("code_verifier", code_verifier.to_string())) + .path("/.kittybox/login") + .expires(None) + .secure(true) + .http_only(true) + .build() + ); + + let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); + let redirect_uri = { + let mut uri = client_id.clone(); + uri.set_path("/.kittybox/login/finish"); + uri + }; + let indieauth_state = kittybox_indieauth::AuthorizationRequest { + response_type: kittybox_indieauth::ResponseType::Code, + client_id, redirect_uri, + state: kittybox_indieauth::State::new(), + code_challenge: kittybox_indieauth::PKCEChallenge::new(&code_verifier, kittybox_indieauth::PKCEMethod::S256), + scope: Some(Scopes::new(vec![Scope::Profile])), + me: Some(form.url.clone()) + }; + + // Fetch the user's homepage, determine their authorization endpoint + // and either start the IndieAuth dance with the data above or bail out. + + // TODO: move IndieAuth endpoint discovery into kittybox-util or kittybox-indieauth + tracing::debug!("Fetching {}", &form.url); + let response = match http.get(form.url.clone()).send().await { + Ok(response) => response, + Err(err) => { + tracing::error!("Error fetching homepage: {:?}", err); + return ( + StatusCode::BAD_REQUEST, + format!("couldn't fetch your homepage: {}", err) + ).into_response() + } + }; + + // XXX: Blocked on https://github.com/hyperium/headers/pull/113 + // let links = response + // .headers() + // .iter() + // .filter(|(k, v)| **k == reqwest::header::LINK) + // .map(|(k, v)| axum_extra::headers::Link::decode(v)) + // .map(|res| res.ok()) + // .map(|res| res.unwrap()) + // .collect::>(); + // + // todo!("parse Link: headers") + + let body = match response.text().await { + Ok(body) => match microformats::from_html(&body, form.url) { + Ok(mf2) => mf2, + Err(err) => return ( + StatusCode::BAD_REQUEST, + format!("error while parsing your homepage with mf2: {}", err) + ).into_response() + }, + Err(err) => return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("error while fetching your homepage: {}", err) + ).into_response() + }; + + + let mut iss: Option = None; + let mut authorization_endpoint = match body + .rels + .by_rels() + .get("indieauth_metadata") + .map(|v| v.as_slice()) + .unwrap_or_default() + .first() + .cloned() + { + // TODO: cache indieauth-metadata using http_cache_reqwest crate + // this will also allow caching all the other things! + Some(metadata_endpoint) => match http.get(metadata_endpoint).send().await { + Ok(res) => match res.json::().await { + Ok(metadata) => { + iss = Some(metadata.issuer); + metadata.authorization_endpoint + }, + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't parse your oauth2 metadata: {}", err)).into_response() + }, + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("couldn't fetch your oauth2 metadata: {}", err)).into_response() + }, + None => match body + .rels + .by_rels() + .get("authorization_endpoint") + .map(|v| v.as_slice()) + .unwrap_or_default() + .first() + .cloned() { + Some(authorization_endpoint) => authorization_endpoint, + None => return ( + StatusCode::BAD_REQUEST, + "no authorization endpoint was found on your homepage." + ).into_response() + } + }; + + cookies = cookies.add( + Cookie::build(("authorization_endpoint", authorization_endpoint.to_string())) + .path("/.kittybox/login") + .expires(None) + .secure(true) + .http_only(true) + .build() + ); + + if let Some(iss) = iss { + cookies = cookies.add( + Cookie::build(("iss", iss.to_string())) + .path("/.kittybox/login") + .expires(None) + .secure(true) + .http_only(true) + .build() + ); + } + + cookies = cookies.add( + Cookie::build(("me", indieauth_state.me.as_ref().unwrap().to_string())) + .path("/.kittybox/login") + .expires(None) + .secure(true) + .http_only(true) + .build() + ); + + authorization_endpoint + .query_pairs_mut() + .extend_pairs(indieauth_state.as_query_pairs().iter()); + + tracing::debug!("Forwarding user to {}", authorization_endpoint); + (StatusCode::FOUND, [ + ("Location", authorization_endpoint.to_string()), + ], cookies).into_response() } /// Accept the return of the IndieAuth dance. Set a cookie for the /// required session. -async fn callback() { - todo!() +async fn callback( + Host(host): Host, + Query(result): Query, + cookie_jar: SignedCookieJar, + State(http): State, + State(session_store): State, +) -> axum::response::Response { + let client_id: url::Url = format!("https://{}/.kittybox/login/client_metadata", host).parse().unwrap(); + let redirect_uri = { + let mut uri = client_id.clone(); + uri.set_path("/.kittybox/login/finish"); + uri + }; + let response = result; + + let me: url::Url = cookie_jar.get("me").unwrap().value().parse().unwrap(); + let code_verifier: PKCEVerifier = cookie_jar.get("code_verifier").unwrap().value().into(); + let authorization_endpoint: url::Url = cookie_jar.get("authorization_endpoint") + .and_then(|v| v.value().parse().ok()) + .unwrap(); + match cookie_jar.get("iss").and_then(|c| c.value().parse().ok()) { + Some(iss) if response.iss != iss => { + return (StatusCode::FORBIDDEN, [(CACHE_CONTROL, "no-store")], format!("indieauth error: issuer {} doesn't match your declared issuer {}, ceremony aborted for security reasons", response.iss, iss)).into_response() + }, + _ => {}, + } + + let grant_request = kittybox_indieauth::GrantRequest::AuthorizationCode { + code: response.code, + client_id, + redirect_uri, + code_verifier, + }; + tracing::debug!("POSTing {:?} to authorization endpoint {}", grant_request, authorization_endpoint); + let res = match http.post(authorization_endpoint) + .form(&grant_request) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + { + Ok(res) if res.status().is_success() => match res.json::().await { + Ok(grant) => grant, + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing authorization endpoint response: {}", err)).into_response() + }, + Ok(res) => match res.json::().await { + Ok(err) => return (StatusCode::BAD_REQUEST, [(CACHE_CONTROL, "no-store")], err.to_string()).into_response(), + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error parsing indieauth error: {}", err)).into_response() + } + Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, [(CACHE_CONTROL, "no-store")], format!("error redeeming authorization code: {}", err)).into_response() + }; + + let profile = match res { + kittybox_indieauth::GrantResponse::ProfileUrl(profile) => profile, + // We can't be granted an access token if we aren't touching the token endpoint. + kittybox_indieauth::GrantResponse::AccessToken { .. } => unreachable!(), + }; + + if me != profile.me { + todo!("verify the authorization endpoint is authoritative for the value of me"); + } + let session = crate::Session(profile); + let uuid = uuid::Uuid::new_v4(); + session_store.write().await.insert(uuid, session); + let cookies = cookie_jar + .add(Cookie::build(("session_id", uuid.to_string())) + .expires(None) + .secure(true) + .http_only(true) + .path("/") + .build() + ) + .remove("authorization_endpoint") + .remove("me") + .remove("iss") + .remove("code_verifier"); + + (StatusCode::FOUND, [(LOCATION, HeaderValue::from_static("/")), (CACHE_CONTROL, HeaderValue::from_static("no-store"))], dbg!(cookies)).into_response() } /// Show the form necessary for logout. If JS is enabled, @@ -23,23 +285,82 @@ async fn callback() { /// of crawlers working with a user's cookies (wget?). If a crawler is /// stupid enough to execute JS and send a POST request though, that's /// on the crawler. -async fn logout_page() { - todo!() +async fn logout_page() -> impl axum::response::IntoResponse { + (StatusCode::OK, [("Content-Type", "text/html")], Template { + title: "Signing out...", + blog_name: "Kittybox", + feeds: vec![], + user: None, + content: LogoutPage {}.to_string() + }.to_string()) } /// Erase the necessary cookies for login and invalidate the session. -async fn logout() { - todo!() +async fn logout( + mut cookies: SignedCookieJar, + State(session_store): State +) -> (StatusCode, [(&'static str, &'static str); 1], SignedCookieJar) { + if let Some(id) = cookies.get("session_id") + .and_then(|c| uuid::Uuid::parse_str(c.value_trimmed()).ok()) + { + session_store.write().await.remove(&id); + } + cookies = cookies.remove("me") + .remove("iss") + .remove("authorization_endpoint") + .remove("code_verifier") + .remove("session_id"); + + + (StatusCode::FOUND, [("Location", "/")], cookies) } +async fn client_metadata( + Host(host): Host, + State(storage): State, + // XXX: blocked on https://github.com/hyperium/headers/pull/162 + //TypedHeader(accept): TypedHeader +) -> axum::response::Response { + let client_uri: url::Url = format!("https://{}/", host).parse().unwrap(); + let client_id: url::Url = { + let mut url = client_uri.clone(); + url.set_path("/.kittybox/login/client_metadata"); + + url + }; + + let mut metadata = kittybox_indieauth::ClientMetadata::new(client_id, client_uri).unwrap(); + + metadata.client_name = Some(storage.get_setting::(&metadata.client_uri).await.unwrap_or_default().0); + metadata.grant_types = Some(vec![GrantType::AuthorizationCode]); + // We don't request anything more than the profile scope. + metadata.scope = Some(Scopes::new(vec![Scope::Profile])); + metadata.software_id = Some(Cow::Borrowed(crate::OAUTH2_SOFTWARE_ID)); + metadata.software_version = Some(Cow::Borrowed(env!("CARGO_PKG_VERSION"))); + + // XXX: consider matching on Accept: header to detect whether + // we're expected to serve mf2+html for compatibility with older + // identity providers, or json to match newest spec + let mut response = metadata.into_response(); + // Indicate to upstream caches this endpoint does different things depending on the Accept: header. + response.headers_mut().append("Vary", HeaderValue::from_static("Accept")); + + response +} + + /// Produce a router for all of the above. -fn router(key: cookie::Key) -> axum::routing::Router { +pub fn router() -> axum::routing::Router +where + St: Clone + Send + Sync + 'static, + cookie::Key: FromRef, + reqwest::Client: FromRef, + crate::SessionStore: FromRef, + S: Storage + FromRef + Send + Sync + 'static, +{ axum::routing::Router::new() - .route("/start", axum::routing::get(get).post(post)) + .route("/start", axum::routing::get(get::).post(post)) .route("/finish", axum::routing::get(callback)) .route("/logout", axum::routing::get(logout_page).post(logout)) - // I'll need some kind of session store here too. It should be - // a key from UUIDs (128 bits is enough for a session token) - // to at least a URL, if not something more. - .with_state(key) + .route("/client_metadata", axum::routing::get(client_metadata::)) } -- cgit 1.4.1