diff options
Diffstat (limited to 'src/api/auth/password.rs')
-rw-r--r-- | src/api/auth/password.rs | 260 |
1 files changed, 260 insertions, 0 deletions
diff --git a/src/api/auth/password.rs b/src/api/auth/password.rs new file mode 100644 index 0000000..0eeab4f --- /dev/null +++ b/src/api/auth/password.rs @@ -0,0 +1,260 @@ +use std::sync::Arc; + +use anyhow::Result; +use password_hash::SaltString; +use rocket::{request::FromRequest, serde::json::Json, Request, State}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + api::auth, + auth::{AuthSource, Authenticated}, + crypto::{AccountResetReq, AuthPW, KeyBundle, KeyFetchReq, PasswordChangeReq, SecretBytes}, + db::{Db, DbConn}, + mailer::Mailer, + types::{ + AccountResetID, HawkKey, KeyFetchID, OauthToken, PasswordChangeID, SecretKey, UserID, + VerifyHash, + }, +}; + +// MISSING get /password/forgot/status +// MISSING post /password/create +// MISSING post /password/forgot/resend_code + +#[derive(Debug, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ChangeStartReq { + #[validate(email, length(min = 3, max = 256))] + email: String, + oldAuthPW: AuthPW, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ChangeStartResp { + keyFetchToken: SecretBytes<32>, + passwordChangeToken: SecretBytes<32>, +} + +#[post("/password/change/start", data = "<data>")] +pub(crate) async fn change_start( + db: &DbConn, + data: Json<ChangeStartReq>, +) -> auth::Result<ChangeStartResp> { + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + if !user.verified { + return Err(auth::Error::UnverifiedAccount); + } + + let stretched = data.oldAuthPW.stretch(user.auth_salt.as_salt())?; + if stretched.verify_hash() != user.verify_hash.0 { + return Err(auth::Error::IncorrectPassword); + } + + let change_token = SecretBytes::generate(); + let change_req = PasswordChangeReq::from_change_token(&change_token); + let key_fetch_token = SecretBytes::generate(); + let key_req = KeyFetchReq::from_token(&key_fetch_token); + let wrapped = key_req.derive_resp().wrap_keys(&KeyBundle { + ka: user.ka.0.clone(), + wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0), + }); + db.add_key_fetch(KeyFetchID(key_req.token_id.0), &HawkKey(key_req.req_hmac_key), &wrapped) + .await?; + db.add_password_change( + &uid, + &PasswordChangeID(change_req.token_id.0), + &HawkKey(change_req.req_hmac_key), + None, + ) + .await?; + + Ok(Json(ChangeStartResp { keyFetchToken: key_fetch_token, passwordChangeToken: change_token })) +} + +// NOTE we use a plain bool here and in the db instead of an enum because +// enums aren't usable in const generics in stable. +#[derive(Debug)] +pub(crate) struct WithChangeToken<const IS_FORGOT: bool>; + +#[async_trait] +impl<const IS_FORGOT: bool> AuthSource for WithChangeToken<IS_FORGOT> { + type ID = PasswordChangeID; + type Context = (UserID, Option<String>); + async fn hawk( + r: &Request<'_>, + id: &PasswordChangeID, + ) -> Result<(SecretBytes<32>, Self::Context)> { + // unlike key fetch we'll use a separate transaction here since the body of the + // handler can fail. + let pool = <&Db as FromRequest>::from_request(r) + .await + .success_or_else(|| anyhow!("could not open db connection"))?; + let db = pool.begin().await?; + let result = db.finish_password_change(id, IS_FORGOT).await.map(|(h, ctx)| (h.0, ctx))?; + db.commit().await?; + Ok(result) + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> Result<(PasswordChangeID, Self::Context)> { + bail!("invalid password change authentication") + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ChangeFinishReq { + authPW: AuthPW, + wrapKb: SecretBytes<32>, + // MISSING sessionToken +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ChangeFinishResp { + // NOTE we intentionally deviate from mozilla here. mozilla creates a new + // session if sessionToken is set in the request, but we use the "legacy" + // password change mechanism that leaves the requesting session and its + // device and keys intact. as such this struct is intentionally empty. + // + // MISSING uid + // MISSING sessionToken + // MISSING verified + // MISSING authAt + // MISSING keyFetchToken +} + +#[post("/password/change/finish", data = "<data>")] +pub(crate) async fn change_finish( + db: &DbConn, + mailer: &State<Arc<Mailer>>, + data: Authenticated<ChangeFinishReq, WithChangeToken<false>>, +) -> auth::Result<ChangeFinishResp> { + let user = db.get_user_by_id(&data.context.0).await?; + + let auth_salt = SaltString::generate(rand::rngs::OsRng); + let stretched = data.body.authPW.stretch(auth_salt.as_salt())?; + let verify_hash = stretched.verify_hash(); + let wrapwrap_kb = stretched.rewrap_wkb(&data.body.wrapKb); + + db.change_user_auth( + &data.context.0, + auth_salt, + SecretKey(wrapwrap_kb), + VerifyHash(verify_hash), + ) + .await?; + + // NOTE password_changed/password_reset pushes seem to have no effect, so skip them. + + mailer + .send_password_changed(&user.email) + .await + .map_err(|e| { + warn!("password change email send failed: {e}"); + }) + .ok(); + + Ok(Json(ChangeFinishResp {})) +} + +#[derive(Debug, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ForgotStartReq { + #[validate(email, length(min = 3, max = 256))] + email: String, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ForgotStartResp { + passwordForgotToken: SecretBytes<32>, + ttl: u32, + codeLength: u32, + tries: u32, +} + +#[post("/password/forgot/send_code", data = "<data>")] +pub(crate) async fn forgot_start( + db: &DbConn, + mailer: &State<Arc<Mailer>>, + data: Json<ForgotStartReq>, +) -> auth::Result<ForgotStartResp> { + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + if !user.verified { + return Err(auth::Error::UnverifiedAccount); + } + + let forgot_code = hex::encode(SecretBytes::<16>::generate().0); + let forgot_token = SecretBytes::generate(); + let forgot_req = PasswordChangeReq::from_forgot_token(&forgot_token); + db.add_password_change( + &uid, + &PasswordChangeID(forgot_req.token_id.0), + &HawkKey(forgot_req.req_hmac_key), + Some(&forgot_code), + ) + .await?; + + mailer.send_password_forgot(&user.email, &forgot_code).await?; + + Ok(Json(ForgotStartResp { + passwordForgotToken: forgot_token, + ttl: 300, + codeLength: 16, + tries: 1, + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ForgotFinishReq { + code: String, + // MISSING accountResetWithRecoveryKey +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ForgotFinishResp { + accountResetToken: SecretBytes<32>, +} + +#[post("/password/forgot/verify_code", data = "<data>")] +pub(crate) async fn forgot_finish( + db: &DbConn, + data: Authenticated<ForgotFinishReq, WithChangeToken<true>>, +) -> auth::Result<ForgotFinishResp> { + if Some(data.body.code) != data.context.1 { + return Err(auth::Error::InvalidVerificationCode); + } + + let reset_token = SecretBytes::generate(); + let reset_req = AccountResetReq::from_token(&reset_token); + db.add_account_reset( + &data.context.0, + &AccountResetID(reset_req.token_id.0), + &HawkKey(reset_req.req_hmac_key), + ) + .await?; + + Ok(Json(ForgotFinishResp { accountResetToken: reset_token })) +} |