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