scufflecloud_core/operations/
login.rs

1use base64::Engine;
2use core_db_types::models::{
3    MagicLinkRequest, MagicLinkRequestId, Organization, OrganizationMember, User, UserGoogleAccount, UserId,
4};
5use core_db_types::schema::{magic_link_requests, organization_members, organizations, user_google_accounts, users};
6use core_traits::EmailServiceClient;
7use diesel::{BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl, SelectableHelper};
8use diesel_async::RunQueryDsl;
9use ext_traits::{OptionExt, RequestExt, ResultExt};
10use geo_ip::GeoIpRequestExt;
11use pb::scufflecloud::core::v1::CaptchaProvider;
12use sha2::Digest;
13use tonic::Code;
14use tonic_types::{ErrorDetails, StatusExt};
15
16use crate::cedar::{Action, CoreApplication, Unauthenticated};
17use crate::common::normalize_email;
18use crate::operations::{Operation, OperationDriver};
19use crate::{captcha, common, google_api};
20
21impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithMagicLinkRequest> {
22    type Principal = Unauthenticated;
23    type Resource = CoreApplication;
24    type Response = ();
25
26    const ACTION: Action = Action::RequestMagicLink;
27
28    async fn validate(&mut self) -> Result<(), tonic::Status> {
29        let global = &self.global::<G>()?;
30        let captcha = self.get_ref().captcha.clone().require("captcha")?;
31
32        // Check captcha
33        match captcha.provider() {
34            CaptchaProvider::Unspecified => {
35                return Err(tonic::Status::with_error_details(
36                    Code::InvalidArgument,
37                    "captcha provider must be set",
38                    ErrorDetails::new(),
39                ));
40            }
41            CaptchaProvider::Turnstile => {
42                captcha::turnstile::verify_in_tonic(global, self.ip_address_info()?.ip_address, &captcha.token).await?;
43            }
44        }
45
46        Ok(())
47    }
48
49    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
50        Ok(Unauthenticated)
51    }
52
53    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
54        Ok(CoreApplication)
55    }
56
57    async fn execute(
58        self,
59        driver: &mut OperationDriver<'_, G>,
60        _principal: Self::Principal,
61        _resource: Self::Resource,
62    ) -> Result<Self::Response, tonic::Status> {
63        let global = &self.global::<G>()?;
64
65        let email = normalize_email(&self.get_ref().email);
66
67        let conn = driver.conn().await?;
68
69        let user = common::get_user_by_email(conn, &email).await?;
70        let user_id = user.as_ref().map(|u| u.id);
71
72        let code = common::generate_random_bytes().into_tonic_internal_err("failed to generate magic link code")?;
73        let code_base64 = base64::prelude::BASE64_URL_SAFE.encode(code);
74
75        let timeout = global.timeout_config().magic_link_request;
76
77        // Insert email link user session request
78        let session_request = MagicLinkRequest {
79            id: MagicLinkRequestId::new(),
80            user_id,
81            email: email.clone(),
82            code: code.to_vec(),
83            expires_at: chrono::Utc::now() + timeout,
84        };
85        diesel::insert_into(magic_link_requests::dsl::magic_link_requests)
86            .values(session_request)
87            .execute(conn)
88            .await
89            .into_tonic_internal_err("failed to insert magic link user session request")?;
90
91        // Send email
92        let email_msg = if user_id.is_none() {
93            core_emails::register_with_email_email(global.dashboard_origin(), code_base64, timeout)
94                .into_tonic_internal_err("failed to render registration email")?
95        } else {
96            core_emails::magic_link_email(global.dashboard_origin(), code_base64, timeout)
97                .into_tonic_internal_err("failed to render magic link email")?
98        };
99
100        let email_msg = common::email_to_pb(global, email, user.and_then(|u| u.preferred_name), email_msg);
101
102        global
103            .email_service()
104            .send_email(email_msg)
105            .await
106            .into_tonic_internal_err("failed to send magic link email")?;
107
108        Ok(())
109    }
110}
111
112#[derive(Clone)]
113struct CompleteLoginWithMagicLinkState {
114    create_user: bool,
115}
116
117impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteLoginWithMagicLinkRequest> {
118    type Principal = User;
119    type Resource = CoreApplication;
120    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
121
122    const ACTION: Action = Action::LoginWithMagicLink;
123
124    async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
125        let conn = driver.conn().await?;
126
127        // Find and delete magic link request
128        let Some(magic_link_request) = diesel::delete(magic_link_requests::dsl::magic_link_requests)
129            .filter(
130                magic_link_requests::dsl::code
131                    .eq(&self.get_ref().code)
132                    .and(magic_link_requests::dsl::expires_at.gt(chrono::Utc::now())),
133            )
134            .returning(MagicLinkRequest::as_select())
135            .get_result::<MagicLinkRequest>(conn)
136            .await
137            .optional()
138            .into_tonic_internal_err("failed to delete magic link request")?
139        else {
140            return Err(tonic::Status::with_error_details(
141                Code::NotFound,
142                "unknown code",
143                ErrorDetails::new(),
144            ));
145        };
146
147        let mut state = CompleteLoginWithMagicLinkState { create_user: false };
148
149        // Load user
150        let user = if let Some(user_id) = magic_link_request.user_id {
151            users::dsl::users
152                .find(user_id)
153                .first::<User>(conn)
154                .await
155                .into_tonic_internal_err("failed to query user")?
156        } else {
157            state.create_user = true;
158
159            let hash = sha2::Sha256::digest(&magic_link_request.email);
160            let avatar_url = format!("https://gravatar.com/avatar/{:x}?s=80&d=identicon", hash);
161
162            User {
163                id: UserId::new(),
164                preferred_name: None,
165                first_name: None,
166                last_name: None,
167                password_hash: None,
168                primary_email: Some(magic_link_request.email),
169                avatar_url: Some(avatar_url),
170            }
171        };
172
173        self.extensions_mut().insert(state);
174
175        Ok(user)
176    }
177
178    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
179        Ok(CoreApplication)
180    }
181
182    async fn execute(
183        mut self,
184        driver: &mut OperationDriver<'_, G>,
185        principal: Self::Principal,
186        _resource: Self::Resource,
187    ) -> Result<Self::Response, tonic::Status> {
188        let global = &self.global::<G>()?;
189        let ip_info = self.ip_address_info()?;
190        let state: CompleteLoginWithMagicLinkState = self
191            .extensions_mut()
192            .remove()
193            .into_tonic_internal_err("missing CompleteLoginWithMagicLinkState state")?;
194
195        let device = self.into_inner().device.require("device")?;
196
197        let conn = driver.conn().await?;
198
199        if state.create_user {
200            common::create_user(conn, &principal).await?;
201        }
202
203        let new_token = common::create_session(global, conn, &principal, device, &ip_info, !state.create_user).await?;
204        Ok(new_token)
205    }
206}
207
208impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithEmailAndPasswordRequest> {
209    type Principal = User;
210    type Resource = CoreApplication;
211    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
212
213    const ACTION: Action = Action::LoginWithEmailPassword;
214
215    async fn validate(&mut self) -> Result<(), tonic::Status> {
216        let global = &self.global::<G>()?;
217        let captcha = self.get_ref().captcha.clone().require("captcha")?;
218
219        // Check captcha
220        match captcha.provider() {
221            CaptchaProvider::Unspecified => {
222                return Err(tonic::Status::with_error_details(
223                    Code::InvalidArgument,
224                    "captcha provider must be set",
225                    ErrorDetails::new(),
226                ));
227            }
228            CaptchaProvider::Turnstile => {
229                captcha::turnstile::verify_in_tonic(global, self.ip_address_info()?.ip_address, &captcha.token).await?;
230            }
231        }
232
233        Ok(())
234    }
235
236    async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
237        let conn = driver.conn().await?;
238        let Some(user) = common::get_user_by_email(conn, &self.get_ref().email).await? else {
239            return Err(tonic::Status::with_error_details(
240                tonic::Code::NotFound,
241                "user not found",
242                ErrorDetails::new(),
243            ));
244        };
245
246        Ok(user)
247    }
248
249    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
250        Ok(CoreApplication)
251    }
252
253    async fn execute(
254        self,
255        driver: &mut OperationDriver<'_, G>,
256        principal: Self::Principal,
257        _resource: Self::Resource,
258    ) -> Result<Self::Response, tonic::Status> {
259        let global = &self.global::<G>()?;
260        let ip_info = self.ip_address_info()?;
261        let payload = self.into_inner();
262
263        let conn = driver.conn().await?;
264
265        let device = payload.device.require("device")?;
266
267        // Verify password
268        let Some(password_hash) = &principal.password_hash else {
269            return Err(tonic::Status::with_error_details(
270                tonic::Code::FailedPrecondition,
271                "user does not have a password set",
272                ErrorDetails::new(),
273            ));
274        };
275
276        common::verify_password(password_hash, &payload.password)?;
277
278        common::create_session(global, conn, &principal, device, &ip_info, true).await
279    }
280}
281
282#[derive(Clone, Default)]
283struct CompleteLoginWithGoogleState {
284    first_login: bool,
285    google_workspace: Option<pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace>,
286}
287
288impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::CompleteLoginWithGoogleRequest> {
289    type Principal = User;
290    type Resource = CoreApplication;
291    type Response = pb::scufflecloud::core::v1::CompleteLoginWithGoogleResponse;
292
293    const ACTION: Action = Action::LoginWithGoogle;
294
295    async fn validate(&mut self) -> Result<(), tonic::Status> {
296        let device = self.get_ref().device.clone().require("device")?;
297        let device_fingerprint = sha2::Sha256::digest(&device.public_key_data);
298        let state = base64::prelude::BASE64_URL_SAFE
299            .decode(&self.get_ref().state)
300            .into_tonic_internal_err("failed to decode state")?;
301
302        if *device_fingerprint != state {
303            return Err(tonic::Status::with_error_details(
304                tonic::Code::FailedPrecondition,
305                "device fingerprint does not match state",
306                ErrorDetails::new(),
307            ));
308        }
309
310        Ok(())
311    }
312
313    async fn load_principal(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
314        let global = &self.global::<G>()?;
315
316        let google_token = google_api::request_tokens(global, &self.get_ref().code)
317            .await
318            .into_tonic_err_with_field_violation("code", "failed to request google token")?;
319
320        // If user is part of a Google Workspace
321        let workspace_user = if google_token.scope.contains(google_api::ADMIN_DIRECTORY_API_USER_SCOPE) {
322            if let Some(hd) = google_token.id_token.hd.clone() {
323                google_api::request_google_workspace_user(global, &google_token.access_token, &google_token.id_token.sub)
324                    .await
325                    .into_tonic_internal_err("failed to request Google Workspace user")?
326                    .map(|u| (u, hd))
327            } else {
328                None
329            }
330        } else {
331            None
332        };
333
334        let mut state = CompleteLoginWithGoogleState {
335            first_login: false,
336            google_workspace: None,
337        };
338
339        let conn = driver.conn().await?;
340
341        // Update the organization if the user is an admin of a Google Workspace
342        if let Some((workspace_user, hd)) = workspace_user
343            && workspace_user.is_admin
344        {
345            let n = diesel::update(organizations::dsl::organizations)
346                .filter(organizations::dsl::google_customer_id.eq(&workspace_user.customer_id))
347                .set(organizations::dsl::google_hosted_domain.eq(&google_token.id_token.hd))
348                .execute(conn)
349                .await
350                .into_tonic_internal_err("failed to update organization")?;
351
352            if n == 0 {
353                state.google_workspace = Some(pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace::UnassociatedGoogleHostedDomain(hd));
354            }
355        }
356
357        let google_account = user_google_accounts::dsl::user_google_accounts
358            .find(&google_token.id_token.sub)
359            .first::<UserGoogleAccount>(conn)
360            .await
361            .optional()
362            .into_tonic_internal_err("failed to query google account")?;
363
364        match google_account {
365            Some(google_account) => {
366                // Load existing user
367                let user = diesel::update(users::dsl::users)
368                    .filter(users::dsl::id.eq(google_account.user_id))
369                    .set(users::dsl::avatar_url.eq(google_token.id_token.picture))
370                    .returning(User::as_select())
371                    .get_result::<User>(conn)
372                    .await
373                    .into_tonic_internal_err("failed to update user")?;
374
375                self.extensions_mut().insert(state);
376
377                Ok(user)
378            }
379            None => {
380                let user = User {
381                    id: UserId::new(),
382                    preferred_name: google_token.id_token.name,
383                    first_name: google_token.id_token.given_name,
384                    last_name: google_token.id_token.family_name,
385                    password_hash: None,
386                    primary_email: google_token
387                        .id_token
388                        .email_verified
389                        .then(|| normalize_email(&google_token.id_token.email)),
390                    avatar_url: google_token.id_token.picture,
391                };
392
393                common::create_user(conn, &user).await?;
394
395                let google_account = UserGoogleAccount {
396                    sub: google_token.id_token.sub,
397                    access_token: google_token.access_token,
398                    access_token_expires_at: chrono::Utc::now() + chrono::Duration::seconds(google_token.expires_in as i64),
399                    user_id: user.id,
400                    created_at: chrono::Utc::now(),
401                };
402
403                diesel::insert_into(user_google_accounts::dsl::user_google_accounts)
404                    .values(google_account)
405                    .execute(conn)
406                    .await
407                    .into_tonic_internal_err("failed to insert user google account")?;
408
409                if let Some(hd) = google_token.id_token.hd {
410                    // Check if the organization exists for the hosted domain
411                    let organization = organizations::dsl::organizations
412                        .filter(organizations::dsl::google_hosted_domain.eq(hd))
413                        .first::<Organization>(conn)
414                        .await
415                        .optional()
416                        .into_tonic_internal_err("failed to query organization")?;
417
418                    if let Some(org) = organization {
419                        // Associate user with the organization
420                        let membership = OrganizationMember {
421                            organization_id: org.id,
422                            user_id: user.id,
423                            invited_by_id: None,
424                            inline_policy: None,
425                            created_at: chrono::Utc::now(),
426                        };
427
428                        diesel::insert_into(organization_members::dsl::organization_members)
429                            .values(membership)
430                            .execute(conn)
431                            .await
432                            .into_tonic_internal_err("failed to insert organization membership")?;
433
434                        state.google_workspace = Some(
435                            pb::scufflecloud::core::v1::complete_login_with_google_response::GoogleWorkspace::Joined(
436                                org.into(),
437                            ),
438                        );
439                    }
440                }
441
442                state.first_login = true;
443                self.extensions_mut().insert(state);
444
445                Ok(user)
446            }
447        }
448    }
449
450    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
451        Ok(CoreApplication)
452    }
453
454    async fn execute(
455        mut self,
456        driver: &mut OperationDriver<'_, G>,
457        principal: Self::Principal,
458        _resource: Self::Resource,
459    ) -> Result<Self::Response, tonic::Status> {
460        let global = &self.global::<G>()?;
461        let ip_info = self.ip_address_info()?;
462
463        let state = self
464            .extensions_mut()
465            .remove::<CompleteLoginWithGoogleState>()
466            .into_tonic_internal_err("missing CompleteLoginWithGoogleState state")?;
467
468        let device = self.into_inner().device.require("device")?;
469
470        let conn = driver.conn().await?;
471
472        // Create session
473        let token = common::create_session(global, conn, &principal, device, &ip_info, false).await?;
474
475        Ok(pb::scufflecloud::core::v1::CompleteLoginWithGoogleResponse {
476            new_user_session_token: Some(token),
477            first_login: state.first_login,
478            google_workspace: state.google_workspace,
479        })
480    }
481}
482
483impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::LoginWithWebauthnRequest> {
484    type Principal = User;
485    type Resource = CoreApplication;
486    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
487
488    const ACTION: Action = Action::LoginWithWebauthn;
489
490    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
491        let global = &self.global::<G>()?;
492        let user_id: UserId = self
493            .get_ref()
494            .user_id
495            .parse()
496            .into_tonic_err_with_field_violation("user_id", "invalid ID")?;
497
498        common::get_user_by_id(global, user_id).await
499    }
500
501    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
502        Ok(CoreApplication)
503    }
504
505    async fn execute(
506        self,
507        driver: &mut OperationDriver<'_, G>,
508        principal: Self::Principal,
509        _resource: Self::Resource,
510    ) -> Result<Self::Response, tonic::Status> {
511        let global = &self.global::<G>()?;
512        let ip_info = self.ip_address_info()?;
513        let payload = self.into_inner();
514
515        let pk_cred: webauthn_rs::prelude::PublicKeyCredential = serde_json::from_str(&payload.response_json)
516            .into_tonic_err_with_field_violation("response_json", "invalid public key credential")?;
517        let device = payload.device.require("device")?;
518
519        let conn = driver.conn().await?;
520
521        common::finish_webauthn_authentication(global, conn, principal.id, &pk_cred).await?;
522
523        // Create a new session for the user
524        let new_token = common::create_session(global, conn, &principal, device, &ip_info, false).await?;
525        Ok(new_token)
526    }
527}