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