scufflecloud_core/operations/
user_sessions.rs

1use core_db_types::models::{User, UserSession, UserSessionTokenId};
2use core_db_types::schema::user_sessions;
3use diesel::{BoolExpressionMethods, ExpressionMethods, SelectableHelper};
4use diesel_async::RunQueryDsl;
5use ext_traits::{OptionExt, RequestExt, ResultExt};
6use tonic_types::{ErrorDetails, StatusExt};
7
8use crate::cedar::Action;
9use crate::chrono_ext::ChronoDateTimeExt;
10use crate::http_ext::CoreRequestExt;
11use crate::operations::{Operation, OperationDriver};
12use crate::{common, totp};
13
14impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ValidateMfaForUserSessionRequest> {
15    type Principal = User;
16    type Resource = UserSession;
17    type Response = pb::scufflecloud::core::v1::UserSession;
18
19    const ACTION: Action = Action::ValidateMfaForUserSession;
20
21    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
22        let global = &self.global::<G>()?;
23        let session = self.session_or_err()?;
24        common::get_user_by_id(global, session.user_id).await
25    }
26
27    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
28        let session = self.session_or_err()?;
29        Ok(session.clone())
30    }
31
32    async fn execute(
33        self,
34        driver: &mut OperationDriver<'_, G>,
35        _principal: Self::Principal,
36        resource: Self::Resource,
37    ) -> Result<Self::Response, tonic::Status> {
38        let global = &self.global::<G>()?;
39        let payload = self.into_inner();
40
41        let conn = driver.conn().await?;
42
43        // Verify MFA challenge response
44        match payload.response.require("response")? {
45            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::Totp(
46                pb::scufflecloud::core::v1::ValidateMfaForUserSessionTotp { code },
47            ) => {
48                totp::process_token(conn, resource.user_id, &code).await?;
49            }
50            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::Webauthn(
51                pb::scufflecloud::core::v1::ValidateMfaForUserSessionWebauthn { response_json },
52            ) => {
53                let pk_cred: webauthn_rs::prelude::PublicKeyCredential = serde_json::from_str(&response_json)
54                    .into_tonic_err_with_field_violation("response_json", "invalid public key credential")?;
55                common::finish_webauthn_authentication(global, conn, resource.user_id, &pk_cred).await?;
56            }
57            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::RecoveryCode(
58                pb::scufflecloud::core::v1::ValidateMfaForUserSessionRecoveryCode { code },
59            ) => {
60                common::process_recovery_code(conn, resource.user_id, &code).await?;
61            }
62        }
63
64        // Set mfa_pending=false and reset session expiry
65        let session = diesel::update(user_sessions::dsl::user_sessions)
66            .filter(
67                user_sessions::dsl::user_id
68                    .eq(&resource.user_id)
69                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
70            )
71            .set((
72                user_sessions::dsl::mfa_pending.eq(false),
73                user_sessions::dsl::expires_at.eq(chrono::Utc::now() + global.timeout_config().user_session_token),
74            ))
75            .returning(UserSession::as_select())
76            .get_result::<UserSession>(conn)
77            .await
78            .into_tonic_internal_err("failed to update user session")?;
79
80        Ok(session.into())
81    }
82}
83
84pub(crate) struct RefreshUserSessionRequest;
85
86impl<G: core_traits::Global> Operation<G> for tonic::Request<RefreshUserSessionRequest> {
87    type Principal = User;
88    type Resource = UserSession;
89    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
90
91    const ACTION: Action = Action::RefreshUserSession;
92
93    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
94        let global = &self.global::<G>()?;
95        let session = self.expired_session_or_err()?;
96        common::get_user_by_id(global, session.user_id).await
97    }
98
99    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
100        let session = self.expired_session_or_err()?;
101        Ok(session.clone())
102    }
103
104    async fn execute(
105        self,
106        driver: &mut OperationDriver<'_, G>,
107        _principal: Self::Principal,
108        resource: Self::Resource,
109    ) -> Result<Self::Response, tonic::Status> {
110        let global = &self.global::<G>()?;
111
112        let token_id = UserSessionTokenId::new();
113        let token = common::generate_random_bytes().into_tonic_internal_err("failed to generate token")?;
114        let encrypted_token = common::encrypt_token(resource.device_algorithm.into(), &token, &resource.device_pk_data)?;
115        let conn = driver.conn().await?;
116
117        let session = diesel::update(user_sessions::dsl::user_sessions)
118            .filter(
119                user_sessions::dsl::user_id
120                    .eq(&resource.user_id)
121                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
122            )
123            .set((
124                user_sessions::dsl::token_id.eq(token_id),
125                user_sessions::dsl::token.eq(token),
126                user_sessions::dsl::token_expires_at.eq(chrono::Utc::now() + global.timeout_config().user_session_token),
127            ))
128            .returning(UserSession::as_select())
129            .get_result::<UserSession>(conn)
130            .await
131            .into_tonic_internal_err("failed to update user session")?;
132
133        let (Some(token_id), Some(token_expires_at)) = (session.token_id, session.token_expires_at) else {
134            return Err(tonic::Status::with_error_details(
135                tonic::Code::Internal,
136                "user session does not have a token",
137                ErrorDetails::new(),
138            ));
139        };
140
141        let mfa_options = if session.mfa_pending {
142            common::mfa_options(conn, session.user_id).await?
143        } else {
144            vec![]
145        };
146
147        let new_token = pb::scufflecloud::core::v1::NewUserSessionToken {
148            id: token_id.to_string(),
149            encrypted_token,
150            user_id: session.user_id.to_string(),
151            expires_at: Some(token_expires_at.to_prost_timestamp_utc()),
152            session_expires_at: Some(session.expires_at.to_prost_timestamp_utc()),
153            session_mfa_pending: session.mfa_pending,
154            mfa_options: mfa_options.into_iter().map(|o| o as i32).collect(),
155        };
156
157        Ok(new_token)
158    }
159}
160
161pub(crate) struct InvalidateUserSessionRequest;
162
163impl<G: core_traits::Global> Operation<G> for tonic::Request<InvalidateUserSessionRequest> {
164    type Principal = User;
165    type Resource = UserSession;
166    type Response = ();
167
168    const ACTION: Action = Action::InvalidateUserSession;
169
170    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
171        let global = &self.global::<G>()?;
172        let session = self.session_or_err()?;
173        common::get_user_by_id(global, session.user_id).await
174    }
175
176    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
177        let session = self.session_or_err()?;
178        Ok(session.clone())
179    }
180
181    async fn execute(
182        self,
183        _driver: &mut OperationDriver<'_, G>,
184        _principal: Self::Principal,
185        resource: Self::Resource,
186    ) -> Result<Self::Response, tonic::Status> {
187        let global = &self.global::<G>()?;
188        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
189
190        diesel::delete(user_sessions::dsl::user_sessions)
191            .filter(
192                user_sessions::dsl::user_id
193                    .eq(&resource.user_id)
194                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
195            )
196            .execute(&mut db)
197            .await
198            .into_tonic_internal_err("failed to delete user session")?;
199
200        Ok(())
201    }
202}