scufflecloud_core/operations/
login.rs

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