|
|
|
|
@ -1,3 +1,6 @@
|
|
|
|
|
use std::sync::{Arc, LazyLock};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
|
|
|
|
use chrono::Utc;
|
|
|
|
|
use num_traits::FromPrimitive;
|
|
|
|
|
use rocket::{
|
|
|
|
|
@ -8,7 +11,12 @@ use rocket::{
|
|
|
|
|
Route,
|
|
|
|
|
};
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication};
|
|
|
|
|
use webauthn_rs_proto::{
|
|
|
|
|
AuthenticatorAssertionResponseRaw, PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
use crate::api::core::two_factor::webauthn::WEBAUTHN;
|
|
|
|
|
use crate::{
|
|
|
|
|
api::{
|
|
|
|
|
core::{
|
|
|
|
|
@ -26,6 +34,7 @@ use crate::{
|
|
|
|
|
models::{
|
|
|
|
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
|
|
|
|
|
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
|
|
|
|
WebAuthnCredential,
|
|
|
|
|
},
|
|
|
|
|
DbConn,
|
|
|
|
|
},
|
|
|
|
|
@ -45,7 +54,8 @@ pub fn routes() -> Vec<Route> {
|
|
|
|
|
prevalidate,
|
|
|
|
|
authorize,
|
|
|
|
|
oidcsignin,
|
|
|
|
|
oidcsignin_error
|
|
|
|
|
oidcsignin_error,
|
|
|
|
|
get_web_authn_assertion_options
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -101,6 +111,19 @@ async fn login(
|
|
|
|
|
_sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await
|
|
|
|
|
}
|
|
|
|
|
"authorization_code" => err!("SSO sign-in is not available"),
|
|
|
|
|
"webauthn" => {
|
|
|
|
|
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
|
|
|
|
_check_is_some(&data.scope, "scope cannot be blank")?;
|
|
|
|
|
|
|
|
|
|
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
|
|
|
|
|
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
|
|
|
|
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
|
|
|
|
|
|
|
|
|
_check_is_some(&data.device_response, "device_response cannot be blank")?;
|
|
|
|
|
_check_is_some(&data.token, "token cannot be blank")?;
|
|
|
|
|
|
|
|
|
|
_webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
|
|
|
|
|
}
|
|
|
|
|
t => err!("Invalid type", t),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -981,7 +1004,7 @@ async fn register_verification_email(
|
|
|
|
|
let mut rng = SmallRng::from_os_rng();
|
|
|
|
|
let delta: i32 = 100;
|
|
|
|
|
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
|
|
|
|
} else {
|
|
|
|
|
mail::send_register_verify_email(&data.email, &token).await?;
|
|
|
|
|
}
|
|
|
|
|
@ -999,13 +1022,297 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
|
|
|
|
_register(data, true, conn).await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache for webauthn authentication states, keyed by a random token.
|
|
|
|
|
// Entries expire after 5 minutes (matching the WebAuthn ceremony timeout of 60s with margin).
|
|
|
|
|
// This is used for the discoverable credential (passkey login) flow where we don't know
|
|
|
|
|
// the user until the authenticator response arrives.
|
|
|
|
|
// Wrapped in Arc because PasskeyAuthentication does not implement Clone.
|
|
|
|
|
static WEBAUTHN_AUTHENTICATION_STATES: LazyLock<mini_moka::sync::Cache<String, Arc<PasskeyAuthentication>>> =
|
|
|
|
|
LazyLock::new(|| {
|
|
|
|
|
mini_moka::sync::Cache::builder().max_capacity(10_000).time_to_live(Duration::from_secs(300)).build()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Copied from webauthn-rs to rename clientDataJSON -> clientDataJson for Bitwarden compatibility
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct AssertionResponseCopy {
|
|
|
|
|
pub authenticator_data: Base64UrlSafeData,
|
|
|
|
|
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
|
|
|
|
|
pub client_data_json: Base64UrlSafeData,
|
|
|
|
|
pub signature: Base64UrlSafeData,
|
|
|
|
|
pub user_handle: Option<Base64UrlSafeData>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "camelCase")]
|
|
|
|
|
struct PublicKeyCredentialCopy {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub raw_id: Base64UrlSafeData,
|
|
|
|
|
pub response: AssertionResponseCopy,
|
|
|
|
|
pub r#type: String,
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub extensions: Option<Value>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
|
|
|
|
|
fn from(p: PublicKeyCredentialCopy) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
id: p.id,
|
|
|
|
|
raw_id: p.raw_id,
|
|
|
|
|
response: AuthenticatorAssertionResponseRaw {
|
|
|
|
|
authenticator_data: p.response.authenticator_data,
|
|
|
|
|
client_data_json: p.response.client_data_json,
|
|
|
|
|
signature: p.response.signature,
|
|
|
|
|
user_handle: p.response.user_handle,
|
|
|
|
|
},
|
|
|
|
|
extensions: Default::default(),
|
|
|
|
|
type_: p.r#type,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[get("/accounts/webauthn/assertion-options")]
|
|
|
|
|
fn get_web_authn_assertion_options() -> JsonResult {
|
|
|
|
|
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?;
|
|
|
|
|
|
|
|
|
|
// Allow any credential (discoverable) and require user verification
|
|
|
|
|
response.public_key.allow_credentials = vec![];
|
|
|
|
|
response.public_key.user_verification = UserVerificationPolicy::Required;
|
|
|
|
|
response.public_key.extensions = Some(RequestAuthenticationExtensions {
|
|
|
|
|
appid: None,
|
|
|
|
|
uvm: None,
|
|
|
|
|
hmac_get_secret: None,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let token = util::get_uuid();
|
|
|
|
|
WEBAUTHN_AUTHENTICATION_STATES.insert(token.clone(), Arc::new(state));
|
|
|
|
|
|
|
|
|
|
let options = serde_json::to_value(response.public_key)?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({
|
|
|
|
|
"options": options,
|
|
|
|
|
"token": token,
|
|
|
|
|
"object": "webAuthnLoginAssertionOptions"
|
|
|
|
|
})))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn _webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
|
|
|
|
// Validate scope
|
|
|
|
|
AuthMethod::WebAuthn.check_scope(data.scope.as_ref())?;
|
|
|
|
|
|
|
|
|
|
// Ratelimit the login
|
|
|
|
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
|
|
|
|
|
|
|
|
|
// Parse the device response to get the user handle (user UUID)
|
|
|
|
|
let device_response: PublicKeyCredentialCopy = serde_json::from_str(data.device_response.as_ref().unwrap())?;
|
|
|
|
|
|
|
|
|
|
let user = if let Some(ref uuid_bytes) = device_response.response.user_handle {
|
|
|
|
|
// The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration.
|
|
|
|
|
// We need to reconstruct the UUID string from these bytes.
|
|
|
|
|
let bytes: &[u8] = uuid_bytes.as_ref();
|
|
|
|
|
let uuid_str = uuid::Uuid::from_slice(bytes)
|
|
|
|
|
.map(|u| u.to_string())
|
|
|
|
|
.or_else(|_| {
|
|
|
|
|
// Fallback: try interpreting as UTF-8 string (for compatibility)
|
|
|
|
|
String::from_utf8(bytes.to_vec())
|
|
|
|
|
})
|
|
|
|
|
.map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?;
|
|
|
|
|
let uuid = UserId::from(uuid_str);
|
|
|
|
|
User::find_by_uuid(&uuid, conn).await
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(user) = user else {
|
|
|
|
|
err!(
|
|
|
|
|
"Passkey authentication failed.",
|
|
|
|
|
format!("IP: {}. Could not find user from device response.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let username = user.display_name().to_string();
|
|
|
|
|
|
|
|
|
|
// Set the user_id here to be passed back used for event logging.
|
|
|
|
|
*user_id = Some(user.uuid.clone());
|
|
|
|
|
|
|
|
|
|
// Check if the user is disabled
|
|
|
|
|
if !user.enabled {
|
|
|
|
|
err!(
|
|
|
|
|
"This user has been disabled",
|
|
|
|
|
format!("IP: {}. Username: {username}.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retrieve all webauthn login credentials for this user
|
|
|
|
|
let web_authn_credentials: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await;
|
|
|
|
|
|
|
|
|
|
let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = web_authn_credentials
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|wac| {
|
|
|
|
|
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
|
|
|
|
|
Some((wac, passkey))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if parsed_credentials.is_empty() {
|
|
|
|
|
err!(
|
|
|
|
|
"No passkey credentials registered for this user.",
|
|
|
|
|
format!("IP: {}. Username: {username}.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Retrieve and consume the saved authentication state (one-time use)
|
|
|
|
|
let token = data.token.as_ref().unwrap();
|
|
|
|
|
let state = WEBAUTHN_AUTHENTICATION_STATES.get(token);
|
|
|
|
|
// Invalidate immediately to prevent replay
|
|
|
|
|
WEBAUTHN_AUTHENTICATION_STATES.invalidate(token);
|
|
|
|
|
debug!(
|
|
|
|
|
"WebAuthn login: found {} credentials for user, state present: {}",
|
|
|
|
|
parsed_credentials.len(),
|
|
|
|
|
state.is_some()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let Some(state_arc) = state else {
|
|
|
|
|
err!(
|
|
|
|
|
"Passkey authentication failed. Please try again.",
|
|
|
|
|
format!("IP: {}. Username: {username}. Missing authentication state.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Inject the user's credentials into the state so the library can verify against them.
|
|
|
|
|
// We serialize the state to JSON, inject the user's credentials, then deserialize back.
|
|
|
|
|
// This is necessary because for discoverable credentials (passkey login), the initial
|
|
|
|
|
// assertion was created without knowing which user will authenticate, so the state has
|
|
|
|
|
// no credentials to verify against. This is the same pattern used by
|
|
|
|
|
// check_and_update_backup_eligible() in two_factor/webauthn.rs.
|
|
|
|
|
let passkeys: Vec<Passkey> =
|
|
|
|
|
parsed_credentials.iter().map(|(_, p): &(WebAuthnCredential, Passkey)| p.clone()).collect();
|
|
|
|
|
|
|
|
|
|
let mut raw_state = serde_json::to_value(&*state_arc)?;
|
|
|
|
|
if let Some(credentials) =
|
|
|
|
|
raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut())
|
|
|
|
|
{
|
|
|
|
|
credentials.clear();
|
|
|
|
|
for passkey in &passkeys {
|
|
|
|
|
let passkey_owned: Passkey = passkey.clone();
|
|
|
|
|
let cred = <webauthn_rs::prelude::Credential>::from(passkey_owned);
|
|
|
|
|
credentials.push(serde_json::to_value(&cred)?);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let state: PasskeyAuthentication = serde_json::from_value(raw_state).map_err(|e| {
|
|
|
|
|
error!("Failed to deserialize PasskeyAuthentication state after credential injection: {e:?}");
|
|
|
|
|
e
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let rsp: PublicKeyCredential = device_response.into();
|
|
|
|
|
let authentication_result = match WEBAUTHN.finish_passkey_authentication(&rsp, &state) {
|
|
|
|
|
Ok(result) => result,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
err!(
|
|
|
|
|
"Passkey authentication failed.",
|
|
|
|
|
format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Find the matching credential and update its counter
|
|
|
|
|
let matched_wac = parsed_credentials.iter().find(|(_, p): &&(WebAuthnCredential, Passkey)| {
|
|
|
|
|
crate::crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice())
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let matched_wac = match matched_wac {
|
|
|
|
|
Some((wac, _)) => wac,
|
|
|
|
|
None => {
|
|
|
|
|
err!(
|
|
|
|
|
"Passkey authentication failed.",
|
|
|
|
|
format!("IP: {}. Username: {username}. Credential not found.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update the credential counter
|
|
|
|
|
let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?;
|
|
|
|
|
if passkey.update_credential(&authentication_result) == Some(true) {
|
|
|
|
|
WebAuthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Email verification check
|
|
|
|
|
let now = Utc::now().naive_utc();
|
|
|
|
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
|
|
|
|
if user.last_verifying_at.is_none()
|
|
|
|
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|
|
|
|
> CONFIG.signups_verify_resend_time() as i64
|
|
|
|
|
{
|
|
|
|
|
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
|
|
|
|
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
|
|
|
|
let mut user = user;
|
|
|
|
|
user.last_verifying_at = Some(now);
|
|
|
|
|
user.login_verify_count += 1;
|
|
|
|
|
|
|
|
|
|
if let Err(e) = user.save(conn).await {
|
|
|
|
|
error!("Error updating user: {e:#?}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await {
|
|
|
|
|
error!("Error auto-sending email verification email: {e:#?}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err!(
|
|
|
|
|
"Please verify your email before trying again.",
|
|
|
|
|
format!("IP: {}. Username: {username}.", ip.ip),
|
|
|
|
|
ErrorEvent {
|
|
|
|
|
event: EventType::UserFailedLogIn
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut device = get_device(&data, conn, &user).await?;
|
|
|
|
|
|
|
|
|
|
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id);
|
|
|
|
|
|
|
|
|
|
// Build response using the common authenticated_response helper
|
|
|
|
|
let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?;
|
|
|
|
|
|
|
|
|
|
// Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption)
|
|
|
|
|
if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() {
|
|
|
|
|
let Json(ref mut val) = result;
|
|
|
|
|
val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
|
|
|
|
|
"EncryptedPrivateKey": matched_wac.encrypted_private_key,
|
|
|
|
|
"EncryptedUserKey": matched_wac.encrypted_user_key,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, FromForm)]
|
|
|
|
|
struct ConnectData {
|
|
|
|
|
#[field(name = uncased("grant_type"))]
|
|
|
|
|
#[field(name = uncased("granttype"))]
|
|
|
|
|
grant_type: String, // refresh_token, password, client_credentials (API key)
|
|
|
|
|
grant_type: String, // refresh_token, password, client_credentials (API key), webauthn
|
|
|
|
|
|
|
|
|
|
// Needed for grant_type="refresh_token"
|
|
|
|
|
#[field(name = uncased("refresh_token"))]
|
|
|
|
|
@ -1058,6 +1365,13 @@ struct ConnectData {
|
|
|
|
|
code: Option<OIDCState>,
|
|
|
|
|
#[field(name = uncased("code_verifier"))]
|
|
|
|
|
code_verifier: Option<OIDCCodeVerifier>,
|
|
|
|
|
|
|
|
|
|
// Needed for grant_type = "webauthn"
|
|
|
|
|
#[field(name = uncased("deviceresponse"))]
|
|
|
|
|
device_response: Option<String>,
|
|
|
|
|
// Token identifying the webauthn authentication state
|
|
|
|
|
#[field(name = uncased("token"))]
|
|
|
|
|
token: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
|
|
|
|
if value.is_none() {
|
|
|
|
|
|