diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 0a005920c..9d0b7b108 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -955,6 +955,7 @@ impl KanidmClient { step: AuthStep::Init2 { username: ident.to_string(), issue: AuthIssueSession::Token, + privileged: false, }, }; diff --git a/proto/src/v1.rs b/proto/src/v1.rs index a982abdf8..51b02ea43 100644 --- a/proto/src/v1.rs +++ b/proto/src/v1.rs @@ -919,6 +919,8 @@ pub enum AuthStep { Init2 { username: String, issue: AuthIssueSession, + #[serde(default)] + privileged: bool, }, // We want to talk to you like this. Begin(AuthMech), diff --git a/server/lib/src/idm/authsession.rs b/server/lib/src/idm/authsession.rs index c8e29d083..ec60a83c3 100644 --- a/server/lib/src/idm/authsession.rs +++ b/server/lib/src/idm/authsession.rs @@ -77,7 +77,9 @@ impl fmt::Display for AuthType { #[derive(Debug, Clone)] enum AuthIntent { - InitialAuth, + InitialAuth { + privileged: bool, + }, Reauth { session_id: Uuid, session_expiry: Option, @@ -751,6 +753,7 @@ impl AuthSession { pub fn new( account: Account, issue: AuthIssueSession, + privileged: bool, webauthn: &Webauthn, ct: Duration, source: Source, @@ -808,7 +811,7 @@ impl AuthSession { account, state, issue, - intent: AuthIntent::InitialAuth, + intent: AuthIntent::InitialAuth { privileged }, source, }; // Get the set of mechanisms that can proceed. This is tied @@ -1109,7 +1112,7 @@ impl AuthSession { ) -> Result { security_debug!("Successful cred handling"); match self.intent { - AuthIntent::InitialAuth => { + AuthIntent::InitialAuth { privileged } => { let session_id = Uuid::new_v4(); // We need to actually work this out better, and then // pass it to to_userauthtoken @@ -1117,13 +1120,18 @@ impl AuthSession { AuthType::UnixPassword | AuthType::Anonymous => SessionScope::ReadOnly, AuthType::GeneratedPassword => SessionScope::ReadWrite, AuthType::Password | AuthType::PasswordMfa | AuthType::Passkey => { - SessionScope::PrivilegeCapable + if privileged { + SessionScope::ReadWrite + } else { + SessionScope::PrivilegeCapable + } } }; security_info!( - "Issuing {:?} session {} for {} {}", + "Issuing {:?} session ({:?}) {} for {} {}", self.issue, + scope, session_id, self.account.spn, self.account.uuid @@ -1229,11 +1237,14 @@ impl AuthSession { #[cfg(test)] mod tests { pub use std::collections::BTreeSet as Set; + use std::str::FromStr; use std::time::Duration; - use compact_jwt::JwsSigner; + use compact_jwt::{Jws, JwsSigner, JwsUnverified}; use hashbrown::HashSet; - use kanidm_proto::v1::{AuthAllowed, AuthCredential, AuthIssueSession, AuthMech}; + use kanidm_proto::v1::{ + AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, UatPurpose, UserAuthToken, + }; use tokio::sync::mpsc::unbounded_channel as unbounded; use webauthn_authenticator_rs::softpasskey::SoftPasskey; use webauthn_authenticator_rs::WebauthnAuthenticator; @@ -1282,6 +1293,7 @@ mod tests { let (session, state) = AuthSession::new( anon_account, AuthIssueSession::Token, + false, &webauthn, duration_from_epoch_now(), Source::Internal, @@ -1311,11 +1323,13 @@ mod tests { ( $audit:expr, $account:expr, - $webauthn:expr + $webauthn:expr, + $privileged:expr ) => {{ let (session, state) = AuthSession::new( $account.clone(), AuthIssueSession::Token, + $privileged, $webauthn, duration_from_epoch_now(), Source::Internal, @@ -1344,9 +1358,7 @@ mod tests { }}; } - #[test] - fn test_idm_authsession_simple_password_mech() { - sketching::test_init(); + fn start_session_simple_password_mech(privileged: bool) -> UserAuthToken { let webauthn = create_webauthn(); // create the ent let mut account = entry_to_account!(E_ADMIN_V1.clone()); @@ -1360,7 +1372,7 @@ mod tests { // now check let (mut session, pw_badlist_cache) = - start_password_session!(&mut audit, account, &webauthn); + start_password_session!(&mut audit, account, &webauthn, false); let attempt = AuthCredential::Password("bad_password".to_string()); let jws_signer = create_jwt_signer(); @@ -1385,10 +1397,10 @@ mod tests { // === Now begin a new session, and use a good pw. let (mut session, pw_badlist_cache) = - start_password_session!(&mut audit, account, &webauthn); + start_password_session!(&mut audit, account, &webauthn, privileged); let attempt = AuthCredential::Password("test_password".to_string()); - match session.validate_creds( + let uat: UserAuthToken = match session.validate_creds( &attempt, Duration::from_secs(0), &async_tx, @@ -1397,7 +1409,12 @@ mod tests { &jws_signer, &AccountPolicy::from_pw_badlist_cache(pw_badlist_cache), ) { - Ok(AuthState::Success(_, AuthIssueSession::Token)) => {} + Ok(AuthState::Success(jwt, AuthIssueSession::Token)) => { + let uat = JwsUnverified::from_str(&jwt).expect("Failed to parse jwt"); + let uat: Jws = + uat.validate_embeded().expect("Embedded uat not found"); + uat.into_inner() + } _ => panic!(), }; @@ -1410,6 +1427,34 @@ mod tests { assert!(async_rx.blocking_recv().is_none()); drop(audit_tx); assert!(audit_rx.blocking_recv().is_none()); + + uat + } + + #[test] + fn test_idm_authsession_simple_password_mech() { + sketching::test_init(); + let uat = start_session_simple_password_mech(false); + match uat.purpose { + UatPurpose::ReadOnly => panic!("Unexpected UatPurpose::ReadOnly"), + UatPurpose::ReadWrite { expiry } => { + // Long lived RO session capable of reauth + assert!(expiry.is_none()) + } + } + } + + #[test] + fn test_idm_authsession_simple_password_mech_priv_shortcut() { + sketching::test_init(); + let uat = start_session_simple_password_mech(true); + match uat.purpose { + UatPurpose::ReadOnly => panic!("Unexpected UatPurpose::ReadOnly"), + UatPurpose::ReadWrite { expiry } => { + // Short lived RW session + assert!(expiry.is_some()) + } + } } #[test] @@ -1429,7 +1474,7 @@ mod tests { // now check, even though the password is correct, Auth should be denied since it is in badlist let (mut session, pw_badlist_cache) = - start_password_session!(&mut audit, account, &webauthn); + start_password_session!(&mut audit, account, &webauthn, false); let attempt = AuthCredential::Password("list@no3IBTyqHu$bad".to_string()); match session.validate_creds( @@ -1464,6 +1509,7 @@ mod tests { let (session, state) = AuthSession::new( $account.clone(), AuthIssueSession::Token, + false, $webauthn, duration_from_epoch_now(), Source::Internal, @@ -1790,6 +1836,7 @@ mod tests { let (session, state) = AuthSession::new( $account.clone(), AuthIssueSession::Token, + false, $webauthn, duration_from_epoch_now(), Source::Internal, diff --git a/server/lib/src/idm/event.rs b/server/lib/src/idm/event.rs index 60a0dc41e..5b9bd5dec 100644 --- a/server/lib/src/idm/event.rs +++ b/server/lib/src/idm/event.rs @@ -280,6 +280,7 @@ impl LdapTokenAuthEvent { pub struct AuthEventStepInit { pub username: String, pub issue: AuthIssueSession, + pub privileged: bool, } #[derive(Debug)] @@ -311,14 +312,23 @@ impl AuthEventStep { Ok(AuthEventStep::Init(AuthEventStepInit { username, issue: AuthIssueSession::Token, + privileged: false, })) } } - AuthStep::Init2 { username, issue } => { + AuthStep::Init2 { + username, + issue, + privileged, + } => { if username.trim().is_empty() { Err(OperationError::EmptyRequest) } else { - Ok(AuthEventStep::Init(AuthEventStepInit { username, issue })) + Ok(AuthEventStep::Init(AuthEventStepInit { + username, + issue, + privileged, + })) } } @@ -342,6 +352,7 @@ impl AuthEventStep { AuthEventStep::Init(AuthEventStepInit { username: "anonymous".to_string(), issue: AuthIssueSession::Token, + privileged: false, }) } @@ -350,6 +361,7 @@ impl AuthEventStep { AuthEventStep::Init(AuthEventStepInit { username: name.to_string(), issue: AuthIssueSession::Token, + privileged: false, }) } diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 187cd605a..e3459e992 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -1006,6 +1006,7 @@ impl<'a> IdmServerAuthTransaction<'a> { security_info!( username = %init.username, issue = ?init.issue, + privileged = ?init.privileged, uuid = %euuid, "Initiating Authentication Session", ); @@ -1048,8 +1049,14 @@ impl<'a> IdmServerAuthTransaction<'a> { slock_ref }); - let (auth_session, state) = - AuthSession::new(account, init.issue, self.webauthn, ct, source); + let (auth_session, state) = AuthSession::new( + account, + init.issue, + init.privileged, + self.webauthn, + ct, + source, + ); match auth_session { Some(auth_session) => { diff --git a/server/testkit/tests/proto_v1_test.rs b/server/testkit/tests/proto_v1_test.rs index 4bf8f1c7f..f6d13feb1 100644 --- a/server/testkit/tests/proto_v1_test.rs +++ b/server/testkit/tests/proto_v1_test.rs @@ -2,7 +2,9 @@ use std::time::SystemTime; use kanidm_proto::v1::{ - ApiToken, CURegState, CredentialDetailType, Entry, Filter, Modify, ModifyList, UserAuthToken, + ApiToken, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthResponse, AuthState, + AuthStep, CURegState, CredentialDetailType, Entry, Filter, Modify, ModifyList, UatPurpose, + UserAuthToken, }; use kanidmd_lib::credential::totp::Totp; use tracing::debug; @@ -13,7 +15,7 @@ use compact_jwt::JwsUnverified; use webauthn_authenticator_rs::softpasskey::SoftPasskey; use webauthn_authenticator_rs::WebauthnAuthenticator; -use kanidm_client::KanidmClient; +use kanidm_client::{ClientError, KanidmClient}; use kanidmd_testkit::ADMIN_TEST_PASSWORD; const UNIX_TEST_PASSWORD: &str = "unix test user password"; @@ -1221,6 +1223,67 @@ async fn setup_demo_account_passkey(rsclient: &KanidmClient) -> WebauthnAuthenti wa } +async fn setup_demo_account_password( + rsclient: &KanidmClient, +) -> Result<(String, String), ClientError> { + let account_name = String::from_str("demo_account").expect("Failed to parse string"); + + let account_pass = String::from_str("eicieY7ahchaoCh0eeTa").expect("Failed to parse string"); + + rsclient + .auth_simple_password("admin", ADMIN_TEST_PASSWORD) + .await + .expect("Failed to authenticate as admin"); + + // Not recommended in production! + rsclient + .idm_group_add_members("idm_admins", &["admin"]) + .await + .expect("Failed to add admin to idm_admins"); + + rsclient + .idm_person_account_create("demo_account", "Deeeeemo") + .await + .expect("Failed to create demo account"); + + // First, show there are no auth sessions. + let sessions = rsclient + .idm_account_list_user_auth_token("demo_account") + .await + .expect("Failed to list user auth tokens"); + assert!(sessions.is_empty()); + + // Setup the credentials for the account + // Create an intent token for them + let intent_token = rsclient + .idm_person_account_credential_update_intent("demo_account", None) + .await + .expect("Failed to create intent token"); + + // Logout, we don't need any auth now. + rsclient.logout().await.expect("Failed to logout"); + + // Exchange the intent token + let (session_token, _status) = rsclient + .idm_account_credential_update_exchange(intent_token) + .await + .expect("Failed to exchange intent token"); + + // Setup and update the password + rsclient + .idm_account_credential_update_set_password(&session_token, account_pass.as_str()) + .await + .expect("Failed to set password"); + + // Commit it + rsclient + .idm_account_credential_update_commit(&session_token) + .await + .expect("Failed to commit changes"); + + Ok((account_name, account_pass)) +} + #[kanidmd_testkit::test] async fn test_server_credential_update_session_passkey(rsclient: KanidmClient) { let mut wa = setup_demo_account_passkey(&rsclient).await; @@ -1491,3 +1554,131 @@ async fn test_privilege_expiry(rsclient: KanidmClient) { let result = rsclient.system_auth_privilege_expiry_get().await.unwrap(); assert_eq!(authsession_expiry, result); } + +async fn start_password_session( + rsclient: &KanidmClient, + username: &str, + password: &str, + privileged: bool, +) -> Result { + let client = reqwest::Client::new(); + + let authreq = AuthRequest { + step: AuthStep::Init2 { + username: username.to_string(), + issue: AuthIssueSession::Token, + privileged, + }, + }; + let authreq = serde_json::to_string(&authreq).expect("Failed to serialize AuthRequest"); + + let res = match client + .post(rsclient.make_url("/v1/auth")) + .header("Content-Type", "application/json") + .body(authreq) + .send() + .await + { + Ok(value) => value, + Err(error) => panic!("Failed to post: {:#?}", error), + }; + assert_eq!(res.status(), 200); + + let session_id = res.headers().get("x-kanidm-auth-session-id").unwrap(); + + let authreq = AuthRequest { + step: AuthStep::Begin(AuthMech::Password), + }; + let authreq = serde_json::to_string(&authreq).expect("Failed to serialize AuthRequest"); + + let res = match client + .post(rsclient.make_url("/v1/auth")) + .header("Content-Type", "application/json") + .header("x-kanidm-auth-session-id", session_id) + .body(authreq) + .send() + .await + { + Ok(value) => value, + Err(error) => panic!("Failed to post: {:#?}", error), + }; + assert_eq!(res.status(), 200); + + let authreq = AuthRequest { + step: AuthStep::Cred(AuthCredential::Password(password.to_string())), + }; + let authreq = serde_json::to_string(&authreq).expect("Failed to serialize AuthRequest"); + + let res = match client + .post(rsclient.make_url("/v1/auth")) + .header("Content-Type", "application/json") + .header("x-kanidm-auth-session-id", session_id) + .body(authreq) + .send() + .await + { + Ok(value) => value, + Err(error) => panic!("Failed to post: {:#?}", error), + }; + assert_eq!(res.status(), 200); + + let res: AuthResponse = res.json().await.expect("Failed to read JSON response"); + let jwt = match res.state { + AuthState::Success(val) => val, + _ => panic!("Failed to extract jwt"), + }; + + let jwt = JwsUnverified::from_str(&jwt).expect("Failed to parse jwt"); + let uat: UserAuthToken = jwt + .validate_embeded() + .map(|jws| jws.into_inner()) + .expect("Unable extract uat"); + + Ok(uat) +} + +#[kanidmd_testkit::test] +async fn test_server_user_auth_unprivileged(rsclient: KanidmClient) { + let (account_name, account_pass) = setup_demo_account_password(&rsclient) + .await + .expect("Failed to setup demo_account"); + + let uat = start_password_session( + &rsclient, + account_name.as_str(), + account_pass.as_str(), + false, + ) + .await + .expect("Failed to start session"); + + match uat.purpose { + UatPurpose::ReadOnly => panic!("Unexpected uat purpose"), + UatPurpose::ReadWrite { expiry } => { + assert!(expiry.is_none()) + } + } +} + +#[kanidmd_testkit::test] +async fn test_server_user_auth_privileged_shortcut(rsclient: KanidmClient) { + let (account_name, account_pass) = setup_demo_account_password(&rsclient) + .await + .expect("Failed to setup demo_account"); + + let uat = start_password_session( + &rsclient, + account_name.as_str(), + account_pass.as_str(), + true, + ) + .await + .expect("Failed to start session"); + + match uat.purpose { + UatPurpose::ReadOnly => panic!("Unexpected uat purpose"), + UatPurpose::ReadWrite { expiry } => { + assert!(expiry.is_some()) + } + } +} diff --git a/server/web_ui/src/login/mod.rs b/server/web_ui/src/login/mod.rs index 002181aaf..7afa628d4 100644 --- a/server/web_ui/src/login/mod.rs +++ b/server/web_ui/src/login/mod.rs @@ -100,6 +100,7 @@ impl LoginApp { step: AuthStep::Init2 { username, issue: AuthIssueSession::Token, + privileged: false, }, }; let req_jsvalue = serde_json::to_string(&authreq)