Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ Versions follow [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed — a sign-in lasts 30 days from login

- `POST /auth/refresh` returns a new access token together with the refresh
token it was sent, so a sign-in ends 30 days after login; without a
database it answers `503`, like login.
- Everyone who is signed in has to sign in once more after this update.
- `docs/api-reference.md` and `docs/api-usage-guide.md` describe refresh and
password reset; tests in `tests/test_auth_refresh.py` (new),
`tests/test_password_reset.py` and `tests/test_upload_auth_resolution.py`.

### Fixed — patch-policy's `cosign verify` names an image tag that exists

`docs/patch-policy.md` told readers to verify the release image
Expand Down
58 changes: 34 additions & 24 deletions app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
create_refresh_token,
decode_email_verify_token,
decode_password_reset_token,
decode_token,
decode_session_token,
password_hash_version,
)
from app.core.config import settings
Expand Down Expand Up @@ -160,12 +160,19 @@ async def get_current_user(
)
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated.")
token = authorization.removeprefix("Bearer ")
user_id = decode_token(token, expected_type="access")
return await _session_user(db, authorization.removeprefix("Bearer "), expected_type="access")


async def _session_user(db: AsyncSession, token: str, expected_type: str) -> User:
"""Resolve an access or refresh token to its live user, or raise 401.

The token's ``phv`` must match the user's current password hash, so a
password reset ends every session issued before it."""
user_id, token_phv = decode_session_token(token, expected_type=expected_type)
# asyncpg happily binds a str to a UUID column, but SQLAlchemy's generic
# UUID type (used by the SQLite test engine) calls ``.hex`` on the value
# and blows up on bare strings. Cast explicitly so the dependency works
# on any backend and rejects malformed subjects cleanly.
# and blows up on bare strings. Cast explicitly so this works on any
# backend and rejects malformed subjects cleanly.
try:
user_uuid = uuid.UUID(user_id)
except (ValueError, TypeError):
Expand All @@ -181,6 +188,10 @@ async def get_current_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found.")
if password_hash_version(user.password_hash) != token_phv:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token."
)
return user


Expand Down Expand Up @@ -238,6 +249,19 @@ def _db_required(db: AsyncSession | None) -> AsyncSession:
return db


def _token_pair(user: User, refresh_token: str | None = None) -> TokenResponse:
"""Access + refresh token for ``user``, both bound to the current password.

``/auth/refresh`` passes the presented refresh token back instead of
minting a new one, so refreshing never extends a sign-in: it ends when
that token expires, 30 days after login."""
phv = password_hash_version(user.password_hash)
return TokenResponse(
access_token=create_access_token(str(user.id), phv=phv, role=user.role.value),
refresh_token=refresh_token or create_refresh_token(str(user.id), phv=phv),
)


def _email_hash(email: str) -> str:
"""Lowercased SHA-256 of an email address.

Expand Down Expand Up @@ -345,10 +369,7 @@ async def register(
actor_ip=_client_ip(request),
payload={"trigger": "register"},
)
return TokenResponse(
access_token=create_access_token(str(user.id), role=user.role.value),
refresh_token=create_refresh_token(str(user.id)),
)
return _token_pair(user)


@router.post("/login", response_model=TokenResponse)
Expand Down Expand Up @@ -387,10 +408,7 @@ async def login(request: Request, body: LoginRequest, db: AsyncSession | None =
actor_user_id=user.id,
actor_ip=_client_ip(request),
)
return TokenResponse(
access_token=create_access_token(str(user.id), role=user.role.value),
refresh_token=create_refresh_token(str(user.id)),
)
return _token_pair(user)


# Deliberately unlimited. A request costs one signature check (plus one
Expand All @@ -401,17 +419,9 @@ async def login(request: Request, body: LoginRequest, db: AsyncSession | None =
@router.post("/refresh", response_model=TokenResponse)
@limiter.exempt
async def refresh(body: RefreshRequest, db: AsyncSession | None = Depends(get_db)):
user_id = decode_token(body.refresh_token, expected_type="refresh")
role = RoleEnum.user.value
if db is not None:
result = await db.execute(select(User).where(User.id == user_id, User.is_active.is_(True)))
user = result.scalar_one_or_none()
if user:
role = user.role.value
return TokenResponse(
access_token=create_access_token(user_id, role=role),
refresh_token=create_refresh_token(user_id),
)
db = _db_required(db)
user = await _session_user(db, body.refresh_token, expected_type="refresh")
return _token_pair(user, refresh_token=body.refresh_token)


def _user_response(user: User) -> UserResponse:
Expand Down
62 changes: 37 additions & 25 deletions app/core/tokens.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""JWT token primitives — issuance, decoding, and the password-hash-version
fingerprint that binds reset tokens to a specific stored password.
fingerprint that binds session and reset tokens to a specific stored password.

Four token types share the JWT secret and are discriminated only by the
``type`` claim:

- ``access`` — short-lived bearer credential (15 min)
- ``refresh`` — rotating session token (30 d)
- ``access`` — short-lived bearer credential (15 min, bound to ``phv``)
- ``refresh`` — session token (30 d from login, bound to ``phv``)
- ``reset`` — single-use password-reset link (30 min, bound to ``phv``)
- ``verify`` — email-verification link (7 d, bound to ``eat``)

Expand Down Expand Up @@ -75,45 +75,51 @@ def _decode(token: str) -> dict[str, Any]:


# ── Access / refresh tokens ───────────────────────────────────────────────────
#
# Both carry the ``phv`` of the password hash they were issued under (see
# ``password_hash_version`` below). ``_session_user`` in ``app/api/routes/auth.py``
# compares it with the current hash, so a password change ends every session
# issued before it. A token without ``phv`` is rejected outright.


def create_access_token(subject: str, role: str = "user") -> str:
def create_access_token(subject: str, *, phv: str, role: str = "user") -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(
_encode_claims({"sub": subject, "exp": expire, "type": "access", "role": role}),
_encode_claims({"sub": subject, "exp": expire, "type": "access", "role": role, "phv": phv}),
settings.jwt_secret,
algorithm=ALGORITHM,
)


def create_refresh_token(subject: str) -> str:
def create_refresh_token(subject: str, *, phv: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
return jwt.encode(
_encode_claims({"sub": subject, "exp": expire, "type": "refresh"}),
_encode_claims({"sub": subject, "exp": expire, "type": "refresh", "phv": phv}),
settings.jwt_secret,
algorithm=ALGORITHM,
)


def decode_token(token: str, expected_type: str = "access") -> str:
"""Return the subject claim. Use `decode_token_full` if the role claim is needed."""
sub, _role = decode_token_full(token, expected_type=expected_type)
"""Return the subject claim. Resolving a token to a user needs the ``phv``
check too — see ``_session_user`` in ``app/api/routes/auth.py``."""
sub, _phv = decode_session_token(token, expected_type=expected_type)
return sub


def decode_token_full(token: str, expected_type: str = "access") -> tuple[str, str]:
"""Return ``(subject, role)``. The role defaults to ``"user"`` for legacy tokens."""
def decode_session_token(token: str, expected_type: str = "access") -> tuple[str, str]:
"""Return ``(subject, phv)`` of an access or refresh token."""
try:
payload = _decode(token)
if payload.get("type") != expected_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type."
)
sub: str | None = payload.get("sub")
if not sub:
phv: str | None = payload.get("phv")
if not sub or not phv:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token.")
role: str = payload.get("role", "user")
return sub, role
return sub, phv
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token."
Expand All @@ -125,22 +131,28 @@ def decode_token_full(token: str, expected_type: str = "access") -> tuple[str, s
# A reset token is a short-lived JWT bound to a *version* of the user's
# current password hash. Changing the password — either via a successful
# reset or an admin intervention — rotates the version and silently
# invalidates every outstanding reset token. No DB table, no cleanup job,
# and single-use is implicit.
# invalidates every outstanding reset token (and every session, whose
# tokens carry the same version). No DB table, no cleanup job, and
# single-use is implicit.


def password_hash_version(password_hash: str) -> str:
"""Return a short stable fingerprint of a password hash.

We take the SHA-256 of the first 16 characters of the hash so a single
reset token cannot be replayed after a successful password change. The
bcrypt string starts with ``$2b$12$`` plus a 22-char salt; these 16
chars are enough entropy to diverge on any new hash.

If we ever migrate to argon2 the prefix shape changes — bump the reset
JWT ``type`` claim (e.g. ``reset`` → ``reset_v2``) at the same time so
in-flight tokens from the old scheme are rejected, then update this
function.
We take the SHA-256 of the first 16 characters of the hash so neither a
reset token nor a session token outlives a password change. The bcrypt
string starts with ``$2b$12$`` plus a 22-char salt; these 16 chars are
enough entropy to diverge on any new hash.

If we ever migrate to argon2 the prefix shape changes — and every
argon2id hash starts with the same 16 characters, so this function must
change with it or the fingerprint never changes again
(``test_every_new_hash_changes_the_phv`` pins that two hashes of one
password get different fingerprints). Bump the reset JWT ``type`` claim
(e.g. ``reset`` → ``reset_v2``) at the same time so in-flight tokens
from the old scheme are rejected. If login ever rehashes a password
(e.g. a higher bcrypt cost), that changes the fingerprint too and signs
the user's other sessions out.
"""
return hashlib.sha256(password_hash[:16].encode()).hexdigest()

Expand Down
4 changes: 2 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ The endpoints in this section only respond when the Cloud overlay is configured
|---|---|---|
| `POST /api/v1/auth/register` | none | Create account; returns access + refresh tokens. Sends a verification email (fire-and-forget) in the request locale, and stores that locale as `preferred_lang`. |
| `POST /api/v1/auth/login` | none | Exchange email + password for access (15 min) + refresh (30 d) tokens. |
| `POST /api/v1/auth/refresh` | none (refresh-token in body) | Issue a new access token. |
| `POST /api/v1/auth/refresh` | none (refresh-token in body) | Issue a new access token. The refresh token in the response is the one you sent, so a sign-in ends 30 d after login. |
| `GET /api/v1/auth/me` | Bearer | Return the currently authenticated user (`id`, `email`, `tier`, `role`, `created_at`, `subscription_status`, `preferred_lang`). |
| `PUT /api/v1/auth/account/language` | Bearer | Set the language for this user's transactional email. Body: `{"preferred_lang": "de"\|"en"}` — an unsupported value is a `422`. Returns the updated user object. This is **email** locale only; the web-UI locale stays URL-prefix driven (no cookie). |
| `POST /api/v1/auth/forgot-password` | none | Issue a single-use password-reset link via email (30 min TTL). |
| `POST /api/v1/auth/reset-password` | reset-token in body | Set a new password and invalidate older sessions via password-hash rotation. |
| `POST /api/v1/auth/reset-password` | reset-token in body | Set a new password. Every access and refresh token issued before the reset stops working, so all existing sign-ins end. |
| `POST /api/v1/auth/verify-email` | verify-token | Mark the user's email as verified. |
| `POST /api/v1/auth/resend-verification` | Bearer | Re-send the verification mail (auth-required to avoid spam). |
| `DELETE /api/v1/auth/account` | Bearer | Self-service account deletion (GDPR Art. 17). Requires re-confirmation: current password, registered email, and the literal string `DELETE`. Success is `204`. Free / never-paid accounts are hard-deleted; an account linked to Stripe is retained in a restricted state — only `email`, the Stripe customer id, the last `tier`, and `created_at` are kept for the 10-year HGB §257 / AO §147 invoice record (permitted by GDPR Art. 17(3)(b)), every other personal field is erased, and the row is hard-deleted at the end of the retention period. Any active Stripe subscription is cancelled first; a Stripe API error returns `500` and leaves the account unchanged. See [`docs/gdpr-account-deletion-design.md`](./gdpr-account-deletion-design.md). |
Expand Down
8 changes: 5 additions & 3 deletions docs/api-usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ callers are also accepted — you get the `anonymous` tier.
│ ◄──── no 401: runs on the anonymous tier │
│ │
│ POST /auth/refresh { refresh_token } │
│ ◄──── { access_token, refresh_token } (rotated) │
│ ◄──── { access_token, the same refresh_token } │
│ │
│ POST /api/v1/convert (new bearer) │
│ ◄──── 200 OK │
Expand Down Expand Up @@ -128,8 +128,10 @@ async function login(email, password) {
### Refresh the access token

Before the access token expires, exchange your refresh token for a fresh
access token. Refresh tokens rotate — store the new one and discard
the old.
access token. The response hands back the refresh token you sent —
refreshing never extends a sign-in. It ends 30 days after login, or
earlier when the account's password is reset; after that,
`/auth/refresh` answers `401` and you log in again.

Don't wait for a `401`. Endpoints that need an account — for example
`/auth/me`, `/keys` and `/billing/checkout` — answer an expired token with
Expand Down
Loading
Loading