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 repositories;
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod sessions;
pub mod timeline; pub mod timeline;
pub mod tokens; pub mod tokens;
pub mod users; pub mod users;

View file

@ -5,6 +5,7 @@ use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasters::{Roaster, UpdateRoaster}; use crate::domain::roasters::{Roaster, UpdateRoaster};
use crate::domain::roasts::RoastSortKey; use crate::domain::roasts::RoastSortKey;
use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast};
use crate::domain::sessions::{Session, SessionId};
use crate::domain::timeline::{TimelineEvent, TimelineSortKey}; use crate::domain::timeline::{TimelineEvent, TimelineSortKey};
use crate::domain::tokens::{Token, TokenId}; use crate::domain::tokens::{Token, TokenId};
use crate::domain::users::{User, UserId}; 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 revoke(&self, id: TokenId) -> Result<Token, RepositoryError>;
async fn update_last_used(&self, id: TokenId) -> Result<(), 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 roasters;
pub mod roasts; pub mod roasts;
pub mod sessions;
pub mod timeline_events; pub mod timeline_events;
pub mod tokens; pub mod tokens;
pub mod users; 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::http::StatusCode;
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use axum::Form; use axum::Form;
use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use tower_cookies::{Cookie, Cookies}; use tower_cookies::{Cookie, Cookies};
use tracing::warn; use tracing::{error, warn};
use crate::infrastructure::auth::{generate_session_token, verify_password};
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::routes::render_html;
use crate::server::server::AppState; use crate::server::server::AppState;
@ -26,9 +30,12 @@ pub struct LoginForm {
password: String, 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 // 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()); return Ok(Redirect::to("/timeline").into_response());
} }
@ -65,6 +72,21 @@ pub(crate) async fn login_submit(
// Create session token // Create session token
let session_token = generate_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 // Set secure cookie
let mut cookie = Cookie::new(SESSION_COOKIE_NAME, session_token); 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()) 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)); cookies.remove(Cookie::from(SESSION_COOKIE_NAME));
Redirect::to("/timeline") 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 /// Check if user is authenticated based on session cookie
pub fn is_authenticated(cookies: &Cookies) -> bool { /// Validates the session token against the database
cookies.get(SESSION_COOKIE_NAME).is_some() 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 .await
.map_err(|err| map_app_error(err))?; .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 { let template = RoastersTemplate {
nav_active: "roasters", nav_active: "roasters",
@ -85,7 +85,7 @@ pub(crate) async fn roaster_page(
.map_err(|err| map_app_error(AppError::from(err)))?; .map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_view = RoasterView::from(roaster); 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 { let template = RoasterDetailTemplate {
nav_active: "roasters", nav_active: "roasters",

View file

@ -63,7 +63,7 @@ pub(crate) async fn roasts_page(
.await .await
.map_err(|err| map_app_error(err))?; .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 { let template = RoastsTemplate {
nav_active: "roasts", nav_active: "roasts",
@ -92,7 +92,7 @@ pub(crate) async fn roast_page(
.await .await
.map_err(|err| map_app_error(AppError::from(err)))?; .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 { let template = RoastDetailTemplate {
nav_active: "roasts", nav_active: "roasts",

View file

@ -35,7 +35,7 @@ pub(crate) async fn timeline_page(
.await .await
.map_err(|err| map_app_error(err))?; .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 { let template = TimelineTemplate {
nav_active: "timeline", nav_active: "timeline",

View file

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

View file

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