From 8d7b3db33d7e8a14c374b05fc567bcc70d2b018c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 4 Aug 2023 21:12:23 +0200 Subject: [PATCH] Implement login-with-device --- .../down.sql | 0 .../up.sql | 19 ++ .../down.sql | 0 .../up.sql | 19 ++ .../down.sql | 0 .../up.sql | 19 ++ src/api/core/accounts.rs | 220 +++++++++++++++++- src/api/core/mod.rs | 1 + src/api/identity.rs | 24 +- src/api/mod.rs | 3 +- src/api/notifications.rs | 184 ++++++++++++++- src/api/push.rs | 37 +++ src/config.rs | 8 + src/db/models/auth_request.rs | 148 ++++++++++++ src/db/models/device.rs | 88 +++++++ src/db/models/mod.rs | 4 +- src/db/schemas/mysql/schema.rs | 22 ++ src/db/schemas/postgresql/schema.rs | 22 ++ src/db/schemas/sqlite/schema.rs | 22 ++ src/main.rs | 10 + 20 files changed, 842 insertions(+), 8 deletions(-) create mode 100644 migrations/mysql/2023-06-17-200424_create_auth_requests_table/down.sql create mode 100644 migrations/mysql/2023-06-17-200424_create_auth_requests_table/up.sql create mode 100644 migrations/postgresql/2023-06-17-200424_create_auth_requests_table/down.sql create mode 100644 migrations/postgresql/2023-06-17-200424_create_auth_requests_table/up.sql create mode 100644 migrations/sqlite/2023-06-17-200424_create_auth_requests_table/down.sql create mode 100644 migrations/sqlite/2023-06-17-200424_create_auth_requests_table/up.sql create mode 100644 src/db/models/auth_request.rs diff --git a/migrations/mysql/2023-06-17-200424_create_auth_requests_table/down.sql b/migrations/mysql/2023-06-17-200424_create_auth_requests_table/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/mysql/2023-06-17-200424_create_auth_requests_table/up.sql b/migrations/mysql/2023-06-17-200424_create_auth_requests_table/up.sql new file mode 100644 index 0000000000..2366c3b99a --- /dev/null +++ b/migrations/mysql/2023-06-17-200424_create_auth_requests_table/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE auth_requests ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + user_uuid CHAR(36) NOT NULL, + organization_uuid CHAR(36), + request_device_identifier CHAR(36) NOT NULL, + device_type INTEGER NOT NULL, + request_ip TEXT NOT NULL, + response_device_id CHAR(36), + access_code TEXT NOT NULL, + public_key TEXT NOT NULL, + enc_key TEXT NOT NULL, + master_password_hash TEXT NOT NULL, + approved BOOLEAN, + creation_date DATETIME NOT NULL, + response_date DATETIME, + authentication_date DATETIME, + FOREIGN KEY(user_uuid) REFERENCES users(uuid), + FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) +); \ No newline at end of file diff --git a/migrations/postgresql/2023-06-17-200424_create_auth_requests_table/down.sql b/migrations/postgresql/2023-06-17-200424_create_auth_requests_table/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/postgresql/2023-06-17-200424_create_auth_requests_table/up.sql b/migrations/postgresql/2023-06-17-200424_create_auth_requests_table/up.sql new file mode 100644 index 0000000000..8d495e72d2 --- /dev/null +++ b/migrations/postgresql/2023-06-17-200424_create_auth_requests_table/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE auth_requests ( + uuid CHAR(36) NOT NULL PRIMARY KEY, + user_uuid CHAR(36) NOT NULL, + organization_uuid CHAR(36), + request_device_identifier CHAR(36) NOT NULL, + device_type INTEGER NOT NULL, + request_ip TEXT NOT NULL, + response_device_id CHAR(36), + access_code TEXT NOT NULL, + public_key TEXT NOT NULL, + enc_key TEXT NOT NULL, + master_password_hash TEXT NOT NULL, + approved BOOLEAN, + creation_date TIMESTAMP NOT NULL, + response_date TIMESTAMP, + authentication_date TIMESTAMP, + FOREIGN KEY(user_uuid) REFERENCES users(uuid), + FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) +); \ No newline at end of file diff --git a/migrations/sqlite/2023-06-17-200424_create_auth_requests_table/down.sql b/migrations/sqlite/2023-06-17-200424_create_auth_requests_table/down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/migrations/sqlite/2023-06-17-200424_create_auth_requests_table/up.sql b/migrations/sqlite/2023-06-17-200424_create_auth_requests_table/up.sql new file mode 100644 index 0000000000..f16922ec64 --- /dev/null +++ b/migrations/sqlite/2023-06-17-200424_create_auth_requests_table/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE auth_requests ( + uuid TEXT NOT NULL PRIMARY KEY, + user_uuid TEXT NOT NULL, + organization_uuid TEXT, + request_device_identifier TEXT NOT NULL, + device_type INTEGER NOT NULL, + request_ip TEXT NOT NULL, + response_device_id TEXT, + access_code TEXT NOT NULL, + public_key TEXT NOT NULL, + enc_key TEXT NOT NULL, + master_password_hash TEXT NOT NULL, + approved BOOLEAN, + creation_date DATETIME NOT NULL, + response_date DATETIME, + authentication_date DATETIME, + FOREIGN KEY(user_uuid) REFERENCES users(uuid), + FOREIGN KEY(organization_uuid) REFERENCES organizations(uuid) +); \ No newline at end of file diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6c7d8ed594..3feccd801f 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1,13 +1,14 @@ +use crate::db::DbPool; use chrono::Utc; use rocket::serde::json::Json; use serde_json::Value; use crate::{ api::{ - core::log_user_event, register_push_device, unregister_push_device, EmptyResult, JsonResult, JsonUpcase, - Notify, NumberOrString, PasswordData, UpdateType, + core::log_user_event, register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, + JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType, }, - auth::{decode_delete, decode_invite, decode_verify_email, Headers}, + auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers}, crypto, db::{models::*, DbConn}, mail, CONFIG, @@ -51,6 +52,11 @@ pub fn routes() -> Vec { put_device_token, put_clear_device_token, post_clear_device_token, + post_auth_request, + get_auth_request, + put_auth_request, + get_auth_request_response, + get_auth_requests, ] } @@ -996,3 +1002,211 @@ async fn put_clear_device_token(uuid: &str, mut conn: DbConn) -> EmptyResult { async fn post_clear_device_token(uuid: &str, conn: DbConn) -> EmptyResult { put_clear_device_token(uuid, conn).await } + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct AuthRequestRequest { + accessCode: String, + deviceIdentifier: String, + email: String, + publicKey: String, + #[serde(alias = "type")] + _type: i32, +} + +#[post("/auth-requests", data = "")] +async fn post_auth_request( + data: Json, + headers: ClientHeaders, + mut conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let data = data.into_inner(); + + let user = match User::find_by_mail(&data.email, &mut conn).await { + Some(user) => user, + None => { + err!("AuthRequest doesn't exist") + } + }; + + let mut auth_request = AuthRequest::new( + user.uuid.clone(), + data.deviceIdentifier.clone(), + headers.device_type, + headers.ip.ip.to_string(), + data.accessCode, + data.publicKey, + ); + auth_request.save(&mut conn).await?; + + nt.send_auth_request(&user.uuid, &auth_request.uuid, &data.deviceIdentifier, &mut conn).await; + + Ok(Json(json!({ + "id": auth_request.uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": null, + "masterPasswordHash": null, + "creationDate": auth_request.creation_date.and_utc(), + "responseDate": null, + "requestApproved": false, + "origin": CONFIG.domain_origin(), + "object": "auth-request" + }))) +} + +#[get("/auth-requests/")] +async fn get_auth_request(uuid: &str, mut conn: DbConn) -> JsonResult { + let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } + }; + + let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc()); + + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc(), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +struct AuthResponseRequest { + deviceIdentifier: String, + key: String, + masterPasswordHash: String, + requestApproved: bool, +} + +#[put("/auth-requests/", data = "")] +async fn put_auth_request( + uuid: &str, + data: Json, + mut conn: DbConn, + ant: AnonymousNotify<'_>, + nt: Notify<'_>, +) -> JsonResult { + let data = data.into_inner(); + let mut auth_request: AuthRequest = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } + }; + + auth_request.approved = Some(data.requestApproved); + auth_request.enc_key = data.key; + auth_request.master_password_hash = data.masterPasswordHash; + auth_request.response_device_id = Some(data.deviceIdentifier.clone()); + auth_request.save(&mut conn).await?; + + if auth_request.approved.unwrap_or(false) { + ant.send_auth_response(&auth_request.user_uuid, &auth_request.uuid).await; + nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, data.deviceIdentifier, &mut conn).await; + } + + let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc()); + + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc(), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) +} + +#[get("/auth-requests//response?")] +async fn get_auth_request_response(uuid: &str, code: &str, mut conn: DbConn) -> JsonResult { + let auth_request = match AuthRequest::find_by_uuid(uuid, &mut conn).await { + Some(auth_request) => auth_request, + None => { + err!("AuthRequest doesn't exist") + } + }; + + if !auth_request.check_access_code(code) { + err!("Access code invalid doesn't exist") + } + + let response_date_utc = auth_request.response_date.map(|response_date| response_date.and_utc()); + + Ok(Json(json!( + { + "id": uuid, + "publicKey": auth_request.public_key, + "requestDeviceType": DeviceType::from_i32(auth_request.device_type).to_string(), + "requestIpAddress": auth_request.request_ip, + "key": auth_request.enc_key, + "masterPasswordHash": auth_request.master_password_hash, + "creationDate": auth_request.creation_date.and_utc(), + "responseDate": response_date_utc, + "requestApproved": auth_request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + } + ))) +} + +#[get("/auth-requests")] +async fn get_auth_requests(headers: Headers, mut conn: DbConn) -> JsonResult { + let auth_requests = AuthRequest::find_by_user(&headers.user.uuid, &mut conn).await; + + Ok(Json(json!({ + "data": auth_requests + .iter() + .filter(|request| request.approved.is_none()) + .map(|request| { + let response_date_utc = request.response_date.map(|response_date| response_date.and_utc()); + + json!({ + "id": request.uuid, + "publicKey": request.public_key, + "requestDeviceType": DeviceType::from_i32(request.device_type).to_string(), + "requestIpAddress": request.request_ip, + "key": request.enc_key, + "masterPasswordHash": request.master_password_hash, + "creationDate": request.creation_date.and_utc(), + "responseDate": response_date_utc, + "requestApproved": request.approved, + "origin": CONFIG.domain_origin(), + "object":"auth-request" + }) + }).collect::>(), + "continuationToken": null, + "object": "list" + }))) +} + +pub async fn purge_auth_requests(pool: DbPool) { + debug!("Purging auth requests"); + if let Ok(mut conn) = pool.get().await { + AuthRequest::purge_expired_auth_requests(&mut conn).await; + } else { + error!("Failed to get DB connection while purging trashed ciphers") + } +} diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index f7e912cf8a..f1424688ae 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -8,6 +8,7 @@ mod public; mod sends; pub mod two_factor; +pub use accounts::purge_auth_requests; pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType}; pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job}; pub use events::{event_cleanup_job, log_event, log_user_event}; diff --git a/src/api/identity.rs b/src/api/identity.rs index 048ac17d10..8dbb78f69a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -155,7 +155,27 @@ async fn _password_login( // Check password let password = data.password.as_ref().unwrap(); - if !user.check_valid_password(password) { + if let Some(auth_request_uuid) = data.auth_request.clone() { + if let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_uuid.as_str(), conn).await { + if !auth_request.check_access_code(password) { + err!( + "Username or access code is incorrect. Try again", + format!("IP: {}. Username: {}.", ip.ip, username), + ErrorEvent { + event: EventType::UserFailedLogIn, + } + ) + } + } else { + err!( + "Auth request not found. Try again.", + format!("IP: {}. Username: {}.", ip.ip, username), + ErrorEvent { + event: EventType::UserFailedLogIn, + } + ) + } + } else if !user.check_valid_password(password) { err!( "Username or password is incorrect. Try again", format!("IP: {}. Username: {}.", ip.ip, username), @@ -646,6 +666,8 @@ struct ConnectData { #[field(name = uncased("two_factor_remember"))] #[field(name = uncased("twofactorremember"))] two_factor_remember: Option, + #[field(name = uncased("authrequest"))] + auth_request: Option, } fn _check_is_some(value: &Option, msg: &str) -> EmptyResult { diff --git a/src/api/mod.rs b/src/api/mod.rs index f3f79210bb..fd181fda50 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -13,6 +13,7 @@ pub use crate::api::{ admin::catchers as admin_catchers, admin::routes as admin_routes, core::catchers as core_catchers, + core::purge_auth_requests, core::purge_sends, core::purge_trashed_ciphers, core::routes as core_routes, @@ -22,7 +23,7 @@ pub use crate::api::{ icons::routes as icons_routes, identity::routes as identity_routes, notifications::routes as notifications_routes, - notifications::{start_notification_server, Notify, UpdateType}, + notifications::{start_notification_server, AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS}, push::{ push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, unregister_push_device, diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 5a07372390..7e76021b35 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -36,10 +36,19 @@ static WS_USERS: Lazy> = Lazy::new(|| { }) }); -use super::{push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update}; +pub static WS_ANONYMOUS_SUBSCRIPTIONS: Lazy> = Lazy::new(|| { + Arc::new(AnonymousWebSocketSubscriptions { + map: Arc::new(dashmap::DashMap::new()), + }) +}); + +use super::{ + push::push_auth_request, push::push_auth_response, push_cipher_update, push_folder_update, push_logout, + push_send_update, push_user_update, +}; pub fn routes() -> Vec { - routes![websockets_hub] + routes![websockets_hub, anonymous_websockets_hub] } #[derive(FromForm, Debug)] @@ -74,6 +83,29 @@ impl Drop for WSEntryMapGuard { } } +struct WSAnonymousEntryMapGuard { + subscriptions: Arc, + token: String, + addr: IpAddr, +} + +impl WSAnonymousEntryMapGuard { + fn new(subscriptions: Arc, token: String, addr: IpAddr) -> Self { + Self { + subscriptions, + token, + addr, + } + } +} + +impl Drop for WSAnonymousEntryMapGuard { + fn drop(&mut self) { + info!("Closing WS connection from {}", self.addr); + self.subscriptions.map.remove(&self.token); + } +} + #[get("/hub?")] fn websockets_hub<'r>( ws: rocket_ws::WebSocket, @@ -144,6 +176,72 @@ fn websockets_hub<'r>( }) } +#[get("/anonymous-hub?")] +fn anonymous_websockets_hub<'r>( + ws: rocket_ws::WebSocket, + token: String, + ip: ClientIp, +) -> Result { + let addr = ip.ip; + info!("Accepting Anonymous Rocket WS connection from {addr}"); + + let (mut rx, guard) = { + let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); + + // Add a channel to send messages to this client to the map + let (tx, rx) = tokio::sync::mpsc::channel::(100); + subscriptions.map.insert(token.clone(), tx); + + // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map + (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, addr)) + }; + + Ok({ + rocket_ws::Stream! { ws => { + let mut ws = ws; + let _guard = guard; + let mut interval = tokio::time::interval(Duration::from_secs(15)); + loop { + tokio::select! { + res = ws.next() => { + match res { + Some(Ok(message)) => { + match message { + // Respond to any pings + Message::Ping(ping) => yield Message::Pong(ping), + Message::Pong(_) => {/* Ignored */}, + + // We should receive an initial message with the protocol and version, and we will reply to it + Message::Text(ref message) => { + let msg = message.strip_suffix(RECORD_SEPARATOR as char).unwrap_or(message); + + if serde_json::from_str(msg).ok() == Some(INITIAL_MESSAGE) { + yield Message::binary(INITIAL_RESPONSE); + continue; + } + } + // Just echo anything else the client sends + _ => yield message, + } + } + _ => break, + } + } + + res = rx.recv() => { + match res { + Some(res) => yield res, + None => break, + } + } + + _ = interval.tick() => yield Message::Ping(create_ping()) + } + } + }} + }) +} + // // Websockets server // @@ -352,6 +450,69 @@ impl WebSocketUsers { push_send_update(ut, send, acting_device_uuid, conn).await; } } + + pub async fn send_auth_request( + &self, + user_uuid: &String, + auth_request_uuid: &String, + acting_device_uuid: &String, + conn: &mut DbConn, + ) { + let data = create_update( + vec![("Id".into(), auth_request_uuid.clone().into()), ("UserId".into(), user_uuid.clone().into())], + UpdateType::AuthRequest, + Some(acting_device_uuid.to_string()), + ); + self.send_update(user_uuid, &data).await; + + if CONFIG.push_enabled() { + push_auth_request(user_uuid.to_string(), auth_request_uuid.to_string(), conn).await; + } + } + + pub async fn send_auth_response( + &self, + user_uuid: &String, + auth_response_uuid: &str, + approving_device_uuid: String, + conn: &mut DbConn, + ) { + let data = create_update( + vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())], + UpdateType::AuthRequestResponse, + approving_device_uuid.clone().into(), + ); + self.send_update(auth_response_uuid, &data).await; + + if CONFIG.push_enabled() { + push_auth_response(user_uuid.to_string(), auth_response_uuid.to_string(), approving_device_uuid, conn) + .await; + } + } +} + +#[derive(Clone)] +pub struct AnonymousWebSocketSubscriptions { + map: Arc>>, +} + +impl AnonymousWebSocketSubscriptions { + async fn send_update(&self, token: &str, data: &[u8]) { + if let Some(sender) = self.map.get(token).map(|v| v.clone()) { + if let Err(e) = sender.send(Message::binary(data)).await { + error!("Error sending WS update {e}"); + } + } + } + + pub async fn send_auth_response(&self, user_uuid: &String, auth_response_uuid: &str) { + let data = create_anonymous_update( + vec![("Id".into(), auth_response_uuid.to_owned().into()), ("UserId".into(), user_uuid.clone().into())], + UpdateType::AuthRequestResponse, + user_uuid.to_string(), + ); + self.send_update(auth_response_uuid, &data).await; + } } /* Message Structure @@ -387,6 +548,24 @@ fn create_update(payload: Vec<(Value, Value)>, ut: UpdateType, acting_device_uui serialize(value) } +fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id: String) -> Vec { + use rmpv::Value as V; + + let value = V::Array(vec![ + 1.into(), + V::Map(vec![]), + V::Nil, + "AuthRequestResponseRecieved".into(), + V::Array(vec![V::Map(vec![ + ("Type".into(), (ut as i32).into()), + ("Payload".into(), payload.into()), + ("UserId".into(), user_id.into()), + ])]), + ]); + + serialize(value) +} + fn create_ping() -> Vec { serialize(Value::Array(vec![6.into()])) } @@ -420,6 +599,7 @@ pub enum UpdateType { } pub type Notify<'a> = &'a rocket::State>; +pub type AnonymousNotify<'a> = &'a rocket::State>; pub fn start_notification_server() -> Arc { let users = Arc::clone(&WS_USERS); diff --git a/src/api/push.rs b/src/api/push.rs index da9255a662..e6a931e609 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -255,3 +255,40 @@ async fn send_to_push_relay(notification_data: Value) { error!("An error occured while sending a send update to the push relay: {}", e); }; } + +pub async fn push_auth_request(user_uuid: String, auth_request_uuid: String, conn: &mut crate::db::DbConn) { + if Device::check_user_has_push_device(user_uuid.as_str(), conn).await { + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user_uuid, + "organizationId": (), + "deviceId": null, + "identifier": null, + "type": UpdateType::AuthRequest as i32, + "payload": { + "id": auth_request_uuid, + "userId": user_uuid, + } + }))); + } +} + +pub async fn push_auth_response( + user_uuid: String, + auth_request_uuid: String, + approving_device_uuid: String, + conn: &mut crate::db::DbConn, +) { + if Device::check_user_has_push_device(user_uuid.as_str(), conn).await { + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user_uuid, + "organizationId": (), + "deviceId": approving_device_uuid, + "identifier": approving_device_uuid, + "type": UpdateType::AuthRequestResponse as i32, + "payload": { + "id": auth_request_uuid, + "userId": user_uuid, + } + }))); + } +} diff --git a/src/config.rs b/src/config.rs index 1173afb96c..d54b356b15 100644 --- a/src/config.rs +++ b/src/config.rs @@ -409,6 +409,10 @@ make_config! { /// Event cleanup schedule |> Cron schedule of the job that cleans old events from the event table. /// Defaults to daily. Set blank to disable this job. event_cleanup_schedule: String, false, def, "0 10 0 * * *".to_string(); + /// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request. + /// Defaults to every minute. Set blank to disable this job. + auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string(); + }, /// General settings @@ -893,6 +897,10 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { err!("`EVENT_CLEANUP_SCHEDULE` is not a valid cron expression") } + if !cfg.auth_request_purge_schedule.is_empty() && cfg.auth_request_purge_schedule.parse::().is_err() { + err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") + } + if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs new file mode 100644 index 0000000000..0b129ac16a --- /dev/null +++ b/src/db/models/auth_request.rs @@ -0,0 +1,148 @@ +use crate::crypto::ct_eq; +use chrono::{NaiveDateTime, Utc}; + +db_object! { + #[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)] + #[diesel(table_name = auth_requests)] + #[diesel(treat_none_as_null = true)] + #[diesel(primary_key(uuid))] + pub struct AuthRequest { + pub uuid: String, + pub user_uuid: String, + pub organization_uuid: Option, + + pub request_device_identifier: String, + pub device_type: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs + + pub request_ip: String, + pub response_device_id: Option, + + pub access_code: String, + pub public_key: String, + + pub enc_key: String, + + pub master_password_hash: String, + pub approved: Option, + pub creation_date: NaiveDateTime, + pub response_date: Option, + + pub authentication_date: Option, + } +} + +impl AuthRequest { + pub fn new( + user_uuid: String, + request_device_identifier: String, + device_type: i32, + request_ip: String, + access_code: String, + public_key: String, + ) -> Self { + let now = Utc::now().naive_utc(); + + Self { + uuid: crate::util::get_uuid(), + user_uuid, + organization_uuid: None, + + request_device_identifier, + device_type, + request_ip, + response_device_id: None, + access_code, + public_key, + enc_key: String::new(), + master_password_hash: String::new(), + approved: None, + creation_date: now, + response_date: None, + authentication_date: None, + } + } +} + +use crate::db::DbConn; + +use crate::api::EmptyResult; +use crate::error::MapResult; + +impl AuthRequest { + pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(auth_requests::table) + .values(AuthRequestDb::to_db(self)) + .execute(conn) + { + Ok(_) => Ok(()), + // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. + Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { + diesel::update(auth_requests::table) + .filter(auth_requests::uuid.eq(&self.uuid)) + .set(AuthRequestDb::to_db(self)) + .execute(conn) + .map_res("Error auth_request") + } + Err(e) => Err(e.into()), + }.map_res("Error auth_request") + } + postgresql { + let value = AuthRequestDb::to_db(self); + diesel::insert_into(auth_requests::table) + .values(&value) + .on_conflict(auth_requests::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error saving auth_request") + } + } + } + + pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option { + db_run! {conn: { + auth_requests::table + .filter(auth_requests::uuid.eq(uuid)) + .first::(conn) + .ok() + .from_db() + }} + } + + pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec { + db_run! {conn: { + auth_requests::table + .filter(auth_requests::user_uuid.eq(user_uuid)) + .load::(conn).expect("Error loading auth_requests").from_db() + }} + } + + pub async fn find_created_before(dt: &NaiveDateTime, conn: &mut DbConn) -> Vec { + db_run! {conn: { + auth_requests::table + .filter(auth_requests::creation_date.lt(dt)) + .load::(conn).expect("Error loading auth_requests").from_db() + }} + } + + pub async fn delete(&self, conn: &mut DbConn) -> EmptyResult { + db_run! { conn: { + diesel::delete(auth_requests::table.filter(auth_requests::uuid.eq(&self.uuid))) + .execute(conn) + .map_res("Error deleting auth request") + }} + } + + pub fn check_access_code(&self, access_code: &str) -> bool { + ct_eq(&self.access_code, access_code) + } + + pub async fn purge_expired_auth_requests(conn: &mut DbConn) { + let expiry_time = Utc::now().naive_utc() - chrono::Duration::minutes(5); //after 5 minutes, clients reject the request + for auth_request in Self::find_created_before(&expiry_time, conn).await { + auth_request.delete(conn).await.ok(); + } + } +} diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 78519737e7..b80b47a104 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,6 +1,7 @@ use chrono::{NaiveDateTime, Utc}; use crate::{crypto, CONFIG}; +use core::fmt; db_object! { #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -225,3 +226,90 @@ impl Device { }} } } + +pub enum DeviceType { + Android = 0, + Ios = 1, + ChromeExtension = 2, + FirefoxExtension = 3, + OperaExtension = 4, + EdgeExtension = 5, + WindowsDesktop = 6, + MacOsDesktop = 7, + LinuxDesktop = 8, + ChromeBrowser = 9, + FirefoxBrowser = 10, + OperaBrowser = 11, + EdgeBrowser = 12, + IEBrowser = 13, + UnknownBrowser = 14, + AndroidAmazon = 15, + Uwp = 16, + SafariBrowser = 17, + VivaldiBrowser = 18, + VivaldiExtension = 19, + SafariExtension = 20, + Sdk = 21, + Server = 22, +} + +impl fmt::Display for DeviceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DeviceType::Android => write!(f, "Android"), + DeviceType::Ios => write!(f, "iOS"), + DeviceType::ChromeExtension => write!(f, "Chrome Extension"), + DeviceType::FirefoxExtension => write!(f, "Firefox Extension"), + DeviceType::OperaExtension => write!(f, "Opera Extension"), + DeviceType::EdgeExtension => write!(f, "Edge Extension"), + DeviceType::WindowsDesktop => write!(f, "Windows Desktop"), + DeviceType::MacOsDesktop => write!(f, "MacOS Desktop"), + DeviceType::LinuxDesktop => write!(f, "Linux Desktop"), + DeviceType::ChromeBrowser => write!(f, "Chrome Browser"), + DeviceType::FirefoxBrowser => write!(f, "Firefox Browser"), + DeviceType::OperaBrowser => write!(f, "Opera Browser"), + DeviceType::EdgeBrowser => write!(f, "Edge Browser"), + DeviceType::IEBrowser => write!(f, "Internet Explorer"), + DeviceType::UnknownBrowser => write!(f, "Unknown Browser"), + DeviceType::AndroidAmazon => write!(f, "Android Amazon"), + DeviceType::Uwp => write!(f, "UWP"), + DeviceType::SafariBrowser => write!(f, "Safari Browser"), + DeviceType::VivaldiBrowser => write!(f, "Vivaldi Browser"), + DeviceType::VivaldiExtension => write!(f, "Vivaldi Extension"), + DeviceType::SafariExtension => write!(f, "Safari Extension"), + DeviceType::Sdk => write!(f, "SDK"), + DeviceType::Server => write!(f, "Server"), + } + } +} + +impl DeviceType { + pub fn from_i32(value: i32) -> DeviceType { + match value { + 0 => DeviceType::Android, + 1 => DeviceType::Ios, + 2 => DeviceType::ChromeExtension, + 3 => DeviceType::FirefoxExtension, + 4 => DeviceType::OperaExtension, + 5 => DeviceType::EdgeExtension, + 6 => DeviceType::WindowsDesktop, + 7 => DeviceType::MacOsDesktop, + 8 => DeviceType::LinuxDesktop, + 9 => DeviceType::ChromeBrowser, + 10 => DeviceType::FirefoxBrowser, + 11 => DeviceType::OperaBrowser, + 12 => DeviceType::EdgeBrowser, + 13 => DeviceType::IEBrowser, + 14 => DeviceType::UnknownBrowser, + 15 => DeviceType::AndroidAmazon, + 16 => DeviceType::Uwp, + 17 => DeviceType::SafariBrowser, + 18 => DeviceType::VivaldiBrowser, + 19 => DeviceType::VivaldiExtension, + 20 => DeviceType::SafariExtension, + 21 => DeviceType::Sdk, + 22 => DeviceType::Server, + _ => DeviceType::UnknownBrowser, + } + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 6cbde05fe4..0379141ad2 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,4 +1,5 @@ mod attachment; +mod auth_request; mod cipher; mod collection; mod device; @@ -15,9 +16,10 @@ mod two_factor_incomplete; mod user; pub use self::attachment::Attachment; +pub use self::auth_request::AuthRequest; pub use self::cipher::Cipher; pub use self::collection::{Collection, CollectionCipher, CollectionUser}; -pub use self::device::Device; +pub use self::device::{Device, DeviceType}; pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType}; pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 695d5bd77b..c2b2c9617c 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -286,6 +286,26 @@ table! { } } +table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Text, + master_password_hash -> Text, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -312,6 +332,7 @@ joinable!(groups_users -> groups (groups_uuid)); joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); +joinable!(auth_requests -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -335,4 +356,5 @@ allow_tables_to_appear_in_same_query!( groups_users, collections_groups, event, + auth_requests, ); diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 766c03e7ba..4ae9e82156 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -286,6 +286,26 @@ table! { } } +table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Text, + master_password_hash -> Text, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -312,6 +332,7 @@ joinable!(groups_users -> groups (groups_uuid)); joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); +joinable!(auth_requests -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -335,4 +356,5 @@ allow_tables_to_appear_in_same_query!( groups_users, collections_groups, event, + auth_requests, ); diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 031ec7aa8b..62c04e9147 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -286,6 +286,26 @@ table! { } } +table! { + auth_requests (uuid) { + uuid -> Text, + user_uuid -> Text, + organization_uuid -> Nullable, + request_device_identifier -> Text, + device_type -> Integer, + request_ip -> Text, + response_device_id -> Nullable, + access_code -> Text, + public_key -> Text, + enc_key -> Text, + master_password_hash -> Text, + approved -> Nullable, + creation_date -> Timestamp, + response_date -> Nullable, + authentication_date -> Nullable, + } +} + joinable!(attachments -> ciphers (cipher_uuid)); joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> users (user_uuid)); @@ -313,6 +333,7 @@ joinable!(groups_users -> groups (groups_uuid)); joinable!(collections_groups -> collections (collections_uuid)); joinable!(collections_groups -> groups (groups_uuid)); joinable!(event -> users_organizations (uuid)); +joinable!(auth_requests -> users (user_uuid)); allow_tables_to_appear_in_same_query!( attachments, @@ -336,4 +357,5 @@ allow_tables_to_appear_in_same_query!( groups_users, collections_groups, event, + auth_requests, ); diff --git a/src/main.rs b/src/main.rs index 29eccaeab1..33d802ee52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,9 +82,12 @@ mod mail; mod ratelimit; mod util; +use crate::api::purge_auth_requests; +use crate::api::WS_ANONYMOUS_SUBSCRIPTIONS; pub use config::CONFIG; pub use error::{Error, MapResult}; use rocket::data::{Limits, ToByteUnit}; +use std::sync::Arc; pub use util::is_running_in_docker; #[rocket::main] @@ -533,6 +536,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .register([basepath, "/admin"].concat(), api::admin_catchers()) .manage(pool) .manage(api::start_notification_server()) + .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .attach(util::AppHeaders()) .attach(util::Cors()) .attach(util::BetterLogging(extra_debug)) @@ -608,6 +612,12 @@ fn schedule_jobs(pool: db::DbPool) { })); } + if !CONFIG.auth_request_purge_schedule().is_empty() { + sched.add(Job::new(CONFIG.auth_request_purge_schedule().parse().unwrap(), || { + runtime.spawn(purge_auth_requests(pool.clone())); + })); + } + // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty()