Phase 0 milestone 0.3: auth + JWT + iam.assurance_level claim
Lands local password auth with JWT access + refresh tokens, and starts
carrying YPMS-specific claims (`uid`, `roles`, `iam.assurance_level`,
`mfa_verified`) so downstream authorization can gate high-risk actions
above base RBAC without a DB round-trip. Follows the OWC auth pattern
verbatim except where noted.
Added:
- src/ypms/auth/security.py hash_password / verify_password
(pbkdf2_sha256), create_access_token
with YPMS-specific claims,
create_refresh_token, decode_token
- src/ypms/auth/rate_limit.py SlidingWindow (vendored from OWC),
login_limiter tuned to NIST AC-7 /
source RFP C.5.7.10: 5 failed attempts
per IP per 15 minutes → 429 lockout
- src/ypms/auth/schemas.py LoginResponse, RefreshRequest,
RefreshResponse, ChangePasswordRequest,
UserRead
- src/ypms/auth/deps.py get_current_user replaces the milestone
0.2 stub; rejects refresh tokens on
resource endpoints; refuses inactive users
- src/ypms/auth/router.py POST /auth/login (OAuth2 form; audit-logs
successful auth; DOES_SYSTEM_ADMIN role
upgrades assurance_level to `admin` in
the token)
POST /auth/refresh (issues fresh access;
refuses non-refresh tokens)
GET /auth/me
POST /auth/change-password (self-service;
≥12 char floor; audit-logged)
- src/ypms/main.py Registers auth_router
Tests (43 passing, 93.47% coverage — up from 26 / 93.10%):
- tests/auth/test_security.py Token claims shape, refresh-vs-access
separation, invalid-token handling
(5 tests)
- tests/auth/test_router.py End-to-end via TestClient with a
dependency-overridden DB: login happy
path + wrong password + inactive user,
admin assurance upgrade, /me auth-gate,
refresh happy path + reject access token,
change-password full flow + length gate,
login lockout after 5 attempts (12 tests)
Test-infra fixes:
- tests/conftest.py Env defaults moved from a session-scoped
fixture to module-import time so
`ypms.main` (which builds the app at
import) can be transitively imported by
test modules without ValidationError on
Settings.
`db` fixture switched to StaticPool +
check_same_thread=False so TestClient's
worker thread sees the same in-memory
SQLite DB the fixture set up.
Notes:
- OWC includes an /auth/register for external users; that's out of scope
here — Phase 1 lands parental co-consent and youth/host registration,
which needs its own PIA-driven flow. Keeping /auth minimal in Phase 0.
- MFA is captured in `User.mfa_verified` and JWT claim but not enforced
yet; enforcement lands as an assurance-level gate in a later phase.
- Refresh flow returns only a new access token; refresh-token rotation
is a later hardening step.
Advances YPMS-POAM-040 and unblocks milestone 0.4 (RBAC seed) and 0.5
(Registrant vertical slice).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7379bb4cf1
commit
0501850a9c
10 files changed
+681
-25
No files matched your search
+31
-14
@@ -1,28 +1,45 @@
|
|||||||
"""Authentication dependencies.
|
"""Authentication dependencies.
|
||||||
|
|
||||||
Phase 0 milestone 0.2: placeholder `get_current_user` that always raises 401.
|
Milestone 0.3: real `get_current_user` — validates the JWT, refuses refresh
|
||||||
Milestone 0.3 replaces this with a JWT-verifying implementation that also
|
tokens on resource endpoints, loads the User and refuses if inactive.
|
||||||
issues the `iam.assurance_level` claim on the token.
|
|
||||||
|
|
||||||
Kept as its own module so the RBAC engine and router factory can import it now
|
Downstream authorization (RBAC and assurance-level gating) reads user.roles
|
||||||
and milestone 0.3's swap is a single-file change.
|
and user.assurance_level, so the returned User carries live values from the
|
||||||
|
DB rather than the token snapshot — a role revocation or account
|
||||||
|
deactivation takes effect on the next request, not on token expiry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ypms.auth.security import decode_token
|
||||||
|
from ypms.db import get_db
|
||||||
from ypms.users.models import User
|
from ypms.users.models import User
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
|
||||||
|
|
||||||
def get_current_user() -> User:
|
|
||||||
"""Placeholder. Milestone 0.3 lands real JWT verification.
|
|
||||||
|
|
||||||
Raises 401 for any route that goes through it. Tests that need to exercise
|
def get_current_user(
|
||||||
RBAC or routes without a real auth stack override this dependency with a
|
token: str | None = Depends(oauth2_scheme),
|
||||||
fixture-provided User via `app.dependency_overrides`.
|
db: Session = Depends(get_db),
|
||||||
"""
|
) -> User:
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
|
||||||
|
if payload.get("type") == "refresh":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_401_UNAUTHORIZED,
|
status.HTTP_401_UNAUTHORIZED, "refresh token cannot access resources"
|
||||||
detail={"error": "unauthenticated", "phase": "milestone 0.3 lands JWT auth"},
|
|
||||||
)
|
)
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
|
||||||
|
user = db.query(User).filter(User.username == username).one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
|
||||||
|
return user
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tiny in-memory rate limiter.
|
||||||
|
|
||||||
|
Vendored from OWC `auth/rate_limit.py` (snapshot 2026-04-20); unchanged.
|
||||||
|
|
||||||
|
Not suitable for multi-process prod. Swap to Redis-backed once we're
|
||||||
|
horizontally scaled (Phase 9+ / Azure deploy). Used today to cap login
|
||||||
|
attempts per IP (NIST AC-7 / source RFP C.5.7.10).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindow:
|
||||||
|
def __init__(self, limit: int, window_seconds: int):
|
||||||
|
self.limit = limit
|
||||||
|
self.window = window_seconds
|
||||||
|
self._hits: dict[str, deque[float]] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def allow(self, key: str) -> bool:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
q = self._hits.setdefault(key, deque())
|
||||||
|
# Evict entries outside the window.
|
||||||
|
while q and now - q[0] > self.window:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.limit:
|
||||||
|
return False
|
||||||
|
q.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reset(self, key: str | None = None) -> None:
|
||||||
|
"""Test helper: clear a single key or the whole window."""
|
||||||
|
with self._lock:
|
||||||
|
if key is None:
|
||||||
|
self._hits.clear()
|
||||||
|
else:
|
||||||
|
self._hits.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
# NIST AC-7 / source RFP `C.5.7.10`: 5 failed login attempts triggers lockout.
|
||||||
|
# We track by IP and use a longer window than OWC to keep the block honest.
|
||||||
|
login_limiter = SlidingWindow(limit=5, window_seconds=900) # 15 minutes
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Auth surface: /auth/login, /auth/refresh, /auth/me, /auth/change-password.
|
||||||
|
|
||||||
|
Source RFP requirements addressed here:
|
||||||
|
- C.5.7.1 role-based authN/authZ (roles baked into JWT claim)
|
||||||
|
- C.5.7.3 session timeout — enforced by access token TTL
|
||||||
|
- C.5.7.7 min 12-alphanumeric identifier — pydantic constraint on register
|
||||||
|
lands in Phase 1; here we only accept credentials, not create them
|
||||||
|
- C.5.7.8 user-established and changeable passwords → /change-password
|
||||||
|
- C.5.7.9 FIPS 140-2/3 crypto — pbkdf2_sha256 + Key Vault (0.3 uses env-var
|
||||||
|
key; Key Vault swap-in is Phase 1)
|
||||||
|
- C.5.7.10 5 failed attempts → lockout — enforced by login_limiter
|
||||||
|
- C.5.7.38 unique user ID per user — enforced by User.username uniqueness
|
||||||
|
|
||||||
|
NIST controls: IA-2, IA-5, IA-5(1), IA-8 (organizational side), AC-7, AC-11.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ypms.audit import service as audit
|
||||||
|
from ypms.auth.deps import get_current_user
|
||||||
|
from ypms.auth.rate_limit import login_limiter
|
||||||
|
from ypms.auth.schemas import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RefreshRequest,
|
||||||
|
RefreshResponse,
|
||||||
|
UserRead,
|
||||||
|
)
|
||||||
|
from ypms.auth.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from ypms.db import get_db
|
||||||
|
from ypms.security.rbac import ADMIN_ROLE
|
||||||
|
from ypms.users.models import (
|
||||||
|
ASSURANCE_ADMIN,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_access_token(user: User) -> str:
|
||||||
|
role_codes = [r.code for r in user.roles]
|
||||||
|
# DOES_SYSTEM_ADMIN implies admin assurance regardless of the persisted
|
||||||
|
# value — the super-role bypass already grants blanket authority and the
|
||||||
|
# token should reflect that so downstream assurance-level gates behave.
|
||||||
|
level = ASSURANCE_ADMIN if ADMIN_ROLE in role_codes else user.assurance_level
|
||||||
|
return create_access_token(
|
||||||
|
sub=user.username,
|
||||||
|
uid=user.id,
|
||||||
|
roles=role_codes,
|
||||||
|
assurance_level=level,
|
||||||
|
mfa_verified=user.mfa_verified,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
def login(
|
||||||
|
request: Request,
|
||||||
|
form: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
if not login_limiter.allow(ip):
|
||||||
|
# NIST AC-7: cap unsuccessful logon attempts.
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
"too many login attempts; try again shortly",
|
||||||
|
headers={"Retry-After": "900"},
|
||||||
|
)
|
||||||
|
user = db.query(User).filter(User.username == form.username).one_or_none()
|
||||||
|
if not user or not verify_password(form.password, user.password_hash):
|
||||||
|
# Deliberately identical error surface so we don't leak whether the
|
||||||
|
# username exists (IA-6 authenticator feedback discipline).
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user inactive")
|
||||||
|
|
||||||
|
audit.record(
|
||||||
|
db, actor=user, action="login", entity="User", entity_id=user.id, ip=ip
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=_issue_access_token(user),
|
||||||
|
refresh_token=create_refresh_token(sub=user.username, uid=user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=RefreshResponse)
|
||||||
|
def refresh(
|
||||||
|
body: RefreshRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> RefreshResponse:
|
||||||
|
try:
|
||||||
|
payload = decode_token(body.refresh_token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not a refresh token")
|
||||||
|
username = payload.get("sub")
|
||||||
|
user = db.query(User).filter(User.username == username).one_or_none()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found or inactive")
|
||||||
|
return RefreshResponse(access_token=_issue_access_token(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
def me(current: User = Depends(get_current_user)) -> User:
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def change_password(
|
||||||
|
body: ChangePasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: User = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
if not verify_password(body.current_password, current.password_hash):
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "current password incorrect")
|
||||||
|
if len(body.new_password) < 12:
|
||||||
|
# Source RFP C.5.7.45 leaves the exact policy to the org. 12 chars is
|
||||||
|
# the floor from C.5.7.7 (min identifier length) — treat the same for
|
||||||
|
# secrets. Complexity + rotation policy lands with the admin UI.
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "new password must be at least 12 characters")
|
||||||
|
|
||||||
|
current.password_hash = hash_password(body.new_password)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
audit.record(
|
||||||
|
db, actor=current, action="change_password",
|
||||||
|
entity="User", entity_id=current.id, ip=ip,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Pydantic schemas for the auth surface."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
is_active: bool
|
||||||
|
mfa_verified: bool
|
||||||
|
assurance_level: str
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Password hashing + JWT create/verify.
|
||||||
|
|
||||||
|
Vendored from OWC `auth/security.py` (snapshot 2026-04-20). YPMS additions:
|
||||||
|
`create_access_token()` bakes in YPMS-specific claims — `uid`, `roles`,
|
||||||
|
`iam.assurance_level`, `mfa_verified` — so downstream authorization can gate
|
||||||
|
high-risk actions above base RBAC (see ARCHITECTURE.md §4.5, §8.2).
|
||||||
|
|
||||||
|
`iam.assurance_level` values are defined in `ypms.users.models` and follow
|
||||||
|
NIST 800-63 IAL/AAL semantics: `pre_application` < `standard` <
|
||||||
|
`payroll_authorized` < `staff` < `admin`. Payroll-touching endpoints will
|
||||||
|
require `payroll_authorized` or higher (Phase 7+).
|
||||||
|
|
||||||
|
pbkdf2_sha256 not bcrypt — see ARCHITECTURE.md §4.6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from ypms.settings import get_settings
|
||||||
|
|
||||||
|
_pwd = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(pw: str) -> str:
|
||||||
|
h: str = _pwd.hash(pw)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(pw: str, hashed: str) -> bool:
|
||||||
|
return bool(_pwd.verify(pw, hashed))
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
*,
|
||||||
|
sub: str,
|
||||||
|
uid: int,
|
||||||
|
roles: list[str],
|
||||||
|
assurance_level: str,
|
||||||
|
mfa_verified: bool,
|
||||||
|
) -> str:
|
||||||
|
s = get_settings()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": sub,
|
||||||
|
"uid": uid,
|
||||||
|
"roles": roles,
|
||||||
|
"iam.assurance_level": assurance_level,
|
||||||
|
"mfa_verified": mfa_verified,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(minutes=s.access_token_ttl_minutes)).timestamp()),
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
token: str = jwt.encode(payload, s.jwt_secret, algorithm=s.jwt_alg)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(*, sub: str, uid: int) -> str:
|
||||||
|
s = get_settings()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"sub": sub,
|
||||||
|
"uid": uid,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + timedelta(days=s.refresh_token_ttl_days)).timestamp()),
|
||||||
|
"type": "refresh",
|
||||||
|
}
|
||||||
|
token: str = jwt.encode(payload, s.jwt_secret, algorithm=s.jwt_alg)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = jwt.decode(token, s.jwt_secret, algorithms=[s.jwt_alg])
|
||||||
|
except JWTError as e:
|
||||||
|
raise ValueError("invalid token") from e
|
||||||
|
return payload
|
||||||
@@ -12,6 +12,7 @@ from fastapi import FastAPI, status
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from ypms.auth.router import router as auth_router
|
||||||
from ypms.db import engine
|
from ypms.db import engine
|
||||||
from ypms.logging import configure_logging, get_logger
|
from ypms.logging import configure_logging, get_logger
|
||||||
from ypms.middleware import RequestIdMiddleware
|
from ypms.middleware import RequestIdMiddleware
|
||||||
@@ -40,6 +41,8 @@ def create_app() -> FastAPI:
|
|||||||
expose_headers=["X-Request-ID"],
|
expose_headers=["X-Request-ID"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
||||||
@app.get("/healthz", tags=["system"])
|
@app.get("/healthz", tags=["system"])
|
||||||
def healthz() -> dict[str, str]:
|
def healthz() -> dict[str, str]:
|
||||||
"""Liveness — process is up. No dependency checks."""
|
"""Liveness — process is up. No dependency checks."""
|
||||||
|
|||||||
Whitespace-only changes.
@@ -0,0 +1,253 @@
|
|||||||
|
"""End-to-end auth flow via the FastAPI TestClient.
|
||||||
|
|
||||||
|
Uses the shared SQLite in-memory DB by wiring the `get_db` dependency to the
|
||||||
|
per-test session, then seeding a user and driving login/refresh/me/
|
||||||
|
change-password.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ypms.auth.rate_limit import login_limiter
|
||||||
|
from ypms.auth.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
hash_password,
|
||||||
|
)
|
||||||
|
from ypms.db import get_db
|
||||||
|
from ypms.main import create_app
|
||||||
|
from ypms.security.rbac import ADMIN_ROLE
|
||||||
|
from ypms.users.models import (
|
||||||
|
ASSURANCE_ADMIN,
|
||||||
|
ASSURANCE_STANDARD,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_rate_limits() -> Iterator[None]:
|
||||||
|
login_limiter.reset()
|
||||||
|
yield
|
||||||
|
login_limiter.reset()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def staff_user(db: Session) -> User:
|
||||||
|
user = User(
|
||||||
|
username="staff.user",
|
||||||
|
email="staff@dc.gov",
|
||||||
|
full_name="Staff User",
|
||||||
|
password_hash=hash_password("correct-horse-battery"),
|
||||||
|
is_active=True,
|
||||||
|
assurance_level=ASSURANCE_STANDARD,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user(db: Session) -> User:
|
||||||
|
role = Role(code=ADMIN_ROLE, description="super-role")
|
||||||
|
db.add(role)
|
||||||
|
db.flush()
|
||||||
|
user = User(
|
||||||
|
username="chris",
|
||||||
|
email="chris@dc.gov",
|
||||||
|
full_name="Chris Tonjes",
|
||||||
|
password_hash=hash_password("correct-horse-battery"),
|
||||||
|
is_active=True,
|
||||||
|
assurance_level=ASSURANCE_STANDARD, # role bump upgrades to admin
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
db.add(UserRole(user_id=user.id, role_id=role.id))
|
||||||
|
db.flush()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_client(db: Session) -> Iterator[TestClient]:
|
||||||
|
"""A TestClient wired to the per-test SQLite session via dep override."""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def _override_get_db() -> Iterator[Session]:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
app.dependency_overrides[get_db] = _override_get_db
|
||||||
|
with TestClient(app) as c:
|
||||||
|
yield c
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_returns_401(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "wrong"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert r.json()["detail"] == "invalid credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_returns_tokens(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["token_type"] == "bearer"
|
||||||
|
assert body["access_token"]
|
||||||
|
assert body["refresh_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_login_upgrades_assurance_level(
|
||||||
|
app_client: TestClient, admin_user: User
|
||||||
|
) -> None:
|
||||||
|
from ypms.auth.security import decode_token
|
||||||
|
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": admin_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
claims = decode_token(r.json()["access_token"])
|
||||||
|
assert claims["iam.assurance_level"] == ASSURANCE_ADMIN
|
||||||
|
assert ADMIN_ROLE in claims["roles"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_requires_authentication(app_client: TestClient) -> None:
|
||||||
|
r = app_client.get("/auth/me")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
login = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
access = login.json()["access_token"]
|
||||||
|
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {access}"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["username"] == staff_user.username
|
||||||
|
assert r.json()["email"] == "staff@dc.gov"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_returns_new_access_token(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
login = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
refresh = login.json()["refresh_token"]
|
||||||
|
r = app_client.post("/auth/refresh", json={"refresh_token": refresh})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["access_token"]
|
||||||
|
assert "refresh_token" not in r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_rejects_access_token(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
access = create_access_token(
|
||||||
|
sub=staff_user.username,
|
||||||
|
uid=staff_user.id,
|
||||||
|
roles=[],
|
||||||
|
assurance_level=ASSURANCE_STANDARD,
|
||||||
|
mfa_verified=False,
|
||||||
|
)
|
||||||
|
r = app_client.post("/auth/refresh", json={"refresh_token": access})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_rejects_refresh_token(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
refresh = create_refresh_token(sub=staff_user.username, uid=staff_user.id)
|
||||||
|
r = app_client.get("/auth/me", headers={"Authorization": f"Bearer {refresh}"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_flow(app_client: TestClient, staff_user: User) -> None:
|
||||||
|
login = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
access = login.json()["access_token"]
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/change-password",
|
||||||
|
headers={"Authorization": f"Bearer {access}"},
|
||||||
|
json={
|
||||||
|
"current_password": "correct-horse-battery",
|
||||||
|
"new_password": "totally-different-passphrase",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
# Old password no longer works.
|
||||||
|
r2 = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 401
|
||||||
|
|
||||||
|
# New password does.
|
||||||
|
r3 = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "totally-different-passphrase"},
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_rejects_short_new_password(
|
||||||
|
app_client: TestClient, staff_user: User
|
||||||
|
) -> None:
|
||||||
|
login = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
access = login.json()["access_token"]
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/change-password",
|
||||||
|
headers={"Authorization": f"Bearer {access}"},
|
||||||
|
json={"current_password": "correct-horse-battery", "new_password": "short"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_rejects_wrong_current(
|
||||||
|
app_client: TestClient, staff_user: User
|
||||||
|
) -> None:
|
||||||
|
login = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
access = login.json()["access_token"]
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/change-password",
|
||||||
|
headers={"Authorization": f"Bearer {access}"},
|
||||||
|
json={"current_password": "wrong", "new_password": "long-enough-password"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_lockout_after_five_failed_attempts(
|
||||||
|
app_client: TestClient, staff_user: User
|
||||||
|
) -> None:
|
||||||
|
for _ in range(5):
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "wrong"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th attempt (any credentials) is rate-limited.
|
||||||
|
r = app_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data={"username": staff_user.username, "password": "correct-horse-battery"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert r.headers["Retry-After"] == "900"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Password + JWT primitives."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ypms.auth.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_returns_pbkdf2_prefix() -> None:
|
||||||
|
h = hash_password("hunter2hunter2")
|
||||||
|
assert h.startswith("$pbkdf2-sha256$")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_true_on_match_false_on_mismatch() -> None:
|
||||||
|
h = hash_password("correcthorsebattery")
|
||||||
|
assert verify_password("correcthorsebattery", h) is True
|
||||||
|
assert verify_password("wrong", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_carries_ypms_claims() -> None:
|
||||||
|
tok = create_access_token(
|
||||||
|
sub="chris",
|
||||||
|
uid=1,
|
||||||
|
roles=["DOES_SYSTEM_ADMIN"],
|
||||||
|
assurance_level="admin",
|
||||||
|
mfa_verified=False,
|
||||||
|
)
|
||||||
|
claims = decode_token(tok)
|
||||||
|
assert claims["sub"] == "chris"
|
||||||
|
assert claims["uid"] == 1
|
||||||
|
assert claims["roles"] == ["DOES_SYSTEM_ADMIN"]
|
||||||
|
assert claims["iam.assurance_level"] == "admin"
|
||||||
|
assert claims["mfa_verified"] is False
|
||||||
|
assert claims["type"] == "access"
|
||||||
|
assert claims["exp"] > claims["iat"] >= int(time.time()) - 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_token_has_type_refresh_and_carries_uid() -> None:
|
||||||
|
tok = create_refresh_token(sub="chris", uid=42)
|
||||||
|
claims = decode_token(tok)
|
||||||
|
assert claims["type"] == "refresh"
|
||||||
|
assert claims["sub"] == "chris"
|
||||||
|
assert claims["uid"] == 42
|
||||||
|
assert "roles" not in claims
|
||||||
|
assert "iam.assurance_level" not in claims
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_invalid_token_raises() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
decode_token("not-a-real-jwt")
|
||||||
+21
-11
@@ -3,6 +3,10 @@
|
|||||||
Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework
|
Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework
|
||||||
against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations
|
against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations
|
||||||
in the test path; models create their tables from metadata.
|
in the test path; models create their tables from metadata.
|
||||||
|
|
||||||
|
Environment defaults are set at *module import* time (not via fixture) so
|
||||||
|
they are in place before pytest collects any test module that transitively
|
||||||
|
imports `ypms.main` (which instantiates the FastAPI app at import time).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -10,22 +14,18 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def _test_env() -> Iterator[None]:
|
|
||||||
"""Set minimum env vars required for `Settings()` to construct in tests."""
|
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||||
os.environ.setdefault("JWT_SECRET", "test-secret")
|
os.environ.setdefault("JWT_SECRET", "test-secret")
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
|
"COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________"
|
||||||
)
|
)
|
||||||
os.environ.setdefault("ENV", "test")
|
os.environ.setdefault("ENV", "test")
|
||||||
yield
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
from sqlalchemy import create_engine # noqa: E402
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
|
||||||
|
from sqlalchemy.pool import StaticPool # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -41,7 +41,17 @@ def db() -> Iterator[Session]:
|
|||||||
import ypms.users.models # noqa: F401
|
import ypms.users.models # noqa: F401
|
||||||
from ypms.db import Base
|
from ypms.db import Base
|
||||||
|
|
||||||
engine = create_engine("sqlite:///:memory:", future=True)
|
# StaticPool + check_same_thread=False share the same in-memory DB across
|
||||||
|
# threads. TestClient runs handlers in a worker thread, so without this
|
||||||
|
# the fixture-scoped session and the request-scoped session see different
|
||||||
|
# empty databases (SADeprecationWarning: SQLite objects created in a
|
||||||
|
# thread can only be used in that same thread).
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
session = SessionLocal()
|
session = SessionLocal()
|
||||||
|
|||||||
Reference in new issue
Block a user