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)]
102pub(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 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
243async 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 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 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 .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 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 state.qe_r_ref.handle_idmcredentialupdate(
629 cu_session_token,
630 CURequest::TotpAcceptSha1,
631 kopid.eventid,
632 )
633 } else {
634 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
755pub(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
809fn 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 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 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 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 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 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 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}