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
12 changes: 12 additions & 0 deletions API_EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,18 @@ curl --request PATCH http://127.0.0.1:8000/admin/users/1/role \
--data '{"role":"admin"}'
```

To disable an account and atomically revoke its refresh-token sessions:

```bash
curl --request PATCH http://127.0.0.1:8000/admin/users/1/status \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"is_active":false}'
```

Set `is_active` to `true` to allow new authentication again. Re-enabling an
account does not restore revoked sessions; the user must sign in again.

Non-admin users receive `403`; an unknown user ID returns `404`.

## Inspect health and metrics
Expand Down
38 changes: 22 additions & 16 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,54 +81,60 @@ the response omits internal details.
creates an opaque refresh token, and stores only the refresh-token hash.
3. Protected routes decode the JWT and validate algorithm, issuer, audience,
expiration, token type, and subject. The current user is then loaded from the
database, so deleted users and role changes take effect without waiting for a
new access token.
database, so deleted users, disabled users, and role changes take effect
without waiting for a new access token.
4. `POST /auth/refresh` validates the stored token, rejects revoked or expired
records, locks and revokes the old token, and commits a replacement in the
same server-generated family. Replay of a rotated token revokes the family's
live descendant.
5. `POST /auth/logout` revokes the submitted refresh-token family.
6. Registration may attach a normalized email identity. Verification requests
6. Disabling a user through the admin status endpoint atomically marks the
account inactive and revokes every refresh-token session. Existing access
tokens fail the current-user lookup, and re-enabling the account does not
restore revoked sessions.
7. Registration may attach a normalized email identity. Verification requests
invalidate older outstanding tokens, persist only a SHA-256 token hash, and
deliver the raw token through the configured SMTP boundary.
7. Confirmation locks and atomically consumes the scoped token while setting
8. Confirmation locks and atomically consumes the scoped token while setting
`email_verified_at`. Expiry, replay, purpose, and current-email checks occur
before the transaction commits.
8. Password-reset requests reuse the account-action token table with a distinct
9. Password-reset requests reuse the account-action token table with a distinct
purpose and only accept active, verified email identities without revealing
eligibility to the caller.
9. Reset confirmation locks both token and user, updates the password hash,
10. Reset confirmation locks both token and user, updates the password hash,
consumes outstanding reset tokens, and revokes every refresh token in one
transaction. It creates no replacement session.
10. Authenticated session endpoints aggregate active refresh-token families and
11. Authenticated session endpoints aggregate active refresh-token families and
allow idempotent revocation of one or all families while filtering every
operation by current user ownership.
11. TOTP enrollment encrypts the authenticator seed with a dedicated Fernet
12. TOTP enrollment encrypts the authenticator seed with a dedicated Fernet
key. Confirmation stores only hashes of newly generated recovery codes.
12. Login for an MFA-enabled account returns a short-lived, opaque challenge
13. Login for an MFA-enabled account returns a short-lived, opaque challenge
instead of session tokens. Successful TOTP or recovery verification consumes
the challenge and creates the device session in one transaction.
13. Accepted TOTP counters are recorded to reject replay in the same time step.
14. Accepted TOTP counters are recorded to reject replay in the same time step.
Access tokens record authentication methods (`amr`) and time (`auth_time`);
refresh-issued access tokens use `amr=["refresh"]` and cannot satisfy recent
MFA step-up checks.
14. OIDC authorization creates a short-lived database transaction containing
15. OIDC authorization creates a short-lived database transaction containing
hashes of `state`, nonce, and browser binding plus an encrypted PKCE verifier.
The authorization request always uses Authorization Code and PKCE S256.
15. The callback validates browser binding, discovery issuer, ID-token signature,
16. The callback validates browser binding, discovery issuer, ID-token signature,
algorithm, issuer, audience, authorized party, lifetime, subject, and nonce
before consuming the transaction and issuing a local device session.
16. External identities use immutable `(issuer, subject)` keys. Matching email
17. External identities use immutable `(issuer, subject)` keys. Matching email
never links an existing account; linking requires a recent authenticated
local session. Identity changes revoke refresh sessions.
17. Optional Redis cache-aside stores only validated public OIDC discovery and
18. Optional Redis cache-aside stores only validated public OIDC discovery and
JWKS documents under versioned issuer-digest keys. Every cache read is
validated again. Misses use a bounded refresh lock; Redis errors bypass to
the provider. An unknown cached `kid` forces one provider JWKS refresh.

Refresh-token families detect replay and make device-level revocation possible.
Access tokens are stateless and remain valid until expiration, so clients must
discard them on logout and deployments must protect the signing secret.
Access tokens are stateless and logout does not revoke them, so clients must
discard them on logout and deployments must protect the signing secret. A
protected request still reloads the user, allowing deletion or account disable
to reject an otherwise unexpired access token.

## Authorization model

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- Administrative account disable/re-enable lifecycle with immediate access
rejection and atomic refresh-session revocation
- Optional Redis-backed fixed-window rate limiting shared across API processes
- Privacy-preserving HMAC client identifiers, atomic counters, and bounded TTLs
- Explicit fail-closed/fail-open outage policies with readiness and metrics
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ and safe extension points.
| `GET` | `/auth/oidc/identities` | List linked external identity providers |
| `GET` | `/auth/me` | Return the authenticated user |
| `GET` | `/admin/users` | List users as an admin |
| `PATCH` | `/admin/users/{user_id}/status` | Disable or re-enable a user and revoke sessions on disable |

The generated OpenAPI document at `/docs` is the source of truth for the full
request and response schemas.
Expand Down
44 changes: 43 additions & 1 deletion src/app/api/v1/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from app.auth.permissions import require_admin
from app.db.dependency import get_db
from app.models.user import User
from app.schemas.user import UserAdminResponse, UserRoleUpdate
from app.schemas.user import UserAdminResponse, UserRoleUpdate, UserStatusUpdate
from app.services.sessions import (
ACCOUNT_DISABLED_REVOCATION_REASON,
revoke_all_user_sessions,
)

router = APIRouter(
prefix="/admin",
Expand Down Expand Up @@ -89,6 +93,44 @@ def update_user_role(
return user


@router.patch(
"/users/{user_id}/status",
response_model=UserAdminResponse,
)
def update_user_status(
user_id: int,
data: UserStatusUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
require_admin(current_user)

user = db.query(User).filter(User.id == user_id).first()

if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)

try:
user.is_active = data.is_active
if data.is_active:
db.commit()
else:
revoke_all_user_sessions(
user.id,
db,
reason=ACCOUNT_DISABLED_REVOCATION_REASON,
)
db.refresh(user)
except Exception:
db.rollback()
raise

return user


@router.delete(
"/users/{user_id}",
)
Expand Down
6 changes: 6 additions & 0 deletions src/app/auth/current_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,10 @@ def get_current_user(
detail="User not found",
)

if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user",
)

return user
2 changes: 1 addition & 1 deletion src/app/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def login(
):
user = db.query(User).filter(User.username == form_data.username).first()

if not user:
if not user or not user.is_active:
raise HTTPException(
status_code=401,
detail="Invalid username or password",
Expand Down
15 changes: 15 additions & 0 deletions src/app/auth/refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
)
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.services.sessions import (
ACCOUNT_DISABLED_REVOCATION_REASON,
revoke_all_user_sessions,
)

ROTATION_REASON = "rotated"
REPLAY_REASON = "reuse_detected"
Expand Down Expand Up @@ -92,6 +96,17 @@ def refresh_access_token(
detail="User not found",
)

if not user.is_active:
revoke_all_user_sessions(
user.id,
db,
reason=ACCOUNT_DISABLED_REVOCATION_REASON,
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)

new_refresh_token, expires_at = create_refresh_token()

new_db_refresh_token = RefreshToken(
Expand Down
5 changes: 5 additions & 0 deletions src/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ class UserRoleUpdate(BaseModel):
role: str


class UserStatusUpdate(BaseModel):
is_active: bool


class UserAdminResponse(BaseModel):
id: int
username: str
role: str
is_active: bool

model_config = ConfigDict(
from_attributes=True,
Expand Down
21 changes: 19 additions & 2 deletions src/app/services/session_issuance.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from datetime import UTC, datetime

from fastapi import HTTPException, status
from sqlalchemy.orm import Session

from app.auth.jwt import create_access_token
Expand All @@ -20,18 +21,34 @@ def prepare_session_tokens(
*,
authentication_methods: list[str],
) -> Token:
# SessionLocal disables autoflush. Preserve pending security state, such as
# an accepted MFA counter, before populate_existing reloads the locked row.
db.flush()
active_user = (
db.query(User)
.filter(User.id == user.id)
.populate_existing()
.with_for_update()
.first()
)
if active_user is None or not active_user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication is not available",
)

authenticated_at = datetime.now(UTC)
access_token = create_access_token(
{
"sub": user.username,
"sub": active_user.username,
"amr": authentication_methods,
"auth_time": int(authenticated_at.timestamp()),
}
)
refresh_token, expires_at = create_refresh_token()
db.add(
RefreshToken(
user_id=user.id,
user_id=active_user.id,
family_id=create_refresh_token_family_id(),
token=hash_refresh_token(refresh_token),
expires_at=expires_at,
Expand Down
13 changes: 10 additions & 3 deletions src/app/services/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from app.schemas.session import DeviceSession

USER_REVOCATION_REASON = "user_revoked"
ACCOUNT_DISABLED_REVOCATION_REASON = "account_disabled"


def _as_utc(value: datetime) -> datetime:
Expand Down Expand Up @@ -52,15 +53,21 @@ def revoke_session_family(user_id: int, family_id: str, db: Session) -> None:
_revoke_user_sessions(user_id, db, family_id=family_id)


def revoke_all_user_sessions(user_id: int, db: Session) -> None:
_revoke_user_sessions(user_id, db)
def revoke_all_user_sessions(
user_id: int,
db: Session,
*,
reason: str = USER_REVOCATION_REASON,
) -> None:
_revoke_user_sessions(user_id, db, reason=reason)


def _revoke_user_sessions(
user_id: int,
db: Session,
*,
family_id: str | None = None,
reason: str = USER_REVOCATION_REASON,
) -> None:
now = datetime.now(UTC)
statement = update(RefreshToken).where(
Expand All @@ -75,7 +82,7 @@ def _revoke_user_sessions(
statement.values(
revoked=True,
revoked_at=now,
revocation_reason=USER_REVOCATION_REASON,
revocation_reason=reason,
)
)
db.commit()
Expand Down
Loading