rustio_admin/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("CREATE INDEX IF NOT EXISTS rustio_sessions_user_idx ON rustio_sessions (user_id)")
33 .execute(db.pool())
34 .await?;
35
36 sqlx::query(
37 "CREATE INDEX IF NOT EXISTS rustio_sessions_expires_idx ON rustio_sessions (expires_at)",
38 )
39 .execute(db.pool())
40 .await?;
41
42 Ok(())
43}
44
45pub(crate) async fn migrate_session_schema(db: &Db) -> Result<()> {
49 sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS ip TEXT")
50 .execute(db.pool())
51 .await?;
52 sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS user_agent TEXT")
53 .execute(db.pool())
54 .await?;
55 Ok(())
56}
57
58pub async fn create_session(db: &Db, user_id: i64) -> Result<String> {
59 let token = random_token();
60 let expires = Utc::now() + Duration::days(SESSION_LENGTH_DAYS);
61 sqlx::query("INSERT INTO rustio_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)")
62 .bind(&token)
63 .bind(user_id)
64 .bind(expires)
65 .execute(db.pool())
66 .await?;
67 Ok(token)
68}
69
70pub async fn delete_session(db: &Db, token: &str) -> Result<()> {
71 sqlx::query("DELETE FROM rustio_sessions WHERE token = $1")
72 .bind(token)
73 .execute(db.pool())
74 .await?;
75 Ok(())
76}
77
78pub async fn identity_from_session(db: &Db, token: &str) -> Result<Option<Identity>> {
79 let row = sqlx::query(
80 "SELECT u.id, u.email, u.role, u.is_active, u.is_demo, u.demo_label, s.expires_at
81 FROM rustio_sessions s
82 JOIN rustio_users u ON u.id = s.user_id
83 WHERE s.token = $1",
84 )
85 .bind(token)
86 .fetch_optional(db.pool())
87 .await?;
88
89 let row = match row {
90 Some(r) => r,
91 None => return Ok(None),
92 };
93 let r = Row::from_pg(&row);
94 let expires_at = r.get_datetime("expires_at")?;
95 if expires_at < Utc::now() {
96 let _ = delete_session(db, token).await;
98 return Ok(None);
99 }
100
101 let db_clone = db.clone();
103 let token_owned = token.to_string();
104 tokio::spawn(async move {
105 let _ = sqlx::query("UPDATE rustio_sessions SET last_seen = NOW() WHERE token = $1")
106 .bind(&token_owned)
107 .execute(db_clone.pool())
108 .await;
109 });
110
111 Ok(Some(Identity {
112 user_id: r.get_i64("id")?,
113 email: r.get_string("email")?,
114 role: Role::parse(&r.get_string("role")?)?,
115 is_active: r.get_bool("is_active")?,
116 is_demo: r.get_bool("is_demo")?,
117 demo_label: r.get_optional_string("demo_label")?,
118 }))
119}
120
121pub async fn purge_expired_sessions(db: &Db) -> Result<u64> {
124 let result = sqlx::query("DELETE FROM rustio_sessions WHERE expires_at < NOW()")
125 .execute(db.pool())
126 .await?;
127 Ok(result.rows_affected())
128}
129
130pub fn session_token_from_cookie(cookie_header: &str) -> Option<String> {
131 let prefix = format!("{SESSION_COOKIE}=");
132 for part in cookie_header.split(';') {
133 let part = part.trim();
134 if let Some(v) = part.strip_prefix(&prefix) {
135 return Some(v.to_string());
136 }
137 }
138 None
139}
140
141fn random_token() -> String {
142 let mut bytes = [0u8; 32];
143 rand::thread_rng().fill_bytes(&mut bytes);
144 URL_SAFE_NO_PAD.encode(bytes)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn extracts_token_from_cookie_header() {
153 let h = "foo=bar; rustio_session=abc123; other=x";
154 assert_eq!(session_token_from_cookie(h), Some("abc123".into()));
155 }
156
157 #[test]
158 fn returns_none_when_cookie_missing() {
159 let h = "foo=bar; other=x";
160 assert!(session_token_from_cookie(h).is_none());
161 }
162
163 #[test]
164 fn random_token_has_reasonable_entropy() {
165 assert_ne!(random_token(), random_token());
167 }
168}