//! Page: web sessions (`/settings/sessions`). Revoke one or all other sessions. use forge_core::{AuditAction, Session}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::{page, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{logout, require_csrf, require_user}; use crate::i18n::{self, Key, Lang, session_meta, t}; use crate::state::AppState; use crate::views::shell; #[derive(Deserialize)] struct RevokeForm { #[serde(default)] csrf: String, #[serde(default)] id: String, } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[component] async fn sessions_view( lang: Lang, csrf: &str, current_id: &str, sessions: Vec, ) -> Result { let others = sessions.iter().filter(|s| s.id != current_id).count(); Ok(view! {

(t(lang, Key::Sessions))

    for session in sessions.into_iter() {
  • (session.user_agent.clone().unwrap_or_else(|| t(lang, Key::UnknownBrowser).into())) (session_meta(lang, session.created_at.as_str(), session.last_seen.as_str(), session.expires_at.as_str())) if session.id == current_id { (t(lang, Key::ThisSession)) }
    if session.id != current_id {
    }
  • }
if others > 0 {
} }) } #[page("/settings/sessions")] async fn sessions_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let sessions = app_context::(cx) .store .list_sessions(&viewer.0.id) .await?; let csrf = viewer.1.csrf_token.clone(); let current_id = viewer.1.id.clone(); Ok(view! { 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)) }) } #[route(POST "/settings/sessions/revoke")] async fn sessions_revoke(cx: &Cx, Form(form): Form) -> Result { let (user, current) = require_csrf(cx, &form.csrf).await?; if form.id == current.id { audit::record( &app_context::(cx).store, Some(cx), Actor::User(&user), AuditAction::SessionRevoked, Target { kind: "session", id: ¤t.id, name: "", }, serde_json::Value::Null, ) .await; logout(cx, ¤t).await?; return Ok(see_other("/")); } let store = &app_context::(cx).store; // Only revoke sessions that belong to the signed-in user. let owned = store.list_sessions(&user.id).await?; if owned.iter().any(|s| s.id == form.id) { store.delete_session(&form.id).await?; audit::record( store, Some(cx), Actor::User(&user), AuditAction::SessionRevoked, Target { kind: "session", id: &form.id, name: "", }, serde_json::Value::Null, ) .await; } Ok(see_other("/settings/sessions")) } #[route(POST "/settings/sessions/revoke-others")] async fn sessions_revoke_others(cx: &Cx, Form(form): Form) -> Result { let (user, current) = require_csrf(cx, &form.csrf).await?; let store = &app_context::(cx).store; let mut revoked = 0_u64; for session in store.list_sessions(&user.id).await? { if session.id != current.id { store.delete_session(&session.id).await?; revoked += 1; } } if revoked > 0 { audit::record( store, Some(cx), Actor::User(&user), AuditAction::SessionRevoked, Target { kind: "user", id: &user.id, name: &user.username, }, serde_json::json!({ "others": revoked }), ) .await; } Ok(see_other("/settings/sessions")) } #[cfg(test)] mod tests { use forge_core::{AuditAction, AuditFilter}; use topcoat::router::request::Request; use topcoat::router::{Body, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "sessions-test-csrf"; #[tokio::test] async fn revoking_the_current_session_is_audited() { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store = state.store.clone(); let router = crate::test_support::app_router(state.clone()); let alice = store .create_user("alice", None, "x", false) .await .expect("alice"); crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; let current = store .list_sessions(&alice.id) .await .expect("sessions") .pop() .expect("one session"); let req = Request::builder() .method("POST") .uri("/settings/sessions/revoke") .header("cookie", format!("__Host-{SESSION_COOKIE}=alice-token")) .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(format!("csrf={CSRF}&id={}", current.id))) .expect("request"); let res = router.handle(req).await; assert_eq!(res.status().as_u16(), 303); let _ = to_bytes(res.into_body(), usize::MAX).await; assert!( store .list_sessions(&alice.id) .await .expect("sessions") .is_empty(), "the session is gone" ); let rows = store .list_audit( &AuditFilter { action: Some(AuditAction::SessionRevoked), ..Default::default() }, None, 10, ) .await .expect("audit"); assert_eq!(rows.len(), 1, "one SessionRevoked entry"); assert_eq!(rows[0].target_kind, "session"); assert_eq!(rows[0].target_id, current.id); assert_eq!(rows[0].actor_id.as_deref(), Some(alice.id.as_str())); } }