From af383720b795f328b186d113812cbcb14b4dc976 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Fri, 11 Nov 2022 12:26:02 +0100 Subject: [PATCH] Merge factors type for authentication --- assets/img/key.svg | 3 + assets/img/pin.svg | 3 + src/actors/bruteforce_actor.rs | 10 +- src/actors/mod.rs | 4 +- src/actors/openid_sessions_actor.rs | 37 +- src/actors/users_actor.rs | 13 +- src/constants.rs | 2 +- src/controllers/admin_api.rs | 23 +- src/controllers/admin_controller.rs | 157 ++++--- src/controllers/assets_controller.rs | 6 +- src/controllers/base_controller.rs | 2 +- src/controllers/login_api.rs | 14 +- src/controllers/login_controller.rs | 243 ++++++---- src/controllers/mod.rs | 10 +- src/controllers/openid_controller.rs | 444 ++++++++++++------ src/controllers/settings_controller.rs | 77 ++- src/controllers/two_factor_api.rs | 60 ++- src/controllers/two_factors_controller.rs | 68 +-- src/data/access_token.rs | 4 +- src/data/client.rs | 2 +- src/data/code_challenge.rs | 13 +- src/data/crypto_wrapper.rs | 26 +- src/data/current_user.rs | 26 +- src/data/entity_manager.rs | 17 +- src/data/id_token.rs | 2 +- src/data/jwt_signer.rs | 7 +- src/data/login_redirect.rs | 2 +- src/data/mod.rs | 28 +- src/data/open_id_user_info.rs | 2 +- src/data/openid_config.rs | 2 +- src/data/remote_ip.rs | 6 +- src/data/session_identity.rs | 22 +- src/data/totp_key.rs | 20 +- src/data/user.rs | 75 ++- src/data/webauthn_manager.rs | 95 ++-- src/main.rs | 181 ++++--- src/middlewares/auth_middleware.rs | 66 +-- src/utils/crypt_utils.rs | 2 +- src/utils/mod.rs | 4 +- src/utils/network_utils.rs | 38 +- src/utils/string_utils.rs | 10 +- templates/login/choose_second_factor.html | 16 +- .../login/{opt_input.html => otp_input.html} | 7 +- templates/login/webauthn_input.html | 2 +- 44 files changed, 1177 insertions(+), 674 deletions(-) create mode 100644 assets/img/key.svg create mode 100644 assets/img/pin.svg rename templates/login/{opt_input.html => otp_input.html} (92%) diff --git a/assets/img/key.svg b/assets/img/key.svg new file mode 100644 index 0000000..2560732 --- /dev/null +++ b/assets/img/key.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/img/pin.svg b/assets/img/pin.svg new file mode 100644 index 0000000..e91fbbf --- /dev/null +++ b/assets/img/pin.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/actors/bruteforce_actor.rs b/src/actors/bruteforce_actor.rs index 89477b2..451467a 100644 --- a/src/actors/bruteforce_actor.rs +++ b/src/actors/bruteforce_actor.rs @@ -19,7 +19,6 @@ pub struct CountFailedAttempt { pub ip: IpAddr, } - #[derive(Debug, Default)] pub struct BruteForceActor { failed_attempts: HashMap>, @@ -28,10 +27,7 @@ pub struct BruteForceActor { impl BruteForceActor { pub fn clean_attempts(&mut self) { #[allow(clippy::map_clone)] - let keys = self.failed_attempts - .keys() - .map(|i| *i) - .collect::>(); + let keys = self.failed_attempts.keys().map(|i| *i).collect::>(); for ip in keys { // Remove old attempts @@ -102,7 +98,9 @@ mod test { let mut actor = BruteForceActor::default(); actor.failed_attempts.insert(IP_1, vec![1, 10]); actor.failed_attempts.insert(IP_2, vec![1, 10, time() + 10]); - actor.failed_attempts.insert(IP_3, vec![time() + 10, time() + 20]); + actor + .failed_attempts + .insert(IP_3, vec![time() + 10, time() + 20]); actor.clean_attempts(); diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 352c944..4298570 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,3 +1,3 @@ -pub mod users_actor; pub mod bruteforce_actor; -pub mod openid_sessions_actor; \ No newline at end of file +pub mod openid_sessions_actor; +pub mod users_actor; diff --git a/src/actors/openid_sessions_actor.rs b/src/actors/openid_sessions_actor.rs index 577868c..0b3a9d6 100644 --- a/src/actors/openid_sessions_actor.rs +++ b/src/actors/openid_sessions_actor.rs @@ -1,5 +1,5 @@ -use actix::{Actor, AsyncContext, Context, Handler}; use actix::Message; +use actix::{Actor, AsyncContext, Context, Handler}; use crate::constants::*; use crate::data::access_token::AccessToken; @@ -37,13 +37,16 @@ pub struct Session { impl Session { pub fn is_expired(&self) -> bool { - self.authorization_code_expire_at < time() && self.access_token_expire_at < time() + self.authorization_code_expire_at < time() + && self.access_token_expire_at < time() && self.refresh_token_expire_at < time() } - pub fn regenerate_access_and_refresh_tokens(&mut self, - app_config: &AppConfig, - jwt_signer: &JWTSigner) -> Res { + pub fn regenerate_access_and_refresh_tokens( + &mut self, + app_config: &AppConfig, + jwt_signer: &JWTSigner, + ) -> Res { let access_token = AccessToken { issuer: app_config.website_origin.to_string(), subject_identifier: self.user.clone().0, @@ -116,7 +119,11 @@ impl Handler for OpenIDSessionsActor { impl Handler for OpenIDSessionsActor { type Result = Option; - fn handle(&mut self, msg: FindSessionByAuthorizationCode, _ctx: &mut Self::Context) -> Self::Result { + fn handle( + &mut self, + msg: FindSessionByAuthorizationCode, + _ctx: &mut Self::Context, + ) -> Self::Result { self.session .iter() .find(|f| f.authorization_code.eq(&msg.0)) @@ -141,7 +148,12 @@ impl Handler for OpenIDSessionsActor { fn handle(&mut self, msg: FindSessionByAccessToken, _ctx: &mut Self::Context) -> Self::Result { self.session .iter() - .find(|f| f.access_token.as_ref().map(|t| t.eq(&msg.0)).unwrap_or(false)) + .find(|f| { + f.access_token + .as_ref() + .map(|t| t.eq(&msg.0)) + .unwrap_or(false) + }) .cloned() } } @@ -150,9 +162,14 @@ impl Handler for OpenIDSessionsActor { type Result = (); fn handle(&mut self, msg: UpdateSession, _ctx: &mut Self::Context) -> Self::Result { - if let Some(r) = self.session.iter().enumerate() - .find(|f| f.1.session_id.eq(&msg.0.session_id)).map(|f| f.0) { + if let Some(r) = self + .session + .iter() + .enumerate() + .find(|f| f.1.session_id.eq(&msg.0.session_id)) + .map(|f| f.0) + { self.session[r] = msg.0; } } -} \ No newline at end of file +} diff --git a/src/actors/users_actor.rs b/src/actors/users_actor.rs index 77bd29c..ed9feae 100644 --- a/src/actors/users_actor.rs +++ b/src/actors/users_actor.rs @@ -123,7 +123,9 @@ impl Handler for UsersActor { type Result = MessageResult; fn handle(&mut self, msg: FindUserByUsername, _ctx: &mut Self::Context) -> Self::Result { - MessageResult(FindUserByUsernameResult(self.manager.find_by_username_or_email(&msg.0))) + MessageResult(FindUserByUsernameResult( + self.manager.find_by_username_or_email(&msg.0), + )) } } @@ -155,10 +157,13 @@ impl Handler for UsersActor { fn handle(&mut self, msg: DeleteUserRequest, _ctx: &mut Self::Context) -> Self::Result { let user = match self.manager.find_by_user_id(&msg.0) { None => { - log::warn!("Could not delete account {:?} because it was not found!", msg.0); + log::warn!( + "Could not delete account {:?} because it was not found!", + msg.0 + ); return MessageResult(DeleteUserResult(false)); } - Some(s) => s + Some(s) => s, }; MessageResult(DeleteUserResult(match self.manager.remove(&user) { @@ -169,4 +174,4 @@ impl Handler for UsersActor { } })) } -} \ No newline at end of file +} diff --git a/src/constants.rs b/src/constants.rs index 7c5c047..688753d 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -60,4 +60,4 @@ pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000; /// Webauthn constants pub const WEBAUTHN_REGISTER_CHALLENGE_EXPIRE: u64 = 3600; -pub const WEBAUTHN_LOGIN_CHALLENGE_EXPIRE: u64 = 3600; \ No newline at end of file +pub const WEBAUTHN_LOGIN_CHALLENGE_EXPIRE: u64 = 3600; diff --git a/src/controllers/admin_api.rs b/src/controllers/admin_api.rs index 039ba62..5499cf8 100644 --- a/src/controllers/admin_api.rs +++ b/src/controllers/admin_api.rs @@ -1,5 +1,5 @@ use actix::Addr; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{web, HttpResponse, Responder}; use crate::actors::users_actor::{DeleteUserRequest, FindUserByUsername, UsersActor}; use crate::data::current_user::CurrentUser; @@ -15,10 +15,16 @@ struct FindUserResult { user_id: Option, } -pub async fn find_username(req: web::Form, users: web::Data>) -> impl Responder { - let res = users.send(FindUserByUsername(req.0.username)).await.unwrap(); +pub async fn find_username( + req: web::Form, + users: web::Data>, +) -> impl Responder { + let res = users + .send(FindUserByUsername(req.0.username)) + .await + .unwrap(); HttpResponse::Ok().json(FindUserResult { - user_id: res.0.map(|r| r.uid.0) + user_id: res.0.map(|r| r.uid.0), }) } @@ -27,9 +33,11 @@ pub struct DeleteUserReq { user_id: UserID, } - -pub async fn delete_user(user: CurrentUser, req: web::Form, - users: web::Data>) -> impl Responder { +pub async fn delete_user( + user: CurrentUser, + req: web::Form, + users: web::Data>, +) -> impl Responder { if user.uid == req.user_id { return HttpResponse::BadRequest().body("You can not remove your own account!"); } @@ -41,4 +49,3 @@ pub async fn delete_user(user: CurrentUser, req: web::Form, HttpResponse::InternalServerError().finish() } } - diff --git a/src/controllers/admin_controller.rs b/src/controllers/admin_controller.rs index b15e900..37ff7fa 100644 --- a/src/controllers/admin_controller.rs +++ b/src/controllers/admin_controller.rs @@ -1,7 +1,7 @@ use std::ops::Deref; use actix::Addr; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{web, HttpResponse, Responder}; use askama::Template; use crate::actors::users_actor; @@ -35,17 +35,15 @@ struct EditUserTemplate { clients: Vec, } - pub async fn clients_route(user: CurrentUser, clients: web::Data) -> impl Responder { - HttpResponse::Ok().body(ClientsListTemplate { - _p: BaseSettingsPage::get( - "Clients list", - &user, - None, - None, - ), - clients: clients.cloned(), - }.render().unwrap()) + HttpResponse::Ok().body( + ClientsListTemplate { + _p: BaseSettingsPage::get("Clients list", &user, None, None), + clients: clients.cloned(), + } + .render() + .unwrap(), + ) } #[derive(serde::Deserialize, Debug)] @@ -63,13 +61,20 @@ pub struct UpdateUserQuery { two_factor: String, } -pub async fn users_route(user: CurrentUser, users: web::Data>, update_query: Option>) -> impl Responder { +pub async fn users_route( + user: CurrentUser, + users: web::Data>, + update_query: Option>, +) -> impl Responder { let mut danger = None; let mut success = None; if let Some(update) = update_query { - let current_user: Option = users.send(users_actor::FindUserByUsername(update.username.to_string())) - .await.unwrap().0; + let current_user: Option = users + .send(users_actor::FindUserByUsername(update.username.to_string())) + .await + .unwrap() + .0; let is_creating = current_user.is_none(); let mut user = current_user.unwrap_or_default(); @@ -82,67 +87,84 @@ pub async fn users_route(user: CurrentUser, users: web::Data>, user.admin = update.0.admin.is_some(); let factors_to_keep = update.0.two_factor.split(';').collect::>(); - user.two_factor.retain(|f| factors_to_keep.contains(&f.id.0.as_str())); - + user.two_factor + .retain(|f| factors_to_keep.contains(&f.id.0.as_str())); user.authorized_clients = match update.0.grant_type.as_str() { "all_clients" => None, - "custom_clients" => Some(update.0.granted_clients.split(',') - .map(|c| ClientID(c.to_string())) - .collect::>()), - _ => Some(Vec::new()) + "custom_clients" => Some( + update + .0 + .granted_clients + .split(',') + .map(|c| ClientID(c.to_string())) + .collect::>(), + ), + _ => Some(Vec::new()), }; let new_password = match update.0.gen_new_password.is_some() { false => None, true => { let temp_pass = rand_str(TEMPORARY_PASSWORDS_LEN); - user.password = hash_password(&temp_pass) - .expect("Failed to hash password"); + user.password = hash_password(&temp_pass).expect("Failed to hash password"); user.need_reset_password = true; Some(temp_pass) } }; - let res = users.send(users_actor::UpdateUserRequest(user.clone())).await.unwrap().0; + let res = users + .send(users_actor::UpdateUserRequest(user.clone())) + .await + .unwrap() + .0; if !res { - danger = Some(match is_creating { - true => "Failed to create user!", - false => "Failed to update user!" - }.to_string()) + danger = Some( + match is_creating { + true => "Failed to create user!", + false => "Failed to update user!", + } + .to_string(), + ) } else { success = Some(match is_creating { true => format!("User {} was successfully created!", user.full_name()), - false => format!("User {} was successfully updated!", user.full_name()) + false => format!("User {} was successfully updated!", user.full_name()), }); if let Some(pass) = new_password { - danger = Some(format!("{}'s temporary password is {}", user.full_name(), pass)); + danger = Some(format!( + "{}'s temporary password is {}", + user.full_name(), + pass + )); } } } - let users = users.send(users_actor::GetAllUsersRequest).await.unwrap().0; - HttpResponse::Ok().body(UsersListTemplate { - _p: BaseSettingsPage::get( - "Users list", - &user, - danger, - success, - ), - users, - }.render().unwrap()) + HttpResponse::Ok().body( + UsersListTemplate { + _p: BaseSettingsPage::get("Users list", &user, danger, success), + users, + } + .render() + .unwrap(), + ) } pub async fn create_user(user: CurrentUser, clients: web::Data) -> impl Responder { - HttpResponse::Ok().body(EditUserTemplate { - _p: BaseSettingsPage::get("Create a new user", user.deref(), None, None), - u: Default::default(), - clients: clients.cloned(), - }.render().unwrap()) + HttpResponse::Ok().body( + EditUserTemplate { + _p: BaseSettingsPage::get("Create a new user", user.deref(), None, None), + u: Default::default(), + clients: clients.cloned(), + } + .render() + .unwrap(), + ) } #[derive(serde::Deserialize)] @@ -150,26 +172,33 @@ pub struct EditUserQuery { id: UserID, } -pub async fn edit_user(user: CurrentUser, - clients: web::Data, - users: web::Data>, - query: web::Query, +pub async fn edit_user( + user: CurrentUser, + clients: web::Data, + users: web::Data>, + query: web::Query, ) -> impl Responder { - let edited_account = users.send(users_actor::GetUserRequest(query.0.id)) - .await.unwrap().0; + let edited_account = users + .send(users_actor::GetUserRequest(query.0.id)) + .await + .unwrap() + .0; - - HttpResponse::Ok().body(EditUserTemplate { - _p: BaseSettingsPage::get( - "Edit user account", - user.deref(), - match edited_account.is_none() { - true => Some("Could not find requested user!".to_string()), - false => None - }, - None, - ), - u: edited_account.unwrap_or_default(), - clients: clients.cloned(), - }.render().unwrap()) + HttpResponse::Ok().body( + EditUserTemplate { + _p: BaseSettingsPage::get( + "Edit user account", + user.deref(), + match edited_account.is_none() { + true => Some("Could not find requested user!".to_string()), + false => None, + }, + None, + ), + u: edited_account.unwrap_or_default(), + clients: clients.cloned(), + } + .render() + .unwrap(), + ) } diff --git a/src/controllers/assets_controller.rs b/src/controllers/assets_controller.rs index cef5e57..b300b2b 100644 --- a/src/controllers/assets_controller.rs +++ b/src/controllers/assets_controller.rs @@ -1,7 +1,7 @@ use std::path::Path; -use actix_web::{HttpResponse, web}; -use include_dir::{Dir, include_dir}; +use actix_web::{web, HttpResponse}; +use include_dir::{include_dir, Dir}; /// Assets directory static ASSETS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets"); @@ -23,4 +23,4 @@ pub async fn assets_route(path: web::Path) -> HttpResponse { .body(file.contents()) } } -} \ No newline at end of file +} diff --git a/src/controllers/base_controller.rs b/src/controllers/base_controller.rs index 3972fe7..b197f0f 100644 --- a/src/controllers/base_controller.rs +++ b/src/controllers/base_controller.rs @@ -35,4 +35,4 @@ struct FatalErrorPage { pub fn build_fatal_error_page(msg: &'static str) -> String { FatalErrorPage { message: msg }.render().unwrap() -} \ No newline at end of file +} diff --git a/src/controllers/login_api.rs b/src/controllers/login_api.rs index ef8cc5a..eb9d8ee 100644 --- a/src/controllers/login_api.rs +++ b/src/controllers/login_api.rs @@ -1,5 +1,5 @@ use actix_identity::Identity; -use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; use webauthn_rs::prelude::PublicKeyCredential; use crate::data::session_identity::{SessionIdentity, SessionStatus}; @@ -11,10 +11,12 @@ pub struct AuthWebauthnRequest { credential: PublicKeyCredential, } -pub async fn auth_webauthn(id: Identity, - req: web::Json, - manager: WebAuthManagerReq, - http_req: HttpRequest) -> impl Responder { +pub async fn auth_webauthn( + id: Identity, + req: web::Json, + manager: WebAuthManagerReq, + http_req: HttpRequest, +) -> impl Responder { if !SessionIdentity(Some(&id)).need_2fa_auth() { return HttpResponse::Unauthorized().json("No 2FA required!"); } @@ -31,4 +33,4 @@ pub async fn auth_webauthn(id: Identity, HttpResponse::InternalServerError().body("Failed to validate security key!") } } -} \ No newline at end of file +} diff --git a/src/controllers/login_controller.rs b/src/controllers/login_controller.rs index 6386515..a879ec6 100644 --- a/src/controllers/login_controller.rs +++ b/src/controllers/login_controller.rs @@ -1,17 +1,19 @@ use actix::Addr; use actix_identity::Identity; -use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use actix_web::{web, HttpRequest, HttpResponse, Responder}; use askama::Template; -use crate::actors::{bruteforce_actor, users_actor}; use crate::actors::bruteforce_actor::BruteForceActor; use crate::actors::users_actor::{ChangePasswordResult, LoginResult, UsersActor}; +use crate::actors::{bruteforce_actor, users_actor}; use crate::constants::{APP_NAME, MAX_FAILED_LOGIN_ATTEMPTS, MIN_PASS_LEN}; -use crate::controllers::base_controller::{build_fatal_error_page, redirect_user, redirect_user_for_login}; +use crate::controllers::base_controller::{ + build_fatal_error_page, redirect_user, redirect_user_for_login, +}; use crate::data::login_redirect::LoginRedirect; use crate::data::remote_ip::RemoteIP; use crate::data::session_identity::{SessionIdentity, SessionStatus}; -use crate::data::user::{FactorID, TwoFactor, TwoFactorType, User}; +use crate::data::user::User; use crate::data::webauthn_manager::WebAuthManagerReq; struct BaseLoginPage<'a> { @@ -40,26 +42,23 @@ struct PasswordResetTemplate<'a> { #[template(path = "login/choose_second_factor.html")] struct ChooseSecondFactorTemplate<'a> { _p: BaseLoginPage<'a>, - factors: &'a [TwoFactor], + user: &'a User, } #[derive(Template)] -#[template(path = "login/opt_input.html")] +#[template(path = "login/otp_input.html")] struct LoginWithOTPTemplate<'a> { _p: BaseLoginPage<'a>, - factor: &'a TwoFactor, } #[derive(Template)] #[template(path = "login/webauthn_input.html")] struct LoginWithWebauthnTemplate<'a> { _p: BaseLoginPage<'a>, - factor: &'a TwoFactor, opaque_state: String, challenge_json: String, } - #[derive(serde::Deserialize)] pub struct LoginRequestBody { login: String, @@ -87,13 +86,17 @@ pub async fn login_route( let mut success = None; let mut login = String::new(); - let failed_attempts = bruteforce.send(bruteforce_actor::CountFailedAttempt { ip: remote_ip.into() }) - .await.unwrap(); + let failed_attempts = bruteforce + .send(bruteforce_actor::CountFailedAttempt { + ip: remote_ip.into(), + }) + .await + .unwrap(); if failed_attempts > MAX_FAILED_LOGIN_ATTEMPTS { - return HttpResponse::TooManyRequests().body( - build_fatal_error_page("Too many failed login attempts, please try again later!") - ); + return HttpResponse::TooManyRequests().body(build_fatal_error_page( + "Too many failed login attempts, please try again later!", + )); } // Check if user session must be closed @@ -103,22 +106,24 @@ pub async fn login_route( } success = Some("Goodbye!".to_string()); } - // Check if user is already authenticated else if SessionIdentity(id.as_ref()).is_authenticated() { return redirect_user(query.redirect.get()); } - // Check if the password of the user has to be changed else if SessionIdentity(id.as_ref()).need_new_password() { - return redirect_user(&format!("/reset_password?redirect={}", query.redirect.get_encoded())); + return redirect_user(&format!( + "/reset_password?redirect={}", + query.redirect.get_encoded() + )); } - // Check if the user has to valide a second factor else if SessionIdentity(id.as_ref()).need_2fa_auth() { - return redirect_user(&format!("/2fa_auth?redirect={}", query.redirect.get_encoded())); + return redirect_user(&format!( + "/2fa_auth?redirect={}", + query.redirect.get_encoded() + )); } - // Try to authenticate user else if let Some(req) = &req { login = req.login.clone(); @@ -150,10 +155,20 @@ pub async fn login_route( } c => { - log::warn!("Failed login for ip {:?} / username {}: {:?}", remote_ip, login, c); + log::warn!( + "Failed login for ip {:?} / username {}: {:?}", + remote_ip, + login, + c + ); danger = Some("Login failed.".to_string()); - bruteforce.send(bruteforce_actor::RecordFailedAttempt { ip: remote_ip.into() }).await.unwrap(); + bruteforce + .send(bruteforce_actor::RecordFailedAttempt { + ip: remote_ip.into(), + }) + .await + .unwrap(); } } } @@ -169,8 +184,8 @@ pub async fn login_route( }, login, } - .render() - .unwrap(), + .render() + .unwrap(), ) } @@ -191,10 +206,13 @@ pub struct PasswordResetQuery { } /// Reset user password route -pub async fn reset_password_route(id: Option, query: web::Query, - req: Option>, - users: web::Data>, - http_req: HttpRequest) -> impl Responder { +pub async fn reset_password_route( + id: Option, + query: web::Query, + req: Option>, + users: web::Data>, + http_req: HttpRequest, +) -> impl Responder { let mut danger = None; if !SessionIdentity(id.as_ref()).need_new_password() { @@ -235,8 +253,8 @@ pub async fn reset_password_route(id: Option, query: web::Query, query: web::Query, - users: web::Data>) -> impl Responder { +pub async fn choose_2fa_method( + id: Option, + query: web::Query, + users: web::Data>, +) -> impl Responder { if !SessionIdentity(id.as_ref()).need_2fa_auth() { log::trace!("User does not require 2fa auth, redirecting"); return redirect_user_for_login(query.redirect.get()); } - let user: User = users.send(users_actor::GetUserRequest(SessionIdentity(id.as_ref()).user_id())) - .await.unwrap().0.expect("Could not find user!"); + let user: User = users + .send(users_actor::GetUserRequest( + SessionIdentity(id.as_ref()).user_id(), + )) + .await + .unwrap() + .0 + .expect("Could not find user!"); // Automatically choose factor if there is only one factor - if user.two_factor.len() == 1 && !query.force_display { + if user.get_distinct_factors_types().len() == 1 && !query.force_display { log::trace!("User has only one factor, using it by default"); return redirect_user(&user.two_factor[0].login_url(&query.redirect)); } @@ -274,10 +301,10 @@ pub async fn choose_2fa_method(id: Option, query: web::Query, query: web::Query, query: web::Query, - form: Option>, - users: web::Data>, - http_req: HttpRequest) -> impl Responder { +pub async fn login_with_otp( + id: Option, + query: web::Query, + form: Option>, + users: web::Data>, + http_req: HttpRequest, +) -> impl Responder { let mut danger = None; if !SessionIdentity(id.as_ref()).need_2fa_auth() { return redirect_user_for_login(query.redirect.get()); } - let user: User = users.send(users_actor::GetUserRequest(SessionIdentity(id.as_ref()).user_id())) - .await.unwrap().0.expect("Could not find user!"); + let user: User = users + .send(users_actor::GetUserRequest( + SessionIdentity(id.as_ref()).user_id(), + )) + .await + .unwrap() + .0 + .expect("Could not find user!"); - let factor = match user.find_factor(&query.id) { - Some(f) => f, - None => return HttpResponse::Ok().body(build_fatal_error_page("Factor not found!")) - }; - - let key = match &factor.kind { - TwoFactorType::TOTP(key) => key, - _ => { - return HttpResponse::Ok().body(build_fatal_error_page("Factor is not a TOTP key!")); - } - }; + let keys = user.get_otp_factors(); + if keys.is_empty() { + return HttpResponse::Ok().body(build_fatal_error_page("Factor not found!")); + } if let Some(form) = form { - if !key.check_code(&form.code).unwrap_or(false) { + if !keys + .iter() + .any(|k| k.check_code(&form.code).unwrap_or(false)) + { danger = Some("Specified code is invalid!".to_string()); } else { SessionIdentity(id.as_ref()).set_status(&http_req, SessionStatus::SignedIn); @@ -329,56 +359,60 @@ pub async fn login_with_otp(id: Option, query: web::Query, query: web::Query, - manager: WebAuthManagerReq, - users: web::Data>) -> impl Responder { +pub async fn login_with_webauthn( + id: Option, + query: web::Query, + manager: WebAuthManagerReq, + users: web::Data>, +) -> impl Responder { if !SessionIdentity(id.as_ref()).need_2fa_auth() { return redirect_user_for_login(query.redirect.get()); } - let user: User = users.send(users_actor::GetUserRequest(SessionIdentity(id.as_ref()).user_id())) - .await.unwrap().0.expect("Could not find user!"); + let user: User = users + .send(users_actor::GetUserRequest( + SessionIdentity(id.as_ref()).user_id(), + )) + .await + .unwrap() + .0 + .expect("Could not find user!"); - let factor = match user.find_factor(&query.id) { - Some(f) => f, - None => return HttpResponse::Ok().body(build_fatal_error_page("Factor not found!")) - }; + let pub_keys = user.get_webauthn_pub_keys(); + if pub_keys.is_empty() { + return HttpResponse::Ok() + .body(build_fatal_error_page("No Webauthn public key registered!")); + } - let key = match &factor.kind { - TwoFactorType::WEBAUTHN(key) => key, - _ => { - return HttpResponse::Ok() - .body(build_fatal_error_page("Factor is not a Webauthn key!")); - } - }; - - let challenge = match manager.start_authentication(&user.uid, key) { + let challenge = match manager.start_authentication(&user.uid, &pub_keys) { Ok(c) => c, Err(e) => { log::error!("Failed to generate webauthn challenge! {:?}", e); - return HttpResponse::InternalServerError() - .body(build_fatal_error_page("Failed to generate webauthn challenge")); + return HttpResponse::InternalServerError().body(build_fatal_error_page( + "Failed to generate webauthn challenge", + )); } }; @@ -390,16 +424,19 @@ pub async fn login_with_webauthn(id: Option, query: web::Query) -> impl Responder { - let is_secure_request = req.headers().get("HTTP_X_FORWARDED_PROTO") + let is_secure_request = req + .headers() + .get("HTTP_X_FORWARDED_PROTO") .map(|v| v.to_str().unwrap_or_default().to_lowercase().eq("https")) .unwrap_or(false); @@ -33,10 +35,14 @@ pub async fn get_configuration(req: HttpRequest, app_conf: web::Data) Some(s) => s.to_str().unwrap_or_default(), }; - let curr_origin = format!("{}://{}", match is_secure_request { - true => "https", - false => "http" - }, host); + let curr_origin = format!( + "{}://{}", + match is_secure_request { + true => "https", + false => "http", + }, + host + ); HttpResponse::Ok().json(OpenIDConfig { issuer: app_conf.website_origin.clone(), @@ -80,35 +86,43 @@ pub struct AuthorizeQuery { } fn error_redirect(query: &AuthorizeQuery, error: &str, description: &str) -> HttpResponse { - log::warn!("Failed to process sign in request ({} => {}): {:?}", error, description, query); + log::warn!( + "Failed to process sign in request ({} => {}): {:?}", + error, + description, + query + ); HttpResponse::Found() - .append_header( - ("Location", format!( + .append_header(( + "Location", + format!( "{}?error={}?error_description={}&state={}", query.redirect_uri, urlencoding::encode(error), urlencoding::encode(description), urlencoding::encode(&query.state) - )) - ) + ), + )) .finish() } -pub async fn authorize(user: CurrentUser, id: Identity, query: web::Query, - clients: web::Data, - sessions: web::Data>) -> impl Responder { +pub async fn authorize( + user: CurrentUser, + id: Identity, + query: web::Query, + clients: web::Data, + sessions: web::Data>, +) -> impl Responder { let client = match clients.find_by_id(&query.client_id) { None => { - return HttpResponse::BadRequest() - .body(build_fatal_error_page("Client is invalid!")); + return HttpResponse::BadRequest().body(build_fatal_error_page("Client is invalid!")); } - Some(c) => c + Some(c) => c, }; let redirect_uri = query.redirect_uri.trim().to_string(); if !redirect_uri.starts_with(&client.redirect_uri) { - return HttpResponse::BadRequest() - .body(build_fatal_error_page("Redirect URI is invalid!")); + return HttpResponse::BadRequest().body(build_fatal_error_page("Redirect URI is invalid!")); } if !query.scope.split(' ').any(|x| x == "openid") { @@ -116,7 +130,11 @@ pub async fn authorize(user: CurrentUser, id: Identity, query: web::Query { let meth = query.0.code_challenge_method.as_deref().unwrap_or("plain"); if !meth.eq("S256") && !meth.eq("plain") { - return error_redirect(&query, "invalid_request", - "Only S256 and plain code challenge methods are supported!"); + return error_redirect( + &query, + "invalid_request", + "Only S256 and plain code challenge methods are supported!", + ); } - Some(CodeChallenge { code_challenge: chal, code_challenge_method: meth.to_string() }) + Some(CodeChallenge { + code_challenge: chal, + code_challenge_method: meth.to_string(), + }) } - _ => None + _ => None, }; // Check if user is authorized to access the application if !user.can_access_app(&client.id) { - return error_redirect(&query, "invalid_request", - "User is not authorized to access this application!"); + return error_redirect( + &query, + "invalid_request", + "User is not authorized to access this application!", + ); } // Save all authentication information in memory @@ -157,18 +184,25 @@ pub async fn authorize(user: CurrentUser, id: Identity, query: web::Query(query: &D, error: &str, description: &str) -> HttpResponse { - log::warn!("request failed: {} - {} => '{:#?}'", error, description, query); - HttpResponse::BadRequest() - .json(ErrorResponse { - error: error.to_string(), - error_description: description.to_string(), - }) + log::warn!( + "request failed: {} - {} => '{:#?}'", + error, + description, + query + ); + HttpResponse::BadRequest().json(ErrorResponse { + error: error.to_string(), + error_description: description.to_string(), + }) } #[derive(Debug, serde::Deserialize)] @@ -198,7 +236,6 @@ pub struct TokenRefreshTokenQuery { refresh_token: String, } - #[derive(Debug, serde::Deserialize)] pub struct TokenQuery { grant_type: String, @@ -222,115 +259,175 @@ pub struct TokenResponse { id_token: Option, } -pub async fn token(req: HttpRequest, - query: web::Form, - clients: web::Data, - app_config: web::Data, - sessions: web::Data>, - users: web::Data>, - jwt_signer: web::Data) -> actix_web::Result { - +pub async fn token( + req: HttpRequest, + query: web::Form, + clients: web::Data, + app_config: web::Data, + sessions: web::Data>, + users: web::Data>, + jwt_signer: web::Data, +) -> actix_web::Result { // Extraction authentication information let authorization_header = req.headers().get("authorization"); - let (client_id, client_secret) = match (&query.client_id, &query.client_secret, authorization_header) { - // post authentication - (Some(client_id), Some(client_secret), None) => { - (client_id.clone(), client_secret.to_string()) - } - - // Basic authentication - (None, None, Some(v)) => { - let token = match v.to_str().unwrap_or_default().strip_prefix("Basic ") { - None => { - return Ok(error_response( - &query, - "invalid_request", - &format!("Authorization header does not start with 'Basic ', got '{:#?}'", v), - )); - } - Some(v) => v - }; - - let decode = String::from_utf8_lossy(&match base64::decode(token) { - Ok(d) => d, - Err(e) => { - log::error!("Failed to decode authorization header: {:?}", e); - return Ok(error_response(&query, "invalid_request", "Failed to decode authorization header!")); - } - }).to_string(); - - match decode.split_once(':') { - None => (ClientID(decode), "".to_string()), - Some((id, secret)) => (ClientID(id.to_string()), secret.to_string()) + let (client_id, client_secret) = + match (&query.client_id, &query.client_secret, authorization_header) { + // post authentication + (Some(client_id), Some(client_secret), None) => { + (client_id.clone(), client_secret.to_string()) } - } - _ => { - return Ok(error_response(&query, "invalid_request", "Authentication method unknown!")); - } - }; + // Basic authentication + (None, None, Some(v)) => { + let token = match v.to_str().unwrap_or_default().strip_prefix("Basic ") { + None => { + return Ok(error_response( + &query, + "invalid_request", + &format!( + "Authorization header does not start with 'Basic ', got '{:#?}'", + v + ), + )); + } + Some(v) => v, + }; + + let decode = String::from_utf8_lossy(&match base64::decode(token) { + Ok(d) => d, + Err(e) => { + log::error!("Failed to decode authorization header: {:?}", e); + return Ok(error_response( + &query, + "invalid_request", + "Failed to decode authorization header!", + )); + } + }) + .to_string(); + + match decode.split_once(':') { + None => (ClientID(decode), "".to_string()), + Some((id, secret)) => (ClientID(id.to_string()), secret.to_string()), + } + } + + _ => { + return Ok(error_response( + &query, + "invalid_request", + "Authentication method unknown!", + )); + } + }; let client = clients .find_by_id(&client_id) .ok_or_else(|| ErrorUnauthorized("Client not found"))?; if !client.secret.eq(&client_secret) { - return Ok(error_response(&query, "invalid_request", "Client secret is invalid!")); + return Ok(error_response( + &query, + "invalid_request", + "Client secret is invalid!", + )); } - let token_response = match (query.grant_type.as_str(), - &query.authorization_code_query, - &query.refresh_token_query) { + let token_response = match ( + query.grant_type.as_str(), + &query.authorization_code_query, + &query.refresh_token_query, + ) { ("authorization_code", Some(q), _) => { let mut session: Session = match sessions - .send(openid_sessions_actor::FindSessionByAuthorizationCode(q.code.clone())) - .await.unwrap() + .send(openid_sessions_actor::FindSessionByAuthorizationCode( + q.code.clone(), + )) + .await + .unwrap() { None => { - return Ok(error_response(&query, "invalid_request", "Session not found!")); + return Ok(error_response( + &query, + "invalid_request", + "Session not found!", + )); } Some(s) => s, }; if session.client != client.id { - return Ok(error_response(&query, "invalid_request", "Client mismatch!")); + return Ok(error_response( + &query, + "invalid_request", + "Client mismatch!", + )); } if session.redirect_uri != q.redirect_uri { - return Ok(error_response(&query, "invalid_request", "Invalid redirect URI!")); + return Ok(error_response( + &query, + "invalid_request", + "Invalid redirect URI!", + )); } if session.authorization_code_expire_at < time() { - return Ok(error_response(&query, "invalid_request", "Authorization code expired!")); + return Ok(error_response( + &query, + "invalid_request", + "Authorization code expired!", + )); } // Check code challenge, if needed if let Some(chall) = &session.code_challenge { let code_verifier = match &q.code_verifier { None => { - return Ok(error_response(&query, "access_denied", "Code verifier missing")); + return Ok(error_response( + &query, + "access_denied", + "Code verifier missing", + )); } - Some(s) => s + Some(s) => s, }; if !chall.verify_code(code_verifier) { - return Ok(error_response(&query, "invalid_grant", "Invalid code verifier")); + return Ok(error_response( + &query, + "invalid_grant", + "Invalid code verifier", + )); } } else if q.code_verifier.is_some() { - return Ok(error_response(&query, "invalid_grant", "Unexpected `code_verifier` parameter!")); + return Ok(error_response( + &query, + "invalid_grant", + "Unexpected `code_verifier` parameter!", + )); } if session.access_token.is_some() { - return Ok(error_response(&query, "invalid_request", "Authorization code already used!")); + return Ok(error_response( + &query, + "invalid_request", + "Authorization code already used!", + )); } session.regenerate_access_and_refresh_tokens(&app_config, &jwt_signer)?; - sessions.send(openid_sessions_actor::UpdateSession(session.clone())) - .await.unwrap(); + sessions + .send(openid_sessions_actor::UpdateSession(session.clone())) + .await + .unwrap(); - let user: Option = users.send(users_actor::GetUserRequest(session.user.clone())) - .await.unwrap().0; + let user: Option = users + .send(users_actor::GetUserRequest(session.user.clone())) + .await + .unwrap() + .0; let user = match user { None => return Ok(error_response(&query, "invalid_request", "User not found!")), Some(u) => u, @@ -359,28 +456,44 @@ pub async fn token(req: HttpRequest, ("refresh_token", _, Some(q)) => { let mut session: Session = match sessions - .send(openid_sessions_actor::FindSessionByRefreshToken(q.refresh_token.clone())) - .await.unwrap() + .send(openid_sessions_actor::FindSessionByRefreshToken( + q.refresh_token.clone(), + )) + .await + .unwrap() { None => { - return Ok(error_response(&query, "invalid_request", "Session not found!")); + return Ok(error_response( + &query, + "invalid_request", + "Session not found!", + )); } Some(s) => s, }; if session.client != client.id { - return Ok(error_response(&query, "invalid_request", "Client mismatch!")); + return Ok(error_response( + &query, + "invalid_request", + "Client mismatch!", + )); } if session.refresh_token_expire_at < time() { - return Ok(error_response(&query, "access_denied", "Refresh token has expired!")); + return Ok(error_response( + &query, + "access_denied", + "Refresh token has expired!", + )); } session.regenerate_access_and_refresh_tokens(&app_config, &jwt_signer)?; sessions .send(openid_sessions_actor::UpdateSession(session.clone())) - .await.unwrap(); + .await + .unwrap(); TokenResponse { access_token: session.access_token.expect("Missing access token!"), @@ -392,7 +505,11 @@ pub async fn token(req: HttpRequest, } _ => { - return Ok(error_response(&query, "invalid_request", "Grant type unsupported!")); + return Ok(error_response( + &query, + "invalid_request", + "Grant type unsupported!", + )); } }; @@ -408,16 +525,20 @@ struct CertsResponse { } pub async fn cert_uri(jwt_signer: web::Data) -> impl Responder { - HttpResponse::Ok().json(CertsResponse { keys: vec![jwt_signer.get_json_web_key()] }) + HttpResponse::Ok().json(CertsResponse { + keys: vec![jwt_signer.get_json_web_key()], + }) } fn user_info_error(err: &str, description: &str) -> HttpResponse { HttpResponse::Unauthorized() - .insert_header(("WWW-Authenticate", format!( - "Bearer error=\"{}\", error_description=\"{}\"", - err, - description - ))) + .insert_header(( + "WWW-Authenticate", + format!( + "Bearer error=\"{}\", error_description=\"{}\"", + err, description + ), + )) .finish() } @@ -426,37 +547,46 @@ pub struct UserInfoQuery { access_token: Option, } -pub async fn user_info_post(req: HttpRequest, - form: Option>, - query: web::Query, - sessions: web::Data>, - users: web::Data>) -> impl Responder { - user_info(req, - form - .map(|f| f.0.access_token) - .unwrap_or_default() - .or(query.0.access_token), - sessions, - users, - ).await +pub async fn user_info_post( + req: HttpRequest, + form: Option>, + query: web::Query, + sessions: web::Data>, + users: web::Data>, +) -> impl Responder { + user_info( + req, + form.map(|f| f.0.access_token) + .unwrap_or_default() + .or(query.0.access_token), + sessions, + users, + ) + .await } -pub async fn user_info_get(req: HttpRequest, query: web::Query, - sessions: web::Data>, - users: web::Data>) -> impl Responder { +pub async fn user_info_get( + req: HttpRequest, + query: web::Query, + sessions: web::Data>, + users: web::Data>, +) -> impl Responder { user_info(req, query.0.access_token, sessions, users).await } /// Authenticate request using RFC6750 /// -async fn user_info(req: HttpRequest, token: Option, - sessions: web::Data>, - users: web::Data>) -> impl Responder { +async fn user_info( + req: HttpRequest, + token: Option, + sessions: web::Data>, + users: web::Data>, +) -> impl Responder { let token = match token { Some(t) => t, None => { let token = match req.headers().get("Authorization") { None => return user_info_error("invalid_request", "Missing access token!"), - Some(t) => t + Some(t) => t, }; let token = match token.to_str() { @@ -465,7 +595,12 @@ async fn user_info(req: HttpRequest, token: Option, }; let token = match token.strip_prefix("Bearer ") { - None => return user_info_error("invalid_request", "Header token does not start with 'Bearer '!"), + None => { + return user_info_error( + "invalid_request", + "Header token does not start with 'Bearer '!", + ) + } Some(t) => t, }; @@ -474,7 +609,9 @@ async fn user_info(req: HttpRequest, token: Option, }; let session: Option = sessions - .send(openid_sessions_actor::FindSessionByAccessToken(token)).await.unwrap(); + .send(openid_sessions_actor::FindSessionByAccessToken(token)) + .await + .unwrap(); let session = match session { None => { return user_info_error("invalid_request", "Session not found!"); @@ -486,7 +623,11 @@ async fn user_info(req: HttpRequest, token: Option, return user_info_error("invalid_request", "Access token has expired!"); } - let user: Option = users.send(users_actor::GetUserRequest(session.user)).await.unwrap().0; + let user: Option = users + .send(users_actor::GetUserRequest(session.user)) + .await + .unwrap() + .0; let user = match user { None => { return user_info_error("invalid_request", "Failed to extract user information!"); @@ -494,14 +635,13 @@ async fn user_info(req: HttpRequest, token: Option, Some(u) => u, }; - HttpResponse::Ok() - .json(OpenIDUserInfo { - name: user.full_name(), - sub: user.uid.0, - given_name: user.first_name, - family_name: user.last_name, - preferred_username: user.username, - email: user.email, - email_verified: true, - }) -} \ No newline at end of file + HttpResponse::Ok().json(OpenIDUserInfo { + name: user.full_name(), + sub: user.uid.0, + given_name: user.first_name, + family_name: user.last_name, + preferred_username: user.username, + email: user.email, + email_verified: true, + }) +} diff --git a/src/controllers/settings_controller.rs b/src/controllers/settings_controller.rs index 33d0787..a23f3ce 100644 --- a/src/controllers/settings_controller.rs +++ b/src/controllers/settings_controller.rs @@ -1,10 +1,10 @@ use actix::Addr; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{web, HttpResponse, Responder}; use askama::Template; -use crate::actors::{bruteforce_actor, users_actor}; use crate::actors::bruteforce_actor::BruteForceActor; use crate::actors::users_actor::UsersActor; +use crate::actors::{bruteforce_actor, users_actor}; use crate::constants::{APP_NAME, MAX_FAILED_LOGIN_ATTEMPTS, MIN_PASS_LEN}; use crate::data::current_user::CurrentUser; use crate::data::remote_ip::RemoteIP; @@ -21,8 +21,12 @@ pub(crate) struct BaseSettingsPage { } impl BaseSettingsPage { - pub fn get(page_title: &'static str, user: &User, - danger_message: Option, success_message: Option) -> BaseSettingsPage { + pub fn get( + page_title: &'static str, + user: &User, + danger_message: Option, + success_message: Option, + ) -> BaseSettingsPage { Self { danger_message, success_message, @@ -52,11 +56,14 @@ struct ChangePasswordPage { /// Account details page pub async fn account_settings_details_route(user: CurrentUser) -> impl Responder { let user = user.into(); - HttpResponse::Ok() - .body(AccountDetailsPage { + HttpResponse::Ok().body( + AccountDetailsPage { _p: BaseSettingsPage::get("Account details", &user, None, None), u: user, - }.render().unwrap()) + } + .render() + .unwrap(), + ) } #[derive(serde::Deserialize)] @@ -66,53 +73,71 @@ pub struct PassChangeRequest { } /// Change password route -pub async fn change_password_route(user: CurrentUser, - users: web::Data>, - req: Option>, - bruteforce: web::Data>, - remote_ip: RemoteIP) -> impl Responder { +pub async fn change_password_route( + user: CurrentUser, + users: web::Data>, + req: Option>, + bruteforce: web::Data>, + remote_ip: RemoteIP, +) -> impl Responder { let mut danger = None; let mut success = None; let user: User = user.into(); - let failed_attempts = bruteforce.send(bruteforce_actor::CountFailedAttempt { ip: remote_ip.into() }).await.unwrap(); + let failed_attempts = bruteforce + .send(bruteforce_actor::CountFailedAttempt { + ip: remote_ip.into(), + }) + .await + .unwrap(); if failed_attempts > MAX_FAILED_LOGIN_ATTEMPTS { - danger = Some("Too many invalid password attempts. Please try to change your password later.".to_string()); + danger = Some( + "Too many invalid password attempts. Please try to change your password later." + .to_string(), + ); } else if let Some(req) = req { - // Invalid password if !user.verify_password(&req.old_pass) { danger = Some("Old password is invalid!".to_string()); - bruteforce.send(bruteforce_actor::RecordFailedAttempt { ip: remote_ip.into() }).await.unwrap(); + bruteforce + .send(bruteforce_actor::RecordFailedAttempt { + ip: remote_ip.into(), + }) + .await + .unwrap(); } - // Password too short else if req.new_pass.len() < MIN_PASS_LEN { danger = Some("New password is too short!".to_string()); } - // Change password else { - let res = users.send( - users_actor::ChangePasswordRequest { + let res = users + .send(users_actor::ChangePasswordRequest { user_id: user.uid.clone(), new_password: req.new_pass.to_string(), temporary: false, - } - ).await.unwrap().0; + }) + .await + .unwrap() + .0; if !res { - danger = Some("An error occurred while trying to change your password!".to_string()); + danger = + Some("An error occurred while trying to change your password!".to_string()); } else { success = Some("Your password was successfully changed!".to_string()); } } } - HttpResponse::Ok() - .body(ChangePasswordPage { + HttpResponse::Ok().body( + ChangePasswordPage { _p: BaseSettingsPage::get("Change password", &user, danger, success), min_pwd_len: MIN_PASS_LEN, - }.render().unwrap()) + } + .render() + .unwrap(), + ) } diff --git a/src/controllers/two_factor_api.rs b/src/controllers/two_factor_api.rs index 725295e..1326d46 100644 --- a/src/controllers/two_factor_api.rs +++ b/src/controllers/two_factor_api.rs @@ -1,5 +1,5 @@ use actix::Addr; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{web, HttpResponse, Responder}; use uuid::Uuid; use webauthn_rs::prelude::RegisterPublicKeyCredential; @@ -17,15 +17,19 @@ pub struct AddTOTPRequest { first_code: String, } -pub async fn save_totp_factor(user: CurrentUser, form: web::Json, - users: web::Data>) -> impl Responder { +pub async fn save_totp_factor( + user: CurrentUser, + form: web::Json, + users: web::Data>, +) -> impl Responder { let key = TotpKey::from_encoded_secret(&form.secret); if !key.check_code(&form.first_code).unwrap_or(false) { - return HttpResponse::BadRequest() - .body(format!("Given code is invalid (expected {} or {})!", - key.current_code().unwrap_or_default(), - key.previous_code().unwrap_or_default())); + return HttpResponse::BadRequest().body(format!( + "Given code is invalid (expected {} or {})!", + key.current_code().unwrap_or_default(), + key.previous_code().unwrap_or_default() + )); } if form.factor_name.is_empty() { @@ -38,7 +42,11 @@ pub async fn save_totp_factor(user: CurrentUser, form: web::Json name: form.0.factor_name, kind: TwoFactorType::TOTP(key), }); - let res = users.send(users_actor::UpdateUserRequest(user)).await.unwrap().0; + let res = users + .send(users_actor::UpdateUserRequest(user)) + .await + .unwrap() + .0; if !res { HttpResponse::InternalServerError().body("Failed to update user information!") @@ -54,14 +62,13 @@ pub struct AddWebauthnRequest { credential: RegisterPublicKeyCredential, } -pub async fn save_webauthn_factor(user: CurrentUser, form: web::Json, - users: web::Data>, - manager: WebAuthManagerReq) -> impl Responder { - let key = match manager.finish_registration( - &user, - &form.0.opaque_state, - form.0.credential, - ) { +pub async fn save_webauthn_factor( + user: CurrentUser, + form: web::Json, + users: web::Data>, + manager: WebAuthManagerReq, +) -> impl Responder { + let key = match manager.finish_registration(&user, &form.0.opaque_state, form.0.credential) { Ok(k) => k, Err(e) => { log::error!("Failed to register security key! {:?}", e); @@ -75,7 +82,11 @@ pub async fn save_webauthn_factor(user: CurrentUser, form: web::Json, - users: web::Data>) -> impl Responder { +pub async fn delete_factor( + user: CurrentUser, + form: web::Json, + users: web::Data>, +) -> impl Responder { let mut user = User::from(user); user.remove_factor(form.0.id); - let res = users.send(users_actor::UpdateUserRequest(user)).await.unwrap().0; + let res = users + .send(users_actor::UpdateUserRequest(user)) + .await + .unwrap() + .0; if !res { HttpResponse::InternalServerError().body("Failed to update user information!") } else { HttpResponse::Ok().body("Removed factor!") } -} \ No newline at end of file +} diff --git a/src/controllers/two_factors_controller.rs b/src/controllers/two_factors_controller.rs index b0f0bbd..ad4e979 100644 --- a/src/controllers/two_factors_controller.rs +++ b/src/controllers/two_factors_controller.rs @@ -1,6 +1,6 @@ use std::ops::Deref; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{web, HttpResponse, Responder}; use askama::Template; use qrcode_generator::QrCodeEcc; @@ -37,27 +37,25 @@ struct AddWebauhtnPage { /// Manage two factors authentication methods route pub async fn two_factors_route(user: CurrentUser) -> impl Responder { - HttpResponse::Ok() - .body(TwoFactorsPage { - _p: BaseSettingsPage::get( - "Two factor auth", - &user, - None, - None), + HttpResponse::Ok().body( + TwoFactorsPage { + _p: BaseSettingsPage::get("Two factor auth", &user, None, None), user: user.deref(), - }.render().unwrap()) + } + .render() + .unwrap(), + ) } - /// Configure a new TOTP authentication factor -pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data) -> impl Responder { +pub async fn add_totp_factor_route( + user: CurrentUser, + app_conf: web::Data, +) -> impl Responder { let key = TotpKey::new_random(); - let qr_code = qrcode_generator::to_png_to_vec( - key.url_for_user(&user, &app_conf), - QrCodeEcc::Low, - 1024, - ); + let qr_code = + qrcode_generator::to_png_to_vec(key.url_for_user(&user, &app_conf), QrCodeEcc::Low, 1024); let qr_code = match qr_code { Ok(q) => q, Err(e) => { @@ -66,26 +64,29 @@ pub async fn add_totp_factor_route(user: CurrentUser, app_conf: web::Data impl Responder { +pub async fn add_webauthn_factor_route( + user: CurrentUser, + manager: WebAuthManagerReq, +) -> impl Responder { let registration_request = match manager.start_register(&user) { Ok(r) => r, Err(e) => { log::error!("Failed to request new key! {:?}", e); - return HttpResponse::InternalServerError().body("Failed to generate request for registration!"); + return HttpResponse::InternalServerError() + .body("Failed to generate request for registration!"); } }; @@ -97,15 +98,14 @@ pub async fn add_webauthn_factor_route(user: CurrentUser, manager: WebAuthManage } }; - HttpResponse::Ok() - .body(AddWebauhtnPage { - _p: BaseSettingsPage::get( - "New security key", - &user, - None, - None), + HttpResponse::Ok().body( + AddWebauhtnPage { + _p: BaseSettingsPage::get("New security key", &user, None, None), opaque_state: registration_request.opaque_state, challenge_json: urlencoding::encode(&challenge_json).to_string(), - }.render().unwrap()) + } + .render() + .unwrap(), + ) } diff --git a/src/data/access_token.rs b/src/data/access_token.rs index bce0584..bd40d4f 100644 --- a/src/data/access_token.rs +++ b/src/data/access_token.rs @@ -27,8 +27,8 @@ impl AccessToken { jwt_id: None, nonce: self.nonce, custom: CustomAccessTokenClaims { - rand_val: self.rand_val + rand_val: self.rand_val, }, } } -} \ No newline at end of file +} diff --git a/src/data/client.rs b/src/data/client.rs index d5a1299..6f121f2 100644 --- a/src/data/client.rs +++ b/src/data/client.rs @@ -42,4 +42,4 @@ impl EntityManager { c.redirect_uri = apply_env_vars(&c.redirect_uri); } } -} \ No newline at end of file +} diff --git a/src/data/code_challenge.rs b/src/data/code_challenge.rs index 464d2ce..e46964e 100644 --- a/src/data/code_challenge.rs +++ b/src/data/code_challenge.rs @@ -16,10 +16,8 @@ impl CodeChallenge { match self.code_challenge_method.as_str() { "plain" => code_verifer.eq(&self.code_challenge), "S256" => { - let encoded = base64::encode_config( - sha256(code_verifer.as_bytes()), - URL_SAFE_NO_PAD, - ); + let encoded = + base64::encode_config(sha256(code_verifer.as_bytes()), URL_SAFE_NO_PAD); encoded.eq(&self.code_challenge) } @@ -64,7 +62,10 @@ mod test { code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_string(), }; - assert_eq!(true, chal.verify_code("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")); + assert_eq!( + true, + chal.verify_code("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") + ); assert_eq!(false, chal.verify_code("text1")); } -} \ No newline at end of file +} diff --git a/src/data/crypto_wrapper.rs b/src/data/crypto_wrapper.rs index dfb8588..1f539e8 100644 --- a/src/data/crypto_wrapper.rs +++ b/src/data/crypto_wrapper.rs @@ -1,7 +1,7 @@ use std::io::ErrorKind; -use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; use aes_gcm::aead::{Aead, OsRng}; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; use rand::Rng; use serde::de::DeserializeOwned; use serde::Serialize; @@ -17,7 +17,9 @@ pub struct CryptoWrapper { impl CryptoWrapper { /// Generate a new memory wrapper pub fn new_random() -> Self { - Self { key: Aes256Gcm::generate_key(&mut OsRng) } + Self { + key: Aes256Gcm::generate_key(&mut OsRng), + } } /// Encrypt some data @@ -27,11 +29,11 @@ impl CryptoWrapper { let serialized_data = bincode::serialize(data)?; - let mut enc = aes_key.encrypt(Nonce::from_slice(&nonce_bytes), - serialized_data.as_slice()).unwrap(); + let mut enc = aes_key + .encrypt(Nonce::from_slice(&nonce_bytes), serialized_data.as_slice()) + .unwrap(); enc.extend_from_slice(&nonce_bytes); - Ok(base64::encode(enc)) } @@ -40,8 +42,10 @@ impl CryptoWrapper { let bytes = base64::decode(input)?; if bytes.len() < NONCE_LEN { - return Err(Box::new(std::io::Error::new(ErrorKind::Other, - "Input string is smaller than nonce!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Input string is smaller than nonce!", + ))); } let (enc, nonce) = bytes.split_at(bytes.len() - NONCE_LEN); @@ -53,8 +57,10 @@ impl CryptoWrapper { Ok(d) => d, Err(e) => { log::error!("Failed to decrypt wrapped data! {:#?}", e); - return Err(Box::new(std::io::Error::new(ErrorKind::Other, - "Failed to decrypt wrapped data!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Failed to decrypt wrapped data!", + ))); } }; @@ -87,4 +93,4 @@ mod test { let enc = wrapper_1.encrypt(&msg).unwrap(); wrapper_2.decrypt::(&enc).unwrap_err(); } -} \ No newline at end of file +} diff --git a/src/data/current_user.rs b/src/data/current_user.rs index 5e00848..7606fee 100644 --- a/src/data/current_user.rs +++ b/src/data/current_user.rs @@ -4,9 +4,9 @@ use std::pin::Pin; use actix::Addr; use actix_identity::Identity; -use actix_web::{Error, FromRequest, HttpRequest, web}; use actix_web::dev::Payload; use actix_web::error::ErrorInternalServerError; +use actix_web::{web, Error, FromRequest, HttpRequest}; use crate::actors::users_actor; use crate::actors::users_actor::UsersActor; @@ -31,27 +31,33 @@ impl Deref for CurrentUser { impl FromRequest for CurrentUser { type Error = Error; - type Future = Pin>>>; + type Future = Pin>>>; fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - let user_actor: &web::Data> = req.app_data().expect("UserActor undefined!"); + let user_actor: &web::Data> = + req.app_data().expect("UserActor undefined!"); let user_actor: Addr = user_actor.as_ref().clone(); - let identity: Identity = Identity::from_request(req, payload).into_inner() + let identity: Identity = Identity::from_request(req, payload) + .into_inner() .expect("Failed to get identity!"); let user_id = SessionIdentity(Some(&identity)).user_id(); - Box::pin(async move { - let user = match user_actor.send( - users_actor::GetUserRequest(user_id) - ).await.unwrap().0 { + let user = match user_actor + .send(users_actor::GetUserRequest(user_id)) + .await + .unwrap() + .0 + { Some(u) => u, None => { - return Err(ErrorInternalServerError("Could not extract user information!")); + return Err(ErrorInternalServerError( + "Could not extract user information!", + )); } }; Ok(CurrentUser(user)) }) } -} \ No newline at end of file +} diff --git a/src/data/entity_manager.rs b/src/data/entity_manager.rs index 0241b43..f5ac8a6 100644 --- a/src/data/entity_manager.rs +++ b/src/data/entity_manager.rs @@ -3,7 +3,10 @@ use std::slice::{Iter, IterMut}; use crate::utils::err::Res; -enum FileFormat { Json, Yaml } +enum FileFormat { + Json, + Yaml, +} pub struct EntityManager { file_path: PathBuf, @@ -11,8 +14,8 @@ pub struct EntityManager { } impl EntityManager - where - E: serde::Serialize + serde::de::DeserializeOwned + Eq + Clone, +where + E: serde::Serialize + serde::de::DeserializeOwned + Eq + Clone, { /// Open entity pub fn open_or_create>(path: A) -> Res { @@ -30,7 +33,7 @@ impl EntityManager file_path: path.as_ref().to_path_buf(), list: match Self::file_format(path.as_ref()) { FileFormat::Json => serde_json::from_str(&file_content)?, - FileFormat::Yaml => serde_yaml::from_str(&file_content)? + FileFormat::Yaml => serde_yaml::from_str(&file_content)?, }, }) } @@ -49,7 +52,7 @@ impl EntityManager fn file_format(p: &Path) -> FileFormat { match p.to_string_lossy().ends_with(".json") { true => FileFormat::Json, - false => FileFormat::Yaml + false => FileFormat::Yaml, } } @@ -70,8 +73,8 @@ impl EntityManager /// Replace entries in the list that matches a criteria pub fn replace_entries(&mut self, filter: F, el: &E) -> Res - where - F: Fn(&E) -> bool, + where + F: Fn(&E) -> bool, { for i in 0..self.list.len() { if filter(&self.list[i]) { diff --git a/src/data/id_token.rs b/src/data/id_token.rs index 8f0ec74..a0e51ea 100644 --- a/src/data/id_token.rs +++ b/src/data/id_token.rs @@ -49,4 +49,4 @@ impl IdToken { }, } } -} \ No newline at end of file +} diff --git a/src/data/jwt_signer.rs b/src/data/jwt_signer.rs index 7d02f8c..c7c53bf 100644 --- a/src/data/jwt_signer.rs +++ b/src/data/jwt_signer.rs @@ -27,8 +27,9 @@ pub struct JWTSigner(RS256KeyPair); impl JWTSigner { pub fn gen_from_memory() -> Res { - Ok(Self(RS256KeyPair::generate(2048)? - .with_key_id(&format!("key-{}", rand_str(15))))) + Ok(Self( + RS256KeyPair::generate(2048)?.with_key_id(&format!("key-{}", rand_str(15))), + )) } pub fn get_json_web_key(&self) -> JsonWebKey { @@ -45,4 +46,4 @@ impl JWTSigner { pub fn sign_token(&self, c: JWTClaims) -> Res { Ok(self.0.sign(c)?) } -} \ No newline at end of file +} diff --git a/src/data/login_redirect.rs b/src/data/login_redirect.rs index e2f288e..d25e977 100644 --- a/src/data/login_redirect.rs +++ b/src/data/login_redirect.rs @@ -18,4 +18,4 @@ impl Default for LoginRedirect { fn default() -> Self { Self("/".to_string()) } -} \ No newline at end of file +} diff --git a/src/data/mod.rs b/src/data/mod.rs index 37bb154..ba74ba4 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,17 +1,17 @@ -pub mod app_config; -pub mod entity_manager; -pub mod session_identity; -pub mod user; -pub mod client; -pub mod remote_ip; -pub mod current_user; -pub mod openid_config; -pub mod jwt_signer; -pub mod id_token; -pub mod code_challenge; -pub mod open_id_user_info; pub mod access_token; -pub mod totp_key; +pub mod app_config; +pub mod client; +pub mod code_challenge; +pub mod crypto_wrapper; +pub mod current_user; +pub mod entity_manager; +pub mod id_token; +pub mod jwt_signer; pub mod login_redirect; +pub mod open_id_user_info; +pub mod openid_config; +pub mod remote_ip; +pub mod session_identity; +pub mod totp_key; +pub mod user; pub mod webauthn_manager; -pub mod crypto_wrapper; \ No newline at end of file diff --git a/src/data/open_id_user_info.rs b/src/data/open_id_user_info.rs index af7e703..04d35bc 100644 --- a/src/data/open_id_user_info.rs +++ b/src/data/open_id_user_info.rs @@ -21,4 +21,4 @@ pub struct OpenIDUserInfo { /// True if the End-User's e-mail address has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this e-mail address was controlled by the End-User at the time the verification was performed. The means by which an e-mail address is verified is context-specific, and dependent upon the trust framework or contractual agreements within which the parties are operating. pub email_verified: bool, -} \ No newline at end of file +} diff --git a/src/data/openid_config.rs b/src/data/openid_config.rs index 3587e8a..b3e6df0 100644 --- a/src/data/openid_config.rs +++ b/src/data/openid_config.rs @@ -34,4 +34,4 @@ pub struct OpenIDConfig { pub claims_supported: Vec<&'static str>, pub code_challenge_methods_supported: Vec<&'static str>, -} \ No newline at end of file +} diff --git a/src/data/remote_ip.rs b/src/data/remote_ip.rs index 1fd803f..25b5b2a 100644 --- a/src/data/remote_ip.rs +++ b/src/data/remote_ip.rs @@ -1,8 +1,8 @@ use std::net::IpAddr; -use actix_web::{Error, FromRequest, HttpRequest, web}; use actix_web::dev::Payload; -use futures_util::future::{Ready, ready}; +use actix_web::{web, Error, FromRequest, HttpRequest}; +use futures_util::future::{ready, Ready}; use crate::data::app_config::AppConfig; use crate::utils::network_utils::get_remote_ip; @@ -25,4 +25,4 @@ impl FromRequest for RemoteIP { let config: &web::Data = req.app_data().expect("AppData undefined!"); ready(Ok(RemoteIP(get_remote_ip(req, config.proxy_ip.as_deref())))) } -} \ No newline at end of file +} diff --git a/src/data/session_identity.rs b/src/data/session_identity.rs index cacc071..20a6246 100644 --- a/src/data/session_identity.rs +++ b/src/data/session_identity.rs @@ -33,8 +33,7 @@ impl<'a> SessionIdentity<'a> { fn get_session_data(&self) -> Option { if let Some(id) = self.0 { Self::deserialize_session_data(id.id().ok()) - } - else { + } else { None } } @@ -71,12 +70,15 @@ impl<'a> SessionIdentity<'a> { } pub fn set_user(&self, req: &HttpRequest, user: &User, status: SessionStatus) { - self.set_session_data(req, &SessionIdentityData { - id: Some(user.uid.clone()), - is_admin: user.admin, - auth_time: time(), - status, - }); + self.set_session_data( + req, + &SessionIdentityData { + id: Some(user.uid.clone()), + is_admin: user.admin, + auth_time: time(), + status, + }, + ); } pub fn set_status(&self, req: &HttpRequest, status: SessionStatus) { @@ -108,7 +110,9 @@ impl<'a> SessionIdentity<'a> { } pub fn user_id(&self) -> UserID { - self.get_session_data().unwrap_or_default().id + self.get_session_data() + .unwrap_or_default() + .id .expect("UserID should never be null here!") } diff --git a/src/data/totp_key.rs b/src/data/totp_key.rs index aac8697..53adf13 100644 --- a/src/data/totp_key.rs +++ b/src/data/totp_key.rs @@ -23,13 +23,15 @@ impl TotpKey { pub fn new_random() -> Self { let random_bytes = rand::thread_rng().gen::<[u8; 10]>(); Self { - encoded: base32::encode(BASE32_ALPHABET, &random_bytes) + encoded: base32::encode(BASE32_ALPHABET, &random_bytes), } } /// Get a key from an encoded secret pub fn from_encoded_secret(s: &str) -> Self { - Self { encoded: s.to_string() } + Self { + encoded: s.to_string(), + } } /// Get QrCode URL for user @@ -74,15 +76,19 @@ impl TotpKey { /// Get the code at a specific time fn get_code_at u64>(&self, get_time: F) -> Res { let gen = TotpGenerator::new() - .set_digit(NUM_DIGITS).unwrap() - .set_step(PERIOD).unwrap() + .set_digit(NUM_DIGITS) + .unwrap() + .set_step(PERIOD) + .unwrap() .set_hash_algorithm(HashAlgorithm::SHA1) .build(); let key = match base32::decode(BASE32_ALPHABET, &self.encoded) { None => { - return Err(Box::new( - std::io::Error::new(ErrorKind::Other, "Failed to decode base32 secret!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Failed to decode base32 secret!", + ))); } Some(k) => k, }; @@ -113,4 +119,4 @@ mod test { let key = TotpKey::from_encoded_secret("JBSWY3DPEHPK3PXP"); assert_eq!("124851", key.get_code_at(|| 1650470683).unwrap()); } -} \ No newline at end of file +} diff --git a/src/data/user.rs b/src/data/user.rs index 71e222e..908324f 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -32,14 +32,32 @@ impl TwoFactor { } } + pub fn description_str(&self) -> &'static str { + match self.kind { + TwoFactorType::TOTP(_) => "Login by entering an OTP code", + TwoFactorType::WEBAUTHN(_) => "Login using a security key", + } + } + + pub fn type_image(&self) -> &'static str { + match self.kind { + TwoFactorType::TOTP(_) => "/assets/img/pin.svg", + TwoFactorType::WEBAUTHN(_) => "/assets/img/key.svg", + } + } + pub fn login_url(&self, redirect_uri: &LoginRedirect) -> String { match self.kind { - TwoFactorType::TOTP(_) => format!("/2fa_otp?id={}&redirect={}", - self.id.0, redirect_uri.get_encoded()), - TwoFactorType::WEBAUTHN(_) => format!("/2fa_webauthn?id={}&redirect={}", - self.id.0, redirect_uri.get_encoded()), + TwoFactorType::TOTP(_) => format!("/2fa_otp?redirect={}", redirect_uri.get_encoded()), + TwoFactorType::WEBAUTHN(_) => { + format!("/2fa_webauthn?redirect={}", redirect_uri.get_encoded()) + } } } + + pub fn is_webauthn(&self) -> bool { + matches!(self.kind, TwoFactorType::WEBAUTHN(_)) + } } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -71,7 +89,7 @@ impl User { pub fn can_access_app(&self, id: &ClientID) -> bool { match &self.authorized_clients { None => true, - Some(c) => c.contains(id) + Some(c) => c.contains(id), } } @@ -94,6 +112,49 @@ impl User { pub fn find_factor(&self, factor_id: &FactorID) -> Option<&TwoFactor> { self.two_factor.iter().find(|f| f.id.eq(factor_id)) } + + pub fn has_webauthn_factor(&self) -> bool { + self.two_factor.iter().any(TwoFactor::is_webauthn) + } + + /// Get all registered OTP registered passwords + pub fn get_otp_factors(&self) -> Vec { + self.two_factor.iter().fold(vec![], |mut acc, factor| { + if let TwoFactorType::TOTP(key) = &factor.kind { + acc.push(key.clone()) + } + + acc + }) + } + + /// Get all registered 2FA webauthn public keys + pub fn get_webauthn_pub_keys(&self) -> Vec { + self.two_factor.iter().fold(vec![], |mut acc, factor| { + if let TwoFactorType::WEBAUTHN(key) = &factor.kind { + acc.push(*key.clone()) + } + + acc + }) + } + + /// Get the first factor of each kind of factors + pub fn get_distinct_factors_types(&self) -> Vec<&TwoFactor> { + let mut urls = vec![]; + + self.two_factor + .iter() + .filter(|f| { + if urls.contains(&f.type_str()) { + false + } else { + urls.push(f.type_str()); + true + } + }) + .collect::>() + } } impl PartialEq for User { @@ -157,8 +218,8 @@ impl EntityManager { /// Update user information fn update_user(&mut self, id: &UserID, update: F) -> bool - where - F: FnOnce(User) -> User, + where + F: FnOnce(User) -> User, { let user = match self.find_by_user_id(id) { None => return false, diff --git a/src/data/webauthn_manager.rs b/src/data/webauthn_manager.rs index 4d4ef16..bcf5f78 100644 --- a/src/data/webauthn_manager.rs +++ b/src/data/webauthn_manager.rs @@ -3,10 +3,15 @@ use std::sync::Arc; use actix_web::web; use uuid::Uuid; +use webauthn_rs::prelude::{ + CreationChallengeResponse, Passkey, PublicKeyCredential, RegisterPublicKeyCredential, + RequestChallengeResponse, +}; use webauthn_rs::{Webauthn, WebauthnBuilder}; -use webauthn_rs::prelude::{CreationChallengeResponse, Passkey, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse}; -use crate::constants::{APP_NAME, WEBAUTHN_LOGIN_CHALLENGE_EXPIRE, WEBAUTHN_REGISTER_CHALLENGE_EXPIRE}; +use crate::constants::{ + APP_NAME, WEBAUTHN_LOGIN_CHALLENGE_EXPIRE, WEBAUTHN_REGISTER_CHALLENGE_EXPIRE, +}; use crate::data::app_config::AppConfig; use crate::data::crypto_wrapper::CryptoWrapper; use crate::data::user::{User, UserID}; @@ -42,7 +47,6 @@ struct AuthStateOpaqueData { expire: u64, } - pub type WebAuthManagerReq = web::Data>; pub struct WebAuthManager { @@ -54,24 +58,23 @@ impl WebAuthManager { pub fn init(conf: &AppConfig) -> Self { Self { core: WebauthnBuilder::new( - conf.domain_name().split_once(':') + conf.domain_name() + .split_once(':') .map(|s| s.0) .unwrap_or_else(|| conf.domain_name()), &url::Url::parse(&conf.website_origin) - .expect("Failed to parse configuration origin!")) - .expect("Invalid Webauthn configuration") - .rp_name(APP_NAME) - .build() - .expect("Failed to build webauthn") - - , + .expect("Failed to parse configuration origin!"), + ) + .expect("Invalid Webauthn configuration") + .rp_name(APP_NAME) + .build() + .expect("Failed to build webauthn"), crypto_wrapper: CryptoWrapper::new_random(), } } pub fn start_register(&self, user: &User) -> Res { - let (creation_challenge, registration_state) - = self.core.start_passkey_registration( + let (creation_challenge, registration_state) = self.core.start_passkey_registration( Uuid::parse_str(&user.uid.0).expect("Failed to parse user id"), &user.username, &user.full_name(), @@ -88,29 +91,43 @@ impl WebAuthManager { }) } - pub fn finish_registration(&self, user: &User, opaque_state: &str, - pub_cred: RegisterPublicKeyCredential) -> Res { + pub fn finish_registration( + &self, + user: &User, + opaque_state: &str, + pub_cred: RegisterPublicKeyCredential, + ) -> Res { let state: RegisterKeyOpaqueData = self.crypto_wrapper.decrypt(opaque_state)?; if state.user_id != user.uid { - return Err(Box::new( - std::io::Error::new(ErrorKind::Other, "Invalid user for pubkey!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Invalid user for pubkey!", + ))); } if state.expire < time() { - return Err(Box::new( - std::io::Error::new(ErrorKind::Other, "Challenge has expired!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Challenge has expired!", + ))); } - let res = self.core - .finish_passkey_registration(&pub_cred, &serde_json::from_str(&state.registration_state)?)?; + let res = self.core.finish_passkey_registration( + &pub_cred, + &serde_json::from_str(&state.registration_state)?, + )?; Ok(WebauthnPubKey { creds: res }) } - pub fn start_authentication(&self, user_id: &UserID, key: &WebauthnPubKey) -> Res { - let (login_challenge, authentication_state) = self.core.start_passkey_authentication(&vec![ - key.creds.clone() - ])?; + pub fn start_authentication( + &self, + user_id: &UserID, + keys: &[WebauthnPubKey], + ) -> Res { + let (login_challenge, authentication_state) = self.core.start_passkey_authentication( + &keys.iter().map(|k| k.creds.clone()).collect::>(), + )?; Ok(AuthRequest { opaque_state: self.crypto_wrapper.encrypt(&AuthStateOpaqueData { @@ -122,22 +139,32 @@ impl WebAuthManager { }) } - pub fn finish_authentication(&self, user_id: &UserID, opaque_state: &str, - pub_cred: &PublicKeyCredential) -> Res { + pub fn finish_authentication( + &self, + user_id: &UserID, + opaque_state: &str, + pub_cred: &PublicKeyCredential, + ) -> Res { let state: AuthStateOpaqueData = self.crypto_wrapper.decrypt(opaque_state)?; if &state.user_id != user_id { - return Err(Box::new( - std::io::Error::new(ErrorKind::Other, "Invalid user for pubkey!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Invalid user for pubkey!", + ))); } if state.expire < time() { - return Err(Box::new( - std::io::Error::new(ErrorKind::Other, "Challenge has expired!"))); + return Err(Box::new(std::io::Error::new( + ErrorKind::Other, + "Challenge has expired!", + ))); } - self.core.finish_passkey_authentication(pub_cred, - &serde_json::from_str(&state.authentication_state)?)?; + self.core.finish_passkey_authentication( + pub_cred, + &serde_json::from_str(&state.authentication_state)?, + )?; Ok(()) } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 270286f..f642622 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,19 +4,19 @@ use std::sync::Arc; use actix::Actor; use actix_identity::config::LogoutBehaviour; use actix_identity::IdentityMiddleware; -use actix_session::SessionMiddleware; use actix_session::storage::CookieSessionStore; -use actix_web::{App, get, HttpResponse, HttpServer, middleware, web}; +use actix_session::SessionMiddleware; use actix_web::cookie::{Key, SameSite}; use actix_web::middleware::Logger; +use actix_web::{get, middleware, web, App, HttpResponse, HttpServer}; use clap::Parser; use basic_oidc::actors::bruteforce_actor::BruteForceActor; use basic_oidc::actors::openid_sessions_actor::OpenIDSessionsActor; use basic_oidc::actors::users_actor::UsersActor; use basic_oidc::constants::*; -use basic_oidc::controllers::*; use basic_oidc::controllers::assets_controller::assets_route; +use basic_oidc::controllers::*; use basic_oidc::data::app_config::AppConfig; use basic_oidc::data::client::ClientManager; use basic_oidc::data::entity_manager::EntityManager; @@ -72,8 +72,7 @@ async fn main() -> std::io::Result<()> { let users_actor = UsersActor::new(users).start(); let bruteforce_actor = BruteForceActor::default().start(); let openid_sessions_actor = OpenIDSessionsActor::default().start(); - let jwt_signer = JWTSigner::gen_from_memory() - .expect("Failed to generate JWKS key"); + let jwt_signer = JWTSigner::gen_from_memory().expect("Failed to generate JWKS key"); let webauthn_manager = Arc::new(WebAuthManager::init(&config)); log::info!("Server will listen on {}", config.listen_address); @@ -84,13 +83,14 @@ async fn main() -> std::io::Result<()> { .expect("Failed to load clients list!"); clients.apply_environment_variables(); - let session_mw = - SessionMiddleware::builder(CookieSessionStore::default(), - Key::from(config.token_key.as_bytes())) - .cookie_name(SESSION_COOKIE_NAME.to_string()) - .cookie_secure(config.secure_cookie()) - .cookie_same_site(SameSite::Lax) - .build(); + let session_mw = SessionMiddleware::builder( + CookieSessionStore::default(), + Key::from(config.token_key.as_bytes()), + ) + .cookie_name(SESSION_COOKIE_NAME.to_string()) + .cookie_secure(config.secure_cookie()) + .cookie_same_site(SameSite::Lax) + .build(); let identity_middleware = IdentityMiddleware::builder() .logout_behaviour(LogoutBehaviour::PurgeSession) @@ -106,74 +106,145 @@ async fn main() -> std::io::Result<()> { .app_data(web::Data::new(clients)) .app_data(web::Data::new(jwt_signer.clone())) .app_data(web::Data::new(webauthn_manager.clone())) - - .wrap(middleware::DefaultHeaders::new() - .add(("Permissions-Policy", "interest-cohort=()"))) + .wrap( + middleware::DefaultHeaders::new().add(("Permissions-Policy", "interest-cohort=()")), + ) .wrap(Logger::default()) .wrap(AuthMiddleware {}) .wrap(identity_middleware) .wrap(session_mw) - // main route - .route("/", web::get() - .to(|| async { HttpResponse::Found().append_header(("Location", "/settings")).finish() })) + .route( + "/", + web::get().to(|| async { + HttpResponse::Found() + .append_header(("Location", "/settings")) + .finish() + }), + ) .route("/robots.txt", web::get().to(assets_controller::robots_txt)) - // health route .service(health) - // Assets serving .route("/assets/{path:.*}", web::get().to(assets_route)) - // Login pages .route("/logout", web::get().to(login_controller::logout_route)) .route("/login", web::get().to(login_controller::login_route)) .route("/login", web::post().to(login_controller::login_route)) - .route("/reset_password", web::get().to(login_controller::reset_password_route)) - .route("/reset_password", web::post().to(login_controller::reset_password_route)) - .route("/2fa_auth", web::get().to(login_controller::choose_2fa_method)) + .route( + "/reset_password", + web::get().to(login_controller::reset_password_route), + ) + .route( + "/reset_password", + web::post().to(login_controller::reset_password_route), + ) + .route( + "/2fa_auth", + web::get().to(login_controller::choose_2fa_method), + ) .route("/2fa_otp", web::get().to(login_controller::login_with_otp)) .route("/2fa_otp", web::post().to(login_controller::login_with_otp)) - .route("/2fa_webauthn", web::get().to(login_controller::login_with_webauthn)) - + .route( + "/2fa_webauthn", + web::get().to(login_controller::login_with_webauthn), + ) // Login api - .route("/login/api/auth_webauthn", web::post().to(login_api::auth_webauthn)) - + .route( + "/login/api/auth_webauthn", + web::post().to(login_api::auth_webauthn), + ) // Settings routes - .route("/settings", web::get().to(settings_controller::account_settings_details_route)) - .route("/settings/change_password", web::get().to(settings_controller::change_password_route)) - .route("/settings/change_password", web::post().to(settings_controller::change_password_route)) - .route("/settings/two_factors", web::get().to(two_factors_controller::two_factors_route)) - .route("/settings/two_factors/add_totp", web::get().to(two_factors_controller::add_totp_factor_route)) - .route("/settings/two_factors/add_webauthn", web::get().to(two_factors_controller::add_webauthn_factor_route)) - + .route( + "/settings", + web::get().to(settings_controller::account_settings_details_route), + ) + .route( + "/settings/change_password", + web::get().to(settings_controller::change_password_route), + ) + .route( + "/settings/change_password", + web::post().to(settings_controller::change_password_route), + ) + .route( + "/settings/two_factors", + web::get().to(two_factors_controller::two_factors_route), + ) + .route( + "/settings/two_factors/add_totp", + web::get().to(two_factors_controller::add_totp_factor_route), + ) + .route( + "/settings/two_factors/add_webauthn", + web::get().to(two_factors_controller::add_webauthn_factor_route), + ) // User API - .route("/settings/api/two_factor/save_totp_factor", web::post().to(two_factor_api::save_totp_factor)) - .route("/settings/api/two_factor/save_webauthn_factor", web::post().to(two_factor_api::save_webauthn_factor)) - .route("/settings/api/two_factor/delete_factor", web::post().to(two_factor_api::delete_factor)) - + .route( + "/settings/api/two_factor/save_totp_factor", + web::post().to(two_factor_api::save_totp_factor), + ) + .route( + "/settings/api/two_factor/save_webauthn_factor", + web::post().to(two_factor_api::save_webauthn_factor), + ) + .route( + "/settings/api/two_factor/delete_factor", + web::post().to(two_factor_api::delete_factor), + ) // Admin routes - .route("/admin", web::get() - .to(|| async { HttpResponse::Found().append_header(("Location", "/settings")).finish() })) - .route("/admin/clients", web::get().to(admin_controller::clients_route)) + .route( + "/admin", + web::get().to(|| async { + HttpResponse::Found() + .append_header(("Location", "/settings")) + .finish() + }), + ) + .route( + "/admin/clients", + web::get().to(admin_controller::clients_route), + ) .route("/admin/users", web::get().to(admin_controller::users_route)) - .route("/admin/users", web::post().to(admin_controller::users_route)) - .route("/admin/create_user", web::get().to(admin_controller::create_user)) - .route("/admin/edit_user", web::get().to(admin_controller::edit_user)) - + .route( + "/admin/users", + web::post().to(admin_controller::users_route), + ) + .route( + "/admin/create_user", + web::get().to(admin_controller::create_user), + ) + .route( + "/admin/edit_user", + web::get().to(admin_controller::edit_user), + ) // Admin API - .route("/admin/api/find_username", web::post().to(admin_api::find_username)) - .route("/admin/api/delete_user", web::post().to(admin_api::delete_user)) - + .route( + "/admin/api/find_username", + web::post().to(admin_api::find_username), + ) + .route( + "/admin/api/delete_user", + web::post().to(admin_api::delete_user), + ) // OpenID routes - .route("/.well-known/openid-configuration", web::get().to(openid_controller::get_configuration)) + .route( + "/.well-known/openid-configuration", + web::get().to(openid_controller::get_configuration), + ) .route(AUTHORIZE_URI, web::get().to(openid_controller::authorize)) .route(TOKEN_URI, web::post().to(openid_controller::token)) .route(CERT_URI, web::get().to(openid_controller::cert_uri)) - .route(USERINFO_URI, web::post().to(openid_controller::user_info_post)) - .route(USERINFO_URI, web::get().to(openid_controller::user_info_get)) + .route( + USERINFO_URI, + web::post().to(openid_controller::user_info_post), + ) + .route( + USERINFO_URI, + web::get().to(openid_controller::user_info_get), + ) }) - .bind(listen_address)? - .run() - .await + .bind(listen_address)? + .run() + .await } diff --git a/src/middlewares/auth_middleware.rs b/src/middlewares/auth_middleware.rs index ea4e735..3791af4 100644 --- a/src/middlewares/auth_middleware.rs +++ b/src/middlewares/auth_middleware.rs @@ -1,18 +1,20 @@ //! # Authentication middleware -use std::future::{Future, ready, Ready}; +use std::future::{ready, Future, Ready}; use std::pin::Pin; use std::rc::Rc; use actix_identity::IdentityExt; -use actix_web::{ - dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, - Error, HttpResponse, web, -}; use actix_web::body::EitherBody; use actix_web::http::{header, Method}; +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + web, Error, HttpResponse, +}; -use crate::constants::{ADMIN_ROUTES, AUTHENTICATED_ROUTES, AUTHORIZE_URI, TOKEN_URI, USERINFO_URI}; +use crate::constants::{ + ADMIN_ROUTES, AUTHENTICATED_ROUTES, AUTHORIZE_URI, TOKEN_URI, USERINFO_URI, +}; use crate::controllers::base_controller::{build_fatal_error_page, redirect_user_for_login}; use crate::data::app_config::AppConfig; use crate::data::session_identity::{SessionIdentity, SessionIdentityData, SessionStatus}; @@ -27,10 +29,10 @@ pub struct AuthMiddleware; // `S` - type of the next service // `B` - type of response's body impl Transform for AuthMiddleware - where - S: Service, Error=Error> + 'static, - S::Future: 'static, - B: 'static, +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, { type Response = ServiceResponse>; type Error = Error; @@ -62,22 +64,21 @@ impl ConnStatus { } } - pub struct AuthInnerMiddleware { service: Rc, } impl Service for AuthInnerMiddleware - where - S: Service, Error=Error> + 'static, - S::Future: 'static, - B: 'static, +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, { type Response = ServiceResponse>; type Error = Error; #[allow(clippy::type_complexity)] - type Future = Pin>>>; + type Future = Pin>>>; forward_ready!(service); @@ -90,7 +91,8 @@ impl Service for AuthInnerMiddleware // Check if POST request comes from another website (block invalid origins) let origin = req.headers().get(header::ORIGIN); - if req.method() == Method::POST && req.path() != TOKEN_URI && req.path() != USERINFO_URI { + if req.method() == Method::POST && req.path() != TOKEN_URI && req.path() != USERINFO_URI + { if let Some(o) = origin { if !o.to_str().unwrap_or("bad").eq(&config.website_origin) { log::warn!( @@ -118,14 +120,14 @@ impl Service for AuthInnerMiddleware let session_data = SessionIdentity::deserialize_session_data(id); let session = match session_data { Some(SessionIdentityData { - status: SessionStatus::SignedIn, - is_admin: true, - .. - }) => ConnStatus::Admin, + status: SessionStatus::SignedIn, + is_admin: true, + .. + }) => ConnStatus::Admin, Some(SessionIdentityData { - status: SessionStatus::SignedIn, - .. - }) => ConnStatus::RegularUser, + status: SessionStatus::SignedIn, + .. + }) => ConnStatus::RegularUser, _ => ConnStatus::SignedOut, }; @@ -135,10 +137,13 @@ impl Service for AuthInnerMiddleware // Redirect user to login page if !session.is_auth() && (req.path().starts_with(ADMIN_ROUTES) - || req.path().starts_with(AUTHENTICATED_ROUTES) || req.path().eq(AUTHORIZE_URI)) + || req.path().starts_with(AUTHENTICATED_ROUTES) + || req.path().eq(AUTHORIZE_URI)) { - log::debug!("Redirect unauthenticated user from {} to authorization route.", - req.path()); + log::debug!( + "Redirect unauthenticated user from {} to authorization route.", + req.path() + ); let path = req.uri().to_string(); return Ok(req @@ -149,10 +154,9 @@ impl Service for AuthInnerMiddleware // Restrict access to admin pages if !session.is_admin() && req.path().starts_with(ADMIN_ROUTES) { return Ok(req - .into_response( - HttpResponse::Unauthorized().body( - build_fatal_error_page("You are not allowed to access this resource.")), - ) + .into_response(HttpResponse::Unauthorized().body(build_fatal_error_page( + "You are not allowed to access this resource.", + ))) .map_into_right_body()); } diff --git a/src/utils/crypt_utils.rs b/src/utils/crypt_utils.rs index 14b2885..91a5340 100644 --- a/src/utils/crypt_utils.rs +++ b/src/utils/crypt_utils.rs @@ -3,4 +3,4 @@ use digest::Digest; #[inline] pub fn sha256(input: &[u8]) -> Vec { sha2::Sha256::digest(input).to_vec() -} \ No newline at end of file +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index dcc3635..a93d33b 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,5 @@ +pub mod crypt_utils; pub mod err; -pub mod time; pub mod network_utils; pub mod string_utils; -pub mod crypt_utils; \ No newline at end of file +pub mod time; diff --git a/src/utils/network_utils.rs b/src/utils/network_utils.rs index 4a4838b..874a5e4 100644 --- a/src/utils/network_utils.rs +++ b/src/utils/network_utils.rs @@ -16,7 +16,6 @@ pub fn match_ip(pattern: &str, ip: &str) -> bool { false } - /// Get the remote IP address pub fn get_remote_ip(req: &HttpRequest, proxy_ip: Option<&str>) -> IpAddr { let mut ip = req.peer_addr().unwrap().ip(); @@ -78,7 +77,10 @@ mod test { let req = TestRequest::default() .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .to_http_request(); - assert_eq!(get_remote_ip(&req, None), "192.168.1.1".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, None), + "192.168.1.1".parse::().unwrap() + ); } #[test] @@ -87,7 +89,10 @@ mod test { .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .insert_header(("X-Forwarded-For", "1.1.1.1")) .to_http_request(); - assert_eq!(get_remote_ip(&req, Some("192.168.1.1")), "1.1.1.1".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, Some("192.168.1.1")), + "1.1.1.1".parse::().unwrap() + ); } #[test] @@ -96,7 +101,10 @@ mod test { .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) .to_http_request(); - assert_eq!(get_remote_ip(&req, Some("192.168.1.1")), "1.1.1.1".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, Some("192.168.1.1")), + "1.1.1.1".parse::().unwrap() + ); } #[test] @@ -105,7 +113,10 @@ mod test { .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .insert_header(("X-Forwarded-For", "10::1, 1.2.2.2")) .to_http_request(); - assert_eq!(get_remote_ip(&req, Some("192.168.1.1")), "10::".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, Some("192.168.1.1")), + "10::".parse::().unwrap() + ); } #[test] @@ -114,7 +125,10 @@ mod test { .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) .to_http_request(); - assert_eq!(get_remote_ip(&req, None), "192.168.1.1".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, None), + "192.168.1.1".parse::().unwrap() + ); } #[test] @@ -123,7 +137,10 @@ mod test { .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) .to_http_request(); - assert_eq!(get_remote_ip(&req, Some("192.168.1.2")), "192.168.1.1".parse::().unwrap()); + assert_eq!( + get_remote_ip(&req, Some("192.168.1.2")), + "192.168.1.1".parse::().unwrap() + ); } #[test] @@ -141,7 +158,10 @@ mod test { #[test] fn parse_ip_v6_address() { let ip = parse_ip("2a00:1450:4007:813::200e").unwrap(); - assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0x2a00, 0x1450, 0x4007, 0x813, 0, 0, 0, 0))); + assert_eq!( + ip, + IpAddr::V6(Ipv6Addr::new(0x2a00, 0x1450, 0x4007, 0x813, 0, 0, 0, 0)) + ); } #[test] @@ -155,4 +175,4 @@ mod test { let ip = parse_ip("a::1").unwrap(); assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0xa, 0, 0, 0, 0, 0, 0, 0))); } -} \ No newline at end of file +} diff --git a/src/utils/string_utils.rs b/src/utils/string_utils.rs index 2427066..10a9fa3 100644 --- a/src/utils/string_utils.rs +++ b/src/utils/string_utils.rs @@ -16,7 +16,11 @@ pub fn apply_env_vars(val: &str) -> String { let mut val = val.to_string(); if let Some(varname_with_wrapper) = regex_find!(r#"\$\{[a-zA-Z0-9_-]+\}"#, &val) { - let varname = varname_with_wrapper.strip_prefix("${").unwrap().strip_suffix('}').unwrap(); + let varname = varname_with_wrapper + .strip_prefix("${") + .unwrap() + .strip_suffix('}') + .unwrap(); let value = match std::env::var(varname) { Ok(v) => v, Err(e) => { @@ -34,8 +38,8 @@ pub fn apply_env_vars(val: &str) -> String { #[cfg(test)] mod test { - use std::env; use crate::utils::string_utils::apply_env_vars; + use std::env; const VAR_ONE: &str = "VAR_ONE"; #[test] @@ -52,4 +56,4 @@ mod test { let src = format!("This is ${{{}}}", VAR_INVALID); assert_eq!(src, apply_env_vars(&src)); } -} \ No newline at end of file +} diff --git a/templates/login/choose_second_factor.html b/templates/login/choose_second_factor.html index 384b291..69bcd31 100644 --- a/templates/login/choose_second_factor.html +++ b/templates/login/choose_second_factor.html @@ -4,13 +4,15 @@

You need to validate a second factor to complete your login.

- {% for factor in factors %} -

- - {{ factor.name }}
- {{ factor.type_str() }} -
-

+ {% for factor in user.get_distinct_factors_types() %} + + Factor icon +
+ {{ factor.type_str() }}
+ {{ factor.description_str() }} +
+
+
{% endfor %}
diff --git a/templates/login/opt_input.html b/templates/login/otp_input.html similarity index 92% rename from templates/login/opt_input.html rename to templates/login/otp_input.html index fed383a..27c9ba3 100644 --- a/templates/login/opt_input.html +++ b/templates/login/otp_input.html @@ -10,8 +10,8 @@
-

Please go to your authenticator app {{ factor.name }}, generate a new code and enter it here:

-
+

Please open one of your registered authenticator app, generate a new code and enter it here:

+
@@ -34,6 +34,9 @@