383 164 authentication updates 9 ()

* implementation of passkeys as an auth mech
* listing the current passkeys when asking to remove one
* tweaking insecure dev server config so passkeys will work
* Fix domain rename
Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
This commit is contained in:
Firstyear 2022-07-30 22:10:24 +10:00 committed by GitHub
parent f6fe2f575c
commit 4151897948
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 3578 additions and 6418 deletions

4638
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -32,9 +32,14 @@ exclude = [
# ldap3_server = { path = "../ldap3_server" }
# webauthn-rs = { path = "../webauthn-rs" }
# webauthn-authenticator-rs = { path = "../webauthn-authenticator-rs" }
# webauthn-rs = { path = "../webauthn-rs/webauthn-rs" }
webauthn-rs = { git = "https://github.com/kanidm/webauthn-rs.git", rev = "7a8e6c6b351ab7544f08cf8ba48424baacee1360" }
# webauthn-rs-core = { path = "../webauthn-rs/webauthn-rs-core" }
webauthn-rs-core = { git = "https://github.com/kanidm/webauthn-rs.git", rev = "7a8e6c6b351ab7544f08cf8ba48424baacee1360" }
# webauthn-rs-proto = { path = "../webauthn-rs/webauthn-rs-proto" }
webauthn-rs-proto = { git = "https://github.com/kanidm/webauthn-rs.git", rev = "7a8e6c6b351ab7544f08cf8ba48424baacee1360" }
# webauthn-authenticator-rs = { path = "../webauthn-rs/webauthn-authenticator-rs" }
webauthn-authenticator-rs = { git = "https://github.com/kanidm/webauthn-rs.git", rev = "7a8e6c6b351ab7544f08cf8ba48424baacee1360" }
# compact_jwt = { path = "../compact_jwt" }
# compact_jwt = { git = "https://github.com/kanidm/compact-jwt.git" }

View file

@ -10,6 +10,6 @@ tls_key = "/tmp/kanidm/key.pem"
# log_level = "quiet"
log_level = "verbose"
domain = "idm.example.com"
origin = "https://idm.example.com:8443"
domain = "localhost"
origin = "https://localhost:8443"

View file

@ -16,9 +16,9 @@ reqwest = { version = "^0.11.11", features=["cookies", "json", "native-tls"] }
kanidm_proto = { path = "../kanidm_proto", version = "1.1.0-alpha.8" }
serde = { version = "^1.0.139", features = ["derive"] }
serde_json = "^1.0.82"
tokio = { version = "^1.20.0", features = ["rt", "net", "time", "macros", "sync", "signal"] }
toml = "^0.5.9"
uuid = { version = "^1.1.2", features = ["serde", "v4"] }
url = { version = "^2.2.2", features = ["serde"] }
webauthn-rs = "^0.3.2"
tokio = { version = "^1.20.0", features = ["rt", "net", "time", "macros", "sync", "signal"] }
webauthn-rs-proto = { version = "0.4.2-beta.3", features = ["wasm"] }

View file

@ -38,9 +38,8 @@ use uuid::Uuid;
pub use reqwest::StatusCode;
use kanidm_proto::v1::*;
use webauthn_rs::proto::{
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
RequestChallengeResponse,
use webauthn_rs_proto::{
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
};
pub const APPLICATION_JSON: &str = "application/json";
@ -85,7 +84,7 @@ pub struct KanidmClientBuilder {
pub struct KanidmClient {
pub(crate) client: reqwest::Client,
pub(crate) addr: String,
pub(crate) origin: String,
pub(crate) origin: Url,
pub(crate) builder: KanidmClientBuilder,
pub(crate) bearer_token: RwLock<Option<String>>,
pub(crate) auth_session_id: RwLock<Option<String>>,
@ -374,10 +373,11 @@ impl KanidmClientBuilder {
// Now get the origin.
#[allow(clippy::expect_used)]
let uri = Url::parse(&address).expect("can not fail");
let uri = Url::parse(&address).expect("failed to parse address");
#[allow(clippy::expect_used)]
let origin = uri.origin().unicode_serialization();
let origin =
Url::parse(&uri.origin().ascii_serialization()).expect("failed to parse origin");
Ok(KanidmClient {
client,
@ -391,8 +391,8 @@ impl KanidmClientBuilder {
}
impl KanidmClient {
pub fn get_origin(&self) -> &str {
self.origin.as_str()
pub fn get_origin(&self) -> &Url {
&self.origin
}
pub fn get_url(&self) -> &str {
@ -919,12 +919,29 @@ impl KanidmClient {
r
}
pub async fn auth_step_webauthn_complete(
pub async fn auth_step_securitykey_complete(
&self,
pkc: PublicKeyCredential,
) -> Result<AuthResponse, ClientError> {
let auth_req = AuthRequest {
step: AuthStep::Cred(AuthCredential::Webauthn(pkc)),
step: AuthStep::Cred(AuthCredential::SecurityKey(pkc)),
};
let r: Result<AuthResponse, _> = self.perform_auth_post_request("/v1/auth", auth_req).await;
if let Ok(ar) = &r {
if let AuthState::Success(token) = &ar.state {
self.set_token(token.clone()).await;
};
};
r
}
pub async fn auth_step_passkey_complete(
&self,
pkc: PublicKeyCredential,
) -> Result<AuthResponse, ClientError> {
let auth_req = AuthRequest {
step: AuthStep::Cred(AuthCredential::Passkey(pkc)),
};
let r: Result<AuthResponse, _> = self.perform_auth_post_request("/v1/auth", auth_req).await;
@ -1091,7 +1108,7 @@ impl KanidmClient {
}
}
pub async fn auth_webauthn_begin(
pub async fn auth_passkey_begin(
&self,
ident: &str,
) -> Result<RequestChallengeResponse, ClientError> {
@ -1100,28 +1117,25 @@ impl KanidmClient {
Err(e) => return Err(e),
};
if !mechs.contains(&AuthMech::Webauthn) {
if !mechs.contains(&AuthMech::Passkey) {
debug!("Webauthn mech not presented");
return Err(ClientError::AuthenticationFailed);
}
let state = match self.auth_step_begin(AuthMech::Webauthn).await {
let state = match self.auth_step_begin(AuthMech::Passkey).await {
Ok(mut s) => s.pop(),
Err(e) => return Err(e),
};
// State is now a set of auth continues.
match state {
Some(AuthAllowed::Webauthn(r)) => Ok(r),
Some(AuthAllowed::Passkey(r)) => Ok(r),
_ => Err(ClientError::AuthenticationFailed),
}
}
pub async fn auth_webauthn_complete(
&self,
pkc: PublicKeyCredential,
) -> Result<(), ClientError> {
let r = self.auth_step_webauthn_complete(pkc).await?;
pub async fn auth_passkey_complete(&self, pkc: PublicKeyCredential) -> Result<(), ClientError> {
let r = self.auth_step_passkey_complete(pkc).await?;
match r.state {
AuthState::Success(_token) => Ok(()),
_ => Err(ClientError::AuthenticationFailed),
@ -1504,6 +1518,7 @@ impl KanidmClient {
}
}
/*
pub async fn idm_account_primary_credential_register_webauthn(
&self,
id: &str,
@ -1561,6 +1576,7 @@ impl KanidmClient {
Err(e) => Err(e),
}
}
*/
pub async fn idm_account_primary_credential_generate_backup_code(
&self,
@ -1722,6 +1738,36 @@ impl KanidmClient {
.await
}
pub async fn idm_account_credential_update_passkey_init(
&self,
session_token: &CUSessionToken,
) -> Result<CUStatus, ClientError> {
let scr = CURequest::PasskeyInit;
self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token))
.await
}
pub async fn idm_account_credential_update_passkey_finish(
&self,
session_token: &CUSessionToken,
label: String,
registration: RegisterPublicKeyCredential,
) -> Result<CUStatus, ClientError> {
let scr = CURequest::PasskeyFinish(label, registration);
self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token))
.await
}
pub async fn idm_account_credential_update_passkey_remove(
&self,
session_token: &CUSessionToken,
uuid: Uuid,
) -> Result<CUStatus, ClientError> {
let scr = CURequest::PasskeyRemove(uuid);
self.perform_simple_post_request("/v1/credential/_update", &(scr, &session_token))
.await
}
pub async fn idm_account_credential_update_commit(
&self,
session_token: &CUSessionToken,

View file

@ -10,12 +10,16 @@ documentation = "https://docs.rs/kanidm_proto/latest/kanidm_proto/"
homepage = "https://github.com/kanidm/kanidm/"
repository = "https://github.com/kanidm/kanidm/"
[features]
wasm = ["webauthn-rs-proto/wasm"]
[dependencies]
serde = { version = "^1.0.139", features = ["derive"] }
serde_json = "^1.0.82"
uuid = { version = "^1.1.2", features = ["serde"] }
base32 = "^0.4.0"
webauthn-rs = { version = "^0.3.2", default-features = false, features = ["wasm"] }
base64urlsafedata = "0.1.0"
webauthn-rs-proto = "0.4.2-beta.3"
# Can not upgrade due to breaking timezone apis.
time = { version = "=0.2.27", features = ["serde", "std"] }
url = { version = "^2.2.2", features = ["serde"] }

View file

@ -11,3 +11,5 @@
pub mod messages;
pub mod oauth2;
pub mod v1;
pub use webauthn_rs_proto as webauthn;

View file

@ -1,7 +1,7 @@
use base64urlsafedata::Base64UrlSafeData;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use url::Url;
use webauthn_rs::base64_data::Base64UrlSafeData;
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
pub enum CodeChallengeMethod {

View file

@ -3,7 +3,7 @@ use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt;
use uuid::Uuid;
use webauthn_rs::proto::{
use webauthn_rs_proto::{
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
RequestChallengeResponse,
};
@ -202,11 +202,8 @@ pub enum AuthType {
UnixPassword,
Password,
GeneratedPassword,
Webauthn,
PasswordMfa,
// PasswordWebauthn,
// WebauthnVerified,
// PasswordWebauthnVerified,
Passkey,
}
impl fmt::Display for AuthType {
@ -216,8 +213,8 @@ impl fmt::Display for AuthType {
AuthType::UnixPassword => write!(f, "unixpassword"),
AuthType::Password => write!(f, "password"),
AuthType::GeneratedPassword => write!(f, "generatedpassword"),
AuthType::Webauthn => write!(f, "webauthn"),
AuthType::PasswordMfa => write!(f, "passwordmfa"),
AuthType::Passkey => write!(f, "passkey"),
}
}
}
@ -380,7 +377,7 @@ pub struct AccountOrgPersonExtend {
pub enum CredentialDetailType {
Password,
GeneratedPassword,
Webauthn(Vec<String>),
Passkey(Vec<String>),
/// totp, webauthn
PasswordMfa(bool, Vec<String>, usize),
}
@ -388,7 +385,6 @@ pub enum CredentialDetailType {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CredentialDetail {
pub uuid: Uuid,
pub claims: Vec<String>,
pub type_: CredentialDetailType,
}
@ -404,11 +400,11 @@ impl fmt::Display for CredentialDetail {
match &self.type_ {
CredentialDetailType::Password => writeln!(f, "password: set"),
CredentialDetailType::GeneratedPassword => writeln!(f, "generated password: set"),
CredentialDetailType::Webauthn(labels) => {
CredentialDetailType::Passkey(labels) => {
if labels.is_empty() {
writeln!(f, "webauthn: no authenticators")
writeln!(f, "passkeys: none registered")
} else {
writeln!(f, "webauthn:")?;
writeln!(f, "passkeys:")?;
for label in labels {
writeln!(f, " * {}", label)?;
}
@ -441,6 +437,12 @@ impl fmt::Display for CredentialDetail {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PasskeyDetail {
pub uuid: Uuid,
pub tag: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CredentialStatus {
pub creds: Vec<CredentialDetail>,
@ -597,8 +599,10 @@ pub enum AuthCredential {
Anonymous,
Password(String),
Totp(u32),
Webauthn(PublicKeyCredential),
SecurityKey(PublicKeyCredential),
BackupCode(String),
// Should this just be discoverable?
Passkey(PublicKeyCredential),
}
impl fmt::Debug for AuthCredential {
@ -607,8 +611,9 @@ impl fmt::Debug for AuthCredential {
AuthCredential::Anonymous => write!(fmt, "Anonymous"),
AuthCredential::Password(_) => write!(fmt, "Password(_)"),
AuthCredential::Totp(_) => write!(fmt, "TOTP(_)"),
AuthCredential::Webauthn(_) => write!(fmt, "Webauthn(_)"),
AuthCredential::SecurityKey(_) => write!(fmt, "SecurityKey(_)"),
AuthCredential::BackupCode(_) => write!(fmt, "BackupCode(_)"),
AuthCredential::Passkey(_) => write!(fmt, "Passkey(_)"),
}
}
}
@ -619,9 +624,7 @@ pub enum AuthMech {
Anonymous,
Password,
PasswordMfa,
Webauthn,
// WebauthnVerified,
// PasswordWebauthnVerified
Passkey,
}
impl PartialEq for AuthMech {
@ -634,9 +637,9 @@ impl fmt::Display for AuthMech {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthMech::Anonymous => write!(f, "Anonymous (no credentials)"),
AuthMech::Password => write!(f, "Passwold Only"),
AuthMech::PasswordMfa => write!(f, "TOTP or Token, and Password"),
AuthMech::Webauthn => write!(f, "Webauthn Token"),
AuthMech::Password => write!(f, "Password"),
AuthMech::PasswordMfa => write!(f, "TOTP/Backup Code and Password"),
AuthMech::Passkey => write!(f, "Passkey"),
}
}
}
@ -669,7 +672,8 @@ pub enum AuthAllowed {
BackupCode,
Password,
Totp,
Webauthn(RequestChallengeResponse),
SecurityKey(RequestChallengeResponse),
Passkey(RequestChallengeResponse),
}
impl PartialEq for AuthAllowed {
@ -695,9 +699,11 @@ impl Ord for AuthAllowed {
(_, AuthAllowed::BackupCode) => Ordering::Greater,
(AuthAllowed::Totp, _) => Ordering::Less,
(_, AuthAllowed::Totp) => Ordering::Greater,
(AuthAllowed::Webauthn(_), _) => Ordering::Less,
(AuthAllowed::SecurityKey(_), _) => Ordering::Less,
(_, AuthAllowed::SecurityKey(_)) => Ordering::Greater,
(AuthAllowed::Passkey(_), _) => Ordering::Less,
// Unreachable
// (_, AuthAllowed::Webauthn(_)) => Ordering::Greater,
// (_, AuthAllowed::Passkey(_)) => Ordering::Greater,
}
}
}
@ -716,7 +722,8 @@ impl fmt::Display for AuthAllowed {
AuthAllowed::Password => write!(f, "Password"),
AuthAllowed::BackupCode => write!(f, "Backup Code"),
AuthAllowed::Totp => write!(f, "TOTP"),
AuthAllowed::Webauthn(_) => write!(f, "Webauthn Token"),
AuthAllowed::SecurityKey(_) => write!(f, "Security Token"),
AuthAllowed::Passkey(_) => write!(f, "Passkey"),
}
}
}
@ -751,12 +758,6 @@ pub enum SetCredentialRequest {
TotpVerify(Uuid, u32),
TotpAcceptSha1(Uuid),
TotpRemove,
// Start the rego.
WebauthnBegin(String),
// Finish it.
WebauthnRegister(Uuid, RegisterPublicKeyCredential),
// Remove
WebauthnRemove(String),
BackupCodeGenerate,
BackupCodeRemove,
}
@ -817,7 +818,7 @@ pub enum SetCredentialResponse {
Token(String),
TotpCheck(Uuid, TotpSecret),
TotpInvalidSha1(Uuid),
WebauthnCreateChallenge(Uuid, CreationChallengeResponse),
SecurityKeyCreateChallenge(Uuid, CreationChallengeResponse),
BackupCodes(Vec<String>),
}
@ -843,6 +844,9 @@ pub enum CURequest {
TotpRemove,
BackupCodeGenerate,
BackupCodeRemove,
PasskeyInit,
PasskeyFinish(String, RegisterPublicKeyCredential),
PasskeyRemove(Uuid),
}
impl fmt::Debug for CURequest {
@ -857,6 +861,9 @@ impl fmt::Debug for CURequest {
CURequest::TotpRemove => "CURequest::TotpRemove",
CURequest::BackupCodeGenerate => "CURequest::BackupCodeGenerate",
CURequest::BackupCodeRemove => "CURequest::BackupCodeRemove",
CURequest::PasskeyInit => "CURequest::PasskeyInit",
CURequest::PasskeyFinish(_, _) => "CURequest::PasskeyFinish",
CURequest::PasskeyRemove(_) => "CURequest::PasskeyRemove",
};
writeln!(f, "{}", t)
}
@ -870,6 +877,7 @@ pub enum CURegState {
TotpTryAgain,
TotpInvalidSha1,
BackupCodes(Vec<String>),
Passkey(CreationChallengeResponse),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -878,6 +886,7 @@ pub struct CUStatus {
pub displayname: String,
pub can_commit: bool,
pub primary: Option<CredentialDetail>,
pub passkeys: Vec<PasskeyDetail>,
pub mfaregstate: CURegState,
}

View file

@ -48,9 +48,13 @@ zxcvbn = "^2.2.1"
dialoguer = "^0.10.1"
webauthn-authenticator-rs = "^0.3.0-alpha.12"
# webauthn-authenticator-rs = { version = "0.4.2-beta.3", features = ["u2fhid"] }
webauthn-authenticator-rs = { git = "https://github.com/kanidm/webauthn-rs.git", rev = "7a8e6c6b351ab7544f08cf8ba48424baacee1360", features = ["u2fhid"] }
tokio = { version = "^1.20.0", features = ["rt", "macros"] }
url = { version = "^2.2.2", features = ["serde"] }
uuid = "^1.1.2"
[build-dependencies]
clap = { version = "^3.2", features = ["derive"] }

View file

@ -15,6 +15,9 @@ use std::fmt::{self, Debug};
use std::str::FromStr;
use time::OffsetDateTime;
use url::Url;
use uuid::Uuid;
use webauthn_authenticator_rs::{u2fhid::U2FHid, WebauthnAuthenticator};
impl AccountOpt {
pub fn debug(&self) -> bool {
@ -617,6 +620,8 @@ enum CUAction {
TotpRemove,
BackupCodes,
Remove,
Passkey,
PasskeyRemove,
End,
Commit,
}
@ -628,13 +633,17 @@ impl fmt::Display for CUAction {
r#"
help (h, ?) - Display this help
status (ls, st) - Show the status of the credential
end (quit, exit, x, q) - End, without saving any changes
commit (save) - Commit the changes to the credential
-- Password and MFA
password (passwd, pass, pw) - Set a new password
totp - Generate a new totp, requires a password to be set
totp remove (totp rm, trm) - Remove the TOTP of this account
backup codes (bcg, bcode) - (Re)generate backup codes for this account
remove (rm) - Remove only the primary credential
end (quit, exit, x, q) - End, without saving any changes
commit (save) - Commit the changes to the credential
remove (rm) - Remove only the password based credential
-- Passkeys
passkey (pk) - Add a new Passkey
passkey remove (passkey rm, pkrm) - Remove a Passkey
"#
)
}
@ -648,13 +657,15 @@ impl FromStr for CUAction {
match s.as_str() {
"help" | "h" | "?" => Ok(CUAction::Help),
"status" | "ls" | "st" => Ok(CUAction::Status),
"end" | "quit" | "exit" | "x" | "q" => Ok(CUAction::End),
"commit" | "save" => Ok(CUAction::Commit),
"password" | "passwd" | "pass" | "pw" => Ok(CUAction::Password),
"totp" => Ok(CUAction::Totp),
"totp remove" | "totp rm" | "trm" => Ok(CUAction::TotpRemove),
"backup codes" | "bcode" | "bcg" => Ok(CUAction::BackupCodes),
"remove" | "rm" => Ok(CUAction::Remove),
"end" | "quit" | "exit" | "x" | "q" => Ok(CUAction::End),
"commit" | "save" => Ok(CUAction::Commit),
"passkey" | "pk" => Ok(CUAction::Passkey),
"passkey remove" | "passkey rm" | "pkrm" => Ok(CUAction::PasskeyRemove),
_ => Err(()),
}
}
@ -823,6 +834,57 @@ async fn totp_enroll_prompt(session_token: &CUSessionToken, client: &KanidmClien
// Done!
}
async fn passkey_enroll_prompt(session_token: &CUSessionToken, client: &KanidmClient) {
let pk_reg = match client
.idm_account_credential_update_passkey_init(session_token)
.await
{
Ok(CUStatus {
mfaregstate: CURegState::Passkey(pk_reg),
..
}) => pk_reg,
Ok(status) => {
debug!(?status);
eprintln!("An error occured -> InvalidState");
return;
}
Err(e) => {
eprintln!("An error occured -> {:?}", e);
return;
}
};
// Setup and connect to the webauthn handler ...
let mut wa = WebauthnAuthenticator::new(U2FHid::new());
eprintln!("Your authenticator will now flash for you to interact with.");
eprintln!("You may be asked to enter the PIN for your device.");
let rego = match wa.do_registration(client.get_origin().clone(), pk_reg) {
Ok(rego) => rego,
Err(e) => {
error!("Error Signing -> {:?}", e);
return;
}
};
let label: String = Input::new()
.with_prompt("\nEnter a label for this Passkey # ")
.allow_empty(false)
.interact_text()
.unwrap();
match client
.idm_account_credential_update_passkey_finish(session_token, label, rego)
.await
{
Ok(_) => println!("success"),
Err(e) => {
eprintln!("An error occured -> {:?}", e);
}
};
}
// For webauthn later
/*
@ -880,6 +942,7 @@ fn display_status(status: CUStatus) {
can_commit,
primary,
mfaregstate: _,
passkeys,
} = status;
println!("spn: {}", spn);
@ -891,6 +954,14 @@ fn display_status(status: CUStatus) {
println!("Primary Credential:");
println!(" not set");
}
println!("Passkeys:");
if passkeys.is_empty() {
println!(" not set");
} else {
for pk in passkeys {
println!(" {} ({})", pk.tag, pk.uuid);
}
}
// We may need to be able to display if there are dangling
// curegstates, but the cli ui statemachine can match the
@ -1035,6 +1106,54 @@ async fn credential_update_exec(
println!("Primary credential was NOT removed");
}
}
CUAction::Passkey => passkey_enroll_prompt(&session_token, &client).await,
CUAction::PasskeyRemove => {
// TODO: make this a scrollable selector with a "cancel" option as the default
match client
.idm_account_credential_update_status(&session_token)
.await
{
Ok(status) => {
if status.passkeys.is_empty() {
println!("No passkeys are configured for this user");
return
}
println!("Current passkeys:");
for pk in status.passkeys {
println!(" {} ({})", pk.tag, pk.uuid);
}
},
Err(e) => {
eprintln!("An error occured pulling existing credentials -> {:?}", e);
}
}
let uuid_s: String = Input::new()
.with_prompt("\nEnter the UUID of the Passkey to remove (blank to stop) # ")
.validate_with(|input: &String| -> Result<(), &str> {
if input.is_empty() || Uuid::parse_str(input).is_ok() {
Ok(())
} else {
Err("This is not a valid UUID")
}
})
.allow_empty(true)
.interact_text()
.unwrap();
// Remeber, if it's NOT a valid uuid, it must have been empty as a termination.
if let Ok(uuid) = Uuid::parse_str(&uuid_s) {
if let Err(e) = client
.idm_account_credential_update_passkey_remove(&session_token, uuid)
.await
{
eprintln!("An error occured -> {:?}", e);
} else {
println!("success");
}
} else {
println!("Passkeys were NOT changed");
}
}
CUAction::End => {
println!("Changes were NOT saved.");
break;

View file

@ -11,7 +11,9 @@ use std::io::ErrorKind;
use std::io::{self, BufReader, BufWriter, Write};
use std::path::PathBuf;
use std::str::FromStr;
use webauthn_authenticator_rs::{u2fhid::U2FHid, RequestChallengeResponse, WebauthnAuthenticator};
use webauthn_authenticator_rs::{
prelude::RequestChallengeResponse, u2fhid::U2FHid, WebauthnAuthenticator,
};
use dialoguer::{theme::ColorfulTheme, Select};
@ -190,7 +192,7 @@ impl LoginOpt {
client.auth_step_totp(totp).await
}
async fn do_webauthn(
async fn do_passkey(
&self,
client: &mut KanidmClient,
pkr: RequestChallengeResponse,
@ -198,13 +200,30 @@ impl LoginOpt {
let mut wa = WebauthnAuthenticator::new(U2FHid::new());
println!("Your authenticator will now flash for you to interact with it.");
let auth = wa
.do_authentication(client.get_origin(), pkr)
.do_authentication(client.get_origin().clone(), pkr)
.unwrap_or_else(|e| {
error!("Failed to interact with webauthn device. -- {:?}", e);
std::process::exit(1);
});
client.auth_step_webauthn_complete(auth).await
client.auth_step_passkey_complete(auth).await
}
async fn do_securitykey(
&self,
client: &mut KanidmClient,
pkr: RequestChallengeResponse,
) -> Result<AuthResponse, ClientError> {
let mut wa = WebauthnAuthenticator::new(U2FHid::new());
println!("Your authenticator will now flash for you to interact with it.");
let auth = wa
.do_authentication(client.get_origin().clone(), pkr)
.unwrap_or_else(|e| {
error!("Failed to interact with webauthn device. -- {:?}", e);
std::process::exit(1);
});
client.auth_step_securitykey_complete(auth).await
}
pub async fn exec(&self) {
@ -297,7 +316,10 @@ impl LoginOpt {
AuthAllowed::Password => self.do_password(&mut client).await,
AuthAllowed::BackupCode => self.do_backup_code(&mut client).await,
AuthAllowed::Totp => self.do_totp(&mut client).await,
AuthAllowed::Webauthn(chal) => self.do_webauthn(&mut client, chal.clone()).await,
AuthAllowed::Passkey(chal) => self.do_passkey(&mut client, chal.clone()).await,
AuthAllowed::SecurityKey(chal) => {
self.do_securitykey(&mut client, chal.clone()).await
}
};
// Now update state.

View file

@ -18,6 +18,7 @@ path = "src/lib.rs"
async-std = { version = "^1.12.0", features = ["tokio1"] }
async-trait = "^0.1.53"
base64 = "^0.13.0"
base64urlsafedata = "0.1.0"
chrono = "^0.4.19"
compact_jwt = "^0.2.3"
compiled-uuid = "0.1.2"
@ -53,14 +54,15 @@ tokio = { version = "^1.20.0", features = ["net", "sync", "time"] }
tokio-util = { version = "^0.7.3", features = ["codec"] }
toml = "^0.5.9"
touch = "^0.0.1"
tracing = { version = "^0.1.35", features = ["attributes"] }
tracing = { version = "^0.1.35", features = ["attributes", "max_level_trace", "release_max_level_debug"] }
tracing-serde = "^0.1.3"
tracing-subscriber = { version = "^0.3.14", features = ["env-filter"] }
url = { version = "^2.2.2", features = ["serde"] }
urlencoding = "2.1.0"
uuid = { version = "^1.1.2", features = ["serde", "v4" ] }
validator = { version = "^0.15.0", features = ["phone"] }
webauthn-rs = "^0.3.2"
webauthn-rs = { version = "0.4.2-beta.3", features = ["resident_key_support"] }
webauthn-rs-core = "0.4.2-beta.3"
zxcvbn = "^2.2.1"
# because windows really can't build without the bundled one
@ -85,7 +87,7 @@ users = "^0.11.0"
[dev-dependencies]
criterion = { version = "^0.3.6", features = ["html_reports"] }
# For testing webauthn
webauthn-authenticator-rs = "^0.3.2"
webauthn-authenticator-rs = "0.4.2-beta.3"
[build-dependencies]
profiles = { path = "../../profiles" }

View file

@ -79,7 +79,7 @@ impl QueryServerReadV1 {
// ! Ideally, this function takes &self, uat, req, and then a `uuid` argument that is a `&str` of the hyphenated uuid.
// ! Then we just don't skip uuid, and we don't have to do the custom `fields(..)` stuff in this macro call.
#[instrument(
level = "trace",
level = "info",
name = "search",
skip(self, uat, req, eventid)
fields(uuid = ?eventid)
@ -126,7 +126,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "auth",
skip(self, sessionid, req, eventid)
fields(uuid = ?eventid)
@ -171,7 +171,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "online_backup",
skip(self, msg, outpath, versions)
fields(uuid = ?msg.eventid)
@ -290,7 +290,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "whoami",
skip(self, uat, eventid)
fields(uuid = ?eventid)
@ -353,8 +353,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalsearch",
level = "info",
name = "search2",
skip(self, uat, filter, attrs, eventid)
fields(uuid = ?eventid)
)]
@ -401,8 +401,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalsearchrecycled",
level = "info",
name = "search_recycled",
skip(self, uat, filter, attrs, eventid)
fields(uuid = ?eventid)
)]
@ -450,8 +450,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalradiusread",
level = "info",
name = "radius_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -515,8 +515,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalradiustokenread",
level = "info",
name = "radius_token_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -567,8 +567,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalunixusertokenread",
level = "info",
name = "unix_user_token_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -622,8 +622,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalunixgrouptokenread",
level = "info",
name = "unix_group_token_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -676,8 +676,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalsshkeyread",
level = "info",
name = "ssh_key_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -743,8 +743,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "internalsshkeytagread",
level = "info",
name = "ssh_key_tag_read",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -813,8 +813,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "idmaccountunixauth",
level = "info",
name = "idm_account_unix_auth",
skip(self, uat, uuid_or_name, cred, eventid)
fields(uuid = ?eventid)
)]
@ -867,8 +867,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialstatus",
level = "info",
name = "idm_credential_status",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -918,8 +918,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "idmbackupcodeview",
level = "info",
name = "idm_backup_code_view",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -969,8 +969,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialupdatestatus",
level = "info",
name = "idm_credential_update_status",
skip(self, session_token, eventid)
fields(uuid = ?eventid)
)]
@ -1001,8 +1001,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialupdate",
level = "info",
name = "idm_credential_update",
skip(self, session_token, scr, eventid)
fields(uuid = ?eventid)
)]
@ -1103,6 +1103,33 @@ impl QueryServerReadV1 {
);
e
}),
CURequest::PasskeyInit => idms_cred_update
.credential_passkey_init(&session_token, ct)
.map_err(|e| {
admin_error!(
err = ?e,
"Failed to begin credential_passkey_init",
);
e
}),
CURequest::PasskeyFinish(label, rpkc) => idms_cred_update
.credential_passkey_finish(&session_token, ct, label, rpkc)
.map_err(|e| {
admin_error!(
err = ?e,
"Failed to begin credential_passkey_init",
);
e
}),
CURequest::PasskeyRemove(uuid) => idms_cred_update
.credential_passkey_remove(&session_token, ct, uuid)
.map_err(|e| {
admin_error!(
err = ?e,
"Failed to begin credential_passkey_init",
);
e
}),
}
.map(|sta| sta.into())
});
@ -1110,7 +1137,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_authorise",
skip(self, uat, auth_req, eventid)
fields(uuid = ?eventid)
@ -1143,7 +1170,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_authorise_permit",
skip(self, uat, consent_req, eventid)
fields(uuid = ?eventid)
@ -1175,7 +1202,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_authorise_reject",
skip(self, uat, consent_req, eventid)
fields(uuid = ?eventid)
@ -1207,7 +1234,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_token_exchange",
skip(self, client_authz, token_req, eventid)
fields(uuid = ?eventid)
@ -1228,7 +1255,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_token_introspect",
skip(self, client_authz, intr_req, eventid)
fields(uuid = ?eventid)
@ -1249,7 +1276,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_openid_userinfo",
skip(self, client_id, client_authz, eventid)
fields(uuid = ?eventid)
@ -1269,7 +1296,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_openid_discovery",
skip(self, client_id, eventid)
fields(uuid = ?eventid)
@ -1287,7 +1314,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_openid_publickey",
skip(self, client_id, eventid)
fields(uuid = ?eventid)
@ -1305,8 +1332,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "domain_display_name",
level = "info",
name = "get_domain_display_name",
skip(self, eventid)
fields(uuid = ?eventid)
)]
@ -1319,7 +1346,7 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "auth_valid",
skip(self, uat, eventid)
fields(uuid = ?eventid)
@ -1345,8 +1372,8 @@ impl QueryServerReadV1 {
}
#[instrument(
level = "trace",
name = "ldaprequest",
level = "info",
name = "ldap_request",
skip(self, eventid, protomsg, uat)
fields(uuid = ?eventid)
)]

View file

@ -18,8 +18,7 @@ use crate::event::{
};
use crate::idm::event::{
AcceptSha1TotpEvent, GeneratePasswordEvent, GenerateTotpEvent, PasswordChangeEvent,
RegenerateRadiusSecretEvent, RemoveTotpEvent, RemoveWebauthnEvent, UnixPasswordChangeEvent,
VerifyTotpEvent, WebauthnDoRegisterEvent, WebauthnInitRegisterEvent,
RegenerateRadiusSecretEvent, RemoveTotpEvent, UnixPasswordChangeEvent, VerifyTotpEvent,
};
use crate::modify::{Modify, ModifyInvalid, ModifyList};
use crate::value::{PartialValue, Value};
@ -168,7 +167,7 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "create",
skip(self, uat, req, eventid)
fields(uuid = ?eventid)
@ -210,11 +209,17 @@ impl QueryServerWriteV1 {
res
}
#[instrument(
level = "info",
name = "modify",
skip(self, uat, req, eventid)
fields(uuid = ?eventid)
)]
pub async fn handle_modify(
&self,
uat: Option<String>,
req: ModifyRequest,
_eventid: Uuid,
eventid: Uuid,
) -> Result<(), OperationError> {
let idms_prox_write = self.idms.proxy_write_async(duration_from_epoch_now()).await;
let res = spanned!("actors::v1_write::handle<ModifyMessage>", {
@ -246,7 +251,7 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "delete",
skip(self, uat, req, eventid)
fields(uuid = ?eventid)
@ -285,12 +290,18 @@ impl QueryServerWriteV1 {
res
}
#[instrument(
level = "info",
name = "patch",
skip(self, uat, filter, update, eventid)
fields(uuid = ?eventid)
)]
pub async fn handle_internalpatch(
&self,
uat: Option<String>,
filter: Filter<FilterInvalid>,
update: ProtoEntry,
_eventid: Uuid,
eventid: Uuid,
) -> Result<(), OperationError> {
// Given a protoEntry, turn this into a modification set.
let idms_prox_write = self.idms.proxy_write_async(duration_from_epoch_now()).await;
@ -333,8 +344,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "internal_delete",
level = "info",
name = "delete2",
skip(self, uat, filter, eventid)
fields(uuid = ?eventid)
)]
@ -373,8 +384,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "reviverecycled",
level = "info",
name = "revive_recycled",
skip(self, uat, filter, eventid)
fields(uuid = ?eventid)
)]
@ -415,8 +426,8 @@ impl QueryServerWriteV1 {
// === IDM native types for modifications
#[instrument(
level = "trace",
name = "credentialset",
level = "info",
name = "credential_set",
skip(self, uat, uuid_or_name, sac, eventid)
fields(uuid = ?eventid)
)]
@ -561,7 +572,8 @@ impl QueryServerWriteV1 {
.remove_account_totp(&rte)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
SetCredentialRequest::WebauthnBegin(label) => {
/*
SetCredentialRequest::SecurityKeyBegin(label) => {
let wre = WebauthnInitRegisterEvent::from_parts(
// &idms_prox_write.qs_write,
ident,
@ -579,7 +591,7 @@ impl QueryServerWriteV1 {
.reg_account_webauthn_init(&wre, ct)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
SetCredentialRequest::WebauthnRegister(uuid, rpkc) => {
SetCredentialRequest::SecurityKeyRegister(uuid, rpkc) => {
let wre = WebauthnDoRegisterEvent::from_parts(
// &idms_prox_write.qs_write,
ident,
@ -598,7 +610,7 @@ impl QueryServerWriteV1 {
.reg_account_webauthn_complete(&wre)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
SetCredentialRequest::WebauthnRemove(label) => {
SetCredentialRequest::SecurityKeyRemove(label) => {
let rwe = RemoveWebauthnEvent::from_parts(
// &idms_prox_write.qs_write,
ident,
@ -616,6 +628,7 @@ impl QueryServerWriteV1 {
.remove_account_webauthn(&rwe)
.and_then(|r| idms_prox_write.commit().map(|_| r))
}
*/
SetCredentialRequest::BackupCodeGenerate => {
let gbe = GenerateBackupCodeEvent::from_parts(
// &idms_prox_write.qs_write,
@ -657,8 +670,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialupdate",
level = "info",
name = "idm_credential_update",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -710,8 +723,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialupdateintent",
level = "info",
name = "idm_credential_update_intent",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -762,8 +775,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialexchangeintent",
level = "info",
name = "idm_credential_exchange_intent",
skip(self, intent_token, eventid)
fields(uuid = ?eventid)
)]
@ -802,8 +815,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmcredentialupdatecommit",
level = "info",
name = "idm_credential_update_commit",
skip(self, session_token, eventid)
fields(uuid = ?eventid)
)]
@ -834,8 +847,40 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmaccountsetpassword",
level = "info",
name = "idm_credential_update_cancel",
skip(self, session_token, eventid)
fields(uuid = ?eventid)
)]
pub async fn handle_idmcredentialupdatecancel(
&self,
session_token: CUSessionToken,
eventid: Uuid,
) -> Result<(), OperationError> {
let ct = duration_from_epoch_now();
let mut idms_prox_write = self.idms.proxy_write_async(ct).await;
let res = spanned!("actors::v1_write::handle<IdmCredentialUpdateCancel>", {
let session_token = CredentialUpdateSessionToken {
token_enc: session_token.token,
};
idms_prox_write
.cancel_credential_update(session_token, ct)
.and_then(|tok| idms_prox_write.commit().map(|_| tok))
.map_err(|e| {
admin_error!(
err = ?e,
"Failed to begin commit_credential_cancel",
);
e
})
});
res
}
#[instrument(
level = "info",
name = "idm_account_set_password",
skip(self, uat, cleartext, eventid)
fields(uuid = ?eventid)
)]
@ -875,8 +920,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "regenerateradius",
level = "info",
name = "regenerate_radius_secret",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -931,8 +976,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "purgeattribute",
level = "info",
name = "purge_attribute",
skip(self, uat, uuid_or_name, attr, filter, eventid)
fields(uuid = ?eventid)
)]
@ -987,8 +1032,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "removeattributevalues",
level = "info",
name = "remove_attribute_values",
skip(self, uat, uuid_or_name, attr, values, filter, eventid)
fields(uuid = ?eventid)
)]
@ -1051,8 +1096,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "appendattribute",
level = "info",
name = "append_attribute",
skip(self, uat, uuid_or_name, attr, values, filter, eventid)
fields(uuid = ?eventid)
)]
@ -1080,8 +1125,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "setattribute",
level = "info",
name = "set_attribute",
skip(self, uat, uuid_or_name, attr, values, filter, eventid)
fields(uuid = ?eventid)
)]
@ -1112,8 +1157,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "sshkeycreate",
level = "info",
name = "ssh_key_create",
skip(self, uat, uuid_or_name, tag, key, filter, eventid)
fields(uuid = ?eventid)
)]
@ -1137,8 +1182,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmaccountpersonextend",
level = "info",
name = "idm_account_person_extend",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -1193,8 +1238,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmaccountpersonset",
level = "info",
name = "idm_account_person_set",
skip(self, uat, uuid_or_name, eventid)
fields(uuid = ?eventid)
)]
@ -1248,8 +1293,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmaccountunixextend",
level = "info",
name = "idm_account_unix_extend",
skip(self, uat, uuid_or_name, ux, eventid)
fields(uuid = ?eventid)
)]
@ -1295,8 +1340,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmgroupunixextend",
level = "info",
name = "idm_group_unix_extend",
skip(self, uat, uuid_or_name, gx, eventid)
fields(uuid = ?eventid)
)]
@ -1330,8 +1375,8 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
name = "idmaccountunixsetcred",
level = "info",
name = "idm_account_unix_set_cred",
skip(self, uat, uuid_or_name, cred, eventid)
fields(uuid = ?eventid)
)]
@ -1384,7 +1429,7 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_scopemap_create",
skip(self, uat, filter, eventid)
fields(uuid = ?eventid)
@ -1453,7 +1498,7 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "oauth2_scopemap_delete",
skip(self, uat, filter, eventid)
fields(uuid = ?eventid)
@ -1514,7 +1559,7 @@ impl QueryServerWriteV1 {
// ===== These below are internal only event types. =====
#[instrument(
level = "trace",
level = "info",
name = "purge_tombstone_event",
skip(self, msg)
fields(uuid = ?msg.eventid)
@ -1535,7 +1580,7 @@ impl QueryServerWriteV1 {
}
#[instrument(
level = "trace",
level = "info",
name = "purge_recycled_event",
skip(self, msg)
fields(uuid = ?msg.eventid)
@ -1556,7 +1601,7 @@ impl QueryServerWriteV1 {
pub(crate) async fn handle_delayedaction(&self, da: DelayedAction) {
let eventid = Uuid::new_v4();
let nspan = span!(Level::TRACE, "delayedaction", uuid = ?eventid);
let nspan = span!(Level::INFO, "process_delayed_action", uuid = ?eventid);
let _span = nspan.enter();
trace!("Begin delayed action ...");

View file

@ -3,7 +3,11 @@ use serde::{Deserialize, Serialize};
use std::time::Duration;
use url::Url;
use uuid::Uuid;
use webauthn_rs::proto::{COSEKey, UserVerificationPolicy};
use webauthn_rs_core::proto::{COSEKey, UserVerificationPolicy};
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
use webauthn_rs::prelude::SecurityKey as SecurityKeyV4;
#[derive(Serialize, Deserialize, Debug)]
pub struct DbCidV1 {
@ -100,41 +104,82 @@ impl std::fmt::Debug for DbBackupCodeV1 {
}
}
#[derive(Serialize, Deserialize, Debug)]
pub enum DbCredTypeV1 {
Pw,
GPw,
PwMfa,
// PwWn,
Wn,
// WnVer,
// PwWnVer,
}
// We have to allow this as serde expects &T for the fn sig.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!b
}
fn dbcred_type_default_pw() -> DbCredTypeV1 {
DbCredTypeV1::Pw
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DbCredV1 {
#[serde(default = "dbcred_type_default_pw")]
pub type_: DbCredTypeV1,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_code: Option<DbBackupCodeV1>,
pub claims: Vec<String>,
pub uuid: Uuid,
#[serde(tag = "type_")]
pub enum DbCred {
// These are the old v1 versions.
Pw {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_code: Option<DbBackupCodeV1>,
claims: Vec<String>,
uuid: Uuid,
},
GPw {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_code: Option<DbBackupCodeV1>,
claims: Vec<String>,
uuid: Uuid,
},
PwMfa {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_code: Option<DbBackupCodeV1>,
claims: Vec<String>,
uuid: Uuid,
},
Wn {
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_code: Option<DbBackupCodeV1>,
claims: Vec<String>,
uuid: Uuid,
},
TmpWn {
webauthn: Vec<(String, PasskeyV4)>,
uuid: Uuid,
},
#[serde(rename = "V2Pw")]
V2Password { password: DbPasswordV1, uuid: Uuid },
#[serde(rename = "V2GPw")]
V2GenPassword { password: DbPasswordV1, uuid: Uuid },
#[serde(rename = "V2PwMfa")]
V2PasswordMfa {
password: DbPasswordV1,
totp: Option<DbTotpV1>,
backup_code: Option<DbBackupCodeV1>,
webauthn: Vec<(String, SecurityKeyV4)>,
uuid: Uuid,
},
}
#[derive(Serialize, Deserialize, Debug)]
@ -142,7 +187,17 @@ pub struct DbValueCredV1 {
#[serde(rename = "t")]
pub tag: String,
#[serde(rename = "d")]
pub data: DbCredV1,
pub data: DbCred,
}
#[derive(Serialize, Deserialize, Debug)]
pub enum DbValuePasskeyV1 {
V4 { u: Uuid, t: String, k: PasskeyV4 },
}
#[derive(Serialize, Deserialize, Debug)]
pub enum DbValueDeviceKeyV1 {
V4 { u: Uuid, t: String, k: DeviceKeyV4 },
}
#[derive(Serialize, Deserialize, Debug)]
@ -312,6 +367,10 @@ pub enum DbValueSetV2 {
RestrictedString(Vec<String>),
#[serde(rename = "IT")]
IntentToken(Vec<(String, DbValueIntentTokenStateV1)>),
#[serde(rename = "PK")]
Passkey(Vec<DbValuePasskeyV1>),
#[serde(rename = "DK")]
DeviceKey(Vec<DbValueDeviceKeyV1>),
#[serde(rename = "TE")]
TrustedDeviceEnrollment(Vec<Uuid>),
#[serde(rename = "AS")]
@ -348,6 +407,8 @@ impl DbValueSetV2 {
DbValueSetV2::PublicBinary(set) => set.len(),
DbValueSetV2::RestrictedString(set) => set.len(),
DbValueSetV2::IntentToken(set) => set.len(),
DbValueSetV2::Passkey(set) => set.len(),
DbValueSetV2::DeviceKey(set) => set.len(),
DbValueSetV2::TrustedDeviceEnrollment(set) => set.len(),
DbValueSetV2::AuthSession(set) => set.len(),
}
@ -360,7 +421,41 @@ impl DbValueSetV2 {
#[cfg(test)]
mod tests {
use crate::be::dbvalue::DbCredV1;
use super::DbCred;
use super::{DbBackupCodeV1, DbPasswordV1, DbTotpV1, DbWebauthnV1};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
fn dbcred_type_default_pw() -> DbCredTypeV1 {
DbCredTypeV1::Pw
}
#[derive(Serialize, Deserialize, Debug)]
pub enum DbCredTypeV1 {
Pw,
GPw,
PwMfa,
// PwWn,
Wn,
// WnVer,
// PwWnVer,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DbCredV1 {
#[serde(default = "dbcred_type_default_pw")]
pub type_: DbCredTypeV1,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<DbPasswordV1>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webauthn: Option<Vec<DbWebauthnV1>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp: Option<DbTotpV1>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_code: Option<DbBackupCodeV1>,
pub claims: Vec<String>,
pub uuid: Uuid,
}
#[test]
fn test_dbcred_pre_totp_decode() {
@ -377,6 +472,16 @@ mod tests {
*/
let s = "o2hwYXNzd29yZKFmUEJLREYygwCBAIEAZmNsYWltc4BkdXVpZFAjkHFm4q5M86UcNRi4hBjN";
let data = base64::decode(s).unwrap();
let _dbcred: DbCredV1 = serde_cbor::from_slice(data.as_slice()).unwrap();
let dbcred: DbCredV1 = serde_cbor::from_slice(data.as_slice()).unwrap();
// Test converting to the new enum format
let x = vec![dbcred];
let json = serde_json::to_string(&x).unwrap();
eprintln!("{}", json);
let _e_dbcred: Vec<DbCred> = serde_json::from_str(&json).unwrap();
// assert!(dbcred == e_dbcred);
}
}

View file

@ -152,7 +152,7 @@ impl Configuration {
maximum_request: 256 * 1024, // 256k
// log type
// log path
// TODO #63: default true in prd
// default true in prd
secure_cookies: !cfg!(test),
tls_config: None,
cookie_key: [0; 32],

View file

@ -100,7 +100,9 @@ pub const JSON_IDM_SELF_ACP_READ_V1: &str = r#"{
"uuid",
"account_expire",
"account_valid_from",
"primary_credential"
"primary_credential",
"passkeys",
"devicekeys"
]
}
}"#;
@ -118,10 +120,10 @@ 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"
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password", "passkeys", "devicekeys"
],
"acp_modify_presentattr": [
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password"
"name", "displayname", "legalname", "radius_secret", "primary_credential", "ssh_publickey", "unix_password", "passkeys", "devicekeys"
]
}
}"#;
@ -432,7 +434,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"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "mail", "gidnumber", "account_expire", "account_valid_from", "passkeys", "devicekeys"
]
}
}"#;
@ -454,10 +456,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", "account_expire", "account_valid_from"
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from", "passkeys", "devicekeys"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from"
"name", "displayname", "ssh_publickey", "primary_credential", "mail", "account_expire", "account_valid_from", "passkeys", "devicekeys"
]
}
}"#;
@ -488,7 +490,9 @@ pub const JSON_IDM_ACP_ACCOUNT_MANAGE_PRIV_V1: &str = r#"{
"ssh_publickey",
"mail",
"account_expire",
"account_valid_from"
"account_valid_from",
"passkeys",
"devicekeys"
],
"acp_create_class": [
"object", "account"
@ -587,7 +591,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"
"class", "name", "spn", "uuid", "displayname", "ssh_publickey", "primary_credential", "memberof", "account_expire", "account_valid_from", "passkeys", "devicekeys"
]
}
}"#;
@ -609,10 +613,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", "account_expire", "account_valid_from"
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from", "passkeys", "devicekeys"
],
"acp_modify_presentattr": [
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from"
"name", "displayname", "ssh_publickey", "primary_credential", "account_expire", "account_valid_from", "passkeys", "devicekeys"
]
}
}"#;
@ -913,7 +917,9 @@ pub const JSON_IDM_ACP_HP_ACCOUNT_MANAGE_PRIV_V1: &str = r#"{
"primary_credential",
"ssh_publickey",
"account_expire",
"account_valid_from"
"account_valid_from",
"passkeys",
"devicekeys"
],
"acp_create_class": [
"object", "account"

View file

@ -928,6 +928,68 @@ pub const JSON_SCHEMA_ATTR_CREDENTIAL_UPDATE_INTENT_TOKEN: &str = r#"{
}
}"#;
pub const JSON_SCHEMA_ATTR_PASSKEYS: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"A set of registered passkeys"
],
"index": [
"EQUALITY"
],
"unique": [
"false"
],
"multivalue": [
"true"
],
"attributename": [
"passkeys"
],
"syntax": [
"PASSKEY"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000099"
]
}
}"#;
pub const JSON_SCHEMA_ATTR_DEVICEKEYS: &str = r#"{
"attrs": {
"class": [
"object",
"system",
"attributetype"
],
"description": [
"A set of registered device keys"
],
"index": [
"EQUALITY"
],
"unique": [
"false"
],
"multivalue": [
"true"
],
"attributename": [
"devicekeys"
],
"syntax": [
"DEVICEKEY"
],
"uuid": [
"00000000-0000-0000-0000-ffff00000100"
]
}
}"#;
// === classes ===
pub const JSON_SCHEMA_CLASS_PERSON: &str = r#"
@ -1031,6 +1093,8 @@ pub const JSON_SCHEMA_CLASS_ACCOUNT: &str = r#"
],
"systemmay": [
"primary_credential",
"passkeys",
"devicekeys",
"credential_update_intent_token",
"ssh_publickey",
"radius_secret",

View file

@ -171,6 +171,8 @@ pub const _UUID_SCHEMA_CLASS_OAUTH2_CONSENT_SCOPE_MAP: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000097");
pub const _UUID_SCHEMA_ATTR_DOMAIN_DISPLAY_NAME: Uuid =
uuid!("00000000-0000-0000-0000-ffff00000098");
pub const _UUID_SCHEMA_ATTR_PASSKEYS: Uuid = uuid!("00000000-0000-0000-0000-ffff00000099");
pub const _UUID_SCHEMA_ATTR_DEVICEKEYS: Uuid = uuid!("00000000-0000-0000-0000-ffff00000100");
// System and domain infos
// I'd like to strongly criticise william of the past for making poor choices about these allocations.

View file

@ -1,5 +1,5 @@
use crate::be::dbvalue::DbBackupCodeV1;
use crate::be::dbvalue::{DbCredTypeV1, DbCredV1, DbPasswordV1, DbWebauthnV1};
use crate::be::dbvalue::{DbCred, DbPasswordV1};
use hashbrown::HashMap as Map;
use hashbrown::HashSet;
use kanidm_proto::v1::{BackupCodesView, CredentialDetail, CredentialDetailType, OperationError};
@ -10,13 +10,15 @@ use rand::prelude::*;
use std::convert::TryFrom;
use std::time::{Duration, Instant};
use uuid::Uuid;
use webauthn_rs::proto::Credential as WebauthnCredential;
use webauthn_rs::proto::{Counter, CredentialID};
use webauthn_rs_core::proto::Credential as WebauthnCredential;
use webauthn_rs_core::proto::CredentialV3;
use webauthn_rs::prelude::{AuthenticationResult, Passkey, SecurityKey};
pub mod policy;
pub mod softlock;
pub mod totp;
pub mod webauthn;
use crate::credential::policy::CryptoPolicy;
use crate::credential::softlock::CredSoftLockPolicy;
@ -260,7 +262,6 @@ impl BackupCodes {
pub struct Credential {
// policy: Policy,
pub(crate) type_: CredentialType,
pub(crate) claims: Vec<String>,
// Uuid of Credential, used by auth session to lock this specific credential
// if required.
pub(crate) uuid: Uuid,
@ -277,30 +278,26 @@ pub enum CredentialType {
// Anonymous,
Password(Password),
GeneratedPassword(Password),
Webauthn(Map<String, WebauthnCredential>),
PasswordMfa(
Password,
Option<Totp>,
Map<String, WebauthnCredential>,
Map<String, SecurityKey>,
Option<BackupCodes>,
),
// PasswordWebauthn(Password, Map<String, WebauthnCredential>),
// WebauthnVerified(Map<String, WebauthnCredential>),
// PasswordWebauthnVerified(Password, Map<String, WebauthnCredential>),
Webauthn(Map<String, Passkey>),
}
impl From<&Credential> for CredentialDetail {
fn from(value: &Credential) -> Self {
CredentialDetail {
uuid: value.uuid,
claims: value.claims.clone(),
type_: match &value.type_ {
CredentialType::Password(_) => CredentialDetailType::Password,
CredentialType::GeneratedPassword(_) => CredentialDetailType::GeneratedPassword,
CredentialType::Webauthn(wan) => {
let mut labels: Vec<_> = wan.keys().cloned().collect();
labels.sort_unstable();
CredentialDetailType::Webauthn(labels)
CredentialDetailType::Passkey(labels)
}
CredentialType::PasswordMfa(_, totp, wan, backup_code) => {
let mut labels: Vec<_> = wan.keys().cloned().collect();
@ -316,73 +313,164 @@ impl From<&Credential> for CredentialDetail {
}
}
impl TryFrom<DbCredV1> for Credential {
impl TryFrom<DbCred> for Credential {
type Error = ();
fn try_from(value: DbCredV1) -> Result<Self, Self::Error> {
fn try_from(value: DbCred) -> Result<Self, Self::Error> {
// Work out what the policy is?
let DbCredV1 {
type_,
password,
webauthn,
totp,
backup_code,
claims,
uuid,
} = value;
let v_password = match password {
Some(dbp) => Some(Password::try_from(dbp)?),
None => None,
};
let v_totp = match totp {
Some(dbt) => Some(Totp::try_from(dbt)?),
None => None,
};
let v_webauthn = webauthn.map(|dbw| {
dbw.into_iter()
.map(|wc| {
(
wc.label,
WebauthnCredential {
cred_id: wc.id,
cred: wc.cred,
counter: wc.counter,
verified: wc.verified,
registration_policy: wc.registration_policy,
},
)
})
.collect()
});
let v_backup_code = match backup_code {
Some(dbb) => Some(BackupCodes::try_from(dbb)?),
None => None,
};
let type_ = match type_ {
DbCredTypeV1::Pw => v_password.map(CredentialType::Password),
DbCredTypeV1::GPw => v_password.map(CredentialType::GeneratedPassword),
// In the future this could use .zip
DbCredTypeV1::PwMfa => match (v_password, v_webauthn) {
(Some(pw), Some(wn)) => {
Some(CredentialType::PasswordMfa(pw, v_totp, wn, v_backup_code))
match value {
DbCred::V2Password {
password: db_password,
uuid,
}
| DbCred::Pw {
password: Some(db_password),
webauthn: None,
totp: None,
backup_code: None,
claims: _,
uuid,
} => {
let v_password = Password::try_from(db_password)?;
let type_ = CredentialType::Password(v_password);
if type_.is_valid() {
Ok(Credential { type_, uuid })
} else {
Err(())
}
_ => None,
},
DbCredTypeV1::Wn => v_webauthn.map(CredentialType::Webauthn),
}
.filter(|v| v.is_valid())
.ok_or(())?;
}
DbCred::V2GenPassword {
password: db_password,
uuid,
}
| DbCred::GPw {
password: Some(db_password),
webauthn: None,
totp: None,
backup_code: None,
claims: _,
uuid,
} => {
let v_password = Password::try_from(db_password)?;
let type_ = CredentialType::GeneratedPassword(v_password);
if type_.is_valid() {
Ok(Credential { type_, uuid })
} else {
Err(())
}
}
DbCred::PwMfa {
password: Some(db_password),
webauthn: Some(db_webauthn),
totp,
backup_code,
claims: _,
uuid,
} => {
let v_password = Password::try_from(db_password)?;
Ok(Credential {
type_,
claims,
uuid,
})
let v_totp = match totp {
Some(dbt) => Some(Totp::try_from(dbt)?),
None => None,
};
let v_webauthn = db_webauthn
.into_iter()
.map(|wc| {
(
wc.label,
SecurityKey::from(WebauthnCredential::from(CredentialV3 {
cred_id: wc.id,
cred: wc.cred,
counter: wc.counter,
verified: wc.verified,
registration_policy: wc.registration_policy,
})),
)
})
.collect();
let v_backup_code = match backup_code {
Some(dbb) => Some(BackupCodes::try_from(dbb)?),
None => None,
};
let type_ =
CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code);
if type_.is_valid() {
Ok(Credential { type_, uuid })
} else {
Err(())
}
}
DbCred::Wn {
password: None,
webauthn: Some(db_webauthn),
totp: None,
backup_code: None,
claims: _,
uuid,
} => {
let v_webauthn = db_webauthn
.into_iter()
.map(|wc| {
(
wc.label,
Passkey::from(WebauthnCredential::from(CredentialV3 {
cred_id: wc.id,
cred: wc.cred,
counter: wc.counter,
verified: wc.verified,
registration_policy: wc.registration_policy,
})),
)
})
.collect();
let type_ = CredentialType::Webauthn(v_webauthn);
if type_.is_valid() {
Ok(Credential { type_, uuid })
} else {
Err(())
}
}
DbCred::TmpWn { .. } => {
todo!()
}
DbCred::V2PasswordMfa {
password: db_password,
totp: Some(db_totp),
backup_code,
webauthn: db_webauthn,
uuid,
} => {
let v_password = Password::try_from(db_password)?;
let v_totp = Some(Totp::try_from(db_totp)?);
let v_backup_code = match backup_code {
Some(dbb) => Some(BackupCodes::try_from(dbb)?),
None => None,
};
let v_webauthn = db_webauthn.into_iter().collect();
let type_ =
CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code);
if type_.is_valid() {
Ok(Credential { type_, uuid })
} else {
Err(())
}
}
_ => {
error!("Database content may be corrupt - invalid credential");
Err(())
}
}
}
}
@ -404,12 +492,11 @@ impl Credential {
}
/// Create a new credential that contains a CredentialType::Webauthn
pub fn new_webauthn_only(label: String, cred: WebauthnCredential) -> Self {
pub fn new_passkey_only(label: String, cred: Passkey) -> Self {
let mut webauthn_map = Map::new();
webauthn_map.insert(label, cred);
Credential {
type_: CredentialType::Webauthn(webauthn_map),
claims: Vec::new(),
uuid: Uuid::new_v4(),
}
}
@ -427,10 +514,10 @@ impl Credential {
/// Extend this credential with another alternate webauthn credential. This is especially
/// useful for `PasswordMfa` where you can have many webauthn credentials and a password
/// generally so that one is a backup.
pub fn append_webauthn(
pub fn append_securitykey(
&self,
label: String,
cred: WebauthnCredential,
cred: SecurityKey,
) -> Result<Self, OperationError> {
let type_ = match &self.type_ {
CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => {
@ -448,32 +535,25 @@ impl Credential {
}
CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
}
CredentialType::Webauthn(map) => {
let mut nmap = map.clone();
if nmap.insert(label.clone(), cred).is_some() {
return Err(OperationError::InvalidAttribute(format!(
"Webauthn label '{:?}' already exists",
label
)));
}
CredentialType::Webauthn(nmap)
}
// Ignore
CredentialType::Webauthn(map) => CredentialType::Webauthn(map.clone()),
};
// Check stuff
Ok(Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
})
}
/// Remove a webauthn token identified by `label` from this Credential.
pub fn remove_webauthn(&self, label: &str) -> Result<Self, OperationError> {
pub fn remove_securitykey(&self, label: &str) -> Result<Self, OperationError> {
let type_ = match &self.type_ {
CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => {
CredentialType::Password(_)
| CredentialType::GeneratedPassword(_)
| CredentialType::Webauthn(_) => {
return Err(OperationError::InvalidAttribute(
"Webauthn is not present on this credential".to_string(),
"SecurityKey is not present on this credential".to_string(),
));
}
CredentialType::PasswordMfa(pw, totp, map, backup_code) => {
@ -500,28 +580,11 @@ impl Credential {
CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
}
}
CredentialType::Webauthn(map) => {
let mut nmap = map.clone();
if nmap.remove(label).is_none() {
return Err(OperationError::InvalidAttribute(format!(
"Removing Webauthn token with label '{:?}': does not exist",
label
)));
}
if nmap.is_empty() {
return Err(OperationError::InvalidAttribute(format!(
"Removing Webauthn token with label '{:?}': unable to remove, this is the last webauthn token",
label
)));
}
CredentialType::Webauthn(nmap)
}
};
// Check stuff
Ok(Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
})
}
@ -529,68 +592,60 @@ impl Credential {
#[allow(clippy::ptr_arg)]
/// After a successful authentication with Webauthn, we need to advance the credentials
/// counter value to prevent certain classes of replay attacks.
pub fn update_webauthn_counter(
pub fn update_webauthn_properties(
&self,
cid: &CredentialID,
counter: Counter,
auth_result: &AuthenticationResult,
) -> Result<Option<Self>, OperationError> {
let nmap = match &self.type_ {
CredentialType::Password(_pw) | CredentialType::GeneratedPassword(_pw) => {
// No action required
return Ok(None);
}
CredentialType::PasswordMfa(_, _, map, _) | CredentialType::Webauthn(map) => map
.iter()
.find_map(|(k, v)| {
if &v.cred_id == cid && v.counter < counter {
Some(k)
} else {
None
}
})
.map(|label| {
let mut webauthn_map = map.clone();
if let Some(cred) = webauthn_map.get_mut(label) {
cred.counter = counter
};
webauthn_map
}),
};
let map = match nmap {
Some(map) => map,
None => {
// No action needed.
return Ok(None);
}
};
let type_ = match &self.type_ {
CredentialType::Password(_pw) | CredentialType::GeneratedPassword(_pw) => {
// Should not be possible!
return Err(OperationError::InvalidState);
// -- this does occur when we have mixed pw/passkey
// and we need to do an update, so we just mask this no Ok(None).
// return Err(OperationError::InvalidState);
return Ok(None);
}
CredentialType::Webauthn(_) => CredentialType::Webauthn(map),
CredentialType::PasswordMfa(pw, totp, _, backup_code) => {
CredentialType::PasswordMfa(pw.clone(), totp.clone(), map, backup_code.clone())
CredentialType::Webauthn(map) => {
let mut nmap = map.clone();
nmap.values_mut().for_each(|pk| {
pk.update_credential(auth_result);
});
CredentialType::Webauthn(nmap)
}
CredentialType::PasswordMfa(pw, totp, map, backup_code) => {
let mut nmap = map.clone();
nmap.values_mut().for_each(|sk| {
sk.update_credential(auth_result);
});
CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
}
};
Ok(Some(Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
}))
}
/// Get a reference to the contained webuthn credentials, if any.
pub fn webauthn_ref(&self) -> Result<&Map<String, WebauthnCredential>, OperationError> {
pub fn securitykey_ref(&self) -> Result<&Map<String, SecurityKey>, OperationError> {
match &self.type_ {
CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => Err(
OperationError::InvalidAccountState("non-webauthn cred type?".to_string()),
),
CredentialType::PasswordMfa(_, _, map, _) | CredentialType::Webauthn(map) => Ok(map),
CredentialType::Webauthn(_)
| CredentialType::Password(_)
| CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
"non-webauthn cred type?".to_string(),
)),
CredentialType::PasswordMfa(_, _, map, _) => Ok(map),
}
}
pub fn passkey_ref(&self) -> Result<&Map<String, Passkey>, OperationError> {
match &self.type_ {
CredentialType::PasswordMfa(_, _, _, _)
| CredentialType::Password(_)
| CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
"non-webauthn cred type?".to_string(),
)),
CredentialType::Webauthn(map) => Ok(map),
}
}
@ -612,66 +667,26 @@ impl Credential {
}
/// Extract this credential into it's Serialisable Database form, ready for persistence.
pub fn to_db_valuev1(&self) -> DbCredV1 {
let claims = self.claims.clone();
pub fn to_db_valuev1(&self) -> DbCred {
let uuid = self.uuid;
match &self.type_ {
CredentialType::Password(pw) => DbCredV1 {
type_: DbCredTypeV1::Pw,
password: Some(pw.to_dbpasswordv1()),
webauthn: None,
totp: None,
backup_code: None,
claims,
CredentialType::Password(pw) => DbCred::V2Password {
password: pw.to_dbpasswordv1(),
uuid,
},
CredentialType::GeneratedPassword(pw) => DbCredV1 {
type_: DbCredTypeV1::GPw,
password: Some(pw.to_dbpasswordv1()),
webauthn: None,
totp: None,
backup_code: None,
claims,
CredentialType::GeneratedPassword(pw) => DbCred::V2GenPassword {
password: pw.to_dbpasswordv1(),
uuid,
},
CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCredV1 {
type_: DbCredTypeV1::PwMfa,
password: Some(pw.to_dbpasswordv1()),
webauthn: Some(
map.iter()
.map(|(k, v)| DbWebauthnV1 {
label: k.clone(),
id: v.cred_id.clone(),
cred: v.cred.clone(),
counter: v.counter,
verified: v.verified,
registration_policy: v.registration_policy,
})
.collect(),
),
CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCred::V2PasswordMfa {
password: pw.to_dbpasswordv1(),
totp: totp.as_ref().map(|t| t.to_dbtotpv1()),
backup_code: backup_code.as_ref().map(|b| b.to_dbbackupcodev1()),
claims,
webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
uuid,
},
CredentialType::Webauthn(map) => DbCredV1 {
type_: DbCredTypeV1::Wn,
password: None,
webauthn: Some(
map.iter()
.map(|(k, v)| DbWebauthnV1 {
label: k.clone(),
id: v.cred_id.clone(),
cred: v.cred.clone(),
counter: v.counter,
verified: v.verified,
registration_policy: v.registration_policy,
})
.collect(),
),
totp: None,
backup_code: None,
claims,
CredentialType::Webauthn(map) => DbCred::TmpWn {
webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
uuid,
},
}
@ -685,13 +700,11 @@ impl Credential {
CredentialType::PasswordMfa(_, totp, wan, backup_code) => {
CredentialType::PasswordMfa(pw, totp.clone(), wan.clone(), backup_code.clone())
}
CredentialType::Webauthn(wan) => {
CredentialType::PasswordMfa(pw, None, wan.clone(), None)
}
// Ignore
CredentialType::Webauthn(wan) => CredentialType::Webauthn(wan.clone()),
};
Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
}
}
@ -715,7 +728,6 @@ impl Credential {
};
Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
}
}
@ -734,7 +746,6 @@ impl Credential {
};
Credential {
type_,
claims: self.claims.clone(),
uuid: self.uuid,
}
}
@ -742,7 +753,6 @@ impl Credential {
pub(crate) fn new_from_generatedpassword(pw: Password) -> Self {
Credential {
type_: CredentialType::GeneratedPassword(pw),
claims: Vec::new(),
uuid: Uuid::new_v4(),
}
}
@ -750,7 +760,6 @@ impl Credential {
pub(crate) fn new_from_password(pw: Password) -> Self {
Credential {
type_: CredentialType::Password(pw),
claims: Vec::new(),
uuid: Uuid::new_v4(),
}
}
@ -786,7 +795,6 @@ impl Credential {
wan.clone(),
Some(backup_codes),
),
claims: self.claims.clone(),
uuid: self.uuid,
}),
_ => Err(OperationError::InvalidAccountState(
@ -806,7 +814,6 @@ impl Credential {
backup_codes.remove(code_to_remove);
Ok(Credential {
type_: CredentialType::PasswordMfa(pw, totp, wan, Some(backup_codes)),
claims: self.claims.clone(),
uuid: self.uuid,
})
}
@ -825,7 +832,6 @@ impl Credential {
match &self.type_ {
CredentialType::PasswordMfa(pw, totp, wan, _) => Ok(Credential {
type_: CredentialType::PasswordMfa(pw.clone(), totp.clone(), wan.clone(), None),
claims: self.claims.clone(),
uuid: self.uuid,
}),
_ => Err(OperationError::InvalidAccountState(
@ -851,26 +857,6 @@ impl Credential {
)),
}
}
/*
pub fn add_claim(&mut self) {
}
pub fn remove_claim(&mut self) {
}
*/
/*
pub fn modify_password(&mut self) {
// Change the password
}
pub fn add_webauthn_token() {
}
pub fn remove_webauthn_token() {
}
*/
}
impl CredentialType {

View file

@ -1,26 +0,0 @@
use url::Url;
use webauthn_rs::WebauthnConfig;
pub struct WebauthnDomainConfig {
pub rp_name: String,
pub origin: Url,
pub rp_id: String,
}
impl WebauthnConfig for WebauthnDomainConfig {
fn get_relying_party_name(&self) -> &str {
self.rp_name.as_str()
}
fn get_origin(&self) -> &Url {
&self.origin
}
fn get_relying_party_id(&self) -> &str {
self.rp_id.as_str()
}
fn allow_subdomains_origin(&self) -> bool {
true
}
}

View file

@ -55,6 +55,10 @@ use std::sync::Arc;
use time::OffsetDateTime;
use uuid::Uuid;
use std::collections::BTreeMap;
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
// use std::convert::TryFrom;
// use std::str::FromStr;
@ -1887,6 +1891,18 @@ impl<VALID, STATE> Entry<VALID, STATE> {
.and_then(|vs| vs.to_credential_single())
}
#[inline(always)]
/// Get the set of passkeys on this account, if any are present.
pub fn get_ava_passkeys(&self, attr: &str) -> Option<&BTreeMap<Uuid, (String, PasskeyV4)>> {
self.attrs.get(attr).and_then(|vs| vs.as_passkey_map())
}
#[inline(always)]
/// Get the set of devicekeys on this account, if any are present.
pub fn get_ava_devicekeys(&self, attr: &str) -> Option<&BTreeMap<Uuid, (String, DeviceKeyV4)>> {
self.attrs.get(attr).and_then(|vs| vs.as_devicekey_map())
}
#[inline(always)]
/// Return a single secret value, if valid to transform this value.
pub fn get_ava_single_secret(&self, attr: &str) -> Option<&str> {

View file

@ -39,6 +39,9 @@ use uuid::Uuid;
#[cfg(test)]
use std::sync::Arc;
#[cfg(test)]
use webauthn_rs::prelude::PublicKeyCredential;
#[derive(Debug)]
pub struct SearchResult {
entries: Vec<ProtoEntry>,
@ -764,6 +767,14 @@ impl AuthEventStep {
cred: AuthCredential::BackupCode(code.to_string()),
})
}
#[cfg(test)]
pub fn cred_step_passkey(sid: Uuid, passkey_response: PublicKeyCredential) -> Self {
AuthEventStep::Cred(AuthEventStepCred {
sessionid: sid,
cred: AuthCredential::Passkey(passkey_response),
})
}
}
#[derive(Debug)]
@ -836,6 +847,14 @@ impl AuthEvent {
step: AuthEventStep::cred_step_backup_code(sid, code),
}
}
#[cfg(test)]
pub fn cred_step_passkey(sid: Uuid, passkey_response: PublicKeyCredential) -> Self {
AuthEvent {
ident: None,
step: AuthEventStep::cred_step_passkey(sid, passkey_response),
}
}
}
// Probably should be a struct with the session id present.

View file

@ -6,6 +6,10 @@ use kanidm_proto::v1::OperationError;
use kanidm_proto::v1::{AuthType, UserAuthToken};
use kanidm_proto::v1::{BackupCodesView, CredentialStatus};
use webauthn_rs::prelude::CredentialID;
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
use crate::constants::UUID_ANONYMOUS;
use crate::credential::policy::CryptoPolicy;
use crate::credential::totp::Totp;
@ -18,8 +22,7 @@ use std::collections::BTreeMap;
use std::time::Duration;
use time::OffsetDateTime;
use uuid::Uuid;
use webauthn_rs::proto::Credential as WebauthnCredential;
use webauthn_rs::proto::{Counter, CredentialID};
use webauthn_rs::prelude::AuthenticationResult;
lazy_static! {
static ref PVCLASS_ACCOUNT: PartialValue = PartialValue::new_class("account");
@ -53,6 +56,16 @@ macro_rules! try_from_entry {
.get_ava_single_credential("primary_credential")
.map(|v| v.clone());
let passkeys = $value
.get_ava_passkeys("passkeys")
.cloned()
.unwrap_or_default();
let devicekeys = $value
.get_ava_devicekeys("devicekeys")
.cloned()
.unwrap_or_default();
let spn = $value.get_ava_single_proto_string("spn").ok_or(
OperationError::InvalidAccountState("Missing attribute: spn".to_string()),
)?;
@ -88,6 +101,8 @@ macro_rules! try_from_entry {
displayname,
groups,
primary,
passkeys,
devicekeys,
valid_from,
expire,
radius_secret,
@ -114,6 +129,8 @@ pub(crate) struct Account {
#[allow(dead_code)]
pub groups: Vec<Group>,
pub primary: Option<Credential>,
pub passkeys: BTreeMap<Uuid, (String, PasskeyV4)>,
pub devicekeys: BTreeMap<Uuid, (String, DeviceKeyV4)>,
pub valid_from: Option<OffsetDateTime>,
pub expire: Option<OffsetDateTime>,
pub radius_secret: Option<String>,
@ -334,58 +351,42 @@ impl Account {
}
}
pub(crate) fn gen_webauthn_mod(
&self,
label: String,
cred: WebauthnCredential,
) -> Result<ModifyList<ModifyInvalid>, OperationError> {
let ncred = match &self.primary {
Some(primary) => primary.append_webauthn(label, cred)?,
None => Credential::new_webauthn_only(label, cred),
};
let vcred = Value::new_credential("primary", ncred);
Ok(ModifyList::new_purge_and_set("primary_credential", vcred))
}
pub(crate) fn gen_webauthn_remove_mod(
&self,
label: &str,
) -> Result<ModifyList<ModifyInvalid>, OperationError> {
match &self.primary {
// Change the cred
Some(primary) => {
let ncred = primary.remove_webauthn(label)?;
let vcred = Value::new_credential("primary", ncred);
Ok(ModifyList::new_purge_and_set("primary_credential", vcred))
}
None => {
// No credential exists, we can't remove what is not real.
Err(OperationError::InvalidState)
}
}
}
#[allow(clippy::ptr_arg)]
pub(crate) fn gen_webauthn_counter_mod(
&self,
cid: &CredentialID,
counter: Counter,
&mut self,
auth_result: &AuthenticationResult,
) -> Result<Option<ModifyList<ModifyInvalid>>, OperationError> {
//
let mut ml = Vec::with_capacity(2);
// Where is the credential we need to update?
let opt_ncred = match self.primary.as_ref() {
Some(primary) => primary.update_webauthn_counter(cid, counter)?,
Some(primary) => primary.update_webauthn_properties(auth_result)?,
None => None,
};
match opt_ncred {
Some(ncred) => {
let vcred = Value::new_credential("primary", ncred);
Ok(Some(ModifyList::new_purge_and_set(
"primary_credential",
vcred,
)))
if let Some(ncred) = opt_ncred {
let vcred = Value::new_credential("primary", ncred);
ml.push(Modify::Purged("primary_credential".into()));
ml.push(Modify::Present("primary_credential".into(), vcred));
}
// Is it a passkey?
self.passkeys.iter_mut().for_each(|(u, (t, k))| {
if let Some(true) = k.update_credential(auth_result) {
ml.push(Modify::Removed(
"passkeys".into(),
PartialValue::Passkey(*u),
));
ml.push(Modify::Present(
"passkeys".into(),
Value::Passkey(*u, t.clone(), k.clone()),
));
}
None => Ok(None),
});
if ml.is_empty() {
Ok(None)
} else {
Ok(Some(ModifyList::new_list(ml)))
}
}
@ -489,6 +490,11 @@ impl Account {
.ok_or(OperationError::InvalidState)
.and_then(|cred| cred.get_backup_code_view())
}
pub(crate) fn existing_credential_id_list(&self) -> Option<Vec<CredentialID>> {
// TODO!!!
None
}
}
// Need to also add a "to UserAuthToken" ...

View file

@ -18,14 +18,19 @@ use crate::idm::delayed::{DelayedAction, PasswordUpgrade, WebauthnCounterIncreme
// use crossbeam::channel::Sender;
use tokio::sync::mpsc::UnboundedSender as Sender;
use crate::credential::webauthn::WebauthnDomainConfig;
use std::time::Duration;
use uuid::Uuid;
// use webauthn_rs::proto::Credential as WebauthnCredential;
use compact_jwt::{Jws, JwsSigner};
use std::collections::BTreeMap;
pub use std::collections::BTreeSet as Set;
use webauthn_rs::proto::RequestChallengeResponse;
use webauthn_rs::{AuthenticationState, Webauthn};
use std::convert::TryFrom;
use webauthn_rs::prelude::{
PasskeyAuthentication, RequestChallengeResponse, SecurityKeyAuthentication, Webauthn,
};
// use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
// Each CredHandler takes one or more credentials and determines if the
// handlers requirements can be 100% fufilled. This is where MFA or other
@ -62,7 +67,7 @@ struct CredMfa {
pw: Password,
pw_state: CredVerifyState,
totp: Option<Totp>,
wan: Option<(RequestChallengeResponse, AuthenticationState)>,
wan: Option<(RequestChallengeResponse, SecurityKeyAuthentication)>,
backup_code: Option<BackupCodes>,
mfa_state: CredVerifyState,
}
@ -71,7 +76,7 @@ struct CredMfa {
/// The state of a webauthn credential during authentication
struct CredWebauthn {
chal: RequestChallengeResponse,
wan_state: AuthenticationState,
wan_state: PasskeyAuthentication,
state: CredVerifyState,
}
@ -81,26 +86,27 @@ struct CredWebauthn {
#[derive(Clone, Debug)]
enum CredHandler {
Anonymous,
// AppPassword (?)
Password(Password, bool),
PasswordMfa(Box<CredMfa>),
Webauthn(CredWebauthn),
// Webauthn + Password
Passkey(CredWebauthn),
}
impl CredHandler {
impl TryFrom<(&Credential, &Webauthn)> for CredHandler {
type Error = ();
/// Given a credential and some external configuration, Generate the credential handler
/// that will be used for this session. This credential handler is a "self contained"
/// unit that defines what is possible to use during this authentication session to prevent
/// inconsistency.
fn try_from(c: &Credential, webauthn: &Webauthn<WebauthnDomainConfig>) -> Result<Self, ()> {
fn try_from((c, webauthn): (&Credential, &Webauthn)) -> Result<Self, Self::Error> {
match &c.type_ {
CredentialType::Password(pw) => Ok(CredHandler::Password(pw.clone(), false)),
CredentialType::GeneratedPassword(pw) => Ok(CredHandler::Password(pw.clone(), true)),
CredentialType::PasswordMfa(pw, maybe_totp, maybe_wan, maybe_backup_code) => {
let wan = if !maybe_wan.is_empty() {
let sks: Vec<_> = maybe_wan.values().cloned().collect();
webauthn
.generate_challenge_authenticate(maybe_wan.values().cloned().collect())
.start_securitykey_authentication(&sks)
.map(Some)
.map_err(|e| {
security_info!(
@ -129,23 +135,61 @@ impl CredHandler {
Ok(CredHandler::PasswordMfa(cmfa))
}
CredentialType::Webauthn(wan) => webauthn
.generate_challenge_authenticate(wan.values().cloned().collect())
.map(|(chal, wan_state)| {
CredHandler::Webauthn(CredWebauthn {
chal,
wan_state,
state: CredVerifyState::Init,
CredentialType::Webauthn(wan) => {
let pks: Vec<_> = wan.values().cloned().collect();
webauthn
.start_passkey_authentication(&pks)
.map(|(chal, wan_state)| {
CredHandler::Passkey(CredWebauthn {
chal,
wan_state,
state: CredVerifyState::Init,
})
})
})
.map_err(|e| {
security_info!(?e, "Unable to create webauthn authentication challenge");
// maps to unit.
}),
.map_err(|e| {
security_info!(?e, "Unable to create webauthn authentication challenge");
// maps to unit.
})
}
}
}
}
impl TryFrom<(&BTreeMap<Uuid, (String, PasskeyV4)>, &Webauthn)> for CredHandler {
type Error = ();
/// Given a credential and some external configuration, Generate the credential handler
/// that will be used for this session. This credential handler is a "self contained"
/// unit that defines what is possible to use during this authentication session to prevent
/// inconsistency.
fn try_from(
(wan, webauthn): (&BTreeMap<Uuid, (String, PasskeyV4)>, &Webauthn),
) -> Result<Self, Self::Error> {
if wan.is_empty() {
security_info!("Account does not have any passkeys");
return Err(());
}
let pks: Vec<_> = wan.values().map(|(_, k)| k).cloned().collect();
webauthn
.start_passkey_authentication(&pks)
.map(|(chal, wan_state)| {
CredHandler::Passkey(CredWebauthn {
chal,
wan_state,
state: CredVerifyState::Init,
})
})
.map_err(|e| {
security_info!(
?e,
"Unable to create passkey webauthn authentication challenge"
);
// maps to unit.
})
}
}
impl CredHandler {
/// Determine if this password factor requires an upgrade of it's cryptographic type. If
/// so, send an asynchronous event into the queue that will allow the password to have it's
@ -232,7 +276,7 @@ impl CredHandler {
cred: &AuthCredential,
ts: &Duration,
pw_mfa: &mut CredMfa,
webauthn: &Webauthn<WebauthnDomainConfig>,
webauthn: &Webauthn,
who: Uuid,
async_tx: &Sender<DelayedAction>,
pw_badlist_set: Option<&HashSet<String>>,
@ -246,24 +290,23 @@ impl CredHandler {
pw_mfa.wan.as_ref(),
pw_mfa.backup_code.as_ref(),
) {
(AuthCredential::Webauthn(resp), _, Some((_, wan_state)), _) => {
match webauthn.authenticate_credential(resp, wan_state) {
Ok((cid, auth_data)) => {
(AuthCredential::SecurityKey(resp), _, Some((_, wan_state)), _) => {
match webauthn.finish_securitykey_authentication(resp, wan_state) {
Ok(auth_result) => {
pw_mfa.mfa_state = CredVerifyState::Success;
// Success. Determine if we need to update the counter
// async from r.
if auth_data.counter != 0 {
if auth_result.needs_update() {
// Do async
if let Err(_e) =
async_tx.send(DelayedAction::WebauthnCounterIncrement(
WebauthnCounterIncrement {
target_uuid: who,
cid: cid.clone(),
counter: auth_data.counter,
auth_result,
},
))
{
admin_warn!("unable to queue delayed webauthn counter increment, continuing ... ");
admin_warn!("unable to queue delayed webauthn property update, continuing ... ");
};
};
CredState::Continue(vec![AuthAllowed::Password])
@ -369,7 +412,7 @@ impl CredHandler {
pub fn validate_webauthn(
cred: &AuthCredential,
wan_cred: &mut CredWebauthn,
webauthn: &Webauthn<WebauthnDomainConfig>,
webauthn: &Webauthn,
who: Uuid,
async_tx: &Sender<DelayedAction>,
) -> CredState {
@ -379,26 +422,25 @@ impl CredHandler {
}
match cred {
AuthCredential::Webauthn(resp) => {
AuthCredential::Passkey(resp) => {
// lets see how we go.
match webauthn.authenticate_credential(resp, &wan_cred.wan_state) {
Ok((cid, auth_data)) => {
match webauthn.finish_passkey_authentication(resp, &wan_cred.wan_state) {
Ok(auth_result) => {
wan_cred.state = CredVerifyState::Success;
// Success. Determine if we need to update the counter
// async from r.
if auth_data.counter != 0 {
if auth_result.needs_update() {
// Do async
if let Err(_e) = async_tx.send(DelayedAction::WebauthnCounterIncrement(
WebauthnCounterIncrement {
target_uuid: who,
cid: cid.clone(),
counter: auth_data.counter,
auth_result,
},
)) {
admin_warn!("unable to queue delayed webauthn counter increment, continuing ... ");
admin_warn!("unable to queue delayed webauthn property update, continuing ... ");
};
};
CredState::Success(AuthType::Webauthn)
CredState::Success(AuthType::Passkey)
}
Err(e) => {
wan_cred.state = CredVerifyState::Fail;
@ -425,7 +467,7 @@ impl CredHandler {
ts: &Duration,
who: Uuid,
async_tx: &Sender<DelayedAction>,
webauthn: &Webauthn<WebauthnDomainConfig>,
webauthn: &Webauthn,
pw_badlist_set: Option<&HashSet<String>>,
) -> CredState {
match self {
@ -442,7 +484,7 @@ impl CredHandler {
async_tx,
pw_badlist_set,
),
CredHandler::Webauthn(ref mut wan_cred) => {
CredHandler::Passkey(ref mut wan_cred) => {
Self::validate_webauthn(cred, wan_cred, webauthn, who, async_tx)
}
}
@ -463,10 +505,10 @@ impl CredHandler {
pw_mfa
.wan
.iter()
.map(|(chal, _)| AuthAllowed::Webauthn(chal.clone())),
.map(|(chal, _)| AuthAllowed::SecurityKey(chal.clone())),
)
.collect(),
CredHandler::Webauthn(webauthn) => vec![AuthAllowed::Webauthn(webauthn.chal.clone())],
CredHandler::Passkey(webauthn) => vec![AuthAllowed::Passkey(webauthn.chal.clone())],
}
}
@ -476,7 +518,7 @@ impl CredHandler {
(CredHandler::Anonymous, AuthMech::Anonymous)
| (CredHandler::Password(_, _), AuthMech::Password)
| (CredHandler::PasswordMfa(_), AuthMech::PasswordMfa)
| (CredHandler::Webauthn(_), AuthMech::Webauthn) => true,
| (CredHandler::Passkey(_), AuthMech::Passkey) => true,
(_, _) => false,
}
}
@ -486,7 +528,7 @@ impl CredHandler {
CredHandler::Anonymous => AuthMech::Anonymous,
CredHandler::Password(_, _) => AuthMech::Password,
CredHandler::PasswordMfa(_) => AuthMech::PasswordMfa,
CredHandler::Webauthn(_) => AuthMech::Webauthn,
CredHandler::Passkey(_) => AuthMech::Passkey,
}
}
}
@ -533,11 +575,7 @@ impl AuthSession {
/// Create a new auth session, based on the available credential handlers of the account.
/// the session is a whole encapsulated unit of what we need to proceed, so that subsequent
/// or interleved write operations do not cause inconsistency in this process.
pub fn new(
account: Account,
webauthn: &Webauthn<WebauthnDomainConfig>,
ct: Duration,
) -> (Option<Self>, AuthState) {
pub fn new(account: Account, webauthn: &Webauthn, 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.
@ -548,25 +586,31 @@ impl AuthSession {
if account.is_anonymous() {
AuthSessionState::Init(vec![CredHandler::Anonymous])
} else {
// Now we see if they have one ...
match &account.primary {
Some(cred) => {
// TODO: Make it possible to have multiple creds.
// Probably means new authsession has to be failable
CredHandler::try_from(cred, webauthn)
.map(|ch| AuthSessionState::Init(vec![ch]))
.unwrap_or_else(|_| {
security_critical!(
"corrupt credentials, unable to start credhandler"
);
AuthSessionState::Denied("invalid credential state")
})
}
None => {
security_info!("account has no primary credentials");
AuthSessionState::Denied("invalid credential state")
// What's valid to use in this context?
let mut handlers = Vec::new();
if let Some(cred) = &account.primary {
// TODO: Make it possible to have multiple creds.
// Probably means new authsession has to be failable
if let Ok(ch) = CredHandler::try_from((cred, webauthn)) {
handlers.push(ch);
} else {
security_critical!(
"corrupt credentials, unable to start primary credhandler"
);
}
}
if let Ok(ch) = CredHandler::try_from((&account.passkeys, webauthn)) {
handlers.push(ch);
};
if handlers.is_empty() {
security_info!("account has no primary credentials");
AuthSessionState::Denied("invalid credential state")
} else {
AuthSessionState::Init(handlers)
}
}
} else {
security_info!("account expired");
@ -591,8 +635,16 @@ impl AuthSession {
}
}
pub fn get_account(&self) -> &Account {
&self.account
pub fn get_credential_uuid(&self) -> Result<Option<Uuid>, OperationError> {
match &self.state {
AuthSessionState::InProgress(CredHandler::Password(_, _))
| AuthSessionState::InProgress(CredHandler::PasswordMfa(_)) => {
Ok(self.account.primary_cred_uuid())
}
AuthSessionState::InProgress(CredHandler::Anonymous)
| AuthSessionState::InProgress(CredHandler::Passkey(_)) => Ok(None),
_ => Err(OperationError::InvalidState),
}
}
/// Given the users indicated and preferred authentication mechanism that they want to proceed
@ -602,7 +654,7 @@ impl AuthSession {
&mut self,
mech: &AuthMech,
// time: &Duration,
// webauthn: &Webauthn<WebauthnDomainConfig>,
// webauthn: &WebauthnCore,
) -> Result<AuthState, OperationError> {
// Given some auth mech, select which credential(s) are apropriate
// and attempt to use them.
@ -668,7 +720,7 @@ impl AuthSession {
cred: &AuthCredential,
time: &Duration,
async_tx: &Sender<DelayedAction>,
webauthn: &Webauthn<WebauthnDomainConfig>,
webauthn: &Webauthn,
pw_badlist_set: Option<&HashSet<String>>,
uat_jwt_signer: &JwsSigner,
) -> Result<AuthState, OperationError> {
@ -780,7 +832,6 @@ impl AuthSession {
mod tests {
use crate::credential::policy::CryptoPolicy;
use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
use crate::credential::webauthn::WebauthnDomainConfig;
use crate::credential::{BackupCodes, Credential};
use crate::idm::authsession::{
AuthSession, BAD_AUTH_TYPE_MSG, BAD_BACKUPCODE_MSG, BAD_PASSWORD_MSG, BAD_TOTP_MSG,
@ -796,10 +847,9 @@ mod tests {
use crate::utils::{duration_from_epoch_now, readable_password_from_random};
use kanidm_proto::v1::{AuthAllowed, AuthCredential, AuthMech};
use std::time::Duration;
use webauthn_rs::Webauthn;
use tokio::sync::mpsc::unbounded_channel as unbounded;
use webauthn_authenticator_rs::{softtok::U2FSoft, WebauthnAuthenticator};
use webauthn_authenticator_rs::{softpasskey::SoftPasskey, WebauthnAuthenticator};
use compact_jwt::JwsSigner;
@ -809,12 +859,13 @@ mod tests {
s
}
fn create_webauthn() -> Webauthn<WebauthnDomainConfig> {
Webauthn::new(WebauthnDomainConfig {
rp_name: "example.com".to_string(),
origin: url::Url::parse("https://idm.example.com").unwrap(),
rp_id: "example.com".to_string(),
})
fn create_webauthn() -> webauthn_rs::Webauthn {
webauthn_rs::WebauthnBuilder::new(
"example.com",
&url::Url::parse("https://idm.example.com").unwrap(),
)
.and_then(|builder| builder.build())
.unwrap()
}
fn create_jwt_signer() -> JwsSigner {
@ -997,7 +1048,7 @@ mod tests {
assert!(
true == auth_mechs.iter().fold(false, |acc, x| match x {
// TODO: How to return webauthn chal?
AuthAllowed::Webauthn(chal) => {
AuthAllowed::SecurityKey(chal) => {
rchal = Some(chal.clone());
true
}
@ -1256,24 +1307,24 @@ mod tests {
let mut session = session.unwrap();
if let AuthState::Choose(auth_mechs) = state {
assert!(auth_mechs.iter().any(|x| matches!(x, AuthMech::Webauthn)));
assert!(auth_mechs.iter().any(|x| matches!(x, AuthMech::Passkey)));
} else {
panic!();
}
let state = session
.start_session(&AuthMech::Webauthn)
.expect("Failed to select Webauthn mech.");
.start_session(&AuthMech::Passkey)
.expect("Failed to select Passkey mech.");
let wan_chal = if let AuthState::Continue(auth_mechs) = state {
assert!(auth_mechs.len() == 1);
auth_mechs
.into_iter()
.fold(None, |_acc, x| match x {
AuthAllowed::Webauthn(chal) => Some(chal),
AuthAllowed::Passkey(chal) => Some(chal),
_ => None,
})
.expect("No webauthn challenge found.")
.expect("No securitykey challenge found.")
} else {
panic!();
};
@ -1282,27 +1333,57 @@ mod tests {
}};
}
fn setup_webauthn(
fn setup_webauthn_passkey(
name: &str,
) -> (
webauthn_rs::Webauthn<crate::credential::webauthn::WebauthnDomainConfig>,
webauthn_authenticator_rs::WebauthnAuthenticator<U2FSoft>,
webauthn_rs::proto::Credential,
webauthn_rs::prelude::Webauthn,
webauthn_authenticator_rs::WebauthnAuthenticator<SoftPasskey>,
webauthn_rs::prelude::Passkey,
) {
let webauthn = create_webauthn();
// Setup a soft token
let mut wa = WebauthnAuthenticator::new(U2FSoft::new());
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new());
let uuid = Uuid::new_v4();
let (chal, reg_state) = webauthn
.generate_challenge_register(name, false)
.expect("Failed to setup webauthn rego challenge");
.start_passkey_registration(uuid, name, name, None)
.expect("Failed to setup passkey rego challenge");
let r = wa
.do_registration("https://idm.example.com", chal)
.expect("Failed to create soft token");
.do_registration(webauthn.get_origin().clone(), chal)
.expect("Failed to create soft passkey");
let (wan_cred, _) = webauthn
.register_credential(&r, &reg_state, |_| Ok(false))
let wan_cred = webauthn
.finish_passkey_registration(&r, &reg_state)
.expect("Failed to register soft token");
(webauthn, wa, wan_cred)
}
fn setup_webauthn_securitykey(
name: &str,
) -> (
webauthn_rs::prelude::Webauthn,
webauthn_authenticator_rs::WebauthnAuthenticator<SoftPasskey>,
webauthn_rs::prelude::SecurityKey,
) {
let webauthn = create_webauthn();
// Setup a soft token
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new());
let uuid = Uuid::new_v4();
let (chal, reg_state) = webauthn
.start_securitykey_registration(uuid, name, name, None, None, None)
.expect("Failed to setup passkey rego challenge");
let r = wa
.do_registration(webauthn.get_origin().clone(), chal)
.expect("Failed to create soft securitykey");
let wan_cred = webauthn
.finish_securitykey_registration(&r, &reg_state)
.expect("Failed to register soft token");
(webauthn, wa, wan_cred)
@ -1316,11 +1397,11 @@ mod tests {
// create the ent
let mut account = entry_str_to_account!(JSON_ADMIN_V1);
let (webauthn, mut wa, wan_cred) = setup_webauthn(account.name.as_str());
let (webauthn, mut wa, wan_cred) = setup_webauthn_passkey(account.name.as_str());
let jws_signer = create_jwt_signer();
// Now create the credential for the account.
let cred = Credential::new_webauthn_only("soft".to_string(), wan_cred);
let cred = Credential::new_passkey_only("soft".to_string(), wan_cred);
account.primary = Some(cred);
// now check correct mech was offered.
@ -1348,11 +1429,11 @@ mod tests {
let (mut session, chal) = start_webauthn_only_session!(&mut audit, account, &webauthn);
let resp = wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::Passkey(resp),
&ts,
&async_tx,
&webauthn,
@ -1377,11 +1458,11 @@ mod tests {
let resp = wa
// HERE -> we use inv_chal instead.
.do_authentication("https://idm.example.com", inv_chal)
.do_authentication(webauthn.get_origin().clone(), inv_chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::Passkey(resp),
&ts,
&async_tx,
&webauthn,
@ -1395,27 +1476,27 @@ mod tests {
// Use an incorrect softtoken.
{
let mut inv_wa = WebauthnAuthenticator::new(U2FSoft::new());
let mut inv_wa = WebauthnAuthenticator::new(SoftPasskey::new());
let (chal, reg_state) = webauthn
.generate_challenge_register(&account.name, false)
.start_passkey_registration(account.uuid, &account.name, &account.displayname, None)
.expect("Failed to setup webauthn rego challenge");
let r = inv_wa
.do_registration("https://idm.example.com", chal)
.do_registration(webauthn.get_origin().clone(), chal)
.expect("Failed to create soft token");
let (inv_cred, _) = webauthn
.register_credential(&r, &reg_state, |_| Ok(false))
let inv_cred = webauthn
.finish_passkey_registration(&r, &reg_state)
.expect("Failed to register soft token");
// Discard the auth_state, we only need the invalid challenge.
let (chal, _auth_state) = webauthn
.generate_challenge_authenticate(vec![inv_cred])
.start_passkey_authentication(&vec![inv_cred])
.expect("Failed to generate challenge for in inv softtoken");
// Create the response.
let resp = inv_wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("Failed to use softtoken for response.");
let (mut session, _chal) = start_webauthn_only_session!(&mut audit, account, &webauthn);
@ -1423,7 +1504,7 @@ mod tests {
// get this far, because the client should identify that the cred id's are
// not inline.
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::Passkey(resp),
&ts,
&async_tx,
&webauthn,
@ -1447,7 +1528,7 @@ mod tests {
// create the ent
let mut account = entry_str_to_account!(JSON_ADMIN_V1);
let (webauthn, mut wa, wan_cred) = setup_webauthn(account.name.as_str());
let (webauthn, mut wa, wan_cred) = setup_webauthn_securitykey(account.name.as_str());
let jws_signer = create_jwt_signer();
let pw_good = "test_password";
let pw_bad = "bad_password";
@ -1456,7 +1537,7 @@ mod tests {
let p = CryptoPolicy::minimum();
let cred = Credential::new_password_only(&p, pw_good)
.unwrap()
.append_webauthn("soft".to_string(), wan_cred)
.append_securitykey("soft".to_string(), wan_cred)
.unwrap();
account.primary = Some(cred);
@ -1509,11 +1590,11 @@ mod tests {
let resp = wa
// HERE -> we use inv_chal instead.
.do_authentication("https://idm.example.com", inv_chal)
.do_authentication(webauthn.get_origin().clone(), inv_chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,
@ -1532,11 +1613,11 @@ mod tests {
let chal = chal.unwrap();
let resp = wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,
@ -1572,11 +1653,11 @@ mod tests {
let chal = chal.unwrap();
let resp = wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,
@ -1617,7 +1698,7 @@ mod tests {
// create the ent
let mut account = entry_str_to_account!(JSON_ADMIN_V1);
let (webauthn, mut wa, wan_cred) = setup_webauthn(account.name.as_str());
let (webauthn, mut wa, wan_cred) = setup_webauthn_securitykey(account.name.as_str());
let jws_signer = create_jwt_signer();
let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
@ -1636,7 +1717,7 @@ mod tests {
let p = CryptoPolicy::minimum();
let cred = Credential::new_password_only(&p, pw_good)
.unwrap()
.append_webauthn("soft".to_string(), wan_cred)
.append_securitykey("soft".to_string(), wan_cred)
.unwrap()
.update_totp(totp);
@ -1688,11 +1769,11 @@ mod tests {
let resp = wa
// HERE -> we use inv_chal instead.
.do_authentication("https://idm.example.com", inv_chal)
.do_authentication(webauthn.get_origin().clone(), inv_chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,
@ -1711,11 +1792,11 @@ mod tests {
let chal = chal.unwrap();
let resp = wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,
@ -1809,11 +1890,11 @@ mod tests {
let chal = chal.unwrap();
let resp = wa
.do_authentication("https://idm.example.com", chal)
.do_authentication(webauthn.get_origin().clone(), chal)
.expect("failed to use softtoken to authenticate");
match session.validate_creds(
&AuthCredential::Webauthn(resp),
&AuthCredential::SecurityKey(resp),
&ts,
&async_tx,
&webauthn,

View file

@ -6,13 +6,22 @@ use crate::idm::server::IdmServerProxyWriteTransaction;
use crate::prelude::*;
use crate::value::IntentTokenState;
use hashbrown::HashSet;
use std::collections::BTreeMap;
use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
use kanidm_proto::v1::{CURegState, CUStatus, CredentialDetail, PasswordFeedback, TotpSecret};
use kanidm_proto::v1::{
CURegState, CUStatus, CredentialDetail, PasskeyDetail, PasswordFeedback, TotpSecret,
};
use crate::utils::{backup_code_from_random, readable_password_from_random, uuid_from_duration};
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
use webauthn_rs::prelude::{
CreationChallengeResponse, PasskeyRegistration, RegisterPublicKeyCredential,
};
use serde::{Deserialize, Serialize};
use std::fmt;
@ -21,8 +30,6 @@ use std::sync::Mutex;
use std::time::Duration;
use time::OffsetDateTime;
// use tokio::sync::Mutex;
use core::ops::Deref;
const MAXIMUM_CRED_UPDATE_TTL: Duration = Duration::from_secs(900);
@ -54,11 +61,13 @@ pub struct CredentialUpdateSessionToken {
}
/// The current state of MFA registration
#[derive(Clone)]
enum MfaRegState {
None,
TotpInit(Totp),
TotpTryAgain(Totp),
TotpInvalidSha1(Totp, Totp),
Passkey(CreationChallengeResponse, PasskeyRegistration),
}
impl fmt::Debug for MfaRegState {
@ -68,35 +77,49 @@ impl fmt::Debug for MfaRegState {
MfaRegState::TotpInit(_) => "MfaRegState::TotpInit",
MfaRegState::TotpTryAgain(_) => "MfaRegState::TotpTryAgain",
MfaRegState::TotpInvalidSha1(_, _) => "MfaRegState::TotpInvalidSha1",
MfaRegState::Passkey(_, _) => "MfaRegState::Passkey",
};
write!(f, "{}", t)
}
}
#[derive(Clone)]
pub(crate) struct CredentialUpdateSession {
issuer: String,
// Current credentials - these are on the Account!
account: Account,
//
// What intent was used to initiate this session.
intent_token_id: Option<String>,
// Acc policy
// The credentials as they are being updated
// The pw credential as they are being updated
primary: Option<Credential>,
// Internal reg state.
mfaregstate: MfaRegState,
// trusted_devices: Map<Webauthn>?
// Passkeys that have been configured.
passkeys: BTreeMap<Uuid, (String, PasskeyV4)>,
// Devicekeys
_devicekeys: BTreeMap<Uuid, (String, DeviceKeyV4)>,
//
// Internal reg state of any inprogress totp or webauthn credentials.
mfaregstate: MfaRegState,
}
impl fmt::Debug for CredentialUpdateSession {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let primary: Option<CredentialDetail> = self.primary.as_ref().map(|c| c.into());
let passkeys: Vec<PasskeyDetail> = self
.passkeys
.iter()
.map(|(uuid, (tag, _pk))| PasskeyDetail {
tag: tag.clone(),
uuid: *uuid,
})
.collect();
f.debug_struct("CredentialUpdateSession")
.field("account.spn", &self.account.spn)
.field("intent_token_id", &self.intent_token_id)
.field("primary.detail()", &primary)
.field("passkeys.list()", &passkeys)
.field("mfaregstate", &self.mfaregstate)
.finish()
}
@ -109,6 +132,7 @@ enum MfaRegStateStatus {
TotpTryAgain,
TotpInvalidSha1,
BackupCodes(HashSet<String>),
Passkey(CreationChallengeResponse),
}
impl fmt::Debug for MfaRegStateStatus {
@ -119,6 +143,7 @@ impl fmt::Debug for MfaRegStateStatus {
MfaRegStateStatus::TotpTryAgain => "MfaRegStateStatus::TotpTryAgain",
MfaRegStateStatus::TotpInvalidSha1 => "MfaRegStateStatus::TotpInvalidSha1",
MfaRegStateStatus::BackupCodes(_) => "MfaRegStateStatus::BackupCodes",
MfaRegStateStatus::Passkey(_) => "MfaRegStateStatus::Passkey",
};
write!(f, "{}", t)
}
@ -133,6 +158,7 @@ pub struct CredentialUpdateSessionStatus {
//
can_commit: bool,
primary: Option<CredentialDetail>,
passkeys: Vec<PasskeyDetail>,
// Any info the client needs about mfareg state.
mfaregstate: MfaRegStateStatus,
}
@ -144,6 +170,7 @@ impl Into<CUStatus> for CredentialUpdateSessionStatus {
displayname: self.displayname.clone(),
can_commit: self.can_commit,
primary: self.primary,
passkeys: self.passkeys,
mfaregstate: match self.mfaregstate {
MfaRegStateStatus::None => CURegState::None,
MfaRegStateStatus::TotpCheck(c) => CURegState::TotpCheck(c),
@ -152,6 +179,7 @@ impl Into<CUStatus> for CredentialUpdateSessionStatus {
MfaRegStateStatus::BackupCodes(s) => {
CURegState::BackupCodes(s.into_iter().collect())
}
MfaRegStateStatus::Passkey(r) => CURegState::Passkey(r),
},
}
}
@ -165,6 +193,14 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus {
can_commit: true,
primary: session.primary.as_ref().map(|c| c.into()),
passkeys: session
.passkeys
.iter()
.map(|(uuid, (tag, _pk))| PasskeyDetail {
tag: tag.clone(),
uuid: *uuid,
})
.collect(),
mfaregstate: match &session.mfaregstate {
MfaRegState::None => MfaRegStateStatus::None,
MfaRegState::TotpInit(token) => MfaRegStateStatus::TotpCheck(
@ -172,6 +208,7 @@ impl From<&CredentialUpdateSession> for CredentialUpdateSessionStatus {
),
MfaRegState::TotpTryAgain(_) => MfaRegStateStatus::TotpTryAgain,
MfaRegState::TotpInvalidSha1(_, _) => MfaRegStateStatus::TotpInvalidSha1,
MfaRegState::Passkey(r, _) => MfaRegStateStatus::Passkey(r.clone()),
},
}
}
@ -242,7 +279,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
let entry = self.qs_write.internal_search_uuid(&target)?;
security_info!(
?entry,
%entry,
%target,
"Initiating Credential Update Session",
);
@ -255,7 +292,11 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
.get_accesscontrols()
.effective_permission_check(
&ident,
Some(btreeset![AttrString::from("primary_credential")]),
Some(btreeset![
AttrString::from("primary_credential"),
AttrString::from("passkeys"),
AttrString::from("devicekeys")
]),
&[entry],
)?;
@ -296,6 +337,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
) -> Result<(CredentialUpdateSessionToken, CredentialUpdateSessionStatus), OperationError> {
// - stash the current state of all associated credentials
let primary = account.primary.clone();
let passkeys = account.passkeys.clone();
let devicekeys = account.devicekeys.clone();
// Stash the issuer for some UI elements
let issuer = self.qs_write.get_domain_display_name().to_string();
@ -305,6 +348,8 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
issuer,
intent_token_id,
primary,
passkeys,
_devicekeys: devicekeys,
mfaregstate: MfaRegState::None,
};
@ -497,7 +542,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
let max_ttl = match account.credential_update_intent_tokens.get(&intent_id) {
Some(IntentTokenState::Consumed { max_ttl: _ }) => {
security_info!(
?entry,
%entry,
%account.uuid,
"Rejecting Update Session - Intent Token has already been exchanged",
);
@ -511,14 +556,14 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
if current_time > *session_ttl {
// The former session has expired, continue.
security_info!(
?entry,
%entry,
%account.uuid,
"Initiating Credential Update Session - Previous session {} has expired", session_id
);
*max_ttl
} else {
security_info!(
?entry,
%entry,
%account.uuid,
"Rejecting Update Session - Intent Token is in use {}. Try again later", session_id
);
@ -535,7 +580,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
return Err(OperationError::SessionExpired);
} else {
security_info!(
?entry,
%entry,
%account.uuid,
"Initiating Credential Update Session",
);
@ -621,11 +666,19 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
trace!(?removed);
}
pub fn commit_credential_update(
// This shares some common paths between commit and cancel.
fn credential_update_commit_common(
&mut self,
cust: CredentialUpdateSessionToken,
ct: Duration,
) -> Result<(), OperationError> {
) -> Result<
(
ModifyList<ModifyInvalid>,
CredentialUpdateSession,
CredentialUpdateSessionTokenInner,
),
OperationError,
> {
let session_token: CredentialUpdateSessionTokenInner = self
.token_enc_key
.decrypt(&cust.token_enc)
@ -652,14 +705,28 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
OperationError::InvalidState
})?;
let session = session_handle.try_lock().map_err(|_| {
admin_error!("Session already locked, unable to proceed.");
OperationError::InvalidState
})?;
let session = session_handle
.try_lock()
.map(|guard| (*guard).clone())
.map_err(|_| {
admin_error!("Session already locked, unable to proceed.");
OperationError::InvalidState
})?;
trace!(?session);
let mut modlist = ModifyList::new();
let modlist = ModifyList::new();
Ok((modlist, session, session_token))
}
pub fn commit_credential_update(
&mut self,
cust: CredentialUpdateSessionToken,
ct: Duration,
) -> Result<(), OperationError> {
let (mut modlist, session, session_token) =
self.credential_update_commit_common(cust, ct)?;
// Setup mods for the various bits. We always assert an *exact* state.
@ -724,6 +791,14 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
}
};
// Need to update passkeys.
modlist.push_mod(Modify::Purged(AttrString::from("passkeys")));
// Add all the passkeys. If none, nothing will be added! This handles
// the delete case quite cleanly :)
session.passkeys.iter().for_each(|(uuid, (tag, pk))| {
let v_pk = Value::Passkey(*uuid, tag.clone(), pk.clone());
modlist.push_mod(Modify::Present(AttrString::from("passkeys"), v_pk));
});
// Are any other checks needed?
// Apply to the account!
@ -740,9 +815,73 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
e
})
}
pub fn cancel_credential_update(
&mut self,
cust: CredentialUpdateSessionToken,
ct: Duration,
) -> Result<(), OperationError> {
let (mut modlist, session, session_token) =
self.credential_update_commit_common(cust, ct)?;
// If an intent token was used, remove it's former value, and add it as VALID since we didn't commit.
if let Some(intent_token_id) = &session.intent_token_id {
let entry = self.qs_write.internal_search_uuid(&session.account.uuid)?;
let account = Account::try_from_entry_rw(entry.as_ref(), &mut self.qs_write)?;
let max_ttl = match account.credential_update_intent_tokens.get(intent_token_id) {
Some(IntentTokenState::InProgress {
max_ttl,
session_id,
session_ttl: _,
}) => {
if *session_id != session_token.sessionid {
security_info!("Session originated from an intent token, but the intent token has initiated a conflicting second update session. Refusing to commit changes.");
return Err(OperationError::InvalidState);
} else {
*max_ttl
}
}
Some(IntentTokenState::Consumed { max_ttl: _ })
| Some(IntentTokenState::Valid { max_ttl: _ })
| None => {
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
return Err(OperationError::InvalidState);
}
};
modlist.push_mod(Modify::Removed(
AttrString::from("credential_update_intent_token"),
PartialValue::IntentToken(intent_token_id.clone()),
));
modlist.push_mod(Modify::Present(
AttrString::from("credential_update_intent_token"),
Value::IntentToken(intent_token_id.clone(), IntentTokenState::Valid { max_ttl }),
));
};
// Apply to the account!
trace!(?modlist, "processing change");
self.qs_write
.internal_modify(
// Filter as executed
&filter!(f_eq("uuid", PartialValue::new_uuid(session.account.uuid))),
&modlist,
)
.map_err(|e| {
request_error!(error = ?e);
e
})
}
}
impl<'a> IdmServerCredUpdateTransaction<'a> {
#[cfg(test)]
pub fn get_origin(&self) -> &Url {
self.webauthn.get_origin()
}
fn get_current_session(
&self,
cust: &CredentialUpdateSessionToken,
@ -1203,6 +1342,95 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
Ok(session.deref().into())
}
pub fn credential_passkey_init(
&self,
cust: &CredentialUpdateSessionToken,
ct: Duration,
) -> Result<CredentialUpdateSessionStatus, OperationError> {
let session_handle = self.get_current_session(cust, ct)?;
let mut session = session_handle.try_lock().map_err(|_| {
admin_error!("Session already locked, unable to proceed.");
OperationError::InvalidState
})?;
trace!(?session);
if !matches!(session.mfaregstate, MfaRegState::None) {
admin_info!("Invalid Passkey Init state, another update is in progress");
return Err(OperationError::InvalidState);
}
let (ccr, pk_reg) = self
.webauthn
.start_passkey_registration(
session.account.uuid,
&session.account.spn,
&session.account.displayname,
session.account.existing_credential_id_list(),
)
.map_err(|e| {
error!(?e, "Unable to start passkey registration");
OperationError::Webauthn
})?;
session.mfaregstate = MfaRegState::Passkey(ccr, pk_reg);
// Now that it's in the state, it'll be in the status when returned.
Ok(session.deref().into())
}
pub fn credential_passkey_finish(
&self,
cust: &CredentialUpdateSessionToken,
ct: Duration,
label: String,
reg: RegisterPublicKeyCredential,
) -> Result<CredentialUpdateSessionStatus, OperationError> {
let session_handle = self.get_current_session(cust, ct)?;
let mut session = session_handle.try_lock().map_err(|_| {
admin_error!("Session already locked, unable to proceed.");
OperationError::InvalidState
})?;
trace!(?session);
match &session.mfaregstate {
MfaRegState::Passkey(_ccr, pk_reg) => {
let passkey = self
.webauthn
.finish_passkey_registration(&reg, pk_reg)
.map_err(|e| {
error!(?e, "Unable to start passkey registration");
OperationError::Webauthn
})?;
let pk_id = Uuid::new_v4();
session.passkeys.insert(pk_id, (label, passkey));
// The reg is done.
session.mfaregstate = MfaRegState::None;
Ok(session.deref().into())
}
_ => Err(OperationError::InvalidRequestState),
}
}
pub fn credential_passkey_remove(
&self,
cust: &CredentialUpdateSessionToken,
ct: Duration,
uuid: Uuid,
) -> Result<CredentialUpdateSessionStatus, OperationError> {
let session_handle = self.get_current_session(cust, ct)?;
let mut session = session_handle.try_lock().map_err(|_| {
admin_error!("Session already locked, unable to proceed.");
OperationError::InvalidState
})?;
trace!(?session);
// No-op if not present
session.passkeys.remove(&uuid);
Ok(session.deref().into())
}
pub fn credential_update_cancel_mfareg(
&self,
cust: &CredentialUpdateSessionToken,
@ -1245,13 +1473,16 @@ mod tests {
};
use crate::credential::totp::Totp;
use crate::event::{AuthEvent, AuthResult, CreateEvent};
use crate::idm::delayed::DelayedAction;
use crate::idm::server::IdmServer;
use crate::prelude::*;
use std::time::Duration;
use webauthn_authenticator_rs::{softpasskey::SoftPasskey, WebauthnAuthenticator};
use crate::idm::AuthState;
use compiled_uuid::uuid;
use kanidm_proto::v1::{AuthMech, CredentialDetailType};
use kanidm_proto::v1::{AuthAllowed, AuthMech, CredentialDetailType};
use async_std::task;
@ -1609,6 +1840,71 @@ mod tests {
}
}
fn check_testperson_passkey(
idms: &IdmServer,
wa: &mut WebauthnAuthenticator<SoftPasskey>,
origin: Url,
ct: Duration,
) -> Option<String> {
let mut idms_auth = idms.auth();
let auth_init = AuthEvent::named_init("testperson");
let r1 = task::block_on(idms_auth.auth(&auth_init, ct));
let ar = r1.unwrap();
let AuthResult {
sessionid,
state,
delay: _,
} = ar;
if !matches!(state, AuthState::Choose(_)) {
debug!("Can't proceed - {:?}", state);
return None;
};
let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::Passkey);
let r2 = task::block_on(idms_auth.auth(&auth_begin, ct));
let ar = r2.unwrap();
let AuthResult {
sessionid,
state,
delay: _,
} = ar;
trace!(?state);
let rcr = match state {
AuthState::Continue(mut allowed) => match allowed.pop() {
Some(AuthAllowed::Passkey(rcr)) => rcr,
_ => unreachable!(),
},
_ => unreachable!(),
};
trace!(?rcr);
let resp = wa
.do_authentication(origin, rcr)
.expect("failed to use softtoken to authenticate");
let passkey_step = AuthEvent::cred_step_passkey(sessionid, resp);
let r3 = task::block_on(idms_auth.auth(&passkey_step, ct));
debug!("r3 ==> {:?}", r3);
idms_auth.commit().expect("Must not fail");
match r3 {
Ok(AuthResult {
sessionid: _,
state: AuthState::Success(token),
delay: _,
}) => Some(token),
_ => None,
}
}
#[test]
fn test_idm_credential_update_session_cleanup() {
run_idm_test!(|_qs: &QueryServer,
@ -2024,7 +2320,7 @@ mod tests {
//
let c_status = cutxn
.credential_primary_init_totp(&cust, ct)
.expect("Failed to update the primary cred password");
.expect("Failed to update the primary cred totp");
// Check the status has the token.
assert!(c_status.can_commit);
@ -2054,6 +2350,104 @@ mod tests {
// - remove webauthn
// - test mulitple webauthn token.
#[test]
fn test_idm_credential_update_onboarding_create_new_passkey() {
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let ct = Duration::from_secs(TEST_CURRENT_TIME);
let (cust, _) = setup_test_session(idms, ct);
let cutxn = idms.cred_update_transaction();
let origin = cutxn.get_origin().clone();
// Create a soft passkey
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new());
// Start the registration
let c_status = cutxn
.credential_passkey_init(&cust, ct)
.expect("Failed to initiate passkey registration");
assert!(c_status.passkeys.is_empty());
let passkey_chal = match c_status.mfaregstate {
MfaRegStateStatus::Passkey(c) => Some(c),
_ => None,
}
.expect("Unable to access passkey challenge, invalid state");
let passkey_resp = wa
.do_registration(origin.clone(), passkey_chal)
.expect("Failed to create soft passkey");
// Finish the registration
let label = "softtoken".to_string();
let c_status = cutxn
.credential_passkey_finish(&cust, ct, label, passkey_resp)
.expect("Failed to initiate passkey registration");
assert!(matches!(c_status.mfaregstate, MfaRegStateStatus::None));
assert!(matches!(
// Shuld be none.
c_status.primary.as_ref(),
None
));
// Check we have the passkey
trace!(?c_status);
assert!(c_status.passkeys.len() == 1);
// Get the UUID of the passkey here.
let pk_uuid = c_status.passkeys.get(0).map(|pkd| pkd.uuid).unwrap();
// Commit
drop(cutxn);
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");
// Now test removing the token
let (cust, _) = renew_test_session(idms, ct);
let cutxn = idms.cred_update_transaction();
trace!(?c_status);
assert!(c_status.primary.is_none());
assert!(c_status.passkeys.len() == 1);
let c_status = cutxn
.credential_passkey_remove(&cust, ct, pk_uuid)
.expect("Failed to delete the primary cred");
trace!(?c_status);
assert!(c_status.primary.is_none());
assert!(c_status.passkeys.is_empty());
drop(cutxn);
commit_session(idms, ct, cust);
// Must fail now!
assert!(check_testperson_passkey(idms, &mut wa, origin, ct).is_none());
}
)
}
// W_ policy, assert can't remove MFA if it's enforced.
// enroll trusted device

View file

@ -1,5 +1,5 @@
use uuid::Uuid;
use webauthn_rs::proto::{Counter, CredentialID};
use webauthn_rs::prelude::AuthenticationResult;
pub(crate) enum DelayedAction {
PwUpgrade(PasswordUpgrade),
@ -21,8 +21,7 @@ pub(crate) struct UnixPasswordUpgrade {
pub(crate) struct WebauthnCounterIncrement {
pub target_uuid: Uuid,
pub counter: Counter,
pub cid: CredentialID,
pub auth_result: AuthenticationResult,
}
pub(crate) struct BackupCodeRemoval {

View file

@ -1,7 +1,5 @@
use crate::prelude::*;
use kanidm_proto::v1::OperationError;
use webauthn_rs::proto::RegisterPublicKeyCredential;
pub struct PasswordChangeEvent {
pub ident: Identity,
@ -383,107 +381,6 @@ impl RemoveTotpEvent {
}
}
#[derive(Debug)]
pub struct WebauthnInitRegisterEvent {
pub ident: Identity,
pub target: Uuid,
pub label: String,
}
impl WebauthnInitRegisterEvent {
pub fn from_parts(
// qs: &QueryServerWriteTransaction,
ident: Identity,
target: Uuid,
label: String,
) -> Result<Self, OperationError> {
Ok(WebauthnInitRegisterEvent {
ident,
target,
label,
})
}
#[cfg(test)]
pub fn new_internal(target: Uuid, label: String) -> Self {
let ident = Identity::from_internal();
WebauthnInitRegisterEvent {
ident,
target,
label,
}
}
}
#[derive(Debug)]
pub struct WebauthnDoRegisterEvent {
pub ident: Identity,
pub target: Uuid,
pub session: Uuid,
pub chal: RegisterPublicKeyCredential,
}
impl WebauthnDoRegisterEvent {
pub fn from_parts(
// qs: &QueryServerWriteTransaction,
ident: Identity,
target: Uuid,
session: Uuid,
chal: RegisterPublicKeyCredential,
) -> Result<Self, OperationError> {
Ok(WebauthnDoRegisterEvent {
ident,
target,
session,
chal,
})
}
#[cfg(test)]
pub fn new_internal(target: Uuid, session: Uuid, chal: RegisterPublicKeyCredential) -> Self {
let ident = Identity::from_internal();
WebauthnDoRegisterEvent {
ident,
target,
session,
chal,
}
}
}
#[derive(Debug)]
pub struct RemoveWebauthnEvent {
pub ident: Identity,
pub target: Uuid,
pub label: String,
}
impl RemoveWebauthnEvent {
pub fn from_parts(
// qs: &QueryServerWriteTransaction,
ident: Identity,
target: Uuid,
label: String,
) -> Result<Self, OperationError> {
Ok(RemoveWebauthnEvent {
ident,
target,
label,
})
}
#[cfg(test)]
pub fn new_internal(target: Uuid, label: String) -> Self {
let ident = Identity::from_internal();
RemoveWebauthnEvent {
ident,
target,
label,
}
}
}
#[derive(Debug)]
pub struct CredentialStatusEvent {
pub ident: Identity,

View file

@ -1,29 +1,20 @@
use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
use crate::credential::webauthn::WebauthnDomainConfig;
use crate::identity::IdentityId;
use crate::idm::account::Account;
use crate::prelude::*;
use kanidm_proto::v1::TotpSecret;
use kanidm_proto::v1::{OperationError, SetCredentialResponse};
use std::mem;
use std::time::Duration;
use uuid::Uuid;
use webauthn_rs::proto::Credential as WebauthnCredential;
use webauthn_rs::proto::{CreationChallengeResponse, RegisterPublicKeyCredential};
use webauthn_rs::RegistrationState as WebauthnRegistrationState;
use webauthn_rs::Webauthn;
pub(crate) enum MfaRegCred {
Totp(Totp),
Webauthn(String, WebauthnCredential),
}
pub(crate) enum MfaRegNext {
Success,
TotpCheck(TotpSecret),
TotpInvalidSha1,
WebauthnChallenge(CreationChallengeResponse),
}
impl MfaRegNext {
@ -33,9 +24,6 @@ impl MfaRegNext {
MfaRegNext::Success => SetCredentialResponse::Success,
MfaRegNext::TotpCheck(secret) => SetCredentialResponse::TotpCheck(u, secret),
MfaRegNext::TotpInvalidSha1 => SetCredentialResponse::TotpInvalidSha1(u),
MfaRegNext::WebauthnChallenge(ccr) => {
SetCredentialResponse::WebauthnCreateChallenge(u, ccr)
}
}
}
}
@ -45,8 +33,6 @@ enum MfaRegState {
TotpInit(Totp),
TotpInvalidSha1(Totp),
TotpDone,
WebauthnInit(String, WebauthnRegistrationState),
WebauthnDone,
}
#[derive(Clone)]
@ -164,59 +150,4 @@ impl MfaRegSession {
_ => Err(OperationError::InvalidRequestState),
}
}
pub fn webauthn_new(
origin: IdentityId,
account: Account,
label: String,
webauthn: &Webauthn<WebauthnDomainConfig>,
issuer: String,
) -> Result<(Self, MfaRegNext), OperationError> {
// Setup the registration.
let (chal, reg_state) = webauthn
.generate_challenge_register(&account.name, false)
.map_err(|e| {
admin_error!("Unable to generate webauthn challenge -> {:?}", e);
OperationError::Webauthn
})?;
let state = MfaRegState::WebauthnInit(label, reg_state);
let s = MfaRegSession {
origin,
account,
state,
// this isn't used in webauthn... yet?
issuer,
};
let next = MfaRegNext::WebauthnChallenge(chal);
Ok((s, next))
}
pub fn webauthn_step(
&mut self,
origin: &IdentityId,
target: &Uuid,
chal: &RegisterPublicKeyCredential,
webauthn: &Webauthn<WebauthnDomainConfig>,
) -> Result<(MfaRegNext, Option<MfaRegCred>), OperationError> {
if &self.origin != origin || target != &self.account.uuid {
// Verify that the same event source is the one continuing this attempt
return Err(OperationError::InvalidRequestState);
};
// Regardless of the outcome, we are done!
let mut nstate = MfaRegState::WebauthnDone;
mem::swap(&mut self.state, &mut nstate);
match nstate {
MfaRegState::WebauthnInit(label, reg_state) => webauthn
.register_credential(chal, &reg_state, |_| Ok(false))
.map_err(|e| {
admin_error!("Unable to register webauthn credential -> {:?}", e);
OperationError::Webauthn
})
.map(|(cred, _)| (MfaRegNext::Success, Some(MfaRegCred::Webauthn(label, cred)))),
_ => Err(OperationError::InvalidRequestState),
}
}
}

View file

@ -10,6 +10,7 @@ use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant};
use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction};
use crate::prelude::*;
use crate::value::OAUTHSCOPE_RE;
use base64urlsafedata::Base64UrlSafeData;
pub use compact_jwt::{JwkKeySet, OidcToken};
use compact_jwt::{JwsSigner, OidcClaims, OidcSubject};
use concread::cowcell::*;
@ -25,7 +26,6 @@ use time::OffsetDateTime;
use tokio::sync::mpsc::UnboundedSender as Sender;
use tracing::trace;
use url::{Origin, Url};
use webauthn_rs::base64_data::Base64UrlSafeData;
pub use kanidm_proto::oauth2::{
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
@ -1343,9 +1343,9 @@ mod tests {
use crate::event::{DeleteEvent, ModifyEvent};
use base64urlsafedata::Base64UrlSafeData;
use kanidm_proto::oauth2::*;
use kanidm_proto::v1::{AuthType, UserAuthToken};
use webauthn_rs::base64_data::Base64UrlSafeData;
use compact_jwt::{JwaAlg, Jwk, JwkUse, JwsValidator, OidcSubject, OidcUnverified};

View file

@ -1,6 +1,5 @@
use crate::credential::policy::CryptoPolicy;
use crate::credential::softlock::CredSoftLock;
use crate::credential::webauthn::WebauthnDomainConfig;
use crate::credential::BackupCodes;
use crate::event::{AuthEvent, AuthEventStep, AuthResult};
use crate::identity::{IdentType, IdentUser, Limits};
@ -10,9 +9,8 @@ use crate::idm::credupdatesession::CredentialUpdateSessionMutex;
use crate::idm::event::{
AcceptSha1TotpEvent, CredentialStatusEvent, GeneratePasswordEvent, GenerateTotpEvent,
LdapAuthEvent, PasswordChangeEvent, RadiusAuthTokenEvent, RegenerateRadiusSecretEvent,
RemoveTotpEvent, RemoveWebauthnEvent, UnixGroupTokenEvent, UnixPasswordChangeEvent,
UnixUserAuthEvent, UnixUserTokenEvent, VerifyTotpEvent, WebauthnDoRegisterEvent,
WebauthnInitRegisterEvent,
RemoveTotpEvent, UnixGroupTokenEvent, UnixPasswordChangeEvent, UnixUserAuthEvent,
UnixUserTokenEvent, VerifyTotpEvent,
};
use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession};
use crate::idm::oauth2::{
@ -71,7 +69,7 @@ use std::{sync::Arc, time::Duration};
use tokio::sync::Mutex;
use url::Url;
use webauthn_rs::Webauthn;
use webauthn_rs::prelude::{Webauthn, WebauthnBuilder};
use super::delayed::BackupCodeRemoval;
use super::event::{GenerateBackupCodeEvent, ReadBackupCodeEvent, RemoveBackupCodeEvent};
@ -98,7 +96,7 @@ pub struct IdmServer {
crypto_policy: CryptoPolicy,
async_tx: Sender<DelayedAction>,
/// [Webauthn] verifier/config
webauthn: Webauthn<WebauthnDomainConfig>,
webauthn: Webauthn,
pw_badlist_cache: Arc<CowCell<HashSet<String>>>,
oauth2rs: Arc<Oauth2ResourceServers>,
@ -118,7 +116,7 @@ pub struct IdmServerAuthTransaction<'a> {
sid: Sid,
// For flagging eventual actions.
async_tx: Sender<DelayedAction>,
webauthn: &'a Webauthn<WebauthnDomainConfig>,
webauthn: &'a Webauthn,
pw_badlist_cache: CowCellReadTxn<HashSet<String>>,
uat_jwt_signer: CowCellReadTxn<JwsSigner>,
uat_jwt_validator: CowCellReadTxn<JwsValidator>,
@ -127,7 +125,7 @@ pub struct IdmServerAuthTransaction<'a> {
pub(crate) struct IdmServerCredUpdateTransaction<'a> {
pub _qs_read: QueryServerReadTransaction<'a>,
// sid: Sid,
pub _webauthn: &'a Webauthn<WebauthnDomainConfig>,
pub webauthn: &'a Webauthn,
pub pw_badlist_cache: CowCellReadTxn<HashSet<String>>,
pub cred_update_sessions: BptreeMapReadTxn<'a, Uuid, CredentialUpdateSessionMutex>,
pub token_enc_key: CowCellReadTxn<Fernet>,
@ -151,7 +149,7 @@ pub struct IdmServerProxyWriteTransaction<'a> {
pub(crate) cred_update_sessions: BptreeMapWriteTxn<'a, Uuid, CredentialUpdateSessionMutex>,
pub(crate) sid: Sid,
crypto_policy: &'a CryptoPolicy,
webauthn: &'a Webauthn<WebauthnDomainConfig>,
webauthn: &'a Webauthn,
pw_badlist_cache: CowCellWriteTxn<'a, HashSet<String>>,
uat_jwt_signer: CowCellWriteTxn<'a, JwsSigner>,
uat_jwt_validator: CowCellWriteTxn<'a, JwsValidator>,
@ -181,10 +179,11 @@ impl IdmServer {
let (async_tx, async_rx) = unbounded();
// Get the domain name, as the relying party id.
let (rp_id, fernet_private_key, es256_private_key, pw_badlist_set, oauth2rs_set) = {
let (rp_id, rp_name, fernet_private_key, es256_private_key, pw_badlist_set, oauth2rs_set) = {
let qs_read = task::block_on(qs.read_async());
(
qs_read.get_domain_name().to_string(),
qs_read.get_domain_display_name().to_string(),
qs_read.get_domain_fernet_private_key()?,
qs_read.get_domain_es256_private_key()?,
qs_read.get_password_badlist()?,
@ -217,14 +216,12 @@ impl IdmServer {
}
})?;
// Now clone to rp_name.
let rp_name = rp_id.clone();
let webauthn = Webauthn::new(WebauthnDomainConfig {
rp_name,
origin: origin_url.clone(),
rp_id,
});
let webauthn = WebauthnBuilder::new(&rp_id, &origin_url)
.and_then(|builder| builder.allow_subdomains(true).rp_name(&rp_name).build())
.map_err(|e| {
admin_error!("Invalid Webauthn Configuration - {:?}", e);
OperationError::InvalidState
})?;
// Setup our auth token signing key.
let fernet_key = Fernet::new(&fernet_private_key).ok_or_else(|| {
@ -350,7 +347,7 @@ impl IdmServer {
IdmServerCredUpdateTransaction {
_qs_read: self.qs.read_async().await,
// sid: Sid,
_webauthn: &self.webauthn,
webauthn: &self.webauthn,
pw_badlist_cache: self.pw_badlist_cache.read(),
cred_update_sessions: self.cred_update_sessions.read(),
token_enc_key: self.token_enc_key.read(),
@ -381,6 +378,11 @@ impl IdmServerDelayed {
}
}
#[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> {
let waker = futures_task::noop_waker();
@ -614,11 +616,12 @@ impl<'a> IdmServerAuthTransaction<'a> {
// Intent to take both trees to write.
let _session_ticket = self.session_ticket.acquire().await;
// Check the credential that the auth_session will attempt to
// use.
// We don't actually check the softlock here. We just initialise
// it under the write lock we currently have, so that we can validate
// it once we understand what auth mech we will be using.
//
// NOTE: Very careful use of await here to avoid an issue with write.
let maybe_slock_ref =
let _maybe_slock_ref =
account
.primary_cred_uuid_and_policy()
.map(|(cred_uuid, policy)| {
@ -640,6 +643,7 @@ impl<'a> IdmServerAuthTransaction<'a> {
slock_ref
});
/*
let mut maybe_slock = if let Some(slock_ref) = maybe_slock_ref.as_ref() {
Some(slock_ref.lock().await)
} else {
@ -653,7 +657,9 @@ impl<'a> IdmServerAuthTransaction<'a> {
} else {
false
};
*/
/*
let (auth_session, state) = if is_valid {
AuthSession::new(account, self.webauthn, ct)
} else {
@ -664,6 +670,9 @@ impl<'a> IdmServerAuthTransaction<'a> {
AuthState::Denied("Account is temporarily locked".to_string()),
)
};
*/
let (auth_session, state) = AuthSession::new(account, self.webauthn, ct);
match auth_session {
Some(auth_session) => {
@ -715,8 +724,11 @@ impl<'a> IdmServerAuthTransaction<'a> {
let mut auth_session = auth_session_ref.lock().await;
let is_valid =
if let Some(cred_uuid) = auth_session.get_account().primary_cred_uuid() {
// Indicate to the session which auth mech we now want to proceed with.
let auth_result = auth_session.start_session(&mech.mech);
let is_valid = match auth_session.get_credential_uuid()? {
Some(cred_uuid) => {
// From the auth_session, determine if the current account
// credential that we are using has become softlocked or not.
let softlock_read = self.softlocks.read();
@ -729,13 +741,12 @@ impl<'a> IdmServerAuthTransaction<'a> {
} else {
false
}
} else {
false
};
}
None => true,
};
let r = if is_valid {
// Indicate to the session which auth mech we now want to proceed with.
auth_session.start_session(&mech.mech)
let auth_result = if is_valid {
auth_result
} else {
// Fail the session
auth_session.end_session("Account is temporarily locked")
@ -748,9 +759,8 @@ impl<'a> IdmServerAuthTransaction<'a> {
delay,
}
});
// softlock_write.commit();
// session_write.commit();
r
auth_result
} // End AuthEventStep::Mech
AuthEventStep::Cred(creds) => {
// lperf_segment!("idm::server::auth<Creds>", || {
@ -769,67 +779,60 @@ impl<'a> IdmServerAuthTransaction<'a> {
let mut auth_session = auth_session_ref.lock().await;
let maybe_slock_ref =
auth_session
.get_account()
.primary_cred_uuid()
.and_then(|cred_uuid| {
let softlock_read = self.softlocks.read();
softlock_read.get(&cred_uuid).map(|s| s.clone())
});
let maybe_slock_ref = match auth_session.get_credential_uuid()? {
Some(cred_uuid) => {
let softlock_read = self.softlocks.read();
softlock_read.get(&cred_uuid).map(|s| s.clone())
}
None => None,
};
// From the auth_session, determine if the current account
// credential that we are using has become softlocked or not.
let maybe_slock = if let Some(s) = maybe_slock_ref.as_ref() {
let mut maybe_slock = if let Some(s) = maybe_slock_ref.as_ref() {
Some(s.lock().await)
} else {
None
};
let maybe_valid = if let Some(mut slock) = maybe_slock {
let is_valid = if let Some(ref mut slock) = maybe_slock {
// Apply the current time.
slock.apply_time_step(ct);
// Now check the results
if slock.is_valid() {
Some(slock)
} else {
None
}
slock.is_valid()
} else {
None
// No slock is present for this cred_uuid
true
};
let r = match maybe_valid {
Some(mut slock) => {
// Process the credentials here as required.
// Basically throw them at the auth_session and see what
// falls out.
let pw_badlist_cache = Some(&(*self.pw_badlist_cache));
auth_session
.validate_creds(
&creds.cred,
&ct,
&self.async_tx,
self.webauthn,
pw_badlist_cache,
&*self.uat_jwt_signer,
)
.map(|aus| {
// Inspect the result:
// if it was a failure, we need to inc the softlock.
if let AuthState::Denied(_) = &aus {
// Update it.
let r = if is_valid {
// Process the credentials here as required.
// Basically throw them at the auth_session and see what
// falls out.
let pw_badlist_cache = Some(&(*self.pw_badlist_cache));
auth_session
.validate_creds(
&creds.cred,
&ct,
&self.async_tx,
self.webauthn,
pw_badlist_cache,
&*self.uat_jwt_signer,
)
.map(|aus| {
// Inspect the result:
// if it was a failure, we need to inc the softlock.
if let AuthState::Denied(_) = &aus {
// Update it.
if let Some(ref mut slock) = maybe_slock {
slock.record_failure(ct);
};
aus
})
}
_ => {
// Fail the session
auth_session.end_session("Account is temporarily locked")
}
}
};
aus
})
} else {
// Fail the session
auth_session.end_session("Account is temporarily locked")
}
.map(|aus| {
// TODO: Change this william!
@ -1264,6 +1267,10 @@ impl<'a> IdmServerTransaction<'a> for IdmServerProxyWriteTransaction<'a> {
}
impl<'a> IdmServerProxyWriteTransaction<'a> {
pub fn get_origin(&self) -> &Url {
self.webauthn.get_origin()
}
pub fn expire_mfareg_sessions(&mut self, ct: Duration) {
// ct is current time - sub the timeout. and then split.
let expire = ct - Duration::from_secs(MFAREG_SESSION_TIMEOUT);
@ -1679,6 +1686,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
.map(|_| cleartext)
}
/*
pub fn reg_account_webauthn_init(
&mut self,
wre: &WebauthnInitRegisterEvent,
@ -1731,10 +1739,13 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
if let (MfaRegNext::Success, Some(MfaRegCred::Webauthn(label, cred))) = (&next, wan_cred) {
// Persist the credential
let modlist = session.account.gen_webauthn_mod(label, cred).map_err(|e| {
admin_error!("Failed to gen webauthn mod {:?}", e);
e
})?;
let modlist = session
.account
.gen_securitykey_mod(label, cred)
.map_err(|e| {
admin_error!("Failed to gen webauthn mod {:?}", e);
e
})?;
// Perform the mod
self.qs_write
.impersonate_modify(
@ -1767,7 +1778,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
let account = self.target_to_account(&rwe.target)?;
let modlist = account
.gen_webauthn_remove_mod(rwe.label.as_str())
.gen_securitykey_remove_mod(rwe.label.as_str())
.map_err(|e| {
admin_error!("Failed to gen webauthn remove mod {:?}", e);
e
@ -1788,6 +1799,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
})
.map(|_| SetCredentialResponse::Success)
}
*/
pub fn generate_account_totp(
&mut self,
@ -2014,11 +2026,11 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
&mut self,
wci: &WebauthnCounterIncrement,
) -> Result<(), OperationError> {
let account = self.target_to_account(&wci.target_uuid)?;
let mut account = self.target_to_account(&wci.target_uuid)?;
// Generate an optional mod and then attempt to apply it.
let opt_modlist = account
.gen_webauthn_counter_mod(&wci.cid, wci.counter)
.gen_webauthn_counter_mod(&wci.auth_result)
.map_err(|e| {
admin_error!("Unable to generate webauthn counter mod {:?}", e);
e
@ -2170,12 +2182,11 @@ mod tests {
use crate::credential::totp::Totp;
use crate::credential::{Credential, Password};
use crate::event::{AuthEvent, AuthResult, CreateEvent, ModifyEvent};
use crate::idm::delayed::{BackupCodeRemoval, DelayedAction, WebauthnCounterIncrement};
use crate::idm::delayed::{BackupCodeRemoval, DelayedAction};
use crate::idm::event::{
AcceptSha1TotpEvent, GenerateBackupCodeEvent, GenerateTotpEvent, PasswordChangeEvent,
RadiusAuthTokenEvent, RegenerateRadiusSecretEvent, RemoveTotpEvent, RemoveWebauthnEvent,
UnixGroupTokenEvent, UnixPasswordChangeEvent, UnixUserAuthEvent, UnixUserTokenEvent,
VerifyTotpEvent, WebauthnDoRegisterEvent, WebauthnInitRegisterEvent,
RadiusAuthTokenEvent, RegenerateRadiusSecretEvent, RemoveTotpEvent, UnixGroupTokenEvent,
UnixPasswordChangeEvent, UnixUserAuthEvent, UnixUserTokenEvent, VerifyTotpEvent,
};
use crate::idm::AuthState;
use crate::modify::{Modify, ModifyList};
@ -2192,7 +2203,6 @@ mod tests {
use std::convert::TryFrom;
use std::time::Duration;
use uuid::Uuid;
use webauthn_authenticator_rs::{softtok::U2FSoft, WebauthnAuthenticator};
const TEST_PASSWORD: &'static str = "ntaoeuntnaoeuhraohuercahu😍";
const TEST_PASSWORD_INC: &'static str = "ntaoentu nkrcgaeunhibwmwmqj;k wqjbkx ";
@ -2405,13 +2415,7 @@ mod tests {
} = ar;
debug_assert!(delay.is_none());
match state {
AuthState::Choose(_) => {}
_ => {
error!("Sessions was not initialised");
panic!();
}
};
assert!(matches!(state, AuthState::Choose(_)));
// Now push that we want the Password Mech.
let admin_begin = AuthEvent::begin_mech(sessionid, AuthMech::Password);
@ -3469,6 +3473,20 @@ mod tests {
idms_auth.auth(&admin_init, Duration::from_secs(TEST_CURRENT_TIME)),
);
let ar = r1.unwrap();
let AuthResult {
sessionid,
state,
delay: _,
} = ar;
assert!(matches!(state, AuthState::Choose(_)));
// Soft locks only apply once a mechanism is chosen
let admin_begin = AuthEvent::begin_mech(sessionid, AuthMech::Password);
let r2 = task::block_on(
idms_auth.auth(&admin_begin, Duration::from_secs(TEST_CURRENT_TIME)),
);
let ar = r2.unwrap();
let AuthResult {
sessionid: _,
state,
@ -3699,102 +3717,6 @@ mod tests {
)
}
#[test]
fn test_idm_webauthn_registration_and_counter_inc() {
run_idm_test!(
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
let ct = duration_from_epoch_now();
let mut idms_prox_write = idms.proxy_write(ct.clone());
let mut wa_softtok = WebauthnAuthenticator::new(U2FSoft::new());
let wrei = WebauthnInitRegisterEvent::new_internal(
UUID_ADMIN.clone(),
"softtoken".to_string(),
);
let (sessionid, ccr) = match idms_prox_write.reg_account_webauthn_init(&wrei, ct) {
Ok(SetCredentialResponse::WebauthnCreateChallenge(sessionid, ccr)) => {
(sessionid, ccr)
}
_ => {
panic!();
}
};
let rego = wa_softtok
.do_registration("https://idm.example.com", ccr)
.expect("Failed to register to softtoken");
let wdre =
WebauthnDoRegisterEvent::new_internal(UUID_ADMIN.clone(), sessionid, rego);
match idms_prox_write.reg_account_webauthn_complete(&wdre) {
Ok(SetCredentialResponse::Success) => {}
_ => {
panic!();
}
};
// Get the account now so we can peek at the registered credential.
let account = idms_prox_write
.target_to_account(&UUID_ADMIN)
.expect("account must exist");
let cred = account.primary.expect("Must exist.");
let wcred = cred
.webauthn_ref()
.expect("must have webauthn")
.values()
.next()
.map(|c| c.clone())
.expect("must have a webauthn credential");
assert!(idms_prox_write.commit().is_ok());
// ===
// Assert we can increment the counter if needed.
// Assert the delayed action queue is empty
idms_delayed.check_is_empty_or_panic();
// Generate a fake counter increment
let da = DelayedAction::WebauthnCounterIncrement(WebauthnCounterIncrement {
target_uuid: UUID_ADMIN.clone(),
counter: wcred.counter + 1,
cid: wcred.cred_id,
});
let r = task::block_on(idms.delayed_action(duration_from_epoch_now(), da));
assert!(Ok(true) == r);
// Check we can remove the webauthn device - provided we set a pw.
let mut idms_prox_write = idms.proxy_write(ct.clone());
let rwe =
RemoveWebauthnEvent::new_internal(UUID_ADMIN.clone(), "softtoken".to_string());
// This fails because the acc is webauthn only.
match idms_prox_write.remove_account_webauthn(&rwe) {
Err(OperationError::InvalidAttribute(_)) => {
//ok
}
_ => assert!(false),
};
// Reg a pw.
let pce = PasswordChangeEvent::new_internal(&UUID_ADMIN, TEST_PASSWORD);
assert!(idms_prox_write.set_account_password(&pce).is_ok());
// Now remove, it will work.
idms_prox_write
.remove_account_webauthn(&rwe)
.expect("Failed to remove webauthn");
assert!(idms_prox_write.commit().is_ok());
check_admin_password(idms, TEST_PASSWORD);
// All done!
}
)
}
#[test]
fn test_idm_backup_code_removal_delayed_action() {
run_idm_test!(
@ -3803,7 +3725,7 @@ mod tests {
let expire = ct - Duration::from_secs(AUTH_SESSION_TIMEOUT);
let mut idms_prox_write = idms.proxy_write(ct.clone());
// let mut wa_softtok = WebauthnAuthenticator::new(U2FSoft::new());
// let mut wa_softtok = WebauthnAuthenticator::new(SoftPasskey::new());
// The account must has primary credential + uses MFA before generating backup codes
// Set a password.
@ -3976,7 +3898,7 @@ mod tests {
// == webauthn
let uat = account
.to_userauthtoken(session_id, ct, AuthType::Webauthn)
.to_userauthtoken(session_id, ct, AuthType::Passkey)
.expect("Unable to create uat");
let ident = idms_prox_write
.process_uat_to_identity(&uat, ct)

View file

@ -75,7 +75,7 @@ pub mod prelude {
};
pub use crate::filter::{Filter, FilterInvalid, FC};
pub use crate::modify::{m_pres, m_purge, m_remove};
pub use crate::modify::{Modify, ModifyList};
pub use crate::modify::{Modify, ModifyInvalid, ModifyList};
pub use crate::entry::{
Entry, EntryCommitted, EntryInit, EntryInvalid, EntryInvalidCommitted, EntryNew,

View file

@ -101,7 +101,7 @@ pub struct SchemaAttribute {
impl SchemaAttribute {
pub fn try_from(value: &Entry<EntrySealed, EntryCommitted>) -> Result<Self, OperationError> {
// Convert entry to a schema attribute.
trace!("Converting -> {:?}", value);
trace!("Converting -> {}", value);
// uuid
let uuid = value.get_uuid();
@ -194,6 +194,8 @@ impl SchemaAttribute {
SyntaxType::OauthScopeMap => v.is_oauthscopemap() || v.is_refer(),
SyntaxType::PrivateBinary => v.is_privatebinary(),
SyntaxType::IntentToken => matches!(v, PartialValue::IntentToken(_)),
SyntaxType::Passkey => matches!(v, PartialValue::Passkey(_)),
SyntaxType::DeviceKey => matches!(v, PartialValue::DeviceKey(_)),
};
if r {
Ok(())
@ -234,6 +236,8 @@ impl SchemaAttribute {
SyntaxType::OauthScopeMap => v.is_oauthscopemap() || v.is_refer(),
SyntaxType::PrivateBinary => v.is_privatebinary(),
SyntaxType::IntentToken => matches!(v, Value::IntentToken(_, _)),
SyntaxType::Passkey => matches!(v, Value::Passkey(_, _, _)),
SyntaxType::DeviceKey => matches!(v, Value::DeviceKey(_, _, _)),
};
if r {
Ok(())
@ -304,7 +308,7 @@ pub struct SchemaClass {
impl SchemaClass {
pub fn try_from(value: &Entry<EntrySealed, EntryCommitted>) -> Result<Self, OperationError> {
trace!("Converting {:?}", value);
trace!("Converting {}", value);
// uuid
let uuid = value.get_uuid();
// Convert entry to a schema class.

View file

@ -523,6 +523,8 @@ pub trait QueryServerTransaction<'a> {
SyntaxType::OauthScopeMap => Err(OperationError::InvalidAttribute("Oauth Scope Maps can not be supplied through modification - please use the IDM api".to_string())),
SyntaxType::PrivateBinary => Err(OperationError::InvalidAttribute("Private Binary Values can not be supplied through modification".to_string())),
SyntaxType::IntentToken => Err(OperationError::InvalidAttribute("Intent Token Values can not be supplied through modification".to_string())),
SyntaxType::Passkey => Err(OperationError::InvalidAttribute("Passkey Values can not be supplied through modification".to_string())),
SyntaxType::DeviceKey => Err(OperationError::InvalidAttribute("DeviceKey Values can not be supplied through modification".to_string())),
}
}
None => {
@ -649,6 +651,16 @@ pub trait QueryServerTransaction<'a> {
"Invalid Intent Token ID (uuid) syntax".to_string(),
)
}),
SyntaxType::Passkey => PartialValue::new_passkey_s(value).ok_or_else(|| {
OperationError::InvalidAttribute("Invalid Passkey UUID syntax".to_string())
}),
SyntaxType::DeviceKey => {
PartialValue::new_devicekey_s(value).ok_or_else(|| {
OperationError::InvalidAttribute(
"Invalid DeviceKey UUID syntax".to_string(),
)
})
}
}
}
None => {
@ -2282,7 +2294,7 @@ impl<'a> QueryServerWriteTransaction<'a> {
filter: &Filter<FilterInvalid>,
modlist: &ModifyList<ModifyInvalid>,
) -> Result<(), OperationError> {
spanned!("server::intenal_modify", {
spanned!("server::internal_modify", {
let f_valid = filter
.validate(self.get_schema())
.map_err(OperationError::SchemaViolation)?;
@ -2554,6 +2566,8 @@ impl<'a> QueryServerWriteTransaction<'a> {
JSON_SCHEMA_ATTR_RS256_PRIVATE_KEY_DER,
JSON_SCHEMA_ATTR_CREDENTIAL_UPDATE_INTENT_TOKEN,
JSON_SCHEMA_ATTR_OAUTH2_CONSENT_SCOPE_MAP,
JSON_SCHEMA_ATTR_PASSKEYS,
JSON_SCHEMA_ATTR_DEVICEKEYS,
JSON_SCHEMA_CLASS_PERSON,
JSON_SCHEMA_CLASS_ORGPERSON,
JSON_SCHEMA_CLASS_GROUP,
@ -2952,8 +2966,11 @@ impl<'a> QueryServerWriteTransaction<'a> {
/// Initiate a domain rename process. This is generally an internal function but it's
/// exposed to the cli for admins to be able to initiate the process.
pub fn domain_rename(&self) -> Result<(), OperationError> {
unsafe { self.domain_rename_inner(self.d_info.d_name.as_str()) }
pub fn domain_rename(&self, new_domain_name: &str) -> Result<(), OperationError> {
// We can't use the d_info struct here, because this has the database version of the domain
// name, not the in memory (config) version. We need to accept the domain's
// new name from the caller so we can change this.
unsafe { self.domain_rename_inner(new_domain_name) }
}
/// # Safety

View file

@ -22,6 +22,9 @@ use sshkeys::PublicKey as SshPublicKey;
use regex::Regex;
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
lazy_static! {
pub static ref SPN_RE: Regex = {
#[allow(clippy::expect_used)]
@ -170,6 +173,8 @@ pub enum SyntaxType {
OauthScopeMap,
PrivateBinary,
IntentToken,
Passkey,
DeviceKey,
}
impl TryFrom<&str> for SyntaxType {
@ -202,6 +207,8 @@ impl TryFrom<&str> for SyntaxType {
"OAUTH_SCOPE_MAP" => Ok(SyntaxType::OauthScopeMap),
"PRIVATE_BINARY" => Ok(SyntaxType::PrivateBinary),
"INTENT_TOKEN" => Ok(SyntaxType::IntentToken),
"PASSKEY" => Ok(SyntaxType::Passkey),
"DEVICEKEY" => Ok(SyntaxType::DeviceKey),
_ => Err(()),
}
}
@ -235,6 +242,8 @@ impl TryFrom<usize> for SyntaxType {
20 => Ok(SyntaxType::OauthScopeMap),
21 => Ok(SyntaxType::PrivateBinary),
22 => Ok(SyntaxType::IntentToken),
23 => Ok(SyntaxType::Passkey),
24 => Ok(SyntaxType::DeviceKey),
_ => Err(()),
}
}
@ -266,6 +275,8 @@ impl SyntaxType {
SyntaxType::OauthScopeMap => 20,
SyntaxType::PrivateBinary => 21,
SyntaxType::IntentToken => 22,
SyntaxType::Passkey => 23,
SyntaxType::DeviceKey => 24,
}
}
}
@ -296,6 +307,8 @@ impl fmt::Display for SyntaxType {
SyntaxType::OauthScopeMap => "OAUTH_SCOPE_MAP",
SyntaxType::PrivateBinary => "PRIVATE_BINARY",
SyntaxType::IntentToken => "INTENT_TOKEN",
SyntaxType::Passkey => "PASSKEY",
SyntaxType::DeviceKey => "DEVICEKEY",
})
}
}
@ -341,6 +354,10 @@ pub enum PartialValue {
// Float64(f64),
RestrictedString(String),
IntentToken(String),
Passkey(Uuid),
DeviceKey(Uuid),
TrustedDeviceEnrollment(Uuid),
AuthSession(Uuid),
}
@ -659,6 +676,14 @@ impl PartialValue {
Some(PartialValue::IntentToken(s))
}
pub fn new_passkey_s(us: &str) -> Option<Self> {
Uuid::parse_str(us).map(PartialValue::Passkey).ok()
}
pub fn new_devicekey_s(us: &str) -> Option<Self> {
Uuid::parse_str(us).map(PartialValue::DeviceKey).ok()
}
pub fn to_str(&self) -> Option<&str> {
match self {
PartialValue::Utf8(s) => Some(s.as_str()),
@ -683,7 +708,10 @@ impl PartialValue {
| PartialValue::Nsuniqueid(s)
| PartialValue::EmailAddress(s)
| PartialValue::RestrictedString(s) => s.clone(),
PartialValue::Refer(u) | PartialValue::Uuid(u) => u.as_hyphenated().to_string(),
PartialValue::Passkey(u)
| PartialValue::DeviceKey(u)
| PartialValue::Refer(u)
| PartialValue::Uuid(u) => u.as_hyphenated().to_string(),
PartialValue::Bool(b) => b.to_string(),
PartialValue::Syntax(syn) => syn.to_string(),
PartialValue::Index(it) => it.to_string(),
@ -760,6 +788,9 @@ pub enum Value {
// Float64(f64),
RestrictedString(String),
IntentToken(String, IntentTokenState),
Passkey(Uuid, String, PasskeyV4),
DeviceKey(Uuid, String, DeviceKeyV4),
TrustedDeviceEnrollment(Uuid),
AuthSession(Uuid),
}

View file

@ -5,10 +5,15 @@ use crate::valueset::ValueSet;
use std::collections::btree_map::Entry as BTreeEntry;
use std::collections::BTreeMap;
use crate::be::dbvalue::{DbValueCredV1, DbValueIntentTokenStateV1};
use crate::be::dbvalue::{
DbValueCredV1, DbValueDeviceKeyV1, DbValueIntentTokenStateV1, DbValuePasskeyV1,
};
use crate::credential::Credential;
use crate::valueset::IntentTokenState;
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
#[derive(Debug, Clone)]
pub struct ValueSetCredential {
map: BTreeMap<String, Credential>,
@ -343,3 +348,315 @@ impl ValueSetT for ValueSetIntentToken {
Some(&self.map)
}
}
#[derive(Debug, Clone)]
pub struct ValueSetPasskey {
map: BTreeMap<Uuid, (String, PasskeyV4)>,
}
impl ValueSetPasskey {
pub fn new(u: Uuid, t: String, k: PasskeyV4) -> Box<Self> {
let mut map = BTreeMap::new();
map.insert(u, (t, k));
Box::new(ValueSetPasskey { map })
}
pub fn push(&mut self, u: Uuid, t: String, k: PasskeyV4) -> bool {
self.map.insert(u, (t, k)).is_none()
}
pub fn from_dbvs2(data: Vec<DbValuePasskeyV1>) -> Result<ValueSet, OperationError> {
let map = data
.into_iter()
.map(|k| match k {
DbValuePasskeyV1::V4 { u, t, k } => Ok((u, (t, k))),
})
.collect::<Result<_, _>>()?;
Ok(Box::new(ValueSetPasskey { map }))
}
pub fn from_iter<T>(iter: T) -> Option<Box<Self>>
where
T: IntoIterator<Item = (Uuid, String, PasskeyV4)>,
{
let map = iter.into_iter().map(|(u, t, k)| (u, (t, k))).collect();
Some(Box::new(ValueSetPasskey { map }))
}
}
impl ValueSetT for ValueSetPasskey {
fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
match value {
Value::Passkey(u, t, k) => {
if let BTreeEntry::Vacant(e) = self.map.entry(u) {
e.insert((t, k));
Ok(true)
} else {
Ok(false)
}
}
_ => Err(OperationError::InvalidValueState),
}
}
fn clear(&mut self) {
self.map.clear();
}
fn remove(&mut self, pv: &PartialValue) -> bool {
match pv {
PartialValue::Passkey(u) => self.map.remove(u).is_some(),
_ => false,
}
}
fn contains(&self, pv: &PartialValue) -> bool {
match pv {
PartialValue::Passkey(u) => self.map.contains_key(u),
_ => false,
}
}
fn substring(&self, _pv: &PartialValue) -> bool {
false
}
fn lessthan(&self, _pv: &PartialValue) -> bool {
false
}
fn len(&self) -> usize {
self.map.len()
}
fn generate_idx_eq_keys(&self) -> Vec<String> {
self.map
.keys()
.map(|u| u.as_hyphenated().to_string())
.collect()
}
fn syntax(&self) -> SyntaxType {
SyntaxType::Passkey
}
fn validate(&self, _schema_attr: &SchemaAttribute) -> bool {
true
}
fn to_proto_string_clone_iter(&self) -> Box<dyn Iterator<Item = String> + '_> {
Box::new(self.map.values().map(|(t, _)| t).cloned())
}
fn to_db_valueset_v2(&self) -> DbValueSetV2 {
DbValueSetV2::Passkey(
self.map
.iter()
.map(|(u, (t, k))| DbValuePasskeyV1::V4 {
u: *u,
t: t.clone(),
k: k.clone(),
})
.collect(),
)
}
fn to_partialvalue_iter(&self) -> Box<dyn Iterator<Item = PartialValue> + '_> {
Box::new(self.map.keys().cloned().map(PartialValue::Passkey))
}
fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
Box::new(
self.map
.iter()
.map(|(u, (t, k))| Value::Passkey(*u, t.clone(), k.clone())),
)
}
fn equal(&self, other: &ValueSet) -> bool {
// Looks like we may not need this?
if let Some(other) = other.as_passkey_map() {
&self.map == other
} else {
// debug_assert!(false);
false
}
}
fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> {
if let Some(b) = other.as_passkey_map() {
mergemaps!(self.map, b)
} else {
debug_assert!(false);
Err(OperationError::InvalidValueState)
}
}
fn to_passkey_single(&self) -> Option<&PasskeyV4> {
if self.map.len() == 1 {
self.map.values().take(1).next().map(|(_, k)| k)
} else {
None
}
}
fn as_passkey_map(&self) -> Option<&BTreeMap<Uuid, (String, PasskeyV4)>> {
Some(&self.map)
}
}
#[derive(Debug, Clone)]
pub struct ValueSetDeviceKey {
map: BTreeMap<Uuid, (String, DeviceKeyV4)>,
}
impl ValueSetDeviceKey {
pub fn new(u: Uuid, t: String, k: DeviceKeyV4) -> Box<Self> {
let mut map = BTreeMap::new();
map.insert(u, (t, k));
Box::new(ValueSetDeviceKey { map })
}
pub fn push(&mut self, u: Uuid, t: String, k: DeviceKeyV4) -> bool {
self.map.insert(u, (t, k)).is_none()
}
pub fn from_dbvs2(data: Vec<DbValueDeviceKeyV1>) -> Result<ValueSet, OperationError> {
let map = data
.into_iter()
.map(|k| match k {
DbValueDeviceKeyV1::V4 { u, t, k } => Ok((u, (t, k))),
})
.collect::<Result<_, _>>()?;
Ok(Box::new(ValueSetDeviceKey { map }))
}
pub fn from_iter<T>(iter: T) -> Option<Box<Self>>
where
T: IntoIterator<Item = (Uuid, String, DeviceKeyV4)>,
{
let map = iter.into_iter().map(|(u, t, k)| (u, (t, k))).collect();
Some(Box::new(ValueSetDeviceKey { map }))
}
}
impl ValueSetT for ValueSetDeviceKey {
fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
match value {
Value::DeviceKey(u, t, k) => {
if let BTreeEntry::Vacant(e) = self.map.entry(u) {
e.insert((t, k));
Ok(true)
} else {
Ok(false)
}
}
_ => Err(OperationError::InvalidValueState),
}
}
fn clear(&mut self) {
self.map.clear();
}
fn remove(&mut self, pv: &PartialValue) -> bool {
match pv {
PartialValue::DeviceKey(u) => self.map.remove(u).is_some(),
_ => false,
}
}
fn contains(&self, pv: &PartialValue) -> bool {
match pv {
PartialValue::DeviceKey(u) => self.map.contains_key(u),
_ => false,
}
}
fn substring(&self, _pv: &PartialValue) -> bool {
false
}
fn lessthan(&self, _pv: &PartialValue) -> bool {
false
}
fn len(&self) -> usize {
self.map.len()
}
fn generate_idx_eq_keys(&self) -> Vec<String> {
self.map
.keys()
.map(|u| u.as_hyphenated().to_string())
.collect()
}
fn syntax(&self) -> SyntaxType {
SyntaxType::DeviceKey
}
fn validate(&self, _schema_attr: &SchemaAttribute) -> bool {
true
}
fn to_proto_string_clone_iter(&self) -> Box<dyn Iterator<Item = String> + '_> {
Box::new(self.map.values().map(|(t, _)| t).cloned())
}
fn to_db_valueset_v2(&self) -> DbValueSetV2 {
DbValueSetV2::DeviceKey(
self.map
.iter()
.map(|(u, (t, k))| DbValueDeviceKeyV1::V4 {
u: *u,
t: t.clone(),
k: k.clone(),
})
.collect(),
)
}
fn to_partialvalue_iter(&self) -> Box<dyn Iterator<Item = PartialValue> + '_> {
Box::new(self.map.keys().copied().map(PartialValue::DeviceKey))
}
fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
Box::new(
self.map
.iter()
.map(|(u, (t, k))| Value::DeviceKey(*u, t.clone(), k.clone())),
)
}
fn equal(&self, other: &ValueSet) -> bool {
// Looks like we may not need this?
if let Some(other) = other.as_devicekey_map() {
&self.map == other
} else {
// debug_assert!(false);
false
}
}
fn merge(&mut self, other: &ValueSet) -> Result<(), OperationError> {
if let Some(b) = other.as_devicekey_map() {
mergemaps!(self.map, b)
} else {
debug_assert!(false);
Err(OperationError::InvalidValueState)
}
}
fn to_devicekey_single(&self) -> Option<&DeviceKeyV4> {
if self.map.len() == 1 {
self.map.values().take(1).next().map(|(_, k)| k)
} else {
None
}
}
fn as_devicekey_map(&self) -> Option<&BTreeMap<Uuid, (String, DeviceKeyV4)>> {
Some(&self.map)
}
}

View file

@ -15,6 +15,9 @@ use dyn_clone::DynClone;
use smolset::SmolSet;
// use std::fmt::Debug;
use webauthn_rs::prelude::DeviceKey as DeviceKeyV4;
use webauthn_rs::prelude::Passkey as PasskeyV4;
use time::OffsetDateTime;
mod address;
@ -43,7 +46,7 @@ pub use self::address::{ValueSetAddress, ValueSetEmailAddress};
pub use self::binary::{ValueSetPrivateBinary, ValueSetPublicBinary};
pub use self::bool::ValueSetBool;
pub use self::cid::ValueSetCid;
pub use self::cred::{ValueSetCredential, ValueSetIntentToken};
pub use self::cred::{ValueSetCredential, ValueSetDeviceKey, ValueSetIntentToken, ValueSetPasskey};
pub use self::datetime::ValueSetDateTime;
pub use self::iname::ValueSetIname;
pub use self::index::ValueSetIndex;
@ -306,6 +309,16 @@ pub trait ValueSetT: std::fmt::Debug + DynClone {
None
}
fn as_passkey_map(&self) -> Option<&BTreeMap<Uuid, (String, PasskeyV4)>> {
debug_assert!(false);
None
}
fn as_devicekey_map(&self) -> Option<&BTreeMap<Uuid, (String, DeviceKeyV4)>> {
debug_assert!(false);
None
}
fn to_value_single(&self) -> Option<Value> {
if self.len() != 1 {
None
@ -442,6 +455,16 @@ pub trait ValueSetT: std::fmt::Debug + DynClone {
debug_assert!(false);
None
}
fn to_passkey_single(&self) -> Option<&PasskeyV4> {
debug_assert!(false);
None
}
fn to_devicekey_single(&self) -> Option<&DeviceKeyV4> {
debug_assert!(false);
None
}
}
impl PartialEq for ValueSet {
@ -539,6 +562,8 @@ pub fn from_value_iter(mut iter: impl Iterator<Item = Value>) -> Result<ValueSet
Value::PublicBinary(t, b) => ValueSetPublicBinary::new(t, b),
Value::IntentToken(u, s) => ValueSetIntentToken::new(u, s),
Value::EmailAddress(a, _) => ValueSetEmailAddress::new(a),
Value::Passkey(u, t, k) => ValueSetPasskey::new(u, t, k),
Value::DeviceKey(u, t, k) => ValueSetDeviceKey::new(u, t, k),
_ => return Err(OperationError::InvalidValueState),
};
@ -576,6 +601,8 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result<ValueSet, OperationErro
DbValueSetV2::PublicBinary(set) => ValueSetPublicBinary::from_dbvs2(set),
DbValueSetV2::IntentToken(set) => ValueSetIntentToken::from_dbvs2(set),
DbValueSetV2::EmailAddress(primary, set) => ValueSetEmailAddress::from_dbvs2(primary, set),
DbValueSetV2::Passkey(set) => ValueSetPasskey::from_dbvs2(set),
DbValueSetV2::DeviceKey(set) => ValueSetDeviceKey::from_dbvs2(set),
/*
DbValueSetV2::PhoneNumber(set) =>
DbValueSetV2::TrustedDeviceEnrollment(set) =>

View file

@ -48,7 +48,7 @@ tracing-subscriber = "^0.3.14"
futures = "^0.3.21"
# async-std = { version = "1.6", features = ["tokio1"] }
webauthn-authenticator-rs = "^0.3.2"
webauthn-authenticator-rs = "0.4.2-beta.3"
oauth2_ext = { package = "oauth2", version = "^4.1.0", default-features = false }
base64 = "^0.13.0"

View file

@ -702,6 +702,10 @@ pub fn create_https_server(
.at("/_commit")
.mapped_post(&mut routemap, credential_update_commit);
cred_route
.at("/_cancel")
.mapped_post(&mut routemap, credential_update_cancel);
let mut group_route = appserver.at("/v1/group");
group_route
.at("/")

View file

@ -500,6 +500,18 @@ pub async fn credential_update_commit(mut req: tide::Request<AppState>) -> tide:
to_tide_response(res, hvalue)
}
pub async fn credential_update_cancel(mut req: tide::Request<AppState>) -> tide::Result {
let (eventid, hvalue) = req.new_eventid();
let session_token: CUSessionToken = req.body_json().await?;
let res = req
.state()
.qe_w_ref
.handle_idmcredentialupdatecancel(session_token, eventid)
.await;
to_tide_response(res, hvalue)
}
pub async fn account_get_id_credential_status(req: tide::Request<AppState>) -> tide::Result {
let uat = req.get_current_uat();
let uuid_or_name = req.get_url_param("id")?;

View file

@ -409,7 +409,7 @@ pub fn domain_rename_core(config: &Configuration) {
}
};
// setup the qs - *with out* init of the migrations and schema.
// Setup the qs, and perform any migrations and changes we may have.
let qs = match setup_qs(be, schema, config) {
Ok(t) => t,
Err(e) => {
@ -441,7 +441,7 @@ pub fn domain_rename_core(config: &Configuration) {
}
let qs_write = task::block_on(qs.write_async(duration_from_epoch_now()));
let r = qs_write.domain_rename().and_then(|_| qs_write.commit());
let r = qs_write.domain_rename(new_domain_name).and_then(|_| qs_write.commit());
match r {
Ok(_) => info!("Domain Rename Success!"),

View file

@ -45,6 +45,8 @@ pub async fn setup_async_test() -> KanidmClient {
admin_password: ADMIN_TEST_PASSWORD.to_string(),
});
let addr = format!("http://localhost:{}", port);
// Setup the config ...
let mut config = Configuration::new();
config.address = format!("127.0.0.1:{}", port);
@ -52,6 +54,8 @@ pub async fn setup_async_test() -> KanidmClient {
config.integration_test_config = Some(int_config);
config.log_level = Some(LogLevel::Quiet as u32);
config.role = ServerRole::WriteReplicaNoUI;
config.domain = "localhost".to_string();
config.origin = addr.clone();
// config.log_level = Some(LogLevel::Verbose as u32);
// config.log_level = Some(LogLevel::FullTrace as u32);
config.threads = 1;
@ -62,7 +66,6 @@ pub async fn setup_async_test() -> KanidmClient {
// We have to yield now to guarantee that the tide elements are setup.
task::yield_now().await;
let addr = format!("http://127.0.0.1:{}", port);
let rsclient = KanidmClientBuilder::new()
.address(addr)
.no_proxy()

View file

@ -64,7 +64,7 @@ async fn test_oauth2_openid_basic_flow() {
rsclient
.idm_account_person_extend(
"admin",
Some(&["admin@idm.example.com".to_string()]),
Some(&["admin@localhost".to_string()]),
Some("Admin Istrator"),
)
.await
@ -138,34 +138,32 @@ async fn test_oauth2_openid_basic_flow() {
.await
.expect("Failed to access response body");
tracing::trace!(?discovery);
// Most values are checked in idm/oauth2.rs, but we want to sanity check
// the urls here as an extended function smoke test.
assert!(
discovery.issuer
== Url::parse("https://idm.example.com/oauth2/openid/test_integration").unwrap()
discovery.issuer == Url::parse(&format!("{}/oauth2/openid/test_integration", url)).unwrap()
);
assert!(
discovery.authorization_endpoint
== Url::parse("https://idm.example.com/ui/oauth2").unwrap()
);
assert!(discovery.authorization_endpoint == Url::parse(&format!("{}/ui/oauth2", url)).unwrap());
assert!(
discovery.token_endpoint == Url::parse("https://idm.example.com/oauth2/token").unwrap()
);
assert!(discovery.token_endpoint == Url::parse(&format!("{}/oauth2/token", url)).unwrap());
assert!(
discovery.userinfo_endpoint
== Some(
Url::parse("https://idm.example.com/oauth2/openid/test_integration/userinfo")
.unwrap()
Url::parse(&format!("{}/oauth2/openid/test_integration/userinfo", url)).unwrap()
)
);
assert!(
discovery.jwks_uri
== Url::parse("https://idm.example.com/oauth2/openid/test_integration/public_key.jwk")
.unwrap()
== Url::parse(&format!(
"{}/oauth2/openid/test_integration/public_key.jwk",
url
))
.unwrap()
);
// Step 0 - get the jwks public key.
@ -319,7 +317,7 @@ async fn test_oauth2_openid_basic_flow() {
assert!(tir.active);
assert!(tir.scope.is_some());
assert!(tir.client_id.as_deref() == Some("test_integration"));
assert!(tir.username.as_deref() == Some("admin@idm.example.com"));
assert!(tir.username.as_deref() == Some("admin@localhost"));
assert!(tir.token_type.as_deref() == Some("access_token"));
assert!(tir.exp.is_some());
assert!(tir.iat.is_some());
@ -339,10 +337,8 @@ async fn test_oauth2_openid_basic_flow() {
// This is mostly checked inside of idm/oauth2.rs. This is more to check the oidc
// token and the userinfo endpoints.
assert!(
oidc.iss == Url::parse("https://idm.example.com/oauth2/openid/test_integration").unwrap()
);
assert!(oidc.s_claims.email.as_deref() == Some("admin@idm.example.com"));
assert!(oidc.iss == Url::parse(&format!("{}/oauth2/openid/test_integration", url)).unwrap());
assert!(oidc.s_claims.email.as_deref() == Some("admin@localhost"));
assert!(oidc.s_claims.email_verified == Some(true));
let response = client

View file

@ -9,7 +9,7 @@ use kanidm_proto::v1::{CURegState, CredentialDetailType, Entry, Filter, Modify,
mod common;
use crate::common::{setup_async_test, ADMIN_TEST_PASSWORD};
use webauthn_authenticator_rs::{softtok::U2FSoft, WebauthnAuthenticator};
use webauthn_authenticator_rs::{softpasskey::SoftPasskey, WebauthnAuthenticator};
const ADMIN_TEST_PASSWORD_CHANGE: &str = "integration test admin new🎉";
const UNIX_TEST_PASSWORD: &str = "unix test user password";
@ -84,7 +84,7 @@ async fn test_server_whoami_anonymous() {
None => panic!(),
};
debug!("{}", uat);
assert!(uat.spn == "anonymous@idm.example.com");
assert!(uat.spn == "anonymous@localhost");
// Do a check of the auth/valid endpoint, tells us if our token
// is okay.
@ -111,7 +111,7 @@ async fn test_server_whoami_admin_simple_password() {
None => panic!(),
};
debug!("{}", uat);
assert!(uat.spn == "admin@idm.example.com");
assert!(uat.spn == "admin@localhost");
}
#[tokio::test]
@ -301,7 +301,7 @@ async fn test_server_rest_group_lifecycle() {
.await
.unwrap();
let members = rsclient.idm_group_get_members("demo_group").await.unwrap();
assert!(members == Some(vec!["admin@idm.example.com".to_string()]));
assert!(members == Some(vec!["admin@localhost".to_string()]));
// Set the list of members
rsclient
@ -312,8 +312,8 @@ async fn test_server_rest_group_lifecycle() {
assert!(
members
== Some(vec![
"admin@idm.example.com".to_string(),
"demo_group@idm.example.com".to_string()
"admin@localhost".to_string(),
"demo_group@localhost".to_string()
])
);
@ -323,7 +323,7 @@ async fn test_server_rest_group_lifecycle() {
.await
.unwrap();
let members = rsclient.idm_group_get_members("demo_group").await.unwrap();
assert!(members == Some(vec!["admin@idm.example.com".to_string()]));
assert!(members == Some(vec!["admin@localhost".to_string()]));
// purge members
rsclient
@ -346,7 +346,7 @@ async fn test_server_rest_group_lifecycle() {
// They should have members
let members = rsclient.idm_group_get_members("idm_admins").await.unwrap();
println!("{:?}", members);
assert!(members == Some(vec!["idm_admin@idm.example.com".to_string()]));
assert!(members == Some(vec!["idm_admin@localhost".to_string()]));
}
#[tokio::test]
@ -1062,201 +1062,6 @@ async fn test_server_rest_backup_code_auth_lifecycle() {
.is_ok());
}
#[tokio::test]
async fn test_server_rest_webauthn_auth_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();
// Create a new account
rsclient
.idm_account_create("demo_account", "Deeeeemo")
.await
.unwrap();
// Enroll a soft token to the account webauthn.
let mut wa_softtok = WebauthnAuthenticator::new(U2FSoft::new());
// Do the challenge
let (sessionid, regchal) = rsclient
.idm_account_primary_credential_register_webauthn("demo_account", "softtok")
.await
.unwrap();
let rego = wa_softtok
.do_registration("https://idm.example.com", regchal)
.expect("Failed to register to softtoken");
// Enroll the cred after signing.
rsclient
.idm_account_primary_credential_complete_webuthn_registration(
"demo_account",
rego,
sessionid,
)
.await
.unwrap();
// ====== Reg a second token.
let mut wa_softtok_2 = WebauthnAuthenticator::new(U2FSoft::new());
// Do the challenge
let (sessionid, regchal) = rsclient
.idm_account_primary_credential_register_webauthn("demo_account", "softtok_2")
.await
.unwrap();
let rego = wa_softtok_2
.do_registration("https://idm.example.com", regchal)
.expect("Failed to register to softtoken");
// Enroll the cred after signing.
rsclient
.idm_account_primary_credential_complete_webuthn_registration(
"demo_account",
rego,
sessionid,
)
.await
.unwrap();
// Now do an auth
let rsclient_good = rsclient.new_session().unwrap();
let pkr = rsclient_good
.auth_webauthn_begin("demo_account")
.await
.unwrap();
// Get the auth chal.
let auth = wa_softtok_2
.do_authentication("https://idm.example.com", pkr)
.expect("Failed to auth to softtoken");
// Submit the webauthn auth.
rsclient_good
.auth_webauthn_complete(auth)
.await
.expect("Failed to authenticate");
// ======== remove the second softtok.
rsclient
.idm_account_primary_credential_remove_webauthn("demo_account", "softtok_2")
.await
.expect("failed to remove softtoken");
// All good, check first tok auth.
let rsclient_good = rsclient.new_session().unwrap();
let pkr = rsclient_good
.auth_webauthn_begin("demo_account")
.await
.unwrap();
// Get the auth chal.
let auth = wa_softtok
.do_authentication("https://idm.example.com", pkr)
.expect("Failed to auth to softtoken");
// Submit the webauthn auth.
rsclient_good
.auth_webauthn_complete(auth)
.await
.expect("Failed to authenticate");
}
#[tokio::test]
async fn test_server_rest_webauthn_mfa_auth_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();
// Create a new account
rsclient
.idm_account_create("demo_account", "Deeeeemo")
.await
.unwrap();
// Enroll a soft token to the account webauthn.
let mut wa_softtok = WebauthnAuthenticator::new(U2FSoft::new());
// Do the challenge
let (sessionid, regchal) = rsclient
.idm_account_primary_credential_register_webauthn("demo_account", "softtok")
.await
.unwrap();
let rego = wa_softtok
.do_registration("https://idm.example.com", regchal)
.expect("Failed to register to softtoken");
// Enroll the cred after signing.
rsclient
.idm_account_primary_credential_complete_webuthn_registration(
"demo_account",
rego,
sessionid,
)
.await
.unwrap();
// Now do an auth
let rsclient_good = rsclient.new_session().unwrap();
let pkr = rsclient_good
.auth_webauthn_begin("demo_account")
.await
.unwrap();
// Get the auth chal.
let auth = wa_softtok
.do_authentication("https://idm.example.com", pkr)
.expect("Failed to auth to softtoken");
// Submit the webauthn auth.
rsclient_good
.auth_webauthn_complete(auth)
.await
.expect("Failed to authenticate");
// Set a password to cause the state to change to PasswordMfa
assert!(rsclient
.idm_account_primary_credential_set_password("demo_account", "sohdi3iuHo6mai7noh0a")
.await
.is_ok());
// Now remove Webauthn ...
rsclient
.idm_account_primary_credential_remove_webauthn("demo_account", "softtok")
.await
.expect("failed to remove softtoken");
// Check pw only
let rsclient_good = rsclient.new_session().unwrap();
assert!(rsclient_good
.auth_simple_password("demo_account", "sohdi3iuHo6mai7noh0a")
.await
.is_ok());
}
#[tokio::test]
async fn test_server_rest_oauth2_basic_lifecycle() {
let rsclient = setup_async_test().await;
@ -1560,3 +1365,92 @@ async fn test_server_credential_update_session_totp_pw() {
.await;
assert!(res.is_ok());
}
#[tokio::test]
async fn test_server_credential_update_session_passkey() {
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();
// Create an account
rsclient
.idm_account_create("demo_account", "Demo Account")
.await
.unwrap();
// Create an intent token for them
let intent_token = rsclient
.idm_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();
let _status = rsclient
.idm_account_credential_update_status(&session_token)
.await
.unwrap();
// Setup and update the passkey
let mut wa = WebauthnAuthenticator::new(SoftPasskey::new());
let status = rsclient
.idm_account_credential_update_passkey_init(&session_token)
.await
.unwrap();
let passkey_chal = match status.mfaregstate {
CURegState::Passkey(c) => Some(c),
_ => None,
}
.expect("Unable to access passkey challenge, invalid state");
eprintln!("{}", rsclient.get_origin());
let passkey_resp = wa
.do_registration(rsclient.get_origin().clone(), passkey_chal)
.expect("Failed to create soft passkey");
let label = "Soft Passkey".to_string();
let status = rsclient
.idm_account_credential_update_passkey_finish(&session_token, label, passkey_resp)
.await
.unwrap();
assert!(status.can_commit);
assert!(status.passkeys.len() == 1);
// Commit it
rsclient
.idm_account_credential_update_commit(&session_token)
.await
.unwrap();
// Assert it now works.
let _ = rsclient.logout();
let res = rsclient
.auth_passkey_begin("demo_account")
.await
.expect("Failed to start passkey auth");
let pkc = wa
.do_authentication(rsclient.get_origin().clone(), res)
.expect("Failed to authentication with soft passkey");
let res = rsclient.auth_passkey_complete(pkc).await;
assert!(res.is_ok());
}

View file

@ -13,6 +13,18 @@ documentation = "https://docs.rs/kanidm/latest/kanidm/"
homepage = "https://github.com/kanidm/kanidm/"
repository = "https://github.com/kanidm/kanidm/"
[profile.release]
# less code to include into binary
panic = 'abort'
# optimization over all codebase ( better optimization, slower build )
codegen-units = 1
# optimization for size ( more aggressive )
opt-level = 'z'
# optimization for size
# opt-level = 's'
# link time optimization using using whole-program analysis
lto = true
[lib]
crate-type = ["cdylib", "rlib"]
@ -22,9 +34,8 @@ serde_json = "^1.0.82"
wasm-bindgen = { version = "^0.2.81", features = ["serde-serialize"] }
wasm-bindgen-futures = { version = "^0.4.30" }
kanidm_proto = { path = "../kanidm_proto" }
kanidm_proto = { path = "../kanidm_proto", features = ["wasm"] }
webauthn-rs = { version = "^0.3.2", default-features = false, features = ["wasm"] }
qrcode = { version = "^0.12.0", default-features = false, features = ["svg"] }
yew-router = "^0.16.0"
@ -33,9 +44,13 @@ yew-agent = "^0.1.0"
gloo = "^0.8.0"
js-sys = "^0.3.58"
uuid = "^1.1.2"
compact_jwt = { version = "^0.2.3", default-features = false, features = ["unsafe_release_without_verify"] }
# compact_jwt = { path = "../../compact_jwt" , default-features = false, features = ["unsafe_release_without_verify"] }
wee_alloc = "^0.4.5"
[dependencies.web-sys]
version = "^0.3.57"
features = [

View file

@ -12,9 +12,9 @@ export interface InitOutput {
readonly __wbindgen_malloc: (a: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number) => number;
readonly __wbindgen_export_2: WebAssembly.Table;
readonly _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc0b84f4ddf4a8fd2: (a: number, b: number, c: number) => void;
readonly _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hfed1f3471f1b926f: (a: number, b: number, c: number) => void;
readonly _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h3b7aa7dd2123cac1: (a: number, b: number, c: number) => void;
readonly _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc22c8c82b073edeb: (a: number, b: number, c: number) => void;
readonly _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h88db5b93ed6c64b4: (a: number, b: number, c: number) => void;
readonly _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__ha0cda2043cde760e: (a: number, b: number, c: number) => void;
readonly __wbindgen_add_to_stack_pointer: (a: number) => number;
readonly __wbindgen_free: (a: number, b: number) => void;
readonly __wbindgen_exn_store: (a: number) => void;

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,9 @@ export function run_app(a: number): void;
export function __wbindgen_malloc(a: number): number;
export function __wbindgen_realloc(a: number, b: number, c: number): number;
export const __wbindgen_export_2: WebAssembly.Table;
export function _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc0b84f4ddf4a8fd2(a: number, b: number, c: number): void;
export function _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hfed1f3471f1b926f(a: number, b: number, c: number): void;
export function _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h3b7aa7dd2123cac1(a: number, b: number, c: number): void;
export function _dyn_core__ops__function__FnMut___A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hc22c8c82b073edeb(a: number, b: number, c: number): void;
export function _dyn_core__ops__function__Fn__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h88db5b93ed6c64b4(a: number, b: number, c: number): void;
export function _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__ha0cda2043cde760e(a: number, b: number, c: number): void;
export function __wbindgen_add_to_stack_pointer(a: number): number;
export function __wbindgen_free(a: number, b: number): void;
export function __wbindgen_exn_store(a: number): void;

View file

@ -2,5 +2,7 @@ pub mod reset;
mod delete;
mod eventbus;
mod passkey;
mod passkeyremove;
mod pwmodal;
mod totpmodal;

View file

@ -0,0 +1,392 @@
use crate::error::*;
use crate::utils;
use super::eventbus::{EventBus, EventBusMsg};
use super::reset::ModalProps;
use gloo::console;
use yew::prelude::*;
use yew_agent::Dispatched;
use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt};
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use kanidm_proto::v1::{CURegState, CURequest, CUSessionToken, CUStatus};
use kanidm_proto::webauthn::{CreationChallengeResponse, RegisterPublicKeyCredential};
pub struct PasskeyModalApp {
state: State,
label_val: String,
}
pub enum State {
Init,
FetchingChallenge,
ChallengeReady(CreationChallengeResponse),
CredentialReady(RegisterPublicKeyCredential),
Submitting,
}
pub enum Msg {
LabelCheck,
Cancel,
Submit,
Generate,
Success,
ChallengeReady(CreationChallengeResponse),
CredentialCreate,
CredentialReady(RegisterPublicKeyCredential),
Error { emsg: String, kopid: Option<String> },
NavigatorError,
}
impl From<FetchError> for Msg {
fn from(fe: FetchError) -> Self {
Msg::Error {
emsg: fe.as_string(),
kopid: None,
}
}
}
impl PasskeyModalApp {
fn reset_and_hide(&mut self) {
utils::modal_hide_by_id("staticPasskeyCreate");
self.state = State::Init;
self.label_val = "".to_string();
}
async fn submit_passkey_update(
token: CUSessionToken,
req: CURequest,
) -> Result<Msg, FetchError> {
let req_jsvalue = serde_json::to_string(&(req, token))
.map(|s| JsValue::from(&s))
.expect_throw("Failed to serialise pw curequest");
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::SameOrigin);
opts.body(Some(&req_jsvalue));
let request = Request::new_with_str_and_init("/v1/credential/_update", &opts)?;
request
.headers()
.set("content-type", "application/json")
.expect_throw("failed to set header");
let window = utils::window();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into().expect_throw("Invalid response type");
let status = resp.status();
let headers = resp.headers();
let kopid = headers.get("x-kanidm-opid").ok().flatten();
if status == 200 {
let jsval = JsFuture::from(resp.json()?).await?;
let status: CUStatus = jsval.into_serde().expect_throw("Invalid response type");
EventBus::dispatcher().send(EventBusMsg::UpdateStatus {
status: status.clone(),
});
Ok(match status.mfaregstate {
CURegState::TotpCheck(_)
| CURegState::TotpTryAgain
| CURegState::TotpInvalidSha1
| CURegState::BackupCodes(_) => Msg::Error {
emsg: "Invalid Passkey reg state response".to_string(),
kopid,
},
CURegState::Passkey(challenge) => Msg::ChallengeReady(challenge),
CURegState::None => Msg::Success,
})
} else {
let text = JsFuture::from(resp.text()?).await?;
let emsg = text.as_string().unwrap_or_else(|| "".to_string());
Ok(Msg::Error { emsg, kopid })
}
}
}
impl Component for PasskeyModalApp {
type Message = Msg;
type Properties = ModalProps;
fn create(_ctx: &Context<Self>) -> Self {
console::log!("passkey modal create");
PasskeyModalApp {
state: State::Init,
label_val: "".to_string(),
}
}
fn changed(&mut self, _ctx: &Context<Self>) -> bool {
console::log!("passkey modal::change");
false
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
console::log!("passkey modal::update");
match msg {
Msg::LabelCheck => {
let label = utils::get_value_from_element_id("passkey-label")
.unwrap_or_else(|| "".to_string());
self.label_val = label;
}
Msg::Submit => {
if let State::CredentialReady(rpkc) = &self.state {
let rpkc = rpkc.clone();
let label = self.label_val.clone();
// Init a fetch to get the challenge.
let token_c = ctx.props().token.clone();
ctx.link().send_future(async {
match Self::submit_passkey_update(
token_c,
CURequest::PasskeyFinish(label, rpkc),
)
.await
{
Ok(v) => v,
Err(v) => v.into(),
}
});
self.state = State::Submitting;
}
// Error?
}
Msg::Success => {
self.reset_and_hide();
}
Msg::Generate => {
// Init a fetch to get the challenge.
let token_c = ctx.props().token.clone();
ctx.link().send_future(async {
match Self::submit_passkey_update(token_c, CURequest::PasskeyInit).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
self.state = State::FetchingChallenge;
}
Msg::ChallengeReady(challenge) => {
console::log!(format!("{:?}", challenge).as_str());
self.state = State::ChallengeReady(challenge);
}
Msg::CredentialCreate => {
if let State::ChallengeReady(ccr) = &self.state {
let ccr = ccr.clone();
let c_options: web_sys::CredentialCreationOptions = ccr.into();
// Create a promise that calls the browsers navigator.credentials.create api.
let promise = utils::window()
.navigator()
.credentials()
.create_with_options(&c_options)
.expect_throw("Unable to create promise");
let fut = JsFuture::from(promise);
// Wait on the promise, when complete it will issue a callback.
ctx.link().send_future(async move {
match fut.await {
Ok(jsval) => {
// Convert from the raw js value into the expected PublicKeyCredential
let w_rpkc = web_sys::PublicKeyCredential::from(jsval);
// Serialise the web_sys::pkc into the webauthn proto version, ready to
// handle/transmit.
let rpkc = RegisterPublicKeyCredential::from(w_rpkc);
// Update our state
Msg::CredentialReady(rpkc)
}
Err(e) => {
console::log!(format!("error -> {:?}", e).as_str());
Msg::NavigatorError
}
}
});
}
}
Msg::CredentialReady(rpkc) => {
console::log!(format!("{:?}", rpkc).as_str());
self.state = State::CredentialReady(rpkc);
}
Msg::NavigatorError => {
// Do something useful, like prompt or have a breadcrumb. But it's
// not a full error.
}
Msg::Cancel => {
let token_c = ctx.props().token.clone();
ctx.link().send_future(async {
match Self::submit_passkey_update(token_c, CURequest::CancelMFAReg).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
self.state = State::FetchingChallenge;
}
Msg::Error { emsg, kopid } => {
// Submit the error to the parent.
EventBus::dispatcher().send(EventBusMsg::Error { emsg, kopid });
self.reset_and_hide();
}
};
true
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
console::log!("passkey modal::rendered");
}
fn destroy(&mut self, _ctx: &Context<Self>) {
console::log!("passkey modal::destroy");
}
fn view(&self, ctx: &Context<Self>) -> Html {
console::log!("passkey modal::view");
let label_val = self.label_val.clone();
let passkey_state = match &self.state {
State::Init => {
html! {
<button id="passkey-generate" type="button" class="btn btn-secondary"
onclick={
ctx.link()
.callback(move |_| {
Msg::Generate
})
}
>{ "Start Creating a New Passkey" }</button>
}
}
State::Submitting | State::FetchingChallenge => {
html! {
<div class="spinner-border text-dark" role="status">
<span class="visually-hidden">{ "Loading..." }</span>
</div>
}
}
State::ChallengeReady(_challenge) => {
// This works around a bug in safari :(
html! {
<button id="passkey-generate" type="button" class="btn btn-primary"
onclick={
ctx.link()
.callback(move |_| {
Msg::CredentialCreate
})
}
>{ "Do it!" }</button>
}
}
State::CredentialReady(_) => {
html! {
<h3>{ "Passkey Created!" }</h3>
}
}
};
let submit_enabled =
!label_val.is_empty() && matches!(self.state, State::CredentialReady(_));
let submit_state = match &self.state {
State::CredentialReady(_rpkc) => {
html! {
<button id="passkey-submit" type="button" class="btn btn-primary"
disabled={ !submit_enabled }
onclick={
ctx.link()
.callback(move |_| {
Msg::Submit
})
}
>{ "Submit" }</button>
}
}
_ => {
html! {
<button id="passkey-cancel" type="button" class="btn btn-secondary"
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
>{ "Cancel" }</button>
}
}
};
html! {
<div class="modal fade" id="staticPasskeyCreate" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="staticPasskeyLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="staticPasskeyLabel">{ "Add a New Passkey" }</h5>
<button type="button" class="btn-close" aria-label="Close"
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
></button>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
{ passkey_state }
</div>
</div>
<form class="row g-3 needs-validation" novalidate=true
onsubmit={ ctx.link().callback(move |e: FocusEvent| {
console::log!("passkey modal::on form submit prevent default");
e.prevent_default();
if submit_enabled {
Msg::Submit
} else {
Msg::Cancel
}
} ) }
>
<label for="passkey-label" class="form-label">{ "Enter Label for Passkey" }</label>
<input
type="text"
class="form-control"
id="passkey-label"
placeholder=""
value={ label_val }
required=true
oninput={
ctx.link()
.callback(move |_| {
Msg::LabelCheck
})
}
/>
</form>
</div>
<div class="modal-footer">
{ submit_state }
</div>
</div>
</div>
</div>
}
}
}

View file

@ -0,0 +1,243 @@
use crate::error::*;
use crate::utils;
use super::eventbus::{EventBus, EventBusMsg};
use super::reset::PasskeyRemoveModalProps;
use gloo::console;
use yew::prelude::*;
use yew_agent::Dispatched;
use wasm_bindgen::{JsCast, JsValue, UnwrapThrowExt};
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use uuid::Uuid;
use kanidm_proto::v1::{CURegState, CURequest, CUSessionToken, CUStatus};
pub struct PasskeyRemoveModalApp {
state: State,
target: String,
tag: String,
uuid: Uuid,
}
pub enum State {
Init,
Submitting,
}
pub enum Msg {
Cancel,
Submit,
Success,
Error { emsg: String, kopid: Option<String> },
}
impl From<FetchError> for Msg {
fn from(fe: FetchError) -> Self {
Msg::Error {
emsg: fe.as_string(),
kopid: None,
}
}
}
impl PasskeyRemoveModalApp {
pub fn render_button(tag: &str, uuid: Uuid) -> Html {
let remove_tgt = format!("#staticPasskeyRemove-{}", uuid);
let tag = tag.to_string();
html! {
<li>
<div class="row g-3">
<p>{ tag }</p>
<button type="button" class="btn btn-dark btn-sml" data-bs-toggle="modal" data-bs-target={ remove_tgt }>
{ "Remove" }
</button>
</div>
</li>
}
}
fn reset_and_hide(&mut self) {
utils::modal_hide_by_id(&self.target);
self.state = State::Init;
}
async fn submit_passkey_update(
token: CUSessionToken,
req: CURequest,
) -> Result<Msg, FetchError> {
let req_jsvalue = serde_json::to_string(&(req, token))
.map(|s| JsValue::from(&s))
.expect_throw("Failed to serialise pw curequest");
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::SameOrigin);
opts.body(Some(&req_jsvalue));
let request = Request::new_with_str_and_init("/v1/credential/_update", &opts)?;
request
.headers()
.set("content-type", "application/json")
.expect_throw("failed to set header");
let window = utils::window();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into().expect_throw("Invalid response type");
let status = resp.status();
let headers = resp.headers();
let kopid = headers.get("x-kanidm-opid").ok().flatten();
if status == 200 {
let jsval = JsFuture::from(resp.json()?).await?;
let status: CUStatus = jsval.into_serde().expect_throw("Invalid response type");
EventBus::dispatcher().send(EventBusMsg::UpdateStatus {
status: status.clone(),
});
Ok(match status.mfaregstate {
CURegState::TotpCheck(_)
| CURegState::TotpTryAgain
| CURegState::TotpInvalidSha1
| CURegState::Passkey(_)
| CURegState::BackupCodes(_) => Msg::Error {
emsg: "Invalid Passkey reg state response".to_string(),
kopid,
},
CURegState::None => Msg::Success,
})
} else {
let text = JsFuture::from(resp.text()?).await?;
let emsg = text.as_string().unwrap_or_else(|| "".to_string());
Ok(Msg::Error { emsg, kopid })
}
}
}
impl Component for PasskeyRemoveModalApp {
type Message = Msg;
type Properties = PasskeyRemoveModalProps;
fn create(ctx: &Context<Self>) -> Self {
console::log!("passkey remove modal create");
let tag = ctx.props().tag.clone();
let uuid = ctx.props().uuid.clone();
let target = format!("staticPasskeyRemove-{}", uuid);
PasskeyRemoveModalApp {
state: State::Init,
tag,
uuid,
target,
}
}
fn changed(&mut self, _ctx: &Context<Self>) -> bool {
console::log!("passkey remove modal::change");
false
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
console::log!("passkey remove modal::update");
match msg {
Msg::Submit => {
self.reset_and_hide();
// Do the call back.
let token_c = ctx.props().token.clone();
let uuid = self.uuid.clone();
ctx.link().send_future(async move {
match Self::submit_passkey_update(token_c, CURequest::PasskeyRemove(uuid)).await
{
Ok(v) => v,
Err(v) => v.into(),
}
});
self.state = State::Submitting;
}
Msg::Success | Msg::Cancel => {
self.reset_and_hide();
}
Msg::Error { emsg, kopid } => {
// Submit the error to the parent.
EventBus::dispatcher().send(EventBusMsg::Error { emsg, kopid });
self.reset_and_hide();
}
}
true
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
console::log!("passkey remove modal::rendered");
}
fn destroy(&mut self, _ctx: &Context<Self>) {
console::log!("passkey remove modal::destroy");
}
fn view(&self, ctx: &Context<Self>) -> Html {
console::log!("passkey remove modal::view");
let remove_tgt = self.target.clone();
let remove_id = format!("staticPasskeyRemove-{}", self.uuid);
let remove_label = format!("staticPasskeyRemoveLabel-{}", self.uuid);
let msg = format!("Delete the Passkey named '{}'?", self.tag);
let submit_enabled = matches!(self.state, State::Init);
html! {
<div class="modal fade" id={ remove_id } data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby={ remove_tgt } aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id={ remove_label }>{ "Delete Passkey" }</h5>
<button type="button" class="btn-close" aria-label="Close"
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
></button>
</div>
<div class="modal-body">
<p>{ msg }</p>
</div>
<div class="modal-footer">
<button id="delete-cancel" type="button" class="btn btn-secondary"
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
>{ "Cancel" }</button>
<button id="delete-submit" type="button" class="btn btn-danger"
disabled={ !submit_enabled }
onclick={
ctx.link()
.callback(move |_| {
Msg::Submit
})
}
>{ "Submit" }</button>
</div>
</div>
</div>
</div>
}
}
}

View file

@ -17,14 +17,25 @@ use web_sys::{Request, RequestInit, RequestMode, Response};
use super::delete::DeleteApp;
use super::eventbus::{EventBus, EventBusMsg};
use super::passkey::PasskeyModalApp;
use super::passkeyremove::PasskeyRemoveModalApp;
use super::pwmodal::PwModalApp;
use super::totpmodal::TotpModalApp;
use uuid::Uuid;
#[derive(PartialEq, Properties)]
pub struct ModalProps {
pub token: CUSessionToken,
}
#[derive(PartialEq, Properties)]
pub struct PasskeyRemoveModalProps {
pub token: CUSessionToken,
pub tag: String,
pub uuid: Uuid,
}
pub enum Msg {
TokenSubmit,
BeginSession {
@ -34,6 +45,7 @@ pub enum Msg {
UpdateSession {
status: CUStatus,
},
Cancel,
Commit,
Success,
Error {
@ -187,9 +199,22 @@ impl Component for CredentialResetApp {
Some(State::WaitingForCommit)
}
(Msg::Cancel, State::Main { token, status }) => {
console::log!(format!("{:?}", status).as_str());
let token_c = token.clone();
ctx.link().send_future(async {
match Self::cancel_session(token_c).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
Some(State::WaitingForCommit)
}
(Msg::Success, State::WaitingForCommit) => {
let loc = models::pop_return_location();
console::log!(format!("credential was updated, try going to -> {:?}", loc));
console::log!(format!("Going to -> {:?}", loc));
loc.goto(&ctx.link().history().expect_throw("failed to read history"));
None
@ -292,7 +317,6 @@ impl CredentialResetApp {
let pw_html = match &status.primary {
Some(CredentialDetail {
uuid: _,
claims: _,
type_: CredentialDetailType::Password,
}) => {
html! {
@ -312,7 +336,6 @@ impl CredentialResetApp {
}
Some(CredentialDetail {
uuid: _,
claims: _,
type_: CredentialDetailType::GeneratedPassword,
}) => {
html! {
@ -326,23 +349,22 @@ impl CredentialResetApp {
}
Some(CredentialDetail {
uuid: _,
claims: _,
type_: CredentialDetailType::Webauthn(_),
type_: CredentialDetailType::Passkey(_),
}) => {
html! {
<>
<p>{ "Webauthn Only - Will migrate to trusted devices in a future update" }</p>
<p>{ "Webauthn Only - Will migrate to Passkeys in a future update" }</p>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#staticDeletePrimaryCred">
{ "Delete this Password" }
{ "Delete this Credential" }
</button>
</>
}
}
Some(CredentialDetail {
uuid: _,
claims: _,
type_:
// TODO: review this and find out why we aren't using these variables
// Because I'm lazy 🙃
CredentialDetailType::PasswordMfa(
_totp_set,
_security_key_labels,
@ -373,6 +395,34 @@ impl CredentialResetApp {
}
};
let passkey_html = if status.passkeys.is_empty() {
html! {
<p>{ "No Passkeys Registered" }</p>
}
} else {
html! {
<div class="container">
<ul class="list-unstyled">
{ for status.passkeys.iter()
.map(|detail|
PasskeyRemoveModalApp::render_button(&detail.tag, detail.uuid)
)
}
</ul>
</div>
}
};
let passkey_modals_html = html! {
<>
{ for status.passkeys.iter()
.map(|detail|
html! { <PasskeyRemoveModalApp token={ token.clone() } tag={ detail.tag.clone() } uuid={ detail.uuid } /> }
)
}
</>
};
html! {
<div class="d-flex align-items-start form-cred-reset-body">
<main class="w-100">
@ -386,8 +436,10 @@ impl CredentialResetApp {
<form class="needs-validation" novalidate=true>
<hr class="my-4" />
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticTrustedDevice">
{ "Add New Trusted Device" }
{ passkey_html }
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#staticPasskeyCreate">
{ "Add Passkey" }
</button>
<hr class="my-4" />
@ -395,7 +447,17 @@ impl CredentialResetApp {
{ pw_html }
<hr class="my-4" />
<button class="w-100 btn btn-success btn-lg" type="submit"
<button class="w-50 btn btn-danger btn-lg" type="submit"
disabled=false
onclick={
ctx.link()
.callback(move |_| {
Msg::Cancel
})
}
>{ "Cancel" }</button>
<button class="w-50 btn btn-success btn-lg" type="submit"
disabled={ !can_commit }
onclick={
ctx.link()
@ -408,28 +470,7 @@ impl CredentialResetApp {
</div>
</main>
<div class="modal fade" id="staticTrustedDevice" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="staticTrustedDeviceLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="staticTrustedDeviceLabel">{ "Add a Trusted Device" }</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{ "Scan the following link to add a new device" }</p>
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">{ "Loading..." }</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{ "Cancel" }</button>
<button type="button" class="btn btn-primary">{ "Submit" }</button>
</div>
</div>
</div>
</div>
<PasskeyModalApp token={ token.clone() } />
<PwModalApp token={ token.clone() } />
@ -437,10 +478,10 @@ impl CredentialResetApp {
<DeleteApp token= { token.clone() }/>
{ passkey_modals_html }
</div>
}
// <DelPrimaryModalApp token={ token.clone() }/>
}
fn view_error(&self, _ctx: &Context<Self>, msg: &str, kopid: Option<&str>) -> Html {
@ -499,7 +540,7 @@ impl CredentialResetApp {
}
}
async fn commit_session(token: CUSessionToken) -> Result<Msg, FetchError> {
async fn end_session(token: CUSessionToken, url: &str) -> Result<Msg, FetchError> {
let req_jsvalue = serde_json::to_string(&token)
.map(|s| JsValue::from(&s))
.expect_throw("Failed to serialise session token");
@ -510,7 +551,7 @@ impl CredentialResetApp {
opts.body(Some(&req_jsvalue));
let request = Request::new_with_str_and_init("/v1/credential/_commit", &opts)?;
let request = Request::new_with_str_and_init(url, &opts)?;
request
.headers()
.set("content-type", "application/json")
@ -531,4 +572,12 @@ impl CredentialResetApp {
Ok(Msg::Error { emsg, kopid })
}
}
async fn cancel_session(token: CUSessionToken) -> Result<Msg, FetchError> {
Self::end_session(token, "/v1/credential/_cancel").await
}
async fn commit_session(token: CUSessionToken) -> Result<Msg, FetchError> {
Self::end_session(token, "/v1/credential/_commit").await
}
}

View file

@ -29,6 +29,7 @@ enum TotpCheck {
enum TotpValue {
Init,
Waiting,
Secret(TotpSecret),
}
@ -39,6 +40,7 @@ pub struct TotpModalApp {
}
pub enum Msg {
TotpGenerate,
TotpCancel,
TotpSubmit,
TotpSecretReady(TotpSecret),
@ -101,7 +103,7 @@ impl TotpModalApp {
});
Ok(match status.mfaregstate {
CURegState::BackupCodes(_) => Msg::Error {
CURegState::Passkey(_) | CURegState::BackupCodes(_) => Msg::Error {
emsg: "Invalid TOTP mfa reg state response".to_string(),
kopid,
},
@ -122,19 +124,9 @@ impl Component for TotpModalApp {
type Message = Msg;
type Properties = ModalProps;
fn create(ctx: &Context<Self>) -> Self {
fn create(_ctx: &Context<Self>) -> Self {
console::log!("totp modal create");
// SEND OFF A REQUEST TO GET THE TOTP STRING
let token_c = ctx.props().token.clone();
ctx.link().send_future(async {
match Self::submit_totp_update(token_c, CURequest::TotpGenerate).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
TotpModalApp {
state: TotpState::Init,
check: TotpCheck::Init,
@ -186,6 +178,19 @@ impl Component for TotpModalApp {
}
}
}
Msg::TotpGenerate => {
// SEND OFF A REQUEST TO GET THE TOTP STRING
let token_c = ctx.props().token.clone();
ctx.link().send_future(async {
match Self::submit_totp_update(token_c, CURequest::TotpGenerate).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
self.secret = TotpValue::Waiting;
}
Msg::TotpSecretReady(secret) => {
// THIS IS WHATS CALLED WHEN THE SECRET IS BACK
self.secret = TotpValue::Secret(secret);
@ -280,6 +285,18 @@ impl Component for TotpModalApp {
let totp_secret_state = match &self.secret {
TotpValue::Init => {
html! {
<button id="totp-generate" type="button" class="btn btn-secondary"
onclick={
ctx.link()
.callback(move |_| {
Msg::TotpGenerate
})
}
>{ "Generate TOTP" }</button>
}
}
TotpValue::Waiting => {
html! {
<div class="spinner-border text-dark" role="status">
<span class="visually-hidden">{ "Loading..." }</span>

View file

@ -1,5 +1,5 @@
#![recursion_limit = "256"]
// #![deny(warnings)]
#![deny(warnings)]
#![warn(unused_extern_crates)]
#![deny(clippy::todo)]
#![deny(clippy::unimplemented)]
@ -11,6 +11,9 @@
#![deny(clippy::needless_pass_by_value)]
#![deny(clippy::trivially_copy_pass_by_ref)]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use wasm_bindgen::prelude::*;
#[macro_use]

View file

@ -12,10 +12,9 @@ use crate::models;
use crate::utils;
use kanidm_proto::v1::{
AuthAllowed, AuthCredential, AuthRequest, AuthResponse, AuthState, AuthStep,
AuthAllowed, AuthCredential, AuthRequest, AuthResponse, AuthState, AuthStep, AuthMech
};
use webauthn_rs::proto::PublicKeyCredential;
use kanidm_proto::webauthn::PublicKeyCredential;
pub struct LoginApp {
inputvalue: String,
@ -32,11 +31,17 @@ enum TotpState {
enum LoginState {
Init(bool),
// Select between different cred types
Select(Vec<AuthMech>),
// The choices of current ways to proceed.
Continue(Vec<AuthAllowed>),
// The different methods
Password(bool),
BackupCode(bool),
Totp(TotpState),
Webauthn(web_sys::CredentialRequestOptions),
Passkey(web_sys::CredentialRequestOptions),
SecurityKey(web_sys::CredentialRequestOptions),
// Error, state handling.
Error { emsg: String, kopid: Option<String> },
UnknownUser,
Denied(String),
@ -50,10 +55,12 @@ pub enum LoginAppMsg {
PasswordSubmit,
BackupCodeSubmit,
TotpSubmit,
WebauthnSubmit(PublicKeyCredential),
PasskeySubmit(PublicKeyCredential),
SecurityKeySubmit(PublicKeyCredential),
Start(String, AuthResponse),
Next(AuthResponse),
Continue(usize),
Select(usize),
// DoNothing,
UnknownUser,
Error { emsg: String, kopid: Option<String> },
@ -175,6 +182,18 @@ impl LoginApp {
}
}
fn render_mech_select(&self, ctx: &Context<Self>, idx: usize, allow: &AuthMech) -> Html {
html! {
<li>
<button
type="button"
class="btn btn-dark"
onclick={ ctx.link().callback(move |_| LoginAppMsg::Select(idx)) }
>{ allow.to_string() }</button>
</li>
}
}
fn view_state(&self, ctx: &Context<Self>) -> Html {
let inputvalue = self.inputvalue.clone();
match &self.state {
@ -213,6 +232,24 @@ impl LoginApp {
</>
}
}
LoginState::Select(mechs) => {
html! {
<>
<div class="container">
<p>
{" Which credential would you like to use? "}
</p>
</div>
<div class="container">
<ul class="list-unstyled">
{ for mechs.iter()
.enumerate()
.map(|(idx, mech)| self.render_mech_select(ctx, idx, mech)) }
</ul>
</div>
</>
}
}
LoginState::Continue(allowed) => {
html! {
<>
@ -331,7 +368,7 @@ impl LoginApp {
</>
}
}
LoginState::Webauthn(challenge) => {
LoginState::SecurityKey(challenge) => {
// Start the navigator parts.
if let Some(win) = web_sys::window() {
let promise = win
@ -348,7 +385,7 @@ impl LoginApp {
let data = PublicKeyCredential::from(
web_sys::PublicKeyCredential::from(data),
);
linkc.send_message(LoginAppMsg::WebauthnSubmit(data));
linkc.send_message(LoginAppMsg::SecurityKeySubmit(data));
}
Err(e) => {
linkc.send_message(LoginAppMsg::Error {
@ -368,7 +405,49 @@ impl LoginApp {
html! {
<div class="container">
<p>
{" Webauthn "}
{" Security Key "}
</p>
</div>
}
}
LoginState::Passkey(challenge) => {
// Start the navigator parts.
if let Some(win) = web_sys::window() {
let promise = win
.navigator()
.credentials()
.get_with_options(challenge)
.expect_throw("Unable to create promise");
let fut = JsFuture::from(promise);
let linkc = ctx.link().clone();
spawn_local(async move {
match fut.await {
Ok(data) => {
let data = PublicKeyCredential::from(
web_sys::PublicKeyCredential::from(data),
);
linkc.send_message(LoginAppMsg::PasskeySubmit(data));
}
Err(e) => {
linkc.send_message(LoginAppMsg::Error {
emsg: format!("{:?}", e),
kopid: None,
});
}
}
});
} else {
ctx.link().send_message(LoginAppMsg::Error {
emsg: "failed to access navigator credentials".to_string(),
kopid: None,
});
};
html! {
<div class="container">
<p>
{" Passkey "}
</p>
</div>
}
@ -570,10 +649,25 @@ impl Component for LoginApp {
true
}
LoginAppMsg::WebauthnSubmit(resp) => {
console::log!("At webauthn step".to_string());
LoginAppMsg::SecurityKeySubmit(resp) => {
console::log!("At securitykey step".to_string());
let authreq = AuthRequest {
step: AuthStep::Cred(AuthCredential::Webauthn(resp)),
step: AuthStep::Cred(AuthCredential::SecurityKey(resp)),
};
let session_id = self.session_id.clone();
ctx.link().send_future(async {
match Self::auth_step(authreq, session_id).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
// Do not submit here, we need to wait for the next ui transition.
false
}
LoginAppMsg::PasskeySubmit(resp) => {
console::log!("At passkey step".to_string());
let authreq = AuthRequest {
step: AuthStep::Cred(AuthCredential::Passkey(resp)),
};
let session_id = self.session_id.clone();
ctx.link().send_future(async {
@ -608,12 +702,8 @@ impl Component for LoginApp {
// We do NOT need to change state or redraw
false
} else {
// Offer the choices.
console::log!("This is currently unimplemented".to_string());
self.state = LoginState::Error {
emsg: "Unimplemented".to_string(),
kopid: None,
};
console::log!("multiple mechs exist".to_string());
self.state = LoginState::Select(mechs);
true
}
}
@ -632,6 +722,42 @@ impl Component for LoginApp {
}
}
}
LoginAppMsg::Select(idx) => {
console::log!(format!("chose -> {:?}", idx));
match &self.state {
LoginState::Select(allowed) => {
match allowed.get(idx) {
Some(mech) => {
let authreq = AuthRequest {
step: AuthStep::Begin(mech.clone()),
};
let session_id = self.session_id.clone();
ctx.link().send_future(async {
match Self::auth_step(authreq, session_id).await {
Ok(v) => v,
Err(v) => v.into(),
}
});
}
None => {
console::log!("invalid allowed mech idx".to_string());
self.state = LoginState::Error {
emsg: "Invalid Continue Index".to_string(),
kopid: None,
};
}
}
}
_ => {
console::log!("invalid state transition".to_string());
self.state = LoginState::Error {
emsg: "Invalid UI State Transition".to_string(),
kopid: None,
};
}
};
true
}
LoginAppMsg::Next(resp) => {
// Clear any leftover input
self.inputvalue = "".to_string();
@ -664,8 +790,11 @@ impl Component for LoginApp {
AuthAllowed::Totp => {
self.state = LoginState::Totp(TotpState::Enabled);
}
AuthAllowed::Webauthn(challenge) => {
self.state = LoginState::Webauthn(challenge.into())
AuthAllowed::SecurityKey(challenge) => {
self.state = LoginState::SecurityKey(challenge.into())
}
AuthAllowed::Passkey(challenge) => {
self.state = LoginState::Passkey(challenge.into())
}
}
} else {
@ -707,8 +836,11 @@ impl Component for LoginApp {
Some(AuthAllowed::Totp) => {
self.state = LoginState::Totp(TotpState::Enabled);
}
Some(AuthAllowed::Webauthn(challenge)) => {
self.state = LoginState::Webauthn(challenge.clone().into())
Some(AuthAllowed::SecurityKey(challenge)) => {
self.state = LoginState::SecurityKey(challenge.clone().into())
}
Some(AuthAllowed::Passkey(challenge)) => {
self.state = LoginState::Passkey(challenge.clone().into())
}
None => {
console::log!("invalid allowed mech idx".to_string());