From 69ee4a70b453b81b96347fb299bf5c14f1038156 Mon Sep 17 00:00:00 2001 From: Jeremy Lin Date: Wed, 19 Jan 2022 02:51:26 -0800 Subject: [PATCH 1/2] Add support for API keys This is mainly useful for CLI-based login automation. --- .../2022-01-17-234911_add_api_key/down.sql | 0 .../2022-01-17-234911_add_api_key/up.sql | 2 + .../2022-01-17-234911_add_api_key/down.sql | 0 .../2022-01-17-234911_add_api_key/up.sql | 2 + .../2022-01-17-234911_add_api_key/down.sql | 0 .../2022-01-17-234911_add_api_key/up.sql | 2 + src/api/core/accounts.rs | 39 ++++++++- src/api/identity.rs | 86 ++++++++++++++++++- src/crypto.rs | 28 ++++++ src/db/models/user.rs | 9 +- src/db/schemas/mysql/schema.rs | 1 + src/db/schemas/postgresql/schema.rs | 1 + src/db/schemas/sqlite/schema.rs | 1 + 13 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 migrations/mysql/2022-01-17-234911_add_api_key/down.sql create mode 100644 migrations/mysql/2022-01-17-234911_add_api_key/up.sql create mode 100644 migrations/postgresql/2022-01-17-234911_add_api_key/down.sql create mode 100644 migrations/postgresql/2022-01-17-234911_add_api_key/up.sql create mode 100644 migrations/sqlite/2022-01-17-234911_add_api_key/down.sql create mode 100644 migrations/sqlite/2022-01-17-234911_add_api_key/up.sql diff --git a/migrations/mysql/2022-01-17-234911_add_api_key/down.sql b/migrations/mysql/2022-01-17-234911_add_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2022-01-17-234911_add_api_key/up.sql b/migrations/mysql/2022-01-17-234911_add_api_key/up.sql new file mode 100644 index 00000000..85ac377b --- /dev/null +++ b/migrations/mysql/2022-01-17-234911_add_api_key/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN api_key VARCHAR(255); diff --git a/migrations/postgresql/2022-01-17-234911_add_api_key/down.sql b/migrations/postgresql/2022-01-17-234911_add_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql b/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql new file mode 100644 index 00000000..b1117556 --- /dev/null +++ b/migrations/postgresql/2022-01-17-234911_add_api_key/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN api_key TEXT; diff --git a/migrations/sqlite/2022-01-17-234911_add_api_key/down.sql b/migrations/sqlite/2022-01-17-234911_add_api_key/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql b/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql new file mode 100644 index 00000000..b1117556 --- /dev/null +++ b/migrations/sqlite/2022-01-17-234911_add_api_key/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN api_key TEXT; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index e3ebcde0..1b16ae64 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -34,6 +34,8 @@ pub fn routes() -> Vec { password_hint, prelogin, verify_password, + api_key, + rotate_api_key, ] } @@ -647,15 +649,17 @@ fn prelogin(data: JsonUpcase, conn: DbConn) -> Json { "KdfIterations": kdf_iter })) } + +// https://github.com/bitwarden/server/blob/master/src/Api/Models/Request/Accounts/SecretVerificationRequestModel.cs #[derive(Deserialize)] #[allow(non_snake_case)] -struct VerifyPasswordData { +struct SecretVerificationRequest { MasterPasswordHash: String, } #[post("/accounts/verify-password", data = "")] -fn verify_password(data: JsonUpcase, headers: Headers) -> EmptyResult { - let data: VerifyPasswordData = data.into_inner().data; +fn verify_password(data: JsonUpcase, headers: Headers) -> EmptyResult { + let data: SecretVerificationRequest = data.into_inner().data; let user = headers.user; if !user.check_valid_password(&data.MasterPasswordHash) { @@ -664,3 +668,32 @@ fn verify_password(data: JsonUpcase, headers: Headers) -> Em Ok(()) } + +fn _api_key(data: JsonUpcase, rotate: bool, headers: Headers, conn: DbConn) -> JsonResult { + let data: SecretVerificationRequest = data.into_inner().data; + let mut user = headers.user; + + if !user.check_valid_password(&data.MasterPasswordHash) { + err!("Invalid password") + } + + if rotate || user.api_key.is_none() { + user.api_key = Some(crypto::generate_api_key()); + user.save(&conn).expect("Error saving API key"); + } + + Ok(Json(json!({ + "ApiKey": user.api_key, + "Object": "apiKey", + }))) +} + +#[post("/accounts/api-key", data = "")] +fn api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { + _api_key(data, false, headers, conn) +} + +#[post("/accounts/rotate-api-key", data = "")] +fn rotate_api_key(data: JsonUpcase, headers: Headers, conn: DbConn) -> JsonResult { + _api_key(data, true, headers, conn) +} diff --git a/src/api/identity.rs b/src/api/identity.rs index 3cb26ba3..49310930 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -43,6 +43,13 @@ fn login(data: Form, conn: DbConn, ip: ClientIp) -> JsonResult { _password_login(data, conn, &ip) } + "client_credentials" => { + _check_is_some(&data.client_id, "client_id cannot be blank")?; + _check_is_some(&data.client_secret, "client_secret cannot be blank")?; + _check_is_some(&data.scope, "scope cannot be blank")?; + + _api_key_login(data, conn, &ip) + } t => err!("Invalid type", t), } } @@ -178,6 +185,75 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult Ok(Json(result)) } +fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult { + // Validate scope + let scope = data.scope.as_ref().unwrap(); + if scope != "api" { + err!("Scope not supported") + } + + // Ratelimit the login + crate::ratelimit::check_limit_login(&ip.ip)?; + + // Get the user via the client_id + let client_id = data.client_id.as_ref().unwrap(); + let user_uuid = match client_id.strip_prefix("user.") { + Some(uuid) => uuid, + None => err!("Malformed client_id", format!("IP: {}.", ip.ip)), + }; + let user = match User::find_by_uuid(user_uuid, &conn) { + Some(user) => user, + None => err!("Invalid client_id", format!("IP: {}.", ip.ip)), + }; + + // Check if the user is disabled + if !user.enabled { + err!("This user has been disabled (API key login)", format!("IP: {}. Username: {}.", ip.ip, user.email)) + } + + // Check API key. Note that API key logins bypass 2FA. + let client_secret = data.client_secret.as_ref().unwrap(); + if !user.check_valid_api_key(client_secret) { + err!("Incorrect client_secret", format!("IP: {}. Username: {}.", ip.ip, user.email)) + } + + let (mut device, new_device) = get_device(&data, &conn, &user); + + if CONFIG.mail_enabled() && new_device { + let now = Utc::now().naive_utc(); + if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name) { + error!("Error sending new device email: {:#?}", e); + + if CONFIG.require_device_email() { + err!("Could not send login notification email. Please contact your administrator.") + } + } + } + + // Common + let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn); + + let (access_token, expires_in) = device.refresh_tokens(&user, orgs); + device.save(&conn)?; + + info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); + + Ok(Json(json!({ + "access_token": access_token, + "expires_in": expires_in, + "token_type": "Bearer", + "refresh_token": device.refresh_token, + "Key": user.akey, + "PrivateKey": user.private_key, + + "Kdf": user.client_kdf_type, + "KdfIterations": user.client_kdf_iter, + "ResetMasterPassword": false, // TODO: Same as above + "scope": "api", + "unofficialServer": true, + }))) +} + /// Retrieves an existing device or creates a new device from ConnectData and the User fn get_device(data: &ConnectData, conn: &DbConn, user: &User) -> (Device, bool) { // On iOS, device_type sends "iOS", on others it sends a number @@ -374,17 +450,20 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api Ok(result) } +// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts // https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs #[derive(Debug, Clone, Default)] #[allow(non_snake_case)] struct ConnectData { - grant_type: String, // refresh_token, password + // refresh_token, password, client_credentials (API key) + grant_type: String, // Needed for grant_type="refresh_token" refresh_token: Option, - // Needed for grant_type="password" - client_id: Option, // web, cli, desktop, browser, mobile + // Needed for grant_type = "password" | "client_credentials" + client_id: Option, // web, cli, desktop, browser, mobile + client_secret: Option, // API key login (cli only) password: Option, scope: Option, username: Option, @@ -414,6 +493,7 @@ impl<'f> FromForm<'f> for ConnectData { "granttype" => form.grant_type = value, "refreshtoken" => form.refresh_token = Some(value), "clientid" => form.client_id = Some(value), + "clientsecret" => form.client_secret = Some(value), "password" => form.password = Some(value), "scope" => form.scope = Some(value), "username" => form.username = Some(value), diff --git a/src/crypto.rs b/src/crypto.rs index 61e55649..e30439fc 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -51,6 +51,28 @@ pub fn get_random(mut array: Vec) -> Vec { array } +/// Generates a random string over a specified alphabet. +pub fn get_random_string(alphabet: &[u8], num_chars: usize) -> String { + // Ref: https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html + use rand::Rng; + let mut rng = rand::thread_rng(); + + (0..num_chars) + .map(|_| { + let i = rng.gen_range(0..alphabet.len()); + alphabet[i] as char + }) + .collect() +} + +/// Generates a random alphanumeric string. +pub fn get_random_string_alphanum(num_chars: usize) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789"; + get_random_string(ALPHABET, num_chars) +} + pub fn generate_id(num_bytes: usize) -> String { HEXLOWER.encode(&get_random(vec![0; num_bytes])) } @@ -84,6 +106,12 @@ pub fn generate_token(token_size: u32) -> Result { Ok(token) } +/// Generates a personal API key. +/// Upstream uses 30 chars, which is ~178 bits of entropy. +pub fn generate_api_key() -> String { + get_random_string_alphanum(30) +} + // // Constant time compare // diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 0197535b..599661e5 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -44,8 +44,9 @@ db_object! { pub client_kdf_type: i32, pub client_kdf_iter: i32, - } + pub api_key: Option, + } #[derive(Identifiable, Queryable, Insertable)] #[table_name = "invitations"] @@ -110,6 +111,8 @@ impl User { client_kdf_type: Self::CLIENT_KDF_TYPE_DEFAULT, client_kdf_iter: Self::CLIENT_KDF_ITER_DEFAULT, + + api_key: None, } } @@ -130,6 +133,10 @@ impl User { } } + pub fn check_valid_api_key(&self, key: &str) -> bool { + matches!(self.api_key, Some(ref api_key) if crate::crypto::ct_eq(api_key, key)) + } + /// Set the password hash generated /// And resets the security_stamp. Based upon the allow_next_route the security_stamp will be different. /// diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs index 8bfeae4c..61234a16 100644 --- a/src/db/schemas/mysql/schema.rs +++ b/src/db/schemas/mysql/schema.rs @@ -178,6 +178,7 @@ table! { excluded_globals -> Text, client_kdf_type -> Integer, client_kdf_iter -> Integer, + api_key -> Nullable, } } diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs index 06939ab6..855b4fbc 100644 --- a/src/db/schemas/postgresql/schema.rs +++ b/src/db/schemas/postgresql/schema.rs @@ -178,6 +178,7 @@ table! { excluded_globals -> Text, client_kdf_type -> Integer, client_kdf_iter -> Integer, + api_key -> Nullable, } } diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs index 06939ab6..855b4fbc 100644 --- a/src/db/schemas/sqlite/schema.rs +++ b/src/db/schemas/sqlite/schema.rs @@ -178,6 +178,7 @@ table! { excluded_globals -> Text, client_kdf_type -> Integer, client_kdf_iter -> Integer, + api_key -> Nullable, } } From 8f7900759fdd2f2578461ac8aa38bbcd854a6b0f Mon Sep 17 00:00:00 2001 From: Jeremy Lin Date: Thu, 20 Jan 2022 21:50:58 -0800 Subject: [PATCH 2/2] Fix `scope` and `refresh_token` for API key logins API key logins use a scope of `api`, not `api offline_access`. Since `offline_access` is not requested, no `refresh_token` is returned either. --- src/api/identity.rs | 27 +++++++++++++++------------ src/db/models/device.rs | 9 +++++++-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index 49310930..0adc542f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -61,13 +61,15 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult { // Get device by refresh token let mut device = Device::find_by_refresh_token(&token, &conn).map_res("Invalid refresh token")?; - // COMMON + let scope = "api offline_access"; + let scope_vec = vec!["api".into(), "offline_access".into()]; + + // Common let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap(); let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn); - - let (access_token, expires_in) = device.refresh_tokens(&user, orgs); - + let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); device.save(&conn)?; + Ok(Json(json!({ "access_token": access_token, "expires_in": expires_in, @@ -79,7 +81,7 @@ fn _refresh_login(data: ConnectData, conn: DbConn) -> JsonResult { "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing - "scope": "api offline_access", + "scope": scope, "unofficialServer": true, }))) } @@ -90,6 +92,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult if scope != "api offline_access" { err!("Scope not supported") } + let scope_vec = vec!["api".into(), "offline_access".into()]; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -157,8 +160,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult // Common let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn); - - let (access_token, expires_in) = device.refresh_tokens(&user, orgs); + let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); device.save(&conn)?; let mut result = json!({ @@ -173,7 +175,7 @@ fn _password_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "ResetMasterPassword": false,// TODO: Same as above - "scope": "api offline_access", + "scope": scope, "unofficialServer": true, }); @@ -191,6 +193,7 @@ fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult if scope != "api" { err!("Scope not supported") } + let scope_vec = vec!["api".into()]; // Ratelimit the login crate::ratelimit::check_limit_login(&ip.ip)?; @@ -232,24 +235,24 @@ fn _api_key_login(data: ConnectData, conn: DbConn, ip: &ClientIp) -> JsonResult // Common let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, &conn); - - let (access_token, expires_in) = device.refresh_tokens(&user, orgs); + let (access_token, expires_in) = device.refresh_tokens(&user, orgs, scope_vec); device.save(&conn)?; info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); + // Note: No refresh_token is returned. The CLI just repeats the + // client_credentials login flow when the existing token expires. Ok(Json(json!({ "access_token": access_token, "expires_in": expires_in, "token_type": "Bearer", - "refresh_token": device.refresh_token, "Key": user.akey, "PrivateKey": user.private_key, "Kdf": user.client_kdf_type, "KdfIterations": user.client_kdf_iter, "ResetMasterPassword": false, // TODO: Same as above - "scope": "api", + "scope": scope, "unofficialServer": true, }))) } diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 2fbdea01..05955c04 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -60,7 +60,12 @@ impl Device { self.twofactor_remember = None; } - pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec) -> (String, i64) { + pub fn refresh_tokens( + &mut self, + user: &super::User, + orgs: Vec, + scope: Vec, + ) -> (String, i64) { // If there is no refresh token, we create one if self.refresh_token.is_empty() { use crate::crypto; @@ -98,7 +103,7 @@ impl Device { sstamp: user.security_stamp.to_string(), device: self.uuid.to_string(), - scope: vec!["api".into(), "offline_access".into()], + scope, amr: vec!["Application".into()], };