Implement V2 registration support

This commit is contained in:
Daniel García committed 2026-11-02 17:19:09 +01:00
1 parent 1fd8d8959c
commit 592f9ea0fc
18 files changed
+808 -111

No files matched your search

+99 -31
View File
@@ -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.ip, client_header.client_version.as_ref()).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.ip, client_header.client_version.as_ref()).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.ip, client_header.client_version.as_ref()).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,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login
@@ -325,6 +331,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 +342,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_version, ip)?;
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
@@ -368,7 +379,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,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
// Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -438,6 +455,8 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
)
}
check_client_version(&user, client_version, ip)?;
// 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 +518,47 @@ 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`, a V2 account logs in only from a client that sends its version and is at
/// least 2025.11.0 (upstream also refuses a missing header for V1 accounts). 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/2028f0a5cc9d8ab8ff8475029085f29f81483598/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs#L42-L69>
fn check_client_version(user: &User, client_version: Option<&semver::Version>, ip: &ClientIp) -> EmptyResult {
let Some((error, description)) = client_version_error(user.is_v2(), client_version) else {
return Ok(());
};
Err(crate::error::Error::from((
format!("IP: {}. Username: {}. {error}", ip.ip, user.email),
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>) -> Option<(&'static str, &'static str)> {
const MIN_V2_CLIENT: semver::Version = semver::Version::new(2025, 11, 0);
if !is_v2 {
return None;
}
match client_version {
None => 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\"",
)),
Some(version) if *version < MIN_V2_CLIENT => {
Some(("invalid_client_version", "Please update your app to continue using Bitwarden"))
}
Some(_) => None,
}
}
async fn authenticated_response(
user: &User,
device: &mut Device,
@@ -552,18 +612,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 +647,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,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
// Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?;
@@ -610,7 +665,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, ip, client_version).await
}
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"),
@@ -622,6 +677,7 @@ async fn user_api_key_login(
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&semver::Version>,
) -> JsonResult {
// Get the user via the client_id
let client_id = data.client_id.as_ref().unwrap();
@@ -659,6 +715,8 @@ async fn user_api_key_login(
)
}
check_client_version(&user, client_version, ip)?;
let mut device = get_device(&data, conn, &user).await?;
if CONFIG.mail_enabled() && device.is_new() {
@@ -709,18 +767,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 +1398,24 @@ 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();
// V1 accounts aren't checked, with or without a version
assert_eq!(client_version_error(false, None), None);
assert_eq!(client_version_error(false, Some(&version("2024.1.0"))), None);
// V2 accounts need the header and 2025.11.0 or newer
assert_eq!(client_version_error(true, None).map(|e| e.0), Some("version_header_missing"));
assert_eq!(
client_version_error(true, Some(&version("2025.10.3"))).map(|e| e.0),
Some("invalid_client_version")
);
assert_eq!(client_version_error(true, Some(&version("2025.11.0"))), None);
assert_eq!(client_version_error(true, Some(&version("2026.10.1"))), None);
}
}