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        let ip_info = common::ip_to_pb(global, session.last_ip.ip())?;
81        let ua_info = session.last_user_agent.clone().map(common::ua_to_pb);
82
83        Ok(session.into_pb(ip_info, ua_info))
84    }
85}
86
87pub(crate) struct RefreshUserSessionRequest;
88
89impl<G: core_traits::Global> Operation<G> for tonic::Request<RefreshUserSessionRequest> {
90    type Principal = User;
91    type Resource = UserSession;
92    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
93
94    const ACTION: Action = Action::RefreshUserSession;
95
96    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
97        let global = &self.global::<G>()?;
98        let session = self.expired_session_or_err()?;
99        common::get_user_by_id(global, session.user_id).await
100    }
101
102    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
103        let session = self.expired_session_or_err()?;
104        Ok(session.clone())
105    }
106
107    async fn execute(
108        self,
109        driver: &mut OperationDriver<'_, G>,
110        _principal: Self::Principal,
111        resource: Self::Resource,
112    ) -> Result<Self::Response, tonic::Status> {
113        let global = &self.global::<G>()?;
114
115        let token_id = UserSessionTokenId::new();
116        let token = common::generate_random_bytes().into_tonic_internal_err("failed to generate token")?;
117        let encrypted_token = common::encrypt_token(resource.device_algorithm.into(), &token, &resource.device_pk_data)?;
118        let conn = driver.conn().await?;
119
120        let session = diesel::update(user_sessions::dsl::user_sessions)
121            .filter(
122                user_sessions::dsl::user_id
123                    .eq(&resource.user_id)
124                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
125            )
126            .set((
127                user_sessions::dsl::token_id.eq(token_id),
128                user_sessions::dsl::token.eq(token),
129                user_sessions::dsl::token_expires_at.eq(chrono::Utc::now() + global.timeout_config().user_session_token),
130            ))
131            .returning(UserSession::as_select())
132            .get_result::<UserSession>(conn)
133            .await
134            .into_tonic_internal_err("failed to update user session")?;
135
136        let (Some(token_id), Some(token_expires_at)) = (session.token_id, session.token_expires_at) else {
137            return Err(tonic::Status::with_error_details(
138                tonic::Code::Internal,
139                "user session does not have a token",
140                ErrorDetails::new(),
141            ));
142        };
143
144        let mfa_options = if session.mfa_pending {
145            common::mfa_options(conn, session.user_id).await?
146        } else {
147            vec![]
148        };
149
150        let session_ip_info = common::ip_to_pb(global, session.last_ip.ip())?;
151        let session_ua_info = session.last_user_agent.clone().map(common::ua_to_pb);
152
153        let new_token = pb::scufflecloud::core::v1::NewUserSessionToken {
154            id: token_id.to_string(),
155            encrypted_token,
156            user_id: session.user_id.to_string(),
157            expires_at: Some(token_expires_at.to_prost_timestamp_utc()),
158            session: Some(session.into_pb(session_ip_info, session_ua_info)),
159            mfa_options: mfa_options.into_iter().map(|o| o as i32).collect(),
160        };
161
162        Ok(new_token)
163    }
164}
165
166impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::InvalidateUserSessionRequest> {
167    type Principal = User;
168    type Resource = UserSession;
169    type Response = ();
170
171    const ACTION: Action = Action::InvalidateUserSession;
172
173    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
174        let global = &self.global::<G>()?;
175        let session = self.session_or_err()?;
176        common::get_user_by_id(global, session.user_id).await
177    }
178
179    async fn load_resource(&mut self, driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
180        let user_id: UserId = self
181            .get_ref()
182            .user_id
183            .parse()
184            .into_tonic_err_with_field_violation("id", "invalid ID")?;
185        let device_fingerprint = &self.get_ref().device_fingerprint;
186
187        let session = diesel::delete(user_sessions::dsl::user_sessions)
188            .filter(
189                user_sessions::dsl::user_id
190                    .eq(&user_id)
191                    .and(user_sessions::dsl::device_fingerprint.eq(device_fingerprint)),
192            )
193            .returning(UserSession::as_select())
194            .get_result(driver.conn().await?)
195            .await
196            .into_tonic_internal_err("failed to delete user session")?;
197
198        Ok(session)
199    }
200
201    async fn execute(
202        self,
203        _driver: &mut OperationDriver<'_, G>,
204        _principal: Self::Principal,
205        _resource: Self::Resource,
206    ) -> Result<Self::Response, tonic::Status> {
207        Ok(())
208    }
209}
210
211impl<G: core_traits::Global> Operation<G> for tonic::Request<pb::scufflecloud::core::v1::ListUserSessionsRequest> {
212    type Principal = User;
213    type Resource = CoreApplication;
214    type Response = pb::scufflecloud::core::v1::ListUserSessionsResponse;
215
216    const ACTION: Action = Action::ListUserSessions;
217
218    async fn load_principal(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Principal, tonic::Status> {
219        let global = &self.global::<G>()?;
220        let session = self.session_or_err()?;
221        common::get_user_by_id(global, session.user_id).await
222    }
223
224    async fn load_resource(&mut self, _driver: &mut OperationDriver<'_, G>) -> Result<Self::Resource, tonic::Status> {
225        Ok(CoreApplication)
226    }
227
228    async fn execute(
229        self,
230        _driver: &mut OperationDriver<'_, G>,
231        principal: Self::Principal,
232        _resource: Self::Resource,
233    ) -> Result<Self::Response, tonic::Status> {
234        let global = &self.global::<G>()?;
235        let mut db = global.db().await.into_tonic_internal_err("failed to connect to database")?;
236
237        let sessions = user_sessions::dsl::user_sessions
238            .filter(user_sessions::dsl::user_id.eq(principal.id))
239            .load::<UserSession>(&mut db)
240            .await
241            .into_tonic_internal_err("failed to load user sessions")?;
242
243        let sessions = sessions
244            .into_iter()
245            .map(|s| {
246                let last_ip = common::ip_to_pb(global, s.last_ip.ip())?;
247                let ua_info = s.last_user_agent.clone().map(common::ua_to_pb);
248                Ok(s.into_pb(last_ip, ua_info))
249            })
250            .collect::<Result<_, tonic::Status>>()?;
251
252        Ok(pb::scufflecloud::core::v1::ListUserSessionsResponse { sessions })
253    }
254}