scufflecloud_core/
common.rs

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