1use std::sync::Arc;
2
3use argon2::{Argon2, PasswordVerifier};
4use core_db_types::models::{
5 MfaRecoveryCode, MfaWebauthnCredential, Organization, OrganizationId, User, UserEmail, UserId, UserSession,
6 UserSessionTokenId,
7};
8use core_db_types::schema::{
9 mfa_recovery_codes, mfa_totp_credentials, mfa_webauthn_auth_sessions, mfa_webauthn_credentials, organizations,
10 user_emails, user_sessions, users,
11};
12use core_traits::EmailServiceClient;
13use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl, SelectableHelper};
14use diesel_async::RunQueryDsl;
15use ext_traits::{DisplayExt, OptionExt, ResultExt};
16use geo_ip::maxminddb;
17use geo_ip::middleware::IpAddressInfo;
18use pkcs8::DecodePublicKey;
19use rand::RngCore;
20use sha2::Digest;
21use tonic::Code;
22use tonic_types::{ErrorDetails, StatusExt};
23
24use crate::chrono_ext::ChronoDateTimeExt;
25use crate::http_ext::CoreRequestExt;
26
27pub(crate) fn email_to_pb<G: core_traits::ConfigInterface>(
28 global: &Arc<G>,
29 to_address: String,
30 to_name: Option<String>,
31 email: core_emails::Email,
32) -> pb::scufflecloud::email::v1::SendEmailRequest {
33 pb::scufflecloud::email::v1::SendEmailRequest {
34 from: Some(pb::scufflecloud::email::v1::EmailAddress {
35 name: Some(global.email_from_name().to_string()),
36 address: global.email_from_address().to_string(),
37 }),
38 to: Some(pb::scufflecloud::email::v1::EmailAddress {
39 name: to_name,
40 address: to_address,
41 }),
42 subject: email.subject,
43 text: email.text,
44 html: email.html,
45 }
46}
47
48pub(crate) fn ip_to_pb<G: geo_ip::GeoIpInterface>(
49 global: &Arc<G>,
50 ip_addr: std::net::IpAddr,
51) -> Result<pb::scufflecloud::core::v1::IpAddressInfo, tonic::Status> {
52 let geo_country = global
53 .geo_ip_resolver()
54 .lookup::<maxminddb::geoip2::Country>(ip_addr)
55 .into_tonic_internal_err("failed to lookup geoip info")?
56 .and_then(|c| c.country)
57 .and_then(|c| c.names)
58 .and_then(|names| names.get("en").map(|s| s.to_string()));
59 let geo_city = global
60 .geo_ip_resolver()
61 .lookup::<maxminddb::geoip2::City>(ip_addr)
62 .into_tonic_internal_err("failed to lookup geoip info")?
63 .and_then(|c| c.city)
64 .and_then(|c| c.names)
65 .and_then(|names| names.get("en").map(|s| s.to_string()));
66
67 Ok(pb::scufflecloud::core::v1::IpAddressInfo {
68 ip_address: ip_addr.to_string(),
69 geo_country,
70 geo_city,
71 })
72}
73
74pub(crate) fn ua_to_pb(ua: String) -> pb::scufflecloud::core::v1::UserAgent {
75 let ua_parser = woothee::parser::Parser::new();
76 let ua_res = ua_parser.parse(&ua);
77
78 pb::scufflecloud::core::v1::UserAgent {
79 os: ua_res.as_ref().map(|r| r.os.to_string()),
80 browser: ua_res.as_ref().map(|r| r.name.to_string()),
81 user_agent: ua,
82 }
83}
84
85pub(crate) fn generate_random_bytes() -> Result<[u8; 32], rand::Error> {
86 let mut token = [0u8; 32];
87 rand::rngs::OsRng.try_fill_bytes(&mut token)?;
88 Ok(token)
89}
90
91#[derive(Debug, thiserror::Error)]
92pub(crate) enum TxError {
93 #[error("diesel transaction error: {0}")]
94 Diesel(#[from] diesel::result::Error),
95 #[error("tonic status error: {0}")]
96 Status(#[from] tonic::Status),
97}
98
99impl From<TxError> for tonic::Status {
100 fn from(err: TxError) -> Self {
101 match err {
102 TxError::Diesel(e) => e.into_tonic_internal_err("transaction error"),
103 TxError::Status(s) => s,
104 }
105 }
106}
107
108pub(crate) fn encrypt_token(
109 algorithm: pb::scufflecloud::core::v1::DeviceAlgorithm,
110 token: &[u8],
111 pk_der_data: &[u8],
112) -> Result<Vec<u8>, tonic::Status> {
113 match algorithm {
114 pb::scufflecloud::core::v1::DeviceAlgorithm::RsaOaepSha256 => {
115 let pk = rsa::RsaPublicKey::from_public_key_der(pk_der_data)
116 .into_tonic_err_with_field_violation("public_key_data", "failed to parse public key")?;
117 let padding = rsa::Oaep::new::<sha2::Sha256>();
118 let enc_data = pk
119 .encrypt(&mut rsa::rand_core::OsRng, padding, token)
120 .into_tonic_internal_err("failed to encrypt token")?;
121 Ok(enc_data)
122 }
123 }
124}
125
126pub(crate) async fn get_user_by_id<G: core_traits::Global>(global: &Arc<G>, user_id: UserId) -> Result<User, tonic::Status> {
127 global
128 .user_loader()
129 .load(user_id)
130 .await
131 .ok()
132 .into_tonic_internal_err("failed to query user")?
133 .into_tonic_not_found("user not found")
134}
135
136pub(crate) async fn get_user_by_id_in_tx(
137 db: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
138 user_id: UserId,
139) -> Result<User, tonic::Status> {
140 let user = users::dsl::users
141 .find(user_id)
142 .select(User::as_select())
143 .first::<User>(db)
144 .await
145 .optional()
146 .into_tonic_internal_err("failed to query user")?
147 .into_tonic_not_found("user not found")?;
148
149 Ok(user)
150}
151
152pub(crate) async fn get_user_by_email(
153 db: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
154 email: &str,
155) -> Result<Option<User>, tonic::Status> {
156 let user = users::dsl::users
157 .inner_join(user_emails::dsl::user_emails.on(users::dsl::id.eq(user_emails::dsl::user_id)))
158 .filter(user_emails::dsl::email.eq(&email))
159 .select(User::as_select())
160 .first::<User>(db)
161 .await
162 .optional()
163 .into_tonic_internal_err("failed to query user by email")?;
164
165 Ok(user)
166}
167
168pub(crate) async fn get_organization_by_id<G: core_traits::Global>(
169 global: &Arc<G>,
170 organization_id: OrganizationId,
171) -> Result<Organization, tonic::Status> {
172 let organization = global
173 .organization_loader()
174 .load(organization_id)
175 .await
176 .ok()
177 .into_tonic_internal_err("failed to query organization")?
178 .into_tonic_not_found("organization not found")?;
179
180 Ok(organization)
181}
182
183pub(crate) async fn get_organization_by_id_in_tx(
184 db: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
185 organization_id: OrganizationId,
186) -> Result<Organization, tonic::Status> {
187 let organization = organizations::dsl::organizations
188 .find(organization_id)
189 .first::<Organization>(db)
190 .await
191 .optional()
192 .into_tonic_internal_err("failed to load organization")?
193 .ok_or_else(|| {
194 tonic::Status::with_error_details(tonic::Code::NotFound, "organization not found", ErrorDetails::new())
195 })?;
196
197 Ok(organization)
198}
199
200pub(crate) fn normalize_email(email: &str) -> String {
201 email.trim().to_ascii_lowercase()
202}
203
204pub(crate) async fn create_user(
205 tx: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
206 new_user: &User,
207) -> Result<(), tonic::Status> {
208 diesel::insert_into(users::dsl::users)
209 .values(new_user)
210 .execute(tx)
211 .await
212 .into_tonic_internal_err("failed to insert user")?;
213
214 if let Some(email) = new_user.primary_email.as_ref() {
215 if user_emails::dsl::user_emails
217 .find(email)
218 .select(user_emails::dsl::email)
219 .first::<String>(tx)
220 .await
221 .optional()
222 .into_tonic_internal_err("failed to query user emails")?
223 .is_some()
224 {
225 return Err(tonic::Status::with_error_details(
226 Code::AlreadyExists,
227 "email is already registered",
228 ErrorDetails::new(),
229 ));
230 }
231
232 let user_email = UserEmail {
233 email: email.clone(),
234 user_id: new_user.id,
235 created_at: chrono::Utc::now(),
236 };
237
238 diesel::insert_into(user_emails::dsl::user_emails)
239 .values(&user_email)
240 .execute(tx)
241 .await
242 .into_tonic_internal_err("failed to insert user email")?;
243 }
244
245 Ok(())
246}
247
248pub(crate) async fn mfa_options(
249 tx: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
250 user_id: UserId,
251) -> Result<Vec<pb::scufflecloud::core::v1::MfaOption>, tonic::Status> {
252 let mut mfa_options = vec![];
253
254 if mfa_totp_credentials::dsl::mfa_totp_credentials
255 .filter(mfa_totp_credentials::dsl::user_id.eq(user_id))
256 .count()
257 .get_result::<i64>(tx)
258 .await
259 .into_tonic_internal_err("failed to query mfa factors")?
260 > 0
261 {
262 mfa_options.push(pb::scufflecloud::core::v1::MfaOption::Totp);
263 }
264
265 if mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
266 .filter(mfa_webauthn_credentials::dsl::user_id.eq(user_id))
267 .count()
268 .get_result::<i64>(tx)
269 .await
270 .into_tonic_internal_err("failed to query mfa factors")?
271 > 0
272 {
273 mfa_options.push(pb::scufflecloud::core::v1::MfaOption::WebAuthn);
274 }
275
276 if mfa_recovery_codes::dsl::mfa_recovery_codes
277 .filter(mfa_recovery_codes::dsl::user_id.eq(user_id))
278 .count()
279 .get_result::<i64>(tx)
280 .await
281 .into_tonic_internal_err("failed to query mfa factors")?
282 > 0
283 {
284 mfa_options.push(pb::scufflecloud::core::v1::MfaOption::RecoveryCodes);
285 }
286
287 Ok(mfa_options)
288}
289
290pub(crate) struct CreateSessionMetadata {
291 pub dashboard_origin: url::Url,
292 pub ip_info: IpAddressInfo,
293 pub user_agent: Option<String>,
294}
295
296impl CreateSessionMetadata {
297 pub(crate) fn from_req<
298 G: core_traits::ConfigInterface + 'static,
299 R: ext_traits::RequestExt + geo_ip::GeoIpRequestExt + CoreRequestExt,
300 >(
301 req: &R,
302 ) -> Result<Self, tonic::Status> {
303 let dashboard_origin = req.dashboard_origin::<G>()?;
304 let ip_info = req.ip_address_info()?;
305 let user_agent = req.user_agent().map(ToString::to_string);
306
307 Ok(Self {
308 dashboard_origin,
309 ip_info,
310 user_agent,
311 })
312 }
313}
314
315pub(crate) async fn create_session<G: core_traits::Global>(
316 global: &Arc<G>,
317 tx: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
318 user: &User,
319 device: pb::scufflecloud::core::v1::Device,
320 metadata: CreateSessionMetadata,
321 check_mfa: bool,
322) -> Result<pb::scufflecloud::core::v1::NewUserSessionToken, tonic::Status> {
323 let mfa_options = if check_mfa { mfa_options(tx, user.id).await? } else { vec![] };
324
325 let device_fingerprint = sha2::Sha256::digest(&device.public_key_data).to_vec();
327
328 let session_expires_at = if !mfa_options.is_empty() {
329 chrono::Utc::now() + global.timeout_config().mfa
330 } else {
331 chrono::Utc::now() + global.timeout_config().user_session
332 };
333 let token_id = UserSessionTokenId::new();
334 let token_expires_at = chrono::Utc::now() + global.timeout_config().user_session_token;
335
336 let token = generate_random_bytes().into_tonic_internal_err("failed to generate token")?;
337 let encrypted_token = encrypt_token(device.algorithm(), &token, &device.public_key_data)?;
338
339 let user_session = UserSession {
340 user_id: user.id,
341 device_fingerprint,
342 device_algorithm: device.algorithm().into(),
343 device_pk_data: device.public_key_data,
344 last_used_at: chrono::Utc::now(),
345 last_ip: metadata.ip_info.to_network(),
346 last_user_agent: metadata.user_agent,
347 token_id: Some(token_id),
348 token: Some(token.to_vec()),
349 token_expires_at: Some(token_expires_at),
350 expires_at: session_expires_at,
351 mfa_pending: !mfa_options.is_empty(),
352 };
353
354 diesel::insert_into(user_sessions::dsl::user_sessions)
357 .values(&user_session)
358 .on_conflict((user_sessions::dsl::user_id, user_sessions::dsl::device_fingerprint))
359 .do_update()
360 .set((
361 user_sessions::dsl::last_used_at.eq(user_session.last_used_at),
362 user_sessions::dsl::last_ip.eq(user_session.last_ip),
363 user_sessions::dsl::last_user_agent.eq(&user_session.last_user_agent),
364 user_sessions::dsl::token_id.eq(user_session.token_id),
365 user_sessions::dsl::token.eq(token.to_vec()),
366 user_sessions::dsl::token_expires_at.eq(user_session.token_expires_at),
367 user_sessions::dsl::expires_at.eq(user_session.expires_at),
368 user_sessions::dsl::mfa_pending.eq(user_session.mfa_pending),
369 ))
370 .execute(tx)
371 .await
372 .into_tonic_internal_err("failed to insert user session")?;
373
374 let session_ip_info = ip_to_pb(global, metadata.ip_info.ip_address)?;
375 let session_user_agent = user_session.last_user_agent.clone().map(ua_to_pb);
376
377 let new_token = pb::scufflecloud::core::v1::NewUserSessionToken {
378 id: token_id.to_string(),
379 encrypted_token,
380 user_id: user.id.to_string(),
381 expires_at: Some(token_expires_at.to_prost_timestamp_utc()),
382 session: Some(user_session.into_pb(session_ip_info, session_user_agent)),
383 mfa_options: mfa_options.into_iter().map(|o| o as i32).collect(),
384 };
385
386 if let Some(primary_email) = user.primary_email.as_ref() {
387 let geo_info = metadata
388 .ip_info
389 .lookup_geoip_info::<maxminddb::geoip2::City>(&**global)
390 .into_tonic_internal_err("failed to lookup geoip info")?
391 .map(Into::into)
392 .unwrap_or_default();
393 let email = core_emails::new_device_email(&metadata.dashboard_origin, metadata.ip_info.ip_address, geo_info)
394 .into_tonic_internal_err("failed to render email")?;
395 let email = email_to_pb(global, primary_email.clone(), user.preferred_name.clone(), email);
396
397 global
398 .email_service()
399 .send_email(email)
400 .await
401 .into_tonic_internal_err("failed to send new device email")?;
402 }
403
404 Ok(new_token)
405}
406
407pub(crate) fn verify_password(password_hash: &str, password: &str) -> Result<(), tonic::Status> {
408 let password_hash = argon2::PasswordHash::new(password_hash).into_tonic_internal_err("failed to parse password hash")?;
409
410 match Argon2::default().verify_password(password.as_bytes(), &password_hash) {
411 Ok(_) => Ok(()),
412 Err(argon2::password_hash::Error::Password) => Err(tonic::Status::with_error_details(
413 tonic::Code::PermissionDenied,
414 "invalid password",
415 ErrorDetails::with_bad_request_violation("password", "invalid password"),
416 )),
417 Err(_) => Err(tonic::Status::with_error_details(
418 tonic::Code::Internal,
419 "failed to verify password",
420 ErrorDetails::new(),
421 )),
422 }
423}
424
425pub(crate) async fn finish_webauthn_authentication<G: core_traits::Global>(
426 global: &Arc<G>,
427 tx: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
428 user_id: UserId,
429 reg: &webauthn_rs::prelude::PublicKeyCredential,
430) -> Result<(), tonic::Status> {
431 let state = diesel::delete(mfa_webauthn_auth_sessions::dsl::mfa_webauthn_auth_sessions)
432 .filter(
433 mfa_webauthn_auth_sessions::dsl::user_id
434 .eq(user_id)
435 .and(mfa_webauthn_auth_sessions::dsl::expires_at.gt(chrono::Utc::now())),
436 )
437 .returning(mfa_webauthn_auth_sessions::dsl::state)
438 .get_result::<serde_json::Value>(tx)
439 .await
440 .optional()
441 .into_tonic_internal_err("failed to query webauthn authentication session")?
442 .into_tonic_err(
443 tonic::Code::FailedPrecondition,
444 "no webauthn authentication session found",
445 ErrorDetails::new(),
446 )?;
447
448 let state: webauthn_rs::prelude::PasskeyAuthentication =
449 serde_json::from_value(state).into_tonic_internal_err("failed to deserialize webauthn state")?;
450
451 let result = global
452 .webauthn()
453 .finish_passkey_authentication(reg, &state)
454 .into_tonic_internal_err("failed to finish webauthn authentication")?;
455
456 let counter = result.counter() as i64;
457
458 let credential = mfa_webauthn_credentials::dsl::mfa_webauthn_credentials
459 .filter(mfa_webauthn_credentials::dsl::credential_id.eq(result.cred_id().as_ref()))
460 .select(MfaWebauthnCredential::as_select())
461 .first::<MfaWebauthnCredential>(tx)
462 .await
463 .into_tonic_internal_err("failed to find webauthn credential")?;
464
465 if counter == 0 || credential.counter.is_none_or(|c| c < counter) {
466 diesel::update(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
467 .filter(mfa_webauthn_credentials::dsl::credential_id.eq(result.cred_id().as_ref()))
468 .set((
469 mfa_webauthn_credentials::dsl::counter.eq(counter),
470 mfa_webauthn_credentials::dsl::last_used_at.eq(chrono::Utc::now()),
471 ))
472 .execute(tx)
473 .await
474 .into_tonic_internal_err("failed to update webauthn credential")?;
475 } else {
476 diesel::delete(mfa_webauthn_credentials::dsl::mfa_webauthn_credentials)
478 .filter(mfa_webauthn_credentials::dsl::credential_id.eq(result.cred_id().as_ref()))
479 .execute(tx)
480 .await
481 .into_tonic_internal_err("failed to delete webauthn credential")?;
482
483 return Err(tonic::Status::with_error_details(
484 tonic::Code::FailedPrecondition,
485 "invalid webauthn credential",
486 ErrorDetails::new(),
487 ));
488 }
489
490 Ok(())
491}
492
493pub(crate) async fn process_recovery_code(
494 tx: &mut impl diesel_async::AsyncConnection<Backend = diesel::pg::Pg>,
495 user_id: UserId,
496 code: &str,
497) -> Result<(), tonic::Status> {
498 let codes = mfa_recovery_codes::dsl::mfa_recovery_codes
499 .filter(mfa_recovery_codes::dsl::user_id.eq(user_id))
500 .limit(20)
501 .load::<MfaRecoveryCode>(tx)
502 .await
503 .into_tonic_internal_err("failed to load MFA recovery codes")?;
504
505 let argon2 = Argon2::default();
506
507 for recovery_code in codes {
508 let hash = argon2::PasswordHash::new(&recovery_code.code_hash)
509 .into_tonic_internal_err("failed to parse recovery code hash")?;
510 match argon2.verify_password(code.as_bytes(), &hash) {
511 Ok(()) => {
512 diesel::delete(mfa_recovery_codes::dsl::mfa_recovery_codes)
513 .filter(mfa_recovery_codes::dsl::id.eq(recovery_code.id))
514 .execute(tx)
515 .await
516 .into_tonic_internal_err("failed to delete recovery code")?;
517
518 break;
519 }
520 Err(argon2::password_hash::Error::Password) => continue,
521 Err(e) => {
522 return Err(e.into_tonic_internal_err("failed to verify recovery code"));
523 }
524 }
525 }
526
527 Ok(())
528}