- Store session tokens as one-way SHA256 hashes instead of plaintext - Hash tokens on write (create_session) and on read (get_session, delete_session) - Add migration to drop plaintext sessions table and recreate with token_hash column - Update Session model: token field still contains raw token for signing - Add test to verify tokens are hashed in database, not plaintext - Update Architekture.md to document session token hashing - Update Backend-Development.md with implementation pattern and best practices Prevents direct session token hijacking if database file is exposed to attacker. If plaintext DB was readable, sessions are invalidated by the migration anyway. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
131 lines
3.5 KiB
Python
131 lines
3.5 KiB
Python
"""Session repository.
|
|
|
|
Provides storage, retrieval, and deletion of session records in the
|
|
``sessions`` table of the application SQLite database.
|
|
|
|
Session tokens are stored as one-way SHA256 hashes to ensure that if the
|
|
database is exposed, the session tokens themselves cannot be directly used.
|
|
The hash is computed from the raw token before all database operations.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
import aiosqlite
|
|
|
|
from app.models.auth import Session
|
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
"""Return the SHA256 hash of a session token.
|
|
|
|
Args:
|
|
token: The raw session token to hash.
|
|
|
|
Returns:
|
|
The hexadecimal SHA256 digest of the token.
|
|
"""
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
async def create_session(
|
|
db: aiosqlite.Connection,
|
|
token: str,
|
|
created_at: str,
|
|
expires_at: str,
|
|
) -> Session:
|
|
"""Insert a new session row and return the domain model.
|
|
|
|
Args:
|
|
db: Active aiosqlite connection.
|
|
token: Opaque random session token (hex string).
|
|
created_at: ISO 8601 UTC creation timestamp.
|
|
expires_at: ISO 8601 UTC expiry timestamp.
|
|
|
|
Returns:
|
|
The newly created :class:`~app.models.auth.Session`.
|
|
|
|
Note:
|
|
The token is hashed before storage. The returned Session object
|
|
contains the original raw token for use in signing and response.
|
|
"""
|
|
token_hash = _hash_token(token)
|
|
cursor = await db.execute(
|
|
"INSERT INTO sessions (token_hash, created_at, expires_at) VALUES (?, ?, ?)",
|
|
(token_hash, created_at, expires_at),
|
|
)
|
|
await db.commit()
|
|
return Session(
|
|
id=int(cursor.lastrowid) if cursor.lastrowid else 0,
|
|
token=token,
|
|
created_at=created_at,
|
|
expires_at=expires_at,
|
|
)
|
|
|
|
|
|
async def get_session(db: aiosqlite.Connection, token: str) -> Session | None:
|
|
"""Look up a session by its token.
|
|
|
|
Args:
|
|
db: Active aiosqlite connection.
|
|
token: The session token to retrieve.
|
|
|
|
Returns:
|
|
The :class:`~app.models.auth.Session` if found, else ``None``.
|
|
|
|
Note:
|
|
The token is hashed before the database lookup.
|
|
"""
|
|
token_hash = _hash_token(token)
|
|
async with db.execute(
|
|
"SELECT id, token_hash, created_at, expires_at FROM sessions WHERE token_hash = ?",
|
|
(token_hash,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
|
|
if row is None:
|
|
return None
|
|
|
|
return Session(
|
|
id=int(row[0]),
|
|
token=token,
|
|
created_at=str(row[2]),
|
|
expires_at=str(row[3]),
|
|
)
|
|
|
|
|
|
async def delete_session(db: aiosqlite.Connection, token: str) -> None:
|
|
"""Delete a session by token (logout / expiry clean-up).
|
|
|
|
Args:
|
|
db: Active aiosqlite connection.
|
|
token: The session token to remove.
|
|
|
|
Note:
|
|
The token is hashed before the database lookup.
|
|
"""
|
|
token_hash = _hash_token(token)
|
|
await db.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
|
|
await db.commit()
|
|
|
|
|
|
async def delete_expired_sessions(db: aiosqlite.Connection, now_iso: str) -> int:
|
|
"""Remove all sessions whose ``expires_at`` timestamp is in the past.
|
|
|
|
Args:
|
|
db: Active aiosqlite connection.
|
|
now_iso: Current UTC time as ISO 8601 string used as the cutoff.
|
|
|
|
Returns:
|
|
Number of rows deleted.
|
|
"""
|
|
cursor = await db.execute(
|
|
"DELETE FROM sessions WHERE expires_at <= ?",
|
|
(now_iso,),
|
|
)
|
|
await db.commit()
|
|
return int(cursor.rowcount)
|