Authentication shortcut to get a RW session (#1993)

* auth: Add a privileged flag to AuthStep::Init2 step to request a rw session

The privileged flag is defined as Option<bool> for compatibility with
existing clients.
This commit is contained in:
Samuel Cabrero 2023-08-24 01:54:33 +02:00 committed by GitHub
parent 47e953bfd2
commit 9dda8b1ad3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 283 additions and 22 deletions

View file

@ -955,6 +955,7 @@ impl KanidmClient {
step: AuthStep::Init2 { step: AuthStep::Init2 {
username: ident.to_string(), username: ident.to_string(),
issue: AuthIssueSession::Token, issue: AuthIssueSession::Token,
privileged: false,
}, },
}; };

View file

@ -919,6 +919,8 @@ pub enum AuthStep {
Init2 { Init2 {
username: String, username: String,
issue: AuthIssueSession, issue: AuthIssueSession,
#[serde(default)]
privileged: bool,
}, },
// We want to talk to you like this. // We want to talk to you like this.
Begin(AuthMech), Begin(AuthMech),

View file

@ -77,7 +77,9 @@ impl fmt::Display for AuthType {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum AuthIntent { enum AuthIntent {
InitialAuth, InitialAuth {
privileged: bool,
},
Reauth { Reauth {
session_id: Uuid, session_id: Uuid,
session_expiry: Option<OffsetDateTime>, session_expiry: Option<OffsetDateTime>,
@ -751,6 +753,7 @@ impl AuthSession {
pub fn new( pub fn new(
account: Account, account: Account,
issue: AuthIssueSession, issue: AuthIssueSession,
privileged: bool,
webauthn: &Webauthn, webauthn: &Webauthn,
ct: Duration, ct: Duration,
source: Source, source: Source,
@ -808,7 +811,7 @@ impl AuthSession {
account, account,
state, state,
issue, issue,
intent: AuthIntent::InitialAuth, intent: AuthIntent::InitialAuth { privileged },
source, source,
}; };
// Get the set of mechanisms that can proceed. This is tied // Get the set of mechanisms that can proceed. This is tied
@ -1109,7 +1112,7 @@ impl AuthSession {
) -> Result<UserAuthToken, OperationError> { ) -> Result<UserAuthToken, OperationError> {
security_debug!("Successful cred handling"); security_debug!("Successful cred handling");
match self.intent { match self.intent {
AuthIntent::InitialAuth => { AuthIntent::InitialAuth { privileged } => {
let session_id = Uuid::new_v4(); let session_id = Uuid::new_v4();
// We need to actually work this out better, and then // We need to actually work this out better, and then
// pass it to to_userauthtoken // pass it to to_userauthtoken
@ -1117,13 +1120,18 @@ impl AuthSession {
AuthType::UnixPassword | AuthType::Anonymous => SessionScope::ReadOnly, AuthType::UnixPassword | AuthType::Anonymous => SessionScope::ReadOnly,
AuthType::GeneratedPassword => SessionScope::ReadWrite, AuthType::GeneratedPassword => SessionScope::ReadWrite,
AuthType::Password | AuthType::PasswordMfa | AuthType::Passkey => { AuthType::Password | AuthType::PasswordMfa | AuthType::Passkey => {
SessionScope::PrivilegeCapable if privileged {
SessionScope::ReadWrite
} else {
SessionScope::PrivilegeCapable
}
} }
}; };
security_info!( security_info!(
"Issuing {:?} session {} for {} {}", "Issuing {:?} session ({:?}) {} for {} {}",
self.issue, self.issue,
scope,
session_id, session_id,
self.account.spn, self.account.spn,
self.account.uuid self.account.uuid
@ -1229,11 +1237,14 @@ impl AuthSession {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
pub use std::collections::BTreeSet as Set; pub use std::collections::BTreeSet as Set;
use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use compact_jwt::JwsSigner; use compact_jwt::{Jws, JwsSigner, JwsUnverified};
use hashbrown::HashSet; 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 tokio::sync::mpsc::unbounded_channel as unbounded;
use webauthn_authenticator_rs::softpasskey::SoftPasskey; use webauthn_authenticator_rs::softpasskey::SoftPasskey;
use webauthn_authenticator_rs::WebauthnAuthenticator; use webauthn_authenticator_rs::WebauthnAuthenticator;
@ -1282,6 +1293,7 @@ mod tests {
let (session, state) = AuthSession::new( let (session, state) = AuthSession::new(
anon_account, anon_account,
AuthIssueSession::Token, AuthIssueSession::Token,
false,
&webauthn, &webauthn,
duration_from_epoch_now(), duration_from_epoch_now(),
Source::Internal, Source::Internal,
@ -1311,11 +1323,13 @@ mod tests {
( (
$audit:expr, $audit:expr,
$account:expr, $account:expr,
$webauthn:expr $webauthn:expr,
$privileged:expr
) => {{ ) => {{
let (session, state) = AuthSession::new( let (session, state) = AuthSession::new(
$account.clone(), $account.clone(),
AuthIssueSession::Token, AuthIssueSession::Token,
$privileged,
$webauthn, $webauthn,
duration_from_epoch_now(), duration_from_epoch_now(),
Source::Internal, Source::Internal,
@ -1344,9 +1358,7 @@ mod tests {
}}; }};
} }
#[test] fn start_session_simple_password_mech(privileged: bool) -> UserAuthToken {
fn test_idm_authsession_simple_password_mech() {
sketching::test_init();
let webauthn = create_webauthn(); let webauthn = create_webauthn();
// create the ent // create the ent
let mut account = entry_to_account!(E_ADMIN_V1.clone()); let mut account = entry_to_account!(E_ADMIN_V1.clone());
@ -1360,7 +1372,7 @@ mod tests {
// now check // now check
let (mut session, pw_badlist_cache) = 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 attempt = AuthCredential::Password("bad_password".to_string());
let jws_signer = create_jwt_signer(); let jws_signer = create_jwt_signer();
@ -1385,10 +1397,10 @@ mod tests {
// === Now begin a new session, and use a good pw. // === Now begin a new session, and use a good pw.
let (mut session, pw_badlist_cache) = 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()); let attempt = AuthCredential::Password("test_password".to_string());
match session.validate_creds( let uat: UserAuthToken = match session.validate_creds(
&attempt, &attempt,
Duration::from_secs(0), Duration::from_secs(0),
&async_tx, &async_tx,
@ -1397,7 +1409,12 @@ mod tests {
&jws_signer, &jws_signer,
&AccountPolicy::from_pw_badlist_cache(pw_badlist_cache), &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<UserAuthToken> =
uat.validate_embeded().expect("Embedded uat not found");
uat.into_inner()
}
_ => panic!(), _ => panic!(),
}; };
@ -1410,6 +1427,34 @@ mod tests {
assert!(async_rx.blocking_recv().is_none()); assert!(async_rx.blocking_recv().is_none());
drop(audit_tx); drop(audit_tx);
assert!(audit_rx.blocking_recv().is_none()); 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] #[test]
@ -1429,7 +1474,7 @@ mod tests {
// now check, even though the password is correct, Auth should be denied since it is in badlist // now check, even though the password is correct, Auth should be denied since it is in badlist
let (mut session, pw_badlist_cache) = 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()); let attempt = AuthCredential::Password("list@no3IBTyqHu$bad".to_string());
match session.validate_creds( match session.validate_creds(
@ -1464,6 +1509,7 @@ mod tests {
let (session, state) = AuthSession::new( let (session, state) = AuthSession::new(
$account.clone(), $account.clone(),
AuthIssueSession::Token, AuthIssueSession::Token,
false,
$webauthn, $webauthn,
duration_from_epoch_now(), duration_from_epoch_now(),
Source::Internal, Source::Internal,
@ -1790,6 +1836,7 @@ mod tests {
let (session, state) = AuthSession::new( let (session, state) = AuthSession::new(
$account.clone(), $account.clone(),
AuthIssueSession::Token, AuthIssueSession::Token,
false,
$webauthn, $webauthn,
duration_from_epoch_now(), duration_from_epoch_now(),
Source::Internal, Source::Internal,

View file

@ -280,6 +280,7 @@ impl LdapTokenAuthEvent {
pub struct AuthEventStepInit { pub struct AuthEventStepInit {
pub username: String, pub username: String,
pub issue: AuthIssueSession, pub issue: AuthIssueSession,
pub privileged: bool,
} }
#[derive(Debug)] #[derive(Debug)]
@ -311,14 +312,23 @@ impl AuthEventStep {
Ok(AuthEventStep::Init(AuthEventStepInit { Ok(AuthEventStep::Init(AuthEventStepInit {
username, username,
issue: AuthIssueSession::Token, issue: AuthIssueSession::Token,
privileged: false,
})) }))
} }
} }
AuthStep::Init2 { username, issue } => { AuthStep::Init2 {
username,
issue,
privileged,
} => {
if username.trim().is_empty() { if username.trim().is_empty() {
Err(OperationError::EmptyRequest) Err(OperationError::EmptyRequest)
} else { } else {
Ok(AuthEventStep::Init(AuthEventStepInit { username, issue })) Ok(AuthEventStep::Init(AuthEventStepInit {
username,
issue,
privileged,
}))
} }
} }
@ -342,6 +352,7 @@ impl AuthEventStep {
AuthEventStep::Init(AuthEventStepInit { AuthEventStep::Init(AuthEventStepInit {
username: "anonymous".to_string(), username: "anonymous".to_string(),
issue: AuthIssueSession::Token, issue: AuthIssueSession::Token,
privileged: false,
}) })
} }
@ -350,6 +361,7 @@ impl AuthEventStep {
AuthEventStep::Init(AuthEventStepInit { AuthEventStep::Init(AuthEventStepInit {
username: name.to_string(), username: name.to_string(),
issue: AuthIssueSession::Token, issue: AuthIssueSession::Token,
privileged: false,
}) })
} }

View file

@ -1006,6 +1006,7 @@ impl<'a> IdmServerAuthTransaction<'a> {
security_info!( security_info!(
username = %init.username, username = %init.username,
issue = ?init.issue, issue = ?init.issue,
privileged = ?init.privileged,
uuid = %euuid, uuid = %euuid,
"Initiating Authentication Session", "Initiating Authentication Session",
); );
@ -1048,8 +1049,14 @@ impl<'a> IdmServerAuthTransaction<'a> {
slock_ref slock_ref
}); });
let (auth_session, state) = let (auth_session, state) = AuthSession::new(
AuthSession::new(account, init.issue, self.webauthn, ct, source); account,
init.issue,
init.privileged,
self.webauthn,
ct,
source,
);
match auth_session { match auth_session {
Some(auth_session) => { Some(auth_session) => {

View file

@ -2,7 +2,9 @@
use std::time::SystemTime; use std::time::SystemTime;
use kanidm_proto::v1::{ 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 kanidmd_lib::credential::totp::Totp;
use tracing::debug; use tracing::debug;
@ -13,7 +15,7 @@ use compact_jwt::JwsUnverified;
use webauthn_authenticator_rs::softpasskey::SoftPasskey; use webauthn_authenticator_rs::softpasskey::SoftPasskey;
use webauthn_authenticator_rs::WebauthnAuthenticator; use webauthn_authenticator_rs::WebauthnAuthenticator;
use kanidm_client::KanidmClient; use kanidm_client::{ClientError, KanidmClient};
use kanidmd_testkit::ADMIN_TEST_PASSWORD; use kanidmd_testkit::ADMIN_TEST_PASSWORD;
const UNIX_TEST_PASSWORD: &str = "unix test user password"; const UNIX_TEST_PASSWORD: &str = "unix test user password";
@ -1221,6 +1223,67 @@ async fn setup_demo_account_passkey(rsclient: &KanidmClient) -> WebauthnAuthenti
wa 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] #[kanidmd_testkit::test]
async fn test_server_credential_update_session_passkey(rsclient: KanidmClient) { async fn test_server_credential_update_session_passkey(rsclient: KanidmClient) {
let mut wa = setup_demo_account_passkey(&rsclient).await; 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(); let result = rsclient.system_auth_privilege_expiry_get().await.unwrap();
assert_eq!(authsession_expiry, result); assert_eq!(authsession_expiry, result);
} }
async fn start_password_session(
rsclient: &KanidmClient,
username: &str,
password: &str,
privileged: bool,
) -> Result<UserAuthToken, ()> {
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())
}
}
}

View file

@ -100,6 +100,7 @@ impl LoginApp {
step: AuthStep::Init2 { step: AuthStep::Init2 {
username, username,
issue: AuthIssueSession::Token, issue: AuthIssueSession::Token,
privileged: false,
}, },
}; };
let req_jsvalue = serde_json::to_string(&authreq) let req_jsvalue = serde_json::to_string(&authreq)