scufflecloud_core/
common.rs

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        // Check if email is already registered
216        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    // Create user session, device and token
326    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    // Upsert session
355    // This is an upsert because the user might have already had a session for this device at some point
356    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        // Invalid credential
477        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}