about summary refs log blame commit diff
path: root/src/login.rs
blob: 19bfaf70d9532db432a80717bae46af49dc49ee9 (plain) (tree)
1
2
3
4
5
6
7
8
9
                                     
                            
                                                                                                    
                                                                                                         

                                                                                               
                 
                             
                      





























                                                                            

                                               


                                 
                                                                 











































                                                                                                                     
                                                      




























                                                                              
                                  




































































                                                                                                                                               


                                                                  


                                                
                                                                 






































































                                                                                                                                                                                                                                







                                                                      






                                                                

                                                                     















                                                                       
 



                                                                   
                                                                  
                               






                                                            





                                                                                 





                                                           





















                                                                                                                                              

                                                                                            



            
                                          


                                                   
                                                          

                                                     
                                
                                                                 
                                                                       
                                                                            
 
use std::{borrow::Cow, str::FromStr};

use futures_util::FutureExt;
use axum::{extract::{FromRef, Host, Query, State}, http::HeaderValue, response::IntoResponse, Form};
use axum_extra::{extract::{cookie::{self, Cookie}, SignedCookieJar}, headers::HeaderMapExt, 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 sha2::Digest;

use crate::database::Storage;

/// Show a login page.
async fn get<S: Storage + Send + Sync + 'static>(
    State(db): State<S>,
    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::<crate::database::settings::SiteName>(&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.
#[tracing::instrument]
async fn post(
    Host(host): Host,
    mut cookies: SignedCookieJar,
    State(http): State<reqwest_middleware::ClientWithMiddleware>,
    Form(form): Form<LoginForm>,
) -> 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
    // use axum_extra::{headers::Header, TypedHeader};
    // 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::<Vec<axum_extra::headers::Link>>();
    //
    // 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<url::Url> = 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::<kittybox_indieauth::Metadata>().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(
    Host(host): Host,
    Query(result): Query<AuthorizationResponse>,
    cookie_jar: SignedCookieJar,
    State(http): State<reqwest_middleware::ClientWithMiddleware>,
    State(session_store): State<crate::SessionStore>,
) -> 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::<kittybox_indieauth::GrantResponse>().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::<Error>().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,
/// automatically POST the form.
///
/// This is essentially protection from CSRF and also from some kind
/// 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() -> 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(
    mut cookies: SignedCookieJar,
    State(session_store): State<crate::SessionStore>
) -> (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<S: Storage + Send + Sync + 'static>(
    Host(host): Host,
    State(storage): State<S>,
    // XXX: blocked on https://github.com/hyperium/headers/pull/162
    //TypedHeader(accept): TypedHeader<axum_extra::headers::Accept>
    cached: Option<TypedHeader<axum_extra::headers::IfNoneMatch>>,
) -> axum::response::Response {
    let etag = {
        let mut digest = sha2::Sha256::new();
        digest.update(env!("CARGO_PKG_NAME").as_bytes());
        digest.update(b" ");
        digest.update(env!("CARGO_PKG_VERSION").as_bytes());
        digest.update(b" ");
        digest.update(crate::OAUTH2_SOFTWARE_ID.as_bytes());

        let etag = {
            let mut etag = String::with_capacity(66);
            etag.push_str("W/");
            data_encoding::HEXLOWER.encode_append(&digest.finalize(), &mut etag);

            etag
        };
        axum_extra::headers::ETag::from_str(&etag).unwrap()
    };
    if let Some(cached) = cached {
        if cached.precondition_passes(&etag) {
            return StatusCode::NOT_MODIFIED.into_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::<crate::database::settings::SiteName>(&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"));
    // Cache this metadata for an hour.
    response.headers_mut().append("Cache-Control", HeaderValue::from_static("max-age=600"));
    response.headers_mut().typed_insert(etag);

    response
}


/// Produce a router for all of the above.
pub fn router<St, S>() -> axum::routing::Router<St>
where
    St: Clone + Send + Sync + 'static,
    cookie::Key: FromRef<St>,
    reqwest_middleware::ClientWithMiddleware: FromRef<St>,
    crate::SessionStore: FromRef<St>,
    S: Storage + FromRef<St> + Send + Sync + 'static,
{
    axum::routing::Router::new()
        .route("/start", axum::routing::get(get::<S>).post(post))
        .route("/finish", axum::routing::get(callback))
        .route("/logout", axum::routing::get(logout_page).post(logout))
        .route("/client_metadata", axum::routing::get(client_metadata::<S>))
}