mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 20:47:01 +01:00
Oauth2 in htmx (#2912)
* Apply suggestions from code review Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
This commit is contained in:
parent
c7fcdc3e4e
commit
a695e0d75f
|
@ -27,6 +27,7 @@ pub use self::token::*;
|
||||||
pub const COOKIE_AUTH_SESSION_ID: &str = "auth-session-id";
|
pub const COOKIE_AUTH_SESSION_ID: &str = "auth-session-id";
|
||||||
pub const COOKIE_BEARER_TOKEN: &str = "bearer";
|
pub const COOKIE_BEARER_TOKEN: &str = "bearer";
|
||||||
pub const COOKIE_USERNAME: &str = "username";
|
pub const COOKIE_USERNAME: &str = "username";
|
||||||
|
pub const COOKIE_OAUTH2_REQ: &str = "o2-authreq";
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||||
/// This is a description of a linked or connected application for a user. This is
|
/// This is a description of a linked or connected application for a user. This is
|
||||||
|
|
|
@ -81,4 +81,12 @@ VOLUME /data
|
||||||
|
|
||||||
ENV RUST_BACKTRACE 1
|
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"]
|
CMD [ "/sbin/kanidmd", "server", "-c", "/data/server.toml"]
|
||||||
|
|
|
@ -139,8 +139,8 @@ pub fn get_js_files(role: ServerRole) -> Result<JavaScriptFiles, ()> {
|
||||||
("external/bootstrap.bundle.min.js", None, false, false),
|
("external/bootstrap.bundle.min.js", None, false, false),
|
||||||
("external/htmx.min.1.9.12.js", None, false, false),
|
("external/htmx.min.1.9.12.js", None, false, false),
|
||||||
("external/confetti.js", None, false, false),
|
("external/confetti.js", None, false, false),
|
||||||
("external/pkhtml.js", None, false, false),
|
|
||||||
("external/base64.js", None, false, false),
|
("external/base64.js", None, false, false),
|
||||||
|
("pkhtml.js", None, false, false),
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
|
|
|
@ -1,35 +1,25 @@
|
||||||
|
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
Extension, Form, Json,
|
Extension, Form, Json,
|
||||||
};
|
};
|
||||||
|
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
|
||||||
use compact_jwt::{Jws, JwsSigner};
|
use compact_jwt::{Jws, JwsSigner};
|
||||||
|
use kanidm_proto::internal::{
|
||||||
use kanidmd_lib::prelude::OperationError;
|
COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_OAUTH2_REQ, COOKIE_USERNAME,
|
||||||
|
};
|
||||||
use kanidm_proto::v1::{
|
use kanidm_proto::v1::{
|
||||||
AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthStep,
|
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 kanidmd_lib::idm::event::AuthResult;
|
||||||
|
use kanidmd_lib::idm::AuthState;
|
||||||
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
|
use kanidmd_lib::prelude::OperationError;
|
||||||
|
use kanidmd_lib::prelude::*;
|
||||||
use webauthn_rs::prelude::PublicKeyCredential;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use webauthn_rs::prelude::PublicKeyCredential;
|
||||||
|
|
||||||
use super::{empty_string_as_none, HtmlTemplate, UnrecoverableErrorView};
|
use super::{empty_string_as_none, HtmlTemplate, UnrecoverableErrorView};
|
||||||
|
|
||||||
|
@ -124,7 +114,7 @@ pub async fn view_logout_get(
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
} else {
|
} 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) {
|
jar = if let Some(bearer_cookie) = jar.get(COOKIE_BEARER_TOKEN) {
|
||||||
let mut bearer_cookie = bearer_cookie.clone();
|
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_same_site(SameSite::Lax);
|
||||||
username_cookie.set_http_only(true);
|
username_cookie.set_http_only(true);
|
||||||
username_cookie.set_domain(state.domain.clone());
|
username_cookie.set_domain(state.domain.clone());
|
||||||
username_cookie.set_path("/");
|
username_cookie.set_path("/ui/login");
|
||||||
jar.add(username_cookie)
|
jar.add(username_cookie)
|
||||||
} else {
|
} else {
|
||||||
jar
|
jar
|
||||||
|
@ -670,7 +660,13 @@ async fn view_login_step(
|
||||||
.add(bearer_cookie)
|
.add(bearer_cookie)
|
||||||
.remove(Cookie::from(COOKIE_AUTH_SESSION_ID));
|
.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;
|
break res;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ use crate::https::{
|
||||||
mod apps;
|
mod apps;
|
||||||
mod errors;
|
mod errors;
|
||||||
mod login;
|
mod login;
|
||||||
|
mod oauth2;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "unrecoverable_error.html")]
|
#[template(path = "unrecoverable_error.html")]
|
||||||
|
@ -29,38 +30,42 @@ struct UnrecoverableErrorView {
|
||||||
|
|
||||||
pub fn view_router() -> Router<ServerState> {
|
pub fn view_router() -> Router<ServerState> {
|
||||||
let unguarded_router = Router::new()
|
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("/apps", get(apps::view_apps_get))
|
||||||
.route("/logout", get(login::view_logout_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
|
// The login routes are htmx-free to make them simpler, which means
|
||||||
// they need manual guarding for direct get requests which can occur
|
// they need manual guarding for direct get requests which can occur
|
||||||
// if a user attempts to reload the page.
|
// if a user attempts to reload the page.
|
||||||
|
.route("/login", get(login::view_index_get))
|
||||||
.route(
|
.route(
|
||||||
"/api/login_passkey",
|
"/login/passkey",
|
||||||
post(login::view_login_passkey_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_passkey_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_seckey",
|
"/login/seckey",
|
||||||
post(login::view_login_seckey_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_seckey_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_begin",
|
"/login/begin",
|
||||||
post(login::view_login_begin_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_begin_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_mech_choose",
|
"/login/mech_choose",
|
||||||
post(login::view_login_mech_choose_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_mech_choose_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_backup_code",
|
"/login/backup_code",
|
||||||
post(login::view_login_backupcode_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_backupcode_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_totp",
|
"/login/totp",
|
||||||
post(login::view_login_totp_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_totp_post).get(|| async { Redirect::to("/ui") }),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/login_pw",
|
"/login/pw",
|
||||||
post(login::view_login_pw_post).get(|| async { Redirect::to("/ui") }),
|
post(login::view_login_pw_post).get(|| async { Redirect::to("/ui") }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
271
server/core/src/https/views/oauth2.rs
Normal file
271
server/core/src/https/views/oauth2.rs
Normal file
|
@ -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<String>,
|
||||||
|
pii_scopes: BTreeSet<String>,
|
||||||
|
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<ServerState>,
|
||||||
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(auth_req): Query<AuthorisationRequest>,
|
||||||
|
) -> Response {
|
||||||
|
oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn view_resume_get(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
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::<AuthorisationRequest>(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<AuthoriseResponse, Oauth2Error> = 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<ServerState>,
|
||||||
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(consent_form): Form<ConsentForm>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,14 +40,14 @@ function asskey_login(target) {
|
||||||
try {
|
try {
|
||||||
const myButton = document.getElementById("start-passkey-button");
|
const myButton = document.getElementById("start-passkey-button");
|
||||||
myButton.addEventListener("click", () => {
|
myButton.addEventListener("click", () => {
|
||||||
asskey_login('/ui/api/login_passkey');
|
asskey_login('/ui/login/passkey');
|
||||||
});
|
});
|
||||||
} catch (_error) {};
|
} catch (_error) {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const myButton = document.getElementById("start-seckey-button");
|
const myButton = document.getElementById("start-seckey-button");
|
||||||
myButton.addEventListener("click", () => {
|
myButton.addEventListener("click", () => {
|
||||||
asskey_login('/ui/api/login_seckey');
|
asskey_login('/ui/login/seckey');
|
||||||
});
|
});
|
||||||
} catch (_error) {};
|
} catch (_error) {};
|
||||||
|
|
|
@ -16,13 +16,18 @@
|
||||||
<link rel="apple-touch-icon" sizes="512x512"
|
<link rel="apple-touch-icon" sizes="512x512"
|
||||||
href="/pkg/img/logo-square.svg" />
|
href="/pkg/img/logo-square.svg" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"/>
|
<link rel="stylesheet" href="/pkg/external/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"/>
|
||||||
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"></script>
|
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"></script>
|
||||||
<link rel="stylesheet" href="/pkg/style.css" />
|
<link rel="stylesheet" href="/pkg/style.css" />
|
||||||
|
|
||||||
(% block head %)(% endblock %)
|
(% block head %)(% endblock %)
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="flex-column d-flex h-100">
|
||||||
(% block body %)(% endblock %)
|
(% block body %)(% endblock %)
|
||||||
|
<footer class="footer mt-auto py-3 bg-light text-end">
|
||||||
|
<div class="container">
|
||||||
|
<span class="text-muted">Powered by <a href="https://kanidm.com">Kanidm</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
(% block logincontainer %)
|
(% block logincontainer %)
|
||||||
<label for="username" class="form-label">Username</label>
|
<label for="username" class="form-label">Username</label>
|
||||||
<form id="login" action="/ui/api/login_begin" method="post">
|
<form id="login" action="/ui/login/begin" method="post">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input
|
<input
|
||||||
autofocus=true
|
autofocus=true
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
(% block logincontainer %)
|
(% block logincontainer %)
|
||||||
<label for="Backup Code" class="form-label">Backup Code</label>
|
<label for="Backup Code" class="form-label">Backup Code</label>
|
||||||
<form id="login" action="/ui/api/login_backup_code" method="post">
|
<form id="login" action="/ui/login/backup_code" method="post">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input
|
<input
|
||||||
autofocus=true
|
autofocus=true
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
(% extends "base_htmx.html" %)
|
(% extends "base.html" %)
|
||||||
|
|
||||||
(% block title %)Login(% endblock %)
|
(% block title %)Login(% endblock %)
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<p>Reason: (( reason ))</p>
|
<p>Reason: (( reason ))</p>
|
||||||
<p>Operation ID: (( operation_id ))</p>
|
<p>Operation ID: (( operation_id ))</p>
|
||||||
<a href="/ui">
|
<a href="/ui/login">
|
||||||
<button type="button" class="btn btn-success">Return to Login</button>
|
<button type="button" class="btn btn-success">Return to Login</button>
|
||||||
</a>
|
</a>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<ul class="list-unstyled">
|
<ul class="list-unstyled">
|
||||||
(% for mech in mechs %)
|
(% for mech in mechs %)
|
||||||
<li class="text-center mb-2">
|
<li class="text-center mb-2">
|
||||||
<form id="login" action="/ui/api/login_mech_choose" method="post">
|
<form id="login" action="/ui/login/mech_choose" method="post">
|
||||||
<input type="hidden" id="mech" name="mech" value="(( mech.value ))" />
|
<input type="hidden" id="mech" name="mech" value="(( mech.value ))" />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
(% block logincontainer %)
|
(% block logincontainer %)
|
||||||
<label for="password" class="form-label">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<form id="login" action="/ui/api/login_pw" method="post">
|
<form id="login" action="/ui/login/pw" method="post">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input
|
<input
|
||||||
autofocus=true
|
autofocus=true
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<span class="error">TOTP must only consist of numbers</span>
|
<span class="error">TOTP must only consist of numbers</span>
|
||||||
(% when LoginTotpError::None %)
|
(% when LoginTotpError::None %)
|
||||||
(% endmatch %)
|
(% endmatch %)
|
||||||
<form id="login" action="/ui/api/login_totp" method="post">
|
<form id="login" action="/ui/login/totp" method="post">
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input
|
<input
|
||||||
autofocus=true
|
autofocus=true
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/pkg/external/base64.js" async></script>
|
<script src="/pkg/external/base64.js" async></script>
|
||||||
<script src="/pkg/external/pkhtml.js" defer></script>
|
<script src="/pkg/pkhtml.js" defer></script>
|
||||||
|
|
||||||
(% if passkey %)
|
(% if passkey %)
|
||||||
<button hx-disable type="button" class="btn btn-dark" id="start-passkey-button">Use Passkey</button>
|
<button hx-disable type="button" class="btn btn-dark" id="start-passkey-button">Use Passkey</button>
|
||||||
|
|
15
server/core/templates/oauth2_access_denied.html
Normal file
15
server/core/templates/oauth2_access_denied.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
(% extends "base.html" %)
|
||||||
|
|
||||||
|
(% block title %)Access Denied(% endblock %)
|
||||||
|
|
||||||
|
(% block head %)
|
||||||
|
(% endblock %)
|
||||||
|
|
||||||
|
(% block body %)
|
||||||
|
<h2>Access Denied</h2>
|
||||||
|
<main id="main">
|
||||||
|
<p>If you believe this is an error, please quote the below Operation ID to support persons.</p>
|
||||||
|
<p>Operation ID: (( operation_id ))</p>
|
||||||
|
</main>
|
||||||
|
(% endblock %)
|
||||||
|
|
32
server/core/templates/oauth2_consent_request.html
Normal file
32
server/core/templates/oauth2_consent_request.html
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
(% extends "base.html" %)
|
||||||
|
|
||||||
|
(% block title %)Consent Required(% endblock %)
|
||||||
|
|
||||||
|
(% block head %)
|
||||||
|
(% endblock %)
|
||||||
|
|
||||||
|
(% block body %)
|
||||||
|
<main id="main" class="flex-shrink-0 form-signin">
|
||||||
|
<h2 class="h3 mb-3 fw-normal">Consent to Proceed to (( client_name ))</h2>
|
||||||
|
(% if pii_scopes.is_empty() %)
|
||||||
|
<div>
|
||||||
|
<p>This site will not have access to your personal information.</p>
|
||||||
|
<p>If this site requests personal information in the future we will check with you.</p>
|
||||||
|
</div>
|
||||||
|
(% else %)
|
||||||
|
<div>
|
||||||
|
<p>This site has requested access to the following personal information:</p>
|
||||||
|
<ul>
|
||||||
|
(% for pii_scope in pii_scopes %)
|
||||||
|
<li>(( pii_scope ))</li>
|
||||||
|
(% endfor %)
|
||||||
|
</ul>
|
||||||
|
<p>If this site requests different personal information in the future we will check with you again.</p>
|
||||||
|
</div>
|
||||||
|
(% endif %)
|
||||||
|
<form id="login" action="/ui/oauth2/consent" method="post">
|
||||||
|
<input type="hidden" id="consent_token" name="consent_token" value="(( consent_token ))" />
|
||||||
|
<button autofocus=true class="w-100 btn btn-lg btn-primary" type="submit">Proceed</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
(% endblock %)
|
|
@ -24,6 +24,7 @@ use fs4::FileExt;
|
||||||
use kanidm_proto::messages::ConsoleOutputMode;
|
use kanidm_proto::messages::ConsoleOutputMode;
|
||||||
use sketching::otel::TracingPipelineGuard;
|
use sketching::otel::TracingPipelineGuard;
|
||||||
use sketching::LogLevel;
|
use sketching::LogLevel;
|
||||||
|
use std::io::Read;
|
||||||
#[cfg(target_family = "unix")]
|
#[cfg(target_family = "unix")]
|
||||||
use std::os::unix::fs::MetadataExt;
|
use std::os::unix::fs::MetadataExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
@ -1044,50 +1045,34 @@ async fn kanidm_main(
|
||||||
let ca_cert_path = PathBuf::from(ca_cert);
|
let ca_cert_path = PathBuf::from(ca_cert);
|
||||||
match ca_cert_path.exists() {
|
match ca_cert_path.exists() {
|
||||||
true => {
|
true => {
|
||||||
let ca_contents = match std::fs::read_to_string(ca_cert_path.clone()) {
|
let mut cert_buf = Vec::new();
|
||||||
Ok(val) => val,
|
if let Err(err) = std::fs::File::open(&ca_cert_path)
|
||||||
Err(e) => {
|
.and_then(|mut file| file.read_to_end(&mut cert_buf))
|
||||||
error!(
|
{
|
||||||
"Failed to read {:?} from filesystem: {:?}",
|
error!(
|
||||||
ca_cert_path, e
|
"Failed to read {:?} from filesystem: {:?}",
|
||||||
);
|
ca_cert_path, err
|
||||||
return ExitCode::FAILURE;
|
);
|
||||||
}
|
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::<Vec<String>>();
|
|
||||||
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 ca_cert_parsed =
|
let ca_chain_parsed =
|
||||||
match reqwest::Certificate::from_pem(content.as_bytes()) {
|
match reqwest::Certificate::from_pem_bundle(&cert_buf) {
|
||||||
Ok(val) => val,
|
Ok(val) => val,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
"Failed to parse {} into CA certificate!\nError: {:?}",
|
"Failed to parse {:?} into CA chain!\nError: {:?}",
|
||||||
ca_cert, e
|
ca_cert_path, e
|
||||||
);
|
);
|
||||||
return ExitCode::FAILURE;
|
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 => {
|
false => {
|
||||||
warn!("Couldn't find ca cert {} but carrying on...", ca_cert);
|
warn!("Couldn't find ca cert {} but carrying on...", ca_cert);
|
||||||
|
|
Loading…
Reference in a new issue