fabrica

hanna/fabrica

4110 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Web sessions.
6//!
7//! The `sessions.id` primary key is the SHA-256 of the cookie value (never the
8//! cookie itself), so a database leak does not yield usable sessions. Lookup
9//! filters on `expires_at` so an expired row is treated as absent.
10
11use model::User;
12
13use crate::users::USER_COLUMNS;
14use crate::{Store, StoreError, now_ms};
15
16/// Fields needed to create a session row.
17#[derive(Debug, Clone)]
18pub struct NewSession {
19 /// The session id: SHA-256 (hex) of the cookie value.
20 pub id: String,
21 /// The authenticated user's id.
22 pub user_id: String,
23 /// The client user-agent, if known.
24 pub user_agent: Option<String>,
25 /// The client IP, if known.
26 pub ip: Option<String>,
27 /// Absolute expiry, epoch ms.
28 pub expires_at: i64,
29}
30
31impl Store {
32 /// Create a session row.
33 ///
34 /// # Errors
35 ///
36 /// Returns [`StoreError::Query`] on a database failure.
37 pub async fn create_session(&self, new: NewSession) -> Result<(), StoreError> {
38 let now = now_ms();
39 sqlx::query(
40 "INSERT INTO sessions (id, user_id, user_agent, ip, created_at, last_seen_at, expires_at) \
41 VALUES ($1, $2, $3, $4, $5, $5, $6)",
42 )
43 .bind(new.id)
44 .bind(new.user_id)
45 .bind(new.user_agent)
46 .bind(new.ip)
47 .bind(now)
48 .bind(new.expires_at)
49 .execute(&self.pool)
50 .await?;
51 Ok(())
52 }
53
54 /// Resolve the user behind a live (non-expired) session, or `None`.
55 ///
56 /// A disabled user resolves to `None` — a session must not outlive the
57 /// account's ability to log in.
58 ///
59 /// # Errors
60 ///
61 /// Returns [`StoreError::Query`] on a database failure.
62 pub async fn user_for_session(&self, session_id: &str) -> Result<Option<User>, StoreError> {
63 let sql = format!(
64 "SELECT {cols} FROM users u JOIN sessions s ON s.user_id = u.id \
65 WHERE s.id = $1 AND s.expires_at > $2 AND u.disabled_at IS NULL",
66 cols = USER_COLUMNS
67 .split(", ")
68 .map(|c| format!("u.{c}"))
69 .collect::<Vec<_>>()
70 .join(", "),
71 );
72 let row = sqlx::query(sqlx::AssertSqlSafe(sql))
73 .bind(session_id)
74 .bind(now_ms())
75 .fetch_optional(&self.pool)
76 .await?;
77 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
78 }
79
80 /// Update a session's `last_seen_at`, at most once a minute.
81 ///
82 /// # Errors
83 ///
84 /// Returns [`StoreError::Query`] on a database failure.
85 pub async fn touch_session(&self, session_id: &str) -> Result<(), StoreError> {
86 let now = now_ms();
87 sqlx::query(
88 "UPDATE sessions SET last_seen_at = $1 \
89 WHERE id = $2 AND last_seen_at < $3",
90 )
91 .bind(now)
92 .bind(session_id)
93 .bind(now - 60_000)
94 .execute(&self.pool)
95 .await?;
96 Ok(())
97 }
98
99 /// Delete a session (logout). Returns `true` if a row was removed.
100 ///
101 /// # Errors
102 ///
103 /// Returns [`StoreError::Query`] on a database failure.
104 pub async fn delete_session(&self, session_id: &str) -> Result<bool, StoreError> {
105 let deleted = sqlx::query("DELETE FROM sessions WHERE id = $1")
106 .bind(session_id)
107 .execute(&self.pool)
108 .await?
109 .rows_affected();
110 Ok(deleted > 0)
111 }
112
113 /// Delete every expired session. Returns how many rows were removed.
114 ///
115 /// # Errors
116 ///
117 /// Returns [`StoreError::Query`] on a database failure.
118 pub async fn delete_expired_sessions(&self) -> Result<u64, StoreError> {
119 let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= $1")
120 .bind(now_ms())
121 .execute(&self.pool)
122 .await?
123 .rows_affected();
124 Ok(deleted)
125 }
126}