scufflecloud_core/operations/
login.rs1use 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let new_token = common::create_session(global, conn, principal.id, device, &ip_info, false).await?;
507 Ok(new_token)
508 }
509}