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