From 4219249e11845bb8869c26e1182fa1d38b1a162a Mon Sep 17 00:00:00 2001 From: BlackDex Date: Fri, 2 Jun 2023 21:36:15 +0200 Subject: [PATCH] Add support for Organization token This is a WIP for adding organization token login support. It has basic token login and verification support, but that's about it. This branch is a refresh of the previous version, and will contain code from a PR based upon my previous branch. --- .../down.sql | 0 .../up.sql | 8 ++ .../down.sql | 0 .../up.sql | 8 ++ .../down.sql | 0 .../up.sql | 9 +++ src/api/core/organizations.rs | 58 +++++++++++++- src/api/identity.rs | 56 +++++++++++--- src/auth.rs | 30 ++++++++ src/db/models/device.rs | 6 +- src/db/models/mod.rs | 2 +- src/db/models/organization.rs | 77 ++++++++++++++++++- src/db/schemas/mysql/schema.rs | 12 +++ src/db/schemas/postgresql/schema.rs | 12 +++ src/db/schemas/sqlite/schema.rs | 12 +++ 15 files changed, 272 insertions(+), 18 deletions(-) create mode 100644 migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql create mode 100644 migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql create mode 100644 migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql create mode 100644 migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql create mode 100644 migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql create mode 100644 migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/mysql/2022-07-21-200424_create_organization_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql new file mode 100644 index 00000000..e9e0b739 --- /dev/null +++ b/migrations/mysql/2022-07-21-200424_create_organization_api_key/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE organization_api_key ( + uuid CHAR(36) NOT NULL, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + atype INTEGER NOT NULL, + api_key VARCHAR(255) NOT NULL, + revision_date DATETIME NOT NULL, + PRIMARY KEY(uuid, org_uuid) +); diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql new file mode 100644 index 00000000..3c37bb5c --- /dev/null +++ b/migrations/postgresql/2022-07-21-200424_create_organization_api_key/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE organization_api_key ( + uuid CHAR(36) NOT NULL, + org_uuid CHAR(36) NOT NULL REFERENCES organizations(uuid), + atype INTEGER NOT NULL, + api_key VARCHAR(255), + revision_date TIMESTAMP NOT NULL, + PRIMARY KEY(uuid, org_uuid) +); diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql new file mode 100644 index 00000000..986b00d9 --- /dev/null +++ b/migrations/sqlite/2022-07-21-200424_create_organization_api_key/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE organization_api_key ( + uuid TEXT NOT NULL, + org_uuid TEXT NOT NULL, + atype INTEGER NOT NULL, + api_key TEXT NOT NULL, + revision_date DATETIME NOT NULL, + PRIMARY KEY(uuid, org_uuid), + FOREIGN KEY(org_uuid) REFERENCES organizations(uuid) +); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3899611c..a3aee5be 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -93,7 +93,9 @@ pub fn routes() -> Vec { put_reset_password_enrollment, get_reset_password_details, put_reset_password, - get_org_export + get_org_export, + api_key, + rotate_api_key, ] } @@ -2891,3 +2893,57 @@ async fn get_org_export(org_id: &str, headers: AdminHeaders, mut conn: DbConn) - })) } } + +async fn _api_key( + org_id: String, + data: JsonUpcase, + rotate: bool, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + let data: PasswordData = data.into_inner().data; + let user = headers.user; + + // Validate the admin users password + if !user.check_valid_password(&data.MasterPasswordHash) { + err!("Invalid password") + } + + let org_api_key = match OrganizationApiKey::find_by_org_uuid(&org_id, &conn).await { + Some(mut org_api_key) => { + if rotate { + org_api_key.api_key = crate::crypto::generate_api_key(); + org_api_key.revision_date = chrono::Utc::now().naive_utc(); + org_api_key.save(&conn).await.expect("Error rotating organization API Key"); + } + org_api_key + } + None => { + let api_key = crate::crypto::generate_api_key(); + let new_org_api_key = OrganizationApiKey::new(org_id, api_key); + new_org_api_key.save(&conn).await.expect("Error creating organization API Key"); + new_org_api_key + } + }; + + Ok(Json(json!({ + "ApiKey": org_api_key.api_key, + "RevisionDate": crate::util::format_date(&org_api_key.revision_date), + "Object": "apiKey", + }))) +} + +#[post("/organizations//api-key", data = "")] +async fn api_key(org_id: String, data: JsonUpcase, headers: AdminHeaders, conn: DbConn) -> JsonResult { + _api_key(org_id, data, false, headers, conn).await +} + +#[post("/organizations//rotate-api-key", data = "")] +async fn rotate_api_key( + org_id: String, + data: JsonUpcase, + headers: AdminHeaders, + conn: DbConn, +) -> JsonResult { + _api_key(org_id, data, true, headers, conn).await +} diff --git a/src/api/identity.rs b/src/api/identity.rs index cefee692..048ac17d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -14,7 +14,7 @@ use crate::{ core::two_factor::{duo, email, email::EmailTokenData, yubikey}, ApiResult, EmptyResult, JsonResult, JsonUpcase, }, - auth::{ClientHeaders, ClientIp}, + auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp}, db::{models::*, DbConn}, error::MapResult, mail, util, CONFIG, @@ -276,16 +276,23 @@ async fn _api_key_login( conn: &mut DbConn, ip: &ClientIp, ) -> JsonResult { - // Validate scope - let scope = data.scope.as_ref().unwrap(); - if scope != "api" { - err!("Scope not supported") - } - let scope_vec = vec!["api".into()]; - // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; + // Validate scope + match data.scope.as_ref().unwrap().as_ref() { + "api" => _user_api_key_login(data, user_uuid, conn, ip).await, + "api.organization" => _organization_api_key_login(data, conn, ip).await, + _ => err!("Scope not supported"), + } +} + +async fn _user_api_key_login( + data: ConnectData, + user_uuid: &mut Option, + conn: &mut DbConn, + ip: &ClientIp, +) -> JsonResult { // Get the user via the client_id let client_id = data.client_id.as_ref().unwrap(); let client_user_uuid = match client_id.strip_prefix("user.") { @@ -342,6 +349,7 @@ async fn _api_key_login( } // Common + let scope_vec = vec!["api".into()]; let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await; let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); device.save(conn).await?; @@ -362,13 +370,43 @@ async fn _api_key_login( "KdfMemory": user.client_kdf_memory, "KdfParallelism": user.client_kdf_parallelism, "ResetMasterPassword": false, // TODO: Same as above - "scope": scope, + "scope": "api", "unofficialServer": true, }); Ok(Json(result)) } +async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &ClientIp) -> JsonResult { + // Get the org via the client_id + let client_id = data.client_id.as_ref().unwrap(); + let org_uuid = match client_id.strip_prefix("organization.") { + Some(uuid) => uuid, + None => err!("Malformed client_id", format!("IP: {}.", ip.ip)), + }; + let org_api_key = match OrganizationApiKey::find_by_org_uuid(org_uuid, conn).await { + Some(org_api_key) => org_api_key, + None => err!("Invalid client_id", format!("IP: {}.", ip.ip)), + }; + + // Check API key. + let client_secret = data.client_secret.as_ref().unwrap(); + if !org_api_key.check_valid_api_key(client_secret) { + err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid)) + } + + let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid); + let access_token = crate::auth::encode_jwt(&claim); + + Ok(Json(json!({ + "access_token": access_token, + "expires_in": 3600, + "token_type": "Bearer", + "scope": "api.organization", + "unofficialServer": true, + }))) +} + /// Retrieves an existing device or creates a new device from ConnectData and the User async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) { // On iOS, device_type sends "iOS", on others it sends a number diff --git a/src/auth.rs b/src/auth.rs index 2a6f8a32..d96e98e1 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -23,6 +23,7 @@ static JWT_DELETE_ISSUER: Lazy = Lazy::new(|| format!("{}|delete", CONFI static JWT_VERIFYEMAIL_ISSUER: Lazy = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin())); static JWT_ADMIN_ISSUER: Lazy = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin())); static JWT_SEND_ISSUER: Lazy = Lazy::new(|| format!("{}|send", CONFIG.domain_origin())); +static JWT_ORG_API_KEY_ISSUER: Lazy = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin())); static PRIVATE_RSA_KEY: Lazy = Lazy::new(|| { let key = @@ -200,6 +201,35 @@ pub fn generate_emergency_access_invite_claims( } } +#[derive(Debug, Serialize, Deserialize)] +pub struct OrgApiKeyLoginJwtClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, + + pub client_id: String, + pub client_sub: String, + pub scope: Vec, +} + +pub fn generate_organization_api_key_login_claims(uuid: String, org_id: String) -> OrgApiKeyLoginJwtClaims { + let time_now = Utc::now().naive_utc(); + OrgApiKeyLoginJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::hours(1)).timestamp(), + iss: JWT_ORG_API_KEY_ISSUER.to_string(), + sub: uuid, + client_id: format!("organization.{org_id}"), + client_sub: org_id, + scope: vec!["api.organization".into()], + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before diff --git a/src/db/models/device.rs b/src/db/models/device.rs index a1f4aee9..81f12e18 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -1,6 +1,6 @@ use chrono::{NaiveDateTime, Utc}; -use crate::CONFIG; +use crate::{crypto, CONFIG}; db_object! { #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -47,9 +47,7 @@ impl Device { } pub fn refresh_twofactor_remember(&mut self) -> String { - use crate::crypto; use data_encoding::BASE64; - let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64); self.twofactor_remember = Some(twofactor_remember.clone()); @@ -68,9 +66,7 @@ impl Device { ) -> (String, i64) { // If there is no refresh token, we create one if self.refresh_token.is_empty() { - use crate::crypto; use data_encoding::BASE64URL; - self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL); } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 96dc27ce..6cbde05f 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -24,7 +24,7 @@ pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher}; pub use self::group::{CollectionGroup, Group, GroupUser}; pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType}; -pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization}; +pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization}; pub use self::send::{Send, SendType}; pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor_incomplete::TwoFactorIncomplete; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index e0b92239..47151a96 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,3 +1,4 @@ +use chrono::{NaiveDateTime, Utc}; use num_traits::FromPrimitive; use serde_json::Value; use std::cmp::Ordering; @@ -31,6 +32,17 @@ db_object! { pub atype: i32, pub reset_password_key: Option, } + + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] + #[diesel(table_name = organization_api_key)] + #[diesel(primary_key(uuid, org_uuid))] + pub struct OrganizationApiKey { + pub uuid: String, + pub org_uuid: String, + pub atype: i32, + pub api_key: String, + pub revision_date: NaiveDateTime, + } } // https://github.com/bitwarden/server/blob/b86a04cef9f1e1b82cf18e49fc94e017c641130c/src/Core/Enums/OrganizationUserStatusType.cs @@ -157,7 +169,7 @@ impl Organization { "UseSso": false, // Not supported // "UseKeyConnector": false, // Not supported "SelfHost": true, - "UseApi": false, // Not supported + "UseApi": true, "HasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "UseResetPassword": CONFIG.mail_enabled(), @@ -212,6 +224,23 @@ impl UserOrganization { } } +impl OrganizationApiKey { + pub fn new(org_uuid: String, api_key: String) -> Self { + Self { + uuid: crate::util::get_uuid(), + + org_uuid, + atype: 0, // Type 0 is the default and only type we support currently + api_key, + revision_date: Utc::now().naive_utc(), + } + } + + pub fn check_valid_api_key(&self, api_key: &str) -> bool { + crate::crypto::ct_eq(&self.api_key, api_key) + } +} + use crate::db::DbConn; use crate::api::EmptyResult; @@ -311,7 +340,7 @@ impl UserOrganization { "UseTotp": true, // "UseScim": false, // Not supported (Not AGPLv3 Licensed) "UsePolicies": true, - "UseApi": false, // Not supported + "UseApi": true, "SelfHost": true, "HasPublicAndPrivateKeys": org.private_key.is_some() && org.public_key.is_some(), "ResetPasswordEnrolled": self.reset_password_key.is_some(), @@ -750,6 +779,50 @@ impl UserOrganization { } } +impl OrganizationApiKey { + pub async fn save(&self, conn: &DbConn) -> EmptyResult { + db_run! { conn: + sqlite, mysql { + match diesel::replace_into(organization_api_key::table) + .values(OrganizationApiKeyDb::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(organization_api_key::table) + .filter(organization_api_key::uuid.eq(&self.uuid)) + .set(OrganizationApiKeyDb::to_db(self)) + .execute(conn) + .map_res("Error saving organization") + } + Err(e) => Err(e.into()), + }.map_res("Error saving organization") + + } + postgresql { + let value = OrganizationApiKeyDb::to_db(self); + diesel::insert_into(organization_api_key::table) + .values(&value) + .on_conflict(organization_api_key::uuid) + .do_update() + .set(&value) + .execute(conn) + .map_res("Error saving organization") + } + } + } + + pub async fn find_by_org_uuid(org_uuid: &str, conn: &DbConn) -> Option { + db_run! { conn: { + organization_api_key::table + .filter(organization_api_key::org_uuid.eq(org_uuid)) + .first::(conn) + .ok().from_db() + }} + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 315f4953..4d58f63f 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -229,6 +229,16 @@ table! { } } +table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -292,6 +302,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(organization_api_key -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); joinable!(groups -> organizations (organizations_uuid)); joinable!(groups_users -> users_organizations (users_organizations_uuid)); @@ -316,6 +327,7 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + organization_api_key, emergency_access, groups, groups_users, diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index c42e8d18..941f51cb 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -229,6 +229,16 @@ table! { } } +table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -292,6 +302,7 @@ joinable!(users_collections -> collections (collection_uuid)); joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); +joinable!(organization_api_key -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); joinable!(groups -> organizations (organizations_uuid)); joinable!(groups_users -> users_organizations (users_organizations_uuid)); @@ -316,6 +327,7 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + organization_api_key, emergency_access, groups, groups_users, diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 402f0a22..ce9c7e33 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -229,6 +229,16 @@ table! { } } +table! { + organization_api_key (uuid, org_uuid) { + uuid -> Text, + org_uuid -> Text, + atype -> Integer, + api_key -> Text, + revision_date -> Timestamp, + } +} + table! { emergency_access (uuid) { uuid -> Text, @@ -293,6 +303,7 @@ joinable!(users_collections -> users (user_uuid)); joinable!(users_organizations -> organizations (org_uuid)); joinable!(users_organizations -> users (user_uuid)); joinable!(users_organizations -> ciphers (org_uuid)); +joinable!(organization_api_key -> organizations (org_uuid)); joinable!(emergency_access -> users (grantor_uuid)); joinable!(groups -> organizations (organizations_uuid)); joinable!(groups_users -> users_organizations (users_organizations_uuid)); @@ -317,6 +328,7 @@ allow_tables_to_appear_in_same_query!( users, users_collections, users_organizations, + organization_api_key, emergency_access, groups, groups_users,