fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the account's API tokens too (audited): one minted from a stolen session no longer outlives the answer. - A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500. - Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the foreign key; a link's view count is one atomic UPDATE. - A folder link's card counts exactly the files its page lists (one `links.folder_files`). - A file with no extension is served without a trailing dot. - 0106's downgrade names a Hub folder so it never collides with one of the user's folders. - The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted). - Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link, 0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder links, key change, the cleanup job's token warnings).
This commit is contained in:
@@ -23,10 +23,11 @@ from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy import delete, or_, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import debugsession, logs
|
||||
from app import audit, debugsession, logs
|
||||
from app.audit import AuditAction
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ApiToken, ApiTokenGrant, User
|
||||
@@ -99,7 +100,8 @@ def well_formed(raw: str) -> bool:
|
||||
if len(raw) != TOKEN_LEN or not raw.startswith(PREFIX):
|
||||
return False
|
||||
body, check = raw[len(PREFIX) : -_CHECK_LEN], raw[-_CHECK_LEN:]
|
||||
if any(c not in _ALPHABET for c in body):
|
||||
# The check too: compare_digest refuses (TypeError, a 500) a str with anything non-ASCII in it.
|
||||
if any(c not in _ALPHABET for c in body + check):
|
||||
return False
|
||||
return secrets.compare_digest(_check(body), check)
|
||||
|
||||
@@ -215,6 +217,28 @@ def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller:
|
||||
return caller
|
||||
|
||||
|
||||
def revoke_all(db: Session, user_id: int) -> int:
|
||||
"""Revoke every token of `user_id`, each with its audit row (NOT committed). A token is a
|
||||
credential like a session, so whatever ends every session of an account — a password reset or
|
||||
change, "log out everywhere", a revoked untrusted password — ends its tokens too: one minted
|
||||
from a stolen session must not outlive the owner's answer to that theft. Returns how many."""
|
||||
gone = (
|
||||
db.execute(delete(ApiToken).where(ApiToken.user_id == user_id).returning(ApiToken.id))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for token_id in gone:
|
||||
audit.record(
|
||||
db,
|
||||
user_id,
|
||||
AuditAction.API_TOKEN_REVOKE,
|
||||
target_type="api_token",
|
||||
target_id=token_id,
|
||||
summary="revoked with every session of the account",
|
||||
)
|
||||
return len(gone)
|
||||
|
||||
|
||||
def expiring(token: ApiToken, now: datetime) -> bool:
|
||||
"""Whether `token` runs out within the warning span (and has not yet)."""
|
||||
return token.expires_at is not None and now < token.expires_at <= now + EXPIRY_WARNING
|
||||
|
||||
Reference in New Issue
Block a user