mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 04:27:02 +01:00
20240703 htmx (#2870)
Complete the remainder of the HTMX rewrite of the login page.
This commit is contained in:
parent
681080ba22
commit
b1480e36f0
77
Cargo.lock
generated
77
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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:; ",
|
||||
|
|
|
@ -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
|
||||
})?;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
3
server/core/static/external/base64.js
vendored
Normal 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
53
server/core/static/external/pkhtml.js
vendored
Normal 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) {};
|
||||
|
|
@ -3,6 +3,10 @@ body {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-cred-reset-body {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
|
|
@ -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 %)
|
||||
|
|
|
@ -1,50 +1,56 @@
|
|||
(% extends "base_htmx.html" %)
|
||||
(% extends "login_base.html" %)
|
||||
|
||||
(% block title %)Error(% endblock %)
|
||||
(% block logincontainer %)
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<form id="login" action="/ui/api/login_begin" method="post">
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
autofocus=true
|
||||
class="autofocus form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
value="(( username ))"
|
||||
required=true
|
||||
/>
|
||||
|
||||
(% 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">
|
||||
<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">
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
autofocus=true
|
||||
class="autofocus form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
value="(( username ))"
|
||||
required=true
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
class="form-check-input"
|
||||
role="switch"
|
||||
id="remember_me_check"
|
||||
value="1"
|
||||
(% if remember_me %)checked(% endif %)
|
||||
/>
|
||||
<label class="form-check-label" for="remember_me_check">Remember My Username</label>
|
||||
</div>
|
||||
<div class="input-group mb-3 justify-content-md-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-dark"
|
||||
>Begin</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<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
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
class="form-check-input"
|
||||
role="switch"
|
||||
id="remember_me_check"
|
||||
value="1"
|
||||
(% if remember_me %)checked(% endif %)
|
||||
/>
|
||||
<label class="form-check-label" for="remember_me_check">Remember My Username</label>
|
||||
</div>
|
||||
<div class="input-group mb-3 justify-content-md-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-dark"
|
||||
>Begin</button>
|
||||
</div>
|
||||
</form>
|
||||
(% endblock %)
|
||||
|
|
24
server/core/templates/login_backupcode.html
Normal file
24
server/core/templates/login_backupcode.html
Normal 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 %)
|
19
server/core/templates/login_base.html
Normal file
19
server/core/templates/login_base.html
Normal 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 %)
|
22
server/core/templates/login_mech_choose.html
Normal file
22
server/core/templates/login_mech_choose.html
Normal 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 %)
|
|
@ -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 %)
|
|
@ -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 %)
|
17
server/core/templates/login_webauthn.html
Normal file
17
server/core/templates/login_webauthn.html
Normal 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 %)
|
|
@ -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)]
|
||||
|
|
|
@ -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
|
@ -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())
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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_,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<DbValueSession>) -> Result<ValueSet, OperationError> {
|
||||
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<DbValueSession> {
|
||||
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<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 {
|
||||
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<Item = &'a DbValueSession>,
|
||||
) -> Result<ValueSet, OperationError> {
|
||||
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<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()?;
|
||||
pub fn from_dbvs2(data: Vec<DbValueSession>) -> Result<ValueSet, OperationError> {
|
||||
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<ValueSet, OperationError> {
|
||||
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<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,
|
||||
},
|
||||
)
|
||||
}));
|
||||
|
|
|
@ -165,6 +165,7 @@ impl ValueSetT for ValueSetUint32 {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ValueSetUint32;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[test]
|
||||
|
|
Loading…
Reference in a new issue