406 session revocation (#1123)

This commit is contained in:
Firstyear 2022-10-17 20:09:47 +10:00 committed by GitHub
parent 8b6c25fac5
commit a55c0ca68d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1452 additions and 373 deletions

View file

@ -432,9 +432,11 @@ impl KanidmClient {
builder.build()
}
pub async fn logout(&self) {
pub async fn logout(&self) -> Result<(), ClientError> {
self.perform_get_request("/v1/logout").await?;
let mut tguard = self.bearer_token.write().await;
*tguard = None;
Ok(())
}
async fn perform_simple_post_request<R: Serialize, T: DeserializeOwned>(

View file

@ -1,6 +1,9 @@
use std::collections::BTreeMap;
use kanidm_proto::v1::{AccountUnixExtend, CredentialStatus, Entry, SingleStringRequest};
use kanidm_proto::v1::{
AccountUnixExtend, CredentialStatus, Entry, SingleStringRequest, UatStatus,
};
use uuid::Uuid;
use crate::{ClientError, KanidmClient};
@ -231,4 +234,28 @@ impl KanidmClient {
self.perform_delete_request(format!("/v1/person/{}/_radius", id).as_str())
.await
}
pub async fn idm_account_list_user_auth_token(
&self,
id: &str,
) -> Result<Vec<UatStatus>, ClientError> {
self.perform_get_request(format!("/v1/account/{}/_user_auth_token", id).as_str())
.await
}
pub async fn idm_account_destroy_user_auth_token(
&self,
id: &str,
token_id: Uuid,
) -> Result<(), ClientError> {
self.perform_delete_request(
format!(
"/v1/account/{}/_user_auth_token/{}",
id,
&token_id.to_string()
)
.as_str(),
)
.await
}
}

View file

@ -321,6 +321,45 @@ pub enum UiHint {
PosixAccount,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum UatPurposeStatus {
IdentityOnly,
ReadOnly,
ReadWrite,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub struct UatStatus {
pub account_id: Uuid,
pub session_id: Uuid,
#[serde(with = "time::serde::timestamp::option")]
pub expiry: Option<time::OffsetDateTime>,
#[serde(with = "time::serde::timestamp")]
pub issued_at: time::OffsetDateTime,
pub purpose: UatPurposeStatus,
}
impl fmt::Display for UatStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "account_id: {}", self.account_id)?;
writeln!(f, "session_id: {}", self.session_id)?;
if let Some(exp) = self.expiry {
writeln!(f, "expiry: {}", exp)?;
} else {
writeln!(f, "expiry: -")?;
}
writeln!(f, "issued_at: {}", self.issued_at)?;
match &self.purpose {
UatPurposeStatus::IdentityOnly => writeln!(f, "purpose: identity only")?,
UatPurposeStatus::ReadOnly => writeln!(f, "purpose: read only")?,
UatPurposeStatus::ReadWrite => writeln!(f, "purpose: read write")?,
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum UatPurpose {
@ -345,10 +384,10 @@ pub enum UatPurpose {
pub struct UserAuthToken {
pub session_id: Uuid,
pub auth_type: AuthType,
// When this token should be considered expired. Interpretation
// may depend on the client application.
#[serde(with = "time::serde::timestamp")]
pub expiry: time::OffsetDateTime,
pub issued_at: time::OffsetDateTime,
#[serde(with = "time::serde::timestamp::option")]
pub expiry: Option<time::OffsetDateTime>,
pub purpose: UatPurpose,
pub uuid: Uuid,
pub displayname: String,
@ -363,7 +402,11 @@ impl fmt::Display for UserAuthToken {
writeln!(f, "spn: {}", self.spn)?;
writeln!(f, "uuid: {}", self.uuid)?;
writeln!(f, "display: {}", self.displayname)?;
writeln!(f, "expiry: {}", self.expiry)?;
if let Some(exp) = self.expiry {
writeln!(f, "expiry: {}", exp)?;
} else {
writeln!(f, "expiry: -")?;
}
match &self.purpose {
UatPurpose::IdentityOnly => writeln!(f, "purpose: identity only")?,
UatPurpose::ReadOnly => writeln!(f, "purpose: read only")?,

View file

@ -121,7 +121,8 @@ impl CommonOpt {
.map(|jws: Jws<UserAuthToken>| jws.into_inner())
{
Ok(uat) => {
if time::OffsetDateTime::now_utc() >= uat.expiry {
if let Some(exp) = uat.expiry {
if time::OffsetDateTime::now_utc() >= exp {
error!(
"Session has expired for {} - you may need to login again.",
uat.spn
@ -129,6 +130,7 @@ impl CommonOpt {
std::process::exit(1);
}
}
}
Err(e) => {
error!("Unable to read token for requested user - you may need to login again.");
debug!(?e, "JWT Error");

View file

@ -17,8 +17,8 @@ use webauthn_authenticator_rs::u2fhid::U2FHid;
use webauthn_authenticator_rs::WebauthnAuthenticator;
use crate::{
password_prompt, AccountCredential, AccountRadius, AccountSsh, AccountValidity, PersonOpt,
PersonPosix,
password_prompt, AccountCredential, AccountRadius, AccountSsh, AccountUserAuthToken,
AccountValidity, PersonOpt, PersonPosix,
};
impl PersonOpt {
@ -35,6 +35,10 @@ impl PersonOpt {
PersonPosix::Set(apo) => apo.copt.debug,
PersonPosix::SetPassword(apo) => apo.copt.debug,
},
PersonOpt::Session { commands } => match commands {
AccountUserAuthToken::Status(apo) => apo.copt.debug,
AccountUserAuthToken::Destroy { copt, .. } => copt.debug,
},
PersonOpt::Ssh { commands } => match commands {
AccountSsh::List(ano) => ano.copt.debug,
AccountSsh::Add(ano) => ano.copt.debug,
@ -166,6 +170,46 @@ impl PersonOpt {
}
}
}, // end PersonOpt::Posix
PersonOpt::Session { commands } => match commands {
AccountUserAuthToken::Status(apo) => {
let client = apo.copt.to_client().await;
match client
.idm_account_list_user_auth_token(apo.aopts.account_id.as_str())
.await
{
Ok(tokens) => {
if tokens.is_empty() {
println!("No sessions exist");
} else {
for token in tokens {
println!("token: {}", token);
}
}
}
Err(e) => {
error!("Error listing sessions -> {:?}", e);
}
}
}
AccountUserAuthToken::Destroy {
aopts,
copt,
session_id,
} => {
let client = copt.to_client().await;
match client
.idm_account_destroy_user_auth_token(aopts.account_id.as_str(), *session_id)
.await
{
Ok(()) => {
println!("Success");
}
Err(e) => {
error!("Error destroying account session -> {:?}", e);
}
}
}
}, // End PersonOpt::Session
PersonOpt::Ssh { commands } => match commands {
AccountSsh::List(aopt) => {
let client = aopt.copt.to_client().await;

View file

@ -2,8 +2,8 @@ use kanidm_proto::messages::{AccountChangeMessage, ConsoleOutputMode, MessageSta
use time::OffsetDateTime;
use crate::{
AccountSsh, AccountValidity, ServiceAccountApiToken, ServiceAccountCredential,
ServiceAccountOpt, ServiceAccountPosix,
AccountSsh, AccountUserAuthToken, AccountValidity, ServiceAccountApiToken,
ServiceAccountCredential, ServiceAccountOpt, ServiceAccountPosix,
};
impl ServiceAccountOpt {
@ -22,6 +22,10 @@ impl ServiceAccountOpt {
ServiceAccountPosix::Show(apo) => apo.copt.debug,
ServiceAccountPosix::Set(apo) => apo.copt.debug,
},
ServiceAccountOpt::Session { commands } => match commands {
AccountUserAuthToken::Status(apo) => apo.copt.debug,
AccountUserAuthToken::Destroy { copt, .. } => copt.debug,
},
ServiceAccountOpt::Ssh { commands } => match commands {
AccountSsh::List(ano) => ano.copt.debug,
AccountSsh::Add(ano) => ano.copt.debug,
@ -188,6 +192,46 @@ impl ServiceAccountOpt {
}
}
}, // end ServiceAccountOpt::Posix
ServiceAccountOpt::Session { commands } => match commands {
AccountUserAuthToken::Status(apo) => {
let client = apo.copt.to_client().await;
match client
.idm_account_list_user_auth_token(apo.aopts.account_id.as_str())
.await
{
Ok(tokens) => {
if tokens.is_empty() {
println!("No sessions exist");
} else {
for token in tokens {
println!("token: {}", token);
}
}
}
Err(e) => {
error!("Error listing sessions -> {:?}", e);
}
}
}
AccountUserAuthToken::Destroy {
aopts,
copt,
session_id,
} => {
let client = copt.to_client().await;
match client
.idm_account_destroy_user_auth_token(aopts.account_id.as_str(), *session_id)
.await
{
Ok(()) => {
println!("Success");
}
Err(e) => {
error!("Error destroying account session -> {:?}", e);
}
}
}
}, // End ServiceAccountOpt::Session
ServiceAccountOpt::Ssh { commands } => match commands {
AccountSsh::List(aopt) => {
let client = aopt.copt.to_client().await;

View file

@ -372,33 +372,66 @@ impl LoginOpt {
impl LogoutOpt {
pub fn debug(&self) -> bool {
self.debug
self.copt.debug
}
pub async fn exec(&self) {
let username: String = if self.local_only {
// For now we just remove this from the token store.
let mut _tmp_username = String::new();
let username = match &self.username {
Some(value) => value,
None => {
_tmp_username = match prompt_for_username_get_username() {
match &self.copt.username {
Some(value) => value.clone(),
None => match prompt_for_username_get_username() {
Ok(value) => value,
Err(msg) => {
error!("{}", msg);
std::process::exit(1);
}
};
&_tmp_username
},
}
} else {
let client = self.copt.to_client().await;
let token = match client.get_token().await {
Some(t) => t,
None => {
error!("Client token store is empty/corrupt");
std::process::exit(1);
}
};
// Parse it for the username.
if let Err(e) = client.logout().await {
error!("Failed to logout - {:?}", e);
std::process::exit(1);
}
// Server acked the logout, lets proceed with the local cleanup now.
let jwtu = match JwsUnverified::from_str(&token) {
Ok(value) => value,
Err(e) => {
error!(?e, "Unable to parse token from str");
std::process::exit(1);
}
};
let uat: UserAuthToken = match jwtu.validate_embeded() {
Ok(jwt) => jwt.into_inner(),
Err(e) => {
error!(?e, "Unable to verify token signature, may be corrupt");
std::process::exit(1);
}
};
uat.name().to_string()
};
let mut tokens = read_tokens().unwrap_or_else(|_| {
error!("Error retrieving authentication token store");
std::process::exit(1);
});
// Remove our old one
if tokens.remove(username).is_some() {
if tokens.remove(&username).is_some() {
// write them out.
if let Err(_e) = write_tokens(&tokens) {
error!("Error persisting authentication token store");
@ -463,12 +496,16 @@ impl SessionOpt {
let tokens: BTreeMap<_, _> = tokens
.into_iter()
.filter_map(|(u, (t, uat))| {
if now >= uat.expiry {
if let Some(exp) = uat.expiry {
if now >= exp {
//Expired
None
} else {
Some((u, t))
}
} else {
Some((u, t))
}
})
.collect();

View file

@ -278,6 +278,25 @@ pub enum AccountValidity {
BeginFrom(AccountNamedValidDateTimeOpt),
}
#[derive(Debug, Subcommand)]
pub enum AccountUserAuthToken {
/// Show the status of logged in sessions associated to this account.
#[clap(name = "status")]
Status(AccountNamedOpt),
/// Destroy / revoke a session for this account. Access to the
/// session (user auth token) is NOT required, only the uuid of the session.
#[clap(name = "destroy")]
Destroy {
#[clap(flatten)]
aopts: AccountCommonOpt,
#[clap(flatten)]
copt: CommonOpt,
/// The UUID of the token to destroy.
#[clap(name = "session_id")]
session_id: Uuid,
},
}
#[derive(Debug, Subcommand)]
pub enum PersonOpt {
/// Manage the credentials this person uses for authentication
@ -298,6 +317,12 @@ pub enum PersonOpt {
#[clap(subcommand)]
commands: PersonPosix,
},
/// Manage sessions (user auth tokens) associated to this person.
#[clap(name = "session")]
Session {
#[clap(subcommand)]
commands: AccountUserAuthToken,
},
/// Manage ssh public key's associated to this person
#[clap(name = "ssh")]
Ssh {
@ -417,6 +442,12 @@ pub enum ServiceAccountOpt {
#[clap(subcommand)]
commands: ServiceAccountPosix,
},
/// Manage sessions (user auth tokens) associated to this service account.
#[clap(name = "session")]
Session {
#[clap(subcommand)]
commands: AccountUserAuthToken,
},
/// Manage ssh public key's associated to this person
#[clap(name = "ssh")]
Ssh {
@ -474,14 +505,11 @@ pub struct LoginOpt {
#[derive(Debug, Args)]
pub struct LogoutOpt {
#[clap(short, long, env = "KANIDM_DEBUG")]
pub debug: bool,
#[clap(short = 'H', long = "url", env = "KANIDM_URL")]
pub addr: Option<String>,
#[clap(parse(from_os_str), short = 'C', long = "ca", env = "KANIDM_CA_PATH")]
pub ca_path: Option<PathBuf>,
#[clap()]
pub username: Option<String>,
#[clap(flatten)]
copt: CommonOpt,
#[clap(short, long, hide = true)]
/// Do not send the logout to the server - only remove the session token locally
local_only: bool,
}
#[derive(Debug, Subcommand)]

View file

@ -5,7 +5,7 @@ use std::sync::Arc;
use kanidm_proto::v1::{
ApiToken, AuthRequest, BackupCodesView, CURequest, CUSessionToken, CUStatus, CredentialStatus,
Entry as ProtoEntry, OperationError, RadiusAuthToken, SearchRequest, SearchResponse,
Entry as ProtoEntry, OperationError, RadiusAuthToken, SearchRequest, SearchResponse, UatStatus,
UnixGroupToken, UnixUserToken, WhoamiResponse,
};
use ldap3_proto::simple::*;
@ -18,6 +18,7 @@ use kanidmd_lib::prelude::*;
use kanidmd_lib::{
event::{OnlineBackupEvent, SearchEvent, SearchResult, WhoamiResult},
filter::{Filter, FilterInvalid},
idm::account::ListUserAuthTokenEvent,
idm::credupdatesession::CredentialUpdateSessionToken,
idm::event::{
AuthEvent, AuthResult, CredentialStatusEvent, RadiusAuthTokenEvent, ReadBackupCodeEvent,
@ -765,6 +766,38 @@ impl QueryServerReadV1 {
idms_prox_read.service_account_list_api_token(&lte)
}
#[instrument(
level = "info",
skip_all,
fields(uuid = ?eventid)
)]
pub async fn handle_account_user_auth_token_get(
&self,
uat: Option<String>,
uuid_or_name: String,
eventid: Uuid,
) -> Result<Vec<UatStatus>, OperationError> {
let ct = duration_from_epoch_now();
let idms_prox_read = self.idms.proxy_read_async().await;
let ident = idms_prox_read
.validate_and_parse_token_to_ident(uat.as_deref(), ct)
.map_err(|e| {
admin_error!("Invalid identity: {:?}", e);
e
})?;
let target = idms_prox_read
.qs_read
.name_to_uuid(uuid_or_name.as_str())
.map_err(|e| {
admin_error!("Error resolving id to target");
e
})?;
let lte = ListUserAuthTokenEvent { ident, target };
idms_prox_read.account_list_user_auth_tokens(&lte)
}
#[instrument(
level = "info",
skip_all,

View file

@ -3,9 +3,9 @@ use std::sync::Arc;
use std::time::Duration;
use kanidm_proto::v1::{
AccountUnixExtend, CUIntentToken, CUSessionToken, CUStatus, CreateRequest, DeleteRequest,
Entry as ProtoEntry, GroupUnixExtend, Modify as ProtoModify, ModifyList as ProtoModifyList,
ModifyRequest, OperationError,
AccountUnixExtend, AuthType, CUIntentToken, CUSessionToken, CUStatus, CreateRequest,
DeleteRequest, Entry as ProtoEntry, GroupUnixExtend, Modify as ProtoModify,
ModifyList as ProtoModifyList, ModifyRequest, OperationError,
};
use time::OffsetDateTime;
use tracing::{info, instrument, span, trace, Level};
@ -17,6 +17,7 @@ use kanidmd_lib::{
ReviveRecycledEvent,
},
filter::{Filter, FilterInvalid},
idm::account::DestroySessionTokenEvent,
idm::credupdatesession::{
CredentialUpdateIntentToken, CredentialUpdateSessionToken, InitCredentialUpdateEvent,
InitCredentialUpdateIntentEvent,
@ -498,6 +499,91 @@ impl QueryServerWriteV1 {
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
#[instrument(
level = "info",
skip_all,
fields(uuid = ?eventid)
)]
pub async fn handle_account_user_auth_token_destroy(
&self,
uat: Option<String>,
uuid_or_name: String,
token_id: Uuid,
eventid: Uuid,
) -> Result<(), OperationError> {
let ct = duration_from_epoch_now();
let idms_prox_write = self.idms.proxy_write_async(ct).await;
let ident = idms_prox_write
.validate_and_parse_token_to_ident(uat.as_deref(), ct)
.map_err(|e| {
admin_error!(err = ?e, "Invalid identity");
e
})?;
let target = idms_prox_write
.qs_write
.name_to_uuid(uuid_or_name.as_str())
.map_err(|e| {
admin_error!(err = ?e, "Error resolving id to target");
e
})?;
let dte = DestroySessionTokenEvent {
ident,
target,
token_id,
};
idms_prox_write
.account_destroy_session_token(&dte)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
#[instrument(
level = "info",
skip_all,
fields(uuid = ?eventid)
)]
pub async fn handle_logout(
&self,
uat: Option<String>,
eventid: Uuid,
) -> Result<(), OperationError> {
let ct = duration_from_epoch_now();
let idms_prox_write = self.idms.proxy_write_async(ct).await;
// We specifically need a uat here to assess the auth type!
let (ident, uat) = idms_prox_write
.validate_and_parse_uat(uat.as_deref(), ct)
.and_then(|uat| {
idms_prox_write
.process_uat_to_identity(&uat, ct)
.map(|ident| (ident, uat))
})?;
if uat.auth_type == AuthType::Anonymous {
info!("Ignoring request to logout anonymous session - these sessions are not recorded");
return Ok(());
}
let target = ident.get_uuid().ok_or_else(|| {
admin_error!("Invalid identity - no uuid present");
OperationError::InvalidState
})?;
let token_id = ident.get_session_id();
let dte = DestroySessionTokenEvent {
ident,
target,
token_id,
};
idms_prox_write
.account_destroy_session_token(&dte)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
#[instrument(
level = "info",
skip_all,

View file

@ -535,6 +535,8 @@ pub fn create_https_server(
.at("/v1/auth/valid")
.mapped_get(&mut routemap, auth_valid);
appserver.at("/v1/logout").mapped_get(&mut routemap, logout);
let mut schema_route = appserver.at("/v1/schema");
schema_route.at("/").mapped_get(&mut routemap, schema_get);
schema_route
@ -753,6 +755,12 @@ pub fn create_https_server(
account_route
.at("/:id/_ssh_pubkeys/:tag")
.mapped_get(&mut routemap, account_get_id_ssh_pubkey_tag);
account_route
.at("/:id/_user_auth_token")
.mapped_get(&mut routemap, account_get_id_user_auth_token);
account_route
.at("/:id/_user_auth_token/:token_id")
.mapped_delete(&mut routemap, account_user_auth_token_delete);
// Credential updates, don't require the account id.
let mut cred_route = appserver.at("/v1/credential");

View file

@ -65,6 +65,13 @@ pub async fn whoami(req: tide::Request<AppState>) -> tide::Result {
to_tide_response(res, hvalue)
}
pub async fn logout(req: tide::Request<AppState>) -> tide::Result {
let uat = req.get_current_uat();
let (eventid, hvalue) = req.new_eventid();
let res = req.state().qe_w_ref.handle_logout(uat, eventid).await;
to_tide_response(res, hvalue)
}
// =============== REST generics ========================
pub async fn json_rest_event_get(
@ -574,6 +581,35 @@ pub async fn account_get_id_credential_update_intent(req: tide::Request<AppState
to_tide_response(res, hvalue)
}
pub async fn account_get_id_user_auth_token(req: tide::Request<AppState>) -> tide::Result {
let uat = req.get_current_uat();
let uuid_or_name = req.get_url_param("id")?;
let (eventid, hvalue) = req.new_eventid();
let res = req
.state()
.qe_r_ref
.handle_account_user_auth_token_get(uat, uuid_or_name, eventid)
.await;
to_tide_response(res, hvalue)
}
pub async fn account_user_auth_token_delete(req: tide::Request<AppState>) -> tide::Result {
let uat = req.get_current_uat();
let uuid_or_name = req.get_url_param("id")?;
let token_id = req.get_url_param_uuid("token_id")?;
let (eventid, hvalue) = req.new_eventid();
let res = req
.state()
.qe_w_ref
.handle_account_user_auth_token_destroy(uat, uuid_or_name, token_id, eventid)
.await;
to_tide_response(res, hvalue)
}
pub async fn credential_update_exchange_intent(mut req: tide::Request<AppState>) -> tide::Result {
let (eventid, hvalue) = req.new_eventid();
let intent_token: CUIntentToken = req.body_json().await?;

View file

@ -366,5 +366,8 @@ async fn test_oauth2_openid_basic_flow() {
.await
.expect("Unable to decode OidcToken from userinfo");
tracing::trace!(?userinfo);
tracing::trace!(?oidc);
assert!(userinfo == oidc);
}

View file

@ -2,7 +2,7 @@
use std::time::SystemTime;
use kanidm_proto::v1::{
ApiToken, CURegState, CredentialDetailType, Entry, Filter, Modify, ModifyList,
ApiToken, CURegState, CredentialDetailType, Entry, Filter, Modify, ModifyList, UserAuthToken,
};
use kanidmd_lib::credential::totp::Totp;
use tracing::debug;
@ -1246,3 +1246,99 @@ async fn test_server_api_token_lifecycle() {
// No need to test expiry, that's validated in the server internal tests.
}
#[tokio::test]
async fn test_server_user_auth_token_lifecycle() {
let rsclient = setup_async_test().await;
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
// Not recommended in production!
rsclient
.idm_group_add_members("idm_admins", &["admin"])
.await
.unwrap();
rsclient
.idm_person_account_create("demo_account", "Deeeeemo")
.await
.unwrap();
// 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")
.await
.unwrap();
// Logout, we don't need any auth now.
let _ = rsclient.logout();
// Exchange the intent token
let (session_token, _status) = rsclient
.idm_account_credential_update_exchange(intent_token)
.await
.unwrap();
// Setup and update the password
let _status = rsclient
.idm_account_credential_update_set_password(&session_token, "eicieY7ahchaoCh0eeTa")
.await
.unwrap();
// Commit it
rsclient
.idm_account_credential_update_commit(&session_token)
.await
.unwrap();
}
// Auth as the user.
let _ = rsclient.logout();
let res = rsclient
.auth_simple_password("demo_account", "eicieY7ahchaoCh0eeTa")
.await;
assert!(res.is_ok());
let token = rsclient.get_token().await.expect("No bearer token present");
let token_unverified =
JwsUnverified::from_str(&token).expect("Failed to parse user auth token");
let token: UserAuthToken = token_unverified
.validate_embeded()
.map(|j| j.into_inner())
.expect("Embedded jwk not found");
let sessions = rsclient
.idm_account_list_user_auth_token("demo_account")
.await
.expect("Failed to list user auth tokens");
assert!(sessions[0].session_id == token.session_id);
// idm_account_destroy_user_auth_token
rsclient
.idm_account_destroy_user_auth_token("demo_account", token.session_id)
.await
.expect("Failed to destroy user auth token");
let tokens = rsclient
.idm_service_account_list_api_token("demo_account")
.await
.expect("Failed to list user auth tokens");
assert!(tokens.is_empty());
// No need to test expiry, that's validated in the server internal tests.
}

View file

@ -6,6 +6,10 @@ if [ -z "$KANI_TMP" ]; then
KANI_TMP=/tmp/kanidm
fi
if [ -z "$KANI_CARGO_OPTS" ]; then
KANI_CARGO_OPTS=--debug
fi
CONFIG_FILE="../../examples/insecure_server.toml"
if [ ! -f "${CONFIG_FILE}" ]; then
@ -28,4 +32,4 @@ if [ -n "${1}" ]; then
fi
#shellcheck disable=SC2086
cargo run --bin kanidmd -- ${COMMAND} -c "${CONFIG_FILE}"
cargo run ${KANI_CARGO_OPTS} --bin kanidmd -- ${COMMAND} -c "${CONFIG_FILE}"

View file

@ -101,6 +101,7 @@ pub const JSON_IDM_SELF_ACP_READ_V1: &str = r#"{
"account_expire",
"account_valid_from",
"primary_credential",
"user_auth_token_session",
"passkeys",
"devicekeys"
]
@ -120,7 +121,7 @@ pub const JSON_IDM_SELF_ACP_WRITE_V1: &str = r#"{
"{\"and\": [{\"eq\": [\"class\",\"person\"]}, {\"eq\": [\"class\",\"account\"]}, \"self\"]}"
],
"acp_modify_removedattr": [
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password", "passkeys", "devicekeys"
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password", "passkeys", "devicekeys", "user_auth_token_session"
],
"acp_modify_presentattr": [
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password", "passkeys", "devicekeys"
@ -247,6 +248,9 @@ pub const JSON_IDM_ACP_PEOPLE_MANAGE_PRIV_V1: &str = r#"{
"displayname",
"legalname",
"primary_credential",
"passkeys",
"devicekeys",
"user_auth_token_session",
"ssh_publickey",
"mail"
],
@ -434,7 +438,7 @@ pub const JSON_IDM_ACP_ACCOUNT_READ_PRIV_V1: &str = r#"{
"{\"and\": [{\"eq\": [\"class\",\"account\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"memberof\",\"00000000-0000-0000-0000-000000001000\"]}, {\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
],
"acp_search_attr": [
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "mail", "gidnumber", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "mail", "gidnumber", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session", "user_auth_token_session"
]
}
}"#;
@ -456,7 +460,7 @@ pub const JSON_IDM_ACP_ACCOUNT_WRITE_PRIV_V1: &str = r#"{
"{\"and\": [{\"eq\": [\"class\",\"account\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"memberof\",\"00000000-0000-0000-0000-000000001000\"]}, {\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
],
"acp_modify_removedattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session", "user_auth_token_session"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"
@ -591,7 +595,7 @@ pub const JSON_IDM_ACP_HP_ACCOUNT_READ_PRIV_V1: &str = r#"{
"{\"and\": [{\"eq\": [\"class\",\"account\"]}, {\"eq\": [\"memberof\",\"00000000-0000-0000-0000-000000001000\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
],
"acp_search_attr": [
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session", "user_auth_token_session"
]
}
}"#;
@ -613,7 +617,7 @@ pub const JSON_IDM_ACP_HP_ACCOUNT_WRITE_PRIV_V1: &str = r#"{
"{\"and\": [{\"eq\": [\"class\",\"account\"]}, {\"eq\": [\"memberof\",\"00000000-0000-0000-0000-000000001000\"]}, {\"andnot\": {\"or\": [{\"eq\": [\"class\", \"tombstone\"]}, {\"eq\": [\"class\", \"recycled\"]}]}}]}"
],
"acp_modify_removedattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session", "user_auth_token_session"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from", "passkeys", "devicekeys", "api_token_session"

View file

@ -14,6 +14,8 @@ pub use crate::constants::system_config::*;
pub use crate::constants::uuids::*;
pub use crate::constants::values::*;
use std::time::Duration;
// Increment this as we add new schema types and values!!!
pub const SYSTEM_INDEX_VERSION: i64 = 26;
// On test builds, define to 60 seconds
@ -45,3 +47,12 @@ pub const PW_MIN_LENGTH: usize = 10;
// Default
pub const AUTH_SESSION_EXPIRY: u64 = 3600;
// The time that a token can be used before session
// status is enforced. This needs to be longer than
// replication delay/cycle.
pub const GRACE_WINDOW: Duration = Duration::from_secs(600);
/// How long access tokens should last. This is NOT the length
/// of the refresh token, which is bound to the issuing session.
pub const OAUTH2_ACCESS_TOKEN_EXPIRY: u32 = 4 * 3600;

View file

@ -1140,6 +1140,37 @@ pub const JSON_SCHEMA_ATTR_API_TOKEN_SESSION: &str = r#"{
}
}"#;
pub const JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"A session entry related to an issued user auth token"
],
"index": [
"EQUALITY"
],
"unique": [
"true"
],
"multivalue": [
"true"
],
"attributename": [
"user_auth_token_session"
],
"syntax": [
"SESSION"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000113"
]
}
}"#;
// === classes ===
pub const JSON_SCHEMA_CLASS_PERSON: &str = r#"
@ -1279,7 +1310,8 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#"
"account_expire",
"account_valid_from",
"mail",
"oauth2_consent_scope_map"
"oauth2_consent_scope_map",
"user_auth_token_session"
],
"systemmust": [
"displayname",

View file

@ -192,6 +192,8 @@ pub const _UUID_SCHEMA_ATTR_JWS_ES256_PRIVATE_KEY: Uuid =
pub const _UUID_SCHEMA_ATTR_API_TOKEN_SESSION: Uuid = uuid!("00000000-0000-0000-0000-ffff00000111");
pub const _UUID_SCHEMA_ATTR_OAUTH2_RS_SUP_SCOPE_MAP: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000112");
pub const _UUID_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000113");
// System and domain infos
// I'd like to strongly criticise william of the past for making poor choices about these allocations.

View file

@ -491,7 +491,7 @@ impl Filter<FilterInvalid> {
// This has to have two versions to account for ro/rw traits, because RS can't
// monomorphise on the trait to call clone_value. An option is to make a fn that
// takes "clone_value(t, a, v) instead, but that may have a similar issue.
#[instrument(level = "debug", skip_all)]
#[instrument(name = "filter::from_ro", level = "debug", skip_all)]
pub fn from_ro(
ev: &Identity,
f: &ProtoFilter,
@ -506,7 +506,7 @@ impl Filter<FilterInvalid> {
})
}
#[instrument(level = "debug", skip_all)]
#[instrument(name = "filter::from_rw", level = "debug", skip_all)]
pub fn from_rw(
ev: &Identity,
f: &ProtoFilter,
@ -521,7 +521,7 @@ impl Filter<FilterInvalid> {
})
}
#[instrument(level = "debug", skip_all)]
#[instrument(name = "filter::from_ldap_ro", level = "debug", skip_all)]
pub fn from_ldap_ro(
ev: &Identity,
f: &LdapFilter,

View file

@ -6,8 +6,9 @@
use std::collections::BTreeSet;
use std::hash::Hash;
use std::sync::Arc;
use uuid::uuid;
use kanidm_proto::v1::ApiTokenPurpose;
use kanidm_proto::v1::{ApiTokenPurpose, UatPurpose, UatPurposeStatus};
use serde::{Deserialize, Serialize};
@ -87,6 +88,29 @@ impl TryInto<ApiTokenPurpose> for AccessScope {
}
}
impl From<&UatPurpose> for AccessScope {
fn from(purpose: &UatPurpose) -> Self {
match purpose {
UatPurpose::IdentityOnly => AccessScope::IdentityOnly,
UatPurpose::ReadOnly => AccessScope::ReadOnly,
UatPurpose::ReadWrite { .. } => AccessScope::ReadWrite,
}
}
}
impl TryInto<UatPurposeStatus> for AccessScope {
type Error = OperationError;
fn try_into(self: AccessScope) -> Result<UatPurposeStatus, OperationError> {
match self {
AccessScope::ReadOnly => Ok(UatPurposeStatus::ReadOnly),
AccessScope::ReadWrite => Ok(UatPurposeStatus::ReadWrite),
AccessScope::IdentityOnly => Ok(UatPurposeStatus::IdentityOnly),
AccessScope::Synchronise => Err(OperationError::InvalidEntryState),
}
}
}
#[derive(Debug, Clone)]
/// Metadata and the entry of the current Identity which is an external account/user.
pub struct IdentUser {
@ -122,11 +146,14 @@ impl From<&IdentType> for IdentityId {
}
#[derive(Debug, Clone)]
/// An identity that initiated an `Event`.
/// An identity that initiated an `Event`. Contains extra details about the session
/// and other info that can assist with server decision making.
pub struct Identity {
pub origin: IdentType,
// pub(crate) source:
// pub(crate) impersonate: bool,
// In a way I guess these are session claims?
pub(crate) session_id: Uuid,
pub(crate) scope: AccessScope,
pub(crate) limits: Limits,
}
@ -139,9 +166,10 @@ impl std::fmt::Display for Identity {
let nv = u.entry.get_uuid2spn();
write!(
f,
"User( {}, {} ) ({})",
"User( {}, {} ) ({}, {})",
nv.to_proto_string_clone(),
u.entry.get_uuid().as_hyphenated(),
self.session_id,
self.scope
)
}
@ -153,6 +181,7 @@ impl Identity {
pub fn from_internal() -> Self {
Identity {
origin: IdentType::Internal,
session_id: uuid!("00000000-0000-0000-0000-000000000000"),
scope: AccessScope::ReadWrite,
limits: Limits::unlimited(),
}
@ -164,6 +193,7 @@ impl Identity {
) -> Self {
Identity {
origin: IdentType::User(IdentUser { entry }),
session_id: uuid!("00000000-0000-0000-0000-000000000000"),
scope: AccessScope::IdentityOnly,
limits: Limits::unlimited(),
}
@ -173,6 +203,7 @@ impl Identity {
pub fn from_impersonate_entry_readonly(entry: Arc<Entry<EntrySealed, EntryCommitted>>) -> Self {
Identity {
origin: IdentType::User(IdentUser { entry }),
session_id: uuid!("00000000-0000-0000-0000-000000000000"),
scope: AccessScope::ReadOnly,
limits: Limits::unlimited(),
}
@ -184,6 +215,7 @@ impl Identity {
) -> Self {
Identity {
origin: IdentType::User(IdentUser { entry }),
session_id: uuid!("00000000-0000-0000-0000-000000000000"),
scope: AccessScope::ReadWrite,
limits: Limits::unlimited(),
}
@ -193,6 +225,10 @@ impl Identity {
self.scope
}
pub fn get_session_id(&self) -> Uuid {
self.session_id
}
pub fn from_impersonate(ident: &Self) -> Self {
// TODO #64 ?: In the future, we could change some of this data
// to reflect the fact we are infact impersonating the action

View file

@ -2,7 +2,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use kanidm_proto::v1::{
AuthType, BackupCodesView, CredentialStatus, OperationError, UatPurpose, UiHint, UserAuthToken,
AuthType, BackupCodesView, CredentialStatus, OperationError, UatPurpose, UatStatus, UiHint,
UserAuthToken,
};
use time::OffsetDateTime;
use uuid::Uuid;
@ -15,8 +16,9 @@ use crate::credential::policy::CryptoPolicy;
use crate::credential::softlock::CredSoftLockPolicy;
use crate::credential::Credential;
use crate::entry::{Entry, EntryCommitted, EntryReduced, EntrySealed};
use crate::event::SearchEvent;
use crate::idm::group::Group;
use crate::idm::server::IdmServerProxyWriteTransaction;
use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction};
use crate::modify::{ModifyInvalid, ModifyList};
use crate::prelude::*;
use crate::schema::SchemaTransaction;
@ -195,6 +197,7 @@ impl Account {
// TODO: Apply policy to this expiry time.
let expiry = OffsetDateTime::unix_epoch() + ct + Duration::from_secs(AUTH_SESSION_EXPIRY);
let issued_at = OffsetDateTime::unix_epoch() + ct;
// TODO: Apply priv expiry.
let purpose = UatPurpose::ReadWrite {
expiry: expiry.clone(),
@ -203,7 +206,8 @@ impl Account {
Some(UserAuthToken {
session_id,
auth_type,
expiry,
expiry: Some(expiry),
issued_at,
purpose,
uuid: self.uuid,
displayname: self.displayname.clone(),
@ -417,13 +421,118 @@ impl Account {
// Used in registrations only for disallowing exsiting credentials.
None
}
pub(crate) fn check_user_auth_token_valid(
ct: Duration,
uat: &UserAuthToken,
entry: &Entry<EntrySealed, EntryCommitted>,
) -> bool {
// Remember, token expiry is checked by validate_and_parse_token_to_token.
// If we wanted we could check other properties of the uat here?
// Alternatelly, we could always store LESS in the uat because of this?
let within_valid_window = Account::check_within_valid_time(
ct,
entry.get_ava_single_datetime("account_valid_from").as_ref(),
entry.get_ava_single_datetime("account_expire").as_ref(),
);
if !within_valid_window {
security_info!("Account has expired or is not yet valid, not allowing to proceed");
return false;
}
// Anonymous does NOT record it's sessions, so we simply check the expiry time
// of the token. This is already done for us as noted above.
if uat.auth_type == AuthType::Anonymous {
security_info!("Anonymous sessions do not have session records, session is valid.");
true
} else {
// Get the sessions.
let session_present = entry
.get_ava_as_session_map("user_auth_token_session")
.map(|session_map| session_map.get(&uat.session_id).is_some())
.unwrap_or(false);
if session_present {
security_info!("A valid session value exists for this token");
true
} else {
let grace = uat.issued_at + GRACE_WINDOW;
let current = time::OffsetDateTime::unix_epoch() + ct;
trace!(%grace, %current);
if current >= grace {
security_info!(
"The token grace window has passed, and no session exists. Assuming invalid."
);
false
} else {
security_info!("The token grace window is in effect. Assuming valid.");
true
}
}
}
}
}
// Need to also add a "to UserAuthToken" ...
// Need tests for conversion and the cred validations
pub struct DestroySessionTokenEvent {
// Who initiated this?
pub ident: Identity,
// Who is it targetting?
pub target: Uuid,
// Which token id.
pub token_id: Uuid,
}
impl DestroySessionTokenEvent {
#[cfg(test)]
pub fn new_internal(target: Uuid, token_id: Uuid) -> Self {
DestroySessionTokenEvent {
ident: Identity::from_internal(),
target,
token_id,
}
}
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn account_destroy_session_token(
&self,
dte: &DestroySessionTokenEvent,
) -> Result<(), OperationError> {
// Delete the attribute with uuid.
let modlist = ModifyList::new_list(vec![Modify::Removed(
AttrString::from("user_auth_token_session"),
PartialValue::Refer(dte.token_id),
)]);
self.qs_write
.impersonate_modify(
// Filter as executed
&filter!(f_and!([
f_eq("uuid", PartialValue::Uuid(dte.target)),
f_eq("user_auth_token_session", PartialValue::Refer(dte.token_id))
])),
// Filter as intended (acp)
&filter_all!(f_and!([
f_eq("uuid", PartialValue::Uuid(dte.target)),
f_eq("user_auth_token_session", PartialValue::Refer(dte.token_id))
])),
&modlist,
// Provide the event to impersonate
&dte.ident,
)
.map_err(|e| {
admin_error!("Failed to destroy user auth token {:?}", e);
e
})
}
pub fn service_account_into_person(
&self,
ident: &Identity,
@ -495,6 +604,70 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
}
}
pub struct ListUserAuthTokenEvent {
// Who initiated this?
pub ident: Identity,
// Who is it targetting?
pub target: Uuid,
}
impl<'a> IdmServerProxyReadTransaction<'a> {
pub fn account_list_user_auth_tokens(
&self,
lte: &ListUserAuthTokenEvent,
) -> Result<Vec<UatStatus>, OperationError> {
// Make an event from the request
let srch = match SearchEvent::from_target_uuid_request(
lte.ident.clone(),
lte.target,
&self.qs_read,
) {
Ok(s) => s,
Err(e) => {
admin_error!("Failed to begin account list user auth tokens: {:?}", e);
return Err(e);
}
};
match self.qs_read.search_ext(&srch) {
Ok(mut entries) => {
entries
.pop()
// get the first entry
.and_then(|e| {
let account_id = e.get_uuid();
// From the entry, turn it into the value
e.get_ava_as_session_map("user_auth_token_session")
.map(|smap| {
smap.iter()
.map(|(u, s)| {
s.scope
.try_into()
.map(|purpose| UatStatus {
account_id,
session_id: *u,
expiry: s.expiry.clone(),
issued_at: s.issued_at.clone(),
purpose,
})
.map_err(|e| {
admin_error!("Invalid user auth token {}", u);
e
})
})
.collect::<Result<Vec<_>, _>>()
})
})
.unwrap_or_else(|| {
// No matching entry? Return none.
Ok(Vec::new())
})
}
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use crate::constants::JSON_ANONYMOUS_V1;

View file

@ -22,9 +22,10 @@ use webauthn_rs::prelude::{
use crate::credential::totp::Totp;
use crate::credential::{BackupCodes, Credential, CredentialType, Password};
use crate::identity::IdentityId;
use crate::idm::account::Account;
use crate::idm::delayed::{
BackupCodeRemoval, DelayedAction, PasswordUpgrade, WebauthnCounterIncrement,
AuthSessionRecord, BackupCodeRemoval, DelayedAction, PasswordUpgrade, WebauthnCounterIncrement,
};
use crate::idm::AuthState;
use crate::prelude::*;
@ -740,10 +741,6 @@ impl AuthSession {
) {
CredState::Success(auth_type) => {
security_info!("Successful cred handling");
// TODO: put the operation id into the call to `to_userauthtoken`
// Can't `unwrap` the uuid until full integration, because some unit tests
// call functions that call this indirectly without opening a span first,
// and this returns `None` when not in a span (and panics if the tree isn't initialized).
let session_id = Uuid::new_v4();
security_info!(
"Starting session {} for {} {}",
@ -751,11 +748,47 @@ impl AuthSession {
self.account.spn,
self.account.uuid
);
let uat = self
.account
.to_userauthtoken(session_id, *time, auth_type)
.to_userauthtoken(session_id, *time, auth_type.clone())
.ok_or(OperationError::InvalidState)?;
// Queue the session info write.
// This is dependent on the type of authentication factors
// used. Generally we won't submit for Anonymous. Add an extra
// safety barrier for auth types that shouldn't be here. Generally we
// submit session info for everything else.
match auth_type {
AuthType::Anonymous => {
// Skip - these sessions are not validated by session id.
}
AuthType::UnixPassword => {
// Impossibru!
admin_error!("Impossible auth type (UnixPassword) found");
return Err(OperationError::InvalidState);
}
AuthType::Password
| AuthType::GeneratedPassword
| AuthType::PasswordMfa
| AuthType::Passkey => {
trace!("⚠️ Queued AuthSessionRecord for {}", self.account.uuid);
async_tx.send(DelayedAction::AuthSessionRecord(AuthSessionRecord {
target_uuid: self.account.uuid,
session_id,
label: "Auth Session".to_string(),
expiry: uat.expiry,
issued_at: uat.issued_at.clone(),
issued_by: IdentityId::User(self.account.uuid),
scope: (&uat.purpose).into(),
}))
.map_err(|_| {
admin_error!("unable to queue failing authentication as the session will not validate ... ");
OperationError::InvalidState
})?;
}
};
let jwt = Jws::new(uat);
// Now encrypt and prepare the token for return to the client.
@ -984,6 +1017,11 @@ mod tests {
_ => panic!(),
};
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
drop(async_tx);
assert!(async_rx.blocking_recv().is_none());
}
@ -1225,6 +1263,11 @@ mod tests {
Ok(AuthState::Success(_)) => {}
_ => panic!(),
};
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
}
drop(async_tx);
@ -1445,13 +1488,17 @@ mod tests {
Ok(AuthState::Success(_)) => {}
_ => panic!(),
};
}
// Check the async counter update was sent.
match async_rx.blocking_recv() {
Some(DelayedAction::WebauthnCounterIncrement(_)) => {}
_ => assert!(false),
}
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
}
// Check bad challenge.
{
@ -1686,6 +1733,10 @@ mod tests {
Some(DelayedAction::WebauthnCounterIncrement(_)) => {}
_ => assert!(false),
}
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
}
drop(async_tx);
@ -1883,6 +1934,11 @@ mod tests {
Ok(AuthState::Success(_)) => {}
_ => panic!(),
};
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
}
// Check good webauthn/good pw (pass)
@ -1923,6 +1979,10 @@ mod tests {
Some(DelayedAction::WebauthnCounterIncrement(_)) => {}
_ => assert!(false),
}
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
}
drop(async_tx);
@ -2077,6 +2137,12 @@ mod tests {
_ => assert!(false),
}
// There will be a auth session record too
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
// TOTP should also work:
// check send good TOTP, should continue
// then good pw, success
@ -2108,6 +2174,12 @@ mod tests {
};
}
// There will be a auth session record too
match async_rx.blocking_recv() {
Some(DelayedAction::AuthSessionRecord(_)) => {}
_ => assert!(false),
}
drop(async_tx);
assert!(async_rx.blocking_recv().is_none());
}

View file

@ -1487,7 +1487,7 @@ mod tests {
use crate::event::CreateEvent;
use crate::idm::delayed::DelayedAction;
use crate::idm::event::{AuthEvent, AuthResult};
use crate::idm::server::IdmServer;
use crate::idm::server::{IdmServer, IdmServerDelayed};
use crate::idm::AuthState;
use crate::prelude::*;
@ -1669,7 +1669,12 @@ mod tests {
idms_prox_write.commit().expect("Failed to commit txn");
}
fn check_testperson_password(idms: &IdmServer, pw: &str, ct: Duration) -> Option<String> {
fn check_testperson_password(
idms: &IdmServer,
idms_delayed: &mut IdmServerDelayed,
pw: &str,
ct: Duration,
) -> Option<String> {
let mut idms_auth = idms.auth();
let auth_init = AuthEvent::named_init("testperson");
@ -1711,13 +1716,20 @@ mod tests {
sessionid: _,
state: AuthState::Success(token),
delay: _,
}) => Some(token),
}) => {
// Process the auth session
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
Some(token)
}
_ => None,
}
}
fn check_testperson_password_totp(
idms: &IdmServer,
idms_delayed: &mut IdmServerDelayed,
pw: &str,
token: &Totp,
ct: Duration,
@ -1778,13 +1790,19 @@ mod tests {
sessionid: _,
state: AuthState::Success(token),
delay: _,
}) => Some(token),
}) => {
// Process the auth session
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
Some(token)
}
_ => None,
}
}
fn check_testperson_password_backup_code(
idms: &IdmServer,
idms_delayed: &mut IdmServerDelayed,
pw: &str,
code: &str,
ct: Duration,
@ -1841,13 +1859,25 @@ mod tests {
sessionid: _,
state: AuthState::Success(token),
delay: _,
}) => Some(token),
}) => {
// There now should be a backup code invalidation present
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::BackupCodeRemoval(_)));
let r = task::block_on(idms.delayed_action(ct, da));
assert!(r.is_ok());
// Process the auth session
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
Some(token)
}
_ => None,
}
}
fn check_testperson_passkey(
idms: &IdmServer,
idms_delayed: &mut IdmServerDelayed,
wa: &mut WebauthnAuthenticator<SoftPasskey>,
origin: Url,
ct: Duration,
@ -1906,7 +1936,19 @@ mod tests {
sessionid: _,
state: AuthState::Success(token),
delay: _,
}) => Some(token),
}) => {
// Process the webauthn update
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::WebauthnCounterIncrement(_)));
let r = task::block_on(idms.delayed_action(ct, da));
assert!(r.is_ok());
// Process the auth session
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
Some(token)
}
_ => None,
}
}
@ -1941,10 +1983,10 @@ mod tests {
#[test]
fn test_idm_credential_update_onboarding_create_new_pw() {
run_idm_test!(|_qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed| {
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let test_pw =
"fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (cust, _) = setup_test_session(idms, ct);
@ -1974,7 +2016,7 @@ mod tests {
commit_session(idms, ct, cust);
// Check it works!
assert!(check_testperson_password(idms, test_pw, ct).is_some());
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct).is_some());
// Test deleting the pw
let (cust, _) = renew_test_session(idms, ct);
@ -1996,8 +2038,9 @@ mod tests {
commit_session(idms, ct, cust);
// Must fail now!
assert!(check_testperson_password(idms, test_pw, ct).is_none());
})
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct).is_none());
}
)
}
// Test set of primary account password
@ -2007,10 +2050,10 @@ mod tests {
// - setup TOTP
#[test]
fn test_idm_credential_update_onboarding_create_new_mfa_totp_basic() {
run_idm_test!(|_qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed| {
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let test_pw =
"fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (cust, _) = setup_test_session(idms, ct);
@ -2068,7 +2111,14 @@ mod tests {
commit_session(idms, ct, cust);
// Check it works!
assert!(check_testperson_password_totp(idms, test_pw, &totp_token, ct).is_some());
assert!(check_testperson_password_totp(
idms,
idms_delayed,
test_pw,
&totp_token,
ct
)
.is_some());
// No need to test delete of the whole cred, we already did with pw above.
// If we remove TOTP, show it reverts back.
@ -2089,17 +2139,18 @@ mod tests {
commit_session(idms, ct, cust);
// Check it works with totp removed.
assert!(check_testperson_password(idms, test_pw, ct).is_some());
})
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct).is_some());
}
)
}
// Check sha1 totp.
#[test]
fn test_idm_credential_update_onboarding_create_new_mfa_totp_sha1() {
run_idm_test!(|_qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed| {
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let test_pw =
"fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (cust, _) = setup_test_session(idms, ct);
@ -2160,9 +2211,17 @@ mod tests {
commit_session(idms, ct, cust);
// Check it works!
assert!(check_testperson_password_totp(idms, test_pw, &totp_token, ct).is_some());
assert!(check_testperson_password_totp(
idms,
idms_delayed,
test_pw,
&totp_token,
ct
)
.is_some());
// No need to test delete, we already did with pw above.
})
}
)
}
#[test]
@ -2239,15 +2298,14 @@ mod tests {
let backup_code = codes.iter().next().expect("No codes available");
// Check it works!
assert!(
check_testperson_password_backup_code(idms, test_pw, backup_code, ct).is_some()
);
// There now should be a backup code invalidation present
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::BackupCodeRemoval(_)));
let r = task::block_on(idms.delayed_action(ct, da));
assert!(r.is_ok());
assert!(check_testperson_password_backup_code(
idms,
idms_delayed,
test_pw,
backup_code,
ct
)
.is_some());
// Renew to start the next steps
let (cust, _) = renew_test_session(idms, ct);
@ -2307,10 +2365,10 @@ mod tests {
#[test]
fn test_idm_credential_update_onboarding_cancel_inprogress_totp() {
run_idm_test!(|_qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed| {
let test_pw = "fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let test_pw =
"fo3EitierohF9AelaNgiem0Ei6vup4equo1Oogeevaetehah8Tobeengae3Ci0ooh0uki";
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (cust, _) = setup_test_session(idms, ct);
@ -2347,8 +2405,9 @@ mod tests {
commit_session(idms, ct, cust);
// It's pw only, since we canceled TOTP
assert!(check_testperson_password(idms, test_pw, ct).is_some());
})
assert!(check_testperson_password(idms, idms_delayed, test_pw, ct).is_some());
}
)
}
// Primary cred must be pw or pwmfa
@ -2412,23 +2471,10 @@ mod tests {
commit_session(idms, ct, cust);
// Do an auth test
assert!(check_testperson_passkey(idms, &mut wa, origin.clone(), ct).is_some());
// Since it authed, it should have updated the delayed queue.
let da = idms_delayed
.blocking_recv()
.expect("No queued action found!");
match &da {
DelayedAction::WebauthnCounterIncrement(wci) => {
trace!("{:?}", wci.auth_result);
}
_ => unreachable!(),
}
let mut idms_prox_write = idms.proxy_write(ct);
assert!(idms_prox_write.process_delayedaction(da).is_ok());
idms_prox_write.commit().expect("Failed to commit txn");
assert!(
check_testperson_passkey(idms, idms_delayed, &mut wa, origin.clone(), ct)
.is_some()
);
// Now test removing the token
let (cust, _) = renew_test_session(idms, ct);
@ -2450,7 +2496,9 @@ mod tests {
commit_session(idms, ct, cust);
// Must fail now!
assert!(check_testperson_passkey(idms, &mut wa, origin, ct).is_none());
assert!(
check_testperson_passkey(idms, idms_delayed, &mut wa, origin, ct).is_none()
);
}
)
}

View file

@ -1,12 +1,18 @@
use crate::identity::{AccessScope, IdentityId};
use time::OffsetDateTime;
use uuid::Uuid;
use webauthn_rs::prelude::AuthenticationResult;
use std::fmt;
#[derive(Debug)]
pub enum DelayedAction {
PwUpgrade(PasswordUpgrade),
UnixPwUpgrade(UnixPasswordUpgrade),
WebauthnCounterIncrement(WebauthnCounterIncrement),
BackupCodeRemoval(BackupCodeRemoval),
Oauth2ConsentGrant(Oauth2ConsentGrant),
AuthSessionRecord(AuthSessionRecord),
}
pub struct PasswordUpgrade {
@ -14,23 +20,53 @@ pub struct PasswordUpgrade {
pub existing_password: String,
}
impl fmt::Debug for PasswordUpgrade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PasswordUpgrade")
.field("target_uuid", &self.target_uuid)
.finish()
}
}
pub struct UnixPasswordUpgrade {
pub target_uuid: Uuid,
pub existing_password: String,
}
impl fmt::Debug for UnixPasswordUpgrade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UnixPasswordUpgrade")
.field("target_uuid", &self.target_uuid)
.finish()
}
}
#[derive(Debug)]
pub struct WebauthnCounterIncrement {
pub target_uuid: Uuid,
pub auth_result: AuthenticationResult,
}
#[derive(Debug)]
pub struct BackupCodeRemoval {
pub target_uuid: Uuid,
pub code_to_remove: String,
}
#[derive(Debug)]
pub struct Oauth2ConsentGrant {
pub target_uuid: Uuid,
pub oauth2_rs_uuid: Uuid,
pub scopes: Vec<String>,
}
#[derive(Debug)]
pub struct AuthSessionRecord {
pub target_uuid: Uuid,
pub session_id: Uuid,
pub label: String,
pub expiry: Option<OffsetDateTime>,
pub issued_at: OffsetDateTime,
pub issued_by: IdentityId,
pub scope: AccessScope,
}

View file

@ -940,15 +940,24 @@ impl Oauth2ResourceServersReadTransaction {
let iat = ct.as_secs() as i64;
// TODO: Make configurable from auth policy!
let expires_in = if code_xchg.uat.expiry > odt_ct {
let (expiry, expires_in) = if let Some(expiry) = code_xchg.uat.expiry {
if expiry > odt_ct {
// Becomes a duration.
(code_xchg.uat.expiry - odt_ct).whole_seconds() as u32
(expiry, (expiry - odt_ct).whole_seconds() as u32)
} else {
security_info!(
"User Auth Token has expired before we could publish the oauth2 response"
);
return Err(Oauth2Error::AccessDenied);
}
} else {
security_info!("User Auth Token has no expiry, setting to refresh window");
(
odt_ct + Duration::from_secs(OAUTH2_ACCESS_TOKEN_EXPIRY as u64),
OAUTH2_ACCESS_TOKEN_EXPIRY,
)
};
// let expiry = odt_ct + Duration::from_secs(expires_in as u64);
let scope = if code_xchg.scopes.is_empty() {
None
@ -1044,7 +1053,7 @@ impl Oauth2ResourceServersReadTransaction {
scopes: code_xchg.scopes,
session_id: code_xchg.uat.session_id,
auth_type: code_xchg.uat.auth_type,
expiry: code_xchg.uat.expiry,
expiry,
uuid: code_xchg.uat.uuid,
iat,
nbf: iat,
@ -1870,8 +1879,10 @@ mod tests {
// TEST_CURRENT_TIME UAT_EXPIRE TOKEN_EXPIRE
//
// This lets us check a variety of time based cases.
uat.expiry = time::OffsetDateTime::unix_epoch()
+ Duration::from_secs(TEST_CURRENT_TIME + UAT_EXPIRE - 1);
uat.expiry = Some(
time::OffsetDateTime::unix_epoch()
+ Duration::from_secs(TEST_CURRENT_TIME + UAT_EXPIRE - 1),
);
let idms_prox_read = idms.proxy_read();

View file

@ -27,7 +27,6 @@ use tracing::trace;
use url::Url;
use webauthn_rs::prelude::{Webauthn, WebauthnBuilder};
use super::delayed::BackupCodeRemoval;
use super::event::ReadBackupCodeEvent;
use crate::credential::policy::CryptoPolicy;
use crate::credential::softlock::CredSoftLock;
@ -36,8 +35,8 @@ use crate::idm::account::Account;
use crate::idm::authsession::AuthSession;
use crate::idm::credupdatesession::CredentialUpdateSessionMutex;
use crate::idm::delayed::{
DelayedAction, Oauth2ConsentGrant, PasswordUpgrade, UnixPasswordUpgrade,
WebauthnCounterIncrement,
AuthSessionRecord, BackupCodeRemoval, DelayedAction, Oauth2ConsentGrant, PasswordUpgrade,
UnixPasswordUpgrade, WebauthnCounterIncrement,
};
#[cfg(test)]
use crate::idm::event::PasswordChangeEvent;
@ -60,6 +59,7 @@ use crate::idm::AuthState;
use crate::ldap::{LdapBoundToken, LdapSession};
use crate::prelude::*;
use crate::utils::{password_from_random, readable_password_from_random, uuid_from_duration, Sid};
use crate::value::Session;
type AuthSessionMutex = Arc<Mutex<AuthSession>>;
type CredSoftLockMutex = Arc<Mutex<CredSoftLock>>;
@ -83,7 +83,6 @@ pub struct IdmServer {
webauthn: Webauthn,
pw_badlist_cache: Arc<CowCell<HashSet<String>>>,
oauth2rs: Arc<Oauth2ResourceServers>,
uat_jwt_signer: Arc<CowCell<JwsSigner>>,
uat_jwt_validator: Arc<CowCell<JwsValidator>>,
token_enc_key: Arc<CowCell<Fernet>>,
@ -358,14 +357,19 @@ impl IdmServerDelayed {
let mut cx = Context::from_waker(&waker);
match self.async_rx.poll_recv(&mut cx) {
Poll::Pending | Poll::Ready(None) => {}
Poll::Ready(Some(_m)) => panic!("Task queue not empty"),
Poll::Ready(Some(m)) => {
trace!(?m);
panic!("Task queue not empty")
}
}
}
/*
#[cfg(test)]
pub(crate) fn blocking_recv(&mut self) -> Option<DelayedAction> {
self.async_rx.blocking_recv()
}
*/
#[cfg(test)]
pub(crate) fn try_recv(&mut self) -> Result<DelayedAction, OperationError> {
@ -455,12 +459,17 @@ pub trait IdmServerTransaction<'a> {
})
.map(|t: Jws<UserAuthToken>| t.into_inner())?;
if time::OffsetDateTime::unix_epoch() + ct >= uat.expiry {
if let Some(exp) = uat.expiry {
if time::OffsetDateTime::unix_epoch() + ct >= exp {
security_info!("Session expired");
Err(OperationError::SessionExpired)
} else {
Ok(Token::UserAuthToken(uat))
}
} else {
debug!("Session has no expiry");
Ok(Token::UserAuthToken(uat))
}
} else {
// It's a per-user key, get their validator.
let entry = self
@ -541,12 +550,17 @@ pub trait IdmServerTransaction<'a> {
.map(|t: Jws<UserAuthToken>| t.into_inner())
})?;
if time::OffsetDateTime::unix_epoch() + ct >= uat.expiry {
if let Some(exp) = uat.expiry {
if time::OffsetDateTime::unix_epoch() + ct >= exp {
security_info!("Session expired");
Err(OperationError::SessionExpired)
} else {
Ok(uat)
}
} else {
debug!("Session has no expiry");
Ok(uat)
}
}
fn check_account_uuid_valid(
@ -597,19 +611,14 @@ pub trait IdmServerTransaction<'a> {
e
})?;
// #59: If the account is expired, do not allow the event
// to proceed
let valid = Account::check_within_valid_time(
ct,
entry.get_ava_single_datetime("account_valid_from").as_ref(),
entry.get_ava_single_datetime("account_expire").as_ref(),
);
let valid = Account::check_user_auth_token_valid(ct, uat, &entry);
if !valid {
security_info!("Account has expired or is not yet valid, not allowing to proceed");
return Err(OperationError::SessionExpired);
}
// ✅ Session is valid! Start to setup for it to be used.
let scope = match uat.purpose {
UatPurpose::IdentityOnly => AccessScope::IdentityOnly,
UatPurpose::ReadOnly => AccessScope::ReadOnly,
@ -623,6 +632,8 @@ pub trait IdmServerTransaction<'a> {
}
};
let limits = Limits::default();
// #64: Now apply claims from the uat into the Entry
// to allow filtering.
/*
@ -635,28 +646,12 @@ pub trait IdmServerTransaction<'a> {
AuthType::PasswordMfa => "authtype_passwordmfa",
});
match &uat.auth_type {
AuthType::Anonymous | AuthType::UnixPassword | AuthType::Password => {}
AuthType::GeneratedPassword | AuthType::Webauthn | AuthType::PasswordMfa => {
entry.insert_claim("authlevel_strong")
}
};
match &uat.auth_type {
AuthType::Anonymous => {}
AuthType::UnixPassword
| AuthType::Password
| AuthType::GeneratedPassword
| AuthType::Webauthn => entry.insert_claim("authclass_single"),
AuthType::PasswordMfa => entry.insert_claim("authclass_mfa"),
};
trace!(claims = ?entry.get_ava_set("claim"), "Applied claims");
*/
trace!(claims = ?entry.get_ava_set("claim"), "Applied claims");
let limits = Limits::default();
Ok(Identity {
origin: IdentType::User(IdentUser { entry }),
session_id: uat.session_id,
scope,
limits,
})
@ -681,6 +676,7 @@ pub trait IdmServerTransaction<'a> {
let limits = Limits::default();
Ok(Identity {
origin: IdentType::User(IdentUser { entry }),
session_id: apit.token_id,
scope,
limits,
})
@ -718,8 +714,11 @@ pub trait IdmServerTransaction<'a> {
) {
// Good to go
let limits = Limits::default();
let session_id = Uuid::new_v4();
Ok(Identity {
origin: IdentType::User(IdentUser { entry: anon_entry }),
session_id,
scope: AccessScope::ReadOnly,
limits,
})
@ -1993,6 +1992,46 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
)
}
pub(crate) fn process_authsessionrecord(
&mut self,
asr: &AuthSessionRecord,
) -> Result<(), OperationError> {
let session = Value::Session(
asr.session_id,
Session {
label: asr.label.clone(),
expiry: asr.expiry,
// Need the other inner bits?
// for the gracewindow.
issued_at: asr.issued_at,
// Who actually created this?
issued_by: asr.issued_by.clone(),
// What is the access scope of this session? This is
// for auditing purposes.
scope: asr.scope,
},
);
info!(session_id = %asr.session_id, "Persisting auth session");
// modify the account to put the session onto it.
let modlist = ModifyList::new_list(vec![Modify::Present(
AttrString::from("user_auth_token_session"),
session,
)]);
self.qs_write
.internal_modify(
&filter!(f_eq("uuid", PartialValue::new_uuid(asr.target_uuid))),
&modlist,
)
.map_err(|e| {
admin_error!("Failed to persist user auth token {:?}", e);
e
})
// Done!
}
pub(crate) fn process_oauth2consentgrant(
&mut self,
o2cg: &Oauth2ConsentGrant,
@ -2021,6 +2060,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
DelayedAction::WebauthnCounterIncrement(wci) => self.process_webauthncounterinc(&wci),
DelayedAction::BackupCodeRemoval(bcr) => self.process_backupcoderemoval(&bcr),
DelayedAction::Oauth2ConsentGrant(o2cg) => self.process_oauth2consentgrant(&o2cg),
DelayedAction::AuthSessionRecord(asr) => self.process_authsessionrecord(&asr),
}
}
@ -2110,6 +2150,8 @@ mod tests {
use crate::credential::policy::CryptoPolicy;
use crate::credential::{Credential, Password};
use crate::event::{CreateEvent, ModifyEvent};
use crate::idm::account::DestroySessionTokenEvent;
use crate::idm::delayed::DelayedAction;
use crate::idm::event::{AuthEvent, AuthResult};
use crate::idm::event::{
PasswordChangeEvent, RadiusAuthTokenEvent, RegenerateRadiusSecretEvent,
@ -2399,15 +2441,21 @@ mod tests {
};
idms_auth.commit().expect("Must not fail");
token
}
#[test]
fn test_idm_simple_password_auth() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &IdmServerDelayed| {
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
check_admin_password(idms, TEST_PASSWORD);
// Clear our the session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
idms_delayed.check_is_empty_or_panic();
}
)
}
@ -2415,7 +2463,7 @@ mod tests {
#[test]
fn test_idm_simple_password_spn_auth() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &IdmServerDelayed| {
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
let sid = init_admin_authsession_sid(
@ -2460,6 +2508,11 @@ mod tests {
}
};
// Clear our the session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
idms_delayed.check_is_empty_or_panic();
idms_auth.commit().expect("Must not fail");
}
)
@ -2874,12 +2927,23 @@ mod tests {
idms_delayed.check_is_empty_or_panic();
// Do an auth, this will trigger the action to send.
check_admin_password(idms, "password");
// process it.
let da = idms_delayed.try_recv().expect("invalid");
// The first task is the pw upgrade
assert!(matches!(da, DelayedAction::PwUpgrade(_)));
let r = task::block_on(idms.delayed_action(duration_from_epoch_now(), da));
// The second is the auth session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
assert!(Ok(true) == r);
// Check the admin pw still matches
check_admin_password(idms, "password");
// Clear the next auth session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
// No delayed action was queued.
idms_delayed.check_is_empty_or_panic();
}
@ -3152,7 +3216,7 @@ mod tests {
#[test]
fn test_idm_account_softlocking() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| {
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
// Auth invalid, no softlock present.
@ -3286,6 +3350,12 @@ mod tests {
};
idms_auth.commit().expect("Must not fail");
// Clear the auth session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
idms_delayed.check_is_empty_or_panic();
// Auth valid after reset at, count == 0.
// Tested in the softlock state machine.
@ -3453,13 +3523,20 @@ mod tests {
#[test]
fn test_idm_jwt_uat_expiry() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| {
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let expiry = ct + Duration::from_secs(AUTH_SESSION_EXPIRY + 1);
// Do an authenticate
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
let token = check_admin_password(idms, TEST_PASSWORD);
// Clear our the session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
let r = task::block_on(idms.delayed_action(duration_from_epoch_now(), da));
assert!(Ok(true) == r);
idms_delayed.check_is_empty_or_panic();
let idms_prox_read = idms.proxy_read();
// Check it's valid.
@ -3477,6 +3554,80 @@ mod tests {
)
}
#[test]
fn test_idm_account_session_validation() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
use compact_jwt::{Jws, JwsUnverified};
use kanidm_proto::v1::UserAuthToken;
use std::str::FromStr;
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let post_grace = ct + GRACE_WINDOW + Duration::from_secs(1);
let expiry = ct + Duration::from_secs(AUTH_SESSION_EXPIRY + 1);
// Assert that our grace time is less than expiry, so we know the failure is due to
// this.
assert!(post_grace < expiry);
// Do an authenticate
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
let token = check_admin_password(idms, TEST_PASSWORD);
// Process the session info.
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
let r = task::block_on(idms.delayed_action(duration_from_epoch_now(), da));
assert!(Ok(true) == r);
let uat_unverified =
JwsUnverified::from_str(&token).expect("Failed to parse apitoken");
let uat_inner: Jws<UserAuthToken> = uat_unverified
.validate_embeded()
.expect("Embedded jwk not found");
let uat_inner = uat_inner.into_inner();
let idms_prox_read = idms.proxy_read();
// Check it's valid.
idms_prox_read
.validate_and_parse_token_to_ident(Some(token.as_str()), ct)
.expect("Failed to validate");
// If the auth session record wasn't processed, this will fail.
idms_prox_read
.validate_and_parse_token_to_ident(Some(token.as_str()), post_grace)
.expect("Failed to validate");
drop(idms_prox_read);
// Mark the session as invalid now.
let idms_prox_write = idms.proxy_write(ct.clone());
let dte =
DestroySessionTokenEvent::new_internal(uat_inner.uuid, uat_inner.session_id);
assert!(idms_prox_write.account_destroy_session_token(&dte).is_ok());
assert!(idms_prox_write.commit().is_ok());
// Now check again with the session destroyed.
let idms_prox_read = idms.proxy_read();
// Now, within gracewindow, it's still valid.
idms_prox_read
.validate_and_parse_token_to_ident(Some(token.as_str()), ct)
.expect("Failed to validate");
// post grace, it's not valid.
match idms_prox_read
.validate_and_parse_token_to_ident(Some(token.as_str()), post_grace)
{
Err(OperationError::SessionExpired) => {}
_ => assert!(false),
}
}
)
}
#[test]
fn test_idm_uat_claim_insertion() {
run_idm_test!(|_qs: &QueryServer,
@ -3584,11 +3735,17 @@ mod tests {
#[test]
fn test_idm_jwt_uat_token_key_reload() {
run_idm_test!(
|qs: &QueryServer, idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed| {
|qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
init_admin_w_password(qs, TEST_PASSWORD).expect("Failed to setup admin account");
let token = check_admin_password(idms, TEST_PASSWORD);
// Clear the session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
idms_delayed.check_is_empty_or_panic();
let idms_prox_read = idms.proxy_read();
// Check it's valid.
@ -3619,6 +3776,11 @@ mod tests {
// Check the old token is invalid, due to reload.
let new_token = check_admin_password(idms, TEST_PASSWORD);
// Clear the session record
let da = idms_delayed.try_recv().expect("invalid");
assert!(matches!(da, DelayedAction::AuthSessionRecord(_)));
idms_delayed.check_is_empty_or_panic();
let idms_prox_read = idms.proxy_read();
assert!(idms_prox_read
.validate_and_parse_token_to_ident(Some(token.as_str()), ct)

View file

@ -26,8 +26,6 @@ use crate::value::Session;
// revoke
const GRACE_WINDOW: Duration = Duration::from_secs(600);
macro_rules! try_from_entry {
($value:expr) => {{
// Check the classes
@ -324,7 +322,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
) {
Ok(s) => s,
Err(e) => {
admin_error!("Failed to begin ssh key read: {:?}", e);
admin_error!("Failed to begin service account api token list: {:?}", e);
return Err(e);
}
};
@ -376,7 +374,7 @@ mod tests {
use compact_jwt::{Jws, JwsUnverified};
use kanidm_proto::v1::ApiToken;
use super::{DestroyApiTokenEvent, GenerateApiTokenEvent, GRACE_WINDOW};
use super::{DestroyApiTokenEvent, GenerateApiTokenEvent};
// use crate::prelude::*;
use crate::event::CreateEvent;
use crate::idm::server::IdmServerTransaction;

View file

@ -2655,6 +2655,7 @@ impl<'a> QueryServerWriteTransaction<'a> {
JSON_SCHEMA_ATTR_JWS_ES256_PRIVATE_KEY,
JSON_SCHEMA_ATTR_API_TOKEN_SESSION,
JSON_SCHEMA_ATTR_OAUTH2_RS_SUP_SCOPE_MAP,
JSON_SCHEMA_ATTR_USER_AUTH_TOKEN_SESSION,
JSON_SCHEMA_CLASS_PERSON,
JSON_SCHEMA_CLASS_ORGPERSON,
JSON_SCHEMA_CLASS_GROUP,

View file

@ -291,7 +291,7 @@ impl KaniHttpServer {
}
pub async fn close_connection(&self) {
self.client.logout().await;
assert!(self.client.logout().await.is_ok());
}
pub async fn search(