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