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:
parent
e6811d45ad
commit
4c040f2c58
12 changed files with 287 additions and 14 deletions
13
migrations/0003_sessions.sql
Normal file
13
migrations/0003_sessions.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
35
src/domain/sessions.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod roasters;
|
||||
pub mod roasts;
|
||||
pub mod sessions;
|
||||
pub mod timeline_events;
|
||||
pub mod tokens;
|
||||
pub mod users;
|
||||
|
|
|
|||
155
src/infrastructure/repositories/sessions.rs
Normal file
155
src/infrastructure/repositories/sessions.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue