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 = client_auth_info
765        .pre_validated_uat()
766        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
767
768    let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
769    let can_rw = uat.purpose_readwrite_active(time);
770
771    if can_rw {
772        let (cu_session_token, cu_status) = state
773            .qe_w_ref
774            .handle_idmcredentialupdate(
775                client_auth_info.clone(),
776                uat.uuid.to_string(),
777                kopid.eventid,
778            )
779            .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
780            .await?;
781
782        let cu_resp = get_cu_response(domain_info, cu_status, true);
783
784        jar = add_cu_cookie(jar, &state, cu_session_token);
785        Ok((jar, cu_resp).into_response())
786    } else {
787        let display_ctx = LoginDisplayCtx {
788            domain_info,
789            oauth2: None,
790            reauth: Some(Reauth {
791                username: uat.spn.clone(),
792                purpose: ReauthPurpose::ProfileSettings,
793            }),
794            error: None,
795        };
796
797        Ok(super::login::view_reauth_get(
798            state,
799            client_auth_info,
800            kopid,
801            jar,
802            Urls::UpdateCredentials.as_ref(),
803            display_ctx,
804        )
805        .await)
806    }
807}
808
809// Adds the COOKIE_CU_SESSION_TOKEN to the jar and returns the result
810fn add_cu_cookie(
811    jar: CookieJar,
812    state: &ServerState,
813    cu_session_token: CUSessionToken,
814) -> CookieJar {
815    let mut token_cookie =
816        cookies::make_unsigned(state, COOKIE_CU_SESSION_TOKEN, cu_session_token.token);
817    token_cookie.set_same_site(SameSite::Strict);
818    jar.add(token_cookie)
819}
820
821pub(crate) async fn view_set_unixcred(
822    State(state): State<ServerState>,
823    Extension(kopid): Extension<KOpId>,
824    HxRequest(_hx_request): HxRequest,
825    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
826    DomainInfo(domain_info): DomainInfo,
827    jar: CookieJar,
828    opt_form: Option<Form<NewPassword>>,
829) -> axum::response::Result<Response> {
830    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
831    let swapped_handler_trigger =
832        HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]);
833
834    let new_passwords = match opt_form {
835        None => {
836            return Ok((
837                swapped_handler_trigger,
838                SetUnixCredPartial {
839                    check_res: PwdCheckResult::Init,
840                },
841            )
842                .into_response());
843        }
844        Some(Form(new_passwords)) => new_passwords,
845    };
846
847    let pwd_equal = new_passwords.new_password == new_passwords.new_password_check;
848    let (warnings, status) = if pwd_equal {
849        let res = state
850            .qe_r_ref
851            .handle_idmcredentialupdate(
852                cu_session_token,
853                CURequest::UnixPassword(new_passwords.new_password),
854                kopid.eventid,
855            )
856            .await;
857        match res {
858            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
859            Err(OperationError::PasswordQuality(password_feedback)) => {
860                (password_feedback, StatusCode::UNPROCESSABLE_ENTITY)
861            }
862            Err(operr) => {
863                return Err(ErrorResponse::from(HtmxError::new(
864                    &kopid,
865                    operr,
866                    domain_info,
867                )))
868            }
869        }
870    } else {
871        (vec![], StatusCode::UNPROCESSABLE_ENTITY)
872    };
873
874    let check_res = PwdCheckResult::Failure {
875        pwd_equal,
876        warnings,
877    };
878
879    Ok((
880        status,
881        swapped_handler_trigger,
882        HxPushUrl(Uri::from_static("/ui/reset/set_unixcred")),
883        SetUnixCredPartial { check_res },
884    )
885        .into_response())
886}
887
888struct AddSshPublicKeyError {
889    key: Option<String>,
890    title: Option<String>,
891}
892
893pub(crate) async fn view_add_ssh_publickey(
894    State(state): State<ServerState>,
895    Extension(kopid): Extension<KOpId>,
896    HxRequest(_hx_request): HxRequest,
897    VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
898    DomainInfo(domain_info): DomainInfo,
899    jar: CookieJar,
900    opt_form: Option<Form<NewPublicKey>>,
901) -> axum::response::Result<Response> {
902    let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
903
904    let new_key = match opt_form {
905        None => {
906            return Ok((AddSshPublicKeyPartial {
907                key_title: None,
908                title_error: None,
909                key_value: None,
910                key_error: None,
911            },)
912                .into_response());
913        }
914        Some(Form(new_key)) => new_key,
915    };
916
917    let (
918        AddSshPublicKeyError {
919            key: key_error,
920            title: title_error,
921        },
922        status,
923    ) = {
924        let publickey = match SshPublicKey::from_string(&new_key.key) {
925            Err(_) => {
926                return Ok((AddSshPublicKeyPartial {
927                    key_title: Some(new_key.title),
928                    title_error: None,
929                    key_value: Some(new_key.key),
930                    key_error: Some("Key cannot be parsed".to_string()),
931                },)
932                    .into_response());
933            }
934            Ok(publickey) => publickey,
935        };
936        let res = state
937            .qe_r_ref
938            .handle_idmcredentialupdate(
939                cu_session_token,
940                CURequest::SshPublicKey(new_key.title.clone(), publickey),
941                kopid.eventid,
942            )
943            .await;
944        match res {
945            Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
946            Err(e @ (OperationError::InvalidLabel | OperationError::DuplicateLabel)) => (
947                AddSshPublicKeyError {
948                    title: Some(e.to_string()),
949                    key: None,
950                },
951                StatusCode::UNPROCESSABLE_ENTITY,
952            ),
953            Err(e @ OperationError::DuplicateKey) => (
954                AddSshPublicKeyError {
955                    key: Some(e.to_string()),
956                    title: None,
957                },
958                StatusCode::UNPROCESSABLE_ENTITY,
959            ),
960            Err(operr) => {
961                return Err(ErrorResponse::from(HtmxError::new(
962                    &kopid,
963                    operr,
964                    domain_info,
965                )))
966            }
967        }
968    };
969
970    Ok((
971        status,
972        HxPushUrl(Uri::from_static("/ui/reset/add_ssh_publickey")),
973        AddSshPublicKeyPartial {
974            key_title: Some(new_key.title),
975            title_error,
976            key_error,
977            key_value: Some(new_key.key),
978        },
979    )
980        .into_response())
981}
982
983pub(crate) async fn view_reset_get(
984    State(state): State<ServerState>,
985    Extension(kopid): Extension<KOpId>,
986    HxRequest(_hx_request): HxRequest,
987    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
988    DomainInfo(domain_info): DomainInfo,
989    Query(params): Query<ResetTokenParam>,
990    mut jar: CookieJar,
991) -> axum::response::Result<Response> {
992    let push_url = HxPushUrl(Uri::from_static(Urls::CredReset.as_ref()));
993    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
994    let is_logged_in = state
995        .qe_r_ref
996        .handle_auth_valid(client_auth_info.clone(), kopid.eventid)
997        .await
998        .is_ok();
999
1000    if let Some(cookie) = cookie {
1001        // We already have a session
1002        let cu_session_token = cookie.value();
1003        let cu_session_token = CUSessionToken {
1004            token: cu_session_token.into(),
1005        };
1006        let cu_status = match state
1007            .qe_r_ref
1008            .handle_idmcredentialupdatestatus(cu_session_token, kopid.eventid)
1009            .await
1010        {
1011            Ok(cu_status) => cu_status,
1012            Err(
1013                OperationError::SessionExpired
1014                | OperationError::InvalidSessionState
1015                | OperationError::InvalidState,
1016            ) => {
1017                // If our previous credential update session expired we want to see the reset form again.
1018                jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
1019
1020                if let Some(token) = params.token {
1021                    let token_uri_string = format!("{}?token={}", Urls::CredReset, token);
1022                    return Ok((jar, Redirect::to(&token_uri_string)).into_response());
1023                }
1024                return Ok((jar, Redirect::to(Urls::CredReset.as_ref())).into_response());
1025            }
1026            Err(op_err) => {
1027                return Ok(HtmxError::new(&kopid, op_err, domain_info.clone()).into_response())
1028            }
1029        };
1030
1031        // CU Session cookie is okay
1032        let cu_resp = get_cu_response(domain_info, cu_status, is_logged_in);
1033        Ok(cu_resp)
1034    } else if let Some(token) = params.token {
1035        // We have a reset token and want to create a new session
1036        match state
1037            .qe_w_ref
1038            .handle_idmcredentialexchangeintent(token, kopid.eventid)
1039            .await
1040        {
1041            Ok((cu_session_token, cu_status)) => {
1042                let cu_resp = get_cu_response(domain_info, cu_status, is_logged_in);
1043
1044                jar = add_cu_cookie(jar, &state, cu_session_token);
1045                Ok((jar, cu_resp).into_response())
1046            }
1047            Err(OperationError::SessionExpired) | Err(OperationError::Wait(_)) => {
1048                // Reset code expired
1049                Ok((
1050                    push_url,
1051                    ResetCredFormView {
1052                        domain_info,
1053                        wrong_code: true,
1054                    },
1055                )
1056                    .into_response())
1057            }
1058            Err(op_err) => Err(ErrorResponse::from(
1059                HtmxError::new(&kopid, op_err, domain_info).into_response(),
1060            )),
1061        }
1062    } else {
1063        // We don't have any credential, show reset token input form
1064        Ok((
1065            push_url,
1066            ResetCredFormView {
1067                domain_info,
1068                wrong_code: false,
1069            },
1070        )
1071            .into_response())
1072    }
1073}
1074
1075fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
1076    let CUStatus {
1077        ext_cred_portal,
1078        can_commit,
1079        warnings,
1080        passkeys_state,
1081        attested_passkeys_state,
1082        attested_passkeys,
1083        passkeys,
1084        primary_state,
1085        primary,
1086        unixcred_state,
1087        unixcred,
1088        sshkeys_state,
1089        sshkeys,
1090        ..
1091    } = cu_status;
1092
1093    let sshkeyss: BTreeMap<String, SshKey> = sshkeys
1094        .iter()
1095        .map(|(k, v)| {
1096            (
1097                k.clone(),
1098                SshKey {
1099                    key_type: v.clone().key_type,
1100                    key: v.fingerprint().hash,
1101                    comment: v.comment.clone(),
1102                },
1103            )
1104        })
1105        .collect();
1106
1107    CredResetPartialView {
1108        ext_cred_portal,
1109        can_commit,
1110        warnings,
1111        attested_passkeys_state,
1112        passkeys_state,
1113        attested_passkeys,
1114        passkeys,
1115        primary_state,
1116        primary,
1117        unixcred_state,
1118        unixcred,
1119        sshkeys_state,
1120        sshkeys: sshkeyss,
1121    }
1122}
1123
1124fn get_cu_partial_response(cu_status: CUStatus) -> Response {
1125    let credentials_update_partial = get_cu_partial(cu_status);
1126    (
1127        HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())),
1128        HxRetarget("#credentialUpdateDynamicSection".to_string()),
1129        HxReselect("#credentialUpdateDynamicSection".to_string()),
1130        HxReswap(SwapOption::OuterHtml),
1131        credentials_update_partial,
1132    )
1133        .into_response()
1134}
1135
1136fn get_cu_response(
1137    domain_info: DomainInfoRead,
1138    cu_status: CUStatus,
1139    is_logged_in: bool,
1140) -> Response {
1141    let spn = cu_status.spn.clone();
1142    let displayname = cu_status.displayname.clone();
1143    let (username, _domain) = spn.split_once('@').unwrap_or(("", &spn));
1144    let names = format!("{displayname} ({username})");
1145    let credentials_update_partial = get_cu_partial(cu_status);
1146
1147    if is_logged_in {
1148        let cred_status_view = CredStatusView {
1149            menu_active_item: ProfileMenuItems::Credentials,
1150            domain_info: domain_info.clone(),
1151            names,
1152            credentials_update_partial,
1153        };
1154
1155        (
1156            HxPushUrl(Uri::from_static(Urls::UpdateCredentials.as_ref())),
1157            ProfileView {
1158                navbar_ctx: NavbarCtx { domain_info },
1159                profile_partial: cred_status_view,
1160            },
1161        )
1162            .into_response()
1163    } else {
1164        (
1165            HxPushUrl(Uri::from_static(Urls::CredReset.as_ref())),
1166            CredResetView {
1167                domain_info,
1168                names,
1169                credentials_update_partial,
1170            },
1171        )
1172            .into_response()
1173    }
1174}
1175
1176async fn get_cu_session(jar: &CookieJar) -> Result<CUSessionToken, Response> {
1177    let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
1178    if let Some(cookie) = cookie {
1179        let cu_session_token = cookie.value();
1180        let cu_session_token = CUSessionToken {
1181            token: cu_session_token.into(),
1182        };
1183        Ok(cu_session_token)
1184    } else {
1185        Err((
1186            StatusCode::FORBIDDEN,
1187            Redirect::to(Urls::CredReset.as_ref()),
1188        )
1189            .into_response())
1190    }
1191}