Tahinli/forge
public0 watching
★ 0
| 1 | //! Page: web sessions (`/settings/sessions`). Revoke one or all other sessions. |
| 2 | |
| 3 | use forge_core::{AuditAction, Session}; |
| 4 | use serde::Deserialize; |
| 5 | use topcoat::Result; |
| 6 | use topcoat::context::{Cx, app_context}; |
| 7 | use topcoat::router::content::Form; |
| 8 | use topcoat::router::error::{SeeOther, see_other}; |
| 9 | use topcoat::router::{page, route}; |
| 10 | use topcoat::view::{View, component, view}; |
| 11 | |
| 12 | use crate::audit::{self, Actor, Target}; |
| 13 | use crate::auth::{logout, require_csrf, require_user}; |
| 14 | use crate::i18n::{self, Key, Lang, session_meta, t}; |
| 15 | use crate::state::AppState; |
| 16 | use crate::views::shell; |
| 17 | |
| 18 | #[derive(Deserialize)] |
| 19 | struct RevokeForm { |
| 20 | #[serde(default)] |
| 21 | csrf: String, |
| 22 | #[serde(default)] |
| 23 | id: String, |
| 24 | } |
| 25 | |
| 26 | #[derive(Deserialize)] |
| 27 | struct CsrfForm { |
| 28 | #[serde(default)] |
| 29 | csrf: String, |
| 30 | } |
| 31 | |
| 32 | #[component] |
| 33 | async fn sessions_view( |
| 34 | lang: Lang, |
| 35 | csrf: &str, |
| 36 | current_id: &str, |
| 37 | sessions: Vec<Session>, |
| 38 | ) -> Result<impl View> { |
| 39 | let others = sessions.iter().filter(|s| s.id != current_id).count(); |
| 40 | Ok(view! { |
| 41 | <section class="page-head"> |
| 42 | <h1>(t(lang, Key::Sessions))</h1> |
| 43 | </section> |
| 44 | <section class="card"> |
| 45 | <ul class="item-list"> |
| 46 | for session in sessions.into_iter() { |
| 47 | <li class="item"> |
| 48 | <div class="item-main"> |
| 49 | <strong>(session.user_agent.clone().unwrap_or_else(|| t(lang, Key::UnknownBrowser).into()))</strong> |
| 50 | <span class="muted small">(session_meta(lang, session.created_at.as_str(), session.last_seen.as_str(), session.expires_at.as_str()))</span> |
| 51 | if session.id == current_id { |
| 52 | <span class="badge public">(t(lang, Key::ThisSession))</span> |
| 53 | } |
| 54 | </div> |
| 55 | if session.id != current_id { |
| 56 | <form method="post" action="/settings/sessions/revoke"> |
| 57 | <input type="hidden" name="csrf" value=(csrf)> |
| 58 | <input type="hidden" name="id" value=(session.id.clone())> |
| 59 | <button type="submit" class="danger">(t(lang, Key::Revoke))</button> |
| 60 | </form> |
| 61 | } |
| 62 | </li> |
| 63 | } |
| 64 | </ul> |
| 65 | </section> |
| 66 | if others > 0 { |
| 67 | <section class="card"> |
| 68 | <form method="post" action="/settings/sessions/revoke-others" class="inline-form"> |
| 69 | <input type="hidden" name="csrf" value=(csrf)> |
| 70 | <button type="submit" class="danger">(t(lang, Key::RevokeAllOthers))</button> |
| 71 | </form> |
| 72 | </section> |
| 73 | } |
| 74 | }) |
| 75 | } |
| 76 | |
| 77 | #[page("/settings/sessions")] |
| 78 | async fn sessions_page(cx: &Cx) -> Result<impl View> { |
| 79 | let viewer = require_user(cx).await?; |
| 80 | let lang = i18n::lang(cx, Some(&viewer.0)); |
| 81 | let sessions = app_context::<AppState>(cx) |
| 82 | .store |
| 83 | .list_sessions(&viewer.0.id) |
| 84 | .await?; |
| 85 | let csrf = viewer.1.csrf_token.clone(); |
| 86 | let current_id = viewer.1.id.clone(); |
| 87 | Ok(view! { |
| 88 | shell(title: t(lang, Key::Sessions), viewer: Some(&viewer), sessions_view(lang: lang, csrf: csrf.as_str(), current_id: current_id.as_str(), sessions: sessions)) |
| 89 | }) |
| 90 | } |
| 91 | |
| 92 | #[route(POST "/settings/sessions/revoke")] |
| 93 | async fn sessions_revoke(cx: &Cx, Form(form): Form<RevokeForm>) -> Result<SeeOther> { |
| 94 | let (user, current) = require_csrf(cx, &form.csrf).await?; |
| 95 | if form.id == current.id { |
| 96 | audit::record( |
| 97 | &app_context::<AppState>(cx).store, |
| 98 | Some(cx), |
| 99 | Actor::User(&user), |
| 100 | AuditAction::SessionRevoked, |
| 101 | Target { |
| 102 | kind: "session", |
| 103 | id: ¤t.id, |
| 104 | name: "", |
| 105 | }, |
| 106 | serde_json::Value::Null, |
| 107 | ) |
| 108 | .await; |
| 109 | logout(cx, ¤t).await?; |
| 110 | return Ok(see_other("/")); |
| 111 | } |
| 112 | let store = &app_context::<AppState>(cx).store; |
| 113 | // Only revoke sessions that belong to the signed-in user. |
| 114 | let owned = store.list_sessions(&user.id).await?; |
| 115 | if owned.iter().any(|s| s.id == form.id) { |
| 116 | store.delete_session(&form.id).await?; |
| 117 | audit::record( |
| 118 | store, |
| 119 | Some(cx), |
| 120 | Actor::User(&user), |
| 121 | AuditAction::SessionRevoked, |
| 122 | Target { |
| 123 | kind: "session", |
| 124 | id: &form.id, |
| 125 | name: "", |
| 126 | }, |
| 127 | serde_json::Value::Null, |
| 128 | ) |
| 129 | .await; |
| 130 | } |
| 131 | Ok(see_other("/settings/sessions")) |
| 132 | } |
| 133 | |
| 134 | #[route(POST "/settings/sessions/revoke-others")] |
| 135 | async fn sessions_revoke_others(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> { |
| 136 | let (user, current) = require_csrf(cx, &form.csrf).await?; |
| 137 | let store = &app_context::<AppState>(cx).store; |
| 138 | let mut revoked = 0_u64; |
| 139 | for session in store.list_sessions(&user.id).await? { |
| 140 | if session.id != current.id { |
| 141 | store.delete_session(&session.id).await?; |
| 142 | revoked += 1; |
| 143 | } |
| 144 | } |
| 145 | if revoked > 0 { |
| 146 | audit::record( |
| 147 | store, |
| 148 | Some(cx), |
| 149 | Actor::User(&user), |
| 150 | AuditAction::SessionRevoked, |
| 151 | Target { |
| 152 | kind: "user", |
| 153 | id: &user.id, |
| 154 | name: &user.username, |
| 155 | }, |
| 156 | serde_json::json!({ "others": revoked }), |
| 157 | ) |
| 158 | .await; |
| 159 | } |
| 160 | Ok(see_other("/settings/sessions")) |
| 161 | } |
| 162 | |
| 163 | #[cfg(test)] |
| 164 | mod tests { |
| 165 | use forge_core::{AuditAction, AuditFilter}; |
| 166 | use topcoat::router::request::Request; |
| 167 | use topcoat::router::{Body, to_bytes}; |
| 168 | |
| 169 | use crate::auth::SESSION_COOKIE; |
| 170 | |
| 171 | const CSRF: &str = "sessions-test-csrf"; |
| 172 | |
| 173 | #[tokio::test] |
| 174 | async fn revoking_the_current_session_is_audited() { |
| 175 | let dir = tempfile::tempdir().expect("tempdir"); |
| 176 | let state = crate::test_support::test_state(dir.path()).await; |
| 177 | let store = state.store.clone(); |
| 178 | let router = crate::test_support::app_router(state.clone()); |
| 179 | let alice = store |
| 180 | .create_user("alice", None, "x", false) |
| 181 | .await |
| 182 | .expect("alice"); |
| 183 | crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; |
| 184 | let current = store |
| 185 | .list_sessions(&alice.id) |
| 186 | .await |
| 187 | .expect("sessions") |
| 188 | .pop() |
| 189 | .expect("one session"); |
| 190 | |
| 191 | let req = Request::builder() |
| 192 | .method("POST") |
| 193 | .uri("/settings/sessions/revoke") |
| 194 | .header("cookie", format!("__Host-{SESSION_COOKIE}=alice-token")) |
| 195 | .header("content-type", "application/x-www-form-urlencoded") |
| 196 | .body(Body::from(format!("csrf={CSRF}&id={}", current.id))) |
| 197 | .expect("request"); |
| 198 | let res = router.handle(req).await; |
| 199 | assert_eq!(res.status().as_u16(), 303); |
| 200 | let _ = to_bytes(res.into_body(), usize::MAX).await; |
| 201 | |
| 202 | assert!( |
| 203 | store |
| 204 | .list_sessions(&alice.id) |
| 205 | .await |
| 206 | .expect("sessions") |
| 207 | .is_empty(), |
| 208 | "the session is gone" |
| 209 | ); |
| 210 | let rows = store |
| 211 | .list_audit( |
| 212 | &AuditFilter { |
| 213 | action: Some(AuditAction::SessionRevoked), |
| 214 | ..Default::default() |
| 215 | }, |
| 216 | None, |
| 217 | 10, |
| 218 | ) |
| 219 | .await |
| 220 | .expect("audit"); |
| 221 | assert_eq!(rows.len(), 1, "one SessionRevoked entry"); |
| 222 | assert_eq!(rows[0].target_kind, "session"); |
| 223 | assert_eq!(rows[0].target_id, current.id); |
| 224 | assert_eq!(rows[0].actor_id.as_deref(), Some(alice.id.as_str())); |
| 225 | } |
| 226 | } |