rustio_core/auth/
sessions.rs1use 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
13pub 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
47pub(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 let _ = delete_session(db, token).await;
103 return Ok(None);
104 }
105
106 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
126pub 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 assert_ne!(random_token(), random_token());
172 }
173
174 #[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}