Workflow moved to .gitea/workflows/ci.yml (Gitea is the CI host). Every gate now passes locally, step-for-step; green on main is pending an act_runner on gitea.doesworks.net (runs #1-#5 are queued "Waiting"). Gate fixes found by actually running them: - pip-audit: `--disable-pip` without `-r` was a usage error, so the old step could never pass. Now `pip-audit --strict .` audits the runtime dependency set from pyproject. - pip-audit then flagged ecdsa (PYSEC-2026-1325, no fix), a hard dep of python-jose. Swapped to PyJWT (HS256 only; 3-line change in auth/security.py). Flag to OWC. - PyJWT warned the test JWT secret was 11 bytes. JWT_SECRET now requires >= 32 bytes (RFC 7518 s3.2) at settings load; dev/compose/test defaults updated. Test-only value carries an inline gitleaks:allow. - bandit B101: assert in retention._count_past replaced with a raise. - gitleaks: gitleaks-action needs a GitHub token/license; replaced with the pinned 8.30.1 binary, sha256-verified, scanning full history. Added: - SBOM: CycloneDX 1.6 from a clean runtime-only venv (39 components), uploaded as a CI artifact. YPMS-POAM-042. - cyclonedx-bom in dev extras; CI outputs gitignored. 158 tests passing, 95.83% coverage; ruff, mypy, bandit, pip-audit, gitleaks clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 lines
2.6 KiB
Python
87 lines
2.6 KiB
Python
"""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.
|
|
|
|
JWT library: PyJWT, not OWC's python-jose (milestone 0.8). python-jose hard-
|
|
depends on `ecdsa`, which carries an unfixable advisory (PYSEC-2026-1325)
|
|
that failed the pip-audit gate. YPMS only signs HS256. Flag to OWC.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import 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 jwt.PyJWTError as e:
|
|
raise ValueError("invalid token") from e
|
|
return payload
|