mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-31 05:41:13 +00:00 
			
		
		
		
	Add support for API keys
This is mainly useful for CLI-based login automation.
This commit is contained in:
		
					parent
					
						
							
								8ba6e61fd5
							
						
					
				
			
			
				commit
				
					
						69ee4a70b4
					
				
			
		
					 13 changed files with 164 additions and 7 deletions
				
			
		
							
								
								
									
										0
									
								
								migrations/mysql/2022-01-17-234911_add_api_key/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								migrations/mysql/2022-01-17-234911_add_api_key/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										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; | ||||
							
								
								
									
										0
									
								
								migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								migrations/sqlite/2022-01-17-234911_add_api_key/down.sql
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										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, | ||||
|     ] | ||||
| } | ||||
| 
 | ||||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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
 | ||||
| //
 | ||||
|  |  | |||
|  | @ -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>, | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue