1use argon2::Argon2;
2use argon2::password_hash::{PasswordHasher, SaltString};
3use base64::Engine;
4use diesel::{BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
5use diesel_async::RunQueryDsl;
6use rand::distributions::DistString;
7use tonic::Code;
8use tonic_types::{ErrorDetails, StatusExt};
9
10use crate::cedar::Action;
11use crate::http_ext::RequestExt;
12use crate::models::{
13 EmailRegistrationRequest, EmailRegistrationRequestId, MfaRecoveryCode, MfaRecoveryCodeId, MfaTotpCredential,
14 MfaTotpCredentialId, MfaTotpRegistrationSession, MfaWebauthnAuthenticationSession, MfaWebauthnCredential,
15 MfaWebauthnCredentialId, MfaWebauthnRegistrationSession, User, UserEmail, UserId,
16};
17use crate::operations::Operation;
18use crate::schema::{
19 email_registration_requests, mfa_recovery_codes, mfa_totp_credentials, mfa_totp_reg_sessions,
20 mfa_webauthn_auth_sessions, mfa_webauthn_credentials, mfa_webauthn_reg_sessions, user_emails, users,
21};
22use crate::std_ext::{DisplayExt, OptionExt, ResultExt};
23use crate::totp::TotpError;
24use crate::{CoreConfig, common, emails, totp};
25
26impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::GetUserRequest> {
27 type Principal = User;
28 type Resource = User;
29 type Response = pb::scufflecloud::core::v1::User;
30
31 const ACTION: Action = Action::GetUser;
32 const TRANSACTION: bool = false;
33
34 async fn load_principal(
35 &mut self,
36 _conn: &mut diesel_async::AsyncPgConnection,
37 ) -> Result<Self::Principal, tonic::Status> {
38 let global = &self.global::<G>()?;
39 let session = self.session_or_err()?;
40 common::get_user_by_id(global, session.user_id).await
41 }
42
43 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
44 let global = &self.global::<G>()?;
45 let user_id: UserId = self
46 .get_ref()
47 .id
48 .parse()
49 .into_tonic_err_with_field_violation("id", "invalid ID")?;
50
51 common::get_user_by_id(global, user_id).await
52 }
53
54 async fn execute(
55 self,
56 _conn: &mut diesel_async::AsyncPgConnection,
57 _principal: Self::Principal,
58 resource: Self::Resource,
59 ) -> Result<Self::Response, tonic::Status> {
60 Ok(resource.into())
61 }
62}
63
64impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::UpdateUserRequest> {
65 type Principal = User;
66 type Resource = User;
67 type Response = pb::scufflecloud::core::v1::User;
68
69 const ACTION: Action = Action::UpdateUser;
70
71 async fn load_principal(
72 &mut self,
73 _conn: &mut diesel_async::AsyncPgConnection,
74 ) -> Result<Self::Principal, tonic::Status> {
75 let global = &self.global::<G>()?;
76 let session = self.session_or_err()?;
77 common::get_user_by_id(global, session.user_id).await
78 }
79
80 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
81 let user_id: UserId = self
82 .get_ref()
83 .id
84 .parse()
85 .into_tonic_err_with_field_violation("id", "invalid ID")?;
86
87 common::get_user_by_id_in_tx(conn, user_id).await
88 }
89
90 async fn execute(
91 self,
92 conn: &mut diesel_async::AsyncPgConnection,
93 _principal: Self::Principal,
94 mut resource: Self::Resource,
95 ) -> Result<Self::Response, tonic::Status> {
96 let payload = self.into_inner();
97
98 if let Some(password_update) = payload.password {
99 if let Some(password_hash) = &resource.password_hash {
101 common::verify_password(password_hash, &password_update.current_password.require("current_password")?)?;
102 }
103
104 let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
105 let new_hash = Argon2::default()
106 .hash_password(password_update.new_password.as_bytes(), &salt)
107 .into_tonic_internal_err("failed to hash password")?
108 .to_string();
109
110 resource = diesel::update(users::dsl::users)
111 .filter(users::dsl::id.eq(resource.id))
112 .set(users::dsl::password_hash.eq(&new_hash))
113 .returning(User::as_returning())
114 .get_result::<User>(conn)
115 .await
116 .into_tonic_internal_err("failed to update user password")?;
117 }
118
119 if let Some(names_update) = payload.names {
120 resource = diesel::update(users::dsl::users)
121 .filter(users::dsl::id.eq(resource.id))
122 .set((
123 users::dsl::preferred_name.eq(&names_update.preferred_name),
124 users::dsl::first_name.eq(&names_update.first_name),
125 users::dsl::last_name.eq(&names_update.last_name),
126 ))
127 .returning(User::as_returning())
128 .get_result::<User>(conn)
129 .await
130 .into_tonic_internal_err("failed to update user password")?;
131 }
132
133 if let Some(primary_email_update) = payload.primary_email {
134 let email = common::normalize_email(&primary_email_update.primary_email);
135
136 let email = user_emails::dsl::user_emails
137 .filter(
138 user_emails::dsl::email
139 .eq(&email)
140 .and(user_emails::dsl::user_id.eq(resource.id)),
141 )
142 .select(user_emails::dsl::email)
143 .first::<String>(conn)
144 .await
145 .optional()
146 .into_tonic_internal_err("failed to query user email")?
147 .into_tonic_not_found("user email not found")?;
148
149 resource = diesel::update(users::dsl::users)
150 .filter(users::dsl::id.eq(resource.id))
151 .set(users::dsl::primary_email.eq(&email))
152 .returning(User::as_returning())
153 .get_result::<User>(conn)
154 .await
155 .into_tonic_internal_err("failed to update user password")?;
156 }
157
158 Ok(resource.into())
159 }
160}
161
162impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListUserEmailsRequest> {
163 type Principal = User;
164 type Resource = User;
165 type Response = pb::scufflecloud::core::v1::UserEmailsList;
166
167 const ACTION: Action = Action::ListUserEmails;
168 const TRANSACTION: bool = false;
169
170 async fn load_principal(
171 &mut self,
172 _conn: &mut diesel_async::AsyncPgConnection,
173 ) -> Result<Self::Principal, tonic::Status> {
174 let global = &self.global::<G>()?;
175 let session = self.session_or_err()?;
176 common::get_user_by_id(global, session.user_id).await
177 }
178
179 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
180 let global = &self.global::<G>()?;
181 let user_id: UserId = self
182 .get_ref()
183 .id
184 .parse()
185 .into_tonic_err_with_field_violation("id", "invalid ID")?;
186
187 common::get_user_by_id(global, user_id).await
188 }
189
190 async fn execute(
191 self,
192 conn: &mut diesel_async::AsyncPgConnection,
193 _principal: Self::Principal,
194 resource: Self::Resource,
195 ) -> Result<Self::Response, tonic::Status> {
196 let emails = user_emails::dsl::user_emails
197 .filter(user_emails::dsl::user_id.eq(resource.id))
198 .select(UserEmail::as_select())
199 .load::<UserEmail>(conn)
200 .await
201 .into_tonic_internal_err("failed to query user emails")?;
202
203 Ok(pb::scufflecloud::core::v1::UserEmailsList {
204 emails: emails.into_iter().map(Into::into).collect(),
205 })
206 }
207}
208
209impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateUserEmailRequest> {
210 type Principal = User;
211 type Resource = UserEmail;
212 type Response = ();
213
214 const ACTION: Action = Action::CreateUserEmail;
215
216 async fn load_principal(
217 &mut self,
218 _conn: &mut diesel_async::AsyncPgConnection,
219 ) -> Result<Self::Principal, tonic::Status> {
220 let global = &self.global::<G>()?;
221 let session = self.session_or_err()?;
222 common::get_user_by_id(global, session.user_id).await
223 }
224
225 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
226 let user_id: UserId = self
227 .get_ref()
228 .id
229 .parse()
230 .into_tonic_err_with_field_violation("id", "invalid ID")?;
231
232 Ok(UserEmail {
233 email: common::normalize_email(&self.get_ref().email),
234 user_id,
235 created_at: chrono::Utc::now(),
236 })
237 }
238
239 async fn execute(
240 self,
241 conn: &mut diesel_async::AsyncPgConnection,
242 _principal: Self::Principal,
243 resource: Self::Resource,
244 ) -> Result<Self::Response, tonic::Status> {
245 let global = &self.global::<G>()?;
246
247 let code = common::generate_random_bytes().into_tonic_internal_err("failed to generate registration code")?;
249 let code_base64 = base64::prelude::BASE64_URL_SAFE.encode(code);
250
251 if user_emails::dsl::user_emails
253 .find(&resource.email)
254 .select(user_emails::dsl::email)
255 .first::<String>(conn)
256 .await
257 .optional()
258 .into_tonic_internal_err("failed to query database")?
259 .is_some()
260 {
261 return Err(tonic::Status::with_error_details(
262 Code::AlreadyExists,
263 "email is already registered",
264 ErrorDetails::new(),
265 ));
266 }
267
268 let registration_request = EmailRegistrationRequest {
270 id: EmailRegistrationRequestId::new(),
271 user_id: Some(resource.user_id),
272 email: resource.email.clone(),
273 code: code.to_vec(),
274 expires_at: chrono::Utc::now() + global.email_registration_request_timeout(),
275 };
276
277 diesel::insert_into(email_registration_requests::dsl::email_registration_requests)
278 .values(registration_request)
279 .execute(conn)
280 .await
281 .into_tonic_internal_err("failed to insert email registration request")?;
282
283 let email = emails::add_new_email_email(global, resource.email, code_base64)
285 .await
286 .into_tonic_internal_err("failed to render add new email email")?;
287 global
288 .email_service()
289 .send_email(email)
290 .await
291 .into_tonic_internal_err("failed to send add new email email")?;
292
293 Ok(())
294 }
295}
296
297impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateUserEmailRequest> {
298 type Principal = User;
299 type Resource = UserEmail;
300 type Response = pb::scufflecloud::core::v1::UserEmail;
301
302 const ACTION: Action = Action::CreateUserEmail;
303
304 async fn load_principal(
305 &mut self,
306 _conn: &mut diesel_async::AsyncPgConnection,
307 ) -> Result<Self::Principal, tonic::Status> {
308 let global = &self.global::<G>()?;
309 let session = self.session_or_err()?;
310 common::get_user_by_id(global, session.user_id).await
311 }
312
313 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
314 let user_id: UserId = self
315 .get_ref()
316 .id
317 .parse()
318 .into_tonic_err_with_field_violation("id", "invalid ID")?;
319
320 let Some(registration_request) = diesel::delete(email_registration_requests::dsl::email_registration_requests)
322 .filter(
323 email_registration_requests::dsl::code
324 .eq(&self.get_ref().code)
325 .and(email_registration_requests::dsl::user_id.eq(user_id))
326 .and(email_registration_requests::dsl::expires_at.gt(chrono::Utc::now())),
327 )
328 .returning(EmailRegistrationRequest::as_select())
329 .get_result::<EmailRegistrationRequest>(conn)
330 .await
331 .optional()
332 .into_tonic_internal_err("failed to delete email registration request")?
333 else {
334 return Err(tonic::Status::with_error_details(
335 Code::NotFound,
336 "unknown code",
337 ErrorDetails::new(),
338 ));
339 };
340
341 if user_emails::dsl::user_emails
343 .find(®istration_request.email)
344 .select(user_emails::dsl::email)
345 .first::<String>(conn)
346 .await
347 .optional()
348 .into_tonic_internal_err("failed to query user emails")?
349 .is_some()
350 {
351 return Err(tonic::Status::with_error_details(
352 Code::AlreadyExists,
353 "email is already registered",
354 ErrorDetails::new(),
355 ));
356 }
357
358 Ok(UserEmail {
359 email: registration_request.email,
360 user_id,
361 created_at: chrono::Utc::now(),
362 })
363 }
364
365 async fn execute(
366 self,
367 conn: &mut diesel_async::AsyncPgConnection,
368 _principal: Self::Principal,
369 resource: Self::Resource,
370 ) -> Result<Self::Response, tonic::Status> {
371 diesel::insert_into(user_emails::dsl::user_emails)
372 .values(&resource)
373 .execute(conn)
374 .await
375 .into_tonic_internal_err("failed to insert user email")?;
376
377 Ok(resource.into())
378 }
379}
380
381impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteUserEmailRequest> {
382 type Principal = User;
383 type Resource = UserEmail;
384 type Response = pb::scufflecloud::core::v1::UserEmail;
385
386 const ACTION: Action = Action::DeleteUserEmail;
387
388 async fn load_principal(
389 &mut self,
390 _conn: &mut diesel_async::AsyncPgConnection,
391 ) -> Result<Self::Principal, tonic::Status> {
392 let global = &self.global::<G>()?;
393 let session = self.session_or_err()?;
394 common::get_user_by_id(global, session.user_id).await
395 }
396
397 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
398 let user_id: UserId = self
399 .get_ref()
400 .id
401 .parse()
402 .into_tonic_err_with_field_violation("id", "invalid ID")?;
403
404 let user_email = user_emails::dsl::user_emails
405 .filter(
406 user_emails::dsl::user_id
407 .eq(user_id)
408 .and(user_emails::dsl::email.eq(&self.get_ref().email)),
409 )
410 .select(UserEmail::as_select())
411 .first::<UserEmail>(conn)
412 .await
413 .into_tonic_internal_err("failed to delete user email")?;
414
415 Ok(user_email)
416 }
417
418 async fn execute(
419 self,
420 conn: &mut diesel_async::AsyncPgConnection,
421 _principal: Self::Principal,
422 resource: Self::Resource,
423 ) -> Result<Self::Response, tonic::Status> {
424 diesel::delete(user_emails::dsl::user_emails)
425 .filter(user_emails::dsl::email.eq(&resource.email))
426 .execute(conn)
427 .await
428 .into_tonic_internal_err("failed to delete user email")?;
429
430 Ok(resource.into())
431 }
432}
433
434impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateWebauthnCredentialRequest> {
435 type Principal = User;
436 type Resource = User;
437 type Response = pb::scufflecloud::core::v1::CreateWebauthnCredentialResponse;
438
439 const ACTION: Action = Action::CreateWebauthnCredential;
440
441 async fn load_principal(
442 &mut self,
443 _conn: &mut diesel_async::AsyncPgConnection,
444 ) -> Result<Self::Principal, tonic::Status> {
445 let global = &self.global::<G>()?;
446 let session = self.session_or_err()?;
447 common::get_user_by_id(global, session.user_id).await
448 }
449
450 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
451 let user_id: UserId = self
452 .get_ref()
453 .id
454 .parse()
455 .into_tonic_err_with_field_violation("id", "invalid ID")?;
456 common::get_user_by_id_in_tx(conn, user_id).await
457 }
458
459 async fn execute(
460 self,
461 conn: &mut diesel_async::AsyncPgConnection,
462 _principal: Self::Principal,
463 resource: Self::Resource,
464 ) -> Result<Self::Response, tonic::Status> {
465 let global = &self.global::<G>()?;
466
467 let exclude_credentials: Vec<_> = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
468 .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
469 .select(mfa_webauthn_credentials::dsl::credential_id)
470 .load::<Vec<u8>>(conn)
471 .await
472 .into_tonic_internal_err("failed to query webauthn credentials")?
473 .into_iter()
474 .map(webauthn_rs::prelude::CredentialID::from)
475 .collect();
476
477 let user_name = resource.primary_email.unwrap_or(resource.id.to_string());
478 let user_display_name = resource.preferred_name.or_else(|| {
479 if let (Some(first_name), Some(last_name)) = (resource.first_name, resource.last_name) {
480 Some(format!("{} {}", first_name, last_name))
481 } else {
482 None
483 }
484 });
485
486 let (response, state) = global
487 .webauthn()
488 .start_passkey_registration(
489 resource.id.into(),
490 &user_name,
491 user_display_name.as_ref().unwrap_or(&user_name),
492 Some(exclude_credentials),
493 )
494 .into_tonic_internal_err("failed to start webauthn registration")?;
495
496 let reg_session = MfaWebauthnRegistrationSession {
497 user_id: resource.id,
498 state: serde_json::to_value(&state).into_tonic_internal_err("failed to serialize webauthn state")?,
499 expires_at: chrono::Utc::now() + global.mfa_timeout(),
500 };
501
502 let options_json =
503 serde_json::to_string(&response).into_tonic_internal_err("failed to serialize webauthn options")?;
504
505 diesel::insert_into(mfa_webauthn_reg_sessions::dsl::mfa_webauthn_reg_sessions)
506 .values(reg_session)
507 .execute(conn)
508 .await
509 .into_tonic_internal_err("failed to insert webauthn authentication session")?;
510
511 Ok(pb::scufflecloud::core::v1::CreateWebauthnCredentialResponse { options_json })
512 }
513}
514
515impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateWebauthnCredentialRequest> {
516 type Principal = User;
517 type Resource = MfaWebauthnCredential;
518 type Response = pb::scufflecloud::core::v1::WebauthnCredential;
519
520 const ACTION: Action = Action::CompleteCreateWebauthnCredential;
521
522 async fn load_principal(
523 &mut self,
524 _conn: &mut diesel_async::AsyncPgConnection,
525 ) -> Result<Self::Principal, tonic::Status> {
526 let global = &self.global::<G>()?;
527 let session = self.session_or_err()?;
528 common::get_user_by_id(global, session.user_id).await
529 }
530
531 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
532 let global = &self.global::<G>()?;
533
534 let user_id: UserId = self
535 .get_ref()
536 .id
537 .parse()
538 .into_tonic_err_with_field_violation("id", "invalid ID")?;
539
540 let reg = serde_json::from_str(&self.get_ref().response_json)
541 .into_tonic_err_with_field_violation("response_json", "invalid register public key credential")?;
542
543 let state = diesel::delete(mfa_webauthn_reg_sessions::dsl::mfa_webauthn_reg_sessions)
544 .filter(
545 mfa_webauthn_reg_sessions::dsl::user_id
546 .eq(user_id)
547 .and(mfa_webauthn_reg_sessions::dsl::expires_at.gt(chrono::Utc::now())),
548 )
549 .returning(mfa_webauthn_reg_sessions::dsl::state)
550 .get_result::<serde_json::Value>(conn)
551 .await
552 .optional()
553 .into_tonic_internal_err("failed to query webauthn registration session")?
554 .into_tonic_err(
555 tonic::Code::FailedPrecondition,
556 "no webauthn registration session found",
557 ErrorDetails::new(),
558 )?;
559
560 let state: webauthn_rs::prelude::PasskeyRegistration =
561 serde_json::from_value(state).into_tonic_internal_err("failed to deserialize webauthn state")?;
562
563 let credential = global
564 .webauthn()
565 .finish_passkey_registration(®, &state)
566 .into_tonic_internal_err("failed to finish webauthn registration")?;
567
568 Ok(MfaWebauthnCredential {
569 id: MfaWebauthnCredentialId::new(),
570 user_id,
571 name: self.get_ref().name.clone(),
572 credential_id: credential.cred_id().to_vec(),
573 credential: serde_json::to_value(credential).into_tonic_internal_err("failed to serialize credential")?,
574 counter: None,
575 last_used_at: chrono::Utc::now(),
576 })
577 }
578
579 async fn execute(
580 self,
581 conn: &mut diesel_async::AsyncPgConnection,
582 _principal: Self::Principal,
583 resource: Self::Resource,
584 ) -> Result<Self::Response, tonic::Status> {
585 diesel::insert_into(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
586 .values(&resource)
587 .execute(conn)
588 .await
589 .into_tonic_internal_err("failed to insert webauthn credential")?;
590
591 Ok(resource.into())
592 }
593}
594
595impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListWebauthnCredentialsRequest> {
596 type Principal = User;
597 type Resource = User;
598 type Response = pb::scufflecloud::core::v1::WebauthnCredentialsList;
599
600 const ACTION: Action = Action::ListWebauthnCredentials;
601 const TRANSACTION: bool = false;
602
603 async fn load_principal(
604 &mut self,
605 _conn: &mut diesel_async::AsyncPgConnection,
606 ) -> Result<Self::Principal, tonic::Status> {
607 let global = &self.global::<G>()?;
608 let session = self.session_or_err()?;
609 common::get_user_by_id(global, session.user_id).await
610 }
611
612 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
613 let global = &self.global::<G>()?;
614 let user_id: UserId = self
615 .get_ref()
616 .id
617 .parse()
618 .into_tonic_err_with_field_violation("id", "invalid ID")?;
619 common::get_user_by_id(global, user_id).await
620 }
621
622 async fn execute(
623 self,
624 conn: &mut diesel_async::AsyncPgConnection,
625 _principal: Self::Principal,
626 resource: Self::Resource,
627 ) -> Result<Self::Response, tonic::Status> {
628 let credentials = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
629 .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
630 .select(MfaWebauthnCredential::as_select())
631 .load::<MfaWebauthnCredential>(conn)
632 .await
633 .into_tonic_internal_err("failed to query webauthn credentials")?;
634
635 Ok(pb::scufflecloud::core::v1::WebauthnCredentialsList {
636 credentials: credentials.into_iter().map(Into::into).collect(),
637 })
638 }
639}
640
641impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteWebauthnCredentialRequest> {
642 type Principal = User;
643 type Resource = MfaWebauthnCredential;
644 type Response = pb::scufflecloud::core::v1::WebauthnCredential;
645
646 const ACTION: Action = Action::DeleteWebauthnCredential;
647
648 async fn load_principal(
649 &mut self,
650 _conn: &mut diesel_async::AsyncPgConnection,
651 ) -> Result<Self::Principal, tonic::Status> {
652 let global = &self.global::<G>()?;
653 let session = self.session_or_err()?;
654 common::get_user_by_id(global, session.user_id).await
655 }
656
657 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
658 let user_id: UserId = self
659 .get_ref()
660 .user_id
661 .parse()
662 .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
663
664 let credential_id: MfaWebauthnCredentialId = self
665 .get_ref()
666 .id
667 .parse()
668 .into_tonic_err_with_field_violation("id", "invalid ID")?;
669
670 let credential = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
671 .filter(
672 mfa_webauthn_credentials::dsl::id
673 .eq(credential_id)
674 .and(mfa_webauthn_credentials::dsl::user_id.eq(user_id)),
675 )
676 .select(MfaWebauthnCredential::as_select())
677 .first::<MfaWebauthnCredential>(conn)
678 .await
679 .into_tonic_internal_err("failed to delete webauthn credential")?;
680
681 Ok(credential)
682 }
683
684 async fn execute(
685 self,
686 conn: &mut diesel_async::AsyncPgConnection,
687 _principal: Self::Principal,
688 resource: Self::Resource,
689 ) -> Result<Self::Response, tonic::Status> {
690 diesel::delete(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
691 .filter(mfa_webauthn_credentials::dsl::id.eq(resource.id))
692 .execute(conn)
693 .await
694 .into_tonic_internal_err("failed to delete webauthn credential")?;
695
696 Ok(resource.into())
697 }
698}
699
700impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateWebauthnChallengeRequest> {
701 type Principal = User;
702 type Resource = User;
703 type Response = pb::scufflecloud::core::v1::WebauthnChallenge;
704
705 const ACTION: Action = Action::CreateWebauthnChallenge;
706
707 async fn load_principal(
708 &mut self,
709 _conn: &mut diesel_async::AsyncPgConnection,
710 ) -> Result<Self::Principal, tonic::Status> {
711 let global = &self.global::<G>()?;
712 let session = self.session_or_err()?;
713 common::get_user_by_id(global, session.user_id).await
714 }
715
716 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
717 let global = &self.global::<G>()?;
718 let user_id: UserId = self
719 .get_ref()
720 .id
721 .parse()
722 .into_tonic_err_with_field_violation("id", "invalid ID")?;
723 common::get_user_by_id(global, user_id).await
724 }
725
726 async fn execute(
727 self,
728 conn: &mut diesel_async::AsyncPgConnection,
729 _principal: Self::Principal,
730 resource: Self::Resource,
731 ) -> Result<Self::Response, tonic::Status> {
732 let global = &self.global::<G>()?;
733
734 let credentials = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
735 .filter(mfa_webauthn_credentials::dsl::user_id.eq(resource.id))
736 .select(mfa_webauthn_credentials::dsl::credential)
737 .load::<serde_json::Value>(conn)
738 .await
739 .into_tonic_internal_err("failed to query webauthn credentials")?
740 .into_iter()
741 .map(serde_json::from_value)
742 .collect::<Result<Vec<webauthn_rs::prelude::Passkey>, _>>()
743 .into_tonic_internal_err("failed to deserialize webauthn credentials")?;
744
745 let (response, state) = global
746 .webauthn()
747 .start_passkey_authentication(&credentials)
748 .into_tonic_internal_err("failed to start webauthn authentication")?;
749
750 let auth_session = MfaWebauthnAuthenticationSession {
751 user_id: resource.id,
752 state: serde_json::to_value(&state).into_tonic_internal_err("failed to serialize webauthn state")?,
753 expires_at: chrono::Utc::now() + global.mfa_timeout(),
754 };
755
756 let options_json =
757 serde_json::to_string(&response).into_tonic_internal_err("failed to serialize webauthn options")?;
758
759 diesel::insert_into(mfa_webauthn_auth_sessions::dsl::mfa_webauthn_auth_sessions)
760 .values(auth_session)
761 .execute(conn)
762 .await
763 .into_tonic_internal_err("failed to insert webauthn authentication session")?;
764
765 Ok(pb::scufflecloud::core::v1::WebauthnChallenge { options_json })
766 }
767}
768
769impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CreateTotpCredentialRequest> {
770 type Principal = User;
771 type Resource = User;
772 type Response = pb::scufflecloud::core::v1::CreateTotpCredentialResponse;
773
774 const ACTION: Action = Action::CreateTotpCredential;
775
776 async fn load_principal(
777 &mut self,
778 _conn: &mut diesel_async::AsyncPgConnection,
779 ) -> Result<Self::Principal, tonic::Status> {
780 let global = &self.global::<G>()?;
781 let session = self.session_or_err()?;
782 common::get_user_by_id(global, session.user_id).await
783 }
784
785 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
786 let user_id: UserId = self
787 .get_ref()
788 .id
789 .parse()
790 .into_tonic_err_with_field_violation("id", "invalid ID")?;
791 common::get_user_by_id_in_tx(conn, user_id).await
792 }
793
794 async fn execute(
795 self,
796 conn: &mut diesel_async::AsyncPgConnection,
797 _principal: Self::Principal,
798 resource: Self::Resource,
799 ) -> Result<Self::Response, tonic::Status> {
800 let global = &self.global::<G>()?;
801
802 let totp = totp::new_token(resource.primary_email.unwrap_or(resource.id.to_string()))
803 .into_tonic_internal_err("failed to generate TOTP token")?;
804
805 let response = pb::scufflecloud::core::v1::CreateTotpCredentialResponse {
806 secret_url: totp.get_url(),
807 secret_qrcode_png: totp.get_qr_png().into_tonic_internal_err("failed to generate TOTP QR code")?,
808 };
809
810 diesel::insert_into(mfa_totp_reg_sessions::dsl::mfa_totp_reg_sessions)
811 .values(MfaTotpRegistrationSession {
812 user_id: resource.id,
813 secret: totp.secret,
814 expires_at: chrono::Utc::now() + global.mfa_timeout(),
815 })
816 .execute(conn)
817 .await
818 .into_tonic_internal_err("failed to insert TOTP registration session")?;
819
820 Ok(response)
821 }
822}
823
824impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteCreateTotpCredentialRequest> {
825 type Principal = User;
826 type Resource = MfaTotpCredential;
827 type Response = pb::scufflecloud::core::v1::TotpCredential;
828
829 const ACTION: Action = Action::CompleteCreateTotpCredential;
830
831 async fn load_principal(
832 &mut self,
833 _conn: &mut diesel_async::AsyncPgConnection,
834 ) -> Result<Self::Principal, tonic::Status> {
835 let global = &self.global::<G>()?;
836 let session = self.session_or_err()?;
837 common::get_user_by_id(global, session.user_id).await
838 }
839
840 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
841 let user_id: UserId = self
842 .get_ref()
843 .id
844 .parse()
845 .into_tonic_err_with_field_violation("id", "invalid ID")?;
846
847 let secret = mfa_totp_reg_sessions::dsl::mfa_totp_reg_sessions
848 .find(user_id)
849 .filter(mfa_totp_reg_sessions::dsl::expires_at.gt(chrono::Utc::now()))
850 .select(mfa_totp_reg_sessions::dsl::secret)
851 .first::<Vec<u8>>(conn)
852 .await
853 .optional()
854 .into_tonic_internal_err("failed to query TOTP registration session")?
855 .into_tonic_err(
856 tonic::Code::FailedPrecondition,
857 "no TOTP registration session found",
858 ErrorDetails::new(),
859 )?;
860
861 match totp::verify_token(secret.clone(), &self.get_ref().code) {
862 Ok(()) => {}
863 Err(TotpError::InvalidToken) => {
864 return Err(TotpError::InvalidToken.into_tonic_err_with_field_violation("code", "invalid TOTP token"));
865 }
866 Err(e) => return Err(e.into_tonic_internal_err("failed to verify TOTP token")),
867 }
868
869 Ok(MfaTotpCredential {
870 id: MfaTotpCredentialId::new(),
871 user_id,
872 name: self.get_ref().name.clone(),
873 secret,
874 last_used_at: chrono::Utc::now(),
875 })
876 }
877
878 async fn execute(
879 self,
880 conn: &mut diesel_async::AsyncPgConnection,
881 _principal: Self::Principal,
882 resource: Self::Resource,
883 ) -> Result<Self::Response, tonic::Status> {
884 diesel::insert_into(mfa_totp_credentials::dsl::mfa_totp_credentials)
885 .values(&resource)
886 .execute(conn)
887 .await
888 .into_tonic_internal_err("failed to insert TOTP credential")?;
889
890 Ok(resource.into())
891 }
892}
893
894impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListTotpCredentialsRequest> {
895 type Principal = User;
896 type Resource = User;
897 type Response = pb::scufflecloud::core::v1::TotpCredentialsList;
898
899 const ACTION: Action = Action::ListTotpCredentials;
900 const TRANSACTION: bool = false;
901
902 async fn load_principal(
903 &mut self,
904 _conn: &mut diesel_async::AsyncPgConnection,
905 ) -> Result<Self::Principal, tonic::Status> {
906 let global = &self.global::<G>()?;
907 let session = self.session_or_err()?;
908 common::get_user_by_id(global, session.user_id).await
909 }
910
911 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
912 let global = &self.global::<G>()?;
913 let user_id: UserId = self
914 .get_ref()
915 .id
916 .parse()
917 .into_tonic_err_with_field_violation("id", "invalid ID")?;
918 common::get_user_by_id(global, user_id).await
919 }
920
921 async fn execute(
922 self,
923 conn: &mut diesel_async::AsyncPgConnection,
924 _principal: Self::Principal,
925 resource: Self::Resource,
926 ) -> Result<Self::Response, tonic::Status> {
927 let credentials = mfa_totp_credentials::dsl::mfa_totp_credentials
928 .filter(mfa_totp_credentials::dsl::user_id.eq(resource.id))
929 .select(MfaTotpCredential::as_select())
930 .load::<MfaTotpCredential>(conn)
931 .await
932 .into_tonic_internal_err("failed to query TOTP credentials")?;
933
934 Ok(pb::scufflecloud::core::v1::TotpCredentialsList {
935 credentials: credentials.into_iter().map(Into::into).collect(),
936 })
937 }
938}
939
940impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteTotpCredentialRequest> {
941 type Principal = User;
942 type Resource = MfaTotpCredential;
943 type Response = pb::scufflecloud::core::v1::TotpCredential;
944
945 const ACTION: Action = Action::DeleteTotpCredential;
946
947 async fn load_principal(
948 &mut self,
949 _conn: &mut diesel_async::AsyncPgConnection,
950 ) -> Result<Self::Principal, tonic::Status> {
951 let global = &self.global::<G>()?;
952 let session = self.session_or_err()?;
953 common::get_user_by_id(global, session.user_id).await
954 }
955
956 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
957 let user_id: UserId = self
958 .get_ref()
959 .user_id
960 .parse()
961 .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
962
963 let credential_id: MfaTotpCredentialId = self
964 .get_ref()
965 .id
966 .parse()
967 .into_tonic_err_with_field_violation("id", "invalid ID")?;
968
969 let credential = mfa_totp_credentials::dsl::mfa_totp_credentials
970 .filter(
971 mfa_totp_credentials::dsl::id
972 .eq(credential_id)
973 .and(mfa_totp_credentials::dsl::user_id.eq(user_id)),
974 )
975 .select(MfaTotpCredential::as_select())
976 .first::<MfaTotpCredential>(conn)
977 .await
978 .into_tonic_internal_err("failed to delete TOTP credential")?;
979
980 Ok(credential)
981 }
982
983 async fn execute(
984 self,
985 conn: &mut diesel_async::AsyncPgConnection,
986 _principal: Self::Principal,
987 resource: Self::Resource,
988 ) -> Result<Self::Response, tonic::Status> {
989 diesel::delete(mfa_totp_credentials::dsl::mfa_totp_credentials)
990 .filter(mfa_totp_credentials::dsl::id.eq(resource.id))
991 .execute(conn)
992 .await
993 .into_tonic_internal_err("failed to delete TOTP credential")?;
994
995 Ok(resource.into())
996 }
997}
998
999impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::RegenerateRecoveryCodesRequest> {
1000 type Principal = User;
1001 type Resource = User;
1002 type Response = pb::scufflecloud::core::v1::RecoveryCodes;
1003
1004 const ACTION: Action = Action::RegenerateRecoveryCodes;
1005
1006 async fn load_principal(
1007 &mut self,
1008 _conn: &mut diesel_async::AsyncPgConnection,
1009 ) -> Result<Self::Principal, tonic::Status> {
1010 let global = &self.global::<G>()?;
1011 let session = self.session_or_err()?;
1012 common::get_user_by_id(global, session.user_id).await
1013 }
1014
1015 async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
1016 let global = &self.global::<G>()?;
1017 let user_id: UserId = self
1018 .get_ref()
1019 .id
1020 .parse()
1021 .into_tonic_err_with_field_violation("id", "invalid ID")?;
1022 common::get_user_by_id(global, user_id).await
1023 }
1024
1025 async fn execute(
1026 self,
1027 conn: &mut diesel_async::AsyncPgConnection,
1028 _principal: Self::Principal,
1029 resource: Self::Resource,
1030 ) -> Result<Self::Response, tonic::Status> {
1031 let mut rng = rand::rngs::OsRng;
1032 let codes: Vec<_> = (0..12)
1033 .map(|_| rand::distributions::Alphanumeric.sample_string(&mut rng, 8))
1034 .collect();
1035
1036 let argon2 = Argon2::default();
1037 let recovery_codes = codes
1038 .iter()
1039 .map(|code| {
1040 let salt = SaltString::generate(&mut rng);
1041 argon2.hash_password(code.as_bytes(), &salt).map(|hash| hash.to_string())
1042 })
1043 .map(|code_hash| {
1044 code_hash.map(|code_hash| MfaRecoveryCode {
1045 id: MfaRecoveryCodeId::new(),
1046 user_id: resource.id,
1047 code_hash,
1048 })
1049 })
1050 .collect::<Result<Vec<_>, _>>()
1051 .into_tonic_internal_err("failed to generate recovery codes")?;
1052
1053 diesel::delete(mfa_recovery_codes::dsl::mfa_recovery_codes)
1054 .filter(mfa_recovery_codes::dsl::user_id.eq(resource.id))
1055 .execute(conn)
1056 .await
1057 .into_tonic_internal_err("failed to delete existing recovery codes")?;
1058
1059 diesel::insert_into(mfa_recovery_codes::dsl::mfa_recovery_codes)
1060 .values(recovery_codes)
1061 .execute(conn)
1062 .await
1063 .into_tonic_internal_err("failed to insert new recovery codes")?;
1064
1065 Ok(pb::scufflecloud::core::v1::RecoveryCodes { codes })
1066 }
1067}
1068
1069impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::DeleteUserRequest> {
1070 type Principal = User;
1071 type Resource = User;
1072 type Response = pb::scufflecloud::core::v1::User;
1073
1074 const ACTION: Action = Action::DeleteUser;
1075
1076 async fn load_principal(
1077 &mut self,
1078 _conn: &mut diesel_async::AsyncPgConnection,
1079 ) -> Result<Self::Principal, tonic::Status> {
1080 let global = &self.global::<G>()?;
1081 let session = self.session_or_err()?;
1082 common::get_user_by_id(global, session.user_id).await
1083 }
1084
1085 async fn load_resource(&mut self, conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
1086 let user_id: UserId = self
1087 .get_ref()
1088 .id
1089 .parse()
1090 .into_tonic_err_with_field_violation("id", "invalid ID")?;
1091 common::get_user_by_id_in_tx(conn, user_id).await
1092 }
1093
1094 async fn execute(
1095 self,
1096 conn: &mut diesel_async::AsyncPgConnection,
1097 _principal: Self::Principal,
1098 resource: Self::Resource,
1099 ) -> Result<Self::Response, tonic::Status> {
1100 diesel::delete(users::dsl::users)
1101 .filter(users::dsl::id.eq(resource.id))
1102 .execute(conn)
1103 .await
1104 .into_tonic_internal_err("failed to delete webauthn credential")?;
1105
1106 Ok(resource.into())
1107 }
1108}