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::{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        // Check if email is already registered
178        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    // Create user session, device and token
264    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    // Upsert session
292    // This is an upsert because the user might have already had a session for this device at some point
293    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        // Invalid credential
410        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}