// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Web sessions. //! //! The `sessions.id` primary key is the SHA-256 of the cookie value (never the //! cookie itself), so a database leak does not yield usable sessions. Lookup //! filters on `expires_at` so an expired row is treated as absent. use model::User; use crate::users::USER_COLUMNS; use crate::{Store, StoreError, now_ms}; /// Fields needed to create a session row. #[derive(Debug, Clone)] pub struct NewSession { /// The session id: SHA-256 (hex) of the cookie value. pub id: String, /// The authenticated user's id. pub user_id: String, /// The client user-agent, if known. pub user_agent: Option, /// The client IP, if known. pub ip: Option, /// Absolute expiry, epoch ms. pub expires_at: i64, } impl Store { /// Create a session row. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn create_session(&self, new: NewSession) -> Result<(), StoreError> { let now = now_ms(); sqlx::query( "INSERT INTO sessions (id, user_id, user_agent, ip, created_at, last_seen_at, expires_at) \ VALUES ($1, $2, $3, $4, $5, $5, $6)", ) .bind(new.id) .bind(new.user_id) .bind(new.user_agent) .bind(new.ip) .bind(now) .bind(new.expires_at) .execute(&self.pool) .await?; Ok(()) } /// Resolve the user behind a live (non-expired) session, or `None`. /// /// A disabled user resolves to `None` — a session must not outlive the /// account's ability to log in. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn user_for_session(&self, session_id: &str) -> Result, StoreError> { let sql = format!( "SELECT {cols} FROM users u JOIN sessions s ON s.user_id = u.id \ WHERE s.id = $1 AND s.expires_at > $2 AND u.disabled_at IS NULL", cols = USER_COLUMNS .split(", ") .map(|c| format!("u.{c}")) .collect::>() .join(", "), ); let row = sqlx::query(sqlx::AssertSqlSafe(sql)) .bind(session_id) .bind(now_ms()) .fetch_optional(&self.pool) .await?; Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?) } /// Update a session's `last_seen_at`, at most once a minute. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn touch_session(&self, session_id: &str) -> Result<(), StoreError> { let now = now_ms(); sqlx::query( "UPDATE sessions SET last_seen_at = $1 \ WHERE id = $2 AND last_seen_at < $3", ) .bind(now) .bind(session_id) .bind(now - 60_000) .execute(&self.pool) .await?; Ok(()) } /// Delete a session (logout). Returns `true` if a row was removed. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_session(&self, session_id: &str) -> Result { let deleted = sqlx::query("DELETE FROM sessions WHERE id = $1") .bind(session_id) .execute(&self.pool) .await? .rows_affected(); Ok(deleted > 0) } /// Delete every expired session. Returns how many rows were removed. /// /// # Errors /// /// Returns [`StoreError::Query`] on a database failure. pub async fn delete_expired_sessions(&self) -> Result { let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= $1") .bind(now_ms()) .execute(&self.pool) .await? .rows_affected(); Ok(deleted) } }