refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter
This commit is contained in:
parent
06ab25fb47
commit
03af41e837
74 changed files with 6089 additions and 5183 deletions
|
|
@ -1,7 +1,12 @@
|
|||
"""Access requests, magic-link tokens, and sessions (DB via function args).
|
||||
"""Access requests, magic-link tokens, and stateless JWT sessions.
|
||||
|
||||
Keep API handlers thin: all persistence lives here. Tokens and session ids
|
||||
are bearer secrets — log user/request ids only, never secret values.
|
||||
Keep API handlers thin: all persistence lives here. Tokens and session
|
||||
JWTs are bearer secrets — log user/request ids only, never secret values.
|
||||
|
||||
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
|
||||
iat, exp=30d, jti) in the HttpOnly session cookie. No session table:
|
||||
logout clears the cookie; role/tenant changes apply at next login (or
|
||||
immediately for deleted users — the user row is reloaded per request).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -9,12 +14,14 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
||||
from app.models import AccessRequest, MagicLinkToken, User
|
||||
from app.models.base import utcnow
|
||||
from app.settings import settings
|
||||
|
||||
|
|
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
|
|||
return value
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> UserSession:
|
||||
"""Single-use consume → session row. used_at is set before creation."""
|
||||
def _jwt_secret() -> str:
|
||||
secret = settings.jwt_secret
|
||||
if not secret:
|
||||
raise RuntimeError("JWT_SECRET is empty — set it in .env")
|
||||
return secret
|
||||
|
||||
|
||||
def mint_session_token(user: User) -> str:
|
||||
"""Issue a stateless session JWT for a user (30-day expiry)."""
|
||||
now = utcnow()
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"tenant_id": user.tenant_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + SESSION_TTL).timestamp()),
|
||||
"jti": uuid.uuid4().hex,
|
||||
},
|
||||
_jwt_secret(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def decode_session_token(token: str) -> dict:
|
||||
"""Verify a session JWT → claims. Expired/forged → token errors."""
|
||||
try:
|
||||
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ExpiredTokenError("session expired") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise InvalidTokenError("invalid session") from exc
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
|
||||
"""Single-use consume → (user, session JWT). used_at is set first."""
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
|
||||
).first()
|
||||
|
|
@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession:
|
|||
# Mark used first: a reused token can never mint a second session.
|
||||
record.used_at = utcnow()
|
||||
session.add(record)
|
||||
user_session = UserSession(
|
||||
id=secrets.token_urlsafe(32),
|
||||
user_id=user.id,
|
||||
expires_at=utcnow() + SESSION_TTL,
|
||||
)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
session.refresh(user_session)
|
||||
session.refresh(user)
|
||||
logger.info("magic token consumed for user %s", user.id)
|
||||
return user_session
|
||||
return user, mint_session_token(user)
|
||||
|
||||
|
||||
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is None or record.revoked_at is not None:
|
||||
def get_token_user(session: Session, token: str) -> User | None:
|
||||
"""User for a session JWT, or None (expired/forged/deleted user)."""
|
||||
try:
|
||||
claims = decode_session_token(token)
|
||||
except (InvalidTokenError, ExpiredTokenError):
|
||||
return None
|
||||
if _aware(record.expires_at) <= utcnow():
|
||||
try:
|
||||
user_id = UUID(str(claims.get("sub", "")))
|
||||
except ValueError:
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def revoke_session(session: Session, session_id: str) -> None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is not None and record.revoked_at is None:
|
||||
record.revoked_at = utcnow()
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue