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.
This commit is contained in:
BlackDex 2023-06-02 21:36:15 +02:00
parent bd883de70e
commit 4219249e11
No known key found for this signature in database
GPG key ID: 58C80A2AA6C765E1
15 changed files with 272 additions and 18 deletions

View file

@ -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)
);

View file

@ -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)
);

View file

@ -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)
);

View file

@ -93,7 +93,9 @@ pub fn routes() -> Vec<Route> {
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<PasswordData>,
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/<org_id>/api-key", data = "<data>")]
async fn api_key(org_id: String, data: JsonUpcase<PasswordData>, headers: AdminHeaders, conn: DbConn) -> JsonResult {
_api_key(org_id, data, false, headers, conn).await
}
#[post("/organizations/<org_id>/rotate-api-key", data = "<data>")]
async fn rotate_api_key(
org_id: String,
data: JsonUpcase<PasswordData>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
_api_key(org_id, data, true, headers, conn).await
}

View file

@ -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<String>,
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

View file

@ -23,6 +23,7 @@ static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFI
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
static JWT_SEND_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|send", CONFIG.domain_origin()));
static JWT_ORG_API_KEY_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|api.organization", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: Lazy<EncodingKey> = 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<String>,
}
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

View file

@ -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);
}

View file

@ -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;

View file

@ -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<String>,
}
#[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<Self> {
db_run! { conn: {
organization_api_key::table
.filter(organization_api_key::org_uuid.eq(org_uuid))
.first::<OrganizationApiKeyDb>(conn)
.ok().from_db()
}}
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -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,

View file

@ -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,

View file

@ -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,