From 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 13 Jul 2022 10:33:30 +0200 Subject: initial import --- src/api/auth/account.rs | 413 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 src/api/auth/account.rs (limited to 'src/api/auth/account.rs') 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, + // 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>, + #[serde(serialize_with = "serialize_dt")] + authAt: DateTime, + // MISSING verificationMethod +} + +// MISSING arg: service +#[post("/account/create?", data = "")] +pub(crate) async fn create( + db: &DbConn, + cfg: &State, + mailer: &State>, + keys: Option, + data: Json, +) -> auth::Result { + 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>, + // 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, + // MISSING metricsEnabled +} + +// MISSING arg: service +// MISSING arg: verificationMethod +#[post("/account/login?", data = "")] +pub(crate) async fn login( + db: &DbConn, + mailer: &State>, + keys: Option, + data: Json, +) -> auth::Result { + 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 = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + pc: &State>, + data: Json, +) -> auth::Result { + 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; + 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 { + // 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 = "")] +pub(crate) async fn reset( + db: &DbConn, + mailer: &State>, + client: &State>, + defer: &DeferAction, + data: Authenticated, +) -> auth::Result { + 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(¬ify_devs).await; + Ok(()) + } + }); + + mailer + .send_account_reset(&user.email) + .await + .map_err(|e| { + warn!("account reset email send failed: {e}"); + }) + .ok(); + + Ok(EMPTY) +} -- cgit v1.2.3