|
|
@ -8,6 +8,8 @@ use rocket::{ |
|
|
serde::json::Json, |
|
|
serde::json::Json, |
|
|
}; |
|
|
}; |
|
|
use serde_json::Value; |
|
|
use serde_json::Value; |
|
|
|
|
|
use webauthn_rs::prelude::{Passkey, PasskeyAuthentication}; |
|
|
|
|
|
use webauthn_rs_proto::{PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy}; |
|
|
|
|
|
|
|
|
use crate::{ |
|
|
use crate::{ |
|
|
CONFIG, |
|
|
CONFIG, |
|
|
@ -17,27 +19,30 @@ use crate::{ |
|
|
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, |
|
|
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register}, |
|
|
log_user_event, |
|
|
log_user_event, |
|
|
two_factor::{ |
|
|
two_factor::{ |
|
|
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, |
|
|
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, |
|
|
|
|
|
webauthn::{self, WEBAUTHN}, |
|
|
yubikey, |
|
|
yubikey, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
master_password_policy, |
|
|
master_password_policy, |
|
|
push::register_push_device, |
|
|
push::register_push_device, |
|
|
}, |
|
|
}, |
|
|
auth, |
|
|
auth::{ |
|
|
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims}, |
|
|
self, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims, |
|
|
|
|
|
generate_passwordless_claims, |
|
|
|
|
|
}, |
|
|
crypto, |
|
|
crypto, |
|
|
db::{ |
|
|
db::{ |
|
|
DbConn, |
|
|
DbConn, |
|
|
models::{ |
|
|
models::{ |
|
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, |
|
|
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, |
|
|
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, |
|
|
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, |
|
|
UserId, |
|
|
UserId, WebauthnCredential, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
error::MapResult, |
|
|
error::MapResult, |
|
|
mail, sso, |
|
|
mail, |
|
|
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, |
|
|
sso::{self, OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, |
|
|
util, |
|
|
util, |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
@ -52,7 +57,8 @@ pub fn routes() -> Vec<Route> { |
|
|
prevalidate, |
|
|
prevalidate, |
|
|
authorize, |
|
|
authorize, |
|
|
oidcsignin, |
|
|
oidcsignin, |
|
|
oidcsignin_error |
|
|
oidcsignin_error, |
|
|
|
|
|
get_webauthn_assertion_options |
|
|
] |
|
|
] |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -108,6 +114,19 @@ async fn login( |
|
|
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await |
|
|
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await |
|
|
} |
|
|
} |
|
|
"authorization_code" => err!("SSO sign-in is not available"), |
|
|
"authorization_code" => err!("SSO sign-in is not available"), |
|
|
|
|
|
"webauthn" => { |
|
|
|
|
|
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; |
|
|
|
|
|
check_is_some(data.scope.as_ref(), "scope cannot be blank")?; |
|
|
|
|
|
|
|
|
|
|
|
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?; |
|
|
|
|
|
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")?; |
|
|
|
|
|
|
|
|
|
|
|
check_is_some(data.device_response.as_ref(), "device_response cannot be blank")?; |
|
|
|
|
|
check_is_some(data.token.as_ref(), "token cannot be blank")?; |
|
|
|
|
|
|
|
|
|
|
|
webauthn_login(data, &mut user_id, &conn, &client_header.ip).await |
|
|
|
|
|
} |
|
|
t => err!("Invalid type", t), |
|
|
t => err!("Invalid type", t), |
|
|
}; |
|
|
}; |
|
|
|
|
|
|
|
|
@ -467,6 +486,164 @@ async fn password_login( |
|
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await |
|
|
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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)?; |
|
|
|
|
|
|
|
|
|
|
|
let device_response: PublicKeyCredential = 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. User not found", |
|
|
|
|
|
format!("IP: {}. Could not find user from device response.", ip.ip), |
|
|
|
|
|
ErrorEvent { |
|
|
|
|
|
event: EventType::UserFailedLogIn |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// Retrieve the username to be used for logging
|
|
|
|
|
|
let username = user.email.clone(); |
|
|
|
|
|
|
|
|
|
|
|
// 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 user_webauthn_credentials: Vec<(WebauthnCredential, Passkey)> = |
|
|
|
|
|
WebauthnCredential::find_all_by_user(&user.uuid, conn) |
|
|
|
|
|
.await |
|
|
|
|
|
.into_iter() |
|
|
|
|
|
.filter_map(|wac| { |
|
|
|
|
|
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?; |
|
|
|
|
|
Some((wac, passkey)) |
|
|
|
|
|
}) |
|
|
|
|
|
.collect(); |
|
|
|
|
|
|
|
|
|
|
|
if user_webauthn_credentials.is_empty() { |
|
|
|
|
|
err!( |
|
|
|
|
|
"No passkey credentials registered for this user.", |
|
|
|
|
|
format!("IP: {}. Username: {username}.", ip.ip), |
|
|
|
|
|
ErrorEvent { |
|
|
|
|
|
event: EventType::UserFailedLogIn |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Unpack the token claims, which holds authentication state
|
|
|
|
|
|
let claims = match auth::decode_passwordless(data.token.as_ref().unwrap()) { |
|
|
|
|
|
Ok(claims) => claims, |
|
|
|
|
|
Err(e) => { |
|
|
|
|
|
err!( |
|
|
|
|
|
"Invalid token", |
|
|
|
|
|
format!("IP: {}. Error: {e:#?}", ip.ip), |
|
|
|
|
|
ErrorEvent { |
|
|
|
|
|
event: EventType::UserFailedLogIn |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// HACK: Inject the credentials into the state, since they were not included at creation time.
|
|
|
|
|
|
let state: PasskeyAuthentication = if let Ok(mut raw_state) = serde_json::to_value(&claims.state) { |
|
|
|
|
|
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 user_webauthn_credentials.iter() { |
|
|
|
|
|
let passkey_owned: Passkey = passkey.clone(); |
|
|
|
|
|
let cred = <webauthn_rs::prelude::Credential>::from(passkey_owned); |
|
|
|
|
|
credentials.push(serde_json::to_value(&cred)?); |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
serde_json::from_value(raw_state)? |
|
|
|
|
|
} else { |
|
|
|
|
|
err!( |
|
|
|
|
|
"Invalid state in token", |
|
|
|
|
|
format!("IP: {}. Could not parse state from token.", ip.ip), |
|
|
|
|
|
ErrorEvent { |
|
|
|
|
|
event: EventType::UserFailedLogIn |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// Perform passkey authentication
|
|
|
|
|
|
let authentication_result = match WEBAUTHN.finish_passkey_authentication(&device_response, &state) { |
|
|
|
|
|
Ok(result) => result, |
|
|
|
|
|
Err(e) => { |
|
|
|
|
|
err!( |
|
|
|
|
|
"Passkey authentication failed.", |
|
|
|
|
|
format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip), |
|
|
|
|
|
ErrorEvent { |
|
|
|
|
|
event: EventType::UserFailedLogIn |
|
|
|
|
|
} |
|
|
|
|
|
) |
|
|
|
|
|
} |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
// Retrieve the matched credential based on the passkey from the authentication result
|
|
|
|
|
|
let (matched_wac, _) = user_webauthn_credentials |
|
|
|
|
|
.iter() |
|
|
|
|
|
.find(|(_, p): &&(WebauthnCredential, Passkey)| { |
|
|
|
|
|
crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice()) |
|
|
|
|
|
}) |
|
|
|
|
|
.unwrap(); |
|
|
|
|
|
|
|
|
|
|
|
// Update the credential in the database if necessary (e.g., counter incremented)
|
|
|
|
|
|
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?; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let mut device = get_device(&data, conn, &user).await?; |
|
|
|
|
|
|
|
|
|
|
|
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Webauthn, data.client_id); |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async fn authenticated_response( |
|
|
async fn authenticated_response( |
|
|
user: &User, |
|
|
user: &User, |
|
|
device: &mut Device, |
|
|
device: &mut Device, |
|
|
@ -1001,7 +1178,8 @@ async fn json_err_twofactor( |
|
|
| TwoFactorType::U2fRegisterChallenge |
|
|
| TwoFactorType::U2fRegisterChallenge |
|
|
| TwoFactorType::Webauthn |
|
|
| TwoFactorType::Webauthn |
|
|
| TwoFactorType::WebauthnLoginChallenge |
|
|
| TwoFactorType::WebauthnLoginChallenge |
|
|
| TwoFactorType::WebauthnRegisterChallenge, |
|
|
| TwoFactorType::WebauthnRegisterChallenge |
|
|
|
|
|
| TwoFactorType::WebauthnPasskeyRegisterChallenge, |
|
|
) => { /* Nothing special to do for these providers */ } |
|
|
) => { /* Nothing special to do for these providers */ } |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
@ -1091,7 +1269,7 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult { |
|
|
struct ConnectData { |
|
|
struct ConnectData { |
|
|
#[field(name = uncased("grant_type"))] |
|
|
#[field(name = uncased("grant_type"))] |
|
|
#[field(name = uncased("granttype"))] |
|
|
#[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"
|
|
|
// Needed for grant_type="refresh_token"
|
|
|
#[field(name = uncased("refresh_token"))] |
|
|
#[field(name = uncased("refresh_token"))] |
|
|
@ -1144,6 +1322,12 @@ struct ConnectData { |
|
|
code: Option<OIDCCode>, |
|
|
code: Option<OIDCCode>, |
|
|
#[field(name = uncased("code_verifier"))] |
|
|
#[field(name = uncased("code_verifier"))] |
|
|
code_verifier: Option<OIDCCodeVerifier>, |
|
|
code_verifier: Option<OIDCCodeVerifier>, |
|
|
|
|
|
|
|
|
|
|
|
// Needed for grant_type="webauthn"
|
|
|
|
|
|
#[field(name = uncased("deviceresponse"))] |
|
|
|
|
|
device_response: Option<String>, |
|
|
|
|
|
#[field(name = uncased("token"))] |
|
|
|
|
|
token: Option<String>, |
|
|
} |
|
|
} |
|
|
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult { |
|
|
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult { |
|
|
if value.is_none() { |
|
|
if value.is_none() { |
|
|
@ -1303,3 +1487,29 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, |
|
|
|
|
|
|
|
|
Ok(Redirect::temporary(String::from(auth_url))) |
|
|
Ok(Redirect::temporary(String::from(auth_url))) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
#[get("/accounts/webauthn/assertion-options")] |
|
|
|
|
|
fn get_webauthn_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, |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// Generate JWT token
|
|
|
|
|
|
let claims = generate_passwordless_claims(state); |
|
|
|
|
|
let token = auth::encode_jwt(&claims); |
|
|
|
|
|
|
|
|
|
|
|
let options = serde_json::to_value(response.public_key)?; |
|
|
|
|
|
|
|
|
|
|
|
Ok(Json(json!({ |
|
|
|
|
|
"options": options, |
|
|
|
|
|
"token": token, |
|
|
|
|
|
"object": "webAuthnLoginAssertionOptions" |
|
|
|
|
|
}))) |
|
|
|
|
|
} |
|
|
|