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