mellon 0.1.0

Library for adding contemporary authentication to rust-based websites.
Documentation
pub mod payload;

use std::path::PathBuf;

use jiff::ToSpan;
use payload::response::WebAuthnCredential;
use poem_openapi::OpenApi;
use poem_openapi::payload::{Json, PlainText};
use webauthn_rs::Webauthn;

use crate::SaneName;
use crate::io::file::UserDirectory;

use self::payload::jwt::{UserToken, check_jwt, make_jwt};
use self::payload::request::{self as req};
use self::payload::response::{self as resp};

pub struct AuthApi {
    storage: UserDirectory,
    webauthn: Webauthn,
}

#[OpenApi]
impl AuthApi {
    pub fn new(storage: PathBuf, webauthn: Webauthn) -> Self {
        Self {
            storage: UserDirectory::new(storage),
            webauthn,
        }
    }

    /// Create a new user
    #[oai(path = "/user", method = "post")]
    async fn create_user(&self, request: req::CreateUser) -> resp::CreateUser {
        let namespace = SaneName::new("default".to_string()).expect("hardcoded");
        let req::CreateUser::WithName(PlainText(name)) = request;
        let Ok(name) = SaneName::new(name) else {
            return resp::CreateUser::NameIsTooComplex;
        };

        match self.storage.create_user(&namespace, &name).await {
            Ok(id) => resp::CreateUser::Bearer(PlainText(make_jwt(id, 1.hours(), true))),
            Err(err) => {
                if err.kind() == std::io::ErrorKind::AlreadyExists {
                    resp::CreateUser::NameAlreadyTaken
                } else {
                    resp::CreateUser::Other
                }
            }
        }
    }

    #[oai(path = "/login/prepare", method = "post")]
    async fn prepare_login(&self, request: req::PrepareLogin) -> resp::PrepareLogin {
        let req::PrepareLogin::WithName(PlainText(name)) = request;
        let Ok(name) = SaneName::new(name) else {
            return resp::PrepareLogin::NameIsTooComplex;
        };
        let namespace = SaneName::new("default".to_string()).expect("hardcoded");
        let Ok(id) = self.storage.user_id_by_name(&namespace, &name).await else {
            return resp::PrepareLogin::UsernameNotFound;
        };

        let Ok(login_challenge) = self
            .storage
            .setup_login_challenge(&self.webauthn, &id)
            .await
        else {
            return resp::PrepareLogin::Other;
        };

        let webauthn = if let Some(webauthn) = login_challenge.webauthn {
            if let Ok(value) = serde_json::to_value(webauthn) {
                Some(value)
            } else {
                return resp::PrepareLogin::Other;
            }
        } else {
            None
        };

        let payload = resp::LoginChallenges {
            password: login_challenge.password,
            totp: login_challenge.totp,
            webauthn,
        };
        resp::PrepareLogin::Challenges(Json(payload))
    }

    #[oai(path = "/login/finish", method = "post")]
    async fn finish_login(&self, request: req::FinishLogin) -> resp::FinishLogin {
        let req::FinishLogin::WithName(Json(req::AuthenticationChallengeResponse {
            username,
            password,
            totp,
            pubkey_credential,
            existing_jwt,
        })) = request;

        let Ok(name) = SaneName::new(username) else {
            return resp::FinishLogin::WrongUsername;
        };

        let namespace = SaneName::new("default".to_string()).expect("hardcoded");
        let Ok(id) = self.storage.user_id_by_name(&namespace, &name).await else {
            return resp::FinishLogin::WrongUsername;
        };

        let allow_update_creds = if let Some(jwt) = existing_jwt {
            let Some(claims) = check_jwt(&jwt) else {
                return resp::FinishLogin::Unauthorized;
            };
            if claims.id != id {
                return resp::FinishLogin::Unauthorized;
            }
            true
        } else {
            false
        };

        let pubkey_credential = if let Some(pubkey) = pubkey_credential {
            match serde_json::from_value(pubkey) {
                Ok(pubkey) => Some(pubkey),
                Err(_) => return resp::FinishLogin::InvalidDataFormat,
            }
        } else {
            None
        };

        let exp = if allow_update_creds { 1 } else { 8 }.hours();
        match self
            .storage
            .verify_login_challenge(
                &self.webauthn,
                &id,
                password.as_deref(),
                totp,
                pubkey_credential,
            )
            .await
        {
            Ok(true) => resp::FinishLogin::Bearer(PlainText(make_jwt(id, exp, allow_update_creds))),
            Ok(false) => resp::FinishLogin::Unauthorized,
            Err(_) => resp::FinishLogin::Other,
        }
    }

    /// list the currently activated authentication methods
    #[oai(path = "/current_credentials", method = "get")]
    pub async fn current_credentials(
        &self,
        UserToken(token): UserToken,
    ) -> resp::CurrentCredentials {
        let id = token.id;
        let Ok(auth_data) = self.storage.load_auth_data(&id).await else {
            return resp::CurrentCredentials::Other;
        };

        resp::CurrentCredentials::List(Json(resp::CurrentCredentialsList {
            password: auth_data.phc.is_some(),
            totp: auth_data.totp_setup.is_some(),
            webauthn_keys: auth_data.webauthn.keys().len(),
        }))
    }

    /// list the currently activated webauthn credentials
    #[oai(path = "/webauthn", method = "get")]
    pub async fn current_webauthn_credentials(
        &self,
        UserToken(token): UserToken,
    ) -> resp::CurrentWebauthnCredentials {
        let id = token.id;
        let Ok(auth_data) = self.storage.load_auth_data(&id).await else {
            return resp::CurrentWebauthnCredentials::Other;
        };

        let keys: Vec<WebAuthnCredential> = auth_data
            .webauthn
            .keys()
            .map(|k| WebAuthnCredential {
                name: k.to_string(),
            })
            .collect();
        resp::CurrentWebauthnCredentials::List(Json(keys))
    }

    /// set or update the password
    #[oai(path = "/password", method = "post")]
    pub async fn set_password(
        &self,
        UserToken(token): UserToken,
        request: req::SetPassword,
    ) -> resp::SimpleTask {
        if !token.update_creds {
            return resp::SimpleTask::InsufficientPermissions;
        }
        let id = token.id;
        let Ok(mut auth_data) = self.storage.load_auth_data(&id).await else {
            return resp::SimpleTask::Failed;
        };

        let req::SetPassword::NewPassword(PlainText(new_password)) = request;
        if auth_data.set_password(&new_password).is_ok()
            && self.storage.save_auth_data(&id, &auth_data).await.is_ok()
        {
            resp::SimpleTask::Done
        } else {
            resp::SimpleTask::Failed
        }
    }

    // Disable password authentication for the user
    //
    // This deletes the current password.
    #[oai(path = "/password", method = "delete")]
    pub async fn delete_password(&self, UserToken(token): UserToken) -> resp::SimpleTask {
        if !token.update_creds {
            return resp::SimpleTask::InsufficientPermissions;
        }
        let id = token.id;
        let Ok(mut auth_data) = self.storage.load_auth_data(&id).await else {
            return resp::SimpleTask::Failed;
        };

        auth_data.phc = None;
        if self.storage.save_auth_data(&id, &auth_data).await.is_ok() {
            resp::SimpleTask::Done
        } else {
            resp::SimpleTask::Failed
        }
    }

    /// Prepare a new TOTP credential
    #[oai(path = "/totp/setup", method = "post")]
    pub async fn new_totp(
        &self,
        UserToken(token): UserToken,
        request: req::NewTotp,
    ) -> resp::NewTotp {
        if !token.update_creds {
            return resp::NewTotp::InsufficientPermissions;
        }
        let id = token.id;
        let Ok(totp) = self.storage.new_totp(&id).await else {
            return resp::NewTotp::Other;
        };

        let req::NewTotp::WithLabel(Json(req::TotpLabel { label, issuer })) = request;

        let setup = resp::NewTotpSetup {
            seed_base32: totp.base32_secret(),
            authenticator_url: totp.to_uri(&label, &issuer),
        };
        resp::NewTotp::Setup(Json(setup))
    }

    /// Confirm a previously set up TOTP credential
    ///
    /// This will overwrite any other active TOTP credential for this user
    #[oai(path = "/totp/confirm", method = "post")]
    pub async fn confirm_new_totp(
        &self,
        UserToken(token): UserToken,
        request: req::ConfirmNewTotp,
    ) -> resp::SimpleTask {
        if !token.update_creds {
            return resp::SimpleTask::InsufficientPermissions;
        }
        let id = token.id;
        let req::ConfirmNewTotp::CurrentTotp(PlainText(proposed_totp)) = request;
        let Ok(proposed_totp) = proposed_totp.parse::<u32>() else {
            return resp::SimpleTask::BadInput;
        };
        match self
            .storage
            .confirm_new_totp_challenge(&id, proposed_totp)
            .await
        {
            Ok(true) => resp::SimpleTask::Done,
            Ok(false) => resp::SimpleTask::BadInput,
            _ => resp::SimpleTask::Failed,
        }
    }

    /// Remove TOTP authentication for this user
    #[oai(path = "/totp", method = "delete")]
    pub async fn delete_totp(&self, UserToken(token): UserToken) -> resp::SimpleTask {
        if !token.update_creds {
            return resp::SimpleTask::InsufficientPermissions;
        }
        let id = token.id;
        let Ok(mut auth_data) = self.storage.load_auth_data(&id).await else {
            return resp::SimpleTask::Failed;
        };
        auth_data.totp_setup = None;

        if self.storage.save_auth_data(&id, &auth_data).await.is_ok() {
            resp::SimpleTask::Done
        } else {
            resp::SimpleTask::Failed
        }
    }

    /// Prepare addition of new Webauthn credential
    #[oai(path = "/webauthn/register", method = "get")]
    pub async fn new_webauthn_credential(
        &self,
        UserToken(token): UserToken,
    ) -> resp::NewWebauthnCredential {
        if !token.update_creds {
            return resp::NewWebauthnCredential::InsufficientPermissions;
        }
        let id = token.id;
        match self
            .storage
            .new_webauthn_credential(&id, &self.webauthn)
            .await
        {
            Ok(setup) => {
                if let Ok(json) = serde_json::to_value(setup) {
                    resp::NewWebauthnCredential::Setup(Json(json))
                } else {
                    resp::NewWebauthnCredential::Other
                }
            }
            Err(e) => {
                eprintln!("{e}");
                resp::NewWebauthnCredential::Other
            }
        }
    }

    /// Confirm previously requested new Webauthn credential
    #[oai(path = "/webauthn/register", method = "post")]
    pub async fn confirm_new_webauthn_credential(
        &self,
        UserToken(token): UserToken,
        request: req::ConfirmNewWebauthnCredential,
    ) -> resp::ConfirmNewWebauthnCredential {
        if !token.update_creds {
            return resp::ConfirmNewWebauthnCredential::InsufficientPermissions;
        }
        let id = token.id;
        let req::ConfirmNewWebauthnCredential::ChallengeResponse(Json(
            req::WebauthnRegisterChallengeResponse {
                key_name,
                register_credential,
            },
        )) = request;

        let Ok(key_name) = SaneName::new(key_name) else {
            return resp::ConfirmNewWebauthnCredential::KeyNameIsTooComplex;
        };

        let Ok(register_credential) = serde_json::from_value(register_credential) else {
            return resp::ConfirmNewWebauthnCredential::BadWebauthnPayload;
        };

        match self
            .storage
            .confirm_new_webauthn_credential(&id, &self.webauthn, key_name, register_credential)
            .await
        {
            Ok(_) => resp::ConfirmNewWebauthnCredential::Done,
            Err(_) => resp::ConfirmNewWebauthnCredential::AuthFailed,
        }
    }

    /// Delete a webauthn credential
    #[oai(path = "/webauthn/", method = "delete")]
    pub async fn delete_webauthn_credential(
        &self,
        UserToken(token): UserToken,
        request: req::DeleteWebAuthnCredential,
    ) -> resp::SimpleTask {
        if !token.update_creds {
            return resp::SimpleTask::InsufficientPermissions;
        }
        let req::DeleteWebAuthnCredential::WithKeyName(PlainText(key_name)) = request;
        let Ok(key_name) = SaneName::new(key_name) else {
            return resp::SimpleTask::BadInput;
        };

        match self
            .storage
            .delete_webauthn_credential(&token.id, key_name)
            .await
        {
            Ok(_) => resp::SimpleTask::Done,
            Err(_) => resp::SimpleTask::Failed,
        }
    }
}