commit 69ee4a70b453b81b96347fb299bf5c14f1038156
parent 8ba6e61fd591e7fe038fb13fd24b0e263c87e88a
Author: Jeremy Lin <jeremy.lin@gmail.com>
Date: Wed, 19 Jan 2022 02:51:26 -0800
Add support for API keys
This is mainly useful for CLI-based login automation.
Diffstat:
13 files changed, 164 insertions(+), 7 deletions(-)
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
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
@@ -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
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
@@ -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
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
@@ -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
@@ -34,6 +34,8 @@ pub fn routes() -> Vec<rocket::Route> {
password_hint,
prelogin,
verify_password,
+ api_key,
+ rotate_api_key,
]
}
@@ -647,15 +649,17 @@ fn prelogin(data: JsonUpcase<PreloginData>, conn: DbConn) -> Json<Value> {
"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 = "<data>")]
-fn verify_password(data: JsonUpcase<VerifyPasswordData>, headers: Headers) -> EmptyResult {
- let data: VerifyPasswordData = data.into_inner().data;
+fn verify_password(data: JsonUpcase<SecretVerificationRequest>, 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<VerifyPasswordData>, headers: Headers) -> Em
Ok(())
}
+
+fn _api_key(data: JsonUpcase<SecretVerificationRequest>, 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 = "<data>")]
+fn api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+ _api_key(data, false, headers, conn)
+}
+
+#[post("/accounts/rotate-api-key", data = "<data>")]
+fn rotate_api_key(data: JsonUpcase<SecretVerificationRequest>, headers: Headers, conn: DbConn) -> JsonResult {
+ _api_key(data, true, headers, conn)
+}
diff --git a/src/api/identity.rs b/src/api/identity.rs
@@ -43,6 +43,13 @@ fn login(data: Form<ConnectData>, 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<String>,
- // Needed for grant_type="password"
- client_id: Option<String>, // web, cli, desktop, browser, mobile
+ // Needed for grant_type = "password" | "client_credentials"
+ client_id: Option<String>, // web, cli, desktop, browser, mobile
+ client_secret: Option<String>, // API key login (cli only)
password: Option<String>,
scope: Option<String>,
username: Option<String>,
@@ -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
@@ -51,6 +51,28 @@ pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
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<String, Error> {
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
@@ -44,8 +44,9 @@ db_object! {
pub client_kdf_type: i32,
pub client_kdf_iter: i32,
- }
+ pub api_key: Option<String>,
+ }
#[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
@@ -178,6 +178,7 @@ table! {
excluded_globals -> Text,
client_kdf_type -> Integer,
client_kdf_iter -> Integer,
+ api_key -> Nullable<Text>,
}
}
diff --git 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<Text>,
}
}
diff --git 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<Text>,
}
}