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
@@ -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
|
||||
Reference in new issue
Block a user