kanidmd_core/https/views/
mod.rs

1use crate::https::views::admin::{admin_api_router, admin_router};
2use crate::https::{middleware, ServerState};
3use askama::Template;
4use askama_web::WebTemplate;
5
6use axum::{
7    middleware::from_fn_with_state,
8    response::Redirect,
9    routing::{get, post},
10    Router,
11};
12use axum_htmx::{HxEvent, HxRequestGuardLayer};
13use constants::Urls;
14use kanidmd_lib::{
15    idm::server::DomainInfoRead,
16    prelude::{OperationError, Uuid},
17};
18
19mod admin;
20mod apps;
21pub(crate) mod constants;
22mod cookies;
23mod enrol;
24mod errors;
25mod login;
26mod navbar;
27mod oauth2;
28mod profile;
29mod radius;
30mod reset;
31
32#[derive(Template, WebTemplate)]
33#[template(path = "unrecoverable_error.html")]
34struct UnrecoverableErrorView {
35    err_code: OperationError,
36    operation_id: Uuid,
37    // This is an option because it's not always present in an "unrecoverable" situation
38    domain_info: DomainInfoRead,
39}
40
41#[derive(Template, WebTemplate)]
42#[template(path = "admin/error_toast.html")]
43struct ErrorToastPartial {
44    err_code: OperationError,
45    operation_id: Uuid,
46}
47
48pub fn view_router(state: ServerState) -> Router<ServerState> {
49    // These routes are special, and often need to redirect *out* of kanidm. We need to
50    // allow this within CSP.
51    let unguarded_csp_router = Router::new()
52        .route("/oauth2/resume", get(oauth2::view_resume_get))
53        .route("/oauth2/consent", post(oauth2::view_consent_post))
54        // The login routes are htmx-free to make them simpler, which means
55        // they need manual guarding for direct get requests which can occur
56        // if a user attempts to reload the page.
57        .route("/login", get(login::view_index_get))
58        .route(
59            "/login/passkey",
60            post(login::view_login_passkey_post).get(|| async { Redirect::to("/ui") }),
61        )
62        .route(
63            "/login/seckey",
64            post(login::view_login_seckey_post).get(|| async { Redirect::to("/ui") }),
65        )
66        .route(
67            "/login/begin",
68            post(login::view_login_begin_post).get(|| async { Redirect::to("/ui") }),
69        )
70        .route(
71            "/login/mech_choose",
72            post(login::view_login_mech_choose_post).get(|| async { Redirect::to("/ui") }),
73        )
74        .route(
75            "/login/backup_code",
76            post(login::view_login_backupcode_post).get(|| async { Redirect::to("/ui") }),
77        )
78        .route(
79            "/login/totp",
80            post(login::view_login_totp_post).get(|| async { Redirect::to("/ui") }),
81        )
82        .route(
83            "/login/pw",
84            post(login::view_login_pw_post).get(|| async { Redirect::to("/ui") }),
85        )
86        .route(
87            "/login/oauth2_landing",
88            get(login::view_login_oauth2_landing),
89        )
90        .layer(from_fn_with_state(
91            state,
92            middleware::security_headers::csp_header_no_form_action_layer,
93        ));
94
95    // These will have the standard CSP headers applied.
96    let mut unguarded_router = Router::new()
97        .route(
98            "/",
99            get(|| async { Redirect::permanent(Urls::Login.as_ref()) }),
100        )
101        .route("/apps", get(apps::view_apps_get))
102        .route("/enrol", get(enrol::view_enrol_get))
103        .route("/reset", get(reset::view_reset_get))
104        .route("/update_credentials", get(reset::view_self_reset_get))
105        .route("/profile", get(profile::view_profile_get))
106        .route("/profile/diff", get(profile::view_profile_get))
107        .route("/radius", get(radius::view_radius_get))
108        .route("/unlock", get(login::view_reauth_to_referer_get))
109        .route("/logout", get(login::view_logout_get));
110
111    // This is me being temporarily cheeky to avoid a lint while cfg(dev oauth device) is still
112    // present.
113    unguarded_router = unguarded_router.route("/oauth2", get(oauth2::view_index_get));
114
115    #[cfg(feature = "dev-oauth2-device-flow")]
116    {
117        unguarded_router = unguarded_router.route(
118            kanidmd_lib::prelude::uri::OAUTH2_DEVICE_LOGIN,
119            get(oauth2::view_device_get).post(oauth2::view_device_post),
120        );
121    }
122
123    // The webauthn post is unguarded because it's not a htmx event.
124
125    // Anything that is a partial only works if triggered from htmx
126    let guarded_router = Router::new()
127        .route("/reset/add_totp", post(reset::view_new_totp))
128        .route("/reset/add_password", post(reset::view_new_pwd))
129        .route("/reset/change_password", post(reset::view_new_pwd))
130        .route("/reset/add_passkey", post(reset::view_new_passkey))
131        .route("/reset/set_unixcred", post(reset::view_set_unixcred))
132        .route(
133            "/reset/add_ssh_publickey",
134            post(reset::view_add_ssh_publickey),
135        )
136        .route("/radius/generate", post(radius::view_radius_post))
137        .route("/api/delete_alt_creds", post(reset::remove_alt_creds))
138        .route("/api/delete_unixcred", post(reset::remove_unixcred))
139        .route("/api/add_totp", post(reset::add_totp))
140        .route("/api/remove_totp", post(reset::remove_totp))
141        .route("/api/remove_passkey", post(reset::remove_passkey))
142        .route("/api/finish_passkey", post(reset::finish_passkey))
143        .route("/api/cancel_mfareg", post(reset::cancel_mfareg))
144        .route(
145            "/api/remove_ssh_publickey",
146            post(reset::remove_ssh_publickey),
147        )
148        .route("/api/cu_cancel", post(reset::cancel_cred_update))
149        .route("/api/cu_commit", post(reset::commit))
150        .route(
151            "/api/user_settings/add_email",
152            get(profile::view_new_email_entry_partial),
153        )
154        .route(
155            "/api/user_settings/edit_profile",
156            post(profile::view_profile_diff_start_save_post),
157        )
158        .route(
159            "/api/user_settings/confirm_profile",
160            post(profile::view_profile_diff_confirm_save_post),
161        )
162        .layer(HxRequestGuardLayer::new("/ui"));
163
164    let admin_router = admin_router();
165    let admin_api_router = admin_api_router();
166    Router::new()
167        .merge(unguarded_csp_router)
168        .merge(unguarded_router)
169        .merge(guarded_router)
170        .nest("/admin", admin_router)
171        .nest("/api/admin", admin_api_router)
172}
173
174/// Serde deserialization decorator to map empty Strings to None,
175fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
176where
177    D: serde::Deserializer<'de>,
178    T: std::str::FromStr,
179    T::Err: std::fmt::Display,
180{
181    use serde::Deserialize;
182    use std::str::FromStr;
183
184    let opt = Option::<String>::deserialize(de)?;
185    match opt.as_deref() {
186        None | Some("") => Ok(None),
187        Some(s) => FromStr::from_str(s)
188            .map_err(serde::de::Error::custom)
189            .map(Some),
190    }
191}
192
193/// Used for creating hx events
194pub(crate) enum KanidmHxEventName {
195    AddEmailSwapped,
196    AddTotpSwapped,
197    AddPasskeySwapped,
198    AddPasswordSwapped,
199    PermissionDenied,
200}
201
202impl From<KanidmHxEventName> for HxEvent {
203    fn from(event_name: KanidmHxEventName) -> Self {
204        match event_name {
205            KanidmHxEventName::AddEmailSwapped => HxEvent::new("addEmailSwapped"),
206            KanidmHxEventName::AddTotpSwapped => HxEvent::new("addTotpSwapped"),
207            KanidmHxEventName::AddPasskeySwapped => HxEvent::new("addPasskeySwapped"),
208            KanidmHxEventName::AddPasswordSwapped => HxEvent::new("addPasswordSwapped"),
209            KanidmHxEventName::PermissionDenied => HxEvent::new("permissionDenied"),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216
217    use axum::response::IntoResponse;
218
219    use super::*;
220    #[tokio::test]
221    async fn test_unrecoverableerrorview() {
222        let domain_info = kanidmd_lib::server::DomainInfo::new_test();
223
224        let view = UnrecoverableErrorView {
225            err_code: OperationError::InvalidState,
226            operation_id: Uuid::new_v4(),
227            domain_info: domain_info.read(),
228        };
229
230        let error_html = view.render().expect("Failed to render");
231
232        assert!(error_html.contains(domain_info.read().display_name()));
233
234        let response = view.into_response();
235
236        // TODO: this really should be an error code :(
237        assert_eq!(response.status(), 200);
238    }
239}