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