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