summaryrefslogtreecommitdiff
path: root/src/api/auth/account.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/auth/account.rs')
-rw-r--r--src/api/auth/account.rs413
1 files changed, 413 insertions, 0 deletions
diff --git a/src/api/auth/account.rs b/src/api/auth/account.rs
new file mode 100644
index 0000000..51dd98e
--- /dev/null
+++ b/src/api/auth/account.rs
@@ -0,0 +1,413 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use password_hash::SaltString;
+use rand::{thread_rng, Rng};
+use rocket::request::FromRequest;
+use rocket::State;
+use rocket::{serde::json::Json, Request};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::api::{Empty, EMPTY};
+use crate::db::{Db, DbConn};
+use crate::mailer::Mailer;
+use crate::push::PushClient;
+use crate::types::AccountResetID;
+use crate::utils::DeferAction;
+use crate::Config;
+use crate::{
+ api::{auth, serialize_dt},
+ auth::{AuthSource, Authenticated},
+ crypto::{AuthPW, KeyBundle, KeyFetchReq, SecretBytes, SessionCredentials},
+ types::{HawkKey, KeyFetchID, OauthToken, SecretKey, SessionID, User, UserID, VerifyHash},
+};
+
+// TODO better error handling
+
+// MISSING get /account/profile
+// MISSING get /account/status
+// MISSING post /account/status
+// MISSING post /account/reset
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Create {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING metricsContext
+ // NOTE we misuse style to communicate an invite token!
+ style: Option<String>,
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CreateResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING verificationMethod
+}
+
+// MISSING arg: service
+#[post("/account/create?<keys>", data = "<data>")]
+pub(crate) async fn create(
+ db: &DbConn,
+ cfg: &State<Config>,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Create>,
+) -> auth::Result<CreateResp> {
+ let keys = keys.unwrap_or(false);
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ if db.user_email_exists(&data.email).await? {
+ return Err(auth::Error::AccountExists);
+ }
+
+ match (cfg.invite_only, data.style) {
+ (false, Some(_)) => return Err(auth::Error::InvalidParameter),
+ (false, None) => (),
+ (true, None) => return Err(auth::Error::InviteOnly),
+ (true, Some(code)) => {
+ db.use_invite_code(&code).await.map_err(|e| match e {
+ sqlx::Error::RowNotFound => auth::Error::InviteNotFound,
+ e => auth::Error::Other(anyhow!(e)),
+ })?;
+ },
+ }
+
+ let ka = SecretBytes::generate();
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = req.derive_resp().wrap_keys(&KeyBundle {
+ ka: ka.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&wrapwrap_kb),
+ });
+ db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+ let uid = db
+ .add_user(User {
+ auth_salt,
+ email: data.email.to_owned(),
+ ka: SecretKey(ka),
+ wrapwrap_kb: SecretKey(wrapwrap_kb),
+ verify_hash: VerifyHash(verify_hash),
+ display_name: None,
+ verified: false,
+ })
+ .await?;
+ let session_id = SessionID(session.token_id.0);
+ let auth_at = db
+ .add_session(session_id.clone(), &uid, HawkKey(session.req_hmac_key), false, None)
+ .await?;
+ let verify_code = hex::encode(&SecretBytes::<16>::generate().0);
+ db.add_verify_code(&uid, &session_id, &verify_code).await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_account_verify(&uid, &data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(CreateResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Login {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING reason
+ // MISSING unblockCode
+ // MISSING originalLoginEmail
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct LoginResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ // MISSING verificationMethod
+ // MISSING verificationReason
+ // NOTE this is the *account* verified status, not the session status.
+ // the spec doesn't say.
+ verified: bool,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING metricsEnabled
+}
+
+// MISSING arg: service
+// MISSING arg: verificationMethod
+#[post("/account/login?<keys>", data = "<data>")]
+pub(crate) async fn login(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Login>,
+) -> auth::Result<LoginResp> {
+ let keys = keys.unwrap_or(false);
+ 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.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = 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(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+
+ let session_id = SessionID(session.token_id.0);
+ let verify_code = format!("{:06}", thread_rng().gen_range(0..=999999));
+ let auth_at = db
+ .add_session(
+ session_id.clone(),
+ &uid,
+ HawkKey(session.req_hmac_key),
+ false,
+ Some(&verify_code),
+ )
+ .await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_session_verify(&data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(LoginResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ verified: true,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Destroy {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+}
+
+// TODO may also be authenticated with a verified session
+#[post("/account/destroy", data = "<data>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ pc: &State<Arc<PushClient>>,
+ data: Json<Destroy>,
+) -> auth::Result<Empty> {
+ 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);
+ }
+
+ let stretched = data.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let devs = db.get_devices(&uid).await;
+ db.delete_user(&data.email).await?;
+ match devs {
+ Ok(devs) => defer.spawn_after_success("api::account/destroy(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.account_destroyed(&devs, &uid).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("account_destroyed push failed: {e}"),
+ }
+
+ Ok(EMPTY)
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct KeysResp {
+ bundle: String,
+}
+
+// NOTE the key fetch endpoint must delete a key fetch token from the database
+// once it has identified it, regardless of whether the request succeeds or
+// fails. we'll do this with a single-use auth source that sets the db to always
+// commit. the auth source must not be used for anything else. we can get away
+// with using a request guard because we'll always commit even if the guard
+// fails, but this is only allowable because this is the only handler for the path.
+
+#[derive(Debug)]
+pub(crate) struct WithKeyFetch;
+
+#[async_trait]
+impl AuthSource for WithKeyFetch {
+ type ID = KeyFetchID;
+ type Context = Vec<u8>;
+ async fn hawk(r: &Request<'_>, id: &KeyFetchID) -> Result<(SecretBytes<32>, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ db.always_commit().await?;
+ Ok(db.finish_key_fetch(id).await.map(|(h, ks)| (h.0, ks))?)
+ }
+ async fn bearer_token(_: &Request<'_>, _: &OauthToken) -> Result<(KeyFetchID, Self::Context)> {
+ // key fetch tokens are only valid in hawk requests
+ bail!("invalid key fetch authentication")
+ }
+}
+
+#[get("/account/keys")]
+pub(crate) async fn keys(auth: Authenticated<(), WithKeyFetch>) -> Json<KeysResp> {
+ // NOTE contrary to its own api spec fxa does not delete a key fetch if the
+ // associated session is not verified. we don't duplicate this special case
+ // because we control the clients, and requesting keys on an unverified session
+ // can be interpreted as a protocol violation anyway.
+ Json(KeysResp { bundle: hex::encode(&auth.context) })
+}
+
+#[derive(Debug)]
+pub(crate) struct WithResetToken;
+
+#[async_trait]
+impl AuthSource for WithResetToken {
+ type ID = AccountResetID;
+ type Context = UserID;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &AccountResetID,
+ ) -> 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_account_reset(id).await.map(|(h, ctx)| (h.0, ctx))?;
+ db.commit().await?;
+ Ok(result)
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> Result<(AccountResetID, Self::Context)> {
+ bail!("invalid password change authentication")
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct AccountResetReq {
+ authPW: AuthPW,
+ // MISSING wrapKb
+ // MISSING recoveryKeyId
+ // MISSING sessionToken
+}
+
+// NOTE resetting an account does not clear active sync data on the storage server,
+// so an account may be reported as disconnected for a while. this is not an error,
+// just an inconvenience we haven't found out how to fix yet.
+
+// MISSING arg: keys
+#[post("/account/reset", data = "<data>")]
+pub(crate) async fn reset(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ client: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ data: Authenticated<AccountResetReq, WithResetToken>,
+) -> auth::Result<Empty> {
+ let user = db.get_user_by_id(&data.context).await?;
+
+ let notify_devs = db.get_devices(&data.context).await?;
+
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.body.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+
+ db.reset_user_auth(&data.context, auth_salt, SecretKey(wrapwrap_kb), VerifyHash(verify_hash))
+ .await?;
+
+ defer.spawn_after_success("api::auth/account/reset(post)", {
+ let client = Arc::clone(client);
+ async move {
+ client.password_reset(&notify_devs).await;
+ Ok(())
+ }
+ });
+
+ mailer
+ .send_account_reset(&user.email)
+ .await
+ .map_err(|e| {
+ warn!("account reset email send failed: {e}");
+ })
+ .ok();
+
+ Ok(EMPTY)
+}