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


                                    
 
                                         
                                                        
                                          




                                                                                


                                                      

                                                                       
















                                                  


                                 
                







                                                    
                                   
 



                                                                              
                                                                                      
                                                                       
                                                     







                                                                                              

                                                                                                 



                                                                                                    
 
                                                                   



                                                                                        




















                                                                                                    



                                                                                      




                                                                            
                 






                                                                                        

                                                                                               


                                                                            



                                                                  




                                                                                  







                                                                   

                                                                                














                                                                             
                                                                     
                   


                                                                                     










                                                                       
                       






                                                                                     
      



                                                                                         
                             

                                                               












                                                                                        
                  


                                     
                           







                                 
                          





                                 
                          






                                 
                                     






                                                                                              
                                                                       
                                                                                                                                                   
                                                                                                  
                                






                                                                                     

                                                                                          
                                     



                                                                      



                                                  

                                                                    
                
                                      






                                                                                       

                                                                   


                                               
                                                              
 
use http_types::Mime;
use log::{debug, error};
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::convert::TryInto;
use std::str::FromStr;

use crate::frontend::templates::Template;
use crate::frontend::{FrontendError, IndiewebEndpoints};
use crate::{database::Storage, ApplicationState};
use kittybox_frontend_renderer::LoginPage;

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_some()
    }
}

#[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?;
    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())
}