Merge pull request 'Bypass 2FA after successful login' (#72) from bypass_2fa_after_successful_login into master
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is passing
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			Reviewed-on: #72
This commit is contained in:
		
							
								
								
									
										161
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										161
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -222,7 +222,7 @@ dependencies = [ | ||||
|  "serde_urlencoded", | ||||
|  "smallvec", | ||||
|  "socket2", | ||||
|  "time", | ||||
|  "time 0.3.17", | ||||
|  "url", | ||||
| ] | ||||
|  | ||||
| @@ -325,6 +325,15 @@ dependencies = [ | ||||
|  "alloc-no-stdlib", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "android_system_properties" | ||||
| version = "0.1.5" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "anyhow" | ||||
| version = "1.0.66" | ||||
| @@ -392,7 +401,7 @@ dependencies = [ | ||||
|  "num-traits", | ||||
|  "rusticata-macros", | ||||
|  "thiserror", | ||||
|  "time", | ||||
|  "time 0.3.17", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| @@ -495,6 +504,7 @@ dependencies = [ | ||||
|  "base64", | ||||
|  "bcrypt", | ||||
|  "bincode", | ||||
|  "chrono", | ||||
|  "clap", | ||||
|  "digest", | ||||
|  "env_logger", | ||||
| @@ -644,6 +654,21 @@ version = "1.0.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" | ||||
|  | ||||
| [[package]] | ||||
| name = "chrono" | ||||
| version = "0.4.23" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" | ||||
| dependencies = [ | ||||
|  "iana-time-zone", | ||||
|  "js-sys", | ||||
|  "num-integer", | ||||
|  "num-traits", | ||||
|  "time 0.1.44", | ||||
|  "wasm-bindgen", | ||||
|  "winapi", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cipher" | ||||
| version = "0.4.3" | ||||
| @@ -699,10 +724,20 @@ checksum = "454038500439e141804c655b4cd1bc6a70bcb95cd2bc9463af5661b6956f0e46" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "once_cell", | ||||
|  "wasi", | ||||
|  "wasi 0.11.0+wasi-snapshot-preview1", | ||||
|  "wasm-bindgen", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "codespan-reporting" | ||||
| version = "0.11.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" | ||||
| dependencies = [ | ||||
|  "termcolor", | ||||
|  "unicode-width", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "color_quant" | ||||
| version = "1.1.0" | ||||
| @@ -752,10 +787,16 @@ dependencies = [ | ||||
|  "rand", | ||||
|  "sha2", | ||||
|  "subtle", | ||||
|  "time", | ||||
|  "time 0.3.17", | ||||
|  "version_check", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "core-foundation-sys" | ||||
| version = "0.8.3" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" | ||||
|  | ||||
| [[package]] | ||||
| name = "cpufeatures" | ||||
| version = "0.2.5" | ||||
| @@ -861,6 +902,50 @@ dependencies = [ | ||||
|  "cipher", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cxx" | ||||
| version = "1.0.81" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "97abf9f0eca9e52b7f81b945524e76710e6cb2366aead23b7d4fbf72e281f888" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
|  "cxxbridge-flags", | ||||
|  "cxxbridge-macro", | ||||
|  "link-cplusplus", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cxx-build" | ||||
| version = "1.0.81" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "7cc32cc5fea1d894b77d269ddb9f192110069a8a9c1f1d441195fba90553dea3" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
|  "codespan-reporting", | ||||
|  "once_cell", | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "scratch", | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "cxxbridge-flags" | ||||
| version = "1.0.81" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "8ca220e4794c934dc6b1207c3b42856ad4c302f2df1712e9f8d2eec5afaacf1f" | ||||
|  | ||||
| [[package]] | ||||
| name = "cxxbridge-macro" | ||||
| version = "1.0.81" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "b846f081361125bfc8dc9d3940c84e1fd83ba54bbca7b17cd29483c828be0704" | ||||
| dependencies = [ | ||||
|  "proc-macro2", | ||||
|  "quote", | ||||
|  "syn", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "data-encoding" | ||||
| version = "2.3.2" | ||||
| @@ -1140,7 +1225,7 @@ dependencies = [ | ||||
|  "cfg-if", | ||||
|  "js-sys", | ||||
|  "libc", | ||||
|  "wasi", | ||||
|  "wasi 0.11.0+wasi-snapshot-preview1", | ||||
|  "wasm-bindgen", | ||||
| ] | ||||
|  | ||||
| @@ -1322,6 +1407,30 @@ version = "2.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" | ||||
|  | ||||
| [[package]] | ||||
| name = "iana-time-zone" | ||||
| version = "0.1.53" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" | ||||
| dependencies = [ | ||||
|  "android_system_properties", | ||||
|  "core-foundation-sys", | ||||
|  "iana-time-zone-haiku", | ||||
|  "js-sys", | ||||
|  "wasm-bindgen", | ||||
|  "winapi", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "iana-time-zone-haiku" | ||||
| version = "0.1.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" | ||||
| dependencies = [ | ||||
|  "cxx", | ||||
|  "cxx-build", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "idna" | ||||
| version = "0.3.0" | ||||
| @@ -1516,6 +1625,15 @@ version = "0.2.6" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" | ||||
|  | ||||
| [[package]] | ||||
| name = "link-cplusplus" | ||||
| version = "1.0.7" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9272ab7b96c9046fbc5bc56c06c117cb639fe2d509df0c421cad82d2915cf369" | ||||
| dependencies = [ | ||||
|  "cc", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "local-channel" | ||||
| version = "0.1.3" | ||||
| @@ -1616,7 +1734,7 @@ checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "log", | ||||
|  "wasi", | ||||
|  "wasi 0.11.0+wasi-snapshot-preview1", | ||||
|  "windows-sys", | ||||
| ] | ||||
|  | ||||
| @@ -2163,6 +2281,12 @@ version = "1.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" | ||||
|  | ||||
| [[package]] | ||||
| name = "scratch" | ||||
| version = "1.0.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "9c8132065adcfd6e02db789d9285a0deb2f3fcb04002865ab67d5fb103533898" | ||||
|  | ||||
| [[package]] | ||||
| name = "sec1" | ||||
| version = "0.3.0" | ||||
| @@ -2424,6 +2548,17 @@ dependencies = [ | ||||
|  "weezl", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "time" | ||||
| version = "0.1.44" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" | ||||
| dependencies = [ | ||||
|  "libc", | ||||
|  "wasi 0.10.0+wasi-snapshot-preview1", | ||||
|  "winapi", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "time" | ||||
| version = "0.3.17" | ||||
| @@ -2585,6 +2720,12 @@ dependencies = [ | ||||
|  "tinyvec", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
| name = "unicode-width" | ||||
| version = "0.1.10" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" | ||||
|  | ||||
| [[package]] | ||||
| name = "unicode-xid" | ||||
| version = "0.2.4" | ||||
| @@ -2659,6 +2800,12 @@ version = "0.9.4" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" | ||||
|  | ||||
| [[package]] | ||||
| name = "wasi" | ||||
| version = "0.10.0+wasi-snapshot-preview1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" | ||||
|  | ||||
| [[package]] | ||||
| name = "wasi" | ||||
| version = "0.11.0+wasi-snapshot-preview1" | ||||
| @@ -2888,7 +3035,7 @@ dependencies = [ | ||||
|  "oid-registry", | ||||
|  "rusticata-macros", | ||||
|  "thiserror", | ||||
|  "time", | ||||
|  "time 0.3.17", | ||||
| ] | ||||
|  | ||||
| [[package]] | ||||
|   | ||||
| @@ -36,3 +36,4 @@ webauthn-rs = { version = "0.4.7", features = ["danger-allow-state-serialisation | ||||
| url = "2.3.1" | ||||
| aes-gcm = { version = "0.10.1", features = ["aes"] } | ||||
| bincode = "1.3.3" | ||||
| chrono = "0.4.22" | ||||
| @@ -1,4 +1,5 @@ | ||||
| use actix::{Actor, Context, Handler, Message, MessageResult}; | ||||
| use std::net::IpAddr; | ||||
|  | ||||
| use crate::data::entity_manager::EntityManager; | ||||
| use crate::data::user::{User, UserID}; | ||||
| @@ -8,7 +9,7 @@ pub enum LoginResult { | ||||
|     AccountNotFound, | ||||
|     InvalidPassword, | ||||
|     AccountDisabled, | ||||
|     Success(User), | ||||
|     Success(Box<User>), | ||||
| } | ||||
|  | ||||
| #[derive(Message)] | ||||
| @@ -50,6 +51,14 @@ pub struct ChangePasswordRequest { | ||||
| #[derive(Debug)] | ||||
| pub struct ChangePasswordResult(pub bool); | ||||
|  | ||||
| #[derive(Message)] | ||||
| #[rtype(result = "bool")] | ||||
| pub struct AddSuccessful2FALogin(pub UserID, pub IpAddr); | ||||
|  | ||||
| #[derive(Message)] | ||||
| #[rtype(result = "bool")] | ||||
| pub struct Clear2FALoginHistory(pub UserID); | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct UpdateUserResult(pub bool); | ||||
|  | ||||
| @@ -93,7 +102,7 @@ impl Handler<LoginRequest> for UsersActor { | ||||
|                     return MessageResult(LoginResult::AccountDisabled); | ||||
|                 } | ||||
|  | ||||
|                 MessageResult(LoginResult::Success(user)) | ||||
|                 MessageResult(LoginResult::Success(Box::new(user))) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -111,6 +120,22 @@ impl Handler<ChangePasswordRequest> for UsersActor { | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Handler<AddSuccessful2FALogin> for UsersActor { | ||||
|     type Result = <AddSuccessful2FALogin as actix::Message>::Result; | ||||
|  | ||||
|     fn handle(&mut self, msg: AddSuccessful2FALogin, _ctx: &mut Self::Context) -> Self::Result { | ||||
|         self.manager | ||||
|             .save_new_successful_2fa_authentication(&msg.0, msg.1) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Handler<Clear2FALoginHistory> for UsersActor { | ||||
|     type Result = <Clear2FALoginHistory as actix::Message>::Result; | ||||
|     fn handle(&mut self, msg: Clear2FALoginHistory, _ctx: &mut Self::Context) -> Self::Result { | ||||
|         self.manager.clear_2fa_login_history(&msg.0) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Handler<GetUserRequest> for UsersActor { | ||||
|     type Result = MessageResult<GetUserRequest>; | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,10 @@ pub const MAX_INACTIVITY_DURATION: u64 = 60 * 30; | ||||
| /// Maximum session duration (6 hours) | ||||
| pub const MAX_SESSION_DURATION: u64 = 3600 * 6; | ||||
|  | ||||
| /// When the user successfully authenticate using 2FA, period of time during which the user is | ||||
| /// exempted from this IP address to use 2FA | ||||
| pub const SECOND_FACTOR_EXEMPTION_AFTER_SUCCESSFUL_LOGIN: u64 = 7 * 24 * 3600; | ||||
|  | ||||
| /// Minimum password length | ||||
| pub const MIN_PASS_LEN: usize = 4; | ||||
|  | ||||
|   | ||||
| @@ -55,10 +55,12 @@ pub struct UpdateUserQuery { | ||||
|     email: String, | ||||
|     gen_new_password: Option<String>, | ||||
|     enabled: Option<String>, | ||||
|     two_factor_exemption_after_successful_login: Option<String>, | ||||
|     admin: Option<String>, | ||||
|     grant_type: String, | ||||
|     granted_clients: String, | ||||
|     two_factor: String, | ||||
|     clear_2fa_history: Option<String>, | ||||
| } | ||||
|  | ||||
| pub async fn users_route( | ||||
| @@ -84,6 +86,10 @@ pub async fn users_route( | ||||
|         user.last_name = update.0.last_name; | ||||
|         user.email = update.0.email; | ||||
|         user.enabled = update.0.enabled.is_some(); | ||||
|         user.two_factor_exemption_after_successful_login = update | ||||
|             .0 | ||||
|             .two_factor_exemption_after_successful_login | ||||
|             .is_some(); | ||||
|         user.admin = update.0.admin.is_some(); | ||||
|  | ||||
|         let factors_to_keep = update.0.two_factor.split(';').collect::<Vec<_>>(); | ||||
| @@ -109,10 +115,15 @@ pub async fn users_route( | ||||
|                 let temp_pass = rand_str(TEMPORARY_PASSWORDS_LEN); | ||||
|                 user.password = hash_password(&temp_pass).expect("Failed to hash password"); | ||||
|                 user.need_reset_password = true; | ||||
|                 user.last_successful_2fa = Default::default(); | ||||
|                 Some(temp_pass) | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         if update.0.clear_2fa_history.is_some() { | ||||
|             user.last_successful_2fa = Default::default(); | ||||
|         } | ||||
|  | ||||
|         let res = users | ||||
|             .send(users_actor::UpdateUserRequest(user.clone())) | ||||
|             .await | ||||
|   | ||||
| @@ -1,3 +1,7 @@ | ||||
| use crate::actors::users_actor; | ||||
| use crate::actors::users_actor::UsersActor; | ||||
| use crate::data::remote_ip::RemoteIP; | ||||
| use actix::Addr; | ||||
| use actix_identity::Identity; | ||||
| use actix_web::{web, HttpRequest, HttpResponse, Responder}; | ||||
| use webauthn_rs::prelude::PublicKeyCredential; | ||||
| @@ -16,6 +20,8 @@ pub async fn auth_webauthn( | ||||
|     req: web::Json<AuthWebauthnRequest>, | ||||
|     manager: WebAuthManagerReq, | ||||
|     http_req: HttpRequest, | ||||
|     remote_ip: RemoteIP, | ||||
|     users: web::Data<Addr<UsersActor>>, | ||||
| ) -> impl Responder { | ||||
|     if !SessionIdentity(Some(&id)).need_2fa_auth() { | ||||
|         return HttpResponse::Unauthorized().json("No 2FA required!"); | ||||
| @@ -25,6 +31,11 @@ pub async fn auth_webauthn( | ||||
|  | ||||
|     match manager.finish_authentication(&user_id, &req.opaque_state, &req.credential) { | ||||
|         Ok(_) => { | ||||
|             users | ||||
|                 .send(users_actor::AddSuccessful2FALogin(user_id, remote_ip.0)) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|             SessionIdentity(Some(&id)).set_status(&http_req, SessionStatus::SignedIn); | ||||
|             HttpResponse::Ok().body("You are authenticated!") | ||||
|         } | ||||
|   | ||||
| @@ -139,7 +139,8 @@ pub async fn login_route( | ||||
|             LoginResult::Success(user) => { | ||||
|                 let status = if user.need_reset_password { | ||||
|                     SessionStatus::NeedNewPassword | ||||
|                 } else if user.has_two_factor() { | ||||
|                 } else if user.has_two_factor() && !user.can_bypass_two_factors_for_ip(remote_ip.0) | ||||
|                 { | ||||
|                     SessionStatus::Need2FA | ||||
|                 } else { | ||||
|                     SessionStatus::SignedIn | ||||
| @@ -326,6 +327,7 @@ pub async fn login_with_otp( | ||||
|     form: Option<web::Form<LoginWithOTPForm>>, | ||||
|     users: web::Data<Addr<UsersActor>>, | ||||
|     http_req: HttpRequest, | ||||
|     remote_ip: RemoteIP, | ||||
| ) -> impl Responder { | ||||
|     let mut danger = None; | ||||
|  | ||||
| @@ -354,6 +356,11 @@ pub async fn login_with_otp( | ||||
|         { | ||||
|             danger = Some("Specified code is invalid!".to_string()); | ||||
|         } else { | ||||
|             users | ||||
|                 .send(users_actor::AddSuccessful2FALogin(user.uid, remote_ip.0)) | ||||
|                 .await | ||||
|                 .unwrap(); | ||||
|  | ||||
|             SessionIdentity(id.as_ref()).set_status(&http_req, SessionStatus::SignedIn); | ||||
|             return redirect_user(query.redirect.get()); | ||||
|         } | ||||
|   | ||||
| @@ -120,3 +120,15 @@ pub async fn delete_factor( | ||||
|         HttpResponse::Ok().body("Removed factor!") | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub async fn clear_login_history( | ||||
|     user: CurrentUser, | ||||
|     users: web::Data<Addr<UsersActor>>, | ||||
| ) -> impl Responder { | ||||
|     users | ||||
|         .send(users_actor::Clear2FALoginHistory(user.uid.clone())) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|  | ||||
|     HttpResponse::Ok().body("History successfully cleared") | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,14 @@ | ||||
| use std::collections::HashMap; | ||||
| use std::net::IpAddr; | ||||
|  | ||||
| use crate::constants::SECOND_FACTOR_EXEMPTION_AFTER_SUCCESSFUL_LOGIN; | ||||
| use crate::data::client::ClientID; | ||||
| use crate::data::entity_manager::EntityManager; | ||||
| use crate::data::login_redirect::LoginRedirect; | ||||
| use crate::data::totp_key::TotpKey; | ||||
| use crate::data::webauthn_manager::WebauthnPubKey; | ||||
| use crate::utils::err::Res; | ||||
| use crate::utils::time::{fmt_time, time}; | ||||
|  | ||||
| #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] | ||||
| pub struct UserID(pub String); | ||||
| @@ -60,6 +65,19 @@ impl TwoFactor { | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub struct Successful2FALogin { | ||||
|     pub ip: IpAddr, | ||||
|     pub time: u64, | ||||
|     pub can_bypass_2fa: bool, | ||||
| } | ||||
|  | ||||
| impl Successful2FALogin { | ||||
|     pub fn fmt_time(&self) -> String { | ||||
|         fmt_time(self.time) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] | ||||
| pub struct User { | ||||
|     pub uid: UserID, | ||||
| @@ -76,6 +94,15 @@ pub struct User { | ||||
|     #[serde(default)] | ||||
|     pub two_factor: Vec<TwoFactor>, | ||||
|  | ||||
|     /// Exempt the user from validating a second factor after a previous successful authentication | ||||
|     /// for a defined amount of time | ||||
|     #[serde(default)] | ||||
|     pub two_factor_exemption_after_successful_login: bool, | ||||
|  | ||||
|     /// IP addresses of last successful logins | ||||
|     #[serde(default)] | ||||
|     pub last_successful_2fa: HashMap<IpAddr, u64>, | ||||
|  | ||||
|     /// None = all services | ||||
|     /// Some([]) = no service | ||||
|     pub authorized_clients: Option<Vec<ClientID>>, | ||||
| @@ -101,6 +128,13 @@ impl User { | ||||
|         !self.two_factor.is_empty() | ||||
|     } | ||||
|  | ||||
|     pub fn can_bypass_two_factors_for_ip(&self, ip: IpAddr) -> bool { | ||||
|         self.two_factor_exemption_after_successful_login | ||||
|             && self.last_successful_2fa.get(&ip).unwrap_or(&0) | ||||
|                 + SECOND_FACTOR_EXEMPTION_AFTER_SUCCESSFUL_LOGIN | ||||
|                 > time() | ||||
|     } | ||||
|  | ||||
|     pub fn add_factor(&mut self, factor: TwoFactor) { | ||||
|         self.two_factor.push(factor); | ||||
|     } | ||||
| @@ -155,6 +189,22 @@ impl User { | ||||
|             }) | ||||
|             .collect::<Vec<_>>() | ||||
|     } | ||||
|  | ||||
|     pub fn remove_outdated_successful_2fa_attempts(&mut self) { | ||||
|         self.last_successful_2fa | ||||
|             .retain(|_, t| *t + SECOND_FACTOR_EXEMPTION_AFTER_SUCCESSFUL_LOGIN > time()); | ||||
|     } | ||||
|  | ||||
|     pub fn get_formatted_2fa_successful_logins(&self) -> Vec<Successful2FALogin> { | ||||
|         self.last_successful_2fa | ||||
|             .iter() | ||||
|             .map(|(ip, time)| Successful2FALogin { | ||||
|                 ip: *ip, | ||||
|                 time: *time, | ||||
|                 can_bypass_2fa: self.can_bypass_two_factors_for_ip(*ip), | ||||
|             }) | ||||
|             .collect::<Vec<_>>() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PartialEq for User { | ||||
| @@ -178,6 +228,8 @@ impl Default for User { | ||||
|             enabled: true, | ||||
|             admin: false, | ||||
|             two_factor: vec![], | ||||
|             two_factor_exemption_after_successful_login: false, | ||||
|             last_successful_2fa: Default::default(), | ||||
|             authorized_clients: Some(Vec::new()), | ||||
|         } | ||||
|     } | ||||
| @@ -246,6 +298,25 @@ impl EntityManager<User> { | ||||
|         self.update_user(id, |mut user| { | ||||
|             user.password = new_hash; | ||||
|             user.need_reset_password = temporary; | ||||
|             user.two_factor_exemption_after_successful_login = Default::default(); | ||||
|             user | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn save_new_successful_2fa_authentication(&mut self, id: &UserID, ip: IpAddr) -> bool { | ||||
|         self.update_user(id, |mut user| { | ||||
|             user.last_successful_2fa.insert(ip, time()); | ||||
|  | ||||
|             // Remove outdated successful attempts | ||||
|             user.remove_outdated_successful_2fa_attempts(); | ||||
|  | ||||
|             user | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     pub fn clear_2fa_login_history(&mut self, id: &UserID) -> bool { | ||||
|         self.update_user(id, |mut user| { | ||||
|             user.last_successful_2fa = Default::default(); | ||||
|             user | ||||
|         }) | ||||
|     } | ||||
|   | ||||
| @@ -192,6 +192,11 @@ async fn main() -> std::io::Result<()> { | ||||
|                 "/settings/api/two_factor/delete_factor", | ||||
|                 web::post().to(two_factor_api::delete_factor), | ||||
|             ) | ||||
|             .route( | ||||
|                 "/settings/api/two_factor/clear_login_history", | ||||
|                 // Use POST to prevent CSRF | ||||
|                 web::post().to(two_factor_api::clear_login_history), | ||||
|             ) | ||||
|             // Admin routes | ||||
|             .route( | ||||
|                 "/admin", | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| use chrono::{DateTime, NaiveDateTime, Utc}; | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
|  | ||||
| /// Get the current time since epoch | ||||
| @@ -7,3 +8,16 @@ pub fn time() -> u64 { | ||||
|         .unwrap() | ||||
|         .as_secs() | ||||
| } | ||||
|  | ||||
| /// Format unix timestamp to a human-readable string | ||||
| pub fn fmt_time(timestamp: u64) -> String { | ||||
|     // Create a NaiveDateTime from the timestamp | ||||
|     let naive = | ||||
|         NaiveDateTime::from_timestamp_opt(timestamp as i64, 0).expect("Failed to parse timestamp!"); | ||||
|  | ||||
|     // Create a normal DateTime from the NaiveDateTime | ||||
|     let datetime: DateTime<Utc> = DateTime::from_utc(naive, Utc); | ||||
|  | ||||
|     // Format the datetime how you want | ||||
|     datetime.format("%Y-%m-%d %H:%M:%S").to_string() | ||||
| } | ||||
|   | ||||
| @@ -12,7 +12,7 @@ | ||||
|     <!-- User name --> | ||||
|     <div class="form-group"> | ||||
|         <label class="form-label mt-4" for="username">Username</label> | ||||
|         <input class="form-control" id="username" type="text" | ||||
|         <input class="form-control" id="username" type="text" autocomplete="nope" | ||||
|                name="username" value="{{ u.username }}" required/> | ||||
|         <div class="valid-feedback">This username is valid</div> | ||||
|         <div class="invalid-feedback">This username is already taken.</div> | ||||
| @@ -51,17 +51,27 @@ | ||||
|  | ||||
|         <!-- Enabled --> | ||||
|         <div class="form-check"> | ||||
|             <input class="form-check-input" type="checkbox" name="enabled" id="enabled" {% if u.enabled %} checked="" {% | ||||
|                    endif %}> | ||||
|             <input class="form-check-input" type="checkbox" name="enabled" id="enabled" | ||||
|                    {% if u.enabled %} checked="" {% endif %}> | ||||
|             <label class="form-check-label" for="enabled"> | ||||
|                 Enabled | ||||
|             </label> | ||||
|         </div> | ||||
|  | ||||
|         <!-- 2FA exemption after successful login --> | ||||
|         <div class="form-check"> | ||||
|             <input class="form-check-input" type="checkbox" name="two_factor_exemption_after_successful_login" | ||||
|                    id="two_factor_exemption_after_successful_login" | ||||
|                    {% if u.two_factor_exemption_after_successful_login %} checked="" {% endif %}> | ||||
|             <label class="form-check-label" for="two_factor_exemption_after_successful_login"> | ||||
|                 Exempt user from 2FA authentication for an IP address after a successful login for a limited time | ||||
|             </label> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Admin --> | ||||
|         <div class="form-check"> | ||||
|             <input class="form-check-input" type="checkbox" name="admin" id="admin" {% if u.admin %} checked="" {% endif | ||||
|                    %}> | ||||
|             <input class="form-check-input" type="checkbox" name="admin" id="admin" | ||||
|                    {% if u.admin %} checked="" {% endif %}> | ||||
|             <label class="form-check-label" for="admin"> | ||||
|                 Grant admin privileges | ||||
|             </label> | ||||
| @@ -80,7 +90,7 @@ | ||||
|                 <input type="checkbox" class="form-check-input two-fact-checkbox" | ||||
|                        value="{{ f.id.0 }}" | ||||
|                        checked=""/> | ||||
|                 {{ f.name }} (<img src="{{ f.type_image() }}" alt="Factor icon" style="height:1em;" /> | ||||
|                 {{ f.name }} (<img src="{{ f.type_image() }}" alt="Factor icon" style="height:1em;"/> | ||||
|                 {{ f.type_str() }}) | ||||
|             </label> | ||||
|         </div> | ||||
| @@ -88,6 +98,28 @@ | ||||
|     </fieldset> | ||||
|     {% endif %} | ||||
|  | ||||
|     <!-- Two factor authentication history --> | ||||
|     {% if !u.last_successful_2fa.is_empty() %} | ||||
|     <fieldset class="form-group"> | ||||
|         <legend class="mt-4">Last successful 2FA authentications</legend> | ||||
|  | ||||
|         <!-- Clear 2FA history --> | ||||
|         <div class="form-check"> | ||||
|             <input class="form-check-input" type="checkbox" name="clear_2fa_history" id="clear_2fa_history"> | ||||
|             <label class="form-check-label" for="clear_2fa_history"> | ||||
|                 Clear 2FA authentication history | ||||
|             </label> | ||||
|         </div> | ||||
|  | ||||
|         <ul> | ||||
|         {% for e in u.get_formatted_2fa_successful_logins() %} | ||||
|             {% if e.can_bypass_2fa %}<li style="font-weight: bold;">{{ e.ip }} - {{ e.fmt_time() }} - BYPASS 2FA</li> | ||||
|             {% else %}<li>{{ e.ip }} - {{ e.fmt_time() }}</li>{% endif %} | ||||
|         {% endfor %} | ||||
|         </ul> | ||||
|     </fieldset> | ||||
|     {% endif %} | ||||
|  | ||||
|     <!-- Granted clients --> | ||||
|     <fieldset class="form-group"> | ||||
|         <legend class="mt-4">Granted clients</legend> | ||||
| @@ -190,6 +222,7 @@ | ||||
|  | ||||
|         form.submit(); | ||||
|     }); | ||||
|  | ||||
| </script> | ||||
|  | ||||
| {% endblock content %} | ||||
| @@ -34,6 +34,33 @@ | ||||
|     </tbody> | ||||
| </table> | ||||
|  | ||||
| {% if !user.last_successful_2fa.is_empty() %} | ||||
| <div id="2fa_history_container"> | ||||
|     <h5 style="margin-top: 50px">Successful 2FA login history</h5> | ||||
|     <p> | ||||
|         <a type="button" class="btn btn-danger btn-sm" onclick="clear_login_history()">Clear history</a> | ||||
|     </p> | ||||
|     <table class="table table-hover" style="max-width: 800px;" aria-describedby="Factors list"> | ||||
|         <thead> | ||||
|         <tr> | ||||
|             <th scope="col">IP address</th> | ||||
|             <th scope="col">Date</th> | ||||
|             <th scope="col">Bypass 2FA</th> | ||||
|         </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|         {% for e in user.get_formatted_2fa_successful_logins() %} | ||||
|         <tr> | ||||
|             <td>{{ e.ip }}</td> | ||||
|             <td>{{ e.fmt_time() }}</td> | ||||
|             <td>{% if e.can_bypass_2fa %}YES{% else %}NO{% endif %}</td> | ||||
|         </tr> | ||||
|         {% endfor %} | ||||
|         </tbody> | ||||
|     </table> | ||||
|     {% endif %} | ||||
| </div> | ||||
|  | ||||
| <script> | ||||
|     async function delete_factor(id) { | ||||
|         if (!confirm("Do you really want to remove this factor?")) | ||||
| @@ -61,5 +88,25 @@ | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async function clear_login_history() { | ||||
|         if (!confirm("Do you really want to clear your 2FA login history?")) | ||||
|             return; | ||||
|  | ||||
|         try { | ||||
|             const res = await fetch("/settings/api/two_factor/clear_login_history", { | ||||
|                 method: "post" | ||||
|             }); | ||||
|  | ||||
|             let text = await res.text(); | ||||
|             alert(text); | ||||
|  | ||||
|             if (res.status == 200) | ||||
|                 document.getElementById("2fa_history_container").remove(); | ||||
|         } catch(e) { | ||||
|             console.error(e); | ||||
|             alert("Failed to clear 2FA history!"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| </script> | ||||
| {% endblock content %} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user