scufflecloud_core/operations/
user_sessions.rs

1use diesel::{BoolExpressionMethods, ExpressionMethods, SelectableHelper};
2use diesel_async::RunQueryDsl;
3use tonic_types::{ErrorDetails, StatusExt};
4
5use crate::cedar::Action;
6use crate::chrono_ext::ChronoDateTimeExt;
7use crate::http_ext::RequestExt;
8use crate::models::{User, UserSession, UserSessionTokenId};
9use crate::operations::Operation;
10use crate::schema::user_sessions;
11use crate::std_ext::{OptionExt, ResultExt};
12use crate::{CoreConfig, common, totp};
13
14impl<G: CoreConfig> 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(
22        &mut self,
23        _conn: &mut diesel_async::AsyncPgConnection,
24    ) -> Result<Self::Principal, tonic::Status> {
25        let global = &self.global::<G>()?;
26        let session = self.session_or_err()?;
27        common::get_user_by_id(global, session.user_id).await
28    }
29
30    async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
31        let session = self.session_or_err()?;
32        Ok(session.clone())
33    }
34
35    async fn execute(
36        self,
37        conn: &mut diesel_async::AsyncPgConnection,
38        _principal: Self::Principal,
39        resource: Self::Resource,
40    ) -> Result<Self::Response, tonic::Status> {
41        let global = &self.global::<G>()?;
42        let payload = self.into_inner();
43
44        // Verify MFA challenge response
45        match payload.response.require("response")? {
46            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::Totp(
47                pb::scufflecloud::core::v1::ValidateMfaForUserSessionTotp { code },
48            ) => {
49                totp::process_token(conn, resource.user_id, &code).await?;
50            }
51            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::Webauthn(
52                pb::scufflecloud::core::v1::ValidateMfaForUserSessionWebauthn { response_json },
53            ) => {
54                let pk_cred: webauthn_rs::prelude::PublicKeyCredential = serde_json::from_str(&response_json)
55                    .into_tonic_err_with_field_violation("response_json", "invalid public key credential")?;
56                common::finish_webauthn_authentication(global, conn, resource.user_id, &pk_cred).await?;
57            }
58            pb::scufflecloud::core::v1::validate_mfa_for_user_session_request::Response::RecoveryCode(
59                pb::scufflecloud::core::v1::ValidateMfaForUserSessionRecoveryCode { code },
60            ) => {
61                common::process_recovery_code(conn, resource.user_id, &code).await?;
62            }
63        }
64
65        // Set mfa_pending=false and reset session expiry
66        let session = diesel::update(user_sessions::dsl::user_sessions)
67            .filter(
68                user_sessions::dsl::user_id
69                    .eq(&resource.user_id)
70                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
71            )
72            .set((
73                user_sessions::dsl::mfa_pending.eq(false),
74                user_sessions::dsl::expires_at.eq(chrono::Utc::now() + global.user_session_timeout()),
75            ))
76            .returning(UserSession::as_select())
77            .get_result::<UserSession>(conn)
78            .await
79            .into_tonic_internal_err("failed to update user session")?;
80
81        Ok(session.into())
82    }
83}
84
85pub(crate) struct RefreshUserSessionRequest;
86
87impl<G: CoreConfig> Operation<G> for tonic::Request<RefreshUserSessionRequest> {
88    type Principal = User;
89    type Resource = UserSession;
90    type Response = pb::scufflecloud::core::v1::NewUserSessionToken;
91
92    const ACTION: Action = Action::RefreshUserSession;
93
94    async fn load_principal(
95        &mut self,
96        _conn: &mut diesel_async::AsyncPgConnection,
97    ) -> Result<Self::Principal, tonic::Status> {
98        let global = &self.global::<G>()?;
99        let session = self.session_or_err()?;
100        common::get_user_by_id(global, session.user_id).await
101    }
102
103    async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
104        let session = self.expired_session_or_err()?;
105        Ok(session.clone())
106    }
107
108    async fn execute(
109        self,
110        conn: &mut diesel_async::AsyncPgConnection,
111        _principal: Self::Principal,
112        resource: Self::Resource,
113    ) -> Result<Self::Response, tonic::Status> {
114        let global = &self.global::<G>()?;
115
116        let token_id = UserSessionTokenId::new();
117        let token = common::generate_random_bytes().into_tonic_internal_err("failed to generate token")?;
118        let encrypted_token = common::encrypt_token(resource.device_algorithm.into(), &token, &resource.device_pk_data)?;
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.user_session_token_timeout()),
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 new_token = pb::scufflecloud::core::v1::NewUserSessionToken {
151            id: token_id.to_string(),
152            encrypted_token,
153            expires_at: Some(token_expires_at.to_prost_timestamp_utc()),
154            session_mfa_pending: session.mfa_pending,
155            mfa_options: mfa_options.into_iter().map(|o| o as i32).collect(),
156        };
157
158        Ok(new_token)
159    }
160}
161
162pub(crate) struct InvalidateUserSessionRequest;
163
164impl<G: CoreConfig> Operation<G> for tonic::Request<InvalidateUserSessionRequest> {
165    type Principal = User;
166    type Resource = UserSession;
167    type Response = ();
168
169    const ACTION: Action = Action::InvalidateUserSession;
170    const TRANSACTION: bool = false;
171
172    async fn load_principal(
173        &mut self,
174        _conn: &mut diesel_async::AsyncPgConnection,
175    ) -> Result<Self::Principal, tonic::Status> {
176        let global = &self.global::<G>()?;
177        let session = self.session_or_err()?;
178        common::get_user_by_id(global, session.user_id).await
179    }
180
181    async fn load_resource(&mut self, _conn: &mut diesel_async::AsyncPgConnection) -> Result<Self::Resource, tonic::Status> {
182        let session = self.session_or_err()?;
183        Ok(session.clone())
184    }
185
186    async fn execute(
187        self,
188        conn: &mut diesel_async::AsyncPgConnection,
189        _principal: Self::Principal,
190        resource: Self::Resource,
191    ) -> Result<Self::Response, tonic::Status> {
192        diesel::delete(user_sessions::dsl::user_sessions)
193            .filter(
194                user_sessions::dsl::user_id
195                    .eq(&resource.user_id)
196                    .and(user_sessions::dsl::device_fingerprint.eq(&resource.device_fingerprint)),
197            )
198            .execute(conn)
199            .await
200            .into_tonic_internal_err("failed to delete user session")?;
201
202        Ok(())
203    }
204}