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