mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-05 05:38:36 +02:00
Implement V2 registration support
This commit is contained in:
18 files changed
+808
-111
No files matched your search
+99
-31
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user