scufflecloud_core/operations/
user_sessions.rs

1use core_db_types::models::{User, UserId, UserSession, UserSessionTokenId};
2use core_db_types::schema::user_sessions;
3use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl, SelectableHelper};
4use diesel_async::RunQueryDsl;
5use ext_traits::{OptionExt, RequestExt, ResultExt};
6use tonic_types::{ErrorDetails, StatusExt};
7
8use crate::cedar::{Action, CoreApplication};
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: Some(session.into()),
153            mfa_options: mfa_options.into_iter().map(|o| o as i32).collect(),
154        };
155
156        Ok(new_token)
157    }
158}
159
160impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::InvalidateUserSessionRequest> {
161    type Principal = User;
162    type Resource = UserSession;
163    type Response = ();
164
165    const ACTION: Action = Action::InvalidateUserSession;
166
167    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
168        let global = &self.global::<G>()?;
169        let session = self.session_or_err()?;
170        common::get_user_by_id(global, session.user_id).await
171    }
172
173    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
174        let user_id: UserId = self
175            .get_ref()
176            .user_id
177            .parse()
178            .into_tonic_err_with_field_violation("id", "invalid ID")?;
179        let device_fingerprint = &self.get_ref().device_fingerprint;
180
181        let session = diesel::delete(user_sessions::dsl::user_sessions)
182            .filter(
183                user_sessions::dsl::user_id
184                    .eq(&user_id)
185                    .and(user_sessions::dsl::device_fingerprint.eq(device_fingerprint)),
186            )
187            .returning(UserSession::as_select())
188            .get_result(driver.conn().await?)
189            .await
190            .into_tonic_internal_err("failed to delete user session")?;
191
192        Ok(session)
193    }
194
195    async fn execute(
196        self,
197        _driver: &mut OperationDriver<'_, G>,
198        _principal: Self::Principal,
199        _resource: Self::Resource,
200    ) -> Result<Self::Response, tonic::Status> {
201        Ok(())
202    }
203}
204
205impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListUserSessionsRequest> {
206    type Principal = User;
207    type Resource = CoreApplication;
208    type Response = pb::scufflecloud::core::v1::ListUserSessionsResponse;
209
210    const ACTION: Action = Action::ListUserSessions;
211
212    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
213        let global = &self.global::<G>()?;
214        let session = self.session_or_err()?;
215        common::get_user_by_id(global, session.user_id).await
216    }
217
218    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
219        Ok(CoreApplication)
220    }
221
222    async fn execute(
223        self,
224        _driver: &mut OperationDriver<'_, G>,
225        principal: Self::Principal,
226        _resource: Self::Resource,
227    ) -> Result<Self::Response, tonic::Status> {
228        let global = &self.global::<G>()?;
229        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
230
231        let sessions = user_sessions::dsl::user_sessions
232            .filter(user_sessions::dsl::user_id.eq(principal.id))
233            .load::<UserSession>(&mut db)
234            .await
235            .into_tonic_internal_err("failed to load user sessions")?;
236
237        Ok(pb::scufflecloud::core::v1::ListUserSessionsResponse {
238            sessions: sessions.into_iter().map(Into::into).collect(),
239        })
240    }
241}