diff --git a/src/actors/users_actor.rs b/src/actors/users_actor.rs index ed9feae..dd11869 100644 --- a/src/actors/users_actor.rs +++ b/src/actors/users_actor.rs @@ -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}; @@ -50,6 +51,10 @@ pub struct ChangePasswordRequest { #[derive(Debug)] pub struct ChangePasswordResult(pub bool); +#[derive(Message)] +#[rtype(result = "bool")] +pub struct AddSuccessful2FALogin(pub UserID, pub IpAddr); + #[derive(Debug)] pub struct UpdateUserResult(pub bool); @@ -111,6 +116,15 @@ impl Handler for UsersActor { } } +impl Handler for UsersActor { + type Result = ::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 for UsersActor { type Result = MessageResult; diff --git a/src/constants.rs b/src/constants.rs index 688753d..6603348 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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; diff --git a/src/controllers/admin_controller.rs b/src/controllers/admin_controller.rs index 37ff7fa..090b7df 100644 --- a/src/controllers/admin_controller.rs +++ b/src/controllers/admin_controller.rs @@ -55,6 +55,7 @@ pub struct UpdateUserQuery { email: String, gen_new_password: Option, enabled: Option, + two_factor_exemption_after_successful_login: Option, admin: Option, grant_type: String, granted_clients: String, @@ -84,6 +85,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::>(); diff --git a/src/controllers/login_api.rs b/src/controllers/login_api.rs index eb9d8ee..b1e0bfa 100644 --- a/src/controllers/login_api.rs +++ b/src/controllers/login_api.rs @@ -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, manager: WebAuthManagerReq, http_req: HttpRequest, + remote_ip: RemoteIP, + users: web::Data>, ) -> 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!") } diff --git a/src/controllers/login_controller.rs b/src/controllers/login_controller.rs index a879ec6..4517aed 100644 --- a/src/controllers/login_controller.rs +++ b/src/controllers/login_controller.rs @@ -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>, users: web::Data>, 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()); } diff --git a/src/data/user.rs b/src/data/user.rs index 908324f..c14266a 100644 --- a/src/data/user.rs +++ b/src/data/user.rs @@ -1,9 +1,13 @@ +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::time; +use std::collections::HashMap; +use std::net::IpAddr; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct UserID(pub String); @@ -76,6 +80,15 @@ pub struct User { #[serde(default)] pub two_factor: Vec, + /// 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, + /// None = all services /// Some([]) = no service pub authorized_clients: Option>, @@ -101,6 +114,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); } @@ -178,6 +198,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()), } } @@ -249,4 +271,11 @@ impl EntityManager { 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()); + user + }) + } } diff --git a/templates/settings/edit_user.html b/templates/settings/edit_user.html index 91fcd9e..1cfadcd 100644 --- a/templates/settings/edit_user.html +++ b/templates/settings/edit_user.html @@ -12,7 +12,7 @@
-
This username is valid
This username is already taken.
@@ -51,17 +51,27 @@
- +
+ +
+ + +
+
- + @@ -80,7 +90,7 @@ - {{ f.name }} (Factor icon + {{ f.name }} (Factor icon {{ f.type_str() }})
@@ -190,6 +200,7 @@ form.submit(); }); + {% endblock content %} \ No newline at end of file