scufflecloud_core/operations/
register.rs

1use 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        // Check captcha
26        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        // Generate random code
56        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        // Check if email is already registered
60        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        // Create email registration request
77        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        // Send email
92        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        // Delete email registration request
114        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}