fix(security): validate session tokens against database

- Add sessions table to store session tokens with expiration
- Create Session domain model and SessionRepository trait
- Implement SqlSessionRepository for session persistence
- Update is_authenticated() to validate tokens against database
- Sessions expire after 30 days
- Session tokens hashed with SHA-256 before storage
- Delete sessions from database on logout
- Update all page handlers to properly validate sessions

This prevents session hijacking by ensuring only valid, unexpired
tokens stored in the database can authenticate requests.

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 14:54:30 +00:00 committed by Jon Seager
parent e6811d45ad
commit 4c040f2c58
No known key found for this signature in database
12 changed files with 287 additions and 14 deletions

View file

@ -0,0 +1,13 @@
-- Add sessions table for web authentication
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
session_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(session_token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);

View file

@ -4,6 +4,7 @@ pub mod origins;
pub mod repositories;
pub mod roasters;
pub mod roasts;
pub mod sessions;
pub mod timeline;
pub mod tokens;
pub mod users;

View file

@ -5,6 +5,7 @@ use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasters::{Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
use crate::domain::sessions::{Session, SessionId};
use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
use crate::domain::tokens::{Token, TokenId};
use crate::domain::users::{User, UserId};
@ -96,3 +97,12 @@ pub trait TokenRepository: Send + Sync {
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError>;
async fn update_last_used(&self, id: TokenId) -> Result<(), RepositoryError>;
}
#[async_trait]
pub trait SessionRepository: Send + Sync {
async fn insert(&self, session: Session) -> Result<Session, RepositoryError>;
async fn get(&self, id: SessionId) -> Result<Session, RepositoryError>;
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError>;
async fn delete(&self, id: SessionId) -> Result<(), RepositoryError>;
async fn delete_expired(&self) -> Result<(), RepositoryError>;
}

35
src/domain/sessions.rs Normal file
View file

@ -0,0 +1,35 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type SessionId = String;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub user_id: String,
pub session_token_hash: String,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl Session {
pub fn new(
id: SessionId,
user_id: String,
session_token_hash: String,
created_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Self {
Self {
id,
user_id,
session_token_hash,
created_at,
expires_at,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}

View file

@ -1,5 +1,6 @@
pub mod roasters;
pub mod roasts;
pub mod sessions;
pub mod timeline_events;
pub mod tokens;
pub mod users;

View file

@ -0,0 +1,155 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{Pool, Row, Sqlite};
use crate::domain::sessions::{Session, SessionId};
use crate::domain::{RepositoryError, repositories::SessionRepository};
pub struct SqlSessionRepository {
pool: Pool<Sqlite>,
}
impl SqlSessionRepository {
pub fn new(pool: Pool<Sqlite>) -> Self {
Self { pool }
}
}
#[async_trait]
impl SessionRepository for SqlSessionRepository {
async fn insert(&self, session: Session) -> Result<Session, RepositoryError> {
sqlx::query(
r#"
INSERT INTO sessions (id, user_id, session_token_hash, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)
"#,
)
.bind(&session.id)
.bind(&session.user_id)
.bind(&session.session_token_hash)
.bind(session.created_at.to_rfc3339())
.bind(session.expires_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| RepositoryError::unexpected(format!("failed to insert session: {}", e)))?;
Ok(session)
}
async fn get(&self, id: SessionId) -> Result<Session, RepositoryError> {
let row = sqlx::query(
r#"
SELECT id, user_id, session_token_hash, created_at, expires_at
FROM sessions
WHERE id = ?
"#,
)
.bind(&id)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => RepositoryError::NotFound,
_ => RepositoryError::unexpected(format!("failed to get session: {}", e)),
})?;
let created_at: String = row.try_get("created_at").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse created_at: {}", e))
})?;
let expires_at: String = row.try_get("expires_at").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse expires_at: {}", e))
})?;
Ok(Session {
id: row.try_get("id").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse id: {}", e))
})?,
user_id: row.try_get("user_id").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse user_id: {}", e))
})?,
session_token_hash: row.try_get("session_token_hash").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse session_token_hash: {}", e))
})?,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map_err(|e| {
RepositoryError::unexpected(format!("failed to parse created_at: {}", e))
})?
.with_timezone(&Utc),
expires_at: DateTime::parse_from_rfc3339(&expires_at)
.map_err(|e| {
RepositoryError::unexpected(format!("failed to parse expires_at: {}", e))
})?
.with_timezone(&Utc),
})
}
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError> {
let row = sqlx::query(
r#"
SELECT id, user_id, session_token_hash, created_at, expires_at
FROM sessions
WHERE session_token_hash = ?
"#,
)
.bind(token_hash)
.fetch_one(&self.pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => RepositoryError::NotFound,
_ => RepositoryError::unexpected(format!("failed to get session by token: {}", e)),
})?;
let created_at: String = row.try_get("created_at").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse created_at: {}", e))
})?;
let expires_at: String = row.try_get("expires_at").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse expires_at: {}", e))
})?;
Ok(Session {
id: row.try_get("id").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse id: {}", e))
})?,
user_id: row.try_get("user_id").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse user_id: {}", e))
})?,
session_token_hash: row.try_get("session_token_hash").map_err(|e| {
RepositoryError::unexpected(format!("failed to parse session_token_hash: {}", e))
})?,
created_at: DateTime::parse_from_rfc3339(&created_at)
.map_err(|e| {
RepositoryError::unexpected(format!("failed to parse created_at: {}", e))
})?
.with_timezone(&Utc),
expires_at: DateTime::parse_from_rfc3339(&expires_at)
.map_err(|e| {
RepositoryError::unexpected(format!("failed to parse expires_at: {}", e))
})?
.with_timezone(&Utc),
})
}
async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> {
sqlx::query("DELETE FROM sessions WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(|e| {
RepositoryError::unexpected(format!("failed to delete session: {}", e))
})?;
Ok(())
}
async fn delete_expired(&self) -> Result<(), RepositoryError> {
let now = Utc::now().to_rfc3339();
sqlx::query("DELETE FROM sessions WHERE expires_at < ?")
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| {
RepositoryError::unexpected(format!("failed to delete expired sessions: {}", e))
})?;
Ok(())
}
}

View file

@ -3,10 +3,14 @@ use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response};
use axum::Form;
use chrono::{Duration, Utc};
use serde::Deserialize;
use tower_cookies::{Cookie, Cookies};
use tracing::warn;
use crate::infrastructure::auth::{generate_session_token, verify_password};
use tracing::{error, warn};
use crate::domain::ids::generate_id;
use crate::domain::sessions::Session;
use crate::infrastructure::auth::{generate_session_token, hash_token, verify_password};
use crate::server::routes::render_html;
use crate::server::server::AppState;
@ -26,9 +30,12 @@ pub struct LoginForm {
password: String,
}
pub(crate) async fn login_page(cookies: Cookies) -> Result<Response, StatusCode> {
pub(crate) async fn login_page(
State(state): State<AppState>,
cookies: Cookies,
) -> Result<Response, StatusCode> {
// Check if already authenticated
if cookies.get(SESSION_COOKIE_NAME).is_some() {
if is_authenticated(&state, &cookies).await {
return Ok(Redirect::to("/timeline").into_response());
}
@ -65,6 +72,21 @@ pub(crate) async fn login_submit(
// Create session token
let session_token = generate_session_token();
let session_token_hash = hash_token(&session_token);
// Create session in database (valid for 30 days)
let session = Session::new(
generate_id(),
user.id.clone(),
session_token_hash,
Utc::now(),
Utc::now() + Duration::days(30),
);
if let Err(err) = state.session_repo.insert(session).await {
error!(error = %err, "failed to create session");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
// Set secure cookie
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token);
@ -78,7 +100,18 @@ pub(crate) async fn login_submit(
Ok(Redirect::to("/timeline").into_response())
}
pub(crate) async fn logout(cookies: Cookies) -> Redirect {
pub(crate) async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {
// Try to delete session from database if cookie exists
if let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) {
let session_token = cookie.value();
let session_token_hash = hash_token(session_token);
// Try to find and delete the session
if let Ok(session) = state.session_repo.get_by_token_hash(&session_token_hash).await {
let _ = state.session_repo.delete(session.id).await;
}
}
cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
Redirect::to("/timeline")
}
@ -94,6 +127,18 @@ fn show_login_error(message: &str) -> Result<Response, StatusCode> {
}
/// Check if user is authenticated based on session cookie
pub fn is_authenticated(cookies: &Cookies) -> bool {
cookies.get(SESSION_COOKIE_NAME).is_some()
/// Validates the session token against the database
pub async fn is_authenticated(state: &AppState, cookies: &Cookies) -> bool {
let Some(cookie) = cookies.get(SESSION_COOKIE_NAME) else {
return false;
};
let session_token = cookie.value();
let session_token_hash = hash_token(session_token);
// Check if session exists and is valid
match state.session_repo.get_by_token_hash(&session_token_hash).await {
Ok(session) => !session.is_expired(),
Err(_) => false,
}
}

View file

@ -56,7 +56,7 @@ pub(crate) async fn roasters_page(
.await
.map_err(|err| map_app_error(err))?;
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
let template = RoastersTemplate {
nav_active: "roasters",
@ -85,7 +85,7 @@ pub(crate) async fn roaster_page(
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_view = RoasterView::from(roaster);
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
let template = RoasterDetailTemplate {
nav_active: "roasters",

View file

@ -63,7 +63,7 @@ pub(crate) async fn roasts_page(
.await
.map_err(|err| map_app_error(err))?;
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
let template = RoastsTemplate {
nav_active: "roasts",
@ -92,7 +92,7 @@ pub(crate) async fn roast_page(
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
let template = RoastDetailTemplate {
nav_active: "roasts",

View file

@ -35,7 +35,7 @@ pub(crate) async fn timeline_page(
.await
.map_err(|err| map_app_error(err))?;
let is_authenticated = crate::server::routes::auth::is_authenticated(&cookies);
let is_authenticated = crate::server::routes::auth::is_authenticated(&state, &cookies).await;
let template = TimelineTemplate {
nav_active: "timeline",

View file

@ -10,13 +10,15 @@ use tracing::info;
use crate::domain::ids::generate_id;
use crate::domain::repositories::{
RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository,
RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository,
TokenRepository, UserRepository,
};
use crate::domain::users::User;
use crate::infrastructure::auth::hash_password;
use crate::infrastructure::database::Database;
use crate::infrastructure::repositories::roasters::SqlRoasterRepository;
use crate::infrastructure::repositories::roasts::SqlRoastRepository;
use crate::infrastructure::repositories::sessions::SqlSessionRepository;
use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use crate::infrastructure::repositories::tokens::SqlTokenRepository;
use crate::infrastructure::repositories::users::SqlUserRepository;
@ -35,6 +37,7 @@ pub struct AppState {
pub timeline_repo: Arc<dyn TimelineEventRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub token_repo: Arc<dyn TokenRepository>,
pub session_repo: Arc<dyn SessionRepository>,
}
impl AppState {
@ -44,6 +47,7 @@ impl AppState {
timeline_repo: Arc<dyn TimelineEventRepository>,
user_repo: Arc<dyn UserRepository>,
token_repo: Arc<dyn TokenRepository>,
session_repo: Arc<dyn SessionRepository>,
) -> Self {
Self {
roaster_repo,
@ -51,6 +55,7 @@ impl AppState {
timeline_repo,
user_repo,
token_repo,
session_repo,
}
}
}
@ -68,6 +73,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> =
Arc::new(SqlTokenRepository::new(database.clone_pool()));
let session_repo: Arc<dyn SessionRepository> =
Arc::new(SqlSessionRepository::new(database.clone_pool()));
// Bootstrap admin user if no users exist
bootstrap_admin_user(&user_repo, config.admin_password).await?;
@ -78,6 +85,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> {
timeline_repo,
user_repo,
token_repo,
session_repo,
);
let listener = TcpListener::bind(config.bind_address)

View file

@ -1,7 +1,8 @@
use std::sync::Arc;
use brewlog::domain::repositories::{
RoastRepository, RoasterRepository, TimelineEventRepository, TokenRepository, UserRepository,
RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository,
TokenRepository, UserRepository,
};
use brewlog::domain::roasters::{NewRoaster, Roaster};
use brewlog::domain::users::User;
@ -9,6 +10,7 @@ use brewlog::infrastructure::auth::hash_password;
use brewlog::infrastructure::database::Database;
use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository;
use brewlog::infrastructure::repositories::roasts::SqlRoastRepository;
use brewlog::infrastructure::repositories::sessions::SqlSessionRepository;
use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository;
use brewlog::infrastructure::repositories::tokens::SqlTokenRepository;
use brewlog::infrastructure::repositories::users::SqlUserRepository;
@ -57,6 +59,8 @@ pub async fn spawn_app() -> TestApp {
Arc::new(SqlUserRepository::new(database.clone_pool()));
let token_repo: Arc<dyn TokenRepository> =
Arc::new(SqlTokenRepository::new(database.clone_pool()));
let session_repo: Arc<dyn SessionRepository> =
Arc::new(SqlSessionRepository::new(database.clone_pool()));
// Create application state
let state = AppState::new(
@ -65,6 +69,7 @@ pub async fn spawn_app() -> TestApp {
timeline_repo.clone(),
user_repo.clone(),
token_repo.clone(),
session_repo,
);
// Create router