summaryrefslogtreecommitdiff
path: root/src/api/auth/password.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/auth/password.rs')
-rw-r--r--src/api/auth/password.rs260
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 }))
+}