mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-15 17:45:56 +02:00
Implement V2 registration support (#7765)
* Implement V2 registration support * Require the client version header on every login, like upstream Every user login (password, user API key, SSO, including a first SSO login) now needs the Bitwarden-Client-Version header, for V1 accounts too. V2 accounts need 2026.10.1, or 2026.10.2 for the desktop app, upstream's current minimum. * Escape the email in the client version log line On a first SSO login the email comes from the identity provider and isn't validated yet, so a newline in it could add a line to the log.
This commit is contained in:
1 parent
5ad0c6d41a
commit
d28c8339fb
18 files changed
+838
-113
No files matched your search
+129
-33
@@ -29,7 +29,7 @@ use crate::{
|
||||
db::{
|
||||
DbConn,
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, DeviceType, EventType, Invitation, OIDCCodeResponseError,
|
||||
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
|
||||
TwoFactorType, User, UserId,
|
||||
},
|
||||
@@ -77,14 +77,14 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
password_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
password_login(data, &mut user_id, &conn, &client_header).await
|
||||
}
|
||||
"client_credentials" => {
|
||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
|
||||
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
|
||||
|
||||
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
api_key_login(data, &mut user_id, &conn, &client_header).await
|
||||
}
|
||||
"authorization_code" if CONFIG.sso_enabled() => {
|
||||
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
|
||||
@@ -95,7 +95,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
sso_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
sso_login(data, &mut user_id, &conn, &client_header).await
|
||||
}
|
||||
"authorization_code" => err!("SSO sign-in is not available"),
|
||||
"send_access" => {
|
||||
@@ -179,7 +179,13 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
|
||||
}
|
||||
|
||||
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
async fn sso_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
@@ -317,6 +323,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
||||
}
|
||||
|
||||
let mut user = User::new(&user_infos.email, user_infos.user_name.clone());
|
||||
// Before saving, so that a client that can't log in doesn't create the account
|
||||
check_client_version(&user, client_header)?;
|
||||
user.verified_at = Some(now);
|
||||
user.save(conn).await?;
|
||||
|
||||
@@ -325,6 +333,8 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
||||
(user, device, None, None)
|
||||
}
|
||||
Some((user, _)) if !user.enabled => {
|
||||
// Set in both arms, not only after the match, so that their failed logins are logged
|
||||
*user_id = Some(user.uuid.clone());
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.email),
|
||||
@@ -334,6 +344,9 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
||||
)
|
||||
}
|
||||
Some((mut user, sso_user)) => {
|
||||
*user_id = Some(user.uuid.clone());
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
|
||||
@@ -368,7 +381,13 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
}
|
||||
|
||||
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
async fn password_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
// Validate scope
|
||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||
|
||||
@@ -438,6 +457,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
||||
)
|
||||
}
|
||||
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
// Change the KDF Iterations (only when not logging in with an auth request)
|
||||
if data.auth_request.is_none() {
|
||||
kdf_upgrade(&mut user, password, conn).await?;
|
||||
@@ -499,6 +520,64 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
}
|
||||
|
||||
/// Like upstream's `ClientVersionValidator`, every login needs the `Bitwarden-Client-Version` header, and a V2 account a
|
||||
/// client of at least 2026.10.1 (2026.10.2 for the desktop app). Called right after the credentials are checked, like
|
||||
/// upstream, so that it doesn't tell anyone else which accounts are V2.
|
||||
///
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L33-L79>
|
||||
/// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Core/KeyManagement/Constants.cs#L5-L6>
|
||||
fn check_client_version(user: &User, client_header: &ClientHeaders) -> EmptyResult {
|
||||
let Some((error, description)) =
|
||||
client_version_error(user.is_v2(), client_header.client_version.as_ref(), client_header.device_type)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
Err(crate::error::Error::from((
|
||||
// Escaped: on a first SSO login the email comes from the provider and isn't validated yet
|
||||
format!("IP: {}. Username: {}. {error}", client_header.ip.ip, user.email.escape_debug()),
|
||||
json!({
|
||||
"error": error,
|
||||
"error_description": description,
|
||||
"ErrorModel": { "Message": description, "Object": "error" },
|
||||
}),
|
||||
))
|
||||
.with_event(crate::error::ErrorEvent {
|
||||
event: EventType::UserFailedLogIn,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The error id and message of [`check_client_version`], `None` when the client may log in.
|
||||
fn client_version_error(
|
||||
is_v2: bool,
|
||||
client_version: Option<&semver::Version>,
|
||||
device_type: i32,
|
||||
) -> Option<(&'static str, &'static str)> {
|
||||
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2026, 10, 1);
|
||||
const MIN_V2_DESKTOP_CLIENT: semver::Version = semver::Version::new(2026, 10, 2);
|
||||
|
||||
let Some(version) = client_version else {
|
||||
return Some((
|
||||
"version_header_missing",
|
||||
"No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"",
|
||||
));
|
||||
};
|
||||
if !is_v2 {
|
||||
return None;
|
||||
}
|
||||
// Upstream's desktop device types
|
||||
// Ref: <https://github.com/bitwarden/server/blob/15c94ac58bf361a162381749c146d254a21b2098/src/Libraries/Clients/DeviceTypes.cs#L14-L20>
|
||||
let is_desktop = matches!(
|
||||
DeviceType::from_i32(device_type),
|
||||
DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop | DeviceType::Uwp
|
||||
);
|
||||
let min_version = if is_desktop {
|
||||
&MIN_V2_DESKTOP_CLIENT
|
||||
} else {
|
||||
&MIN_V2_CLIENT
|
||||
};
|
||||
(version < min_version).then_some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
|
||||
}
|
||||
|
||||
async fn authenticated_response(
|
||||
user: &User,
|
||||
device: &mut Device,
|
||||
@@ -552,18 +631,7 @@ async fn authenticated_response(
|
||||
Value::Null
|
||||
};
|
||||
|
||||
let account_keys = if user.private_key.is_some() {
|
||||
json!({
|
||||
"publicKeyEncryptionKeyPair": {
|
||||
"wrappedPrivateKey": user.private_key,
|
||||
"publicKey": user.public_key,
|
||||
"Object": "publicKeyEncryptionKeyPair"
|
||||
},
|
||||
"Object": "privateKeys"
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let account_keys = user.account_keys_json(conn).await;
|
||||
|
||||
let mut result = json!({
|
||||
"access_token": auth_tokens.access_token(),
|
||||
@@ -598,7 +666,13 @@ async fn authenticated_response(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
async fn api_key_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
@@ -610,7 +684,7 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
|
||||
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
|
||||
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
|
||||
|
||||
user_api_key_login(data, user_id, conn, ip).await
|
||||
user_api_key_login(data, user_id, conn, client_header).await
|
||||
}
|
||||
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
|
||||
_ => err!("Scope not supported"),
|
||||
@@ -621,8 +695,9 @@ async fn user_api_key_login(
|
||||
data: ConnectData,
|
||||
user_id: &mut Option<UserId>,
|
||||
conn: &DbConn,
|
||||
ip: &ClientIp,
|
||||
client_header: &ClientHeaders,
|
||||
) -> JsonResult {
|
||||
let ip = &client_header.ip;
|
||||
// Get the user via the client_id
|
||||
let client_id = data.client_id.as_ref().unwrap();
|
||||
let Some(client_user_id) = client_id.strip_prefix("user.") else {
|
||||
@@ -659,6 +734,8 @@ async fn user_api_key_login(
|
||||
)
|
||||
}
|
||||
|
||||
check_client_version(&user, client_header)?;
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && device.is_new() {
|
||||
@@ -709,18 +786,7 @@ async fn user_api_key_login(
|
||||
Value::Null
|
||||
};
|
||||
|
||||
let account_keys = if user.private_key.is_some() {
|
||||
json!({
|
||||
"publicKeyEncryptionKeyPair": {
|
||||
"wrappedPrivateKey": user.private_key,
|
||||
"publicKey": user.public_key,
|
||||
"Object": "publicKeyEncryptionKeyPair"
|
||||
},
|
||||
"Object": "privateKeys"
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
let account_keys = user.account_keys_json(conn).await;
|
||||
|
||||
// Note: No refresh_token is returned. The CLI just repeats the
|
||||
// client_credentials login flow when the existing token expires.
|
||||
@@ -1351,3 +1417,33 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure,
|
||||
|
||||
Ok(Redirect::temporary(String::from(auth_url)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_version_error() {
|
||||
let version = |v| semver::Version::parse(v).unwrap();
|
||||
let web = DeviceType::ChromeBrowser as i32;
|
||||
let desktop = DeviceType::WindowsDesktop as i32;
|
||||
// Every login needs the header
|
||||
assert_eq!(client_version_error(false, None, web).map(|e| e.0), Some("version_header_missing"));
|
||||
assert_eq!(client_version_error(true, None, desktop).map(|e| e.0), Some("version_header_missing"));
|
||||
// V1 accounts log in from any version
|
||||
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), web), None);
|
||||
assert_eq!(client_version_error(false, Some(&version("2024.1.0")), desktop), None);
|
||||
// V2 accounts need 2026.10.1, and 2026.10.2 for the desktop app
|
||||
assert_eq!(
|
||||
client_version_error(true, Some(&version("2026.10.0")), web).map(|e| e.0),
|
||||
Some("invalid_client_version")
|
||||
);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.10.1")), web), None);
|
||||
assert_eq!(
|
||||
client_version_error(true, Some(&version("2026.10.1")), desktop).map(|e| e.0),
|
||||
Some("invalid_client_version")
|
||||
);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.10.2")), DeviceType::Uwp as i32), None);
|
||||
assert_eq!(client_version_error(true, Some(&version("2026.11.0")), desktop), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user