Skip to main content

rustio_core/auth/
sessions.rs

1//! DB-backed sessions with a background expiry sweeper.
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
4use chrono::{Duration, Utc};
5use rand::RngCore;
6
7use crate::error::Result;
8use crate::orm::{Db, Row};
9
10use super::role::Role;
11use super::users::Identity;
12
13/// The cookie name we look for and set. Constant so middleware and
14/// handlers stay in sync.
15pub const SESSION_COOKIE: &str = "rustio_session";
16
17const SESSION_LENGTH_DAYS: i64 = 14;
18
19pub async fn init_session_tables(db: &Db) -> Result<()> {
20    sqlx::query(
21        "CREATE TABLE IF NOT EXISTS rustio_sessions (
22            token      TEXT PRIMARY KEY,
23            user_id    BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
24            expires_at TIMESTAMPTZ NOT NULL,
25            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
26            last_seen  TIMESTAMPTZ NOT NULL DEFAULT NOW()
27        )",
28    )
29    .execute(db.pool())
30    .await?;
31
32    sqlx::query(
33        "CREATE INDEX IF NOT EXISTS rustio_sessions_user_idx ON rustio_sessions (user_id)",
34    )
35    .execute(db.pool())
36    .await?;
37
38    sqlx::query(
39        "CREATE INDEX IF NOT EXISTS rustio_sessions_expires_idx ON rustio_sessions (expires_at)",
40    )
41    .execute(db.pool())
42    .await?;
43
44    Ok(())
45}
46
47/// Phase 10/a — additive schema upgrade for session-level metadata.
48/// Idempotent; safe to call on every boot. Reads consumed by the
49/// built-in user profile page (Sessions tab + last-login IP). The
50/// auth path itself never reads these columns.
51pub(crate) async fn migrate_session_schema(db: &Db) -> Result<()> {
52    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS ip TEXT")
53        .execute(db.pool())
54        .await?;
55    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS user_agent TEXT")
56        .execute(db.pool())
57        .await?;
58    Ok(())
59}
60
61pub async fn create_session(db: &Db, user_id: i64) -> Result<String> {
62    let token = random_token();
63    let expires = Utc::now() + Duration::days(SESSION_LENGTH_DAYS);
64    sqlx::query(
65        "INSERT INTO rustio_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)",
66    )
67    .bind(&token)
68    .bind(user_id)
69    .bind(expires)
70    .execute(db.pool())
71    .await?;
72    Ok(token)
73}
74
75pub async fn delete_session(db: &Db, token: &str) -> Result<()> {
76    sqlx::query("DELETE FROM rustio_sessions WHERE token = $1")
77        .bind(token)
78        .execute(db.pool())
79        .await?;
80    Ok(())
81}
82
83pub async fn identity_from_session(db: &Db, token: &str) -> Result<Option<Identity>> {
84    let row = sqlx::query(
85        "SELECT u.id, u.email, u.role, u.is_active, u.is_demo, u.demo_label, s.expires_at
86           FROM rustio_sessions s
87           JOIN rustio_users u ON u.id = s.user_id
88          WHERE s.token = $1",
89    )
90    .bind(token)
91    .fetch_optional(db.pool())
92    .await?;
93
94    let row = match row {
95        Some(r) => r,
96        None => return Ok(None),
97    };
98    let r = Row::from_pg(&row);
99    let expires_at = r.get_datetime("expires_at")?;
100    if expires_at < Utc::now() {
101        // Don't bother keeping the stale row around. Fire-and-forget.
102        let _ = delete_session(db, token).await;
103        return Ok(None);
104    }
105
106    // Touch last_seen without holding the request back.
107    let db_clone = db.clone();
108    let token_owned = token.to_string();
109    tokio::spawn(async move {
110        let _ = sqlx::query("UPDATE rustio_sessions SET last_seen = NOW() WHERE token = $1")
111            .bind(&token_owned)
112            .execute(db_clone.pool())
113            .await;
114    });
115
116    Ok(Some(Identity {
117        user_id: r.get_i64("id")?,
118        email: r.get_string("email")?,
119        role: Role::parse(&r.get_string("role")?)?,
120        is_active: r.get_bool("is_active")?,
121        is_demo: r.get_bool("is_demo")?,
122        demo_label: r.get_optional_string("demo_label")?,
123    }))
124}
125
126/// Delete all expired sessions. Intended to be called periodically
127/// from a background task (see `background::spawn_session_sweeper`).
128pub async fn purge_expired_sessions(db: &Db) -> Result<u64> {
129    let result = sqlx::query("DELETE FROM rustio_sessions WHERE expires_at < NOW()")
130        .execute(db.pool())
131        .await?;
132    Ok(result.rows_affected())
133}
134
135pub fn session_token_from_cookie(cookie_header: &str) -> Option<String> {
136    let prefix = format!("{SESSION_COOKIE}=");
137    for part in cookie_header.split(';') {
138        let part = part.trim();
139        if let Some(v) = part.strip_prefix(&prefix) {
140            return Some(v.to_string());
141        }
142    }
143    None
144}
145
146fn random_token() -> String {
147    let mut bytes = [0u8; 32];
148    rand::thread_rng().fill_bytes(&mut bytes);
149    URL_SAFE_NO_PAD.encode(bytes)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn extracts_token_from_cookie_header() {
158        let h = "foo=bar; rustio_session=abc123; other=x";
159        assert_eq!(session_token_from_cookie(h), Some("abc123".into()));
160    }
161
162    #[test]
163    fn returns_none_when_cookie_missing() {
164        let h = "foo=bar; other=x";
165        assert!(session_token_from_cookie(h).is_none());
166    }
167
168    #[test]
169    fn random_token_has_reasonable_entropy() {
170        // Rough sanity check — two consecutive tokens should differ.
171        assert_ne!(random_token(), random_token());
172    }
173
174    /// E.5 (PG) — Phase 10/a: existing session create → identity → delete
175    /// path keeps working after `migrate_session_schema` adds the new
176    /// optional columns. Smoke; the new columns aren't populated yet.
177    #[tokio::test]
178    #[ignore = "needs `RUSTIO_TEST_DB=1` + a running postgres (URL via RUSTIO_TEST_DATABASE_URL or default)"]
179    async fn existing_session_crud_unaffected_by_migration() {
180        let url = std::env::var("RUSTIO_TEST_DATABASE_URL")
181            .unwrap_or_else(|_| "postgres://postgres:dev@localhost:5432/rustio_dev".into());
182        let opts = crate::orm::DbOptions {
183            max_connections: 2,
184            ..crate::orm::DbOptions::default()
185        };
186        let db = crate::orm::Db::connect_with(&url, opts).await.unwrap();
187        crate::auth::init_tables(&db).await.unwrap();
188
189        let pid = std::process::id();
190        let nanos = std::time::SystemTime::now()
191            .duration_since(std::time::UNIX_EPOCH)
192            .unwrap()
193            .as_nanos();
194        let email = format!("sess_smoke_{pid}_{nanos}@example.test");
195        let user_id = crate::auth::create_user(&db, &email, "secret-pw-123", Role::User)
196            .await
197            .unwrap();
198
199        let token = create_session(&db, user_id).await.unwrap();
200        let identity = identity_from_session(&db, &token)
201            .await
202            .unwrap()
203            .expect("session resolves to identity");
204        assert_eq!(identity.user_id, user_id);
205        assert_eq!(identity.email, email);
206
207        delete_session(&db, &token).await.unwrap();
208        assert!(identity_from_session(&db, &token).await.unwrap().is_none());
209
210        let _ = sqlx::query("DELETE FROM rustio_users WHERE id = $1")
211            .bind(user_id)
212            .execute(db.pool())
213            .await;
214    }
215}