kanidmd_core/https/views/
reset.rs

1use askama::Template;
2use axum::extract::{Query, State};
3use axum::http::{StatusCode, Uri};
4use axum::response::{ErrorResponse, IntoResponse, Redirect, Response};
5use axum::{Extension, Form};
6use axum_extra::extract::cookie::SameSite;
7use axum_extra::extract::CookieJar;
8use axum_htmx::{
9    HxEvent, HxLocation, HxPushUrl, HxRequest, HxReselect, HxResponseTrigger, HxReswap, HxRetarget,
10    SwapOption,
11};
12use futures_util::TryFutureExt;
13use qrcode::render::svg;
14use qrcode::QrCode;
15use serde::{Deserialize, Serialize};
16use serde_with::skip_serializing_none;
17use std::collections::BTreeMap;
18use std::fmt;
19use std::fmt::{Display, Formatter};
20use std::str::FromStr;
21use uuid::Uuid;
22
23pub use sshkey_attest::proto::PublicKey as SshPublicKey;
24pub use sshkeys::KeyType;
25
26use kanidm_proto::internal::{
27    CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
28    CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken,
29    COOKIE_CU_SESSION_TOKEN,
30};
31use kanidmd_lib::prelude::ClientAuthInfo;
32
33use super::constants::Urls;
34use super::navbar::NavbarCtx;
35use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation};
36use crate::https::middleware::KOpId;
37use crate::https::views::constants::ProfileMenuItems;
38use crate::https::views::cookies;
39use crate::https::views::errors::HtmxError;
40use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
41use crate::https::ServerState;
42
43use super::UnrecoverableErrorView;
44
45#[derive(Template)]
46#[template(path = "user_settings.html")]
47struct ProfileView {
48    navbar_ctx: NavbarCtx,
49    profile_partial: CredStatusView,
50}
51
52#[derive(Template)]
53#[template(path = "credentials_reset_form.html")]
54struct ResetCredFormView {
55    domain_info: DomainInfoRead,
56    wrong_code: bool,
57}
58
59#[derive(Template)]
60#[template(path = "credentials_reset.html")]
61struct CredResetView {
62    domain_info: DomainInfoRead,
63    names: String,
64    credentials_update_partial: CredResetPartialView,
65}
66
67#[derive(Template)]
68#[template(path = "credentials_status.html")]
69struct CredStatusView {
70    domain_info: DomainInfoRead,
71    menu_active_item: ProfileMenuItems,
72    names: String,
73    credentials_update_partial: CredResetPartialView,
74}
75
76struct SshKey {
77    key_type: KeyType,
78    key: String,
79    comment: Option<String>,
80}
81
82#[derive(Template)]
83#[template(path = "credentials_update_partial.html")]
84struct CredResetPartialView {
85    ext_cred_portal: CUExtPortal,
86    can_commit: bool,
87    warnings: Vec<CURegWarning>,
88    attested_passkeys_state: CUCredState,
89    passkeys_state: CUCredState,
90    primary_state: CUCredState,
91    attested_passkeys: Vec<PasskeyDetail>,
92    passkeys: Vec<PasskeyDetail>,
93    primary: Option<CredentialDetail>,
94    unixcred_state: CUCredState,
95    unixcred: Option<CredentialDetail>,
96    sshkeys_state: CUCredState,
97    sshkeys: BTreeMap<String, SshKey>,
98}
99
100#[skip_serializing_none]
101#[derive(Serialize, Deserialize, Debug)]
102// Needs to be visible so axum can create this struct
103pub(crate) struct ResetTokenParam {
104    token: Option<String>,
105}
106
107#[derive(Template)]
108#[template(path = "credential_update_add_password_partial.html")]
109struct AddPasswordPartial {
110    check_res: PwdCheckResult,
111}
112
113#[derive(Template)]
114#[template(path = "credential_update_set_unixcred_partial.html")]
115struct SetUnixCredPartial {
116    check_res: PwdCheckResult,
117}
118
119#[derive(Template)]
120#[template(path = "credential_update_add_ssh_publickey_partial.html")]
121struct AddSshPublicKeyPartial {
122    title_error: Option<String>,
123    key_error: Option<String>,
124    key_value: Option<String>,
125}
126
127#[derive(Serialize, Deserialize, Debug)]
128enum PwdCheckResult {
129    Success,
130    Init,
131    Failure {
132        pwd_equal: bool,
133        warnings: Vec<PasswordFeedback>,
134    },
135}
136
137#[derive(Deserialize, Debug)]
138pub(crate) struct NewPassword {
139    new_password: String,
140    new_password_check: String,
141}
142
143#[derive(Deserialize, Debug)]
144pub(crate) struct NewPublicKey {
145    title: String,
146    key: String,
147}
148
149#[derive(Deserialize, Debug)]
150pub(crate) struct PublicKeyRemoveData {
151    name: String,
152}
153
154#[derive(Deserialize, Debug)]
155pub(crate) struct NewTotp {
156    name: String,
157    #[serde(rename = "checkTOTPCode")]
158    check_totpcode: String,
159    #[serde(rename = "ignoreBrokenApp")]
160    ignore_broken_app: bool,
161}
162
163#[derive(Template)]
164#[template(path = "credential_update_add_passkey_partial.html")]
165struct AddPasskeyPartial {
166    // Passkey challenge for adding a new passkey
167    challenge: String,
168    class: PasskeyClass,
169}
170
171#[derive(Deserialize, Debug)]
172struct PasskeyCreateResponse {}
173
174#[derive(Deserialize, Debug)]
175struct PasskeyCreateExtensions {}
176
177#[derive(Deserialize, Debug)]
178pub(crate) struct PasskeyInitForm {
179    class: PasskeyClass,
180}
181
182#[derive(Deserialize, Debug)]
183pub(crate) struct PasskeyCreateForm {
184    name: String,
185    class: PasskeyClass,
186    #[serde(rename = "creationData")]
187    creation_data: String,
188}
189
190#[derive(Deserialize, Debug)]
191pub(crate) struct PasskeyRemoveData {
192    uuid: Uuid,
193}
194
195#[derive(Deserialize, Debug)]
196pub(crate) struct TOTPRemoveData {
197    name: String,
198}
199
200#[derive(Serialize, Deserialize, Debug)]
201pub(crate) struct TotpInit {
202    secret: String,
203    qr_code_svg: String,
204    steps: u64,
205    digits: u8,
206    algo: TotpAlgo,
207    uri: String,
208}
209
210#[derive(Serialize, Deserialize, Debug, Default)]
211pub(crate) struct TotpCheck {
212    wrong_code: bool,
213    broken_app: bool,
214    bad_name: bool,
215    taken_name: Option<String>,
216}
217
218#[derive(Template)]
219#[template(path = "credential_update_add_totp_partial.html")]
220struct AddTotpPartial {
221    totp_init: Option<TotpInit>,
222    totp_name: String,
223    totp_value: String,
224    check: TotpCheck,
225}
226
227#[derive(PartialEq, Debug, Serialize, Deserialize)]
228pub enum PasskeyClass {
229    Any,
230    Attested,
231}
232
233impl Display for PasskeyClass {
234    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
235        match self {
236            PasskeyClass::Any => write!(f, "Any"),
237            PasskeyClass::Attested => write!(f, "Attested"),
238        }
239    }
240}
241
242/// When the credential update session is ended through a commit or discard of the changes
243/// we need to redirect the user to a relevant location. This location depends on the sessions
244/// current authentication state. If they are authenticated, they are sent to their profile. If
245/// they are not authenticated, they are sent to the login screen.
246async fn end_session_response(
247    state: ServerState,
248    kopid: KOpId,
249    client_auth_info: ClientAuthInfo,
250    jar: CookieJar,
251) -> axum::response::Result<Response> {
252    let is_logged_in = state
253        .qe_r_ref
254        .handle_auth_valid(client_auth_info, kopid.eventid)
255        .await
256        .is_ok();
257
258    let redirect_location = if is_logged_in {
259        Urls::Profile.as_ref()
260    } else {
261        Urls::Login.as_ref()
262    };
263
264    Ok((
265        jar,
266        HxLocation::from(Uri::from_static(redirect_location)),
267        "",
268    )
269        .into_response())
270}
271
272pub(crate) async fn commit(
273    State(state): State<ServerState>,
274    Extension(kopid): Extension<KOpId>,
275    HxRequest(_hx_request): HxRequest,
276    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
277    DomainInfo(domain_info): DomainInfo,
278    jar: CookieJar,
279) -> axum::response::Result<Response> {
280    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
281
282    state
283        .qe_w_ref
284        .handle_idmcredentialupdatecommit(cu_session_token, kopid.eventid)
285        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
286        .await?;
287
288    // No longer need the cookie jar.
289    let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
290
291    end_session_response(state, kopid, client_auth_info, jar).await
292}
293
294pub(crate) async fn cancel_cred_update(
295    State(state): State<ServerState>,
296    Extension(kopid): Extension<KOpId>,
297    HxRequest(_hx_request): HxRequest,
298    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
299    DomainInfo(domain_info): DomainInfo,
300    jar: CookieJar,
301) -> axum::response::Result<Response> {
302    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
303
304    state
305        .qe_w_ref
306        .handle_idmcredentialupdatecancel(cu_session_token, kopid.eventid)
307        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
308        .await?;
309
310    // No longer need the cookie jar.
311    let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
312
313    end_session_response(state, kopid, client_auth_info, jar).await
314}
315
316pub(crate) async fn cancel_mfareg(
317    State(state): State<ServerState>,
318    Extension(kopid): Extension<KOpId>,
319    HxRequest(_hx_request): HxRequest,
320    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
321    DomainInfo(domain_info): DomainInfo,
322    jar: CookieJar,
323) -> axum::response::Result<Response> {
324    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
325
326    let cu_status = state
327        .qe_r_ref
328        .handle_idmcredentialupdate(cu_session_token, CURequest::CancelMFAReg, kopid.eventid)
329        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
330        .await?;
331
332    Ok(get_cu_partial_response(cu_status))
333}
334
335pub(crate) async fn remove_alt_creds(
336    State(state): State<ServerState>,
337    Extension(kopid): Extension<KOpId>,
338    HxRequest(_hx_request): HxRequest,
339    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
340    DomainInfo(domain_info): DomainInfo,
341    jar: CookieJar,
342) -> axum::response::Result<Response> {
343    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
344
345    let cu_status = state
346        .qe_r_ref
347        .handle_idmcredentialupdate(cu_session_token, CURequest::PrimaryRemove, kopid.eventid)
348        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
349        .await?;
350
351    Ok(get_cu_partial_response(cu_status))
352}
353
354pub(crate) async fn remove_unixcred(
355    State(state): State<ServerState>,
356    Extension(kopid): Extension<KOpId>,
357    HxRequest(_hx_request): HxRequest,
358    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
359    DomainInfo(domain_info): DomainInfo,
360    jar: CookieJar,
361) -> axum::response::Result<Response> {
362    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
363
364    let cu_status = state
365        .qe_r_ref
366        .handle_idmcredentialupdate(
367            cu_session_token,
368            CURequest::UnixPasswordRemove,
369            kopid.eventid,
370        )
371        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
372        .await?;
373
374    Ok(get_cu_partial_response(cu_status))
375}
376
377pub(crate) async fn remove_ssh_publickey(
378    State(state): State<ServerState>,
379    Extension(kopid): Extension<KOpId>,
380    HxRequest(_hx_request): HxRequest,
381    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
382    DomainInfo(domain_info): DomainInfo,
383    jar: CookieJar,
384    Form(publickey): Form<PublicKeyRemoveData>,
385) -> axum::response::Result<Response> {
386    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
387
388    let cu_status = state
389        .qe_r_ref
390        .handle_idmcredentialupdate(
391            cu_session_token,
392            CURequest::SshPublicKeyRemove(publickey.name),
393            kopid.eventid,
394        )
395        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
396        .await?;
397
398    Ok(get_cu_partial_response(cu_status))
399}
400
401pub(crate) async fn remove_totp(
402    State(state): State<ServerState>,
403    Extension(kopid): Extension<KOpId>,
404    HxRequest(_hx_request): HxRequest,
405    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
406    DomainInfo(domain_info): DomainInfo,
407    jar: CookieJar,
408    Form(totp): Form<TOTPRemoveData>,
409) -> axum::response::Result<Response> {
410    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
411
412    let cu_status = state
413        .qe_r_ref
414        .handle_idmcredentialupdate(
415            cu_session_token,
416            CURequest::TotpRemove(totp.name),
417            kopid.eventid,
418        )
419        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
420        .await?;
421
422    Ok(get_cu_partial_response(cu_status))
423}
424
425pub(crate) async fn remove_passkey(
426    State(state): State<ServerState>,
427    Extension(kopid): Extension<KOpId>,
428    HxRequest(_hx_request): HxRequest,
429    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
430    DomainInfo(domain_info): DomainInfo,
431    jar: CookieJar,
432    Form(passkey): Form<PasskeyRemoveData>,
433) -> axum::response::Result<Response> {
434    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
435
436    let cu_status = state
437        .qe_r_ref
438        .handle_idmcredentialupdate(
439            cu_session_token,
440            CURequest::PasskeyRemove(passkey.uuid),
441            kopid.eventid,
442        )
443        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
444        .await?;
445
446    Ok(get_cu_partial_response(cu_status))
447}
448
449pub(crate) async fn finish_passkey(
450    State(state): State<ServerState>,
451    Extension(kopid): Extension<KOpId>,
452    DomainInfo(domain_info): DomainInfo,
453    jar: CookieJar,
454    Form(passkey_create): Form<PasskeyCreateForm>,
455) -> axum::response::Result<Response> {
456    let cu_session_token = get_cu_session(&jar).await?;
457
458    match serde_json::from_str(passkey_create.creation_data.as_str()) {
459        Ok(creation_data) => {
460            let cu_request = match passkey_create.class {
461                PasskeyClass::Any => CURequest::PasskeyFinish(passkey_create.name, creation_data),
462                PasskeyClass::Attested => {
463                    CURequest::AttestedPasskeyFinish(passkey_create.name, creation_data)
464                }
465            };
466
467            let cu_status = state
468                .qe_r_ref
469                .handle_idmcredentialupdate(cu_session_token, cu_request, kopid.eventid)
470                .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
471                .await?;
472
473            Ok(get_cu_partial_response(cu_status))
474        }
475        Err(e) => {
476            error!("Bad request for passkey creation: {e}");
477            Ok((
478                StatusCode::UNPROCESSABLE_ENTITY,
479                HtmxError::new(&kopid, OperationError::Backend, domain_info).into_response(),
480            )
481                .into_response())
482        }
483    }
484}
485
486pub(crate) async fn view_new_passkey(
487    State(state): State<ServerState>,
488    Extension(kopid): Extension<KOpId>,
489    HxRequest(_hx_request): HxRequest,
490    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
491    DomainInfo(domain_info): DomainInfo,
492    jar: CookieJar,
493    Form(init_form): Form<PasskeyInitForm>,
494) -> axum::response::Result<Response> {
495    let cu_session_token = get_cu_session(&jar).await?;
496    let cu_req = match init_form.class {
497        PasskeyClass::Any => CURequest::PasskeyInit,
498        PasskeyClass::Attested => CURequest::AttestedPasskeyInit,
499    };
500
501    let cu_status: CUStatus = state
502        .qe_r_ref
503        .handle_idmcredentialupdate(cu_session_token, cu_req, kopid.eventid)
504        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
505        .await?;
506
507    let response = match cu_status.mfaregstate {
508        CURegState::Passkey(chal) | CURegState::AttestedPasskey(chal) => {
509            if let Ok(challenge) = serde_json::to_string(&chal) {
510                AddPasskeyPartial {
511                    challenge,
512                    class: init_form.class,
513                }
514                .into_response()
515            } else {
516                UnrecoverableErrorView {
517                    err_code: OperationError::UI0001ChallengeSerialisation,
518                    operation_id: kopid.eventid,
519                    domain_info,
520                }
521                .into_response()
522            }
523        }
524        _ => UnrecoverableErrorView {
525            err_code: OperationError::UI0002InvalidState,
526            operation_id: kopid.eventid,
527            domain_info,
528        }
529        .into_response(),
530    };
531
532    let passkey_init_trigger =
533        HxResponseTrigger::after_swap([HxEvent::new("addPasskeySwapped".to_string())]);
534    Ok((
535        passkey_init_trigger,
536        HxPushUrl(Uri::from_static("/ui/reset/add_passkey")),
537        response,
538    )
539        .into_response())
540}
541
542pub(crate) async fn view_new_totp(
543    State(state): State<ServerState>,
544    Extension(kopid): Extension<KOpId>,
545    DomainInfo(domain_info): DomainInfo,
546    jar: CookieJar,
547) -> axum::response::Result<Response> {
548    let cu_session_token = get_cu_session(&jar).await?;
549    let push_url = HxPushUrl(Uri::from_static("/ui/reset/add_totp"));
550
551    let cu_status = state
552        .qe_r_ref
553        .handle_idmcredentialupdate(cu_session_token, CURequest::TotpGenerate, kopid.eventid)
554        .await
555        // TODO: better handling for invalid mfaregstate state, can be invalid if certain mfa flows were interrupted
556        // TODO: We should maybe automatically cancel the other MFA reg
557        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
558
559    let partial = if let CURegState::TotpCheck(secret) = cu_status.mfaregstate {
560        let uri = secret.to_uri();
561        let svg = match QrCode::new(uri.as_str()) {
562            Ok(qr) => qr.render::<svg::Color>().build(),
563            Err(qr_err) => {
564                error!("Failed to create TOTP QR code: {qr_err}");
565                "QR Code Generation Failed".to_string()
566            }
567        };
568
569        AddTotpPartial {
570            totp_init: Some(TotpInit {
571                secret: secret.get_secret(),
572                qr_code_svg: svg,
573                steps: secret.step,
574                digits: secret.digits,
575                algo: secret.algo,
576                uri,
577            }),
578            totp_name: Default::default(),
579            totp_value: Default::default(),
580            check: TotpCheck::default(),
581        }
582    } else {
583        return Err(ErrorResponse::from(HtmxError::new(
584            &kopid,
585            OperationError::CannotStartMFADuringOngoingMFASession,
586            domain_info,
587        )));
588    };
589
590    Ok((push_url, partial).into_response())
591}
592
593pub(crate) async fn add_totp(
594    State(state): State<ServerState>,
595    Extension(kopid): Extension<KOpId>,
596    HxRequest(_hx_request): HxRequest,
597    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
598    DomainInfo(domain_info): DomainInfo,
599    jar: CookieJar,
600    new_totp_form: Form<NewTotp>,
601) -> axum::response::Result<Response> {
602    let cu_session_token = get_cu_session(&jar).await?;
603
604    let check_totpcode = u32::from_str(&new_totp_form.check_totpcode).unwrap_or_default();
605    let swapped_handler_trigger =
606        HxResponseTrigger::after_swap([HxEvent::new("addTotpSwapped".to_string())]);
607
608    // If the user has not provided a name or added only spaces we exit early
609    if new_totp_form.name.trim().is_empty() {
610        return Ok((
611            swapped_handler_trigger,
612            AddTotpPartial {
613                totp_init: None,
614                totp_name: "".into(),
615                totp_value: new_totp_form.check_totpcode.clone(),
616                check: TotpCheck {
617                    bad_name: true,
618                    ..Default::default()
619                },
620            },
621        )
622            .into_response());
623    }
624
625    let cu_status = if new_totp_form.ignore_broken_app {
626        // Cope with SHA1 apps because the user has intended to do so, their totp code was already verified
627        state.qe_r_ref.handle_idmcredentialupdate(
628            cu_session_token,
629            CURequest::TotpAcceptSha1,
630            kopid.eventid,
631        )
632    } else {
633        // Validate totp code example
634        state.qe_r_ref.handle_idmcredentialupdate(
635            cu_session_token,
636            CURequest::TotpVerify(check_totpcode, new_totp_form.name.clone()),
637            kopid.eventid,
638        )
639    }
640    .await
641    .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
642
643    let check = match &cu_status.mfaregstate {
644        CURegState::None => return Ok(get_cu_partial_response(cu_status)),
645        CURegState::TotpTryAgain => TotpCheck {
646            wrong_code: true,
647            ..Default::default()
648        },
649        CURegState::TotpNameTryAgain(val) => TotpCheck {
650            taken_name: Some(val.clone()),
651            ..Default::default()
652        },
653        CURegState::TotpInvalidSha1 => TotpCheck {
654            broken_app: true,
655            ..Default::default()
656        },
657        CURegState::TotpCheck(_)
658        | CURegState::BackupCodes(_)
659        | CURegState::Passkey(_)
660        | CURegState::AttestedPasskey(_) => {
661            return Err(ErrorResponse::from(HtmxError::new(
662                &kopid,
663                OperationError::InvalidState,
664                domain_info,
665            )))
666        }
667    };
668
669    let check_totpcode = if check.wrong_code {
670        String::default()
671    } else {
672        new_totp_form.check_totpcode.clone()
673    };
674
675    Ok((
676        swapped_handler_trigger,
677        AddTotpPartial {
678            totp_init: None,
679            totp_name: new_totp_form.name.clone(),
680            totp_value: check_totpcode,
681            check,
682        },
683    )
684        .into_response())
685}
686
687pub(crate) async fn view_new_pwd(
688    State(state): State<ServerState>,
689    Extension(kopid): Extension<KOpId>,
690    HxRequest(_hx_request): HxRequest,
691    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
692    DomainInfo(domain_info): DomainInfo,
693    jar: CookieJar,
694    opt_form: Option<Form<NewPassword>>,
695) -> axum::response::Result<Response> {
696    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
697    let swapped_handler_trigger =
698        HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]);
699
700    let new_passwords = match opt_form {
701        None => {
702            return Ok((
703                swapped_handler_trigger,
704                AddPasswordPartial {
705                    check_res: PwdCheckResult::Init,
706                },
707            )
708                .into_response());
709        }
710        Some(Form(new_passwords)) => new_passwords,
711    };
712
713    let pwd_equal = new_passwords.new_password == new_passwords.new_password_check;
714    let (warnings, status) = if pwd_equal {
715        let res = state
716            .qe_r_ref
717            .handle_idmcredentialupdate(
718                cu_session_token,
719                CURequest::Password(new_passwords.new_password),
720                kopid.eventid,
721            )
722            .await;
723        match res {
724            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
725            Err(OperationError::PasswordQuality(password_feedback)) => {
726                (password_feedback, StatusCode::UNPROCESSABLE_ENTITY)
727            }
728            Err(operr) => {
729                return Err(ErrorResponse::from(HtmxError::new(
730                    &kopid,
731                    operr,
732                    domain_info,
733                )))
734            }
735        }
736    } else {
737        (vec![], StatusCode::UNPROCESSABLE_ENTITY)
738    };
739
740    let check_res = PwdCheckResult::Failure {
741        pwd_equal,
742        warnings,
743    };
744
745    Ok((
746        status,
747        swapped_handler_trigger,
748        HxPushUrl(Uri::from_static("/ui/reset/change_password")),
749        AddPasswordPartial { check_res },
750    )
751        .into_response())
752}
753
754// Allows authenticated users to get a (cred update) or (reauth into cred update) page, depending on whether they have read write access or not respectively.
755pub(crate) async fn view_self_reset_get(
756    State(state): State<ServerState>,
757    Extension(kopid): Extension<KOpId>,
758    HxRequest(_hx_request): HxRequest,
759    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
760    DomainInfo(domain_info): DomainInfo,
761    mut jar: CookieJar,
762) -> axum::response::Result<Response> {
763    let uat: UserAuthToken = state
764        .qe_r_ref
765        .handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
766        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
767        .await?;
768
769    let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
770    let can_rw = uat.purpose_readwrite_active(time);
771
772    if can_rw {
773        let (cu_session_token, cu_status) = state
774            .qe_w_ref
775            .handle_idmcredentialupdate(client_auth_info, uat.uuid.to_string(), kopid.eventid)
776            .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
777            .await?;
778
779        let cu_resp = get_cu_response(domain_info, cu_status, true);
780
781        jar = add_cu_cookie(jar, &state, cu_session_token);
782        Ok((jar, cu_resp).into_response())
783    } else {
784        let display_ctx = LoginDisplayCtx {
785            domain_info,
786            oauth2: None,
787            reauth: Some(Reauth {
788                username: uat.spn,
789                purpose: ReauthPurpose::ProfileSettings,
790            }),
791            error: None,
792        };
793
794        Ok(super::login::view_reauth_get(
795            state,
796            client_auth_info,
797            kopid,
798            jar,
799            Urls::UpdateCredentials.as_ref(),
800            display_ctx,
801        )
802        .await)
803    }
804}
805
806// Adds the COOKIE_CU_SESSION_TOKEN to the jar and returns the result
807fn add_cu_cookie(
808    jar: CookieJar,
809    state: &ServerState,
810    cu_session_token: CUSessionToken,
811) -> CookieJar {
812    let mut token_cookie =
813        cookies::make_unsigned(state, COOKIE_CU_SESSION_TOKEN, cu_session_token.token);
814    token_cookie.set_same_site(SameSite::Strict);
815    jar.add(token_cookie)
816}
817
818pub(crate) async fn view_set_unixcred(
819    State(state): State<ServerState>,
820    Extension(kopid): Extension<KOpId>,
821    HxRequest(_hx_request): HxRequest,
822    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
823    DomainInfo(domain_info): DomainInfo,
824    jar: CookieJar,
825    opt_form: Option<Form<NewPassword>>,
826) -> axum::response::Result<Response> {
827    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
828    let swapped_handler_trigger =
829        HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]);
830
831    let new_passwords = match opt_form {
832        None => {
833            return Ok((
834                swapped_handler_trigger,
835                SetUnixCredPartial {
836                    check_res: PwdCheckResult::Init,
837                },
838            )
839                .into_response());
840        }
841        Some(Form(new_passwords)) => new_passwords,
842    };
843
844    let pwd_equal = new_passwords.new_password == new_passwords.new_password_check;
845    let (warnings, status) = if pwd_equal {
846        let res = state
847            .qe_r_ref
848            .handle_idmcredentialupdate(
849                cu_session_token,
850                CURequest::UnixPassword(new_passwords.new_password),
851                kopid.eventid,
852            )
853            .await;
854        match res {
855            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
856            Err(OperationError::PasswordQuality(password_feedback)) => {
857                (password_feedback, StatusCode::UNPROCESSABLE_ENTITY)
858            }
859            Err(operr) => {
860                return Err(ErrorResponse::from(HtmxError::new(
861                    &kopid,
862                    operr,
863                    domain_info,
864                )))
865            }
866        }
867    } else {
868        (vec![], StatusCode::UNPROCESSABLE_ENTITY)
869    };
870
871    let check_res = PwdCheckResult::Failure {
872        pwd_equal,
873        warnings,
874    };
875
876    Ok((
877        status,
878        swapped_handler_trigger,
879        HxPushUrl(Uri::from_static("/ui/reset/set_unixcred")),
880        AddPasswordPartial { check_res },
881    )
882        .into_response())
883}
884
885struct AddSshPublicKeyError {
886    key: Option<String>,
887    title: Option<String>,
888}
889
890pub(crate) async fn view_add_ssh_publickey(
891    State(state): State<ServerState>,
892    Extension(kopid): Extension<KOpId>,
893    HxRequest(_hx_request): HxRequest,
894    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
895    DomainInfo(domain_info): DomainInfo,
896    jar: CookieJar,
897    opt_form: Option<Form<NewPublicKey>>,
898) -> axum::response::Result<Response> {
899    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
900
901    let new_key = match opt_form {
902        None => {
903            return Ok((AddSshPublicKeyPartial {
904                title_error: None,
905                key_error: None,
906                key_value: None,
907            },)
908                .into_response());
909        }
910        Some(Form(new_key)) => new_key,
911    };
912
913    let (
914        AddSshPublicKeyError {
915            key: key_error,
916            title: title_error,
917        },
918        status,
919    ) = {
920        let publickey = match SshPublicKey::from_string(&new_key.key) {
921            Err(_) => {
922                return Ok((AddSshPublicKeyPartial {
923                    title_error: None,
924                    key_error: Some("Key cannot be parsed".to_string()),
925                    key_value: Some(new_key.key),
926                },)
927                    .into_response());
928            }
929            Ok(publickey) => publickey,
930        };
931        let res = state
932            .qe_r_ref
933            .handle_idmcredentialupdate(
934                cu_session_token,
935                CURequest::SshPublicKey(new_key.title, publickey),
936                kopid.eventid,
937            )
938            .await;
939        match res {
940            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
941            Err(e @ (OperationError::InvalidLabel | OperationError::DuplicateLabel)) => (
942                AddSshPublicKeyError {
943                    title: Some(e.to_string()),
944                    key: None,
945                },
946                StatusCode::UNPROCESSABLE_ENTITY,
947            ),
948            Err(e @ OperationError::DuplicateKey) => (
949                AddSshPublicKeyError {
950                    key: Some(e.to_string()),
951                    title: None,
952                },
953                StatusCode::UNPROCESSABLE_ENTITY,
954            ),
955            Err(operr) => {
956                return Err(ErrorResponse::from(HtmxError::new(
957                    &kopid,
958                    operr,
959                    domain_info,
960                )))
961            }
962        }
963    };
964
965    Ok((
966        status,
967        HxPushUrl(Uri::from_static("/ui/reset/add_ssh_publickey")),
968        AddSshPublicKeyPartial {
969            title_error,
970            key_error,
971            key_value: Some(new_key.key),
972        },
973    )
974        .into_response())
975}
976
977pub(crate) async fn view_reset_get(
978    State(state): State<ServerState>,
979    Extension(kopid): Extension<KOpId>,
980    HxRequest(_hx_request): HxRequest,
981    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
982    DomainInfo(domain_info): DomainInfo,
983    Query(params): Query<ResetTokenParam>,
984    mut jar: CookieJar,
985) -> axum::response::Result<Response> {
986    let push_url = HxPushUrl(Uri::from_static(Urls::CredReset.as_ref()));
987    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
988    let is_logged_in = state
989        .qe_r_ref
990        .handle_auth_valid(client_auth_info.clone(), kopid.eventid)
991        .await
992        .is_ok();
993
994    if let Some(cookie) = cookie {
995        // We already have a session
996        let cu_session_token = cookie.value();
997        let cu_session_token = CUSessionToken {
998            token: cu_session_token.into(),
999        };
1000        let cu_status = match state
1001            .qe_r_ref
1002            .handle_idmcredentialupdatestatus(cu_session_token, kopid.eventid)
1003            .await
1004        {
1005            Ok(cu_status) => cu_status,
1006            Err(
1007                OperationError::SessionExpired
1008                | OperationError::InvalidSessionState
1009                | OperationError::InvalidState,
1010            ) => {
1011                // If our previous credential update session expired we want to see the reset form again.
1012                jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
1013
1014                if let Some(token) = params.token {
1015                    let token_uri_string = format!("{}?token={}", Urls::CredReset, token);
1016                    return Ok((jar, Redirect::to(&token_uri_string)).into_response());
1017                }
1018                return Ok((jar, Redirect::to(Urls::CredReset.as_ref())).into_response());
1019            }
1020            Err(op_err) => {
1021                return Ok(HtmxError::new(&kopid, op_err, domain_info.clone()).into_response())
1022            }
1023        };
1024
1025        // CU Session cookie is okay
1026        let cu_resp = get_cu_response(domain_info, cu_status, is_logged_in);
1027        Ok(cu_resp)
1028    } else if let Some(token) = params.token {
1029        // We have a reset token and want to create a new session
1030        match state
1031            .qe_w_ref
1032            .handle_idmcredentialexchangeintent(token, kopid.eventid)
1033            .await
1034        {
1035            Ok((cu_session_token, cu_status)) => {
1036                let cu_resp = get_cu_response(domain_info, cu_status, is_logged_in);
1037
1038                jar = add_cu_cookie(jar, &state, cu_session_token);
1039                Ok((jar, cu_resp).into_response())
1040            }
1041            Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => {
1042                // Reset code expired
1043                Ok((
1044                    push_url,
1045                    ResetCredFormView {
1046                        domain_info,
1047                        wrong_code: true,
1048                    },
1049                )
1050                    .into_response())
1051            }
1052            Err(op_err) => Err(ErrorResponse::from(
1053                HtmxError::new(&kopid, op_err, domain_info).into_response(),
1054            )),
1055        }
1056    } else {
1057        // We don't have any credential, show reset token input form
1058        Ok((
1059            push_url,
1060            ResetCredFormView {
1061                domain_info,
1062                wrong_code: false,
1063            },
1064        )
1065            .into_response())
1066    }
1067}
1068
1069fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
1070    let CUStatus {
1071        ext_cred_portal,
1072        can_commit,
1073        warnings,
1074        passkeys_state,
1075        attested_passkeys_state,
1076        attested_passkeys,
1077        passkeys,
1078        primary_state,
1079        primary,
1080        unixcred_state,
1081        unixcred,
1082        sshkeys_state,
1083        sshkeys,
1084        ..
1085    } = cu_status;
1086
1087    let sshkeyss: BTreeMap<String, SshKey> = sshkeys
1088        .iter()
1089        .map(|(k, v)| {
1090            (
1091                k.clone(),
1092                SshKey {
1093                    key_type: v.clone().key_type,
1094                    key: v.fingerprint().hash,
1095                    comment: v.comment.clone(),
1096                },
1097            )
1098        })
1099        .collect();
1100
1101    CredResetPartialView {
1102        ext_cred_portal,
1103        can_commit,
1104        warnings,
1105        attested_passkeys_state,
1106        passkeys_state,
1107        attested_passkeys,
1108        passkeys,
1109        primary_state,
1110        primary,
1111        unixcred_state,
1112        unixcred,
1113        sshkeys_state,
1114        sshkeys: sshkeyss,
1115    }
1116}
1117
1118fn get_cu_partial_response(cu_status: CUStatus) -> Response {
1119    let credentials_update_partial = get_cu_partial(cu_status);
1120    (
1121        HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())),
1122        HxRetarget("#credentialUpdateDynamicSection".to_string()),
1123        HxReselect("#credentialUpdateDynamicSection".to_string()),
1124        HxReswap(SwapOption::OuterHtml),
1125        credentials_update_partial,
1126    )
1127        .into_response()
1128}
1129
1130fn get_cu_response(
1131    domain_info: DomainInfoRead,
1132    cu_status: CUStatus,
1133    is_logged_in: bool,
1134) -> Response {
1135    let spn = cu_status.spn.clone();
1136    let displayname = cu_status.displayname.clone();
1137    let (username, _domain) = spn.split_once('@').unwrap_or(("", &spn));
1138    let names = format!("{} ({})", displayname, username);
1139    let credentials_update_partial = get_cu_partial(cu_status);
1140
1141    if is_logged_in {
1142        let cred_status_view = CredStatusView {
1143            menu_active_item: ProfileMenuItems::Credentials,
1144            domain_info: domain_info.clone(),
1145            names,
1146            credentials_update_partial,
1147        };
1148
1149        (
1150            HxPushUrl(Uri::from_static(Urls::UpdateCredentials.as_ref())),
1151            ProfileView {
1152                navbar_ctx: NavbarCtx { domain_info },
1153                profile_partial: cred_status_view,
1154            },
1155        )
1156            .into_response()
1157    } else {
1158        (
1159            HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())),
1160            CredResetView {
1161                domain_info,
1162                names,
1163                credentials_update_partial,
1164            },
1165        )
1166            .into_response()
1167    }
1168}
1169
1170async fn get_cu_session(jar: &CookieJar) -> Result<CUSessionToken, Response> {
1171    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
1172    if let Some(cookie) = cookie {
1173        let cu_session_token = cookie.value();
1174        let cu_session_token = CUSessionToken {
1175            token: cu_session_token.into(),
1176        };
1177        Ok(cu_session_token)
1178    } else {
1179        Err((
1180            StatusCode::FORBIDDEN,
1181            Redirect::to(Urls::CredReset.as_ref()),
1182        )
1183            .into_response())
1184    }
1185}