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