forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.6 KiB
1//! Page: web sessions (`/settings/sessions`). Revoke one or all other sessions.
2
3use forge_core::{AuditAction, Session};
4use serde::Deserialize;
5use topcoat::Result;
6use topcoat::context::{Cx, app_context};
7use topcoat::router::content::Form;
8use topcoat::router::error::{SeeOther, see_other};
9use topcoat::router::{page, route};
10use topcoat::view::{View, component, view};
11
12use crate::audit::{self, Actor, Target};
13use crate::auth::{logout, require_csrf, require_user};
14use crate::i18n::{self, Key, Lang, session_meta, t};
15use crate::state::AppState;
16use crate::views::shell;
17
18#[derive(Deserialize)]
19struct RevokeForm {
20 #[serde(default)]
21 csrf: String,
22 #[serde(default)]
23 id: String,
24}
25
26#[derive(Deserialize)]
27struct CsrfForm {
28 #[serde(default)]
29 csrf: String,
30}
31
32#[component]
33async 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")]
78async 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")]
93async 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: &current.id,
104 name: "",
105 },
106 serde_json::Value::Null,
107 )
108 .await;
109 logout(cx, &current).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")]
135async 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)]
164mod 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}