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
+688
-32
No files matched your search
+33
-16
@@ -1,28 +1,45 @@
|
||||
"""Authentication dependencies.
|
||||
|
||||
Phase 0 milestone 0.2: placeholder `get_current_user` that always raises 401.
|
||||
Milestone 0.3 replaces this with a JWT-verifying implementation that also
|
||||
issues the `iam.assurance_level` claim on the token.
|
||||
Milestone 0.3: real `get_current_user` — validates the JWT, refuses refresh
|
||||
tokens on resource endpoints, loads the User and refuses if inactive.
|
||||
|
||||
Kept as its own module so the RBAC engine and router factory can import it now
|
||||
and milestone 0.3's swap is a single-file change.
|
||||
Downstream authorization (RBAC and assurance-level gating) reads user.roles
|
||||
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 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
|
||||
|
||||
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
|
||||
RBAC or routes without a real auth stack override this dependency with a
|
||||
fixture-provided User via `app.dependency_overrides`.
|
||||
"""
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"error": "unauthenticated", "phase": "milestone 0.3 lands JWT auth"},
|
||||
)
|
||||
def get_current_user(
|
||||
token: str | None = Depends(oauth2_scheme),
|
||||
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(
|
||||
status.HTTP_401_UNAUTHORIZED, "refresh token cannot access resources"
|
||||
)
|
||||
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 sqlalchemy import text
|
||||
|
||||
from ypms.auth.router import router as auth_router
|
||||
from ypms.db import engine
|
||||
from ypms.logging import configure_logging, get_logger
|
||||
from ypms.middleware import RequestIdMiddleware
|
||||
@@ -40,6 +41,8 @@ def create_app() -> FastAPI:
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
|
||||
@app.get("/healthz", tags=["system"])
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Liveness — process is up. No dependency checks."""
|
||||
|
||||
Reference in new issue
Block a user