mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-29 09:20:01 +02:00 
			
		
		
		
	Merge branch 'jjlin-api-key' into main
This commit is contained in:
		
							
								
								
									
										2
									
								
								migrations/mysql/2022-01-17-234911_add_api_key/up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/mysql/2022-01-17-234911_add_api_key/up.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE users | ||||
| ADD COLUMN api_key VARCHAR(255); | ||||
| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE users | ||||
| ADD COLUMN api_key TEXT; | ||||
							
								
								
									
										2
									
								
								migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								migrations/sqlite/2022-01-17-234911_add_api_key/up.sql
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| ALTER TABLE users | ||||
| ADD COLUMN api_key TEXT; | ||||
| @@ -34,6 +34,8 @@ pub fn routes() -> Vec<rocket::Route> { | ||||
|         password_hint, | ||||
|         prelogin, | ||||
|         verify_password, | ||||
|         api_key, | ||||
|         rotate_api_key, | ||||
|     ] | ||||
| } | ||||
|  | ||||
| @@ -644,15 +646,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) { | ||||
| @@ -661,3 +665,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) | ||||
| } | ||||
|   | ||||
| @@ -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), | ||||
|     } | ||||
| } | ||||
| @@ -54,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, | ||||
| @@ -72,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, | ||||
|     }))) | ||||
| } | ||||
| @@ -83,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)?; | ||||
| @@ -150,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!({ | ||||
| @@ -166,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, | ||||
|     }); | ||||
|  | ||||
| @@ -178,6 +187,76 @@ 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") | ||||
|     } | ||||
|     let scope_vec = vec!["api".into()]; | ||||
|  | ||||
|     // 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, 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", | ||||
|         "Key": user.akey, | ||||
|         "PrivateKey": user.private_key, | ||||
|  | ||||
|         "Kdf": user.client_kdf_type, | ||||
|         "KdfIterations": user.client_kdf_iter, | ||||
|         "ResetMasterPassword": false, // TODO: Same as above | ||||
|         "scope": scope, | ||||
|         "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 +453,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" | ||||
|     // 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 +496,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), | ||||
|   | ||||
| @@ -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 | ||||
| // | ||||
|   | ||||
| @@ -60,7 +60,12 @@ impl Device { | ||||
|         self.twofactor_remember = None; | ||||
|     } | ||||
|  | ||||
|     pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec<super::UserOrganization>) -> (String, i64) { | ||||
|     pub fn refresh_tokens( | ||||
|         &mut self, | ||||
|         user: &super::User, | ||||
|         orgs: Vec<super::UserOrganization>, | ||||
|         scope: Vec<String>, | ||||
|     ) -> (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()], | ||||
|         }; | ||||
|  | ||||
|   | ||||
| @@ -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. | ||||
|     /// | ||||
|   | ||||
| @@ -178,6 +178,7 @@ table! { | ||||
|         excluded_globals -> Text, | ||||
|         client_kdf_type -> Integer, | ||||
|         client_kdf_iter -> Integer, | ||||
|         api_key -> Nullable<Text>, | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -178,6 +178,7 @@ table! { | ||||
|         excluded_globals -> Text, | ||||
|         client_kdf_type -> Integer, | ||||
|         client_kdf_iter -> Integer, | ||||
|         api_key -> Nullable<Text>, | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -178,6 +178,7 @@ table! { | ||||
|         excluded_globals -> Text, | ||||
|         client_kdf_type -> Integer, | ||||
|         client_kdf_iter -> Integer, | ||||
|         api_key -> Nullable<Text>, | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user