diff --git a/proto/src/internal/mod.rs b/proto/src/internal/mod.rs index 0469eb6a1..5657cb262 100644 --- a/proto/src/internal/mod.rs +++ b/proto/src/internal/mod.rs @@ -27,6 +27,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"; +pub const COOKIE_OAUTH2_REQ: &str = "o2-authreq"; #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] /// This is a description of a linked or connected application for a user. This is diff --git a/server/Dockerfile b/server/Dockerfile index 2dbc890b8..5d996a1e3 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -81,4 +81,12 @@ VOLUME /data ENV RUST_BACKTRACE 1 +HEALTHCHECK \ + --interval=60s \ + --timeout=10s \ + --start-period=60s \ + --start-interval=5s \ + --retries=3 \ + CMD [ "/sbin/kanidmd", "healthcheck", "-c", "/data/server.toml"] + CMD [ "/sbin/kanidmd", "server", "-c", "/data/server.toml"] diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index f3f9b8da0..12be6c30c 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -139,8 +139,8 @@ pub fn get_js_files(role: ServerRole) -> Result { ("external/bootstrap.bundle.min.js", None, false, false), ("external/htmx.min.1.9.12.js", None, false, false), ("external/confetti.js", None, false, false), - ("external/pkhtml.js", None, false, false), ("external/base64.js", None, false, false), + ("pkhtml.js", None, false, false), ] } else { vec![ diff --git a/server/core/src/https/views/login.rs b/server/core/src/https/views/login.rs index cf19c2178..57500d770 100644 --- a/server/core/src/https/views/login.rs +++ b/server/core/src/https/views/login.rs @@ -1,35 +1,25 @@ +use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; use askama::Template; - use axum::{ extract::State, response::{IntoResponse, Redirect, Response}, Extension, Form, Json, }; - use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; - use compact_jwt::{Jws, JwsSigner}; - -use kanidmd_lib::prelude::OperationError; - +use kanidm_proto::internal::{ + COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_OAUTH2_REQ, COOKIE_USERNAME, +}; 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, COOKIE_USERNAME}; - -use kanidmd_lib::idm::AuthState; - use kanidmd_lib::idm::event::AuthResult; - -use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; - -use webauthn_rs::prelude::PublicKeyCredential; - +use kanidmd_lib::idm::AuthState; +use kanidmd_lib::prelude::OperationError; +use kanidmd_lib::prelude::*; use serde::{Deserialize, Serialize}; use std::str::FromStr; +use webauthn_rs::prelude::PublicKeyCredential; use super::{empty_string_as_none, HtmlTemplate, UnrecoverableErrorView}; @@ -124,7 +114,7 @@ pub async fn view_logout_get( }) .into_response() } else { - let response = Redirect::to("/ui").into_response(); + let response = Redirect::to("/ui/login").into_response(); jar = if let Some(bearer_cookie) = jar.get(COOKIE_BEARER_TOKEN) { let mut bearer_cookie = bearer_cookie.clone(); @@ -660,7 +650,7 @@ async fn view_login_step( username_cookie.set_same_site(SameSite::Lax); username_cookie.set_http_only(true); username_cookie.set_domain(state.domain.clone()); - username_cookie.set_path("/"); + username_cookie.set_path("/ui/login"); jar.add(username_cookie) } else { jar @@ -670,7 +660,13 @@ async fn view_login_step( .add(bearer_cookie) .remove(Cookie::from(COOKIE_AUTH_SESSION_ID)); - let res = Redirect::to("/ui/apps").into_response(); + // Now, we need to decided where to go. If this + + let res = if jar.get(COOKIE_OAUTH2_REQ).is_some() { + Redirect::to("/ui/oauth2/resume").into_response() + } else { + Redirect::to("/ui/apps").into_response() + }; break res; } diff --git a/server/core/src/https/views/mod.rs b/server/core/src/https/views/mod.rs index db490f0d0..c3f2a6941 100644 --- a/server/core/src/https/views/mod.rs +++ b/server/core/src/https/views/mod.rs @@ -19,6 +19,7 @@ use crate::https::{ mod apps; mod errors; mod login; +mod oauth2; #[derive(Template)] #[template(path = "unrecoverable_error.html")] @@ -29,38 +30,42 @@ struct UnrecoverableErrorView { pub fn view_router() -> Router { let unguarded_router = Router::new() - .route("/", get(login::view_index_get)) + .route("/", get(|| async { Redirect::permanent("/ui/login") })) .route("/apps", get(apps::view_apps_get)) .route("/logout", get(login::view_logout_get)) + .route("/oauth2", get(oauth2::view_index_get)) + .route("/oauth2/resume", get(oauth2::view_resume_get)) + .route("/oauth2/consent", post(oauth2::view_consent_post)) // 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("/login", get(login::view_index_get)) .route( - "/api/login_passkey", + "/login/passkey", post(login::view_login_passkey_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_seckey", + "/login/seckey", post(login::view_login_seckey_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_begin", + "/login/begin", post(login::view_login_begin_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_mech_choose", + "/login/mech_choose", post(login::view_login_mech_choose_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_backup_code", + "/login/backup_code", post(login::view_login_backupcode_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_totp", + "/login/totp", post(login::view_login_totp_post).get(|| async { Redirect::to("/ui") }), ) .route( - "/api/login_pw", + "/login/pw", post(login::view_login_pw_post).get(|| async { Redirect::to("/ui") }), ); diff --git a/server/core/src/https/views/oauth2.rs b/server/core/src/https/views/oauth2.rs new file mode 100644 index 000000000..4b7ad4f4d --- /dev/null +++ b/server/core/src/https/views/oauth2.rs @@ -0,0 +1,271 @@ +use compact_jwt::{Jws, JwsSigner}; +use kanidmd_lib::idm::oauth2::{ + AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error, +}; +use kanidmd_lib::prelude::*; + +use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState}; + +use kanidm_proto::internal::COOKIE_OAUTH2_REQ; + +use std::collections::BTreeSet; + +use askama::Template; + +use axum::{ + extract::{Query, State}, + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + response::{IntoResponse, Redirect, Response}, + Extension, Form, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use axum_htmx::HX_REDIRECT; +use serde::Deserialize; + +use super::{HtmlTemplate, UnrecoverableErrorView}; + +#[derive(Template)] +#[template(path = "oauth2_consent_request.html")] +struct ConsentRequestView { + client_name: String, + // scopes: BTreeSet, + pii_scopes: BTreeSet, + consent_token: String, +} + +#[derive(Template)] +#[template(path = "oauth2_access_denied.html")] +struct AccessDeniedView { + operation_id: Uuid, +} + +pub async fn view_index_get( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Query(auth_req): Query, +) -> Response { + oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await +} + +pub async fn view_resume_get( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, +) -> Response { + let maybe_auth_req = jar + .get(COOKIE_OAUTH2_REQ) + .map(|c| c.value()) + .and_then(|s| state.deserialise_from_str::(s)); + + if let Some(auth_req) = maybe_auth_req { + oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await + } else { + error!("unable to resume session, no auth_req was found in the cookie"); + HtmlTemplate(UnrecoverableErrorView { + err_code: OperationError::InvalidState, + operation_id: kopid.eventid, + }) + .into_response() + } +} + +async fn oauth2_auth_req( + state: ServerState, + kopid: KOpId, + client_auth_info: ClientAuthInfo, + jar: CookieJar, + auth_req: AuthorisationRequest, +) -> Response { + let res: Result = state + .qe_r_ref + .handle_oauth2_authorise(client_auth_info, auth_req.clone(), kopid.eventid) + .await; + + match res { + Ok(AuthoriseResponse::Permitted(AuthorisePermitSuccess { + mut redirect_uri, + state, + code, + })) => { + let jar = if let Some(authreq_cookie) = jar.get(COOKIE_OAUTH2_REQ) { + let mut authreq_cookie = authreq_cookie.clone(); + authreq_cookie.make_removal(); + authreq_cookie.set_path("/ui"); + jar.add(authreq_cookie) + } else { + jar + }; + + redirect_uri + .query_pairs_mut() + .clear() + .append_pair("state", &state) + .append_pair("code", &code); + + ( + jar, + [ + (HX_REDIRECT, redirect_uri.as_str().to_string()), + ( + ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), + redirect_uri.origin().ascii_serialization(), + ), + ], + Redirect::to(redirect_uri.as_str()), + ) + .into_response() + } + Ok(AuthoriseResponse::ConsentRequested { + client_name, + scopes: _, + pii_scopes, + consent_token, + }) => { + // We can just render the form now, the consent token has everything we need. + HtmlTemplate(ConsentRequestView { + client_name, + // scopes, + pii_scopes, + consent_token, + }) + .into_response() + } + Err(Oauth2Error::AuthenticationRequired) => { + // We store the auth_req into the cookie. + let kref = &state.jws_signer; + + let token = match Jws::into_json(&auth_req) + .map_err(|err| { + error!(?err, "Failed to serialise AuthorisationRequest"); + OperationError::InvalidSessionState + }) + .and_then(|jws| { + kref.sign(&jws).map_err(|err| { + error!(?err, "Failed to sign AuthorisationRequest"); + OperationError::InvalidSessionState + }) + }) + .map(|jwss| jwss.to_string()) + { + Ok(jws) => jws, + Err(err_code) => { + return HtmlTemplate(UnrecoverableErrorView { + err_code, + operation_id: kopid.eventid, + }) + .into_response(); + } + }; + + let mut authreq_cookie = Cookie::new(COOKIE_OAUTH2_REQ, token); + authreq_cookie.set_secure(state.secure_cookies); + authreq_cookie.set_same_site(SameSite::Strict); + authreq_cookie.set_http_only(true); + authreq_cookie.set_domain(state.domain.clone()); + authreq_cookie.set_path("/ui"); + let jar = jar.add(authreq_cookie); + + (jar, Redirect::to("/ui/login")).into_response() + } + Err(Oauth2Error::AccessDenied) => { + // If scopes are not available for this account. + HtmlTemplate(AccessDeniedView { + operation_id: kopid.eventid, + }) + .into_response() + } + /* + RFC - If the request fails due to a missing, invalid, or mismatching + redirection URI, or if the client identifier is missing or invalid, + the authorization server SHOULD inform the resource owner of the + error and MUST NOT automatically redirect the user-agent to the + invalid redirection URI. + */ + // To further this, it appears that a malicious client configuration can set a phishing + // site as the redirect URL, and then use that to trigger certain types of attacks. Instead + // we do NOT redirect in an error condition, and just render the error ourselves. + Err(err_code) => { + error!( + "Unable to authorise - Error ID: {:?} error: {}", + kopid.eventid, + &err_code.to_string() + ); + + HtmlTemplate(UnrecoverableErrorView { + err_code: OperationError::InvalidState, + operation_id: kopid.eventid, + }) + .into_response() + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ConsentForm { + consent_token: String, +} + +pub async fn view_consent_post( + State(state): State, + Extension(kopid): Extension, + VerifiedClientInformation(client_auth_info): VerifiedClientInformation, + jar: CookieJar, + Form(consent_form): Form, +) -> Response { + let res = state + .qe_w_ref + .handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid) + .await; + + match res { + Ok(AuthorisePermitSuccess { + mut redirect_uri, + state, + code, + }) => { + let jar = if let Some(authreq_cookie) = jar.get(COOKIE_OAUTH2_REQ) { + let mut authreq_cookie = authreq_cookie.clone(); + authreq_cookie.make_removal(); + authreq_cookie.set_path("/ui"); + jar.add(authreq_cookie) + } else { + jar + }; + + redirect_uri + .query_pairs_mut() + .clear() + .append_pair("state", &state) + .append_pair("code", &code); + + ( + jar, + [ + (HX_REDIRECT, redirect_uri.as_str().to_string()), + ( + ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), + redirect_uri.origin().ascii_serialization(), + ), + ], + Redirect::to(redirect_uri.as_str()), + ) + .into_response() + } + Err(err_code) => { + error!( + "Unable to authorise - Error ID: {:?} error: {}", + kopid.eventid, + &err_code.to_string() + ); + + HtmlTemplate(UnrecoverableErrorView { + err_code: OperationError::InvalidState, + operation_id: kopid.eventid, + }) + .into_response() + } + } +} diff --git a/server/core/static/external/pkhtml.js b/server/core/static/pkhtml.js similarity index 95% rename from server/core/static/external/pkhtml.js rename to server/core/static/pkhtml.js index e9375da59..b2889425b 100644 --- a/server/core/static/external/pkhtml.js +++ b/server/core/static/pkhtml.js @@ -40,14 +40,14 @@ function asskey_login(target) { try { const myButton = document.getElementById("start-passkey-button"); myButton.addEventListener("click", () => { - asskey_login('/ui/api/login_passkey'); + asskey_login('/ui/login/passkey'); }); } catch (_error) {}; try { const myButton = document.getElementById("start-seckey-button"); myButton.addEventListener("click", () => { - asskey_login('/ui/api/login_seckey'); + asskey_login('/ui/login/seckey'); }); } catch (_error) {}; diff --git a/server/core/templates/base.html b/server/core/templates/base.html index a0f0cc931..21bb3476d 100644 --- a/server/core/templates/base.html +++ b/server/core/templates/base.html @@ -16,13 +16,18 @@ - - + + (% block head %)(% endblock %) - - (% block body %)(% endblock %) + + (% block body %)(% endblock %) + diff --git a/server/core/templates/login.html b/server/core/templates/login.html index 1c829ff73..67d75cd75 100644 --- a/server/core/templates/login.html +++ b/server/core/templates/login.html @@ -2,7 +2,7 @@ (% block logincontainer %) -
+
Backup Code - +

Reason: (( reason ))

Operation ID: (( operation_id ))

- + diff --git a/server/core/templates/login_mech_choose.html b/server/core/templates/login_mech_choose.html index 20c5ed1e2..7fcaa920e 100644 --- a/server/core/templates/login_mech_choose.html +++ b/server/core/templates/login_mech_choose.html @@ -8,7 +8,7 @@
    (% for mech in mechs %)
  • - + diff --git a/server/core/templates/oauth2_access_denied.html b/server/core/templates/oauth2_access_denied.html new file mode 100644 index 000000000..417c3fec6 --- /dev/null +++ b/server/core/templates/oauth2_access_denied.html @@ -0,0 +1,15 @@ +(% extends "base.html" %) + +(% block title %)Access Denied(% endblock %) + +(% block head %) +(% endblock %) + +(% block body %) +

    Access Denied

    +
    +

    If you believe this is an error, please quote the below Operation ID to support persons.

    +

    Operation ID: (( operation_id ))

    +
    +(% endblock %) + diff --git a/server/core/templates/oauth2_consent_request.html b/server/core/templates/oauth2_consent_request.html new file mode 100644 index 000000000..d87e167a9 --- /dev/null +++ b/server/core/templates/oauth2_consent_request.html @@ -0,0 +1,32 @@ +(% extends "base.html" %) + +(% block title %)Consent Required(% endblock %) + +(% block head %) +(% endblock %) + +(% block body %) +
    +

    Consent to Proceed to (( client_name ))

    + (% if pii_scopes.is_empty() %) +
    +

    This site will not have access to your personal information.

    +

    If this site requests personal information in the future we will check with you.

    +
    + (% else %) +
    +

    This site has requested access to the following personal information:

    +
      + (% for pii_scope in pii_scopes %) +
    • (( pii_scope ))
    • + (% endfor %) +
    +

    If this site requests different personal information in the future we will check with you again.

    +
    + (% endif %) + + + + +
    +(% endblock %) diff --git a/server/daemon/src/main.rs b/server/daemon/src/main.rs index 1f1565a61..3b62671e2 100644 --- a/server/daemon/src/main.rs +++ b/server/daemon/src/main.rs @@ -24,6 +24,7 @@ use fs4::FileExt; use kanidm_proto::messages::ConsoleOutputMode; use sketching::otel::TracingPipelineGuard; use sketching::LogLevel; +use std::io::Read; #[cfg(target_family = "unix")] use std::os::unix::fs::MetadataExt; use std::path::PathBuf; @@ -1044,50 +1045,34 @@ async fn kanidm_main( let ca_cert_path = PathBuf::from(ca_cert); match ca_cert_path.exists() { true => { - let ca_contents = match std::fs::read_to_string(ca_cert_path.clone()) { - Ok(val) => val, - Err(e) => { - error!( - "Failed to read {:?} from filesystem: {:?}", - ca_cert_path, e - ); - return ExitCode::FAILURE; - } - }; - let content = ca_contents - .split("-----END CERTIFICATE-----") - .filter_map(|c| { - if c.trim().is_empty() { - None - } else { - Some(c.trim().to_string()) - } - }) - .collect::>(); - let content = match content.last() { - Some(val) => val, - None => { - error!( - "Failed to parse {:?} as valid certificate", - ca_cert_path - ); - return ExitCode::FAILURE; - } - }; - let content = format!("{}-----END CERTIFICATE-----", content); + let mut cert_buf = Vec::new(); + if let Err(err) = std::fs::File::open(&ca_cert_path) + .and_then(|mut file| file.read_to_end(&mut cert_buf)) + { + error!( + "Failed to read {:?} from filesystem: {:?}", + ca_cert_path, err + ); + return ExitCode::FAILURE; + } - let ca_cert_parsed = - match reqwest::Certificate::from_pem(content.as_bytes()) { + let ca_chain_parsed = + match reqwest::Certificate::from_pem_bundle(&cert_buf) { Ok(val) => val, Err(e) => { error!( - "Failed to parse {} into CA certificate!\nError: {:?}", - ca_cert, e + "Failed to parse {:?} into CA chain!\nError: {:?}", + ca_cert_path, e ); return ExitCode::FAILURE; } }; - client.add_root_certificate(ca_cert_parsed) + + // Need at least 2 certs for the leaf + chain. We skip the leaf. + for cert in ca_chain_parsed.into_iter().skip(1) { + client = client.add_root_certificate(cert) + } + client } false => { warn!("Couldn't find ca cert {} but carrying on...", ca_cert);