From 0fa58f4d3aa5a1120dfa1c0df90d328c4b072363 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 25 Apr 2023 15:03:56 +0200 Subject: [PATCH] Generate state for authentication --- src/actors/mod.rs | 1 + src/actors/providers_states_actor.rs | 130 ++++++++++++++++++++++++ src/constants.rs | 6 ++ src/controllers/mod.rs | 1 + src/controllers/providers_controller.rs | 55 ++++++++++ src/data/action_logger.rs | 26 ++++- src/main.rs | 12 ++- 7 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 src/actors/providers_states_actor.rs create mode 100644 src/controllers/providers_controller.rs diff --git a/src/actors/mod.rs b/src/actors/mod.rs index 4298570..fdfde90 100644 --- a/src/actors/mod.rs +++ b/src/actors/mod.rs @@ -1,3 +1,4 @@ pub mod bruteforce_actor; pub mod openid_sessions_actor; +pub mod providers_states_actor; pub mod users_actor; diff --git a/src/actors/providers_states_actor.rs b/src/actors/providers_states_actor.rs new file mode 100644 index 0000000..41b0c7b --- /dev/null +++ b/src/actors/providers_states_actor.rs @@ -0,0 +1,130 @@ +//! # Providers state actor +//! +//! This actor stores the content of the states +//! during authentication with upstream providers + +use crate::constants::{ + MAX_OIDC_PROVIDERS_STATES, OIDC_PROVIDERS_STATE_DURATION, OIDC_PROVIDERS_STATE_LEN, + OIDC_STATES_CLEANUP_INTERVAL, +}; +use actix::{Actor, AsyncContext, Context, Handler, Message}; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::net::IpAddr; + +use crate::data::login_redirect::LoginRedirect; +use crate::data::provider::ProviderID; +use crate::utils::string_utils::rand_str; +use crate::utils::time::time; + +#[derive(Debug, Clone)] +pub struct ProviderLoginState { + pub provider_id: ProviderID, + pub state_id: String, + pub redirect: LoginRedirect, + pub expire: u64, +} + +impl ProviderLoginState { + pub fn new(prov_id: &ProviderID, redirect: LoginRedirect) -> Self { + Self { + provider_id: prov_id.clone(), + state_id: rand_str(OIDC_PROVIDERS_STATE_LEN), + redirect, + expire: time() + OIDC_PROVIDERS_STATE_DURATION, + } + } +} + +#[derive(Message)] +#[rtype(result = "()")] +pub struct RecordState { + pub ip: IpAddr, + pub state: ProviderLoginState, +} + +#[derive(Message)] +#[rtype(result = "Option")] +pub struct ConsumeState { + pub ip: IpAddr, + pub state_id: String, +} + +#[derive(Debug, Default)] +pub struct ProvidersStatesActor { + states: HashMap>, +} + +impl ProvidersStatesActor { + /// Clean outdated states + fn clean_old_states(&mut self) { + #[allow(clippy::map_clone)] + let keys = self.states.keys().map(|i| *i).collect::>(); + + for ip in keys { + // Remove old attempts + let states = self.states.get_mut(&ip).unwrap(); + states.retain(|i| i.expire < time()); + + // Remove empty entry keys + if states.is_empty() { + self.states.remove(&ip); + } + } + } + + /// Add a new provider login state + pub fn insert_state(&mut self, ip: IpAddr, state: ProviderLoginState) { + if let Entry::Vacant(e) = self.states.entry(ip) { + e.insert(vec![state]); + } else { + let states = self.states.get_mut(&ip).unwrap(); + + // We limit the number of states per IP address + if states.len() > MAX_OIDC_PROVIDERS_STATES { + states.remove(0); + } + + states.push(state); + } + } + + /// Get & consume a login state + pub fn consume_state(&mut self, ip: IpAddr, state_id: &str) -> Option { + let idx = self + .states + .get(&ip)? + .iter() + .position(|val| val.state_id.as_str() == state_id)?; + + Some(self.states.get_mut(&ip)?.remove(idx)) + } +} + +impl Actor for ProvidersStatesActor { + type Context = Context; + + fn started(&mut self, ctx: &mut Self::Context) { + // Clean up at a regular interval failed attempts + ctx.run_interval(OIDC_STATES_CLEANUP_INTERVAL, |act, _ctx| { + log::trace!("Cleaning up old states"); + act.clean_old_states(); + }); + } +} + +impl Handler for ProvidersStatesActor { + type Result = (); + + fn handle(&mut self, req: RecordState, _ctx: &mut Self::Context) -> Self::Result { + self.insert_state(req.ip, req.state); + } +} + +impl Handler for ProvidersStatesActor { + type Result = Option; + + fn handle(&mut self, req: ConsumeState, _ctx: &mut Self::Context) -> Self::Result { + self.consume_state(req.ip, &req.state_id) + } +} diff --git a/src/constants.rs b/src/constants.rs index 37b372b..808d21b 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -71,3 +71,9 @@ 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; + +/// OpenID provider login constants +pub const OIDC_STATES_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); +pub const MAX_OIDC_PROVIDERS_STATES: usize = 10; +pub const OIDC_PROVIDERS_STATE_LEN: usize = 40; +pub const OIDC_PROVIDERS_STATE_DURATION: u64 = 60 * 15; diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index c946f73..63b74d3 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -5,6 +5,7 @@ pub mod base_controller; pub mod login_api; pub mod login_controller; pub mod openid_controller; +pub mod providers_controller; pub mod settings_controller; pub mod two_factor_api; pub mod two_factors_controller; diff --git a/src/controllers/providers_controller.rs b/src/controllers/providers_controller.rs new file mode 100644 index 0000000..27551a9 --- /dev/null +++ b/src/controllers/providers_controller.rs @@ -0,0 +1,55 @@ +use crate::actors::providers_states_actor; +use crate::actors::providers_states_actor::{ProviderLoginState, ProvidersStatesActor}; +use crate::controllers::base_controller::build_fatal_error_page; +use crate::data::action_logger::{Action, ActionLogger}; +use crate::data::login_redirect::LoginRedirect; +use crate::data::provider::{ProviderID, ProvidersManager}; +use crate::data::remote_ip::RemoteIP; +use actix::Addr; +use actix_web::{web, HttpResponse, Responder}; +use std::sync::Arc; + +#[derive(serde::Deserialize)] +pub struct StartLoginQuery { + #[serde(default)] + redirect: LoginRedirect, + id: ProviderID, +} + +/// Start user authentication using a provider +#[allow(clippy::too_many_arguments)] +pub async fn start_login( + remote_ip: RemoteIP, + providers: web::Data>, + states: web::Data>, + query: web::Query, + logger: ActionLogger, +) -> impl Responder { + // Get provider information + let provider = match providers.find_by_id(&query.id) { + None => { + return HttpResponse::NotFound() + .body(build_fatal_error_page("Login provider not found!")) + } + Some(p) => p, + }; + + // Generate & save state + let state = ProviderLoginState::new(&provider.id, query.redirect.clone()); + states + .send(providers_states_actor::RecordState { + ip: remote_ip.0, + state: state.clone(), + }) + .await + .unwrap(); + + logger.log(Action::StartLoginAttemptWithOpenIDProvider { + provider_id: &provider.id, + state: &state.state_id, + }); + + HttpResponse::Ok().body(state.state_id) + + // Redirect user +} diff --git a/src/data/action_logger.rs b/src/data/action_logger.rs index e67163b..2841d81 100644 --- a/src/data/action_logger.rs +++ b/src/data/action_logger.rs @@ -10,6 +10,7 @@ use actix_web::{web, Error, FromRequest, HttpRequest}; use crate::actors::users_actor; use crate::actors::users_actor::{AuthorizedAuthenticationSources, UsersActor}; use crate::data::client::Client; +use crate::data::provider::ProviderID; use crate::data::remote_ip::RemoteIP; use crate::data::session_identity::SessionIdentity; use crate::data::user::{FactorID, GrantedClients, TwoFactor, User, UserID}; @@ -23,7 +24,14 @@ pub enum Action<'a> { AdminSetAuthorizedAuthenticationSources(&'a User, &'a AuthorizedAuthenticationSources), AdminSetNewGrantedClientsList(&'a User, &'a GrantedClients), AdminClear2FAHistory(&'a User), - LoginWebauthnAttempt { success: bool, user_id: UserID }, + LoginWebauthnAttempt { + success: bool, + user_id: UserID, + }, + StartLoginAttemptWithOpenIDProvider { + provider_id: &'a ProviderID, + state: &'a str, + }, Signout, UserNeed2FAOnLogin(&'a User), UserSuccessfullyAuthenticated(&'a User), @@ -32,12 +40,19 @@ pub enum Action<'a> { TryLocalLoginFromUnauthorizedAccount(&'a str), FailedLoginWithBadCredentials(&'a str), UserChangedPasswordOnLogin(&'a UserID), - OTPLoginAttempt { user: &'a User, success: bool }, - NewOpenIDSession { client: &'a Client }, + OTPLoginAttempt { + user: &'a User, + success: bool, + }, + NewOpenIDSession { + client: &'a Client, + }, ChangedHisPassword, ClearedHisLoginHistory, AddNewFactor(&'a TwoFactor), - Removed2FAFactor { factor_id: &'a FactorID }, + Removed2FAFactor { + factor_id: &'a FactorID, + }, } impl<'a> Action<'a> { @@ -80,6 +95,9 @@ impl<'a> Action<'a> { true => format!("successfully performed webauthn attempt for user {user_id:?}"), false => format!("performed FAILED webauthn attempt for user {user_id:?}"), }, + Action::StartLoginAttemptWithOpenIDProvider { provider_id, state } => format!( + "started new authentication attempt through an OpenID provider (prov={} / state={state})", provider_id.0 + ), Action::Signout => "signed out".to_string(), Action::UserNeed2FAOnLogin(user) => { format!( diff --git a/src/main.rs b/src/main.rs index 11bb727..cf3b8b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use actix_web::{get, middleware, web, App, HttpResponse, HttpServer}; use basic_oidc::actors::bruteforce_actor::BruteForceActor; use basic_oidc::actors::openid_sessions_actor::OpenIDSessionsActor; +use basic_oidc::actors::providers_states_actor::ProvidersStatesActor; use basic_oidc::actors::users_actor::{UsersActor, UsersSyncBackend}; use basic_oidc::constants::*; use basic_oidc::controllers::assets_controller::assets_route; @@ -69,6 +70,7 @@ async fn main() -> std::io::Result<()> { let users_actor = UsersActor::new(users).start(); let bruteforce_actor = BruteForceActor::default().start(); + let providers_states_actor = ProvidersStatesActor::default().start(); let openid_sessions_actor = OpenIDSessionsActor::default().start(); let jwt_signer = JWTSigner::gen_from_memory().expect("Failed to generate JWKS key"); let webauthn_manager = Arc::new(WebAuthManager::init(config)); @@ -105,6 +107,7 @@ async fn main() -> std::io::Result<()> { App::new() .app_data(web::Data::new(users_actor.clone())) .app_data(web::Data::new(bruteforce_actor.clone())) + .app_data(web::Data::new(providers_states_actor.clone())) .app_data(web::Data::new(openid_sessions_actor.clone())) .app_data(web::Data::new(clients.clone())) .app_data(web::Data::new(providers.clone())) @@ -117,7 +120,7 @@ async fn main() -> std::io::Result<()> { .wrap(AuthMiddleware {}) .wrap(identity_middleware) .wrap(session_mw) - // main route + // Main route .route( "/", web::get().to(|| async { @@ -127,7 +130,7 @@ async fn main() -> std::io::Result<()> { }), ) .route("/robots.txt", web::get().to(assets_controller::robots_txt)) - // health route + // Health route .service(health) // Assets serving .route("/assets/{path:.*}", web::get().to(assets_route)) @@ -158,6 +161,11 @@ async fn main() -> std::io::Result<()> { "/login/api/auth_webauthn", web::post().to(login_api::auth_webauthn), ) + // Providers controller + .route( + "/login_with_prov", + web::get().to(providers_controller::start_login), + ) // Settings routes .route( "/settings",