scufflecloud_core/operations/
register.rs1use base64::Engine;
2use diesel::{BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
3use diesel_async::RunQueryDsl;
4use tonic::Code;
5use tonic_types::{ErrorDetails, StatusExt};
6
7use crate::cedar::{Action, CoreApplication, Unauthenticated};
8use crate::http_ext::RequestExt;
9use crate::models::{EmailRegistrationRequest, EmailRegistrationRequestId, User, UserId};
10use crate::operations::Operation;
11use crate::schema::{email_registration_requests, user_emails};
12use crate::std_ext::{OptionExt, ResultExt};
13use crate::{CoreConfig, captcha, common, emails};
14
15impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::RegisterWithEmailRequest> {
16 type Principal = Unauthenticated;
17 type Resource = CoreApplication;
18 type Response = ();
19
20 const ACTION: Action = Action::RegisterWithEmail;
21
22 async fn validate(&mut self) -> Result<(), tonic::Status> {
23 let global = &self.global::<G>()?;
24
25 let captcha = self.get_ref().captcha.clone().require("captcha")?;
27 match captcha.provider() {
28 pb::scufflecloud::core::v1::CaptchaProvider::Turnstile => {
29 captcha::turnstile::verify_in_tonic(global, &captcha.token).await?;
30 }
31 }
32
33 Ok(())
34 }
35
36 async fn load_principal(&mut self, _tx: &mut diesel_async::AsyncPgConnection) -> Result<Self::Principal, tonic::Status> {
37 Ok(Unauthenticated)
38 }
39
40 async fn load_resource(&mut self, _tx: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
41 Ok(CoreApplication)
42 }
43
44 async fn execute(
45 self,
46 tx: &mut diesel_async::AsyncPgConnection,
47 _principal: Self::Principal,
48 _resource: Self::Resource,
49 ) -> Result<Self::Response, tonic::Status> {
50 let global = &self.global::<G>()?;
51 let payload = self.into_inner();
52
53 let email = common::normalize_email(&payload.email);
54
55 let code = common::generate_random_bytes().into_tonic_internal_err("failed to generate registration code")?;
57 let code_base64 = base64::prelude::BASE64_URL_SAFE.encode(code);
58
59 if user_emails::dsl::user_emails
61 .find(&email)
62 .select(user_emails::dsl::email)
63 .first::<String>(tx)
64 .await
65 .optional()
66 .into_tonic_internal_err("failed to query database")?
67 .is_some()
68 {
69 return Err(tonic::Status::with_error_details(
70 Code::AlreadyExists,
71 "email is already registered",
72 ErrorDetails::new(),
73 ));
74 }
75
76 let registration_request = EmailRegistrationRequest {
78 id: EmailRegistrationRequestId::new(),
79 user_id: None,
80 email: email.clone(),
81 code: code.to_vec(),
82 expires_at: chrono::Utc::now() + global.email_registration_request_timeout(),
83 };
84
85 diesel::insert_into(email_registration_requests::dsl::email_registration_requests)
86 .values(registration_request)
87 .execute(tx)
88 .await
89 .into_tonic_internal_err("failed to insert email registration request")?;
90
91 let email = emails::register_with_email_email(global, email, code_base64)
93 .await
94 .into_tonic_internal_err("failed to render registration email")?;
95 global
96 .email_service()
97 .send_email(email)
98 .await
99 .into_tonic_internal_err("failed to send registration email")?;
100
101 Ok(())
102 }
103}
104
105impl<G: CoreConfig> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteRegisterWithEmailRequest> {
106 type Principal = User;
107 type Resource = CoreApplication;
108 type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
109
110 const ACTION: Action = Action::CompleteRegisterWithEmail;
111
112 async fn load_principal(&mut self, tx: &mut diesel_async::AsyncPgConnection) -> Result<Self::Principal, tonic::Status> {
113 let Some(registration_request) = diesel::delete(email_registration_requests::dsl::email_registration_requests)
115 .filter(
116 email_registration_requests::dsl::code
117 .eq(&self.get_ref().code)
118 .and(email_registration_requests::dsl::user_id.is_null())
119 .and(email_registration_requests::dsl::expires_at.gt(chrono::Utc::now())),
120 )
121 .returning(EmailRegistrationRequest::as_select())
122 .get_result::<EmailRegistrationRequest>(tx)
123 .await
124 .optional()
125 .into_tonic_internal_err("failed to delete email registration request")?
126 else {
127 return Err(tonic::Status::with_error_details(
128 Code::NotFound,
129 "unknown code",
130 ErrorDetails::new(),
131 ));
132 };
133
134 Ok(User {
135 id: UserId::new(),
136 preferred_name: None,
137 first_name: None,
138 last_name: None,
139 password_hash: None,
140 primary_email: Some(registration_request.email),
141 })
142 }
143
144 async fn load_resource(&mut self, _tx: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
145 Ok(CoreApplication)
146 }
147
148 async fn execute(
149 self,
150 tx: &mut diesel_async::AsyncPgConnection,
151 principal: Self::Principal,
152 _resource: Self::Resource,
153 ) -> Result<Self::Response, tonic::Status> {
154 let global = &self.global::<G>()?;
155 let ip_info = self.ip_address_info()?;
156
157 let device = self.into_inner().device.require("device")?;
158 common::create_user(tx, &principal).await?;
159 let new_token = common::create_session(global, tx, principal.id, device, &ip_info, false).await?;
160
161 Ok(new_token)
162 }
163}