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::crypto::{random_bytes, KeyFetchToken, SessionToken}; use crate::db::{Db, DbConn}; use crate::mailer::Mailer; use crate::push::PushClient; use crate::types::{AccountResetID, HawkKey}; use crate::utils::DeferAction; use crate::Config; use crate::{ api::{auth, serialize_dt}, auth::{AuthSource, Authenticated}, crypto::{AuthPW, KeyBundle, KeyFetchReq, SessionCredentials}, types::{KeyFetchID, OauthToken, SecretKey, 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: SessionToken, #[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 = SecretKey::generate(); let wrapwrap_kb = SecretKey::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 = SessionToken::generate(); let session = SessionCredentials::derive_from(&session_token); let key_fetch_token = if keys { let key_fetch_token = KeyFetchToken::generate(); let req = KeyFetchReq::derive_from(&key_fetch_token); let wrapped = req .derive_resp() .wrap_keys(&KeyBundle { ka, wrap_kb: stretched.decrypt_wwkb(&wrapwrap_kb) }); db.add_key_fetch(req.token_id, &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, wrapwrap_kb, verify_hash: VerifyHash(verify_hash), display_name: None, verified: false, }) .await?; let auth_at = db.add_session(session.token_id, &uid, session.req_hmac_key, false, None).await?; let verify_code = hex::encode(&random_bytes::<16>()); db.add_verify_code(&uid, &session.token_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: SessionToken, #[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 = SessionToken::generate(); let session = SessionCredentials::derive_from(&session_token); let key_fetch_token = if keys { let key_fetch_token = KeyFetchToken::generate(); let req = KeyFetchReq::derive_from(&key_fetch_token); let wrapped = req.derive_resp().wrap_keys(&KeyBundle { ka: user.ka, wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb), }); db.add_key_fetch(req.token_id, &req.req_hmac_key, &wrapped).await?; Some(key_fetch_token) } else { None }; let verify_code = format!("{:06}", thread_rng().gen_range(0..=999999)); let auth_at = db .add_session(session.token_id, &uid, 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<(HawkKey, Self::Context)> { let db = Authenticated::<(), Self>::get_conn(r).await?; db.always_commit().await?; Ok(db.finish_key_fetch(id).await?) } 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<(HawkKey, 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?; 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 = SecretKey::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, 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) }