Account valid-from and expiry (#322)

Fixes #59 account policy and lockout. This is achived with a valid_from and expire attribute that are timestamps. Cli tools are added to manage these.
This commit is contained in:
Firstyear 2020-10-10 10:31:51 +10:00 committed by GitHub
parent ca71b12b46
commit 018039b0b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1241 additions and 169 deletions

36
Cargo.lock generated
View file

@ -178,9 +178,9 @@ dependencies = [
[[package]]
name = "async-io"
version = "1.1.5"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e727cebd055ab2861a854f79def078c4b99ea722d54c6800a0e274389882d4c"
checksum = "b5bfd63f6fc8fd2925473a147d3f4d252c712291efdde0d7057b25146563402c"
dependencies = [
"concurrent-queue",
"fastrand",
@ -286,9 +286,9 @@ dependencies = [
[[package]]
name = "async-trait"
version = "0.1.40"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687c230d85c0a52504709705fc8a53e4a692b83a2184f03dae73e38e1e93a783"
checksum = "b246867b8b3b6ae56035f1eb1ed557c1d8eae97f0d53696138a50fa0e3a3b8c0"
dependencies = [
"proc-macro2",
"quote",
@ -380,9 +380,9 @@ dependencies = [
[[package]]
name = "blake3"
version = "0.3.6"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce4f9586c9a3151c4b49b19e82ba163dd073614dd057e53c969e1a4db5b52720"
checksum = "e9ff35b701f3914bdb8fad3368d822c766ef2858b2583198e41639b936f09d3f"
dependencies = [
"arrayref",
"arrayvec",
@ -1561,7 +1561,7 @@ dependencies = [
"structopt",
"tide",
"tide-rustls",
"time 0.1.44",
"time 0.2.22",
"tokio",
"tokio-openssl",
"tokio-util",
@ -1617,6 +1617,7 @@ dependencies = [
"serde_json",
"shellexpand",
"structopt",
"time 0.2.22",
"zxcvbn",
]
@ -1698,9 +1699,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.77"
version = "0.2.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f96b10ec2560088a8e76961b00d47107b3a625fecb76dedb29ee7ccbf98235"
checksum = "aa7087f49d294270db4e1928fc110c976cd4b9e5a16348e0a1df09afa99e6c98"
[[package]]
name = "libnss"
@ -2141,18 +2142,18 @@ checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
[[package]]
name = "pin-project"
version = "0.4.24"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f48fad7cfbff853437be7cf54d7b993af21f53be7f0988cbfe4a51535aa77205"
checksum = "2b9e280448854bd91559252582173b3bd1f8e094a0e644791c0628ca9b1f144f"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "0.4.24"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24c6d293bdd3ca5a1697997854c6cf7855e43fb6a0ba1c47af57a5bcafd158ae"
checksum = "c8c8b352676bc6a4c3d71970560b913cea444a7a921cc2e2d920225e4b91edaa"
dependencies = [
"proc-macro2",
"quote",
@ -2161,9 +2162,9 @@ dependencies = [
[[package]]
name = "pin-project-lite"
version = "0.1.9"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe74897791e156a0cd8cce0db31b9b2198e67877316bf3086c3acd187f719f0"
checksum = "e555d9e657502182ac97b539fb3dae8b79cda19e3e4f8ffb5e8de4f18df93c95"
[[package]]
name = "pin-utils"
@ -2256,9 +2257,9 @@ checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a"
[[package]]
name = "proc-macro2"
version = "1.0.23"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ef7cd2518ead700af67bf9d1a658d90b6037d77110fd9c0445429d0ba1c6c9"
checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
dependencies = [
"unicode-xid",
]
@ -3056,6 +3057,7 @@ checksum = "55b7151c9065e80917fbf285d9a5d1432f60db41d170ccafc749a136b41a93af"
dependencies = [
"const_fn",
"libc",
"serde",
"standback",
"stdweb",
"time-macros",

128
designs/account_policy.rst Normal file
View file

@ -0,0 +1,128 @@
Account Policy and Lockouts
---------------------------
For accounts we need to be able to define securite constraints and limits to prevent malicious use
or attacks from succeeding. While these attacks may have similar sources or goals, the defences
to them may vary.
A list (not comprehensive) of these include:
* Credential Stuffing
* Phishing (Site Impersonation)
* Key Logging / Physical Discovery
* Common Password / Spray
* Brute Force
Credential Policies
===================
As the majority of the attacks listed can be prevented with TOTP, and all effectively defeated with
Webauthn, it's essential that policies can exist that allow an administrator to set requirements
on accounts to what level of authentication they require to protect resources.
Credential Polcies are inherited from groups, as groups grant rights and claims to other resources.
Since it is these resources and privileges we wish to protect, logically the credential policy becomes
part of the group that should be protected.
When multiple credential policies exist that may be conflicting, the "stricter" policy is enforced
as a group in the set requires it.
The strength of credentials is today sorted as:
* (weakest)
* Password
* GeneratedPassword
* Webauthn (with out verification)
* TOTP + Password
* Webauthn + Password
* WebauthnVerified
* WebauthnVerified + Password
* (strongest)
Rate Limiting
======================
Rate Limiting is the process of delaying authentication responses to slow the number of attempts
against an account to deter attackers. This is often used to prevent attackers from bruteforcing
passwords at a high rate.
The best defence again these attacks is MFA. Due to the design of Kanidm, the second factor
(ie the webauthn token or the otp) is always checked *before* the password, meaning that the
attacker is unable to attack the password *unless* they also have the corresponding MFA token.
However, not all accounts will have MFA enabled, which means that defences are still required to
prevent these attacks for password-only accounts. Accounts protected with TOTP must also be rate
limited according to NIST sp800 63b. Webauthn does *not* require ratelimiting as a single factor
or multi factor device.
As an account can only have a single proceeding authentication session at a time, this provides
serialisation and rate limiting per account of the service. However, as Kanidm will in the future
support multiple, distributed replicas, we must consider an architecture that allows eventually
consistent behaviour.
NIST SP800 63b recommends that after 100 failed attempts that the account be locked. Due to the
eventually consistent nature, this poses a challenge, namely that:
* Synchronising this account lock may not be instant, allowing further attempts on parallel servers.
* That read only servers may exist in the system which can not write to the entries.
* A malicious party may intentionally send incorrect values to force an account to lock.
To account for this for accounts with TOTP:
* After an 5 incorrect TOTP's within the time window, the account is locked for 60 seconds. This prevents bruteforce of the TOTP.
For accounts with password-only:
* After 5 incorrect attempts the account is rate limited by an increasing time window within the API. This limit delays the response to the auth (regardless of success)
* After X attempts, the account is soft locked on the affected server only for a time window of Y increasing up to Z.
* If the attempts continue, the account is hard locked and signalled to an external system that this has occured.
The value of X should be less than 100, so that the NIST guidelines can be met. This is beacuse when there are
many replicas, each replica maintains it's own locking state, so "eventually" as each replica is attempted to be
bruteforced, then they will all eventually soft lock the account. In larger environments, we require
external signalling to coordinate the locking of the account.
In the future, this can also be informed by:
* IP/GEOIP from past login's to determine if the behaviour is expected.
* HTTP/Browser ID to determine if it's "likely" the person in question.
These can then assist with choosing to lock or allow an auth to proceed in the face of an attack.
FUTURE:
* Delayed notification about suspect login?
Ratelimit on unix auth
Hard Lock + Expiry/Active Time Limits
=====================================
It must be possible to expire an account so it no longer operates (IE temporary contractor) or
accounts that can only operate after a known point in time (Student enrollments and their course
commencment date).
This expiry must exist at the account level, but also on issued token/api password levels. This allows revocation of
individual tokens, but also the expiry of the account and all tokens as a whole. This expiry may be
undone, allowing the credentials to become valid once again.
On the account, this is represented by two date times. AuthAllowFrom and AuthAllowUntil. These
are stored on the server in unix epoch to account for timezones and geographic distribution.
* Interaction with already issued tokens.
* it prevents them from working?
Must prevent creation of radius auth tokens
Must prevent login via unix.
Application Passwords / Issued Oauth Tokens
===========================================
* Relates to claims
* Need their own expirys
* Need ratelimit as above?

View file

@ -31,6 +31,8 @@ strength, random, machine only password.
kanidm account credential generate_password --name admin idm_admin
Generated password for idm_admin: tqoReZfz....
## Creating Accounts
We can now use the idm_admin to create initial groups and accounts.
kanidm group create demo_group --name idm_admin
@ -79,6 +81,42 @@ An example can be easily shown with:
kanidm group add_members group2 nest_example --name idm_admin
kanidm account get nest_example --name anonymous
## Account Validity
Kanidm supports accounts that are only able to be authenticated between specific datetime
windows. This takes the form of a "valid from" attribute that defines the earliest start
date where authentication can succeed, and an expiry date where the account will no longer
allow authentication.
This can be displayed with:
kanidm account validity show demo_user --name idm_admin
valid after: 2020-09-25T21:22:04+10:00
expire: 2020-09-25T01:22:04+10:00
These datetimes are stored in the server as UTC, but presented according to your local system time
to aid correct understanding of when the events will occur.
To set the values, an account with account management permission is required (for example, idm_admin).
Again, these values will correctly translated from the entered local timezone to UTC.
# Set the earliest time the account can start authenticating
kanidm account validity begin_from demo_user '2020-09-25T11:22:04+00:00' --name idm_admin
# Set the expiry or end date of the account
kanidm account validity expire_at demo_user '2020-09-25T11:22:04+00:00' --name idm_admin
To unset or remove these values the following can be used:
kanidm account validity begin_from demo_user any|clear --name idm_admin
kanidm account validity expire_at demo_user never|clear --name idm_admin
To "lock" an account, you can set the expire_at value to the past or unix epoch. Even in the situation
where the "valid from" is *after* the expire_at, the expire_at will be respected.
kanidm account validity expire_at demo_user 1970-01-01T00:00:00+00:00 --name idm_admin
These validity settings impact all authentication functions of the account (kanidm, ldap, radius).
## Why Can't I Change admin With idm_admin?
As a security mechanism there is a distinction between "accounts" and "high permission

View file

@ -287,6 +287,17 @@ impl KanidmAsyncClient {
Ok(Some((r.youare, r.uat)))
}
pub async fn idm_account_set_attr(
&self,
id: &str,
attr: &str,
values: &[&str],
) -> Result<bool, ClientError> {
let m: Vec<_> = values.iter().map(|v| (*v).to_string()).collect();
self.perform_put_request(format!("/v1/account/{}/_attr/{}", id, attr).as_str(), m)
.await
}
pub async fn idm_account_unix_token_get(&self, id: &str) -> Result<UnixUserToken, ClientError> {
// Format doesn't work in async
// format!("/v1/account/{}/_unix/_token", id).as_str()

View file

@ -342,7 +342,7 @@ impl KanidmClient {
}
pub fn get_token(&self) -> Option<&str> {
self.bearer_token.as_ref().map(|s| s.as_str())
self.bearer_token.as_deref()
}
pub fn logout(&mut self) -> Result<(), reqwest::Error> {
@ -767,6 +767,28 @@ impl KanidmClient {
)
}
pub fn idm_account_get_attr(
&self,
id: &str,
attr: &str,
) -> Result<Option<Vec<String>>, ClientError> {
self.perform_get_request(format!("/v1/account/{}/_attr/{}", id, attr).as_str())
}
pub fn idm_account_purge_attr(&self, id: &str, attr: &str) -> Result<bool, ClientError> {
self.perform_delete_request(format!("/v1/account/{}/_attr/{}", id, attr).as_str())
}
pub fn idm_account_set_attr(
&self,
id: &str,
attr: &str,
values: &[&str],
) -> Result<bool, ClientError> {
let m: Vec<_> = values.iter().map(|v| (*v).to_string()).collect();
self.perform_put_request(format!("/v1/account/{}/_attr/{}", id, attr).as_str(), m)
}
pub fn idm_account_primary_credential_import_password(
&self,
id: &str,

View file

@ -772,6 +772,8 @@ fn test_server_rest_totp_auth_lifecycle() {
});
}
// Test setting account expiry
// Test the self version of the radius path.
// Test hitting all auth-required endpoints and assert they give unauthorized.

View file

@ -251,6 +251,9 @@ pub struct UnixUserToken {
pub shell: Option<String>,
pub groups: Vec<UnixGroupToken>,
pub sshkeys: Vec<String>,
// The default value of bool is false.
#[serde(default)]
pub valid: bool,
}
impl fmt::Display for UnixUserToken {

View file

@ -37,5 +37,6 @@ serde = "1.0"
serde_json = "1.0"
shellexpand = "2.0"
rayon = "1.2"
time = "0.2"
zxcvbn = "2.0"

View file

@ -1,6 +1,7 @@
use crate::common::CommonOpt;
use crate::password_prompt;
use structopt::StructOpt;
use time::OffsetDateTime;
#[derive(Debug, StructOpt)]
pub struct AccountCommonOpt {
@ -24,6 +25,30 @@ pub struct AccountNamedOpt {
copt: CommonOpt,
}
#[derive(Debug, StructOpt)]
pub struct AccountNamedExpireDateTimeOpt {
#[structopt(flatten)]
aopts: AccountCommonOpt,
#[structopt(flatten)]
copt: CommonOpt,
#[structopt(name = "datetime")]
/// An rfc3339 time of the format "YYYY-MM-DDTHH:MM:SS+TZ", "2020-09-25T11:22:02+10:00"
/// or the word "never", "clear" to remove account expiry.
datetime: String,
}
#[derive(Debug, StructOpt)]
pub struct AccountNamedValidDateTimeOpt {
#[structopt(flatten)]
aopts: AccountCommonOpt,
#[structopt(flatten)]
copt: CommonOpt,
#[structopt(name = "datetime")]
/// An rfc3339 time of the format "YYYY-MM-DDTHH:MM:SS+TZ", "2020-09-25T11:22:02+10:00"
/// or the word "any", "clear" to remove valid from enforcement.
datetime: String,
}
#[derive(Debug, StructOpt)]
pub struct AccountNamedTagOpt {
#[structopt(flatten)]
@ -106,6 +131,16 @@ pub enum AccountSsh {
Delete(AccountNamedTagOpt),
}
#[derive(Debug, StructOpt)]
pub enum AccountValidity {
#[structopt(name = "show")]
Show(AccountNamedOpt),
#[structopt(name = "expire_at")]
ExpireAt(AccountNamedExpireDateTimeOpt),
#[structopt(name = "begin_from")]
BeginFrom(AccountNamedValidDateTimeOpt),
}
#[derive(Debug, StructOpt)]
pub enum AccountOpt {
#[structopt(name = "credential")]
@ -124,6 +159,8 @@ pub enum AccountOpt {
Create(AccountCreateOpt),
#[structopt(name = "delete")]
Delete(AccountNamedOpt),
#[structopt(name = "validity")]
Validity(AccountValidity),
}
impl AccountOpt {
@ -152,6 +189,11 @@ impl AccountOpt {
AccountOpt::Get(aopt) => aopt.copt.debug,
AccountOpt::Delete(aopt) => aopt.copt.debug,
AccountOpt::Create(aopt) => aopt.copt.debug,
AccountOpt::Validity(avopt) => match avopt {
AccountValidity::Show(ano) => ano.copt.debug,
AccountValidity::ExpireAt(ano) => ano.copt.debug,
AccountValidity::BeginFrom(ano) => ano.copt.debug,
},
}
}
@ -327,6 +369,118 @@ impl AccountOpt {
eprintln!("Error -> {:?}", e)
}
}
AccountOpt::Validity(avopt) => match avopt {
AccountValidity::Show(ano) => {
let client = ano.copt.to_client();
let r = client
.idm_account_get_attr(ano.aopts.account_id.as_str(), "account_expire")
.and_then(|v1| {
client
.idm_account_get_attr(
ano.aopts.account_id.as_str(),
"account_valid_from",
)
.map(|v2| (v1, v2))
});
match r {
Ok((ex, vf)) => {
if let Some(t) = vf {
// Convert the time to local timezone.
let t = OffsetDateTime::parse(&t[0], time::Format::Rfc3339)
.map(|odt| {
odt.to_offset(time::UtcOffset::current_local_offset())
.format(time::Format::Rfc3339)
})
.unwrap_or_else(|_| "invalid timestamp".to_string());
println!("valid after: {}", t);
} else {
println!("valid after: any time");
}
if let Some(t) = ex {
let t = OffsetDateTime::parse(&t[0], time::Format::Rfc3339)
.map(|odt| {
odt.to_offset(time::UtcOffset::current_local_offset())
.format(time::Format::Rfc3339)
})
.unwrap_or_else(|_| "invalid timestamp".to_string());
println!("expire: {}", t);
} else {
println!("expire: never");
}
}
Err(e) => eprintln!("Error -> {:?}", e),
}
}
AccountValidity::ExpireAt(ano) => {
let client = ano.copt.to_client();
if ano.datetime == "never" || ano.datetime == "clear" {
// Unset the value
if let Err(e) = client
.idm_account_purge_attr(ano.aopts.account_id.as_str(), "account_expire")
{
eprintln!("Error -> {:?}", e)
} else {
println!("Success")
}
} else {
if let Err(e) =
OffsetDateTime::parse(ano.datetime.as_str(), time::Format::Rfc3339)
{
eprintln!("Error -> {:?}", e);
return;
}
if let Err(e) = client.idm_account_set_attr(
ano.aopts.account_id.as_str(),
"account_expire",
&[ano.datetime.as_str()],
) {
eprintln!("Error -> {:?}", e);
} else {
println!("Success")
}
}
}
AccountValidity::BeginFrom(ano) => {
let client = ano.copt.to_client();
if ano.datetime == "any"
|| ano.datetime == "clear"
|| ano.datetime == "whenever"
{
// Unset the value
if let Err(e) = client.idm_account_purge_attr(
ano.aopts.account_id.as_str(),
"account_valid_from",
) {
eprintln!("Error -> {:?}", e)
} else {
println!("Success")
}
} else {
// Attempt to parse and set
if let Err(e) =
OffsetDateTime::parse(ano.datetime.as_str(), time::Format::Rfc3339)
{
eprintln!("Error -> {:?}", e);
return;
}
if let Err(e) = client.idm_account_set_attr(
ano.aopts.account_id.as_str(),
"account_valid_from",
&[ano.datetime.as_str()],
) {
eprintln!("Error -> {:?}", e);
} else {
println!("Success")
}
}
}
}, // end AccountOpt::Validity
}
}
}

View file

@ -58,14 +58,13 @@ impl CommonOpt {
None => client_builder,
};
let client = match client_builder.build() {
match client_builder.build() {
Ok(c) => c,
Err(e) => {
error!("Failed to build client instance -- {:?}", e);
std::process::exit(1);
}
};
client
}
}
pub fn to_client(&self) -> KanidmClient {
@ -79,7 +78,7 @@ impl CommonOpt {
}
};
if tokens.len() == 0 {
if tokens.is_empty() {
error!(
"No valid authentication tokens found. Please login with the 'login' subcommand."
);
@ -100,6 +99,7 @@ impl CommonOpt {
}
None => {
if tokens.len() == 1 {
#[allow(clippy::expect_used)]
let (f_uname, f_token) = tokens.iter().next().expect("Memory Corruption");
// else pick the first token
info!("Authenticated as {}", f_uname);

View file

@ -4,7 +4,7 @@ use std::fs::File;
use std::io::{BufReader, BufWriter};
use structopt::StructOpt;
static TOKEN_PATH: &'static str = "~/.cache/kanidm_tokens";
static TOKEN_PATH: &str = "~/.cache/kanidm_tokens";
pub fn read_tokens() -> Result<BTreeMap<String, String>, ()> {
let token_path: String = shellexpand::tilde(TOKEN_PATH).into_owned();
@ -50,7 +50,7 @@ impl LoginOpt {
pub fn exec(&self) {
let mut client = self.copt.to_unauth_client();
let (r, username) = match self.copt.username.as_ref().map(|s| s.as_str()) {
let (r, username) = match self.copt.username.as_deref() {
None | Some("anonymous") => (client.auth_anonymous(), "anonymous".to_string()),
Some(username) => {
let password = match rpassword::prompt_password_stderr("Enter password: ") {

View file

@ -443,6 +443,10 @@ impl CacheLayer {
self.refresh_usertoken(&account_id, item).await
}
}
.map(|t| {
debug!("token -> {:?}", t);
t
})
}
async fn get_grouptoken(&self, grp_id: Id) -> Result<Option<UnixGroupToken>, ()> {
@ -506,7 +510,16 @@ impl CacheLayer {
// Get ssh keys for an account id
pub async fn get_sshkeys(&self, account_id: &str) -> Result<Vec<String>, ()> {
let token = self.get_usertoken(Id::Name(account_id.to_string())).await?;
Ok(token.map(|t| t.sshkeys).unwrap_or_else(Vec::new))
Ok(token
.map(|t| {
// Only return keys if the account is valid
if t.valid {
t.sshkeys
} else {
Vec::with_capacity(0)
}
})
.unwrap_or_else(|| Vec::with_capacity(0)))
}
#[inline(always)]
@ -704,7 +717,13 @@ impl CacheLayer {
) -> Result<Option<bool>, ()> {
debug!("Attempt offline password check");
match token.as_ref() {
Some(t) => self.check_cache_userpassword(&t.uuid, cred).await.map(Some),
Some(t) => {
if t.valid {
self.check_cache_userpassword(&t.uuid, cred).await.map(Some)
} else {
Ok(Some(false))
}
}
None => Ok(None),
}
/*
@ -731,7 +750,7 @@ impl CacheLayer {
user_set, self.pam_allow_groups
);
user_set.intersection(&self.pam_allow_groups).count() > 0
user_set.intersection(&self.pam_allow_groups).count() > 0 && tok.valid
}))
}

View file

@ -718,6 +718,7 @@ mod tests {
shell: None,
groups: Vec::new(),
sshkeys: vec!["key-a".to_string()],
valid: true,
};
let id_name = Id::Name("testuser".to_string());
@ -890,6 +891,7 @@ mod tests {
shell: None,
groups: vec![gt1.clone(), gt2],
sshkeys: vec!["key-a".to_string()],
valid: true,
};
// First, add the groups.
@ -944,6 +946,7 @@ mod tests {
shell: None,
groups: Vec::new(),
sshkeys: vec!["key-a".to_string()],
valid: true,
};
// Test that with no account, is false
@ -1046,6 +1049,7 @@ mod tests {
shell: None,
groups: Vec::new(),
sshkeys: vec!["key-a".to_string()],
valid: true,
};
let ut2 = UnixUserToken {
@ -1057,6 +1061,7 @@ mod tests {
shell: None,
groups: Vec::new(),
sshkeys: vec!["key-a".to_string()],
valid: true,
};
let id_name = Id::Name("testuser".to_string());

View file

@ -23,9 +23,10 @@ const ADMIN_TEST_PASSWORD: &str = "integration test admin password";
const TESTACCOUNT1_PASSWORD_A: &str = "password a for account1 test";
const TESTACCOUNT1_PASSWORD_B: &str = "password b for account1 test";
const TESTACCOUNT1_PASSWORD_INC: &str = "never going to work";
const ACCOUNT_EXPIRE: &str = "1970-01-01T00:00:00+00:00";
fn run_test(fix_fn: fn(&mut KanidmClient) -> (), test_fn: fn(CacheLayer, KanidmAsyncClient) -> ()) {
// ::std::env::set_var("RUST_LOG", "actix_web=warn,kanidm=error");
// ::std::env::set_var("RUST_LOG", "kanidm=debug");
let _ = env_logger::builder().is_test(true).try_init();
let (mut ready_tx, mut ready_rx) = mpsc::channel(1);
@ -42,8 +43,8 @@ fn run_test(fix_fn: fn(&mut KanidmClient) -> (), test_fn: fn(CacheLayer, KanidmA
config.address = format!("127.0.0.1:{}", port);
config.secure_cookies = false;
config.integration_test_config = Some(int_config);
// config.log_level = Some(LogLevel::Verbose as u32);
config.log_level = Some(LogLevel::Quiet as u32);
// config.log_level = Some(LogLevel::Verbose as u32);
config.threads = 1;
let t_handle = thread::spawn(move || {
@ -551,3 +552,78 @@ fn test_cache_account_pam_nonexist() {
rt.block_on(fut);
})
}
#[test]
fn test_cache_account_expiry() {
run_test(test_fixture, |cachelayer, mut adminclient| {
let mut rt = Runtime::new().expect("Failed to start tokio");
let fut = async move {
cachelayer.attempt_online().await;
assert!(cachelayer.test_connection().await);
// We need one good auth first to prime the cache with a hash.
let a1 = cachelayer
.pam_account_authenticate("testaccount1", TESTACCOUNT1_PASSWORD_A)
.await
.expect("failed to authenticate");
assert!(a1 == Some(true));
// Invalidate to make sure we go online next checks.
assert!(cachelayer.invalidate().await.is_ok());
// expire the account
adminclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.await
.expect("failed to auth as admin");
adminclient
.idm_account_set_attr("testaccount1", "account_expire", &[ACCOUNT_EXPIRE])
.await
.unwrap();
// auth will fail
let a2 = cachelayer
.pam_account_authenticate("testaccount1", TESTACCOUNT1_PASSWORD_A)
.await
.expect("failed to authenticate");
assert!(a2 == Some(false));
// ssh keys should be empty
let sk = cachelayer
.get_sshkeys("testaccount1")
.await
.expect("Failed to get from cache.");
assert!(sk.len() == 0);
// Pam account allowed should be denied.
let a3 = cachelayer
.pam_account_allowed("testaccount1")
.await
.expect("failed to authenticate");
assert!(a3 == Some(false));
// go offline
cachelayer.mark_offline().await;
// Now, check again ...
let a4 = cachelayer
.pam_account_authenticate("testaccount1", TESTACCOUNT1_PASSWORD_A)
.await
.expect("failed to authenticate");
assert!(a4 == Some(false));
// ssh keys should be empty
let sk = cachelayer
.get_sshkeys("testaccount1")
.await
.expect("Failed to get from cache.");
assert!(sk.len() == 0);
// Pam account allowed should be denied.
let a5 = cachelayer
.pam_account_allowed("testaccount1")
.await
.expect("failed to authenticate");
assert!(a5 == Some(false));
};
rt.block_on(fut);
})
}

View file

@ -66,7 +66,7 @@ r2d2 = "0.8"
r2d2_sqlite = "0.16"
structopt = { version = "0.3", default-features = false }
time = "0.1"
time = { version = "0.2", features = ["serde", "std"] }
hashbrown = "0.8"
concread = "^0.2.3"

View file

@ -500,9 +500,16 @@ impl QueryServerReadV1 {
}
};
let ct = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| {
ladmin_error!(audit, "Clock Error -> {:?}", e);
OperationError::InvalidState
})?;
ltrace!(audit, "Begin event {:?}", rate);
idm_read.get_radiusauthtoken(&mut audit, &rate)
idm_read.get_radiusauthtoken(&mut audit, &rate, ct)
}
);
self.log.send(audit).map_err(|_| {
@ -554,9 +561,16 @@ impl QueryServerReadV1 {
}
};
let ct = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| {
ladmin_error!(audit, "Clock Error -> {:?}", e);
OperationError::InvalidState
})?;
ltrace!(audit, "Begin event {:?}", rate);
idm_read.get_unixusertoken(&mut audit, &rate)
idm_read.get_unixusertoken(&mut audit, &rate, ct)
}
);
self.log.send(audit).map_err(|_| {

View file

@ -67,6 +67,7 @@ pub enum DbValueV1 {
UI(u32),
CI(DbCidV1),
NU(String),
DT(String),
}
#[cfg(test)]

View file

@ -97,7 +97,9 @@ pub const JSON_IDM_SELF_ACP_READ_V1: &str = r#"{
"radius_secret",
"gidnumber",
"loginshell",
"uuid"
"uuid",
"account_expire",
"account_valid_from"
]
}
}"#;
@ -331,7 +333,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"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "mail", "gidnumber", "account_expire", "account_valid_from"
]
}
}"#;
@ -353,10 +355,10 @@ 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"
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "mail"
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from"
]
}
}"#;
@ -385,7 +387,9 @@ pub const JSON_IDM_ACP_ACCOUNT_MANAGE_PRIV_V1: &str = r#"{
"description",
"primary_credential",
"ssh_publickey",
"mail"
"mail",
"account_expire",
"account_valid_from"
],
"acp_create_class": [
"object", "account"
@ -434,7 +438,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"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "account_expire", "account_valid_from"
]
}
}"#;
@ -456,10 +460,10 @@ 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"
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential"
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from"
]
}
}"#;
@ -758,7 +762,9 @@ pub const JSON_IDM_ACP_HP_ACCOUNT_MANAGE_PRIV_V1: &str = r#"{
"displayname",
"description",
"primary_credential",
"ssh_publickey"
"ssh_publickey",
"account_expire",
"account_valid_from"
],
"acp_create_class": [
"object", "account"

View file

@ -13,7 +13,7 @@ pub use crate::constants::system_config::*;
pub use crate::constants::uuids::*;
// Increment this as we add new schema types and values!!!
pub const SYSTEM_INDEX_VERSION: i64 = 11;
pub const SYSTEM_INDEX_VERSION: i64 = 12;
// On test builds, define to 60 seconds
#[cfg(test)]
pub const PURGE_FREQUENCY: u64 = 60;

View file

@ -426,6 +426,66 @@ pub const JSON_SCHEMA_ATTR_NSUNIQUEID: &str = r#"{
}
}"#;
pub const JSON_SCHEMA_ATTR_ACCOUNT_EXPIRE: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"The datetime after which this accounnt no longer may authenticate."
],
"index": [],
"unique": [
"false"
],
"multivalue": [
"false"
],
"attributename": [
"account_expire"
],
"syntax": [
"DATETIME"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000072"
]
}
}"#;
pub const JSON_SCHEMA_ATTR_ACCOUNT_VALID_FROM: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"The datetime after which this account may commence authenticating."
],
"index": [],
"unique": [
"false"
],
"multivalue": [
"false"
],
"attributename": [
"account_valid_from"
],
"syntax": [
"DATETIME"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000073"
]
}
}"#;
// === classes ===
pub const JSON_SCHEMA_CLASS_PERSON: &str = r#"
{
"attrs": {
@ -499,7 +559,9 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#"
"systemmay": [
"primary_credential",
"ssh_publickey",
"radius_secret"
"radius_secret",
"account_expire",
"account_valid_from"
],
"systemmust": [
"displayname",

View file

@ -116,6 +116,9 @@ pub const STR_UUID_SCHEMA_ATTR_NICE: &str = "00000000-0000-0000-0000-ffff0000006
pub const STR_UUID_SCHEMA_ATTR_ENTRYUUID: &str = "00000000-0000-0000-0000-ffff00000070";
pub const STR_UUID_SCHEMA_ATTR_OBJECTCLASS: &str = "00000000-0000-0000-0000-ffff00000071";
pub const _STR_UUID_SCHEMA_ATTR_ACCOUNT_EXPIRE: &str = "00000000-0000-0000-0000-ffff00000072";
pub const _STR_UUID_SCHEMA_ATTR_ACCOUNT_VALID_FROM: &str = "00000000-0000-0000-0000-ffff00000073";
// System and domain infos
// I'd like to strongly criticise william of the past for fucking up these allocations.
pub const STR_UUID_SYSTEM_INFO: &str = "00000000-0000-0000-0000-ffffff000001";

View file

@ -51,6 +51,7 @@ use std::collections::BTreeSet;
// use std::collections::BTreeMap as Map;
use hashbrown::HashMap as Map;
use hashbrown::HashSet;
use time::OffsetDateTime;
use uuid::Uuid;
// use std::convert::TryFrom;
@ -1583,6 +1584,11 @@ impl<VALID, STATE> Entry<VALID, STATE> {
.and_then(|a| a.get_radius_secret())
}
#[inline(always)]
pub fn get_ava_single_datetime(&self, attr: &str) -> Option<OffsetDateTime> {
self.get_ava_single(attr).and_then(|a| a.to_datetime())
}
#[inline(always)]
pub fn get_ava_single_str(&self, attr: &str) -> Option<&str> {
self.get_ava_single(attr).and_then(|v| v.to_str())

View file

@ -14,6 +14,8 @@ use crate::modify::{ModifyInvalid, ModifyList};
use crate::server::{QueryServerReadTransaction, QueryServerWriteTransaction};
use crate::value::{PartialValue, Value};
use std::time::Duration;
use time::OffsetDateTime;
use uuid::Uuid;
lazy_static! {
@ -58,6 +60,10 @@ macro_rules! try_from_entry {
"Missing attribute: spn".to_string(),
))?;
let valid_from = $value.get_ava_single_datetime("account_valid_from");
let expire = $value.get_ava_single_datetime("account_expire");
// Resolved by the caller
let groups = $groups;
@ -69,6 +75,8 @@ macro_rules! try_from_entry {
displayname,
groups,
primary,
valid_from,
expire,
spn,
})
}};
@ -87,6 +95,8 @@ pub(crate) struct Account {
pub uuid: Uuid,
pub groups: Vec<Group>,
pub primary: Option<Credential>,
pub valid_from: Option<OffsetDateTime>,
pub expire: Option<OffsetDateTime>,
// primary: Credential
// app_creds: Vec<Credential>
// account expiry? (as opposed to cred expiry)
@ -147,6 +157,27 @@ impl Account {
})
}
pub fn is_within_valid_time(&self, ct: Duration) -> bool {
let cot = OffsetDateTime::unix_epoch() + ct;
let vmin = if let Some(vft) = &self.valid_from {
// If current time greater than strat time window
vft < &cot
} else {
// We have no time, not expired.
true
};
let vmax = if let Some(ext) = &self.expire {
// If exp greater than ct then expired.
&cot < ext
} else {
// If not present, we are not expired
true
};
// Mix the results
vmin && vmax
}
pub fn is_anonymous(&self) -> bool {
self.uuid == *UUID_ANONYMOUS
}

View file

@ -24,6 +24,7 @@ const BAD_PASSWORD_MSG: &str = "incorrect password";
const BAD_TOTP_MSG: &str = "incorrect totp";
const BAD_AUTH_TYPE_MSG: &str = "invalid authentication method in this context";
const BAD_CREDENTIALS: &str = "invalid credential message";
const ACCOUNT_EXPIRED: &str = "account expired";
enum CredState {
Success(Vec<Claim>),
@ -48,7 +49,7 @@ struct CredTotpPw {
#[derive(Clone, Debug)]
enum CredHandler {
Denied,
Denied(&'static str),
// The bool is a flag if the cred has been authed against.
Anonymous,
// AppPassword
@ -286,10 +287,10 @@ impl CredHandler {
async_tx: &Sender<DelayedAction>,
) -> CredState {
match self {
CredHandler::Denied => {
CredHandler::Denied(reason) => {
// Sad trombone.
lsecurity!(au, "Handler::Denied -> Result::Denied");
CredState::Denied("authentication denied")
CredState::Denied(reason)
}
CredHandler::Anonymous => Self::validate_anonymous(au, creds),
CredHandler::Password(ref mut pw) => {
@ -303,7 +304,7 @@ impl CredHandler {
pub fn valid_auth_mechs(&self) -> Vec<AuthAllowed> {
match &self {
CredHandler::Denied => Vec::new(),
CredHandler::Denied(_) => Vec::new(),
CredHandler::Anonymous => vec![AuthAllowed::Anonymous],
CredHandler::Password(_) => vec![AuthAllowed::Password],
// webauth
@ -312,10 +313,10 @@ impl CredHandler {
}
}
pub(crate) fn is_denied(&self) -> bool {
pub(crate) fn is_denied(&self) -> Option<&'static str> {
match &self {
CredHandler::Denied => true,
_ => false,
CredHandler::Denied(x) => Some(x),
_ => None,
}
}
}
@ -339,51 +340,69 @@ pub(crate) struct AuthSession {
}
impl AuthSession {
pub fn new(au: &mut AuditScope, account: Account, appid: Option<String>) -> Self {
pub fn new(
au: &mut AuditScope,
account: Account,
appid: Option<String>,
ct: Duration,
) -> (Option<Self>, AuthState) {
// During this setup, determine the credential handler that we'll be using
// for this session. This is currently based on presentation of an application
// id.
let handler = match appid {
Some(_) => CredHandler::Denied,
None => {
// We want the primary handler - this is where we make a decision
// based on the anonymous ... in theory this could be cleaner
// and interact with the account more?
if account.is_anonymous() {
CredHandler::Anonymous
} else {
// Now we see if they have one ...
match &account.primary {
Some(cred) => {
// Probably means new authsession has to be failable
CredHandler::try_from(cred).unwrap_or_else(|_| {
lsecurity_critical!(
au,
"corrupt credentials, unable to start credhandler"
);
CredHandler::Denied
})
let handler = if account.is_within_valid_time(ct) {
match appid {
Some(_) => CredHandler::Denied("authentication denied"),
None => {
// We want the primary handler - this is where we make a decision
// based on the anonymous ... in theory this could be cleaner
// and interact with the account more?
if account.is_anonymous() {
CredHandler::Anonymous
} else {
// Now we see if they have one ...
match &account.primary {
Some(cred) => {
// Probably means new authsession has to be failable
CredHandler::try_from(cred).unwrap_or_else(|_| {
lsecurity_critical!(
au,
"corrupt credentials, unable to start credhandler"
);
CredHandler::Denied("invalid credential state")
})
}
None => {
lsecurity!(au, "account has no primary credentials");
CredHandler::Denied("invalid credential state")
}
}
None => CredHandler::Denied,
}
}
}
} else {
lsecurity!(au, "account expired");
CredHandler::Denied(ACCOUNT_EXPIRED)
};
// Is this handler locked?
// Is the whole account locked?
// What about in memory account locking? Is that something
// we store in the account somehow?
// TODO #59: Implement handler locking!
// if credhandler == deny, finish = true.
let finished: bool = handler.is_denied();
if let Some(reason) = handler.is_denied() {
// Already denied, lets send that result
(None, AuthState::Denied(reason.to_string()))
} else {
// We can proceed
let auth_session = AuthSession {
account,
handler,
appid,
finished: false,
};
// Get the set of mechanisms that can proceed. This is tied
// to the session so that it can mutate state and have progression
// of what's next, or ordering.
let next_mech = auth_session.valid_auth_mechs();
AuthSession {
account,
handler,
appid,
finished,
let state = AuthState::Continue(next_mech);
(Some(auth_session), state)
}
}
@ -407,6 +426,7 @@ impl AuthSession {
"Credentials denied: potential flood/dos/bruteforce attempt. {} creds were sent.",
creds.len()
);
self.finished = true;
return Ok(AuthState::Denied(BAD_CREDENTIALS.to_string()));
}
@ -448,7 +468,7 @@ impl AuthSession {
// If success, to authtoken?
}
pub fn valid_auth_mechs(&self) -> Vec<AuthAllowed> {
fn valid_auth_mechs(&self) -> Vec<AuthAllowed> {
if self.finished {
Vec::new()
} else {
@ -468,6 +488,7 @@ mod tests {
AuthSession, BAD_AUTH_TYPE_MSG, BAD_CREDENTIALS, BAD_PASSWORD_MSG, BAD_TOTP_MSG,
};
use crate::idm::AuthState;
use crate::utils::duration_from_epoch_now;
use kanidm_proto::v1::{AuthAllowed, AuthCredential};
use std::time::Duration;
// use async_std::task;
@ -485,16 +506,19 @@ mod tests {
let anon_account = entry_str_to_account!(JSON_ANONYMOUS_V1);
let session = AuthSession::new(&mut audit, anon_account, None);
let (_session, state) =
AuthSession::new(&mut audit, anon_account, None, duration_from_epoch_now());
let auth_mechs = session.valid_auth_mechs();
assert!(
true == auth_mechs.iter().fold(false, |acc, x| match x {
AuthAllowed::Anonymous => true,
_ => acc,
})
);
if let AuthState::Continue(auth_mechs) = state {
assert!(
true == auth_mechs.iter().fold(false, |acc, x| match x {
AuthAllowed::Anonymous => true,
_ => acc,
})
);
} else {
panic!("Invalid auth state")
}
}
#[test]
@ -505,9 +529,13 @@ mod tests {
None,
);
let anon_account = entry_str_to_account!(JSON_ANONYMOUS_V1);
let mut session = AuthSession::new(&mut audit, anon_account, None);
let (session, _) =
AuthSession::new(&mut audit, anon_account, None, duration_from_epoch_now());
let (async_tx, mut async_rx) = unbounded();
// Will be some.
let mut session = session.unwrap();
let attempt = vec![
AuthCredential::Anonymous,
AuthCredential::Anonymous,
@ -534,16 +562,20 @@ mod tests {
None,
);
let session = AuthSession::new(
let (session, state) = AuthSession::new(
&mut audit,
anon_account,
Some("NonExistantAppID".to_string()),
duration_from_epoch_now(),
);
let auth_mechs = session.valid_auth_mechs();
assert!(session.is_none());
// Will always move to denied.
assert!(auth_mechs == Vec::new());
if let AuthState::Denied(_) = state {
// Pass
} else {
panic!();
}
}
#[test]
@ -561,16 +593,20 @@ mod tests {
account.primary = Some(cred);
// now check
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
let (async_tx, mut async_rx) = unbounded();
let auth_mechs = session.valid_auth_mechs();
assert!(
true == auth_mechs.iter().fold(false, |acc, x| match x {
AuthAllowed::Password => true,
_ => acc,
})
);
if let AuthState::Continue(auth_mechs) = state {
assert!(
true == auth_mechs.iter().fold(false, |acc, x| match x {
AuthAllowed::Password => true,
_ => acc,
})
);
} else {
panic!();
}
let attempt = vec![AuthCredential::Password("bad_password".to_string())];
match session.validate_creds(&mut audit, &attempt, &Duration::from_secs(0), &async_tx) {
@ -578,7 +614,9 @@ mod tests {
_ => panic!(),
};
let mut session = AuthSession::new(&mut audit, account, None);
let (session, _state) =
AuthSession::new(&mut audit, account, None, duration_from_epoch_now());
let mut session = session.unwrap();
let attempt = vec![AuthCredential::Password("test_password".to_string())];
match session.validate_creds(&mut audit, &attempt, &Duration::from_secs(0), &async_tx) {
Ok(AuthState::Success(_)) => {}
@ -624,20 +662,26 @@ mod tests {
account.primary = Some(cred);
// now check
let session = AuthSession::new(&mut audit, account.clone(), None);
let (_session, state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let (async_tx, mut async_rx) = unbounded();
let auth_mechs = session.valid_auth_mechs();
assert!(auth_mechs.iter().fold(true, |acc, x| match x {
AuthAllowed::Password => acc,
AuthAllowed::TOTP => acc,
_ => false,
}));
if let AuthState::Continue(auth_mechs) = state {
assert!(auth_mechs.iter().fold(true, |acc, x| match x {
AuthAllowed::Password => acc,
AuthAllowed::TOTP => acc,
_ => false,
}));
} else {
panic!();
}
// Rest of test go here
// check send anon (fail)
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::Anonymous],
@ -654,7 +698,9 @@ mod tests {
// check send bad pw, should get continue (even though denied set)
// then send good totp, should fail.
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::Password(pw_bad.to_string())],
@ -677,7 +723,9 @@ mod tests {
// check send bad pw, should get continue (even though denied set)
// then send bad totp, should fail TOTP
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::Password(pw_bad.to_string())],
@ -701,7 +749,9 @@ mod tests {
// check send good pw, should get continue
// then send good totp, success
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::Password(pw_good.to_string())],
@ -725,7 +775,9 @@ mod tests {
// check send good pw, should get continue
// then send bad totp, fail otp
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::Password(pw_good.to_string())],
@ -748,7 +800,9 @@ mod tests {
// check send bad totp, should fail immediate
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::TOTP(totp_bad)],
@ -763,7 +817,9 @@ mod tests {
// check send good totp, should continue
// then bad pw, fail pw
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::TOTP(totp_good)],
@ -787,7 +843,9 @@ mod tests {
// check send good totp, should continue
// then good pw, success
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![AuthCredential::TOTP(totp_good)],
@ -812,7 +870,9 @@ mod tests {
// check bad totp, bad pw, fail totp.
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![
@ -828,7 +888,9 @@ mod tests {
}
// check send bad pw, good totp fail password
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![
@ -844,7 +906,9 @@ mod tests {
}
// check send good pw, bad totp fail totp.
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![
@ -860,7 +924,9 @@ mod tests {
}
// check good pw, good totp, success
{
let mut session = AuthSession::new(&mut audit, account.clone(), None);
let (session, _state) =
AuthSession::new(&mut audit, account.clone(), None, duration_from_epoch_now());
let mut session = session.unwrap();
match session.validate_creds(
&mut audit,
&vec![

View file

@ -7,6 +7,8 @@ use crate::server::QueryServerReadTransaction;
use crate::value::PartialValue;
use kanidm_proto::v1::OperationError;
use kanidm_proto::v1::RadiusAuthToken;
use std::time::Duration;
use time::OffsetDateTime;
lazy_static! {
static ref PVCLASS_ACCOUNT: PartialValue = PartialValue::new_class("account");
@ -19,6 +21,8 @@ pub(crate) struct RadiusAccount {
pub uuid: Uuid,
pub groups: Vec<Group>,
pub radius_secret: String,
pub valid_from: Option<OffsetDateTime>,
pub expire: Option<OffsetDateTime>,
}
impl RadiusAccount {
@ -58,16 +62,52 @@ impl RadiusAccount {
let groups = Group::try_from_account_entry_red_ro(au, &value, qs)?;
let valid_from = value.get_ava_single_datetime("account_valid_from");
let expire = value.get_ava_single_datetime("account_expire");
Ok(RadiusAccount {
name,
uuid,
displayname,
groups,
radius_secret,
valid_from,
expire,
})
}
pub(crate) fn to_radiusauthtoken(&self) -> Result<RadiusAuthToken, OperationError> {
fn is_within_valid_time(&self, ct: Duration) -> bool {
let cot = OffsetDateTime::unix_epoch() + ct;
let vmin = if let Some(vft) = &self.valid_from {
// If current time greater than strat time window
vft < &cot
} else {
// We have no time, not expired.
true
};
let vmax = if let Some(ext) = &self.expire {
// If exp greater than ct then expired.
&cot < ext
} else {
// If not present, we are not expired
true
};
// Mix the results
vmin && vmax
}
pub(crate) fn to_radiusauthtoken(
&self,
ct: Duration,
) -> Result<RadiusAuthToken, OperationError> {
if !self.is_within_valid_time(ct) {
return Err(OperationError::InvalidAccountState(
"Account Expired".to_string(),
));
}
// If we don't have access/permission, then just error instead.
// This includes if we don't have the secret.
Ok(RadiusAuthToken {

View file

@ -22,8 +22,6 @@ use crate::value::PartialValue;
use crate::actors::v1_write::QueryServerWriteV1;
use crate::idm::delayed::{DelayedAction, PasswordUpgrade, UnixPasswordUpgrade};
use crate::idm::AuthState;
use kanidm_proto::v1::OperationError;
use kanidm_proto::v1::RadiusAuthToken;
// use kanidm_proto::v1::TOTPSecret as ProtoTOTPSecret;
@ -239,7 +237,6 @@ impl<'a> IdmServerWriteTransaction<'a> {
ct: Duration,
) -> Result<AuthResult, OperationError> {
ltrace!(au, "Received -> {:?}", ae);
// Match on the auth event, to see what we need to do.
match &ae.step {
@ -280,30 +277,30 @@ impl<'a> IdmServerWriteTransaction<'a> {
// continue, and helps to keep non-needed entry specific data
// out of the LRU.
let account = Account::try_from_entry_ro(au, &entry, &mut self.qs_read)?;
let auth_session = AuthSession::new(au, account, init.appid.clone());
// Get the set of mechanisms that can proceed. This is tied
// to the session so that it can mutate state and have progression
// of what's next, or ordering.
let next_mech = auth_session.valid_auth_mechs();
let (auth_session, state) =
AuthSession::new(au, account, init.appid.clone(), ct);
match auth_session {
Some(auth_session) => {
lperf_segment!(au, "idm::server::auth<Init> -> sessions", || {
if self.sessions.contains_key(&sessionid) {
Err(OperationError::InvalidSessionState)
} else {
self.sessions.insert(sessionid, auth_session);
// Debugging: ensure we really inserted ...
debug_assert!(self.sessions.get(&sessionid).is_some());
Ok(())
}
})?;
}
None => {
lsecurity!(au, "Authentication Session Unable to begin");
}
};
// If we have a session of the same id, return an error (despite how
// unlikely this is ...
lperf_segment!(au, "idm::server::auth<Init> -> sessions", || {
if self.sessions.contains_key(&sessionid) {
Err(OperationError::InvalidSessionState)
} else {
self.sessions.insert(sessionid, auth_session);
// Debugging: ensure we really inserted ...
debug_assert!(self.sessions.get(&sessionid).is_some());
Ok(())
}
})?;
Ok(AuthResult {
sessionid,
state: AuthState::Continue(next_mech),
})
Ok(AuthResult { sessionid, state })
})
}
AuthEventStep::Creds(creds) => {
@ -338,7 +335,7 @@ impl<'a> IdmServerWriteTransaction<'a> {
&mut self,
au: &mut AuditScope,
uae: &UnixUserAuthEvent,
_ct: Duration,
ct: Duration,
) -> Result<Option<UnixUserToken>, OperationError> {
// TODO #59: Implement soft lock checking for unix creds here!
@ -355,14 +352,14 @@ impl<'a> IdmServerWriteTransaction<'a> {
})?;
// Validate the unix_pw - this checks the account/cred lock states.
account.verify_unix_credential(au, uae.cleartext.as_str(), &self.async_tx)
account.verify_unix_credential(au, uae.cleartext.as_str(), &self.async_tx, ct)
}
pub fn auth_ldap(
&mut self,
au: &mut AuditScope,
lae: &LdapAuthEvent,
_ct: Duration,
ct: Duration,
) -> Result<Option<LdapBoundToken>, OperationError> {
// TODO #59: Implement soft lock checking for unix creds here!
@ -395,7 +392,7 @@ impl<'a> IdmServerWriteTransaction<'a> {
let account =
UnixUserAccount::try_from_entry_ro(au, &account_entry, &mut self.qs_read)?;
if account
.verify_unix_credential(au, lae.cleartext.as_str(), &self.async_tx)?
.verify_unix_credential(au, lae.cleartext.as_str(), &self.async_tx, ct)?
.is_some()
{
// Get the anon uat
@ -438,6 +435,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
&mut self,
au: &mut AuditScope,
rate: &RadiusAuthTokenEvent,
ct: Duration,
) -> Result<RadiusAuthToken, OperationError> {
let account = self
.qs_read
@ -450,26 +448,27 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
e
})?;
account.to_radiusauthtoken()
account.to_radiusauthtoken(ct)
}
pub fn get_unixusertoken(
&mut self,
au: &mut AuditScope,
uute: &UnixUserTokenEvent,
ct: Duration,
) -> Result<UnixUserToken, OperationError> {
let account = self
.qs_read
.impersonate_search_ext_uuid(au, &uute.target, &uute.event)
.impersonate_search_uuid(au, &uute.target, &uute.event)
.and_then(|account_entry| {
UnixUserAccount::try_from_entry_reduced(au, &account_entry, &mut self.qs_read)
UnixUserAccount::try_from_entry_ro(au, &account_entry, &mut self.qs_read)
})
.map_err(|e| {
ladmin_error!(au, "Failed to start unix user token -> {:?}", e);
e
})?;
account.to_unixusertoken()
account.to_unixusertoken(ct)
}
pub fn get_unixgrouptoken(
@ -1423,7 +1422,7 @@ mod tests {
let mut idms_prox_read = idms.proxy_read();
let rate = RadiusAuthTokenEvent::new_internal(UUID_ADMIN.clone());
let tok_r = idms_prox_read
.get_radiusauthtoken(au, &rate)
.get_radiusauthtoken(au, &rate, duration_from_epoch_now())
.expect("Failed to generate radius auth token");
// view the token?
@ -1519,7 +1518,7 @@ mod tests {
let uute = UnixUserTokenEvent::new_internal(UUID_ADMIN.clone());
let tok_r = idms_prox_read
.get_unixusertoken(au, &uute)
.get_unixusertoken(au, &uute, duration_from_epoch_now())
.expect("Failed to generate unix user token");
assert!(tok_r.name == "admin");
@ -1527,6 +1526,7 @@ mod tests {
assert!(tok_r.groups.len() == 2);
assert!(tok_r.groups[0].name == "admin");
assert!(tok_r.groups[1].name == "testgroup");
assert!(tok_r.valid == true);
// Show we can get the admin as a unix group token too
let ugte = UnixGroupTokenEvent::new_internal(
@ -1563,7 +1563,6 @@ mod tests {
let pce = UnixPasswordChangeEvent::new_internal(&UUID_ADMIN, TEST_PASSWORD);
assert!(idms_prox_write.set_unix_account_password(au, &pce).is_ok());
assert!(idms_prox_write.set_unix_account_password(au, &pce).is_ok());
assert!(idms_prox_write.commit(au).is_ok());
@ -1835,4 +1834,202 @@ mod tests {
idms_delayed.is_empty_or_panic();
})
}
// For testing the timeouts
// We need times on this scale
// not yet valid <-> valid from time <-> current_time <-> expire time <-> expired
const TEST_NOT_YET_VALID_TIME: u64 = TEST_CURRENT_TIME - 240;
const TEST_VALID_FROM_TIME: u64 = TEST_CURRENT_TIME - 120;
const TEST_EXPIRE_TIME: u64 = TEST_CURRENT_TIME + 120;
const TEST_AFTER_EXPIRY: u64 = TEST_CURRENT_TIME + 240;
fn set_admin_valid_time(au: &mut AuditScope, qs: &QueryServer) {
let qs_write = qs.write(duration_from_epoch_now());
let v_valid_from = Value::new_datetime_epoch(Duration::from_secs(TEST_VALID_FROM_TIME));
let v_expire = Value::new_datetime_epoch(Duration::from_secs(TEST_EXPIRE_TIME));
// now modify and provide a primary credential.
let me_inv_m = unsafe {
ModifyEvent::new_internal_invalid(
filter!(f_eq("name", PartialValue::new_iname("admin"))),
ModifyList::new_list(vec![
Modify::Present("account_expire".to_string(), v_expire),
Modify::Present("account_valid_from".to_string(), v_valid_from),
]),
)
};
// go!
assert!(qs_write.modify(au, &me_inv_m).is_ok());
qs_write.commit(au).expect("Must not fail");
}
#[test]
fn test_idm_account_valid_from_expire() {
run_idm_test!(|qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
au: &mut AuditScope| {
// Any account taht is not yet valrid / expired can't auth.
init_admin_w_password(au, qs, TEST_PASSWORD).expect("Failed to setup admin account");
// Set the valid bounds high/low
// TEST_VALID_FROM_TIME/TEST_EXPIRE_TIME
set_admin_valid_time(au, qs);
let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME);
let time_high = Duration::from_secs(TEST_AFTER_EXPIRY);
let mut idms_write = idms.write();
let admin_init = AuthEvent::named_init("admin");
let r1 = idms_write.auth(au, &admin_init, time_low);
let ar = r1.unwrap();
let AuthResult {
sessionid: _,
state,
} = ar;
match state {
AuthState::Denied(_) => {}
_ => {
panic!();
}
};
idms_write.commit(au).expect("Must not fail");
// And here!
let mut idms_write = idms.write();
let admin_init = AuthEvent::named_init("admin");
let r1 = idms_write.auth(au, &admin_init, time_high);
let ar = r1.unwrap();
let AuthResult {
sessionid: _,
state,
} = ar;
match state {
AuthState::Denied(_) => {}
_ => {
panic!();
}
};
idms_write.commit(au).expect("Must not fail");
})
}
#[test]
fn test_idm_unix_valid_from_expire() {
run_idm_test!(|qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
au: &mut AuditScope| {
// Any account that is expired can't unix auth.
init_admin_w_password(au, qs, TEST_PASSWORD).expect("Failed to setup admin account");
set_admin_valid_time(au, qs);
let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME);
let time_high = Duration::from_secs(TEST_AFTER_EXPIRY);
// make the admin a valid posix account
let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now());
let me_posix = unsafe {
ModifyEvent::new_internal_invalid(
filter!(f_eq("name", PartialValue::new_iname("admin"))),
ModifyList::new_list(vec![
Modify::Present("class".to_string(), Value::new_class("posixaccount")),
Modify::Present("gidnumber".to_string(), Value::new_uint32(2001)),
]),
)
};
assert!(idms_prox_write.qs_write.modify(au, &me_posix).is_ok());
let pce = UnixPasswordChangeEvent::new_internal(&UUID_ADMIN, TEST_PASSWORD);
assert!(idms_prox_write.set_unix_account_password(au, &pce).is_ok());
assert!(idms_prox_write.commit(au).is_ok());
// Now check auth when the time is too high or too low.
let mut idms_write = idms.write();
let uuae_good = UnixUserAuthEvent::new_internal(&UUID_ADMIN, TEST_PASSWORD);
let a1 = idms_write.auth_unix(au, &uuae_good, time_low);
// Should this actually send an error with the details? Or just silently act as
// badpw?
match a1 {
Ok(None) => {}
_ => assert!(false),
};
let a2 = idms_write.auth_unix(au, &uuae_good, time_high);
match a2 {
Ok(None) => {}
_ => assert!(false),
};
idms_write.commit(au).expect("Must not fail");
// Also check the generated unix tokens are invalid.
let mut idms_prox_read = idms.proxy_read();
let uute = UnixUserTokenEvent::new_internal(UUID_ADMIN.clone());
let tok_r = idms_prox_read
.get_unixusertoken(au, &uute, time_low)
.expect("Failed to generate unix user token");
assert!(tok_r.name == "admin");
assert!(tok_r.valid == false);
let tok_r = idms_prox_read
.get_unixusertoken(au, &uute, time_high)
.expect("Failed to generate unix user token");
assert!(tok_r.name == "admin");
assert!(tok_r.valid == false);
})
}
#[test]
fn test_idm_radius_valid_from_expire() {
run_idm_test!(|qs: &QueryServer,
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
au: &mut AuditScope| {
// Any account not valid/expiry should not return
// a radius packet.
init_admin_w_password(au, qs, TEST_PASSWORD).expect("Failed to setup admin account");
set_admin_valid_time(au, qs);
let time_low = Duration::from_secs(TEST_NOT_YET_VALID_TIME);
let time_high = Duration::from_secs(TEST_AFTER_EXPIRY);
let mut idms_prox_write = idms.proxy_write(duration_from_epoch_now());
let rrse = RegenerateRadiusSecretEvent::new_internal(UUID_ADMIN.clone());
let _r1 = idms_prox_write
.regenerate_radius_secret(au, &rrse)
.expect("Failed to reset radius credential 1");
idms_prox_write.commit(au).expect("failed to commit");
let mut idms_prox_read = idms.proxy_read();
let rate = RadiusAuthTokenEvent::new_internal(UUID_ADMIN.clone());
let tok_r = idms_prox_read.get_radiusauthtoken(au, &rate, time_low);
if let Err(_) = tok_r {
// Ok?
} else {
assert!(false);
}
let tok_r = idms_prox_read.get_radiusauthtoken(au, &rate, time_high);
if let Err(_) = tok_r {
// Ok?
} else {
assert!(false);
}
})
}
}

View file

@ -16,6 +16,8 @@ use kanidm_proto::v1::{UnixGroupToken, UnixUserToken};
use crate::idm::delayed::{DelayedAction, UnixPasswordUpgrade};
// use crossbeam::channel::Sender;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::sync::mpsc::UnboundedSender as Sender;
use std::iter;
@ -31,6 +33,8 @@ pub(crate) struct UnixUserAccount {
pub sshkeys: Vec<String>,
pub groups: Vec<UnixGroup>,
cred: Option<Credential>,
pub valid_from: Option<OffsetDateTime>,
pub expire: Option<OffsetDateTime>,
}
lazy_static! {
@ -94,6 +98,10 @@ macro_rules! try_from_entry {
.get_ava_single_credential("unix_password")
.map(|v| v.clone());
let valid_from = $value.get_ava_single_datetime("account_valid_from");
let expire = $value.get_ava_single_datetime("account_expire");
Ok(UnixUserAccount {
name,
spn,
@ -104,6 +112,8 @@ macro_rules! try_from_entry {
sshkeys,
groups: $groups,
cred,
valid_from,
expire,
})
}};
}
@ -127,6 +137,7 @@ impl UnixUserAccount {
try_from_entry!(value, groups)
}
/*
pub(crate) fn try_from_entry_reduced(
au: &mut AuditScope,
value: &Entry<EntryReduced, EntryCommitted>,
@ -135,8 +146,9 @@ impl UnixUserAccount {
let groups = UnixGroup::try_from_account_entry_red_ro(au, value, qs)?;
try_from_entry!(value, groups)
}
*/
pub(crate) fn to_unixusertoken(&self) -> Result<UnixUserToken, OperationError> {
pub(crate) fn to_unixusertoken(&self, ct: Duration) -> Result<UnixUserToken, OperationError> {
let groups: Result<Vec<_>, _> = self.groups.iter().map(|g| g.to_unixgrouptoken()).collect();
let groups = groups?;
@ -149,6 +161,7 @@ impl UnixUserAccount {
shell: self.shell.clone(),
groups,
sshkeys: self.sshkeys.clone(),
valid: self.is_within_valid_time(ct),
})
}
@ -166,13 +179,41 @@ impl UnixUserAccount {
Ok(ModifyList::new_purge_and_set("unix_password", vcred))
}
fn is_within_valid_time(&self, ct: Duration) -> bool {
let cot = OffsetDateTime::unix_epoch() + ct;
let vmin = if let Some(vft) = &self.valid_from {
// If current time greater than start time window
vft < &cot
} else {
// We have no time, not expired.
true
};
let vmax = if let Some(ext) = &self.expire {
// If exp greater than ct then expired.
&cot < ext
} else {
// If not present, we are not expired
true
};
// Mix the results
vmin && vmax
}
pub(crate) fn verify_unix_credential(
&self,
au: &mut AuditScope,
cleartext: &str,
async_tx: &Sender<DelayedAction>,
ct: Duration,
) -> Result<Option<UnixUserToken>, OperationError> {
// TODO #59: Is the cred locked?
// Is the cred locked?
if !self.is_within_valid_time(ct) {
lsecurity!(au, "Account is not within valid time period");
return Ok(None);
}
// is the cred some or none?
match &self.cred {
Some(cred) => {
@ -192,7 +233,9 @@ impl UnixUserAccount {
})?;
}
Some(self.to_unixusertoken()).transpose()
// Technically this means we check the times twice, but that doesn't
// seem like a big deal when we want to short cut return on invalid.
Some(self.to_unixusertoken(ct)).transpose()
} else {
// Failed to auth
lsecurity!(au, "Failed unix cred handling (denied)");
@ -365,6 +408,7 @@ impl UnixGroup {
try_from_account_group_e!(au, value, qs)
}
/*
pub fn try_from_account_entry_red_ro(
au: &mut AuditScope,
value: &Entry<EntryReduced, EntryCommitted>,
@ -372,6 +416,7 @@ impl UnixGroup {
) -> Result<Vec<Self>, OperationError> {
try_from_account_group_e!(au, value, qs)
}
*/
pub fn try_from_entry_reduced(
value: &Entry<EntryReduced, EntryCommitted>,

View file

@ -200,6 +200,7 @@ impl SchemaAttribute {
SyntaxType::UINT32 => v.is_uint32(),
SyntaxType::CID => v.is_cid(),
SyntaxType::NSUNIQUEID => v.is_nsuniqueid(),
SyntaxType::DATETIME => v.is_datetime(),
};
if r {
Ok(())
@ -375,6 +376,15 @@ impl SchemaAttribute {
}
})
}),
SyntaxType::DATETIME => ava.iter().fold(Ok(()), |acc, v| {
acc.and_then(|_| {
if v.is_datetime() {
Ok(())
} else {
Err(SchemaError::InvalidAttributeSyntax(a.to_string()))
}
})
}),
}
}
}

View file

@ -335,9 +335,8 @@ pub trait QueryServerTransaction {
}
// Who they are will go here
/*
fn impersonate_search(
&mut self,
&self,
audit: &mut AuditScope,
filter: Filter<FilterInvalid>,
filter_intent: Filter<FilterInvalid>,
@ -351,7 +350,6 @@ pub trait QueryServerTransaction {
.map_err(OperationError::SchemaViolation)?;
self.impersonate_search_valid(audit, f_valid, f_intent_valid, event)
}
*/
fn impersonate_search_ext(
&self,
@ -423,6 +421,30 @@ pub trait QueryServerTransaction {
})
}
fn impersonate_search_uuid(
&self,
audit: &mut AuditScope,
uuid: &Uuid,
event: &Event,
) -> Result<Entry<EntrySealed, EntryCommitted>, OperationError> {
lperf_segment!(audit, "server::internal_search_uuid", || {
let filter_intent = filter_all!(f_eq("uuid", PartialValue::new_uuid(*uuid)));
let filter = filter!(f_eq("uuid", PartialValue::new_uuid(*uuid)));
let res = self.impersonate_search(audit, filter, filter_intent, event);
match res {
Ok(vs) => {
if vs.len() > 1 {
return Err(OperationError::NoMatchingEntries);
}
vs.into_iter()
.next()
.ok_or(OperationError::NoMatchingEntries)
}
Err(e) => Err(e),
}
})
}
/// Do a schema aware conversion from a String:String to String:Value for modification
/// present.
fn clone_value(
@ -490,6 +512,8 @@ pub trait QueryServerTransaction {
.ok_or_else(|| OperationError::InvalidAttribute("Invalid uint32 syntax".to_string())),
SyntaxType::CID => Err(OperationError::InvalidAttribute("CIDs are generated and not able to be set.".to_string())),
SyntaxType::NSUNIQUEID => Ok(Value::new_nsuniqueid_s(value)),
SyntaxType::DATETIME => Value::new_datetime_s(value)
.ok_or_else(|| OperationError::InvalidAttribute("Invalid DateTime (rfc3339) syntax".to_string())),
}
}
None => {
@ -576,6 +600,11 @@ pub trait QueryServerTransaction {
OperationError::InvalidAttribute("Invalid cid syntax".to_string())
}),
SyntaxType::NSUNIQUEID => Ok(PartialValue::new_nsuniqueid_s(value)),
SyntaxType::DATETIME => PartialValue::new_datetime_s(value).ok_or_else(|| {
OperationError::InvalidAttribute(
"Invalid DateTime (rfc3339) syntax".to_string(),
)
}),
}
}
None => {
@ -1964,6 +1993,8 @@ impl<'a> QueryServerWriteTransaction<'a> {
JSON_SCHEMA_ATTR_BADLIST_PASSWORD,
JSON_SCHEMA_ATTR_LOGINSHELL,
JSON_SCHEMA_ATTR_UNIX_PASSWORD,
JSON_SCHEMA_ATTR_ACCOUNT_EXPIRE,
JSON_SCHEMA_ATTR_ACCOUNT_VALID_FROM,
JSON_SCHEMA_CLASS_PERSON,
JSON_SCHEMA_CLASS_GROUP,
JSON_SCHEMA_CLASS_ACCOUNT,

View file

@ -7,6 +7,8 @@ use std::borrow::Borrow;
use std::convert::TryFrom;
use std::fmt;
use std::str::FromStr;
use std::time::Duration;
use time::OffsetDateTime;
use uuid::Uuid;
use sshkeys::PublicKey as SshPublicKey;
@ -124,6 +126,7 @@ pub enum SyntaxType {
UINT32,
CID,
NSUNIQUEID,
DATETIME,
}
impl TryFrom<&str> for SyntaxType {
@ -148,6 +151,7 @@ impl TryFrom<&str> for SyntaxType {
"UINT32" => Ok(SyntaxType::UINT32),
"CID" => Ok(SyntaxType::CID),
"NSUNIQUEID" => Ok(SyntaxType::NSUNIQUEID),
"DATETIME" => Ok(SyntaxType::DATETIME),
_ => Err(()),
}
}
@ -174,6 +178,7 @@ impl TryFrom<usize> for SyntaxType {
13 => Ok(SyntaxType::CID),
14 => Ok(SyntaxType::UTF8STRING_INAME),
15 => Ok(SyntaxType::NSUNIQUEID),
16 => Ok(SyntaxType::DATETIME),
_ => Err(()),
}
}
@ -198,6 +203,7 @@ impl SyntaxType {
SyntaxType::CID => 13,
SyntaxType::UTF8STRING_INAME => 14,
SyntaxType::NSUNIQUEID => 15,
SyntaxType::DATETIME => 16,
}
}
}
@ -224,6 +230,7 @@ impl fmt::Display for SyntaxType {
SyntaxType::UINT32 => "UINT32",
SyntaxType::CID => "CID",
SyntaxType::NSUNIQUEID => "NSUNIQUEID",
SyntaxType::DATETIME => "DATETIME",
}
)
}
@ -267,6 +274,7 @@ pub enum PartialValue {
Uint32(u32),
Cid(Cid),
Nsuniqueid(String),
DateTime(OffsetDateTime),
}
impl PartialValue {
@ -529,6 +537,24 @@ impl PartialValue {
}
}
pub fn new_datetime_epoch(ts: Duration) -> Self {
PartialValue::DateTime(OffsetDateTime::unix_epoch() + ts)
}
pub fn new_datetime_s(s: &str) -> Option<Self> {
OffsetDateTime::parse(s, time::Format::Rfc3339)
.ok()
.map(|odt| odt.to_offset(time::UtcOffset::UTC))
.map(PartialValue::DateTime)
}
pub fn is_datetime(&self) -> bool {
match self {
PartialValue::DateTime(_) => true,
_ => false,
}
}
pub fn to_str(&self) -> Option<&str> {
match self {
PartialValue::Utf8(s) => Some(s.as_str()),
@ -578,6 +604,10 @@ impl PartialValue {
PartialValue::Uint32(u) => u.to_string(),
// This will never work, we don't allow equality searching on Cid's
PartialValue::Cid(_) => "_".to_string(),
PartialValue::DateTime(odt) => {
debug_assert!(odt.offset() == time::UtcOffset::UTC);
odt.format(time::Format::Rfc3339)
}
}
}
@ -1041,6 +1071,31 @@ impl Value {
self.pv.is_nsuniqueid()
}
pub fn new_datetime_epoch(ts: Duration) -> Self {
Value {
pv: PartialValue::new_datetime_epoch(ts),
data: None,
}
}
pub fn new_datetime_s(s: &str) -> Option<Self> {
PartialValue::new_datetime_s(s).map(|pv| Value { pv, data: None })
}
pub fn to_datetime(&self) -> Option<OffsetDateTime> {
match &self.pv {
PartialValue::DateTime(odt) => {
debug_assert!(odt.offset() == time::UtcOffset::UTC);
Some(*odt)
}
_ => None,
}
}
pub fn is_datetime(&self) -> bool {
self.pv.is_datetime()
}
pub fn contains(&self, s: &PartialValue) -> bool {
self.pv.contains(s)
}
@ -1133,6 +1188,9 @@ impl Value {
pv: PartialValue::Nsuniqueid(s),
data: None,
}),
DbValueV1::DT(s) => PartialValue::new_datetime_s(&s)
.ok_or(())
.map(|pv| Value { pv, data: None }),
}
}
@ -1200,6 +1258,10 @@ impl Value {
t: c.ts,
}),
PartialValue::Nsuniqueid(s) => DbValueV1::NU(s.clone()),
PartialValue::DateTime(odt) => {
debug_assert!(odt.offset() == time::UtcOffset::UTC);
DbValueV1::DT(odt.format(time::Format::Rfc3339))
}
}
}
@ -1328,6 +1390,10 @@ impl Value {
PartialValue::Spn(n, r) => format!("{}@{}", n, r),
PartialValue::Uint32(u) => u.to_string(),
PartialValue::Cid(c) => format!("{:?}_{}_{}", c.ts, c.d_uuid, c.s_uuid),
PartialValue::DateTime(odt) => {
debug_assert!(odt.offset() == time::UtcOffset::UTC);
odt.format(time::Format::Rfc3339)
}
}
}
@ -1368,6 +1434,7 @@ impl Value {
None => false,
},
PartialValue::Nsuniqueid(s) => NSUNIQUEID_RE.is_match(s),
PartialValue::DateTime(odt) => odt.offset() == time::UtcOffset::UTC,
_ => true,
}
}
@ -1396,6 +1463,10 @@ impl Value {
PartialValue::Spn(n, r) => vec![format!("{}@{}", n, r)],
PartialValue::Uint32(u) => vec![u.to_string()],
PartialValue::Cid(_) => vec![],
PartialValue::DateTime(odt) => {
debug_assert!(odt.offset() == time::UtcOffset::UTC);
vec![odt.format(time::Format::Rfc3339)]
}
}
}
}
@ -1586,6 +1657,34 @@ mod tests {
assert!(val2.validate());
}
#[test]
fn test_value_datetime() {
// Datetimes must always convert to UTC, and must always be rfc3339
let val1 = Value::new_datetime_s("2020-09-25T11:22:02+10:00").expect("Must be valid");
assert!(val1.validate());
let val2 = Value::new_datetime_s("2020-09-25T01:22:02+00:00").expect("Must be valid");
assert!(val2.validate());
assert!(Value::new_datetime_s("2020-09-25T01:22:02").is_none());
assert!(Value::new_datetime_s("2020-09-25").is_none());
assert!(Value::new_datetime_s("2020-09-25T01:22:02+10").is_none());
assert!(Value::new_datetime_s("2020-09-25 01:22:02+00:00").is_none());
// Manually craft
let inv1 = Value {
pv: PartialValue::DateTime(
OffsetDateTime::now_utc().to_offset(time::UtcOffset::east_hours(10)),
),
data: None,
};
assert!(!inv1.validate());
let val3 = Value {
pv: PartialValue::DateTime(OffsetDateTime::now_utc()),
data: None,
};
assert!(val3.validate());
}
/*
#[test]
fn test_schema_syntax_json_filter() {