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