Daniel García
4 years ago
18 changed files with 654 additions and 126 deletions
@ -0,0 +1,394 @@ |
|||||
|
use rocket::Route; |
||||
|
use rocket_contrib::json::Json; |
||||
|
use serde_json::Value; |
||||
|
use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState, RegistrationState, Webauthn}; |
||||
|
|
||||
|
use crate::{ |
||||
|
api::{ |
||||
|
core::two_factor::_generate_recover_code, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData, |
||||
|
}, |
||||
|
auth::Headers, |
||||
|
db::{ |
||||
|
models::{TwoFactor, TwoFactorType}, |
||||
|
DbConn, |
||||
|
}, |
||||
|
error::Error, |
||||
|
CONFIG, |
||||
|
}; |
||||
|
|
||||
|
pub fn routes() -> Vec<Route> { |
||||
|
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] |
||||
|
} |
||||
|
|
||||
|
struct WebauthnConfig { |
||||
|
url: String, |
||||
|
rpid: String, |
||||
|
} |
||||
|
|
||||
|
impl WebauthnConfig { |
||||
|
fn load() -> Webauthn<Self> { |
||||
|
let domain = CONFIG.domain(); |
||||
|
Webauthn::new(Self { |
||||
|
rpid: reqwest::Url::parse(&domain) |
||||
|
.map(|u| u.domain().map(str::to_owned)) |
||||
|
.ok() |
||||
|
.flatten() |
||||
|
.unwrap_or_default(), |
||||
|
url: domain, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
impl webauthn_rs::WebauthnConfig for WebauthnConfig { |
||||
|
fn get_relying_party_name(&self) -> &str { |
||||
|
&self.url |
||||
|
} |
||||
|
|
||||
|
fn get_origin(&self) -> &str { |
||||
|
&self.url |
||||
|
} |
||||
|
|
||||
|
fn get_relying_party_id(&self) -> &str { |
||||
|
&self.rpid |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
impl webauthn_rs::WebauthnConfig for &WebauthnConfig { |
||||
|
fn get_relying_party_name(&self) -> &str { |
||||
|
&self.url |
||||
|
} |
||||
|
|
||||
|
fn get_origin(&self) -> &str { |
||||
|
&self.url |
||||
|
} |
||||
|
|
||||
|
fn get_relying_party_id(&self) -> &str { |
||||
|
&self.rpid |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Serialize, Deserialize)] |
||||
|
pub struct WebauthnRegistration { |
||||
|
pub id: i32, |
||||
|
pub name: String, |
||||
|
pub migrated: bool, |
||||
|
|
||||
|
pub credential: Credential, |
||||
|
} |
||||
|
|
||||
|
impl WebauthnRegistration { |
||||
|
fn to_json(&self) -> Value { |
||||
|
json!({ |
||||
|
"Id": self.id, |
||||
|
"Name": self.name, |
||||
|
"migrated": self.migrated, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[post("/two-factor/get-webauthn", data = "<data>")] |
||||
|
fn get_webauthn(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
if !CONFIG.domain_set() { |
||||
|
err!("`DOMAIN` environment variable is not set. Webauthn disabled") |
||||
|
} |
||||
|
|
||||
|
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { |
||||
|
err!("Invalid password"); |
||||
|
} |
||||
|
|
||||
|
let (enabled, registrations) = get_webauthn_registrations(&headers.user.uuid, &conn)?; |
||||
|
let registrations_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect(); |
||||
|
|
||||
|
Ok(Json(json!({ |
||||
|
"Enabled": enabled, |
||||
|
"Keys": registrations_json, |
||||
|
"Object": "twoFactorWebAuthn" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
#[post("/two-factor/get-webauthn-challenge", data = "<data>")] |
||||
|
fn generate_webauthn_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { |
||||
|
err!("Invalid password"); |
||||
|
} |
||||
|
|
||||
|
let registrations = get_webauthn_registrations(&headers.user.uuid, &conn)? |
||||
|
.1 |
||||
|
.into_iter() |
||||
|
.map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering
|
||||
|
.collect(); |
||||
|
|
||||
|
let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( |
||||
|
headers.user.uuid.as_bytes().to_vec(), |
||||
|
headers.user.email, |
||||
|
headers.user.name, |
||||
|
Some(registrations), |
||||
|
None, |
||||
|
None, |
||||
|
)?; |
||||
|
|
||||
|
let type_ = TwoFactorType::WebauthnRegisterChallenge; |
||||
|
TwoFactor::new(headers.user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&conn)?; |
||||
|
|
||||
|
let mut challenge_value = serde_json::to_value(challenge.public_key)?; |
||||
|
challenge_value["status"] = "ok".into(); |
||||
|
challenge_value["errorMessage"] = "".into(); |
||||
|
Ok(Json(challenge_value)) |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
struct EnableWebauthnData { |
||||
|
Id: NumberOrString, // 1..5
|
||||
|
Name: String, |
||||
|
MasterPasswordHash: String, |
||||
|
DeviceResponse: RegisterPublicKeyCredentialCopy, |
||||
|
} |
||||
|
|
||||
|
// This is copied from RegisterPublicKeyCredential to change the Response objects casing
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
struct RegisterPublicKeyCredentialCopy { |
||||
|
pub Id: String, |
||||
|
pub RawId: Base64UrlSafeData, |
||||
|
pub Response: AuthenticatorAttestationResponseRawCopy, |
||||
|
pub Type: String, |
||||
|
} |
||||
|
|
||||
|
// This is copied from AuthenticatorAttestationResponseRaw to change clientDataJSON to clientDataJson
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
pub struct AuthenticatorAttestationResponseRawCopy { |
||||
|
pub AttestationObject: Base64UrlSafeData, |
||||
|
pub ClientDataJson: Base64UrlSafeData, |
||||
|
} |
||||
|
|
||||
|
impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential { |
||||
|
fn from(r: RegisterPublicKeyCredentialCopy) -> Self { |
||||
|
Self { |
||||
|
id: r.Id, |
||||
|
raw_id: r.RawId, |
||||
|
response: AuthenticatorAttestationResponseRaw { |
||||
|
attestation_object: r.Response.AttestationObject, |
||||
|
client_data_json: r.Response.ClientDataJson, |
||||
|
}, |
||||
|
type_: r.Type, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// This is copied from PublicKeyCredential to change the Response objects casing
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
pub struct PublicKeyCredentialCopy { |
||||
|
pub Id: String, |
||||
|
pub RawId: Base64UrlSafeData, |
||||
|
pub Response: AuthenticatorAssertionResponseRawCopy, |
||||
|
pub Extensions: Option<AuthenticationExtensionsClientOutputsCopy>, |
||||
|
pub Type: String, |
||||
|
} |
||||
|
|
||||
|
// This is copied from AuthenticatorAssertionResponseRaw to change clientDataJSON to clientDataJson
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
pub struct AuthenticatorAssertionResponseRawCopy { |
||||
|
pub AuthenticatorData: Base64UrlSafeData, |
||||
|
pub ClientDataJson: Base64UrlSafeData, |
||||
|
pub Signature: Base64UrlSafeData, |
||||
|
pub UserHandle: Option<Base64UrlSafeData>, |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Deserialize)] |
||||
|
#[allow(non_snake_case)] |
||||
|
pub struct AuthenticationExtensionsClientOutputsCopy { |
||||
|
#[serde(default)] |
||||
|
pub Appid: bool, |
||||
|
} |
||||
|
|
||||
|
impl From<PublicKeyCredentialCopy> for PublicKeyCredential { |
||||
|
fn from(r: PublicKeyCredentialCopy) -> Self { |
||||
|
Self { |
||||
|
id: r.Id, |
||||
|
raw_id: r.RawId, |
||||
|
response: AuthenticatorAssertionResponseRaw { |
||||
|
authenticator_data: r.Response.AuthenticatorData, |
||||
|
client_data_json: r.Response.ClientDataJson, |
||||
|
signature: r.Response.Signature, |
||||
|
user_handle: r.Response.UserHandle, |
||||
|
}, |
||||
|
extensions: r.Extensions.map(|e| AuthenticationExtensionsClientOutputs { |
||||
|
appid: e.Appid, |
||||
|
}), |
||||
|
type_: r.Type, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[post("/two-factor/webauthn", data = "<data>")] |
||||
|
fn activate_webauthn(data: JsonUpcase<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
let data: EnableWebauthnData = data.into_inner().data; |
||||
|
let mut user = headers.user; |
||||
|
|
||||
|
if !user.check_valid_password(&data.MasterPasswordHash) { |
||||
|
err!("Invalid password"); |
||||
|
} |
||||
|
|
||||
|
// Retrieve and delete the saved challenge state
|
||||
|
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; |
||||
|
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) { |
||||
|
Some(tf) => { |
||||
|
let state: RegistrationState = serde_json::from_str(&tf.data)?; |
||||
|
tf.delete(&conn)?; |
||||
|
state |
||||
|
} |
||||
|
None => err!("Can't recover challenge"), |
||||
|
}; |
||||
|
|
||||
|
// Verify the credentials with the saved state
|
||||
|
let (credential, _data) = |
||||
|
WebauthnConfig::load().register_credential(&data.DeviceResponse.into(), &state, |_| Ok(false))?; |
||||
|
|
||||
|
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn)?.1; |
||||
|
// TODO: Check for repeated ID's
|
||||
|
registrations.push(WebauthnRegistration { |
||||
|
id: data.Id.into_i32()?, |
||||
|
name: data.Name, |
||||
|
migrated: false, |
||||
|
|
||||
|
credential, |
||||
|
}); |
||||
|
|
||||
|
// Save the registrations and return them
|
||||
|
TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?).save(&conn)?; |
||||
|
_generate_recover_code(&mut user, &conn); |
||||
|
|
||||
|
let keys_json: Vec<Value> = registrations.iter().map(WebauthnRegistration::to_json).collect(); |
||||
|
Ok(Json(json!({ |
||||
|
"Enabled": true, |
||||
|
"Keys": keys_json, |
||||
|
"Object": "twoFactorU2f" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
#[put("/two-factor/webauthn", data = "<data>")] |
||||
|
fn activate_webauthn_put(data: JsonUpcase<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
activate_webauthn(data, headers, conn) |
||||
|
} |
||||
|
|
||||
|
#[derive(Deserialize, Debug)] |
||||
|
#[allow(non_snake_case)] |
||||
|
struct DeleteU2FData { |
||||
|
Id: NumberOrString, |
||||
|
MasterPasswordHash: String, |
||||
|
} |
||||
|
|
||||
|
#[delete("/two-factor/webauthn", data = "<data>")] |
||||
|
fn delete_webauthn(data: JsonUpcase<DeleteU2FData>, headers: Headers, conn: DbConn) -> JsonResult { |
||||
|
let id = data.data.Id.into_i32()?; |
||||
|
if !headers.user.check_valid_password(&data.data.MasterPasswordHash) { |
||||
|
err!("Invalid password"); |
||||
|
} |
||||
|
|
||||
|
let type_ = TwoFactorType::Webauthn as i32; |
||||
|
let mut tf = match TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn) { |
||||
|
Some(tf) => tf, |
||||
|
None => err!("Webauthn data not found!"), |
||||
|
}; |
||||
|
|
||||
|
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?; |
||||
|
|
||||
|
let item_pos = match data.iter().position(|r| r.id != id) { |
||||
|
Some(p) => p, |
||||
|
None => err!("Webauthn entry not found"), |
||||
|
}; |
||||
|
|
||||
|
let removed_item = data.remove(item_pos); |
||||
|
tf.data = serde_json::to_string(&data)?; |
||||
|
tf.save(&conn)?; |
||||
|
drop(tf); |
||||
|
|
||||
|
// If entry is migrated from u2f, delete the u2f entry as well
|
||||
|
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn) { |
||||
|
use crate::api::core::two_factor::u2f::U2FRegistration; |
||||
|
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) { |
||||
|
Ok(d) => d, |
||||
|
Err(_) => err!("Error parsing U2F data"), |
||||
|
}; |
||||
|
|
||||
|
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id); |
||||
|
let new_data_str = serde_json::to_string(&data)?; |
||||
|
|
||||
|
u2f.data = new_data_str; |
||||
|
u2f.save(&conn)?; |
||||
|
} |
||||
|
|
||||
|
let keys_json: Vec<Value> = data.iter().map(WebauthnRegistration::to_json).collect(); |
||||
|
|
||||
|
Ok(Json(json!({ |
||||
|
"Enabled": true, |
||||
|
"Keys": keys_json, |
||||
|
"Object": "twoFactorU2f" |
||||
|
}))) |
||||
|
} |
||||
|
|
||||
|
pub fn get_webauthn_registrations(user_uuid: &str, conn: &DbConn) -> Result<(bool, Vec<WebauthnRegistration>), Error> { |
||||
|
let type_ = TwoFactorType::Webauthn as i32; |
||||
|
match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) { |
||||
|
Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), |
||||
|
None => Ok((false, Vec::new())), // If no data, return empty list
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
pub fn generate_webauthn_login(user_uuid: &str, conn: &DbConn) -> JsonResult { |
||||
|
// Load saved credentials
|
||||
|
let creds: Vec<Credential> = |
||||
|
get_webauthn_registrations(user_uuid, conn)?.1.into_iter().map(|r| r.credential).collect(); |
||||
|
|
||||
|
if creds.is_empty() { |
||||
|
err!("No Webauthn devices registered") |
||||
|
} |
||||
|
|
||||
|
// Generate a challenge based on the credentials
|
||||
|
let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); |
||||
|
let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?; |
||||
|
|
||||
|
// Save the challenge state for later validation
|
||||
|
TwoFactor::new(user_uuid.into(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) |
||||
|
.save(&conn)?; |
||||
|
|
||||
|
// Return challenge to the clients
|
||||
|
Ok(Json(serde_json::to_value(response.public_key)?)) |
||||
|
} |
||||
|
|
||||
|
pub fn validate_webauthn_login(user_uuid: &str, response: &str, conn: &DbConn) -> EmptyResult { |
||||
|
let type_ = TwoFactorType::WebauthnLoginChallenge as i32; |
||||
|
let state = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) { |
||||
|
Some(tf) => { |
||||
|
let state: AuthenticationState = serde_json::from_str(&tf.data)?; |
||||
|
tf.delete(&conn)?; |
||||
|
state |
||||
|
} |
||||
|
None => err!("Can't recover login challenge"), |
||||
|
}; |
||||
|
|
||||
|
let rsp: crate::util::UpCase<PublicKeyCredentialCopy> = serde_json::from_str(response)?; |
||||
|
let rsp: PublicKeyCredential = rsp.data.into(); |
||||
|
|
||||
|
let mut registrations = get_webauthn_registrations(user_uuid, conn)?.1; |
||||
|
|
||||
|
// If the credential we received is migrated from U2F, enable the U2F compatibility
|
||||
|
//let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0);
|
||||
|
let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?; |
||||
|
|
||||
|
for reg in &mut registrations { |
||||
|
if ®.credential.cred_id == cred_id { |
||||
|
reg.credential.counter = auth_data.counter; |
||||
|
|
||||
|
TwoFactor::new(user_uuid.to_string(), TwoFactorType::Webauthn, serde_json::to_string(®istrations)?) |
||||
|
.save(&conn)?; |
||||
|
return Ok(()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
err!("Credential not present") |
||||
|
} |
Loading…
Reference in new issue