20240703 htmx (#2870)

Complete the remainder of the HTMX rewrite of the login page.
This commit is contained in:
Firstyear 2024-07-07 13:36:47 +10:00 committed by GitHub
parent 681080ba22
commit b1480e36f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1552 additions and 1050 deletions

77
Cargo.lock generated
View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Uuid> {
/// 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<T: DeserializeOwned>(&self, input: &str) -> Option<T> {
match JwsCompact::from_str(input) {
Ok(val) => match self.jws_signer.verify(&val) {
Ok(val) => val.from_json::<SessionId>().ok(),
Ok(val) => val.from_json::<T>().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::<Uuid>(s)
})
}
}
@ -137,6 +139,8 @@ pub fn get_js_files(role: ServerRole) -> Result<JavaScriptFiles, ()> {
("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:; ",

View file

@ -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
})?;

View file

@ -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<Uuid>,
#[serde(rename = "p", default, skip_serializing_if = "Option::is_none")]
password: Option<String>,
#[serde(rename = "t", default, skip_serializing_if = "Option::is_none")]
totp: Option<String>,
}
#[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<Mech<'a>>,
}
#[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<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Extension(kopid): Extension<KOpId>,
_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<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
totp: Option<String>,
#[serde(default)]
remember_me: Option<u8>,
}
pub async fn partial_view_login_begin_post(
pub async fn view_login_begin_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(login_begin_form): Form<LoginBeginForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(login_mech_form): Form<LoginMechForm>,
) -> 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::<SessionContext>(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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(login_totp_form): Form<LoginTotpForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(login_pw_form): Form<LoginPwForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(login_bc_form): Form<LoginBackupCodeForm>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Json(assertion): Json<Box<PublicKeyCredential>>,
) -> 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<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Json(assertion): Json<Box<PublicKeyCredential>>,
) -> 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::<SessionContext>(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<Response, OperationError> {
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));

View file

@ -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<ServerState> {
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>(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<Option<T>, D::Error>
where
@ -88,5 +110,3 @@ where
.map(Some),
}
}
*/

3
server/core/static/external/base64.js vendored Normal file
View file

@ -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;c<t.length;){if((r=t.charCodeAt(c++))>255||(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<e;r+=4096)n.push(d.apply(null,t.subarray(r,r+4096)));return y(n.join(""))},b=function(t,n){return void 0===n&&(n=!1),n?l(A(t)):A(t)},g=function(t){if(t.length<2)return(n=t.charCodeAt(0))<128?t:n<2048?d(192|n>>>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<t.length;)n=c[t.charAt(u++)]<<18|c[t.charAt(u++)]<<12|(r=c[t.charAt(u++)])<<6|(e=c[t.charAt(u++)]),o+=64===r?d(n>>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}));

53
server/core/static/external/pkhtml.js vendored Normal file
View file

@ -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) {};

View file

@ -3,6 +3,10 @@ body {
height: 100%;
}
.input-hidden {
display: none;
}
.form-cred-reset-body {
width: 100%;
max-width: 500px;

View file

@ -15,8 +15,9 @@
href="/pkg/img/logo-192.png" />
<link rel="apple-touch-icon" sizes="512x512"
href="/pkg/img/logo-square.svg" />
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" />
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"/>
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"></script>
<link rel="stylesheet" href="/pkg/style.css" />
(% block head %)(% endblock %)

View file

@ -1,19 +1,8 @@
(% extends "base_htmx.html" %)
(% extends "login_base.html" %)
(% block title %)Error(% endblock %)
(% block head %)
(% endblock %)
(% block body %)
<main id="main" class="flex-shrink-0 form-signin">
<center>
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo"/>
<h3>Kanidm</h3>
</center>
<div id="login-form-container" class="container">
(% block logincontainer %)
<label for="username" class="form-label">Username</label>
<form id="login" hx-post='/ui/api/login_begin' hx-target="#login-form-container" hx-push-url="false">
<form id="login" action="/ui/api/login_begin" method="post">
<div class="input-group mb-3">
<input
autofocus=true
@ -25,6 +14,25 @@
value="(( username ))"
required=true
/>
<input
class="input-hidden"
id="password"
name="password"
type="password"
autocomplete="current-password"
value=""
/>
<input
class="input-hidden"
id="totp"
name="totp"
type="text"
autocomplete="one-time-code"
value=""
/>
</div>
<div class="mb-3 form-check form-switch">
<input
@ -45,6 +53,4 @@
>Begin</button>
</div>
</form>
</div>
</main>
(% endblock %)

View file

@ -0,0 +1,24 @@
(% extends "login_base.html" %)
(% block logincontainer %)
<label for="Backup Code" class="form-label">Backup Code</label>
<form id="login" action="/ui/api/login_backup_code" method="post">
<div class="input-group mb-3">
<input
autofocus=true
class="autofocus form-control"
id="backupcode"
name="backupcode"
type="password"
value=""
required=true
/>
</div>
<div class="input-group mb-3 justify-content-md-center">
<button
type="submit"
class="btn btn-dark"
>Submit</button>
</div>
</form>
(% endblock %)

View file

@ -0,0 +1,19 @@
(% extends "base_htmx.html" %)
(% block title %)Login(% endblock %)
(% block head %)
(% endblock %)
(% block body %)
<main id="main" class="flex-shrink-0 form-signin">
<center>
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo"/>
<h3>Kanidm</h3>
</center>
<div id="login-form-container" class="container">
(% block logincontainer %)
(% endblock %)
</div>
</main>
(% endblock %)

View file

@ -0,0 +1,22 @@
(% extends "login_base.html" %)
(% block logincontainer %)
<div class="container">
<p>Choose how to proceed:</p>
</div>
<div class="container">
<ul class="list-unstyled">
(% for mech in mechs %)
<li class="text-center mb-2">
<form id="login" action="/ui/api/login_mech_choose" method="post">
<input type="hidden" id="mech" name="mech" value="(( mech.value ))" />
<button
type="submit"
class="btn btn-dark"
>(( mech.name ))</button>
</form>
</li>
(% endfor %)
</ul>
</div>
(% endblock %)

View file

@ -1,7 +1,8 @@
(% extends "login_base.html" %)
(% block logincontainer %)
<label for="password" class="form-label">Password</label>
<form id="login" hx-post='/ui/api/login_pw' hx-target="#login-form-container" hx-push-url="false">
<form id="login" action="/ui/api/login_pw" method="post">
<div class="input-group mb-3">
<input
autofocus=true
@ -10,7 +11,7 @@
name="password"
type="password"
autocomplete="current-password"
value=""
value="(( password ))"
required=true
/>
</div>
@ -18,8 +19,7 @@
<button
type="submit"
class="btn btn-dark"
>Begin</button>
>Submit</button>
</div>
</form>
(% endblock %)

View file

@ -1,12 +1,14 @@
(% extends "login_base.html" %)
(% block logincontainer %)
<label for="totp" class="form-label">TOTP</label>
(% match errors %)
(% when LoginTotpError::Syntax %)
<span class="error">Invalid Value - TOTP must only consist of numbers</span>
<span class="error">Invalid Value</span>
<span class="error">TOTP must only consist of numbers</span>
(% when LoginTotpError::None %)
(% endmatch %)
<form id="login" hx-post='/ui/api/login_totp' hx-target="#login-form-container" hx-push-url="false">
<form id="login" action="/ui/api/login_totp" method="post">
<div class="input-group mb-3">
<input
autofocus=true
@ -14,8 +16,8 @@
id="totp"
name="totp"
type="text"
autocomplete="off"
value=""
autocomplete="one-time-code"
value="(( totp ))"
required=true
/>
</div>
@ -23,8 +25,7 @@
<button
type="submit"
class="btn btn-dark"
>Begin</button>
>Submit</button>
</div>
</form>
(% endblock %)

View file

@ -0,0 +1,17 @@
(% extends "login_base.html" %)
(% block logincontainer %)
<script id="data" type="application/json">
(( chal|safe ))
</script>
<script src="/pkg/external/base64.js" async></script>
<script src="/pkg/external/pkhtml.js" defer></script>
(% if passkey %)
<button hx-disable type="button" class="btn btn-dark" id="start-passkey-button">Use Passkey</button>
(% else %)
<button type="button" class="btn btn-dark" id="start-seckey-button">Use Security Key</button>
(% endif %)
(% endblock %)

View file

@ -429,7 +429,7 @@ pub struct DbValueOauthScopeMapV1 {
pub data: Vec<String>,
}
#[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)]

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -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;

View file

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

View file

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

View file

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

View file

@ -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<String>,
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<ReplOauth2SessionV1>,
},
Session {
set: Vec<ReplSessionV1>,
set: Vec<DbValueSession>,
},
ApiToken {
set: Vec<ReplApiTokenV1>,

View file

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

View file

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

View file

@ -159,11 +159,6 @@ pub trait ValueSetT: std::fmt::Debug + DynClone {
Ok(None)
}
fn migrate_session_to_apitoken(&self) -> Result<ValueSet, OperationError> {
debug_assert!(false);
Err(OperationError::InvalidValueState)
}
fn get_ssh_tag(&self, _tag: &str) -> Option<&SshPublicKey> {
None
}

View file

@ -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,169 +35,73 @@ impl ValueSetSession {
self.map.insert(u, m).is_none()
}
pub fn from_dbvs2(data: Vec<DbValueSession>) -> Result<ValueSet, OperationError> {
let map =
data.into_iter()
fn to_vec_dbvs(&self) -> Vec<DbValueSession> {
self.map
.iter()
.map(|(u, m)| DbValueSession::V4 {
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,
},
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<Item = &'a DbValueSession>,
) -> Result<ValueSet, OperationError> {
let map = 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();
// 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<Result<ODT, _>>
})
.transpose()
// Result<Option<ODT>, _>
.map_err(|e| {
admin_error!(
?e,
"Invalidating session {} due to invalid expiry timestamp",
refer
)
})
// Option<Option<ODT>>
.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<Result<ODT, _>>
})
.transpose()
// Result<Option<ODT>, _>
.map_err(|e| {
admin_error!(
?e,
"Invalidating session {} due to invalid expiry timestamp",
refer
)
})
// Option<Option<ODT>>
.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 {
// 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,
@ -203,6 +109,7 @@ impl ValueSetSession {
issued_by,
cred_id,
scope,
type_,
} => {
// Convert things.
let issued_at = OffsetDateTime::parse(&issued_at, &Rfc3339)
@ -231,23 +138,22 @@ impl ValueSetSession {
.ok()?
}
DbValueSessionStateV1::Never => SessionState::NeverExpires,
DbValueSessionStateV1::RevokedAt(dc) => {
SessionState::RevokedAt(Cid {
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),
DbValueIdentityId::V1Uuid(u) => IdentityId::User(*u),
DbValueIdentityId::V1Sync(u) => IdentityId::Synch(*u),
};
let scope = match scope {
DbValueAccessScopeV1::IdentityOnly
| DbValueAccessScopeV1::ReadOnly => SessionScope::ReadOnly,
DbValueAccessScopeV1::IdentityOnly | DbValueAccessScopeV1::ReadOnly => {
SessionScope::ReadOnly
}
DbValueAccessScopeV1::ReadWrite => SessionScope::ReadWrite,
DbValueAccessScopeV1::PrivilegeCapable => {
SessionScope::PrivilegeCapable
@ -255,101 +161,44 @@ impl ValueSetSession {
DbValueAccessScopeV1::Synchronise => SessionScope::Synchronise,
};
Some((
refer,
Session {
label,
state,
issued_at,
issued_by,
cred_id,
scope,
},
))
}
}
})
.collect();
Ok(Box::new(ValueSetSession { map }))
}
pub fn from_repl_v1(data: &[ReplSessionV1]) -> Result<ValueSet, OperationError> {
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()?;
// 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,
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.to_string(),
label: label.clone(),
state,
issued_at,
issued_by,
cred_id: *cred_id,
scope,
type_,
},
))
},
)
}
}
})
.collect();
Ok(Box::new(ValueSetSession { map }))
}
pub fn from_dbvs2(data: Vec<DbValueSession>) -> Result<ValueSet, OperationError> {
Self::from_dbv_iter(data.iter())
}
pub fn from_repl_v1(data: &[DbValueSession]) -> Result<ValueSet, OperationError> {
Self::from_dbv_iter(data.iter())
}
// We need to allow this, because rust doesn't allow us to impl FromIterator on foreign
// types, and tuples are always foreign.
#[allow(clippy::should_implement_trait)]
@ -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<ValueSet, OperationError> {
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<ValueSet> {
// 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<ValueSet, OperationError> {
// 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,
},
)
}));

View file

@ -165,6 +165,7 @@ impl ValueSetT for ValueSetUint32 {
#[cfg(test)]
mod tests {
use super::ValueSetUint32;
use crate::prelude::*;
#[test]