Oauth2 in htmx (#2912)

* Apply suggestions from code review

Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
This commit is contained in:
Firstyear 2024-07-20 12:30:06 +10:00 committed by GitHub
parent c7fcdc3e4e
commit a695e0d75f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 398 additions and 80 deletions

View file

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

View file

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

View file

@ -139,8 +139,8 @@ pub fn get_js_files(role: ServerRole) -> Result<JavaScriptFiles, ()> {
("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![

View file

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

View file

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

View 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()
}
}
}

View file

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

View file

@ -16,13 +16,18 @@
<link rel="apple-touch-icon" sizes="512x512"
href="/pkg/img/logo-square.svg" />
<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/external/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"/>
<script src="/pkg/external/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"></script>
<link rel="stylesheet" href="/pkg/style.css" />
(% block head %)(% endblock %)
</head>
<body>
(% block body %)(% endblock %)
<body class="flex-column d-flex h-100">
(% 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>
</html>

View file

@ -2,7 +2,7 @@
(% block logincontainer %)
<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">
<input
autofocus=true

View file

@ -2,7 +2,7 @@
(% block logincontainer %)
<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">
<input
autofocus=true

View file

@ -1,4 +1,4 @@
(% extends "base_htmx.html" %)
(% extends "base.html" %)
(% block title %)Login(% endblock %)

View file

@ -5,7 +5,7 @@
<main id="main">
<p>Reason: (( reason ))</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>
</a>
</main>

View file

@ -8,7 +8,7 @@
<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">
<form id="login" action="/ui/login/mech_choose" method="post">
<input type="hidden" id="mech" name="mech" value="(( mech.value ))" />
<button
type="submit"

View file

@ -2,7 +2,7 @@
(% block logincontainer %)
<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">
<input
autofocus=true

View file

@ -8,7 +8,7 @@
<span class="error">TOTP must only consist of numbers</span>
(% when LoginTotpError::None %)
(% 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">
<input
autofocus=true

View file

@ -6,7 +6,7 @@
</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 %)
<button hx-disable type="button" class="btn btn-dark" id="start-passkey-button">Use Passkey</button>

View 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 %)

View 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 %)

View file

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