From b1480e36f0a324efaf29a7a1f568b9ed26f44daf Mon Sep 17 00:00:00 2001 From: Firstyear Date: Sun, 7 Jul 2024 13:36:47 +1000 Subject: [PATCH] 20240703 htmx (#2870) Complete the remainder of the HTMX rewrite of the login page. --- Cargo.lock | 77 +- libs/client/src/lib.rs | 12 +- proto/src/internal/mod.rs | 1 + proto/src/v1/auth.rs | 23 +- server/core/Cargo.toml | 6 + server/core/src/https/mod.rs | 18 +- server/core/src/https/v1.rs | 10 +- server/core/src/https/views/login.rs | 385 +++++-- server/core/src/https/views/mod.rs | 56 +- server/core/static/external/base64.js | 3 + server/core/static/external/pkhtml.js | 53 + server/core/static/style.css | 4 + server/core/templates/base.html | 5 +- server/core/templates/login.html | 100 +- server/core/templates/login_backupcode.html | 24 + server/core/templates/login_base.html | 19 + server/core/templates/login_mech_choose.html | 22 + ...sword_partial.html => login_password.html} | 12 +- ...ogin_totp_partial.html => login_totp.html} | 17 +- server/core/templates/login_webauthn.html | 17 + server/lib/src/be/dbvalue.rs | 46 +- server/lib/src/idm/account.rs | 1 - server/lib/src/idm/authsession.rs | 957 +++++++++++------- server/lib/src/idm/credupdatesession.rs | 4 +- server/lib/src/idm/delayed.rs | 2 + server/lib/src/idm/oauth2.rs | 5 +- server/lib/src/idm/reauth.rs | 2 +- server/lib/src/idm/server.rs | 12 +- server/lib/src/lib.rs | 6 +- server/lib/src/plugins/refint.rs | 3 +- server/lib/src/plugins/session.rs | 6 +- server/lib/src/repl/proto.rs | 24 +- server/lib/src/repl/tests.rs | 6 +- server/lib/src/value.rs | 31 +- server/lib/src/valueset/mod.rs | 5 - server/lib/src/valueset/session.rs | 627 ++++-------- server/lib/src/valueset/uint32.rs | 1 + 37 files changed, 1552 insertions(+), 1050 deletions(-) create mode 100644 server/core/static/external/base64.js create mode 100644 server/core/static/external/pkhtml.js create mode 100644 server/core/templates/login_backupcode.html create mode 100644 server/core/templates/login_base.html create mode 100644 server/core/templates/login_mech_choose.html rename server/core/templates/{login_password_partial.html => login_password.html} (69%) rename server/core/templates/{login_totp_partial.html => login_totp.html} (58%) create mode 100644 server/core/templates/login_webauthn.html diff --git a/Cargo.lock b/Cargo.lock index 1cb166b7a..167abbbf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,7 +173,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.66", + "syn 2.0.68", ] [[package]] @@ -2856,6 +2856,23 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.3.1", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.4.1" @@ -3462,6 +3479,7 @@ dependencies = [ "utoipa-swagger-ui", "uuid", "walkdir", + "webauthn-rs", ] [[package]] @@ -5122,6 +5140,7 @@ dependencies = [ "http-body 1.0.0", "http-body-util", "hyper 1.3.1", + "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", @@ -5158,6 +5177,21 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "route-recognizer" version = "0.3.1" @@ -5283,6 +5317,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "2.1.2" @@ -5299,6 +5346,17 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" +[[package]] +name = "rustls-webpki" +version = "0.102.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -6013,6 +6071,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -6372,6 +6441,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.2" diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 307aa0dd4..c2a40f71d 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -1264,12 +1264,12 @@ impl KanidmClient { Err(e) => return Err(e), }; - if !mechs.contains(&AuthMech::PasswordMfa) { - debug!("PasswordMfa mech not presented"); + if !mechs.contains(&AuthMech::PasswordTotp) { + debug!("PasswordTotp mech not presented"); return Err(ClientError::AuthenticationFailed); } - let state = match self.auth_step_begin(AuthMech::PasswordMfa).await { + let state = match self.auth_step_begin(AuthMech::PasswordTotp).await { Ok(s) => s, Err(e) => return Err(e), }; @@ -1315,12 +1315,12 @@ impl KanidmClient { Err(e) => return Err(e), }; - if !mechs.contains(&AuthMech::PasswordMfa) { - debug!("PasswordMfa mech not presented"); + if !mechs.contains(&AuthMech::PasswordBackupCode) { + debug!("PasswordBackupCode mech not presented"); return Err(ClientError::AuthenticationFailed); } - let state = match self.auth_step_begin(AuthMech::PasswordMfa).await { + let state = match self.auth_step_begin(AuthMech::PasswordBackupCode).await { Ok(s) => s, Err(e) => return Err(e), }; diff --git a/proto/src/internal/mod.rs b/proto/src/internal/mod.rs index 72ad29db8..74ad4d2df 100644 --- a/proto/src/internal/mod.rs +++ b/proto/src/internal/mod.rs @@ -26,6 +26,7 @@ pub use self::token::*; pub const COOKIE_AUTH_SESSION_ID: &str = "auth-session-id"; pub const COOKIE_BEARER_TOKEN: &str = "bearer"; +pub const COOKIE_USERNAME: &str = "username"; #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] /// This is a description of a linked or connected application for a user. This is diff --git a/proto/src/v1/auth.rs b/proto/src/v1/auth.rs index 1bb83ec85..8a4304f4e 100644 --- a/proto/src/v1/auth.rs +++ b/proto/src/v1/auth.rs @@ -85,10 +85,27 @@ impl fmt::Debug for AuthCredential { pub enum AuthMech { Anonymous, Password, - PasswordMfa, + // Now represents TOTP. + #[serde(rename = "passwordmfa")] + PasswordTotp, + PasswordBackupCode, + PasswordSecurityKey, Passkey, } +impl AuthMech { + pub fn to_value(&self) -> &'static str { + match self { + AuthMech::Anonymous => "anonymous", + AuthMech::Password => "password", + AuthMech::PasswordTotp => "passwordmfa", + AuthMech::PasswordBackupCode => "passwordbackupcode", + AuthMech::PasswordSecurityKey => "passwordsecuritykey", + AuthMech::Passkey => "passkey", + } + } +} + impl PartialEq for AuthMech { fn eq(&self, other: &Self) -> bool { std::mem::discriminant(self) == std::mem::discriminant(other) @@ -100,7 +117,9 @@ impl fmt::Display for AuthMech { match self { AuthMech::Anonymous => write!(f, "Anonymous (no credentials)"), AuthMech::Password => write!(f, "Password"), - AuthMech::PasswordMfa => write!(f, "TOTP/Backup Code and Password"), + AuthMech::PasswordTotp => write!(f, "TOTP and Password"), + AuthMech::PasswordBackupCode => write!(f, "Backup Code and Password"), + AuthMech::PasswordSecurityKey => write!(f, "Security Key and Password"), AuthMech::Passkey => write!(f, "Passkey"), } } diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index 281d436c7..35cf99844 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -82,6 +82,12 @@ utoipa = { workspace = true, features = [ ] } utoipa-swagger-ui = { workspace = true, features = ["axum"] } +webauthn-rs = { workspace = true, features = [ + "resident-key-support", + "preview-features", + "danger-credential-internals", +] } + [dev-dependencies] walkdir = { workspace = true } tempfile = { workspace = true } diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index e30d2f52e..65c278b3a 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -17,7 +17,6 @@ mod views; use self::extractors::ClientConnInfo; use self::javascript::*; -use self::v1::SessionId; use crate::actors::{QueryServerReadV1, QueryServerWriteV1}; use crate::config::{Configuration, ServerRole, TlsConfiguration}; use crate::CoreAction; @@ -45,6 +44,7 @@ use openssl::x509::X509; use kanidm_lib_crypto::x509_cert::{der::Decode, x509_public_key_s256, Certificate}; +use serde::de::DeserializeOwned; use sketching::*; use tokio::{ net::{TcpListener, TcpStream}, @@ -79,16 +79,18 @@ pub struct ServerState { } impl ServerState { - fn reinflate_uuid_from_bytes(&self, input: &str) -> Option { + /// Deserialize some input string validating that it was signed by our instance's + /// HMAC signer. This is used for short lived server-only sessions and context + /// data. This has applications in both accessing cookie content and header content. + fn deserialise_from_str(&self, input: &str) -> Option { match JwsCompact::from_str(input) { Ok(val) => match self.jws_signer.verify(&val) { - Ok(val) => val.from_json::().ok(), + Ok(val) => val.from_json::().ok(), Err(err) => { error!("Failed to unmarshal JWT from headers: {:?}", err); None } - } - .map(|inner| inner.sessionid), + }, Err(_) => None, } } @@ -109,7 +111,7 @@ impl ServerState { }) .and_then(|s| { trace!(id_jws = %s); - self.reinflate_uuid_from_bytes(s) + self.deserialise_from_str::(s) }) } } @@ -137,6 +139,8 @@ pub fn get_js_files(role: ServerRole) -> Result { ("external/bootstrap.bundle.min.js", None, false, false), ("external/htmx.min.1.9.2.js", None, false, false), ("external/confetti.js", None, false, false), + ("external/pkhtml.js", None, false, false), + ("external/base64.js", None, false, false), ] } else { vec![ @@ -228,8 +232,8 @@ pub async fn create_https_server( let csp_header = format!( concat!( - "base-uri 'self' https:; ", "default-src 'self'; ", + "base-uri 'self' https:; ", "form-action 'self' https:;", "frame-ancestors 'none'; ", "img-src 'self' data:; ", diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs index 47cfe31a7..3c3533314 100644 --- a/server/core/src/https/v1.rs +++ b/server/core/src/https/v1.rs @@ -9,7 +9,6 @@ use axum::{Extension, Json, Router}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use compact_jwt::{Jwk, Jws, JwsSigner}; use kanidm_proto::constants::uri::V1_AUTH_VALID; -use serde::{Deserialize, Serialize}; use std::net::IpAddr; use uuid::Uuid; @@ -36,11 +35,6 @@ use super::ServerState; use crate::https::apidocs::response_schema::{ApiResponseWithout200, DefaultApiResponse}; use crate::https::extractors::{TrustedClientIp, VerifiedClientInformation}; -#[derive(Serialize, Deserialize, Debug, Clone)] -pub(crate) struct SessionId { - pub sessionid: Uuid, -} - #[utoipa::path( post, path = "/v1/raw/create", @@ -2866,7 +2860,7 @@ fn auth_session_state_management( AuthState::Choose(allowed) => { debug!("🧩 -> AuthState::Choose"); let kref = &state.jws_signer; - let jws = Jws::into_json(&SessionId { sessionid }).map_err(|e| { + let jws = Jws::into_json(&sessionid).map_err(|e| { error!(?e); OperationError::InvalidSessionState })?; @@ -2886,7 +2880,7 @@ fn auth_session_state_management( debug!("🧩 -> AuthState::Continue"); let kref = &state.jws_signer; // Get the header token ready. - let jws = Jws::into_json(&SessionId { sessionid }).map_err(|e| { + let jws = Jws::into_json(&sessionid).map_err(|e| { error!(?e); OperationError::InvalidSessionState })?; diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index dad0cdfd2..d5cf5eb32 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -3,7 +3,7 @@ use askama::Template; use axum::{ extract::State, response::{IntoResponse, Redirect, Response}, - Extension, Form, + Extension, Form, Json, }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; @@ -12,33 +12,61 @@ use compact_jwt::{Jws, JwsSigner}; use kanidmd_lib::prelude::OperationError; -use kanidm_proto::v1::{AuthAllowed, AuthCredential, AuthIssueSession, AuthRequest, AuthStep}; +use kanidm_proto::v1::{ + AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthStep, +}; use kanidmd_lib::prelude::*; -use kanidm_proto::internal::{COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN}; +use kanidm_proto::internal::{COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_USERNAME}; use kanidmd_lib::idm::AuthState; use kanidmd_lib::idm::event::AuthResult; -use serde::Deserialize; +use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; -use crate::https::{ - extractors::VerifiedClientInformation, middleware::KOpId, v1::SessionId, ServerState, -}; +use webauthn_rs::prelude::PublicKeyCredential; +use serde::{Deserialize, Serialize}; use std::str::FromStr; -use super::{HtmlTemplate, UnrecoverableErrorView}; +use super::{empty_string_as_none, HtmlTemplate, UnrecoverableErrorView}; + +#[derive(Default, Serialize, Deserialize)] +struct SessionContext { + #[serde(rename = "u")] + username: String, + + #[serde(rename = "r")] + remember_me: bool, + + #[serde(rename = "i", default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(rename = "p", default, skip_serializing_if = "Option::is_none")] + password: Option, + #[serde(rename = "t", default, skip_serializing_if = "Option::is_none")] + totp: Option, +} #[derive(Template)] #[template(path = "login.html")] -struct LoginView<'a> { - username: &'a str, +struct LoginView { + username: String, remember_me: bool, } +pub struct Mech<'a> { + name: AuthMech, + value: &'a str, +} + +#[derive(Template)] +#[template(path = "login_mech_choose.html")] +struct LoginMechView<'a> { + mechs: Vec>, +} + #[derive(Default)] enum LoginTotpError { #[default] @@ -47,20 +75,36 @@ enum LoginTotpError { } #[derive(Template, Default)] -#[template(path = "login_totp_partial.html")] -struct LoginTotpPartialView { +#[template(path = "login_totp.html")] +struct LoginTotpView { + totp: String, errors: LoginTotpError, } #[derive(Template)] -#[template(path = "login_password_partial.html")] -struct LoginPasswordPartialView {} +#[template(path = "login_password.html")] +struct LoginPasswordView { + password: String, +} + +#[derive(Template)] +#[template(path = "login_backupcode.html")] +struct LoginBackupCodeView {} + +#[derive(Template)] +#[template(path = "login_webauthn.html")] +struct LoginWebauthnView { + // Control if we are rendering in security key or passkey mode. + passkey: bool, + // chal: RequestChallengeResponse, + chal: String, +} pub async fn view_index_get( State(state): State, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, Extension(kopid): Extension, - _jar: CookieJar, + jar: CookieJar, ) -> Response { // If we are authenticated, redirect to the landing. let session_valid_result = state @@ -75,10 +119,16 @@ pub async fn view_index_get( } Err(OperationError::NotAuthenticated) | Err(OperationError::SessionExpired) => { // cookie jar with remember me. + let username = jar + .get(COOKIE_USERNAME) + .map(|c| c.value().to_string()) + .unwrap_or_default(); + + let remember_me = !username.is_empty(); HtmlTemplate(LoginView { - username: "", - remember_me: false, + username, + remember_me, }) .into_response() } @@ -93,21 +143,25 @@ pub async fn view_index_get( #[derive(Debug, Clone, Deserialize)] pub struct LoginBeginForm { username: String, + #[serde(default, deserialize_with = "empty_string_as_none")] + password: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + totp: Option, #[serde(default)] remember_me: Option, } -pub async fn partial_view_login_begin_post( +pub async fn view_login_begin_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, jar: CookieJar, Form(login_begin_form): Form, ) -> Response { - trace!(?login_begin_form); - let LoginBeginForm { username, + password, + totp, remember_me, } = login_begin_form; @@ -120,7 +174,7 @@ pub async fn partial_view_login_begin_post( None, AuthRequest { step: AuthStep::Init2 { - username, + username: username.clone(), issue: AuthIssueSession::Cookie, privileged: false, }, @@ -130,10 +184,97 @@ pub async fn partial_view_login_begin_post( ) .await; + let remember_me = remember_me.is_some(); + + let session_context = SessionContext { + id: None, + username, + password, + totp, + remember_me, + }; + // Now process the response if ok. match inter { Ok(ar) => { - match partial_view_login_step(state, kopid.clone(), jar, ar, client_auth_info).await { + match view_login_step( + state, + kopid.clone(), + jar, + ar, + client_auth_info, + session_context, + ) + .await + { + Ok(r) => r, + // Okay, these errors are actually REALLY bad. + Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + err_code, + operation_id: kopid.eventid, + }) + .into_response(), + } + } + // Probably needs to be way nicer on login, especially something like no matching users ... + Err(err_code) => HtmlTemplate(UnrecoverableErrorView { + err_code, + operation_id: kopid.eventid, + }) + .into_response(), + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginMechForm { + mech: AuthMech, +} + +pub async fn view_login_mech_choose_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(login_mech_form): Form, +) -> Response { + let session_context = jar + .get(COOKIE_AUTH_SESSION_ID) + .map(|c| c.value()) + .and_then(|s| { + trace!(id_jws = %s); + state.deserialise_from_str::(s) + }) + .unwrap_or_default(); + + debug!("Session ID: {:?}", session_context.id); + + let LoginMechForm { mech } = login_mech_form; + + let inter = state // This may change in the future ... + .qe_r_ref + .handle_auth( + session_context.id, + AuthRequest { + step: AuthStep::Begin(mech), + }, + kopid.eventid, + client_auth_info.clone(), + ) + .await; + + // Now process the response if ok. + match inter { + Ok(ar) => { + match view_login_step( + state, + kopid.clone(), + jar, + ar, + client_auth_info, + session_context, + ) + .await + { Ok(r) => r, // Okay, these errors are actually REALLY bad. Err(err_code) => HtmlTemplate(UnrecoverableErrorView { @@ -157,64 +298,25 @@ pub struct LoginTotpForm { totp: String, } -pub async fn partial_view_login_totp_post( +pub async fn view_login_totp_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, jar: CookieJar, Form(login_totp_form): Form, ) -> Response { - let maybe_sessionid = jar - .get(COOKIE_AUTH_SESSION_ID) - .map(|c| c.value()) - .and_then(|s| { - trace!(id_jws = %s); - state.reinflate_uuid_from_bytes(s) - }); - - debug!("Session ID: {:?}", maybe_sessionid); - - let Ok(totp) = u32::from_str(&login_totp_form.totp) else { + // trim leading and trailing white space. + let Ok(totp) = u32::from_str(&login_totp_form.totp.trim()) else { // If not an int, we need to re-render with an error - return HtmlTemplate(LoginTotpPartialView { + return HtmlTemplate(LoginTotpView { + totp: String::default(), errors: LoginTotpError::Syntax, }) .into_response(); }; - // Init the login. - let inter = state // This may change in the future ... - .qe_r_ref - .handle_auth( - maybe_sessionid, - AuthRequest { - step: AuthStep::Cred(AuthCredential::Totp(totp)), - }, - kopid.eventid, - client_auth_info.clone(), - ) - .await; - - // Now process the response if ok. - match inter { - Ok(ar) => { - match partial_view_login_step(state, kopid.clone(), jar, ar, client_auth_info).await { - Ok(r) => r, - // Okay, these errors are actually REALLY bad. - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { - err_code, - operation_id: kopid.eventid, - }) - .into_response(), - } - } - // Probably needs to be way nicer on login, especially something like no matching users ... - Err(err_code) => HtmlTemplate(UnrecoverableErrorView { - err_code, - operation_id: kopid.eventid, - }) - .into_response(), - } + let auth_cred = AuthCredential::Totp(totp); + credential_step(state, kopid, jar, client_auth_info, auth_cred).await } #[derive(Debug, Clone, Deserialize)] @@ -222,30 +324,79 @@ pub struct LoginPwForm { password: String, } -pub async fn partial_view_login_pw_post( +pub async fn view_login_pw_post( State(state): State, Extension(kopid): Extension, VerifiedClientInformation(client_auth_info): VerifiedClientInformation, jar: CookieJar, Form(login_pw_form): Form, ) -> Response { - let maybe_sessionid = jar + let auth_cred = AuthCredential::Password(login_pw_form.password); + credential_step(state, kopid, jar, client_auth_info, auth_cred).await +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginBackupCodeForm { + backupcode: String, +} + +pub async fn view_login_backupcode_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(login_bc_form): Form, +) -> Response { + // People (like me) may copy-paste the bc with whitespace that causes issues. Trim it now. + let trimmed = login_bc_form.backupcode.trim().to_string(); + let auth_cred = AuthCredential::BackupCode(trimmed); + credential_step(state, kopid, jar, client_auth_info, auth_cred).await +} + +pub async fn view_login_passkey_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Json(assertion): Json>, +) -> Response { + let auth_cred = AuthCredential::Passkey(assertion); + credential_step(state, kopid, jar, client_auth_info, auth_cred).await +} + +pub async fn view_login_seckey_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Json(assertion): Json>, +) -> Response { + let auth_cred = AuthCredential::SecurityKey(assertion); + credential_step(state, kopid, jar, client_auth_info, auth_cred).await +} + +async fn credential_step( + state: ServerState, + kopid: KOpId, + jar: CookieJar, + client_auth_info: ClientAuthInfo, + auth_cred: AuthCredential, +) -> Response { + let session_context = jar .get(COOKIE_AUTH_SESSION_ID) .map(|c| c.value()) .and_then(|s| { trace!(id_jws = %s); - state.reinflate_uuid_from_bytes(s) - }); + state.deserialise_from_str::(s) + }) + .unwrap_or_default(); - debug!("Session ID: {:?}", maybe_sessionid); - - // Init the login. let inter = state // This may change in the future ... .qe_r_ref .handle_auth( - maybe_sessionid, + session_context.id, AuthRequest { - step: AuthStep::Cred(AuthCredential::Password(login_pw_form.password)), + step: AuthStep::Cred(auth_cred), }, kopid.eventid, client_auth_info.clone(), @@ -255,7 +406,16 @@ pub async fn partial_view_login_pw_post( // Now process the response if ok. match inter { Ok(ar) => { - match partial_view_login_step(state, kopid.clone(), jar, ar, client_auth_info).await { + match view_login_step( + state, + kopid.clone(), + jar, + ar, + client_auth_info, + session_context, + ) + .await + { Ok(r) => r, // Okay, these errors are actually REALLY bad. Err(err_code) => HtmlTemplate(UnrecoverableErrorView { @@ -274,12 +434,13 @@ pub async fn partial_view_login_pw_post( } } -async fn partial_view_login_step( +async fn view_login_step( state: ServerState, kopid: KOpId, mut jar: CookieJar, auth_result: AuthResult, client_auth_info: ClientAuthInfo, + mut session_context: SessionContext, ) -> Result { trace!(?auth_result); @@ -304,7 +465,9 @@ async fn partial_view_login_step( AuthState::Choose(allowed) => { debug!("🧩 -> AuthState::Choose"); let kref = &state.jws_signer; - let jws = Jws::into_json(&SessionId { sessionid }).map_err(|e| { + // Set the sessionid. + session_context.id = Some(sessionid); + let jws = Jws::into_json(&session_context).map_err(|e| { error!(?e); OperationError::InvalidSessionState })?; @@ -355,8 +518,18 @@ async fn partial_view_login_step( // Autoselect was hit. continue; } + // Render the list of options. - _ => todo!(), + _ => { + let mechs = allowed + .into_iter() + .map(|m| Mech { + value: m.to_value(), + name: m, + }) + .collect(); + HtmlTemplate(LoginMechView { mechs }).into_response() + } }; // break acts as return in a loop. break res; @@ -376,17 +549,41 @@ async fn partial_view_login_step( let auth_allowed = allowed[0].clone(); match auth_allowed { - AuthAllowed::Totp => { - HtmlTemplate(LoginTotpPartialView::default()).into_response() + AuthAllowed::Totp => HtmlTemplate(LoginTotpView { + totp: session_context.totp.clone().unwrap_or_default(), + ..Default::default() + }) + .into_response(), + AuthAllowed::Password => HtmlTemplate(LoginPasswordView { + password: session_context.password.clone().unwrap_or_default(), + }) + .into_response(), + AuthAllowed::BackupCode => { + HtmlTemplate(LoginBackupCodeView {}).into_response() } - AuthAllowed::Password => { - HtmlTemplate(LoginPasswordPartialView {}).into_response() + AuthAllowed::SecurityKey(chal) => { + let chal_json = serde_json::to_string(&chal).unwrap(); + HtmlTemplate(LoginWebauthnView { + passkey: false, + chal: chal_json, + }) + .into_response() } - _ => todo!(), + AuthAllowed::Passkey(chal) => { + let chal_json = serde_json::to_string(&chal).unwrap(); + HtmlTemplate(LoginWebauthnView { + passkey: true, + chal: chal_json, + }) + .into_response() + } + _ => return Err(OperationError::InvalidState), } } _ => { - todo!(); + // We have changed auth session to only ever return one possibility, and + // that one option encodes the possible challenges. + return Err(OperationError::InvalidState); } }; @@ -415,6 +612,20 @@ async fn partial_view_login_step( // then webauthn won't work anyway! bearer_cookie.set_domain(state.domain.clone()); bearer_cookie.set_path("/"); + + jar = if session_context.remember_me { + let mut username_cookie = + Cookie::new(COOKIE_USERNAME, session_context.username.clone()); + username_cookie.set_secure(state.secure_cookies); + username_cookie.set_same_site(SameSite::Strict); + username_cookie.set_http_only(true); + username_cookie.set_domain(state.domain.clone()); + username_cookie.set_path("/"); + jar.add(username_cookie) + } else { + jar + }; + jar = jar .add(bearer_cookie) .remove(Cookie::from(COOKIE_AUTH_SESSION_ID)); diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index 1172cfdfe..61416d8e1 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -2,7 +2,7 @@ use askama::Template; use axum::{ http::StatusCode, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, Router, }; @@ -27,24 +27,47 @@ struct UnrecoverableErrorView { } pub fn view_router() -> Router { - let unauth_router = Router::new().route("/", get(login::view_index_get)); - - let unguarded_router = Router::new().route("/apps", get(apps::view_apps_get)); - - // Anything that is a partial only works if triggered from htmx - let guarded_router = Router::new() - .layer(HxRequestGuardLayer::default()) + let unguarded_router = Router::new() + .route("/", get(login::view_index_get)) + .route("/apps", get(apps::view_apps_get)) + // The login routes are htmx-free to make them simpler, which means + // they need manual guarding for direct get requests which can occur + // if a user attempts to reload the page. + .route( + "/api/login_passkey", + post(login::view_login_passkey_post).get(|| async { Redirect::to("/ui") }), + ) + .route( + "/api/login_seckey", + post(login::view_login_seckey_post).get(|| async { Redirect::to("/ui") }), + ) .route( "/api/login_begin", - post(login::partial_view_login_begin_post), + post(login::view_login_begin_post).get(|| async { Redirect::to("/ui") }), ) - .route("/api/login_totp", post(login::partial_view_login_totp_post)) - .route("/api/login_pw", post(login::partial_view_login_pw_post)); + .route( + "/api/login_mech_choose", + post(login::view_login_mech_choose_post).get(|| async { Redirect::to("/ui") }), + ) + .route( + "/api/login_backup_code", + post(login::view_login_backupcode_post).get(|| async { Redirect::to("/ui") }), + ) + .route( + "/api/login_totp", + post(login::view_login_totp_post).get(|| async { Redirect::to("/ui") }), + ) + .route( + "/api/login_pw", + post(login::view_login_pw_post).get(|| async { Redirect::to("/ui") }), + ); - Router::new() - .merge(unauth_router) - .merge(unguarded_router) - .merge(guarded_router) + // The webauthn post is unguarded because it's not a htmx event. + + // Anything that is a partial only works if triggered from htmx + let guarded_router = Router::new().layer(HxRequestGuardLayer::new("/ui")); + + Router::new().merge(unguarded_router).merge(guarded_router) } struct HtmlTemplate(T); @@ -69,7 +92,6 @@ where } } -/* /// Serde deserialization decorator to map empty Strings to None, fn empty_string_as_none<'de, D, T>(de: D) -> Result, D::Error> where @@ -88,5 +110,3 @@ where .map(Some), } } - -*/ diff --git a/server/core/static/external/base64.js b/server/core/static/external/base64.js new file mode 100644 index 000000000..0cbb4b5fb --- /dev/null +++ b/server/core/static/external/base64.js @@ -0,0 +1,3 @@ +// https://cdn.jsdelivr.net/npm/js-base64@3.7.4/base64.min.js + +!function(t,n){var r,e;"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(r=t.Base64,(e=n()).noConflict=function(){return t.Base64=r,e},t.Meteor&&(Base64=e),t.Base64=e)}("undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:this,(function(){"use strict";var t,n="3.7.4",r="function"==typeof atob,e="function"==typeof btoa,o="function"==typeof Buffer,u="function"==typeof TextDecoder?new TextDecoder:void 0,i="function"==typeof TextEncoder?new TextEncoder:void 0,f=Array.prototype.slice.call("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="),c=(t={},f.forEach((function(n,r){return t[n]=r})),t),a=/^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/,d=String.fromCharCode.bind(String),s="function"==typeof Uint8Array.from?Uint8Array.from.bind(Uint8Array):function(t,n){return void 0===n&&(n=function(t){return t}),new Uint8Array(Array.prototype.slice.call(t,0).map(n))},l=function(t){return t.replace(/=/g,"").replace(/[+\/]/g,(function(t){return"+"==t?"-":"_"}))},h=function(t){return t.replace(/[^A-Za-z0-9\+\/]/g,"")},p=function(t){for(var n,r,e,o,u="",i=t.length%3,c=0;c255||(e=t.charCodeAt(c++))>255||(o=t.charCodeAt(c++))>255)throw new TypeError("invalid character found");u+=f[(n=r<<16|e<<8|o)>>18&63]+f[n>>12&63]+f[n>>6&63]+f[63&n]}return i?u.slice(0,i-3)+"===".substring(i):u},y=e?function(t){return btoa(t)}:o?function(t){return Buffer.from(t,"binary").toString("base64")}:p,A=o?function(t){return Buffer.from(t).toString("base64")}:function(t){for(var n=[],r=0,e=t.length;r>>6)+d(128|63&n):d(224|n>>>12&15)+d(128|n>>>6&63)+d(128|63&n);var n=65536+1024*(t.charCodeAt(0)-55296)+(t.charCodeAt(1)-56320);return d(240|n>>>18&7)+d(128|n>>>12&63)+d(128|n>>>6&63)+d(128|63&n)},B=/[\uD800-\uDBFF][\uDC00-\uDFFFF]|[^\x00-\x7F]/g,x=function(t){return t.replace(B,g)},C=o?function(t){return Buffer.from(t,"utf8").toString("base64")}:i?function(t){return A(i.encode(t))}:function(t){return y(x(t))},m=function(t,n){return void 0===n&&(n=!1),n?l(C(t)):C(t)},v=function(t){return m(t,!0)},U=/[\xC0-\xDF][\x80-\xBF]|[\xE0-\xEF][\x80-\xBF]{2}|[\xF0-\xF7][\x80-\xBF]{3}/g,F=function(t){switch(t.length){case 4:var n=((7&t.charCodeAt(0))<<18|(63&t.charCodeAt(1))<<12|(63&t.charCodeAt(2))<<6|63&t.charCodeAt(3))-65536;return d(55296+(n>>>10))+d(56320+(1023&n));case 3:return d((15&t.charCodeAt(0))<<12|(63&t.charCodeAt(1))<<6|63&t.charCodeAt(2));default:return d((31&t.charCodeAt(0))<<6|63&t.charCodeAt(1))}},w=function(t){return t.replace(U,F)},S=function(t){if(t=t.replace(/\s+/g,""),!a.test(t))throw new TypeError("malformed base64.");t+="==".slice(2-(3&t.length));for(var n,r,e,o="",u=0;u>16&255):64===e?d(n>>16&255,n>>8&255):d(n>>16&255,n>>8&255,255&n);return o},E=r?function(t){return atob(h(t))}:o?function(t){return Buffer.from(t,"base64").toString("binary")}:S,D=o?function(t){return s(Buffer.from(t,"base64"))}:function(t){return s(E(t),(function(t){return t.charCodeAt(0)}))},R=function(t){return D(T(t))},z=o?function(t){return Buffer.from(t,"base64").toString("utf8")}:u?function(t){return u.decode(D(t))}:function(t){return w(E(t))},T=function(t){return h(t.replace(/[-_]/g,(function(t){return"-"==t?"+":"/"})))},Z=function(t){return z(T(t))},j=function(t){return{value:t,enumerable:!1,writable:!0,configurable:!0}},I=function(){var t=function(t,n){return Object.defineProperty(String.prototype,t,j(n))};t("fromBase64",(function(){return Z(this)})),t("toBase64",(function(t){return m(this,t)})),t("toBase64URI",(function(){return m(this,!0)})),t("toBase64URL",(function(){return m(this,!0)})),t("toUint8Array",(function(){return R(this)}))},O=function(){var t=function(t,n){return Object.defineProperty(Uint8Array.prototype,t,j(n))};t("toBase64",(function(t){return b(this,t)})),t("toBase64URI",(function(){return b(this,!0)})),t("toBase64URL",(function(){return b(this,!0)}))},P={version:n,VERSION:"3.7.4",atob:E,atobPolyfill:S,btoa:y,btoaPolyfill:p,fromBase64:Z,toBase64:m,encode:m,encodeURI:v,encodeURL:v,utob:x,btou:w,decode:Z,isValid:function(t){if("string"!=typeof t)return!1;var n=t.replace(/\s+/g,"").replace(/={0,2}$/,"");return!/[^\s0-9a-zA-Z\+/]/.test(n)||!/[^\s0-9a-zA-Z\-_]/.test(n)},fromUint8Array:b,toUint8Array:R,extendString:I,extendUint8Array:O,extendBuiltins:function(){I(),O()},Base64:{}};return Object.keys(P).forEach((function(t){return P.Base64[t]=P[t]})),P})); diff --git a/server/core/static/external/pkhtml.js b/server/core/static/external/pkhtml.js new file mode 100644 index 000000000..e9375da59 --- /dev/null +++ b/server/core/static/external/pkhtml.js @@ -0,0 +1,53 @@ + +function asskey_login(target) { + let credentialRequestOptions = JSON.parse(document.getElementById('data').textContent); + credentialRequestOptions.publicKey.challenge = Base64.toUint8Array(credentialRequestOptions.publicKey.challenge); + credentialRequestOptions.publicKey.allowCredentials?.forEach(function (listItem) { + listItem.id = Base64.toUint8Array(listItem.id) + }); + + navigator.credentials.get({ publicKey: credentialRequestOptions.publicKey }) + .then((assertion) => { + const myRequest = new Request(target, { + method: 'POST', + redirect: 'follow', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + id: assertion.id, + rawId: Base64.fromUint8Array(new Uint8Array(assertion.rawId), true), + type: assertion.type, + response: { + authenticatorData: Base64.fromUint8Array(new Uint8Array(assertion.response.authenticatorData), true), + clientDataJSON: Base64.fromUint8Array(new Uint8Array(assertion.response.clientDataJSON), true), + signature: Base64.fromUint8Array(new Uint8Array(assertion.response.signature), true), + userHandle: Base64.fromUint8Array(new Uint8Array(assertion.response.userHandle), true) + }, + }), + }); + fetch(myRequest).then((response) => { + if (response.redirected) { + window.location.replace(response.url); + return; + } else { + console.error("expected a redirect"); + } + }) + }) +} + +try { + const myButton = document.getElementById("start-passkey-button"); + myButton.addEventListener("click", () => { + asskey_login('/ui/api/login_passkey'); + }); +} catch (_error) {}; + +try { + const myButton = document.getElementById("start-seckey-button"); + myButton.addEventListener("click", () => { + asskey_login('/ui/api/login_seckey'); + }); +} catch (_error) {}; + diff --git a/server/core/static/style.css b/server/core/static/style.css index 193554273..8949f0299 100644 --- a/server/core/static/style.css +++ b/server/core/static/style.css @@ -3,6 +3,10 @@ body { height: 100%; } +.input-hidden { + display: none; +} + .form-cred-reset-body { width: 100%; max-width: 500px; diff --git a/server/core/templates/base.html b/server/core/templates/base.html index a0ce7bafc..a0f0cc931 100644 --- a/server/core/templates/base.html +++ b/server/core/templates/base.html @@ -15,8 +15,9 @@ href="/pkg/img/logo-192.png" /> - + + + (% block head %)(% endblock %) diff --git a/server/core/templates/login.html b/server/core/templates/login.html index 8566dfe0f..1c829ff73 100644 --- a/server/core/templates/login.html +++ b/server/core/templates/login.html @@ -1,50 +1,56 @@ -(% extends "base_htmx.html" %) +(% extends "login_base.html" %) -(% block title %)Error(% endblock %) +(% block logincontainer %) + +
+
+ -(% block head %) -(% endblock %) - -(% block body %) -
-
- -

Kanidm

-
-
- - -
- -
-
- - -
-
- -
- -
-
+ + + + +
+
+ + +
+
+ +
+ (% endblock %) diff --git a/server/core/templates/login_backupcode.html b/server/core/templates/login_backupcode.html new file mode 100644 index 000000000..4ef1a717d --- /dev/null +++ b/server/core/templates/login_backupcode.html @@ -0,0 +1,24 @@ +(% extends "login_base.html" %) + +(% block logincontainer %) + +
+
+ +
+
+ +
+
+(% endblock %) diff --git a/server/core/templates/login_base.html b/server/core/templates/login_base.html new file mode 100644 index 000000000..a487bdbc4 --- /dev/null +++ b/server/core/templates/login_base.html @@ -0,0 +1,19 @@ +(% extends "base_htmx.html" %) + +(% block title %)Login(% endblock %) + +(% block head %) +(% endblock %) + +(% block body %) +
+
+ +

Kanidm

+
+
+ (% block logincontainer %) + (% endblock %) +
+
+(% endblock %) diff --git a/server/core/templates/login_mech_choose.html b/server/core/templates/login_mech_choose.html new file mode 100644 index 000000000..20c5ed1e2 --- /dev/null +++ b/server/core/templates/login_mech_choose.html @@ -0,0 +1,22 @@ +(% extends "login_base.html" %) + +(% block logincontainer %) +
+

Choose how to proceed:

+
+
+
    + (% for mech in mechs %) +
  • +
    + + +
    +
  • + (% endfor %) +
+
+(% endblock %) diff --git a/server/core/templates/login_password_partial.html b/server/core/templates/login_password.html similarity index 69% rename from server/core/templates/login_password_partial.html rename to server/core/templates/login_password.html index 3c88ca402..c67bcfa30 100644 --- a/server/core/templates/login_password_partial.html +++ b/server/core/templates/login_password.html @@ -1,7 +1,8 @@ +(% extends "login_base.html" %) - +(% block logincontainer %) -
+
@@ -18,8 +19,7 @@ + >Submit
- - +(% endblock %) diff --git a/server/core/templates/login_totp_partial.html b/server/core/templates/login_totp.html similarity index 58% rename from server/core/templates/login_totp_partial.html rename to server/core/templates/login_totp.html index eb7364dff..341c6d98d 100644 --- a/server/core/templates/login_totp_partial.html +++ b/server/core/templates/login_totp.html @@ -1,12 +1,14 @@ +(% extends "login_base.html" %) - +(% block logincontainer %) (% match errors %) (% when LoginTotpError::Syntax %) - Invalid Value - TOTP must only consist of numbers + Invalid Value + TOTP must only consist of numbers (% when LoginTotpError::None %) (% endmatch %) -
+
@@ -23,8 +25,7 @@ + >Submit
- - +(% endblock %) diff --git a/server/core/templates/login_webauthn.html b/server/core/templates/login_webauthn.html new file mode 100644 index 000000000..61acbef67 --- /dev/null +++ b/server/core/templates/login_webauthn.html @@ -0,0 +1,17 @@ +(% extends "login_base.html" %) + +(% block logincontainer %) + + + + + +(% if passkey %) + +(% else %) + +(% endif %) + +(% endblock %) diff --git a/server/lib/src/be/dbvalue.rs b/server/lib/src/be/dbvalue.rs index cc7359562..7865ae0f3 100644 --- a/server/lib/src/be/dbvalue.rs +++ b/server/lib/src/be/dbvalue.rs @@ -429,7 +429,7 @@ pub struct DbValueOauthScopeMapV1 { pub data: Vec, } -#[derive(Serialize, Deserialize, Debug, Default)] +#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum DbValueAccessScopeV1 { #[serde(rename = "i")] IdentityOnly, @@ -444,7 +444,7 @@ pub enum DbValueAccessScopeV1 { Synchronise, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] #[allow(clippy::enum_variant_names)] pub enum DbValueIdentityId { #[serde(rename = "v1i")] @@ -455,7 +455,7 @@ pub enum DbValueIdentityId { V1Sync(Uuid), } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum DbValueSessionStateV1 { #[serde(rename = "ea")] ExpiresAt(String), @@ -465,7 +465,27 @@ pub enum DbValueSessionStateV1 { RevokedAt(DbCidV1), } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum DbValueAuthTypeV1 { + #[serde(rename = "an")] + Anonymous, + #[serde(rename = "po")] + Password, + #[serde(rename = "pg")] + GeneratedPassword, + #[serde(rename = "pt")] + PasswordTotp, + #[serde(rename = "pb")] + PasswordBackupCode, + #[serde(rename = "ps")] + PasswordSecurityKey, + #[serde(rename = "as")] + Passkey, + #[serde(rename = "ap")] + AttestedPasskey, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum DbValueSession { V1 { #[serde(rename = "u")] @@ -513,6 +533,24 @@ pub enum DbValueSession { #[serde(rename = "s", default)] scope: DbValueAccessScopeV1, }, + V4 { + #[serde(rename = "u")] + refer: Uuid, + #[serde(rename = "l")] + label: String, + #[serde(rename = "e")] + state: DbValueSessionStateV1, + #[serde(rename = "i")] + issued_at: String, + #[serde(rename = "b")] + issued_by: DbValueIdentityId, + #[serde(rename = "c")] + cred_id: Uuid, + #[serde(rename = "s", default)] + scope: DbValueAccessScopeV1, + #[serde(rename = "t")] + type_: DbValueAuthTypeV1, + }, } #[derive(Serialize, Deserialize, Debug, Default)] diff --git a/server/lib/src/idm/account.rs b/server/lib/src/idm/account.rs index e9afc7ac0..b58f07986 100644 --- a/server/lib/src/idm/account.rs +++ b/server/lib/src/idm/account.rs @@ -24,7 +24,6 @@ use crate::prelude::*; use crate::schema::SchemaTransaction; use crate::value::{IntentTokenState, PartialValue, SessionState, Value}; use kanidm_lib_crypto::CryptoPolicy; - use sshkey_attest::proto::PublicKey as SshPublicKey; #[derive(Debug, Clone)] diff --git a/server/lib/src/idm/authsession.rs b/server/lib/src/idm/authsession.rs index 6bfb4a8e4..88a4e8f8c 100644 --- a/server/lib/src/idm/authsession.rs +++ b/server/lib/src/idm/authsession.rs @@ -3,8 +3,6 @@ //! factor to assert that the user is legitimate. This also contains some //! support code for asynchronous task execution. use std::collections::BTreeMap; -use std::convert::TryFrom; -use std::fmt; use std::sync::Arc; use std::time::Duration; @@ -31,7 +29,7 @@ use crate::idm::delayed::{ use crate::idm::AuthState; use crate::prelude::*; use crate::server::keys::KeyObject; -use crate::value::{Session, SessionState}; +use crate::value::{AuthType, Session, SessionState}; use time::OffsetDateTime; use super::accountpolicy::ResolvedAccountPolicy; @@ -51,29 +49,6 @@ const BAD_CREDENTIALS: &str = "invalid credential message"; const ACCOUNT_EXPIRED: &str = "account expired"; const PW_BADLIST_MSG: &str = "password is in badlist"; -#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)] -pub enum AuthType { - Anonymous, - Password, - GeneratedPassword, - PasswordMfa, - Passkey, - AttestedPasskey, -} - -impl fmt::Display for AuthType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AuthType::Anonymous => write!(f, "anonymous"), - AuthType::Password => write!(f, "password"), - AuthType::GeneratedPassword => write!(f, "generatedpassword"), - AuthType::PasswordMfa => write!(f, "passwordmfa"), - AuthType::Passkey => write!(f, "passkey"), - AuthType::AttestedPasskey => write!(f, "attested_passkey"), - } - } -} - #[derive(Debug, Clone)] enum AuthIntent { InitialAuth { @@ -102,12 +77,29 @@ enum CredVerifyState { #[derive(Clone, Debug)] /// The state of a multifactor authenticator during authentication. -struct CredMfa { +struct CredTotp { pw: Password, pw_state: CredVerifyState, totp: BTreeMap, - wan: Option<(RequestChallengeResponse, SecurityKeyAuthentication)>, - backup_code: Option, + mfa_state: CredVerifyState, +} + +#[derive(Clone, Debug)] +/// The state of a multifactor authenticator during authentication. +struct CredBackupCode { + pw: Password, + pw_state: CredVerifyState, + backup_code: BackupCodes, + mfa_state: CredVerifyState, +} + +#[derive(Clone, Debug)] +/// The state of a multifactor authenticator during authentication. +struct CredSecurityKey { + pw: Password, + pw_state: CredVerifyState, + chal: RequestChallengeResponse, + ska: SecurityKeyAuthentication, mfa_state: CredVerifyState, } @@ -140,8 +132,16 @@ enum CredHandler { generated: bool, cred_id: Uuid, }, - PasswordMfa { - cmfa: Box, + PasswordTotp { + cmfa: CredTotp, + cred_id: Uuid, + }, + PasswordBackupCode { + cmfa: CredBackupCode, + cred_id: Uuid, + }, + PasswordSecurityKey { + cmfa: CredSecurityKey, cred_id: Uuid, }, Passkey { @@ -157,89 +157,6 @@ enum 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, webauthn): (&Credential, &Webauthn)) -> Result { - match &c.type_ { - CredentialType::Password(pw) => Ok(CredHandler::Password { - pw: pw.clone(), - generated: false, - cred_id: c.uuid, - }), - CredentialType::GeneratedPassword(pw) => Ok(CredHandler::Password { - pw: pw.clone(), - generated: true, - cred_id: c.uuid, - }), - 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 - .start_securitykey_authentication(&sks) - .map(Some) - .map_err(|e| { - security_info!( - err = ?e, - "Unable to create webauthn authentication challenge" - ) - })? - } else { - None - }; - - let cmfa = Box::new(CredMfa { - pw: pw.clone(), - pw_state: CredVerifyState::Init, - totp: maybe_totp - .iter() - .map(|(l, t)| (l.clone(), t.clone())) - .collect(), - wan, - backup_code: maybe_backup_code.clone(), - mfa_state: CredVerifyState::Init, - }); - - // Paranoia. Should NEVER occur. - if cmfa.totp.is_empty() && cmfa.wan.is_none() { - security_critical!("Unable to create CredHandler::PasswordMfa - totp and webauthn are both not present. Credentials MAY be corrupt!"); - return Err(()); - } - - Ok(CredHandler::PasswordMfa { - cmfa, - cred_id: c.uuid, - }) - } - CredentialType::Webauthn(wan) => { - let pks: Vec<_> = wan.values().cloned().collect(); - let cred_ids: BTreeMap<_, _> = pks - .iter() - .map(|pk| (pk.cred_id().clone(), c.uuid)) - .collect(); - webauthn - .start_passkey_authentication(&pks) - .map(|(chal, wan_state)| CredHandler::Passkey { - c_wan: CredPasskey { - chal, - wan_state, - state: CredVerifyState::Init, - }, - cred_ids, - }) - .map_err(|e| { - security_info!(?e, "Unable to create webauthn authentication challenge"); - // maps to unit. - }) - } - } - } -} - impl CredHandler { /// 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" @@ -248,7 +165,7 @@ impl CredHandler { fn build_from_set_passkey( wan: impl Iterator, webauthn: &Webauthn, - ) -> Result { + ) -> Option { let mut pks = Vec::with_capacity(wan.size_hint().0); let mut cred_ids = BTreeMap::default(); @@ -258,8 +175,8 @@ impl CredHandler { } if pks.is_empty() { - security_info!("Account does not have any passkeys"); - return Err(()); + debug!("Account does not have any passkeys"); + return None; }; webauthn @@ -279,13 +196,14 @@ impl CredHandler { ); // maps to unit. }) + .ok() } fn build_from_single_passkey( cred_id: Uuid, pk: PasskeyV4, webauthn: &Webauthn, - ) -> Result { + ) -> Option { let cred_ids = btreemap!((pk.cred_id().clone(), cred_id)); let pks = vec![pk]; @@ -306,16 +224,17 @@ impl CredHandler { ); // maps to unit. }) + .ok() } fn build_from_set_attested_pk( wan: &BTreeMap, att_ca_list: &AttestationCaList, webauthn: &Webauthn, - ) -> Result { + ) -> Option { if wan.is_empty() { - security_info!("Account does not have any attested passkeys"); - return Err(()); + debug!("Account does not have any attested passkeys"); + return None; }; let pks: Vec<_> = wan.values().map(|(_, k)| k).cloned().collect(); @@ -339,6 +258,7 @@ impl CredHandler { ); // maps to unit. }) + .ok() } fn build_from_single_attested_pk( @@ -346,7 +266,7 @@ impl CredHandler { pk: &AttestedPasskeyV4, att_ca_list: &AttestationCaList, webauthn: &Webauthn, - ) -> Result { + ) -> Option { let creds = btreemap!((pk.clone(), cred_id)); let pks = vec![pk.clone()]; @@ -368,6 +288,100 @@ impl CredHandler { ); // maps to unit. }) + .ok() + } + + fn build_from_password_totp(cred: &Credential) -> Option { + match &cred.type_ { + CredentialType::PasswordMfa(pw, maybe_totp, _, _) => { + if maybe_totp.is_empty() { + None + } else { + let cmfa = CredTotp { + pw: pw.clone(), + pw_state: CredVerifyState::Init, + totp: maybe_totp + .iter() + .map(|(l, t)| (l.clone(), t.clone())) + .collect(), + mfa_state: CredVerifyState::Init, + }; + + Some(CredHandler::PasswordTotp { + cmfa, + cred_id: cred.uuid, + }) + } + } + _ => None, + } + } + + fn build_from_password_backup_code(cred: &Credential) -> Option { + match &cred.type_ { + CredentialType::PasswordMfa(pw, _, _, Some(backup_code)) => { + let cmfa = CredBackupCode { + pw: pw.clone(), + pw_state: CredVerifyState::Init, + backup_code: backup_code.clone(), + mfa_state: CredVerifyState::Init, + }; + + Some(CredHandler::PasswordBackupCode { + cmfa, + cred_id: cred.uuid, + }) + } + _ => None, + } + } + + fn build_from_password_security_key(cred: &Credential, webauthn: &Webauthn) -> Option { + match &cred.type_ { + CredentialType::PasswordMfa(pw, _, maybe_wan, _) => { + if !maybe_wan.is_empty() { + let sks: Vec<_> = maybe_wan.values().cloned().collect(); + let (chal, ska) = webauthn + .start_securitykey_authentication(&sks) + .map_err(|err| { + warn!(?err, "Unable to create webauthn authentication challenge") + }) + .ok()?; + + let cmfa = CredSecurityKey { + pw: pw.clone(), + pw_state: CredVerifyState::Init, + ska, + chal, + mfa_state: CredVerifyState::Init, + }; + + Some(CredHandler::PasswordSecurityKey { + cmfa, + cred_id: cred.uuid, + }) + } else { + None + } + } + _ => None, + } + } + + fn build_from_password_only(cred: &Credential) -> Option { + match &cred.type_ { + CredentialType::Password(pw) => Some(CredHandler::Password { + pw: pw.clone(), + generated: false, + cred_id: cred.uuid, + }), + CredentialType::GeneratedPassword(pw) => Some(CredHandler::Password { + pw: pw.clone(), + generated: true, + cred_id: cred.uuid, + }), + _ => None, + } } /// Determine if this password factor requires an upgrade of it's cryptographic type. If @@ -458,11 +472,104 @@ impl CredHandler { /// Proceed with the next step in a multifactor authentication, based on the current /// verification results and state. If this logic of this statemachine is violated, the /// authentication will fail. - fn validate_password_mfa( + fn validate_password_totp( cred: &AuthCredential, cred_id: Uuid, ts: Duration, - pw_mfa: &mut CredMfa, + pw_mfa: &mut CredTotp, + who: Uuid, + async_tx: &Sender, + pw_badlist_set: &HashSet, + ) -> CredState { + match (&pw_mfa.mfa_state, &pw_mfa.pw_state) { + (CredVerifyState::Init, CredVerifyState::Init) => { + // MFA first + match cred { + AuthCredential::Totp(totp_chal) => { + // So long as one totp matches, success. Log which token was used. + // We don't need to worry about the empty case since none will match and we + // will get the failure. + if let Some(label) = pw_mfa + .totp + .iter() + .find(|(_, t)| t.verify(*totp_chal, ts)) + .map(|(l, _)| l) + { + pw_mfa.mfa_state = CredVerifyState::Success; + security_info!( + "Handler::PasswordMfa -> Result::Continue - TOTP ({}) OK, password -", label + ); + CredState::Continue(Box::new(NonEmpty { + head: AuthAllowed::Password, + tail: Vec::with_capacity(0), + })) + } else { + pw_mfa.mfa_state = CredVerifyState::Fail; + security_error!( + "Handler::PasswordMfa -> Result::Denied - TOTP Fail, password -" + ); + CredState::Denied(BAD_TOTP_MSG) + } + } + _ => { + security_error!("Handler::PasswordMfa -> Result::Denied - invalid cred type for handler"); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } + (CredVerifyState::Success, CredVerifyState::Init) => { + // PW second. + match cred { + AuthCredential::Password(cleartext) => { + if pw_mfa.pw.verify(cleartext.as_str()).unwrap_or(false) { + if pw_badlist_set.contains(&cleartext.to_lowercase()) { + pw_mfa.pw_state = CredVerifyState::Fail; + security_error!("Handler::PasswordMfa -> Result::Denied - Password found in badlist during login"); + CredState::Denied(PW_BADLIST_MSG) + } else { + pw_mfa.pw_state = CredVerifyState::Success; + security_info!("Handler::PasswordMfa -> Result::Success - TOTP OK, password OK"); + Self::maybe_pw_upgrade( + &pw_mfa.pw, + who, + cleartext.as_str(), + async_tx, + ); + CredState::Success { + auth_type: AuthType::PasswordTotp, + cred_id, + } + } + } else { + pw_mfa.pw_state = CredVerifyState::Fail; + security_error!( + "Handler::PasswordMfa -> Result::Denied - TOTP OK, password Fail" + ); + CredState::Denied(BAD_PASSWORD_MSG) + } + } + _ => { + security_error!("Handler::PasswordMfa -> Result::Denied - invalid cred type for handler"); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } + _ => { + security_error!( + "Handler::PasswordMfa -> Result::Denied - invalid credential mfa and pw state" + ); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } // end CredHandler::PasswordTotp + + /// Proceed with the next step in a multifactor authentication, based on the current + /// verification results and state. If this logic of this statemachine is violated, the + /// authentication will fail. + fn validate_password_security_key( + cred: &AuthCredential, + cred_id: Uuid, + pw_mfa: &mut CredSecurityKey, webauthn: &Webauthn, who: Uuid, async_tx: &Sender, @@ -471,14 +578,9 @@ impl CredHandler { match (&pw_mfa.mfa_state, &pw_mfa.pw_state) { (CredVerifyState::Init, CredVerifyState::Init) => { // MFA first - match ( - cred, - !pw_mfa.totp.is_empty(), - pw_mfa.wan.as_ref(), - pw_mfa.backup_code.as_ref(), - ) { - (AuthCredential::SecurityKey(resp), _, Some((_, wan_state)), _) => { - match webauthn.finish_securitykey_authentication(resp, wan_state) { + match cred { + AuthCredential::SecurityKey(resp) => { + match webauthn.finish_securitykey_authentication(resp, &pw_mfa.ska) { Ok(auth_result) => { pw_mfa.mfa_state = CredVerifyState::Success; // Success. Determine if we need to update the counter @@ -512,34 +614,73 @@ impl CredHandler { } } } - (AuthCredential::Totp(totp_chal), true, _, _) => { - // So long as one totp matches, success. Log which token was used. - // We don't need to worry about the empty case since none will match and we - // will get the failure. - if let Some(label) = pw_mfa - .totp - .iter() - .find(|(_, t)| t.verify(*totp_chal, ts)) - .map(|(l, _)| l) - { - pw_mfa.mfa_state = CredVerifyState::Success; - security_info!( - "Handler::PasswordMfa -> Result::Continue - TOTP ({}) OK, password -", label - ); - CredState::Continue(Box::new(NonEmpty { - head: AuthAllowed::Password, - tail: Vec::with_capacity(0), - })) + _ => { + security_error!("Handler::PasswordMfa -> Result::Denied - invalid cred type for handler"); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } + (CredVerifyState::Success, CredVerifyState::Init) => { + // PW second. + match cred { + AuthCredential::Password(cleartext) => { + if pw_mfa.pw.verify(cleartext.as_str()).unwrap_or(false) { + if pw_badlist_set.contains(&cleartext.to_lowercase()) { + pw_mfa.pw_state = CredVerifyState::Fail; + security_error!("Handler::PasswordMfa -> Result::Denied - Password found in badlist during login"); + CredState::Denied(PW_BADLIST_MSG) + } else { + pw_mfa.pw_state = CredVerifyState::Success; + security_info!("Handler::PasswordMfa -> Result::Success - SecurityKey OK, password OK"); + Self::maybe_pw_upgrade( + &pw_mfa.pw, + who, + cleartext.as_str(), + async_tx, + ); + CredState::Success { + auth_type: AuthType::PasswordSecurityKey, + cred_id, + } + } } else { - pw_mfa.mfa_state = CredVerifyState::Fail; - security_error!( - "Handler::PasswordMfa -> Result::Denied - TOTP Fail, password -" - ); - CredState::Denied(BAD_TOTP_MSG) + pw_mfa.pw_state = CredVerifyState::Fail; + security_error!("Handler::PasswordMfa -> Result::Denied - SecurityKey OK, password Fail"); + CredState::Denied(BAD_PASSWORD_MSG) } } - (AuthCredential::BackupCode(code_chal), _, _, Some(backup_codes)) => { - if backup_codes.verify(code_chal) { + _ => { + security_error!("Handler::PasswordMfa -> Result::Denied - invalid cred type for handler"); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } + _ => { + security_error!( + "Handler::PasswordMfa -> Result::Denied - invalid credential mfa and pw state" + ); + CredState::Denied(BAD_AUTH_TYPE_MSG) + } + } + } + + /// Proceed with the next step in a multifactor authentication, based on the current + /// verification results and state. If this logic of this statemachine is violated, the + /// authentication will fail. + fn validate_password_backup_code( + cred: &AuthCredential, + cred_id: Uuid, + pw_mfa: &mut CredBackupCode, + who: Uuid, + async_tx: &Sender, + pw_badlist_set: &HashSet, + ) -> CredState { + match (&pw_mfa.mfa_state, &pw_mfa.pw_state) { + (CredVerifyState::Init, CredVerifyState::Init) => { + // MFA first + match cred { + AuthCredential::BackupCode(code_chal) => { + if pw_mfa.backup_code.verify(code_chal) { if let Err(_e) = async_tx.send(DelayedAction::BackupCodeRemoval(BackupCodeRemoval { target_uuid: who, @@ -579,7 +720,7 @@ impl CredHandler { CredState::Denied(PW_BADLIST_MSG) } else { pw_mfa.pw_state = CredVerifyState::Success; - security_info!("Handler::PasswordMfa -> Result::Success - TOTP/WebAuthn/BackupCode OK, password OK"); + security_info!("Handler::PasswordMfa -> Result::Success - BackupCode OK, password OK"); Self::maybe_pw_upgrade( &pw_mfa.pw, who, @@ -587,13 +728,13 @@ impl CredHandler { async_tx, ); CredState::Success { - auth_type: AuthType::PasswordMfa, + auth_type: AuthType::PasswordBackupCode, cred_id, } } } else { pw_mfa.pw_state = CredVerifyState::Fail; - security_error!("Handler::PasswordMfa -> Result::Denied - TOTP/WebAuthn/BackupCode OK, password Fail"); + security_error!("Handler::PasswordMfa -> Result::Denied - BackupCode OK, password Fail"); CredState::Denied(BAD_PASSWORD_MSG) } } @@ -605,15 +746,13 @@ impl CredHandler { } _ => { security_error!( - "Handler::PasswordMfa -> Result::lenied - invalid credential mfa and pw state" + "Handler::PasswordMfa -> Result::Denied - invalid credential mfa and pw state" ); CredState::Denied(BAD_AUTH_TYPE_MSG) } } } - // end CredHandler::PasswordMfa - /// Validate a webauthn authentication attempt pub fn validate_passkey( cred: &AuthCredential, @@ -781,14 +920,36 @@ impl CredHandler { async_tx, pw_badlist_set, ), - CredHandler::PasswordMfa { + CredHandler::PasswordTotp { ref mut cmfa, cred_id, - } => Self::validate_password_mfa( + } => Self::validate_password_totp( cred, *cred_id, ts, cmfa, + who, + async_tx, + pw_badlist_set, + ), + CredHandler::PasswordBackupCode { + ref mut cmfa, + cred_id, + } => Self::validate_password_backup_code( + cred, + *cred_id, + cmfa, + who, + async_tx, + pw_badlist_set, + ), + CredHandler::PasswordSecurityKey { + ref mut cmfa, + cred_id, + } => Self::validate_password_security_key( + cred, + *cred_id, + cmfa, webauthn, who, async_tx, @@ -820,21 +981,12 @@ impl CredHandler { match &self { CredHandler::Anonymous { .. } => vec![AuthAllowed::Anonymous], CredHandler::Password { .. } => vec![AuthAllowed::Password], - CredHandler::PasswordMfa { ref cmfa, .. } => cmfa - .backup_code - .iter() - .map(|_| AuthAllowed::BackupCode) - // This looks weird but the idea is that if at least *one* - // totp exists, then we only offer TOTP once. If none are - // there we offer it none. - .chain(cmfa.totp.iter().next().map(|_| AuthAllowed::Totp)) - // This iter is over an option so it's there or not. - .chain( - cmfa.wan - .iter() - .map(|(chal, _)| AuthAllowed::SecurityKey(chal.clone())), - ) - .collect(), + CredHandler::PasswordTotp { .. } => vec![AuthAllowed::Totp], + CredHandler::PasswordBackupCode { .. } => vec![AuthAllowed::BackupCode], + + CredHandler::PasswordSecurityKey { ref cmfa, .. } => { + vec![AuthAllowed::SecurityKey(cmfa.chal.clone())] + } CredHandler::Passkey { c_wan, .. } => vec![AuthAllowed::Passkey(c_wan.chal.clone())], CredHandler::AttestedPasskey { c_wan, .. } => { vec![AuthAllowed::Passkey(c_wan.chal.clone())] @@ -847,7 +999,9 @@ impl CredHandler { match (self, mech) { (CredHandler::Anonymous { .. }, AuthMech::Anonymous) | (CredHandler::Password { .. }, AuthMech::Password) - | (CredHandler::PasswordMfa { .. }, AuthMech::PasswordMfa) + | (CredHandler::PasswordTotp { .. }, AuthMech::PasswordTotp) + | (CredHandler::PasswordBackupCode { .. }, AuthMech::PasswordBackupCode) + | (CredHandler::PasswordSecurityKey { .. }, AuthMech::PasswordSecurityKey) | (CredHandler::Passkey { .. }, AuthMech::Passkey) | (CredHandler::AttestedPasskey { .. }, AuthMech::Passkey) => true, (_, _) => false, @@ -858,7 +1012,9 @@ impl CredHandler { match self { CredHandler::Anonymous { .. } => AuthMech::Anonymous, CredHandler::Password { .. } => AuthMech::Password, - CredHandler::PasswordMfa { .. } => AuthMech::PasswordMfa, + CredHandler::PasswordTotp { .. } => AuthMech::PasswordTotp, + CredHandler::PasswordBackupCode { .. } => AuthMech::PasswordBackupCode, + CredHandler::PasswordSecurityKey { .. } => AuthMech::PasswordSecurityKey, CredHandler::Passkey { .. } => AuthMech::Passkey, CredHandler::AttestedPasskey { .. } => AuthMech::Passkey, } @@ -956,8 +1112,7 @@ impl AuthSession { tail: Vec::with_capacity(0), }) } else { - // What's valid to use in this context? - let mut handlers = Vec::with_capacity(0); + let mut handlers = Vec::with_capacity(4); // TODO: We can't yet fully enforce account policy on auth, there is a bit of work // to do to be able to check for pw / mfa etc. @@ -966,18 +1121,34 @@ impl AuthSession { // let cred_type_min = asd.account_policy.credential_policy(); if let Some(cred) = &asd.account.primary { - if let Ok(ch) = CredHandler::try_from((cred, asd.webauthn)) { + // Is it a pw-only credential? + if let Some(ch) = CredHandler::build_from_password_totp(cred) { handlers.push(ch); - } else { - security_critical!( - "corrupt credentials, unable to start primary credhandler" - ); + } + + if let Some(ch) = CredHandler::build_from_password_backup_code(cred) { + handlers.push(ch); + } + + if let Some(ch) = + CredHandler::build_from_password_security_key(cred, asd.webauthn) + { + handlers.push(ch); + } + + if handlers.is_empty() { + // No MFA types were setup, allow the PW only to proceed then. + if let Some(ch) = CredHandler::build_from_password_only(cred) { + handlers.push(ch); + } } } + trace!(?handlers); + // Important - if attested is present, don't use passkeys if let Some(att_ca_list) = asd.account_policy.webauthn_attestation_ca_list() { - if let Ok(ch) = CredHandler::build_from_set_attested_pk( + if let Some(ch) = CredHandler::build_from_set_attested_pk( &asd.account.attested_passkeys, att_ca_list, asd.webauthn, @@ -997,7 +1168,7 @@ impl AuthSession { .map(|(u, (_, pk))| (*u, pk.into())), ); - if let Ok(ch) = + if let Some(ch) = CredHandler::build_from_set_passkey(credential_iter, asd.webauthn) { handlers.push(ch); @@ -1061,80 +1232,90 @@ impl AuthSession { } let state = if asd.account.is_within_valid_time(asd.ct) { - // Get the credential that matches this cred_id. - // - // To make this work "cleanly" we can't really nest a bunch of if - // statements like if primary and if primary.cred_id because the logic will - // just be chaos. So for now we build this as a mut option. - // - // Do we need to double check for anon here? I don't think so since the - // anon cred_id won't ever exist on an account. + // Get the credential that matches this cred_id and auth type used in the + // initial authentication. // We can't yet fully enforce account policy on auth, there is a bit of work // to do to be able to check the credential types match what we expect. let mut cred_handler = None; - if let Some(primary) = asd.account.primary.as_ref() { - if primary.uuid == cred_id { - if let Ok(ch) = CredHandler::try_from((primary, asd.webauthn)) { - // Update it. - debug_assert!(cred_handler.is_none()); - cred_handler = Some(ch); - } else { - security_critical!( - "corrupt credentials, unable to start primary credhandler" - ); + match session.type_ { + AuthType::Password + | AuthType::GeneratedPassword + // If a backup code was used, since the code was scrubbed at use we need to + // fall back to the password of the account instead. + | AuthType::PasswordBackupCode => { + if let Some(primary) = asd.account.primary.as_ref() { + if primary.uuid == cred_id { + cred_handler = CredHandler::build_from_password_only(primary) + } } } - } + AuthType::PasswordTotp => { + if let Some(primary) = asd.account.primary.as_ref() { + if primary.uuid == cred_id { + cred_handler = CredHandler::build_from_password_totp(primary) + } + } + } + AuthType::PasswordSecurityKey => { + if let Some(primary) = asd.account.primary.as_ref() { + if primary.uuid == cred_id { + cred_handler = + CredHandler::build_from_password_security_key(primary, asd.webauthn) + } + } + } + AuthType::Passkey => { + // Scan both attested and passkeys for the possible credential. + let maybe_pk: Option = asd + .account + .attested_passkeys + .get(&cred_id) + .map(|(_, apk)| apk.into()) + .or_else(|| asd.account.passkeys.get(&cred_id).map(|(_, pk)| pk.clone())); - // Do we have an attestation ca list? If so, we only accept attested - // passkeys. - if let Some(att_ca_list) = asd.account_policy.webauthn_attestation_ca_list() { - if let Some(pk) = asd - .account - .attested_passkeys - .get(&cred_id) - .map(|(_, pk)| pk) - { - if let Ok(ch) = CredHandler::build_from_single_attested_pk( - cred_id, - pk, - att_ca_list, - asd.webauthn, - ) { - // Update it. - debug_assert!(cred_handler.is_none()); - cred_handler = Some(ch); - } else { - security_critical!( + if let Some(pk) = maybe_pk { + if let Some(ch) = + CredHandler::build_from_single_passkey(cred_id, pk, asd.webauthn) + { + // Update it. + debug_assert!(cred_handler.is_none()); + cred_handler = Some(ch); + } else { + security_critical!( + "corrupt credentials, unable to start passkey credhandler" + ); + } + } + } + AuthType::AttestedPasskey => { + if let Some(att_ca_list) = asd.account_policy.webauthn_attestation_ca_list() { + if let Some(pk) = asd + .account + .attested_passkeys + .get(&cred_id) + .map(|(_, pk)| pk) + { + if let Some(ch) = CredHandler::build_from_single_attested_pk( + cred_id, + pk, + att_ca_list, + asd.webauthn, + ) { + // Update it. + debug_assert!(cred_handler.is_none()); + cred_handler = Some(ch); + } else { + security_critical!( "corrupt credentials, unable to start attested passkey credhandler" ); + } + } } } - } else { - // Scan both attested and passkeys for the possible credential. - let maybe_pk: Option = asd - .account - .attested_passkeys - .get(&cred_id) - .map(|(_, apk)| apk.into()) - .or_else(|| asd.account.passkeys.get(&cred_id).map(|(_, pk)| pk.clone())); - - if let Some(pk) = maybe_pk { - if let Ok(ch) = - CredHandler::build_from_single_passkey(cred_id, pk, asd.webauthn) - { - // Update it. - debug_assert!(cred_handler.is_none()); - cred_handler = Some(ch); - } else { - security_critical!( - "corrupt credentials, unable to start passkey credhandler" - ); - } - } + AuthType::Anonymous => {} } // Did anything get set-up? @@ -1189,14 +1370,17 @@ impl AuthSession { } } - // This is used for softlock identification only. + /// If the credential class can be softlocked, retrieve the credential ID. This is + /// only used when a credential requires softlocking. pub fn get_credential_uuid(&self) -> Result, OperationError> { match &self.state { AuthSessionState::InProgress(CredHandler::Password { cred_id, .. }) - | AuthSessionState::InProgress(CredHandler::PasswordMfa { cred_id, .. }) => { + | AuthSessionState::InProgress(CredHandler::PasswordTotp { cred_id, .. }) + | AuthSessionState::InProgress(CredHandler::PasswordBackupCode { cred_id, .. }) => { Ok(Some(*cred_id)) } AuthSessionState::InProgress(CredHandler::Anonymous { .. }) + | AuthSessionState::InProgress(CredHandler::PasswordSecurityKey { .. }) | AuthSessionState::InProgress(CredHandler::Passkey { .. }) | AuthSessionState::InProgress(CredHandler::AttestedPasskey { .. }) => Ok(None), _ => Err(OperationError::InvalidState), @@ -1377,7 +1561,9 @@ impl AuthSession { AuthType::Anonymous => SessionScope::ReadOnly, AuthType::GeneratedPassword => SessionScope::ReadWrite, AuthType::Password - | AuthType::PasswordMfa + | AuthType::PasswordTotp + | AuthType::PasswordBackupCode + | AuthType::PasswordSecurityKey | AuthType::Passkey | AuthType::AttestedPasskey => { if privileged { @@ -1413,7 +1599,9 @@ impl AuthSession { } AuthType::Password | AuthType::GeneratedPassword - | AuthType::PasswordMfa + | AuthType::PasswordTotp + | AuthType::PasswordBackupCode + | AuthType::PasswordSecurityKey | AuthType::Passkey | AuthType::AttestedPasskey => { trace!("⚠️ Queued AuthSessionRecord for {}", self.account.uuid); @@ -1426,6 +1614,7 @@ impl AuthSession { issued_at: uat.issued_at, issued_by: IdentityId::User(self.account.uuid), scope, + type_: *auth_type, })) .map_err(|e| { debug!(?e, "queue failure"); @@ -1449,7 +1638,9 @@ impl AuthSession { return Err(OperationError::InvalidState); } AuthType::Password - | AuthType::PasswordMfa + | AuthType::PasswordTotp + | AuthType::PasswordBackupCode + | AuthType::PasswordSecurityKey | AuthType::Passkey | AuthType::AttestedPasskey => SessionScope::PrivilegeCapable, }; @@ -1502,6 +1693,7 @@ mod tests { use tokio::sync::mpsc::unbounded_channel as unbounded; use webauthn_authenticator_rs::softpasskey::SoftPasskey; use webauthn_authenticator_rs::WebauthnAuthenticator; + use webauthn_rs::prelude::{RequestChallengeResponse, Webauthn}; use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP}; use crate::credential::{BackupCodes, Credential}; @@ -1755,65 +1947,129 @@ mod tests { assert!(audit_rx.blocking_recv().is_none()); } - macro_rules! start_password_mfa_session { - ( - $account:expr, - $webauthn:expr - ) => {{ - let asd = AuthSessionData { - account: $account.clone(), - account_policy: ResolvedAccountPolicy::default(), - issue: AuthIssueSession::Token, - webauthn: $webauthn, - ct: duration_from_epoch_now(), - client_auth_info: Source::Internal.into(), - }; - let key_object = KeyObjectInternal::new_test(); - let (session, state) = AuthSession::new(asd, false, key_object); - let mut session = session.expect("Session was unable to be created."); + fn start_password_totp_session( + account: &Account, + webauthn: &Webauthn, + ) -> (AuthSession, HashSet) { + let asd = AuthSessionData { + account: account.clone(), + account_policy: ResolvedAccountPolicy::default(), + issue: AuthIssueSession::Token, + webauthn: webauthn, + ct: duration_from_epoch_now(), + client_auth_info: Source::Internal.into(), + }; + let key_object = KeyObjectInternal::new_test(); + let (session, state) = AuthSession::new(asd, false, key_object); + let mut session = session.expect("Session was unable to be created."); - if let AuthState::Choose(auth_mechs) = state { - assert!(auth_mechs - .iter() - .any(|x| matches!(x, AuthMech::PasswordMfa))) - } else { - panic!(); - } + if let AuthState::Choose(auth_mechs) = state { + assert!(auth_mechs + .iter() + .any(|x| matches!(x, AuthMech::PasswordTotp))) + } else { + panic!(); + } - let state = session - .start_session(&AuthMech::PasswordMfa) - .expect("Failed to select anonymous mech."); + let state = session + .start_session(&AuthMech::PasswordTotp) + .expect("Failed to select password totp mech."); - let mut rchal = None; + if let AuthState::Continue(auth_mechs) = state { + assert!(auth_mechs.iter().fold(false, |acc, x| match x { + AuthAllowed::Totp => true, + _ => acc, + })); + } else { + panic!("Invalid auth state") + } - if let AuthState::Continue(auth_mechs) = state { - assert!( - true == auth_mechs.iter().fold(false, |acc, x| match x { - // TODO: How to return webauthn chal? - AuthAllowed::SecurityKey(chal) => { - rchal = Some(chal.clone()); - true - } - // Why does this also return `true`? If we hit this but not - // Webauthn, then we will panic when unwrapping `rchal` later... - AuthAllowed::Totp => true, - _ => acc, - }) - ); + (session, create_pw_badlist_cache()) + } - // I feel like this is what we should be doing - // assuming there will only be one `AuthAllowed::Webauthn`. - // rchal = auth_mechs.iter().find_map(|x| match x { - // AuthAllowed::Webauthn(chal) => Some(chal), - // _ => None, - // }); - // assert!(rchal.is_some()); - } else { - panic!("Invalid auth state") - } + fn start_password_sk_session( + account: &Account, + webauthn: &Webauthn, + ) -> (AuthSession, RequestChallengeResponse, HashSet) { + let asd = AuthSessionData { + account: account.clone(), + account_policy: ResolvedAccountPolicy::default(), + issue: AuthIssueSession::Token, + webauthn: webauthn, + ct: duration_from_epoch_now(), + client_auth_info: Source::Internal.into(), + }; + let key_object = KeyObjectInternal::new_test(); + let (session, state) = AuthSession::new(asd, false, key_object); + let mut session = session.expect("Session was unable to be created."); - (session, rchal, create_pw_badlist_cache()) - }}; + if let AuthState::Choose(auth_mechs) = state { + assert!(auth_mechs + .iter() + .any(|x| matches!(x, AuthMech::PasswordSecurityKey))) + } else { + panic!(); + } + + let state = session + .start_session(&AuthMech::PasswordSecurityKey) + .expect("Failed to select password security key mech."); + + let mut rchal = None; + + if let AuthState::Continue(auth_mechs) = state { + assert!(auth_mechs.iter().fold(false, |acc, x| match x { + AuthAllowed::SecurityKey(chal) => { + rchal = Some(chal.clone()); + true + } + _ => acc, + })); + } else { + panic!("Invalid auth state") + } + + (session, rchal.unwrap(), create_pw_badlist_cache()) + } + + fn start_password_bc_session( + account: &Account, + webauthn: &Webauthn, + ) -> (AuthSession, HashSet) { + let asd = AuthSessionData { + account: account.clone(), + account_policy: ResolvedAccountPolicy::default(), + issue: AuthIssueSession::Token, + webauthn: webauthn, + ct: duration_from_epoch_now(), + client_auth_info: Source::Internal.into(), + }; + let key_object = KeyObjectInternal::new_test(); + let (session, state) = AuthSession::new(asd, false, key_object); + let mut session = session.expect("Session was unable to be created."); + + if let AuthState::Choose(auth_mechs) = state { + assert!(auth_mechs + .iter() + .any(|x| matches!(x, AuthMech::PasswordBackupCode))) + } else { + panic!(); + } + + let state = session + .start_session(&AuthMech::PasswordBackupCode) + .expect("Failed to select password backup code mech."); + + if let AuthState::Continue(auth_mechs) = state { + assert!(auth_mechs.iter().fold(false, |acc, x| match x { + AuthAllowed::BackupCode => true, + _ => acc, + })); + } else { + panic!("Invalid auth state") + } + + (session, create_pw_badlist_cache()) } #[test] @@ -1854,8 +2110,7 @@ mod tests { // check send anon (fail) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Anonymous, @@ -1879,8 +2134,7 @@ mod tests { // Sending a PW first is an immediate fail. { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Password(pw_bad.to_string()), @@ -1901,8 +2155,7 @@ mod tests { } // check send bad totp, should fail immediate { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_bad), @@ -1925,8 +2178,7 @@ mod tests { // check send good totp, should continue // then bad pw, fail pw { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -1960,8 +2212,7 @@ mod tests { // check send good totp, should continue // then good pw, success { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -2034,8 +2285,7 @@ mod tests { // check send good totp, should continue // then badlist pw, failed { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -2351,8 +2601,7 @@ mod tests { // check pw first (fail) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, _, pw_badlist_cache) = start_password_sk_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Password(pw_bad.to_string()), @@ -2374,8 +2623,7 @@ mod tests { // Check totp first attempt fails. { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, _, pw_badlist_cache) = start_password_sk_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(0), @@ -2400,10 +2648,8 @@ mod tests { // extensively tested. { let (_session, inv_chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let (mut session, _chal, _) = start_password_mfa_session!(account, &webauthn); - - let inv_chal = inv_chal.unwrap(); + start_password_sk_session(&account, &webauthn); + let (mut session, _chal, _) = start_password_sk_session(&account, &webauthn); let resp = wa // HERE -> we use inv_chal instead. @@ -2432,8 +2678,7 @@ mod tests { // check good webauthn/bad pw (fail) { let (mut session, chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let chal = chal.unwrap(); + start_password_sk_session(&account, &webauthn); let resp = wa .do_authentication(webauthn.get_allowed_origins()[0].clone(), chal) @@ -2478,8 +2723,7 @@ mod tests { // Check good webauthn/good pw (pass) { let (mut session, chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let chal = chal.unwrap(); + start_password_sk_session(&account, &webauthn); let resp = wa .do_authentication(webauthn.get_allowed_origins()[0].clone(), chal) @@ -2561,8 +2805,7 @@ mod tests { // check pw first (fail) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Password(pw_bad.to_string()), @@ -2584,8 +2827,7 @@ mod tests { // Check bad totp (fail) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_bad), @@ -2608,10 +2850,8 @@ mod tests { // check bad webauthn (fail) { let (_session, inv_chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let (mut session, _chal, _) = start_password_mfa_session!(account, &webauthn); - - let inv_chal = inv_chal.unwrap(); + start_password_sk_session(&account, &webauthn); + let (mut session, _chal, _) = start_password_sk_session(&account, &webauthn); let resp = wa // HERE -> we use inv_chal instead. @@ -2640,8 +2880,7 @@ mod tests { // check good webauthn/bad pw (fail) { let (mut session, chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let chal = chal.unwrap(); + start_password_sk_session(&account, &webauthn); let resp = wa .do_authentication(webauthn.get_allowed_origins()[0].clone(), chal) @@ -2685,8 +2924,7 @@ mod tests { // check good totp/bad pw (fail) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -2719,8 +2957,7 @@ mod tests { // check good totp/good pw (pass) { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -2754,8 +2991,7 @@ mod tests { // Check good webauthn/good pw (pass) { let (mut session, chal, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); - let chal = chal.unwrap(); + start_password_sk_session(&account, &webauthn); let resp = wa .do_authentication(webauthn.get_allowed_origins()[0].clone(), chal) @@ -2848,8 +3084,7 @@ mod tests { // Sending a PW first is an immediate fail. { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_bc_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Password(pw_bad.to_string()), @@ -2870,8 +3105,7 @@ mod tests { } // check send wrong backup code, should fail immediate { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_bc_session(&account, &webauthn); match session.validate_creds( &AuthCredential::BackupCode(backup_code_bad), @@ -2893,8 +3127,7 @@ mod tests { // check send good backup code, should continue // then bad pw, fail pw { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_bc_session(&account, &webauthn); match session.validate_creds( &AuthCredential::BackupCode(backup_code_good.clone()), @@ -2933,8 +3166,7 @@ mod tests { // check send good backup code, should continue // then good pw, success { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_bc_session(&account, &webauthn); match session.validate_creds( &AuthCredential::BackupCode(backup_code_good), @@ -2975,8 +3207,7 @@ mod tests { // check send good TOTP, should continue // then good pw, success { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good), @@ -3055,8 +3286,7 @@ mod tests { // Test totp_a { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good_a), @@ -3089,8 +3319,7 @@ mod tests { // Test totp_b { - let (mut session, _, pw_badlist_cache) = - start_password_mfa_session!(account, &webauthn); + let (mut session, pw_badlist_cache) = start_password_totp_session(&account, &webauthn); match session.validate_creds( &AuthCredential::Totp(totp_good_b), diff --git a/server/lib/src/idm/credupdatesession.rs b/server/lib/src/idm/credupdatesession.rs index dcd5e7cca..ac41a9fe7 100644 --- a/server/lib/src/idm/credupdatesession.rs +++ b/server/lib/src/idm/credupdatesession.rs @@ -2631,7 +2631,7 @@ mod tests { return None; }; - let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordMfa); + let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordTotp); let r2 = idms_auth .auth(&auth_begin, ct, Source::Internal.into()) @@ -2697,7 +2697,7 @@ mod tests { return None; }; - let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordMfa); + let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordBackupCode); let r2 = idms_auth .auth(&auth_begin, ct, Source::Internal.into()) diff --git a/server/lib/src/idm/delayed.rs b/server/lib/src/idm/delayed.rs index f03e6344a..9e95133cf 100644 --- a/server/lib/src/idm/delayed.rs +++ b/server/lib/src/idm/delayed.rs @@ -1,4 +1,5 @@ use crate::prelude::*; +use crate::value::AuthType; use time::OffsetDateTime; use uuid::Uuid; use webauthn_rs::prelude::AuthenticationResult; @@ -62,4 +63,5 @@ pub struct AuthSessionRecord { pub issued_at: OffsetDateTime, pub issued_by: IdentityId, pub scope: SessionScope, + pub type_: AuthType, } diff --git a/server/lib/src/idm/oauth2.rs b/server/lib/src/idm/oauth2.rs index 22e545bef..ed4c19298 100644 --- a/server/lib/src/idm/oauth2.rs +++ b/server/lib/src/idm/oauth2.rs @@ -2665,8 +2665,7 @@ mod tests { use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error}; use crate::idm::server::{IdmServer, IdmServerTransaction}; use crate::prelude::*; - use crate::value::OauthClaimMapJoin; - use crate::value::SessionState; + use crate::value::{AuthType, OauthClaimMapJoin, SessionState}; use crate::credential::Credential; use kanidm_lib_crypto::CryptoPolicy; @@ -2854,6 +2853,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id, scope: SessionScope::ReadWrite, + type_: AuthType::Passkey, }, ); @@ -2982,6 +2982,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id, scope: SessionScope::ReadWrite, + type_: AuthType::Passkey, }, ); diff --git a/server/lib/src/idm/reauth.rs b/server/lib/src/idm/reauth.rs index 337dec8a3..1407b18c0 100644 --- a/server/lib/src/idm/reauth.rs +++ b/server/lib/src/idm/reauth.rs @@ -424,7 +424,7 @@ mod tests { return None; }; - let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordMfa); + let auth_begin = AuthEvent::begin_mech(sessionid, AuthMech::PasswordTotp); let r2 = idms_auth .auth(&auth_begin, ct, Source::Internal.into()) diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 2d1dc4357..9472b2146 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -1120,6 +1120,7 @@ impl<'a> IdmServerAuthTransaction<'a> { // Now check the results slock.is_valid() } else { + trace!("slock not found"); false } } @@ -1130,6 +1131,7 @@ impl<'a> IdmServerAuthTransaction<'a> { auth_result } else { // Fail the session + trace!("lock step begin"); auth_session.end_session("Account is temporarily locked") } .map(|aus| AuthResult { @@ -1206,6 +1208,7 @@ impl<'a> IdmServerAuthTransaction<'a> { }) } else { // Fail the session + trace!("lock step cred"); auth_session.end_session("Account is temporarily locked") } .map(|aus| AuthResult { @@ -2010,6 +2013,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { // What is the access scope of this session? This is // for auditing purposes. scope: asr.scope, + type_: asr.type_, }, ); @@ -2090,7 +2094,7 @@ mod tests { use crate::modify::{Modify, ModifyList}; use crate::prelude::*; use crate::server::keys::KeyProvidersTransaction; - use crate::value::SessionState; + use crate::value::{AuthType, SessionState}; use compact_jwt::{traits::JwsVerifiable, JwsCompact, JwsEs256Verifier, JwsVerifier}; use kanidm_lib_crypto::CryptoPolicy; @@ -2311,8 +2315,8 @@ mod tests { match state { AuthState::Continue(_) => {} - _ => { - error!("Sessions was not initialised"); + s => { + error!(?s, "Sessions was not initialised"); panic!(); } }; @@ -3436,6 +3440,7 @@ mod tests { issued_at: OffsetDateTime::UNIX_EPOCH + ct, issued_by: IdentityId::User(UUID_ADMIN), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }); // Persist it. let r = idms.delayed_action(ct, da).await; @@ -3467,6 +3472,7 @@ mod tests { issued_at: OffsetDateTime::UNIX_EPOCH + ct, issued_by: IdentityId::User(UUID_ADMIN), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }); // Persist it. let r = idms.delayed_action(expiry_a, da).await; diff --git a/server/lib/src/lib.rs b/server/lib/src/lib.rs index 740b2807c..696cfe3d3 100644 --- a/server/lib/src/lib.rs +++ b/server/lib/src/lib.rs @@ -112,10 +112,10 @@ pub mod prelude { pub use crate::value::{ ApiTokenScope, IndexType, PartialValue, SessionScope, SyntaxType, Value, }; - pub use crate::valueset::{ + + pub(crate) use crate::valueset::{ ValueSet, ValueSetBool, ValueSetCid, ValueSetIndex, ValueSetIutf8, ValueSetRefer, - ValueSetSecret, ValueSetSpn, ValueSetSyntax, ValueSetT, ValueSetUint32, ValueSetUtf8, - ValueSetUuid, + ValueSetSyntax, ValueSetT, ValueSetUtf8, ValueSetUuid, }; #[cfg(test)] diff --git a/server/lib/src/plugins/refint.rs b/server/lib/src/plugins/refint.rs index db35c2384..e9027fac9 100644 --- a/server/lib/src/plugins/refint.rs +++ b/server/lib/src/plugins/refint.rs @@ -476,7 +476,7 @@ mod tests { use crate::event::CreateEvent; use crate::prelude::*; - use crate::value::{Oauth2Session, OauthClaimMapJoin, Session, SessionState}; + use crate::value::{AuthType, Oauth2Session, OauthClaimMapJoin, Session, SessionState}; use time::OffsetDateTime; use crate::credential::Credential; @@ -1172,6 +1172,7 @@ mod tests { // What is the access scope of this session? This is // for auditing purposes. scope, + type_: AuthType::Passkey, }, ) ), diff --git a/server/lib/src/plugins/session.rs b/server/lib/src/plugins/session.rs index 040834368..1ba9e2082 100644 --- a/server/lib/src/plugins/session.rs +++ b/server/lib/src/plugins/session.rs @@ -190,7 +190,7 @@ mod tests { use crate::prelude::*; use crate::event::CreateEvent; - use crate::value::{Oauth2Session, Session, SessionState}; + use crate::value::{AuthType, Oauth2Session, Session, SessionState}; use kanidm_proto::constants::OAUTH2_SCOPE_OPENID; use std::time::Duration; use time::OffsetDateTime; @@ -256,6 +256,7 @@ mod tests { // What is the access scope of this session? This is // for auditing purposes. scope, + type_: AuthType::Passkey, }, ); @@ -418,6 +419,7 @@ mod tests { // What is the access scope of this session? This is // for auditing purposes. scope, + type_: AuthType::Passkey, }, ) ), @@ -590,6 +592,7 @@ mod tests { // What is the access scope of this session? This is // for auditing purposes. scope, + type_: AuthType::Passkey, }, ) ), @@ -837,6 +840,7 @@ mod tests { // What is the access scope of this session? This is // for auditing purposes. scope, + type_: AuthType::Passkey, }, ); diff --git a/server/lib/src/repl/proto.rs b/server/lib/src/repl/proto.rs index 50d420257..681b20671 100644 --- a/server/lib/src/repl/proto.rs +++ b/server/lib/src/repl/proto.rs @@ -5,6 +5,7 @@ use crate::be::dbvalue::DbValueCertificate; use crate::be::dbvalue::DbValueImage; use crate::be::dbvalue::DbValueKeyInternal; use crate::be::dbvalue::DbValueOauthClaimMapJoinV1; +use crate::be::dbvalue::DbValueSession; use crate::entry::Eattrs; use crate::prelude::*; use crate::schema::{SchemaReadTransaction, SchemaTransaction}; @@ -275,15 +276,6 @@ pub struct ReplOauth2SessionV1 { pub rs_uuid: Uuid, } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] -pub enum ReplSessionScopeV1 { - #[default] - ReadOnly, - ReadWrite, - PrivilegeCapable, - Synchronise, -} - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default)] pub enum ReplApiTokenScopeV1 { #[default] @@ -299,18 +291,6 @@ pub enum ReplIdentityIdV1 { Synch(Uuid), } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct ReplSessionV1 { - pub refer: Uuid, - pub label: String, - pub state: ReplSessionStateV1, - // pub expiry: Option, - pub issued_at: String, - pub issued_by: ReplIdentityIdV1, - pub cred_id: Uuid, - pub scope: ReplSessionScopeV1, -} - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] pub enum ReplSessionStateV1 { ExpiresAt(String), @@ -431,7 +411,7 @@ pub enum ReplAttrV1 { set: Vec, }, Session { - set: Vec, + set: Vec, }, ApiToken { set: Vec, diff --git a/server/lib/src/repl/tests.rs b/server/lib/src/repl/tests.rs index 5a1521b75..907a48f9d 100644 --- a/server/lib/src/repl/tests.rs +++ b/server/lib/src/repl/tests.rs @@ -6,7 +6,7 @@ use crate::repl::proto::ConsumerState; use crate::repl::proto::ReplIncrementalContext; use crate::repl::ruv::ReplicationUpdateVectorTransaction; use crate::repl::ruv::{RangeDiffStatus, ReplicationUpdateVector}; -use crate::value::{Session, SessionState}; +use crate::value::{AuthType, Session, SessionState}; use kanidm_lib_crypto::CryptoPolicy; use std::collections::BTreeMap; use time::OffsetDateTime; @@ -3254,6 +3254,7 @@ async fn test_repl_increment_session_new(server_a: &QueryServer, server_b: &Quer let issued_at = curtime_odt; let issued_by = IdentityId::User(t_uuid); let scope = SessionScope::ReadOnly; + let type_ = AuthType::Passkey; let session = Value::Session( session_id_a, @@ -3264,6 +3265,7 @@ async fn test_repl_increment_session_new(server_a: &QueryServer, server_b: &Quer issued_by, cred_id, scope, + type_, }, ); @@ -3293,6 +3295,7 @@ async fn test_repl_increment_session_new(server_a: &QueryServer, server_b: &Quer let issued_at = curtime_odt; let issued_by = IdentityId::User(t_uuid); let scope = SessionScope::ReadOnly; + let type_ = AuthType::Passkey; let session = Value::Session( session_id_b, @@ -3303,6 +3306,7 @@ async fn test_repl_increment_session_new(server_a: &QueryServer, server_b: &Quer issued_by, cred_id, scope, + type_, }, ); diff --git a/server/lib/src/value.rs b/server/lib/src/value.rs index 9f08d14fa..6bf8300f5 100644 --- a/server/lib/src/value.rs +++ b/server/lib/src/value.rs @@ -38,11 +38,10 @@ use crate::credential::{totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; use crate::server::identity::IdentityId; +use crate::server::keys::KeyId; use crate::valueset::image::ImageValueThings; use crate::valueset::uuid_to_proto_string; -use crate::server::keys::KeyId; - use kanidm_proto::internal::{ApiTokenPurpose, Filter as ProtoFilter, UiHint}; use kanidm_proto::v1::UatPurposeStatus; use std::hash::Hash; @@ -990,6 +989,33 @@ impl Ord for SessionState { } } +#[derive(Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +pub enum AuthType { + Anonymous, + Password, + GeneratedPassword, + PasswordTotp, + PasswordBackupCode, + PasswordSecurityKey, + Passkey, + AttestedPasskey, +} + +impl fmt::Display for AuthType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AuthType::Anonymous => write!(f, "anonymous"), + AuthType::Password => write!(f, "password"), + AuthType::GeneratedPassword => write!(f, "generatedpassword"), + AuthType::PasswordTotp => write!(f, "passwordtotp"), + AuthType::PasswordBackupCode => write!(f, "passwordbackupcode"), + AuthType::PasswordSecurityKey => write!(f, "passwordsecuritykey"), + AuthType::Passkey => write!(f, "passkey"), + AuthType::AttestedPasskey => write!(f, "attested_passkey"), + } + } +} + #[derive(Clone, PartialEq, Eq)] pub struct Session { pub label: String, @@ -999,6 +1025,7 @@ pub struct Session { pub issued_by: IdentityId, pub cred_id: Uuid, pub scope: SessionScope, + pub type_: AuthType, } impl fmt::Debug for Session { diff --git a/server/lib/src/valueset/mod.rs b/server/lib/src/valueset/mod.rs index d1a8c654f..dad78230a 100644 --- a/server/lib/src/valueset/mod.rs +++ b/server/lib/src/valueset/mod.rs @@ -159,11 +159,6 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { Ok(None) } - fn migrate_session_to_apitoken(&self) -> Result { - debug_assert!(false); - Err(OperationError::InvalidValueState) - } - fn get_ssh_tag(&self, _tag: &str) -> Option<&SshPublicKey> { None } diff --git a/server/lib/src/valueset/session.rs b/server/lib/src/valueset/session.rs index 44df6ba77..06a158d2e 100644 --- a/server/lib/src/valueset/session.rs +++ b/server/lib/src/valueset/session.rs @@ -4,17 +4,19 @@ use std::collections::BTreeMap; use time::OffsetDateTime; use crate::be::dbvalue::{ - DbCidV1, DbValueAccessScopeV1, DbValueApiToken, DbValueApiTokenScopeV1, DbValueIdentityId, - DbValueOauth2Session, DbValueSession, DbValueSessionStateV1, + DbCidV1, DbValueAccessScopeV1, DbValueApiToken, DbValueApiTokenScopeV1, DbValueAuthTypeV1, + DbValueIdentityId, DbValueOauth2Session, DbValueSession, DbValueSessionStateV1, }; use crate::prelude::*; use crate::repl::cid::Cid; use crate::repl::proto::{ ReplApiTokenScopeV1, ReplApiTokenV1, ReplAttrV1, ReplIdentityIdV1, ReplOauth2SessionV1, - ReplSessionScopeV1, ReplSessionStateV1, ReplSessionV1, + ReplSessionStateV1, }; use crate::schema::SchemaAttribute; -use crate::value::{ApiToken, ApiTokenScope, Oauth2Session, Session, SessionScope, SessionState}; +use crate::value::{ + ApiToken, ApiTokenScope, AuthType, Oauth2Session, Session, SessionScope, SessionState, +}; use crate::valueset::{uuid_to_proto_string, DbValueSetV2, ValueSet}; #[derive(Debug, Clone)] @@ -33,321 +35,168 @@ impl ValueSetSession { self.map.insert(u, m).is_none() } - pub fn from_dbvs2(data: Vec) -> Result { - let map = - data.into_iter() - .filter_map(|dbv| { - match dbv { - // MISTAKE - Skip due to lack of credential id - // Don't actually skip, generate a random cred id. Session cleanup will - // trim sessions on users, but if we skip blazenly we invalidate every api - // token ever issued. OOPS! - DbValueSession::V1 { - refer, - label, - expiry, - issued_at, - issued_by, - scope, - } => { - let cred_id = Uuid::new_v4(); + fn to_vec_dbvs(&self) -> Vec { + self.map + .iter() + .map(|(u, m)| DbValueSession::V4 { + refer: *u, + label: m.label.clone(), - // Convert things. - let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid issued_at timestamp", - refer - ) - }) - .ok()?; - - // This is a bit annoying. In the case we can't parse the optional - // expiry, we need to NOT return the session so that it's immediately - // invalidated. To do this we have to invert some of the options involved - // here. - let expiry = expiry - .map(|e_inner| { - OffsetDateTime::parse(&e_inner, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - // We now have an - // Option> - }) - .transpose() - // Result, _> - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - // Option> - .ok()?; - - let state = expiry - .map(SessionState::ExpiresAt) - .unwrap_or(SessionState::NeverExpires); - - let issued_by = match issued_by { - DbValueIdentityId::V1Internal => IdentityId::Internal, - DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), - DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), - }; - - let scope = match scope { - DbValueAccessScopeV1::IdentityOnly - | DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly, - DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, - DbValueAccessScopeV1::PrivilegeCapable => { - SessionScope::PrivilegeCapable - } - DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, - }; - - Some(( - refer, - Session { - label, - state, - issued_at, - issued_by, - cred_id, - scope, - }, - )) - } - DbValueSession::V2 { - refer, - label, - expiry, - issued_at, - issued_by, - cred_id, - scope, - } => { - // Convert things. - let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid issued_at timestamp", - refer - ) - }) - .ok()?; - - // This is a bit annoying. In the case we can't parse the optional - // expiry, we need to NOT return the session so that it's immediately - // invalidated. To do this we have to invert some of the options involved - // here. - let expiry = expiry - .map(|e_inner| { - OffsetDateTime::parse(&e_inner, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - // We now have an - // Option> - }) - .transpose() - // Result, _> - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - // Option> - .ok()?; - - let state = expiry - .map(SessionState::ExpiresAt) - .unwrap_or(SessionState::NeverExpires); - - let issued_by = match issued_by { - DbValueIdentityId::V1Internal => IdentityId::Internal, - DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), - DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), - }; - - let scope = match scope { - DbValueAccessScopeV1::IdentityOnly - | DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly, - DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, - DbValueAccessScopeV1::PrivilegeCapable => { - SessionScope::PrivilegeCapable - } - DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, - }; - - Some(( - refer, - Session { - label, - state, - issued_at, - issued_by, - cred_id, - scope, - }, - )) - } - DbValueSession::V3 { - refer, - label, - state, - issued_at, - issued_by, - cred_id, - scope, - } => { - // Convert things. - let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid issued_at timestamp", - refer - ) - }) - .ok()?; - - let state = match state { - DbValueSessionStateV1::ExpiresAt(e_inner) => { - OffsetDateTime::parse(&e_inner, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map(SessionState::ExpiresAt) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - .ok()? - } - DbValueSessionStateV1::Never => SessionState::NeverExpires, - DbValueSessionStateV1::RevokedAt(dc) => { - SessionState::RevokedAt(Cid { - s_uuid: dc.server_id, - ts: dc.timestamp, - }) - } - }; - - let issued_by = match issued_by { - DbValueIdentityId::V1Internal => IdentityId::Internal, - DbValueIdentityId::V1Uuid(u) => IdentityId::User(u), - DbValueIdentityId::V1Sync(u) => IdentityId::Synch(u), - }; - - let scope = match scope { - DbValueAccessScopeV1::IdentityOnly - | DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly, - DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, - DbValueAccessScopeV1::PrivilegeCapable => { - SessionScope::PrivilegeCapable - } - DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, - }; - - Some(( - refer, - Session { - label, - state, - issued_at, - issued_by, - cred_id, - scope, - }, - )) - } + state: match &m.state { + SessionState::ExpiresAt(odt) => { + debug_assert!(odt.offset() == time::UtcOffset::UTC); + #[allow(clippy::expect_used)] + odt.format(&Rfc3339) + .map(DbValueSessionStateV1::ExpiresAt) + .expect("Failed to format timestamp into RFC3339!") } - }) - .collect(); + SessionState::NeverExpires => DbValueSessionStateV1::Never, + SessionState::RevokedAt(c) => DbValueSessionStateV1::RevokedAt(DbCidV1 { + server_id: c.s_uuid, + timestamp: c.ts, + }), + }, + + issued_at: { + debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC); + #[allow(clippy::expect_used)] + m.issued_at + .format(&Rfc3339) + .expect("Failed to format timestamp into RFC3339!") + }, + issued_by: match m.issued_by { + IdentityId::Internal => DbValueIdentityId::V1Internal, + IdentityId::User(u) => DbValueIdentityId::V1Uuid(u), + IdentityId::Synch(u) => DbValueIdentityId::V1Sync(u), + }, + cred_id: m.cred_id, + scope: match m.scope { + SessionScope::ReadOnly => DbValueAccessScopeV1::ReadOnly, + SessionScope::ReadWrite => DbValueAccessScopeV1::ReadWrite, + SessionScope::PrivilegeCapable => DbValueAccessScopeV1::PrivilegeCapable, + SessionScope::Synchronise => DbValueAccessScopeV1::Synchronise, + }, + type_: match m.type_ { + AuthType::Anonymous => DbValueAuthTypeV1::Anonymous, + AuthType::Password => DbValueAuthTypeV1::Password, + AuthType::GeneratedPassword => DbValueAuthTypeV1::GeneratedPassword, + AuthType::PasswordTotp => DbValueAuthTypeV1::PasswordTotp, + AuthType::PasswordBackupCode => DbValueAuthTypeV1::PasswordBackupCode, + AuthType::PasswordSecurityKey => DbValueAuthTypeV1::PasswordSecurityKey, + AuthType::Passkey => DbValueAuthTypeV1::Passkey, + AuthType::AttestedPasskey => DbValueAuthTypeV1::AttestedPasskey, + }, + }) + .collect() + } + + fn from_dbv_iter<'a>( + iter: impl Iterator, + ) -> Result { + let map = iter + .filter_map(|dbv| { + match dbv { + // We need to ignore all older session records as they lack the AuthType + // record which prevents re-auth working. + DbValueSession::V1 { .. } + | DbValueSession::V2 { .. } + | DbValueSession::V3 { .. } => None, + DbValueSession::V4 { + refer, + label, + state, + issued_at, + issued_by, + cred_id, + scope, + type_, + } => { + // Convert things. + let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid issued_at timestamp", + refer + ) + }) + .ok()?; + + let state = match state { + DbValueSessionStateV1::ExpiresAt(e_inner) => { + OffsetDateTime::parse(&e_inner, &Rfc3339) + .map(|odt| odt.to_offset(time::UtcOffset::UTC)) + .map(SessionState::ExpiresAt) + .map_err(|e| { + admin_error!( + ?e, + "Invalidating session {} due to invalid expiry timestamp", + refer + ) + }) + .ok()? + } + DbValueSessionStateV1::Never => SessionState::NeverExpires, + DbValueSessionStateV1::RevokedAt(dc) => SessionState::RevokedAt(Cid { + s_uuid: dc.server_id, + ts: dc.timestamp, + }), + }; + + let issued_by = match issued_by { + DbValueIdentityId::V1Internal => IdentityId::Internal, + DbValueIdentityId::V1Uuid(u) => IdentityId::User(*u), + DbValueIdentityId::V1Sync(u) => IdentityId::Synch(*u), + }; + + let scope = match scope { + DbValueAccessScopeV1::IdentityOnly | DbValueAccessScopeV1::ReadOnly => { + SessionScope::ReadOnly + } + DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite, + DbValueAccessScopeV1::PrivilegeCapable => { + SessionScope::PrivilegeCapable + } + DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise, + }; + + let type_ = match type_ { + DbValueAuthTypeV1::Anonymous => AuthType::Anonymous, + DbValueAuthTypeV1::Password => AuthType::Password, + DbValueAuthTypeV1::GeneratedPassword => AuthType::GeneratedPassword, + DbValueAuthTypeV1::PasswordTotp => AuthType::PasswordTotp, + DbValueAuthTypeV1::PasswordBackupCode => AuthType::PasswordBackupCode, + DbValueAuthTypeV1::PasswordSecurityKey => AuthType::PasswordSecurityKey, + DbValueAuthTypeV1::Passkey => AuthType::Passkey, + DbValueAuthTypeV1::AttestedPasskey => AuthType::AttestedPasskey, + }; + + Some(( + *refer, + Session { + label: label.clone(), + state, + issued_at, + issued_by, + cred_id: *cred_id, + scope, + type_, + }, + )) + } + } + }) + .collect(); Ok(Box::new(ValueSetSession { map })) } - pub fn from_repl_v1(data: &[ReplSessionV1]) -> Result { - let map = data - .iter() - .filter_map( - |ReplSessionV1 { - refer, - label, - state, - issued_at, - issued_by, - cred_id, - scope, - }| { - // Convert things. - let issued_at = OffsetDateTime::parse(issued_at, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid issued_at timestamp", - refer - ) - }) - .ok()?; + pub fn from_dbvs2(data: Vec) -> Result { + Self::from_dbv_iter(data.iter()) + } - // This is a bit annoying. In the case we can't parse the optional - // expiry, we need to NOT return the session so that it's immediately - // invalidated. To do this we have to invert some of the options involved - // here. - let state = match state { - ReplSessionStateV1::ExpiresAt(e_inner) => { - OffsetDateTime::parse(e_inner, &Rfc3339) - .map(|odt| odt.to_offset(time::UtcOffset::UTC)) - .map(SessionState::ExpiresAt) - .map_err(|e| { - admin_error!( - ?e, - "Invalidating session {} due to invalid expiry timestamp", - refer - ) - }) - .ok()? - } - ReplSessionStateV1::Never => SessionState::NeverExpires, - ReplSessionStateV1::RevokedAt(rc) => SessionState::RevokedAt(rc.into()), - }; - - let issued_by = match issued_by { - ReplIdentityIdV1::Internal => IdentityId::Internal, - ReplIdentityIdV1::Uuid(u) => IdentityId::User(*u), - ReplIdentityIdV1::Synch(u) => IdentityId::Synch(*u), - }; - - let scope = match scope { - ReplSessionScopeV1::ReadOnly => SessionScope::ReadOnly, - ReplSessionScopeV1::ReadWrite => SessionScope::ReadWrite, - ReplSessionScopeV1::PrivilegeCapable => SessionScope::PrivilegeCapable, - ReplSessionScopeV1::Synchronise => SessionScope::Synchronise, - }; - - Some(( - *refer, - Session { - label: label.to_string(), - state, - issued_at, - issued_by, - cred_id: *cred_id, - scope, - }, - )) - }, - ) - .collect(); - Ok(Box::new(ValueSetSession { map })) + pub fn from_repl_v1(data: &[DbValueSession]) -> Result { + Self::from_dbv_iter(data.iter()) } // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign @@ -510,94 +359,12 @@ impl ValueSetT for ValueSetSession { } fn to_db_valueset_v2(&self) -> DbValueSetV2 { - DbValueSetV2::Session( - self.map - .iter() - .map(|(u, m)| DbValueSession::V3 { - refer: *u, - label: m.label.clone(), - - state: match &m.state { - SessionState::ExpiresAt(odt) => { - debug_assert!(odt.offset() == time::UtcOffset::UTC); - #[allow(clippy::expect_used)] - odt.format(&Rfc3339) - .map(DbValueSessionStateV1::ExpiresAt) - .expect("Failed to format timestamp into RFC3339!") - } - SessionState::NeverExpires => DbValueSessionStateV1::Never, - SessionState::RevokedAt(c) => DbValueSessionStateV1::RevokedAt(DbCidV1 { - server_id: c.s_uuid, - timestamp: c.ts, - }), - }, - - issued_at: { - debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC); - #[allow(clippy::expect_used)] - m.issued_at - .format(&Rfc3339) - .expect("Failed to format timestamp into RFC3339!") - }, - issued_by: match m.issued_by { - IdentityId::Internal => DbValueIdentityId::V1Internal, - IdentityId::User(u) => DbValueIdentityId::V1Uuid(u), - IdentityId::Synch(u) => DbValueIdentityId::V1Sync(u), - }, - cred_id: m.cred_id, - scope: match m.scope { - SessionScope::ReadOnly => DbValueAccessScopeV1::ReadOnly, - SessionScope::ReadWrite => DbValueAccessScopeV1::ReadWrite, - SessionScope::PrivilegeCapable => DbValueAccessScopeV1::PrivilegeCapable, - SessionScope::Synchronise => DbValueAccessScopeV1::Synchronise, - }, - }) - .collect(), - ) + DbValueSetV2::Session(self.to_vec_dbvs()) } fn to_repl_v1(&self) -> ReplAttrV1 { ReplAttrV1::Session { - set: self - .map - .iter() - .map(|(u, m)| ReplSessionV1 { - refer: *u, - label: m.label.clone(), - - state: match &m.state { - SessionState::ExpiresAt(odt) => { - debug_assert!(odt.offset() == time::UtcOffset::UTC); - #[allow(clippy::expect_used)] - odt.format(&Rfc3339) - .map(ReplSessionStateV1::ExpiresAt) - .expect("Failed to format timestamp into RFC3339!") - } - SessionState::NeverExpires => ReplSessionStateV1::Never, - SessionState::RevokedAt(c) => ReplSessionStateV1::RevokedAt(c.into()), - }, - - issued_at: { - debug_assert!(m.issued_at.offset() == time::UtcOffset::UTC); - #[allow(clippy::expect_used)] - m.issued_at - .format(&Rfc3339) - .expect("Failed to format timestamp to RFC3339") - }, - issued_by: match m.issued_by { - IdentityId::Internal => ReplIdentityIdV1::Internal, - IdentityId::User(u) => ReplIdentityIdV1::Uuid(u), - IdentityId::Synch(u) => ReplIdentityIdV1::Synch(u), - }, - cred_id: m.cred_id, - scope: match m.scope { - SessionScope::ReadOnly => ReplSessionScopeV1::ReadOnly, - SessionScope::ReadWrite => ReplSessionScopeV1::ReadWrite, - SessionScope::PrivilegeCapable => ReplSessionScopeV1::PrivilegeCapable, - SessionScope::Synchronise => ReplSessionScopeV1::Synchronise, - }, - }) - .collect(), + set: self.to_vec_dbvs(), } } @@ -651,49 +418,6 @@ impl ValueSetT for ValueSetSession { Some(Box::new(self.map.keys().copied())) } - fn migrate_session_to_apitoken(&self) -> Result { - let map = self - .as_session_map() - .iter() - .flat_map(|m| m.iter()) - .filter_map( - |( - u, - Session { - label, - state, - issued_at, - issued_by, - cred_id: _, - scope, - }, - )| { - let expiry = match state { - SessionState::ExpiresAt(e) => Some(*e), - SessionState::NeverExpires => None, - SessionState::RevokedAt(_) => return None, - }; - - Some(( - *u, - ApiToken { - label: label.clone(), - expiry, - issued_at: *issued_at, - issued_by: issued_by.clone(), - scope: match scope { - SessionScope::Synchronise => ApiTokenScope::Synchronise, - SessionScope::ReadWrite => ApiTokenScope::ReadWrite, - _ => ApiTokenScope::ReadOnly, - }, - }, - )) - }, - ) - .collect(); - Ok(Box::new(ValueSetApiToken { map })) - } - fn repl_merge_valueset(&self, older: &ValueSet, trim_cid: &Cid) -> Option { // If the older value has a different type - return nothing, we // just take the newer value. @@ -1702,11 +1426,6 @@ impl ValueSetT for ValueSetApiToken { // This is what ties us as a type that can be refint checked. Some(Box::new(self.map.keys().copied())) } - - fn migrate_session_to_apitoken(&self) -> Result { - // We are already in the api token format, don't do anything. - Ok(Box::new(self.clone())) - } } #[cfg(test)] @@ -1714,7 +1433,7 @@ mod tests { use super::{ValueSetOauth2Session, ValueSetSession, SESSION_MAXIMUM}; use crate::prelude::{IdentityId, SessionScope, Uuid}; use crate::repl::cid::Cid; - use crate::value::{Oauth2Session, Session, SessionState}; + use crate::value::{AuthType, Oauth2Session, Session, SessionState}; use crate::valueset::ValueSet; use time::OffsetDateTime; @@ -1731,6 +1450,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1763,6 +1483,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1775,6 +1496,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1802,6 +1524,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1814,6 +1537,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1844,6 +1568,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1857,6 +1582,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ( @@ -1868,6 +1594,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ]) @@ -1902,6 +1629,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ); @@ -1915,6 +1643,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ( @@ -1926,6 +1655,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ]) @@ -1964,6 +1694,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ( @@ -1975,6 +1706,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ( @@ -1986,6 +1718,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ), ]) @@ -2016,6 +1749,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, )) .chain((0..SESSION_MAXIMUM).into_iter().map(|_| { @@ -2028,6 +1762,7 @@ mod tests { issued_by: IdentityId::Internal, cred_id: Uuid::new_v4(), scope: SessionScope::ReadOnly, + type_: AuthType::Passkey, }, ) })); diff --git a/server/lib/src/valueset/uint32.rs b/server/lib/src/valueset/uint32.rs index ece207968..08362419a 100644 --- a/server/lib/src/valueset/uint32.rs +++ b/server/lib/src/valueset/uint32.rs @@ -165,6 +165,7 @@ impl ValueSetT for ValueSetUint32 { #[cfg(test)] mod tests { + use super::ValueSetUint32; use crate::prelude::*; #[test]