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