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 ++++++++++++++++++++++++++++++++++++++++++ src/api/auth/device.rs | 455 +++++++++++++++++++++++++++++++++++++++++++++++ src/api/auth/email.rs | 126 +++++++++++++ src/api/auth/invite.rs | 47 +++++ src/api/auth/mod.rs | 238 +++++++++++++++++++++++++ src/api/auth/oauth.rs | 433 ++++++++++++++++++++++++++++++++++++++++++++ src/api/auth/password.rs | 260 +++++++++++++++++++++++++++ src/api/auth/session.rs | 107 +++++++++++ 8 files changed, 2079 insertions(+) create mode 100644 src/api/auth/account.rs create mode 100644 src/api/auth/device.rs create mode 100644 src/api/auth/email.rs create mode 100644 src/api/auth/invite.rs create mode 100644 src/api/auth/mod.rs create mode 100644 src/api/auth/oauth.rs create mode 100644 src/api/auth/password.rs create mode 100644 src/api/auth/session.rs (limited to 'src/api/auth') 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) +} diff --git a/src/api/auth/device.rs b/src/api/auth/device.rs new file mode 100644 index 0000000..2b05e12 --- /dev/null +++ b/src/api/auth/device.rs @@ -0,0 +1,455 @@ +use std::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use futures::future::join_all; +use rocket::{serde::json::Json, State}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession}; +use crate::api::{Empty, EMPTY}; +use crate::db::DbConn; +use crate::push::PushClient; +use crate::utils::DeferAction; +use crate::{ + api::{auth, serialize_dt_opt}, + auth::Authenticated, + db::Db, + types::{ + Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID, + SessionID, + }, +}; + +fn map_error(e: sqlx::Error) -> auth::Error { + match &e { + // not-null violations can presumably only be caused by bad parameters + sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => { + auth::Error::MissingParameter + }, + sqlx::Error::RowNotFound => auth::Error::UnknownDevice, + _ => auth::Error::Other(anyhow!(e)), + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct Info { + isCurrentDevice: bool, + id: DeviceID, + lastAccessTime: i64, + name: String, + r#type: String, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + pushEndpointExpired: bool, + availableCommands: HashMap, + // NOTE location is optional per the spec, but fenix crashes if it isn't present + location: Value, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted +} + +fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info { + let (pcb, ppk, pak) = match dev.push { + Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)), + None => (None, None, None), + }; + Info { + isCurrentDevice: Some(&dev.device_id) == current, + id: dev.device_id, + lastAccessTime: dev.last_active.timestamp(), + name: dev.name, + r#type: dev.type_, + pushCallback: pcb, + pushPublicKey: ppk, + pushAuthKey: pak, + pushEndpointExpired: dev.push_expired, + availableCommands: dev.available_commands.into_map(), + location: dev.location, + } +} + +#[derive(Serialize, Deserialize, PartialEq)] +#[serde(transparent)] +pub(crate) struct ListResp(Vec); + +#[get("/account/devices")] +pub(crate) async fn devices( + db: &DbConn, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let devs = db.get_devices(&auth.context.uid).await?; + Ok(Json(ListResp( + devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(), + ))) +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct DeviceReq { + id: Option, + name: Option, + r#type: Option, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + availableCommands: Option>, + // present for legacy reasons, ignored + #[allow(dead_code)] + capabilities: Option>, + location: Option, +} + +#[post("/account/device", data = "")] +pub(crate) async fn device( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + // need to allow registrations to all sessions, otherwise the "now verified" + // notification can't be sent + data: Authenticated, +) -> auth::Result { + let dev = data.body; + if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) { + return Err(auth::Error::MissingParameter); + } + + let push = dev.pushCallback.map(|pcb| DevicePush { + callback: pcb, + public_key: dev.pushPublicKey.unwrap_or_default(), + auth_key: dev.pushAuthKey.unwrap_or_default(), + }); + + let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) { + (None, None) => { + let new = DeviceID::random(); + (Some(new.clone()), new, true) + }, + (None, Some(own)) => (Some(own.clone()), own, false), + (Some(other), own) => (own, other, false), + }; + let result = db + .change_device( + &data.context.uid, + &changed_id, + DeviceUpdate { + name: dev.name.as_ref().map(AsRef::as_ref), + type_: dev.r#type.as_ref().map(AsRef::as_ref), + push, + available_commands: dev.availableCommands.map(DeviceCommands), + location: dev.location, + }, + ) + .await + .map_err(map_error)?; + if notify { + db.set_session_device(&data.session, Some(&changed_id)).await?; + match db.get_devices(&data.context.uid).await { + Err(e) => warn!("device_connected push failed: {e}"), + Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", { + devs.retain(|d| d.device_id != changed_id); + let (client, db) = (Arc::clone(client), db_pool.clone()); + let name = result.name.clone(); + async move { + let db = db.begin().await?; + client.device_connected(&db, &devs, &name).await; + db.commit().await?; + Ok(()) + } + }), + }; + } + Ok(Json(device_to_json(own_id.as_ref(), result))) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Command { + target: DeviceID, + command: String, + payload: Value, + ttl: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct InvokeResp { + enqueued: bool, + notified: bool, + notifyError: Option, +} + +// NOTE fenix doesn't register a push callback for some reason, so receiving tabs +// always requires opening the tab share menu or tab list first. +#[post("/account/devices/invoke_command", data = "")] +pub(crate) async fn invoke( + client: &State>, + db: &DbConn, + cmd: Authenticated, +) -> auth::Result { + let sender = cmd.context.device_id; + let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?; + if dev.available_commands.get(&cmd.body.command).is_none() { + return Err(auth::Error::NoDeviceCommand); + } + let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400); + let idx = db + .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl) + .await?; + let (notified, error) = client + .command_received(db, &dev, &cmd.body.command, idx, &sender) + .await + .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None)); + Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error })) +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandData { + command: String, + payload: Value, + sender: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsEntry { + index: i64, + data: CommandData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsResp { + index: i64, + last: bool, + messages: Vec, +} + +fn map_command(c: DeviceCommand) -> CommandsEntry { + CommandsEntry { + index: c.index, + data: CommandData { command: c.command, payload: c.payload, sender: c.sender }, + } +} + +#[get("/account/device/commands?&")] +pub(crate) async fn commands( + db: &DbConn, + index: i64, + limit: Option, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?; + let (more, cmds) = + db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?; + Ok(Json(CommandsResp { + index: cmds.iter().map(|c| c.index).max().unwrap_or(0), + last: !more, + messages: cmds.into_iter().map(map_command).collect(), + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + id: DeviceID, +} + +#[post("/account/device/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: crate::auth::Authenticated, +) -> auth::Result { + db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?; + match db.get_devices(&req.context.uid).await { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &req.body.id).await; + db.commit().await?; + Ok(()) + } + }), + }; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyTarget { + #[serde(rename = "all")] + All, +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyEPAction { + #[serde(rename = "accountVerify")] + AccountVerify, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(untagged, deny_unknown_fields)] +pub(crate) enum NotifyReq { + // deny_unknown_fields and flatten don't work together + All { + #[allow(dead_code)] + to: NotifyTarget, + _endpointAction: Option, + excluded: Option>, + payload: Value, + TTL: Option, + }, + Some { + to: Vec, + _endpointAction: Option, + payload: Value, + TTL: Option, + }, +} + +#[post("/account/devices/notify", data = "")] +pub(crate) async fn notify( + db: &DbConn, + client: &State>, + req: Authenticated, +) -> auth::Result { + let (to, payload, ttl) = match req.body { + NotifyReq::All { excluded, payload, TTL: ttl, .. } => { + let excluded = excluded.unwrap_or_default(); + let mut devs = db.get_devices(&req.context.uid).await?; + devs.retain(|d| !excluded.contains(&d.device_id)); + (devs, payload, ttl) + }, + NotifyReq::Some { to, payload, TTL: ttl, .. } => { + let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id))) + .await + .into_iter() + .collect::, _>>()?; + (to, payload, ttl) + }, + }; + client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await; + Ok(EMPTY) +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct AttachedClient { + clientId: Option, + deviceId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + isCurrentSession: bool, + deviceType: Option, + name: Option, + #[serde(serialize_with = "serialize_dt_opt")] + createdTime: Option>, + // MISSING createdTimeFormatted + #[serde(serialize_with = "serialize_dt_opt")] + lastAccessTime: Option>, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted + scope: Option, + // MISSING location + // MISSING userAgent + // MISSING os +} + +// MISSING filterIdleDevicesTimestamp +#[get("/account/attached_clients")] +pub(crate) async fn attached_clients( + db: &DbConn, + auth: Authenticated<(), WithVerifiedFxaLogin>, +) -> auth::Result> { + let clients = db.get_attached_clients(&auth.context.uid).await?; + Ok(Json( + clients + .into_iter() + .map(|dev| AttachedClient { + clientId: dev.client_id, + deviceId: dev.device_id, + refreshTokenId: dev.refresh_token_id, + isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session), + sessionTokenId: dev.session_token_id, + deviceType: dev.device_type, + name: dev.name, + createdTime: dev.created_time, + lastAccessTime: dev.last_access_time, + scope: dev.scope, + }) + .collect::>(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct DestroyAttachedClientReq { + // NOTE should be used to verify token deletion, but since we allow only a fixed + // number of clients that makes little sense. + #[allow(dead_code)] + clientId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + deviceId: Option, +} + +#[post("/account/attached_client/destroy", data = "")] +pub(crate) async fn destroy_attached_client( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: Authenticated, +) -> auth::Result { + // only one id may be given, otherwise deleting things properly is more work. + if (req.body.sessionTokenId.is_some() as u32) + + (req.body.refreshTokenId.is_some() as u32) + + (req.body.deviceId.is_some() as u32) + != 1 + { + return Err(auth::Error::InvalidParameter); + } + + if let Some(dev) = req.body.deviceId { + let devs = db.get_devices(&req.context.uid).await; + db.delete_device(&req.context.uid, &dev).await?; + match devs { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => { + defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &dev).await; + db.commit().await?; + Ok(()) + } + }) + }, + }; + } + if let Some(id) = req.body.sessionTokenId { + db.delete_session(&req.context.uid, &id).await?; + } + if let Some(id) = req.body.refreshTokenId { + db.delete_refresh_token(&id).await?; + } + + Ok(EMPTY) +} diff --git a/src/api/auth/email.rs b/src/api/auth/email.rs new file mode 100644 index 0000000..f206759 --- /dev/null +++ b/src/api/auth/email.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use rocket::{serde::json::Json, State}; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::{ + auth::{self, WithFxaLogin}, + Empty, EMPTY, + }, + auth::Authenticated, + db::{Db, DbConn}, + mailer::Mailer, + push::PushClient, + types::UserID, + utils::DeferAction, +}; + +// MISSING get /recovery_emails +// MISSING post /recovery_email +// MISSING post /recovery_email/destroy +// MISSING post /recovery_email/resend_code +// MISSING post /recovery_email/set_primary +// MISSING post /emails/reminders/cad +// MISSING post /recovery_email/secondary/resend_code +// MISSING post /recovery_email/secondary/verify_code + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct StatusResp { + email: String, + verified: bool, + sessionVerified: bool, + emailVerified: bool, +} + +// MISSING arg: reason +#[get("/recovery_email/status")] +pub(crate) async fn status( + db: &DbConn, + req: Authenticated<(), WithFxaLogin>, +) -> auth::Result { + let user = db.get_user_by_id(&req.context.uid).await?; + Ok(Json(StatusResp { + email: user.email, + verified: user.verified, + sessionVerified: req.context.verified, + emailVerified: user.verified, + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyReq { + uid: UserID, + code: String, + // MISSING service + // MISSING reminder + // MISSING type + // MISSING style + // MISSING marketingOptIn + // MISSING newsletters +} + +#[post("/recovery_email/verify_code", data = "")] +pub(crate) async fn verify_code( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + pc: &State>, + req: Json, +) -> auth::Result { + let code = match db.try_use_verify_code(&req.uid, &req.code).await? { + Some(code) => code, + None => return Err(auth::Error::InvalidVerificationCode), + }; + db.set_user_verified(&req.uid).await?; + if let Some(sid) = code.session_id { + db.set_session_verified(&sid).await?; + } + match db.get_devices(&req.uid).await { + Ok(devs) => defer.spawn_after_success("api::auth/recovery_email/verify_code(post)", { + let (pc, db) = (Arc::clone(pc), db_pool.clone()); + async move { + let db = db.begin().await?; + pc.account_verified(&db, &devs).await; + db.commit().await?; + Ok(()) + } + }), + Err(e) => warn!("account_verified push failed: {e}"), + } + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResendReq { + // MISSING email + // MISSING service + // MISSING redirectTo + // MISSING resume + // MISSING style + // MISSING type +} + +// MISSING arg: service +// MISSING arg: type +#[post("/recovery_email/resend_code", data = "")] +pub(crate) async fn resend_code( + db: &DbConn, + mailer: &State>, + req: Authenticated, +) -> auth::Result { + let (email, code) = match db.get_verify_code(&req.context.uid).await { + Ok(v) => v, + Err(_) => return Err(auth::Error::InvalidVerificationCode), + }; + // NOTE we send the email in this context rather than a spawn to signal + // send errors to the client. + mailer.send_account_verify(&req.context.uid, &email, &code.code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(EMPTY) +} diff --git a/src/api/auth/invite.rs b/src/api/auth/invite.rs new file mode 100644 index 0000000..dd81540 --- /dev/null +++ b/src/api/auth/invite.rs @@ -0,0 +1,47 @@ +use base64::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use rocket::{http::uri::Reference, serde::json::Json, State}; +use serde::{Deserialize, Serialize}; + +use crate::{api::auth, auth::Authenticated, crypto::SecretBytes, db::DbConn, Config}; + +use super::WithVerifiedFxaLogin; + +pub(crate) async fn generate_invite_link( + db: &DbConn, + cfg: &Config, + ttl: Duration, +) -> anyhow::Result> { + let code = base64::encode_config(&SecretBytes::<32>::generate().0, URL_SAFE_NO_PAD); + db.add_invite_code(&code, Utc::now() + ttl).await?; + Ok(Reference::parse_owned(format!("{}/#/register/{}", cfg.location, code)) + .map_err(|e| anyhow!("url building failed at {e}"))?) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct GenerateReq { + ttl_hours: u32, +} + +#[derive(Debug, Serialize)] +pub(crate) struct GenerateResp { + url: Reference<'static>, +} + +#[post("/generate", data = "")] +pub(crate) async fn generate( + db: &DbConn, + cfg: &State, + req: Authenticated, +) -> auth::Result { + if !req.context.verified { + return Err(auth::Error::UnverifiedSession); + } + let user = db.get_user_by_id(&req.context.uid).await?; + if user.email != cfg.invite_admin_address { + return Err(auth::Error::InvalidAuthToken); + } + let url = generate_invite_link(&db, &cfg, Duration::hours(req.body.ttl_hours as i64)).await?; + Ok(Json(GenerateResp { url })) +} diff --git a/src/api/auth/mod.rs b/src/api/auth/mod.rs new file mode 100644 index 0000000..2c6d34d --- /dev/null +++ b/src/api/auth/mod.rs @@ -0,0 +1,238 @@ +use rocket::{ + http::Status, + response::{self, Responder}, + serde::json::Json, + Request, Response, +}; +use serde_json::json; + +use crate::{ + auth::Authenticated, + crypto::SecretBytes, + types::{OauthToken, SessionID, UserSession}, +}; + +pub(crate) mod account; +pub(crate) mod device; +pub(crate) mod email; +pub(crate) mod invite; +pub(crate) mod oauth; +pub(crate) mod password; +pub(crate) mod session; + +// we don't provide any additional fields. some we can't provide anyway (eg +// invalid parameter `validation`), others are implied by the request body (eg +// account exists `email`), and *our* client doesn't care about them anyway +#[derive(Debug)] +pub(crate) enum Error { + AccountExists, + UnknownAccount, + IncorrectPassword, + UnverifiedAccount, + InvalidVerificationCode, + InvalidBody, + InvalidParameter, + MissingParameter, + InvalidSignature, + InvalidAuthToken, + RequestTooLarge, + IncorrectEmailCase, + UnknownDevice, + UnverifiedSession, + EmailFailed, + NoDeviceCommand, + UnknownClientID, + ScopesNotAllowed, + + InviteOnly, + InviteNotFound, + + Other(anyhow::Error), + UnexpectedStatus(Status), +} + +#[rustfmt::skip] +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let (code, errno, msg) = match self { + Error::AccountExists => (Status::BadRequest, 101, "account already exists"), + Error::UnknownAccount => (Status::BadRequest, 102, "unknown account"), + Error::IncorrectPassword => (Status::BadRequest, 103, "incorrect password"), + Error::UnverifiedAccount => (Status::BadRequest, 104, "unverified account"), + Error::InvalidVerificationCode => (Status::BadRequest, 105, "invalid verification code"), + Error::InvalidBody => (Status::BadRequest, 106, "invalid json in request body"), + Error::InvalidParameter => (Status::BadRequest, 107, "invalid parameter in request body"), + Error::MissingParameter => (Status::BadRequest, 108, "missing parameter in request body"), + Error::InvalidSignature => (Status::Unauthorized, 109, "invalid request signature"), + Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"), + Error::RequestTooLarge => (Status::PayloadTooLarge, 113, "request too large"), + Error::IncorrectEmailCase => (Status::BadRequest, 120, "incorrect email case"), + Error::UnknownDevice => (Status::BadRequest, 123, "unknown device"), + Error::UnverifiedSession => (Status::BadRequest, 138, "unverified session"), + Error::EmailFailed => (Status::UnprocessableEntity, 151, "failed to send email"), + Error::NoDeviceCommand => (Status::BadRequest, 157, "unavailable device command"), + Error::UnknownClientID => (Status::BadRequest, 162, "unknown client_id"), + Error::ScopesNotAllowed => (Status::BadRequest, 169, "requested scopes not allowed"), + Error::InviteOnly => (Status::BadRequest, -1, "invite code required"), + Error::InviteNotFound => (Status::BadRequest, -2, "invite code not found"), + Error::Other(e) => { + error!("non-api error during request: {:#?}", e); + (Status::InternalServerError, 999, "internal error") + }, + Error::UnexpectedStatus(s) => (s, 999, ""), + }; + let body = json!({ + "code": code.code, + "errno": errno, + "error": code.reason_lossy(), + "message": msg + }); + Response::build_from(Json(body).respond_to(request)?).status(code).ok() + } +} + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + Error::Other(anyhow!(e)) + } +} + +impl From for Error { + fn from(e: anyhow::Error) -> Self { + Error::Other(e) + } +} + +pub(crate) type Result = std::result::Result, Error>; + +// hack marker type to convey that auth failed due to an unverified session. +// without this the catcher could convert the Unauthorized error we get from +// auth failures into just one thing, even though we have multiple causes. +#[derive(Clone, Copy, Debug)] +struct UsedUnverifiedSession; + +#[catch(default)] +pub(crate) fn catch_all(status: Status, req: &Request<'_>) -> Error { + match req.local_cache(|| None) { + Some(UsedUnverifiedSession) => Error::UnverifiedSession, + _ => { + match status.code { + 401 => Error::InvalidSignature, + // these three are caused by Json errors + 400 => Error::InvalidBody, + 413 => Error::RequestTooLarge, + 422 => Error::InvalidParameter, + // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints + 404 => Error::InvalidSignature, + _ => { + error!("caught unexpected error {status}"); + Error::UnexpectedStatus(status) + }, + } + }, + } +} + +#[derive(Debug)] +pub(crate) struct WithFxaLogin; + +#[async_trait] +impl crate::auth::AuthSource for WithFxaLogin { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + let k = db.use_session(id).await?; + Ok((k.req_hmac_key.0.clone(), k)) + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + bail!("refresh tokens not allowed here"); + } +} + +#[derive(Debug)] +pub(crate) struct WithVerifiedFxaLogin; + +#[async_trait] +impl crate::auth::AuthSource for WithVerifiedFxaLogin { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + let res = WithFxaLogin::hawk(r, id).await?; + match res.1.verified { + true => Ok(res), + false => { + r.local_cache(|| Some(UsedUnverifiedSession)); + bail!("session not verified"); + }, + } + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + bail!("refresh tokens not allowed here"); + } +} + +#[derive(Debug)] +pub(crate) struct WithSession; + +#[rocket::async_trait] +impl crate::auth::AuthSource for WithSession { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + WithFxaLogin::hawk(r, id).await + } + async fn bearer_token( + r: &Request<'_>, + token: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + Ok(db.use_session_from_refresh(&token.hash()).await?) + } +} + +#[derive(Debug)] +pub(crate) struct WithVerifiedSession; + +#[rocket::async_trait] +impl crate::auth::AuthSource for WithVerifiedSession { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + WithVerifiedFxaLogin::hawk(r, id).await + } + async fn bearer_token( + r: &Request<'_>, + token: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + let res = db.use_session_from_refresh(&token.hash()).await?; + match res.1.verified { + true => Ok(res), + false => { + // technically unreachable because generating a refresh token requires a + // valid fxa session + r.local_cache(|| Some(UsedUnverifiedSession)); + bail!("session not verified"); + }, + } + } +} diff --git a/src/api/auth/oauth.rs b/src/api/auth/oauth.rs new file mode 100644 index 0000000..b0ed8ee --- /dev/null +++ b/src/api/auth/oauth.rs @@ -0,0 +1,433 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Duration, Local, Utc}; +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Digest; +use subtle::ConstantTimeEq; + +use crate::api::auth::WithVerifiedFxaLogin; +use crate::db::DbConn; +use crate::types::oauth::{Scope, ScopeSet}; +use crate::{ + api::{auth, serialize_dt}, + auth::Authenticated, + crypto::{SecretBytes, SessionCredentials}, + types::{ + HawkKey, OauthAccessToken, OauthAccessType, OauthAuthorization, OauthAuthorizationID, + OauthRefreshToken, OauthToken, OauthTokenID, SessionID, UserID, + }, +}; + +// MISSING get /oauth/client/{client_id} + +pub(crate) struct OauthClient { + pub(crate) id: &'static str, + // NOTE not read so far, but good to have + #[allow(dead_code)] + pub(crate) name: &'static str, + pub(crate) scopes: &'static [Scope<'static>], +} + +const SESSION_SCOPE: Scope = Scope::borrowed("https://identity.mozilla.com/tokens/session"); + +// NOTE the telemetry scopes don't seem to be needed. since we'd have to give +// out keys for them (fxa does) we'll exclude them entirely. +// see fxa-auth-server/config/dev.json for lists of predefined clients and permissions. +pub(crate) const OAUTH_CLIENTS: [OauthClient; 2] = [ + OauthClient { + id: "5882386c6d801776", + name: "Firefox", + scopes: &[ + Scope::borrowed("profile:write"), + Scope::borrowed("https://identity.mozilla.com/apps/oldsync"), + Scope::borrowed("https://identity.mozilla.com/tokens/session"), + // "https://identity.mozilla.com/ids/ecosystem_telemetry", + ], + }, + OauthClient { + id: "a2270f727f45f648", + name: "Fenix", + scopes: &[ + Scope::borrowed("profile"), + Scope::borrowed("https://identity.mozilla.com/apps/oldsync"), + Scope::borrowed("https://identity.mozilla.com/tokens/session"), + // "https://identity.mozilla.com/ids/ecosystem_telemetry", + ], + }, +]; + +// NOTE fxa dev config allows scoped keys only for: +// - https://identity.mozilla.com/apps/notes +// - https://identity.mozilla.com/apps/oldsync +// - https://identity.mozilla.com/ids/ecosystem_telemetry +// - https://identity.mozilla.com/apps/send +// we only implement sync because notes and send are dead and +// telemetry is of no use to us +const SCOPES_WITH_KEYS: [Scope; 1] = [Scope::borrowed("https://identity.mozilla.com/apps/oldsync")]; + +fn check_client_and_scopes( + client_id: &str, + scope: &ScopeSet, +) -> Result<&'static OauthClient, auth::Error> { + let desc = match OAUTH_CLIENTS.iter().find(|&s| s.id == client_id) { + Some(d) => d, + None => return Err(auth::Error::UnknownClientID), + }; + if !scope.is_allowed_by(desc.scopes) { + return Err(auth::Error::ScopesNotAllowed); + } + Ok(desc) +} + +#[derive(Debug, Deserialize)] +pub(crate) enum PkceChallengeType { + S256, +} + +#[derive(Debug, Deserialize)] +pub(crate) enum AuthResponseType { + #[serde(rename = "code")] + Code, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OauthAuthReq { + client_id: String, + state: String, + keys_jwe: Option, + scope: ScopeSet, + access_type: OauthAccessType, + // NOTE we don't support confidential clients, so PKCE is mandatory + code_challenge: String, + + // MISSING redirect_uri + // MISSING acr_value + + // for validation during deserialization only + #[allow(dead_code)] + code_challenge_method: PkceChallengeType, + #[allow(dead_code)] + response_type: AuthResponseType, +} + +#[derive(Debug, Serialize)] +pub(crate) struct OauthAuthResp { + code: OauthAuthorizationID, + state: String, + // MISSING redirect +} + +#[post("/oauth/authorization", data = "")] +pub(crate) async fn authorization( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + check_client_and_scopes(&req.body.client_id, &req.body.scope)?; + let id = OauthAuthorizationID::random(); + db.add_oauth_authorization( + &id, + OauthAuthorization { + user_id: req.context.uid, + client_id: req.body.client_id, + scope: req.body.scope, + access_type: req.body.access_type, + code_challenge: req.body.code_challenge, + keys_jwe: req.body.keys_jwe, + auth_at: req.context.created_at, + }, + ) + .await?; + Ok(Json(OauthAuthResp { code: id, state: req.body.state })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScopedKeysReq { + client_id: String, + scope: ScopeSet, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ScopedKey { + identifier: String, + keyRotationSecret: &'static str, + keyRotationTimestamp: u64, +} + +#[post("/account/scoped-key-data", data = "")] +pub(crate) async fn scoped_key_data( + data: Authenticated, +) -> auth::Result> { + check_client_and_scopes(&data.body.client_id, &data.body.scope)?; + // like fxa we'll stub out key rotation handling entirely and return the same constants. + Ok(Json( + data.body + .scope + .split() + .filter(|s| SCOPES_WITH_KEYS.contains(s)) + .map(|scope| { + ( + scope.to_string(), + ScopedKey { + identifier: scope.to_string(), + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 0, + }, + ) + }) + .collect(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OauthDestroy { + client_id: String, + token: OauthToken, +} + +#[post("/oauth/destroy", data = "")] +pub(crate) async fn destroy(db: &DbConn, data: Json) -> auth::Result<()> { + // MISSING api spec allows an optional basic auth header, but what for? + // TODO fxa also checks the authorization header if present, but firefox doesn't send it + let client_id = if let Ok(t) = db.get_refresh_token(&data.token.hash()).await { + t.client_id + } else if let Ok(t) = db.get_access_token(&data.token.hash()).await { + t.client_id + } else { + return Err(auth::Error::InvalidParameter); + }; + // fxa does constant-time checks for client_id, do that here too. + if client_id.as_bytes().ct_eq(data.client_id.as_bytes()).into() { + db.delete_oauth_token(&data.token.hash()).await?; + Ok(Json(())) + } else { + Err(auth::Error::InvalidParameter) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "grant_type")] +enum TokenReqDetails { + // we can't use deny_unknown_fields when flatten is involved, and multiple + // flattens in the same struct cause problems if one of them is greedy (like map). + // flatten an extra map into every variant instead and check each of them. + #[serde(rename = "authorization_code")] + AuthCode { + code: OauthAuthorizationID, + code_verifier: String, + // NOTE only useful with redirect flows, which we kinda don't support at all + #[allow(dead_code)] + redirect_uri: Option, + #[serde(flatten)] + extra: HashMap, + }, + #[serde(rename = "refresh_token")] + RefreshToken { + refresh_token: OauthToken, + scope: ScopeSet, + #[serde(flatten)] + extra: HashMap, + }, + #[serde(rename = "fxa-credentials")] + FxaCreds { + scope: ScopeSet, + access_type: Option, + #[serde(flatten)] + extra: HashMap, + }, +} + +impl TokenReqDetails { + fn extra_is_empty(&self) -> bool { + match self { + TokenReqDetails::AuthCode { extra, .. } => extra.is_empty(), + TokenReqDetails::RefreshToken { extra, .. } => extra.is_empty(), + TokenReqDetails::FxaCreds { extra, .. } => extra.is_empty(), + } + } +} + +// TODO log errors in all the places + +#[derive(Debug, Deserialize)] +pub(crate) struct TokenReq { + client_id: String, + ttl: Option, + #[serde(flatten)] + details: TokenReqDetails, + // MISSING client_secret + // MISSING redirect_uri + // MISSING ttl + // MISSING ppid_seed + // MISSING resource +} + +#[derive(Debug, Serialize)] +pub(crate) enum TokenType { + #[serde(rename = "bearer")] + Bearer, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TokenResp { + access_token: OauthToken, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + // MISSING id_token + #[serde(skip_serializing_if = "Option::is_none")] + session_token: Option, + scope: ScopeSet, + token_type: TokenType, + expires_in: u32, + #[serde(serialize_with = "serialize_dt")] + auth_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + keys_jwe: Option, +} + +#[post("/oauth/token", data = "", rank = 1)] +pub(crate) async fn token_authenticated( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + match &req.body.details { + TokenReqDetails::FxaCreds { .. } => (), + _ => return Err(auth::Error::InvalidParameter), + } + token_impl( + db, + Some(req.context.uid), + Some(req.context.created_at), + req.body, + None, + Some(req.session.clone()), + ) + .await +} + +#[post("/oauth/token", data = "", rank = 2)] +pub(crate) async fn token_unauthenticated( + db: &DbConn, + req: Json, +) -> auth::Result { + let (parent_refresh, auth_at) = match &req.details { + TokenReqDetails::RefreshToken { refresh_token, .. } => { + let session = db.use_session_from_refresh(&refresh_token.hash()).await?; + (Some(refresh_token.hash()), Some(session.1.created_at)) + }, + TokenReqDetails::AuthCode { .. } => (None, None), + _ => return Err(auth::Error::InvalidParameter), + }; + token_impl(db, None, auth_at, req.into_inner(), parent_refresh, None).await +} + +async fn token_impl( + db: &DbConn, + user_id: Option, + auth_at: Option>, + req: TokenReq, + parent_refresh: Option, + parent_session: Option, +) -> auth::Result { + if !req.details.extra_is_empty() { + return Err(auth::Error::InvalidParameter); + } + let ttl = req.ttl.unwrap_or(3600).clamp(0, 7 * 86400); + + let (auth_at, scope, keys_jwe, user_id, access_type) = match req.details { + TokenReqDetails::AuthCode { code, code_verifier, .. } => { + let auth = match db.take_oauth_authorization(&code).await { + Ok(a) => a, + Err(_) => return Err(auth::Error::InvalidAuthToken), + }; + if !bool::from(auth.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) { + return Err(auth::Error::UnknownClientID); + } + let mut sha = sha2::Sha256::new(); + sha.update(code_verifier.as_bytes()); + let challenge = base64::encode_config(&sha.finalize(), base64::URL_SAFE_NO_PAD); + if !bool::from(challenge.as_bytes().ct_eq(auth.code_challenge.as_bytes())) { + return Err(auth::Error::InvalidParameter); + } + (auth.auth_at, auth.scope, auth.keys_jwe, auth.user_id, Some(auth.access_type)) + }, + TokenReqDetails::RefreshToken { refresh_token, scope, .. } => { + let auth_at = + auth_at.expect("oauth token requests with refresh token must set auth_at"); + let base = db.get_refresh_token(&refresh_token.hash()).await?; + if !bool::from(base.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) { + return Err(auth::Error::UnknownClientID); + } + check_client_and_scopes(&req.client_id, &scope)?; + if !base.scope.implies_all(&scope) { + return Err(auth::Error::ScopesNotAllowed); + } + (auth_at, scope, None, base.user_id, None) + }, + TokenReqDetails::FxaCreds { scope, access_type, .. } => { + let user_id = user_id.expect("oauth token requests with fxa must set user_id"); + let auth_at = auth_at.expect("oauth token requests with fxa must set auth_at"); + check_client_and_scopes(&req.client_id, &scope)?; + (auth_at, scope, None, user_id, access_type) + }, + }; + + let access_token = OauthToken::random(); + db.add_access_token( + &access_token.hash(), + OauthAccessToken { + user_id: user_id.clone(), + client_id: req.client_id.clone(), + scope: scope.clone(), + parent_refresh, + parent_session, + expires_at: (Local::now() + Duration::seconds(ttl.into())).into(), + }, + ) + .await?; + + let (refresh_token, session_token) = if access_type == Some(OauthAccessType::Offline) { + let (session_token, session_id) = if scope.implies(&SESSION_SCOPE) { + let session_token = SecretBytes::generate(); + let session = SessionCredentials::derive(&session_token); + let session_id = SessionID(session.token_id.0); + db.add_session(session_id.clone(), &user_id, HawkKey(session.req_hmac_key), true, None) + .await?; + (Some(session_token.0), Some(SessionID(session.token_id.0))) + } else { + (None, None) + }; + + let refresh_token = OauthToken::random(); + db.add_refresh_token( + &refresh_token.hash(), + OauthRefreshToken { + user_id, + client_id: req.client_id, + scope: scope.remove(&SESSION_SCOPE), + session_id, + }, + ) + .await?; + (Some(refresh_token), session_token) + } else { + (None, None) + }; + + Ok(Json(TokenResp { + access_token, + refresh_token, + session_token: session_token.map(hex::encode), + scope: scope.remove(&SESSION_SCOPE), + token_type: TokenType::Bearer, + expires_in: ttl, + auth_at, + keys_jwe, + })) +} 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 = "")] +pub(crate) async fn change_start( + db: &DbConn, + 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); + } + 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; + +#[async_trait] +impl AuthSource for WithChangeToken { + type ID = PasswordChangeID; + type Context = (UserID, Option); + 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 = "")] +pub(crate) async fn change_finish( + db: &DbConn, + mailer: &State>, + data: Authenticated>, +) -> auth::Result { + 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 = "")] +pub(crate) async fn forgot_start( + db: &DbConn, + mailer: &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); + } + 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 = "")] +pub(crate) async fn forgot_finish( + db: &DbConn, + data: Authenticated>, +) -> auth::Result { + 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 })) +} diff --git a/src/api/auth/session.rs b/src/api/auth/session.rs new file mode 100644 index 0000000..5911b92 --- /dev/null +++ b/src/api/auth/session.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use rocket::serde::json::Json; +use rocket::State; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::WithFxaLogin; +use crate::api::{auth, Empty, EMPTY}; +use crate::auth::Authenticated; +use crate::db::Db; +use crate::db::DbConn; +use crate::mailer::Mailer; +use crate::push::PushClient; +use crate::types::{SessionID, UserID}; +use crate::utils::DeferAction; + +// MISSING post /session/duplicate +// MISSING post /session/reauth +// MISSING post /session/verify/send_push + +#[derive(Debug, Serialize)] +pub(crate) struct StatusResp { + state: &'static str, // what does this *do*? + uid: UserID, +} + +#[get("/session/status")] +pub(crate) async fn status(req: Authenticated<(), WithFxaLogin>) -> auth::Result { + Ok(Json(StatusResp { state: "", uid: req.context.uid })) +} + +#[post("/session/resend_code", data = "")] +pub(crate) async fn resend_code( + db: &DbConn, + mailer: &State>, + req: Authenticated, +) -> auth::Result { + let code = match req.context.verify_code { + Some(code) => code, + _ => return Err(auth::Error::InvalidVerificationCode), + }; + + let user = db.get_user_by_id(&req.context.uid).await?; + mailer.send_session_verify(&user.email, &code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyReq { + code: String, + // MISSING service + // MISSING scopes + // MISSING marketingOptIn + // MISSING newsletters +} + +#[post("/session/verify_code", data = "")] +pub(crate) async fn verify_code( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + if req.context.verify_code.as_ref() != Some(&req.body.code) { + return Err(auth::Error::InvalidVerificationCode); + } + db.set_session_verified(&req.session).await?; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + custom_session_id: Option, +} + +#[post("/session/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + data: Authenticated, +) -> auth::Result { + if data.body.custom_session_id.is_some() && !data.context.verified { + return Err(auth::Error::UnverifiedSession); + } + let id = data.body.custom_session_id.as_ref().unwrap_or(&data.session); + db.delete_session(&data.context.uid, id).await.map_err(|_| auth::Error::UnknownDevice)?; + if let Some(id) = data.context.device_id { + match db.get_devices(&data.context.uid).await { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => defer.spawn_after_success("api::auth/session/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &id).await; + db.commit().await?; + Ok(()) + } + }), + }; + } + Ok(EMPTY) +} -- cgit v1.2.3