TASK-022: Hash session tokens in database for security

- 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>
This commit is contained in:
2026-04-26 14:35:32 +02:00
parent 5709785942
commit 81f009e323
7 changed files with 168 additions and 45 deletions

View File

@@ -1001,7 +1001,88 @@ async def get_session_repo() -> SessionRepository:
- Before each deployment, run `mypy --strict` to ensure all dependency providers return values compatible with their Protocol types.
- The `cast()` calls in `dependencies.py` are a documented signal that structural compatibility is being verified externally, not via explicit class inheritance.
#### 13.7.2 Session Cache Pluggability — Process-Local vs. Shared Backends
#### 13.7.2 Session Token Hashing — One-Way Protection Against Database Exposure
Session tokens must be protected against database exposure. **Session tokens are stored as one-way SHA256 hashes in the database** to ensure that if the database file is compromised (volume mount misconfiguration, backup leak, etc.), the session tokens themselves cannot be directly used to hijack sessions.
**Implementation pattern:**
```python
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."""
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 with the token hash."""
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 the Session with the ORIGINAL token (not the hash)
# so the service layer can sign and return it to the client.
return Session(
id=int(cursor.lastrowid) if cursor.lastrowid else 0,
token=token, # ← raw token, not the hash
created_at=created_at,
expires_at=expires_at,
)
async def get_session(
db: "aiosqlite.Connection",
token: str
) -> Session | None:
"""Look up a session by token hash."""
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 the Session with the INCOMING token (the one the client sent).
return Session(
id=int(row[0]),
token=token, # ← the raw token passed in
created_at=str(row[2]),
expires_at=str(row[3]),
)
```
**Key points:**
1. **Hash on write** — When inserting a session, hash the token before storage.
2. **Hash on read** — When validating a session, hash the incoming token before the database lookup.
3. **Never store raw tokens** — The `token_hash` column contains only hashes; raw tokens are never persisted.
4. **Return raw tokens to the service layer** — The `Session` model's `token` field contains the raw token (for signing and response), not the hash.
5. **Database schema** — Use `token_hash TEXT NOT NULL UNIQUE` instead of `token TEXT NOT NULL UNIQUE`, and create an index on `token_hash`.
6. **Migration strategy** — When upgrading from plaintext to hashed tokens, drop the old table and recreate it. This invalidates all existing sessions, which is acceptable because the database was exposed in plaintext.
**Why one-way hashing is safe:**
- If an attacker obtains a token hash from the database, they cannot reverse the SHA256 hash to recover the original token.
- The attacker cannot use the hash directly in a client request — they would need the original token to pass the hash check.
- This forces the attacker to either compromise the client (where they'd also get the raw token) or perform a brute-force attack against the hash space (infeasible for random 128-bit tokens).
**Never use symmetric encryption** — symmetric encryption stores a key in the database or environment, which merely shifts the exposure risk. A one-way hash is the correct choice for protecting tokens.
#### 13.7.3 Session Cache Pluggability — Process-Local vs. Shared Backends
Session validation is expensive (SQLite lookup + password verification). To improve performance, **validated session tokens are cached** using the `SessionCache` interface (`app.utils.session_cache`). The default implementation, `InMemorySessionCache`, stores cached sessions in process-local memory.