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 {
username: ident.to_string(),
issue: AuthIssueSession::Token,
privileged: false,
},
};

View file

@ -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),

View file

@ -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<OffsetDateTime>,
@ -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<UserAuthToken, OperationError> {
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 => {
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<UserAuthToken> =
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,

View file

@ -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,
})
}

View file

@ -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) => {

View file

@ -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<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 {
username,
issue: AuthIssueSession::Token,
privileged: false,
},
};
let req_jsvalue = serde_json::to_string(&authreq)