diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 188abc1..2caa00d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,16 +10,28 @@ on: jobs: test: runs-on: ubuntu-latest + # 3.15 is an unreleased nightly; allow it to fail without breaking the build. + continue-on-error: ${{ endsWith(matrix.python-version, '-dev') }} + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test_tet + POSTGRES_PASSWORD: test_tet + POSTGRES_DB: test_tet + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 strategy: matrix: python-version: - - "3.8" - - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15-dev" steps: - name: Checkout uses: actions/checkout@v6 @@ -27,9 +39,20 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies - run: pip install -e '.[dev]' - - name: Run tests - run: pytest + run: | + pip install 'setuptools<82' + pip install -e '.[dev]' + - name: Run tests with coverage + run: | + pytest --cov=tet --cov-report=term-missing --cov-report=xml:coverage.xml -v + - name: Coverage summary + if: always() + run: | + echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + python -m coverage report --skip-covered --skip-empty >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY # Publishing to PyPI is handled by release.yml (OIDC trusted publishing). diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9eeedd6..c0c0ca4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,9 @@ repos: - # Python formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.4 + rev: v0.9.3 # Use the latest stable version hooks: - id: ruff - args: [--fix] + args: [--fix] # Automatically fix lint errors - id: ruff-format # reStructuredText checking diff --git a/CHANGES.md b/CHANGES.md index b4dc0a2..8c6c476 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,25 @@ # Changes +2026-06-17 Antti Haapala + + * 0.6a2: Add ``tet.security`` module with JWT-based authentication, + refresh tokens, TOTP multi-factor authentication, login rate limiting, + password management, and Pyramid security policy integration. + Installable via the ``tet[security]`` extra. + * New SQLAlchemy model mixins: ``TokenMixin``, + ``MultiFactorAuthenticationMethodMixin``, ``TOTPUsedCodeMixin``, + ``RateLimitAttemptMixin``. + * Pyramid 2.0 compatibility for security/authorization imports. + * Auth views registered via ``config.include("tet.security.authentication")``. + * Event system for login, logout, password change, MFA, and token + revocation (``tet.security.events``). + * TOTP replay protection via UNLOGGED tables. + * Login rate limiting by client IP. + * Breached password checking via Have I Been Pwned API (k-anonymity). + * Drop Python 3.8, 3.9 support. Require Python >= 3.10. + + 2026-06-16 Antti Haapala * 0.6a1: first 0.6 alpha. Migrated to a ``src/`` layout and replaced diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7f6c71c --- /dev/null +++ b/TODO.md @@ -0,0 +1,59 @@ +# TODO + +## Upstream + +- Pyramid depends on `pkg_resources` which was removed in setuptools 82. + Pin `setuptools<82` until Pyramid releases a fix. + +## Security + +### TOTP replay protection — DONE +- ~~Add an UNLOGGED PostgreSQL table to track used TOTP time steps per user~~ +- Implemented via `TOTPUsedCodeMixin` + `FOR UPDATE` on the MFA method row +- `cleanup_used_codes()` method available for periodic cleanup + +### Rate limiting — DONE +- ~~Login endpoint has no rate limiting~~ +- Implemented via `RateLimitAttemptMixin` (UNLOGGED table) + `TetRateLimitService` +- Login endpoint rate-limited by client IP (configurable max attempts / window) +- Rate limit records use a separate DB connection to survive transaction rollback +- Extend to refresh, MFA, and password change endpoints as needed + +### Token cleanup — DONE +- ~~No mechanism to purge expired long-term tokens~~ +- `TetTokenService.cleanup_expired_tokens()` available — call from a cron job or admin endpoint + +### Pyramid 2.0 compatibility — DONE +- ~~`NO_PERMISSION_REQUIRED` still imported from `pyramid.security`~~ +- Now uses try/except compatibility import (pyramid.authorization → pyramid.security fallback) +- CI matrix tests both Pyramid ~=1.9.0 and ~=2.0 + +### TOTP secret encryption at rest +- TOTP secrets are stored as plaintext in the `data` JSONB column +- Consider encrypting with an application-level key before storing + +### Future rate limiting +- Rate limit token refresh, MFA verify, and password change endpoints +- Per-account rate limiting (in addition to per-IP) + +## Input validation +- `create_long_term_token()` — validate `user_id` is not None +- `retrieve_and_validate_token()` — validate token format before DB lookup +- `create_short_term_jwt()` — validate user_id is JSON-serializable + +## Architecture + +### TOTP verification in PL/pgSQL (future) +- Move TOTP verification into a PL/pgSQL function for fully atomic + verify + replay check + insert (requires `pgcrypto` for HMAC-SHA1) +- Current approach (Python verify + FOR UPDATE + insert) is safe but + requires a round trip per step + +### Test infrastructure +- Tests depend on a running PostgreSQL instance (`test_tet` database) +- Consider testcontainers or similar for CI portability + +## Documentation +- Document why `require_csrf=False` on all auth endpoints (stateless Bearer token auth) +- Document the security model: token lifecycle, MFA flow, cookie handling +- API endpoint documentation (beyond the existing `docs/authentication_apis.md`) diff --git a/docs/api/modules.rst b/docs/api/modules.rst index 81c41d3..16b4d7d 100644 --- a/docs/api/modules.rst +++ b/docs/api/modules.rst @@ -15,8 +15,19 @@ API Reference tet.request tet.response tet.security + tet.security.auth + tet.security.authentication tet.security.authorization + tet.security.compat + tet.security.config tet.security.csrf + tet.security.events + tet.security.mfa + tet.security.models + tet.security.policy + tet.security.rate_limit + tet.security.tokens + tet.security.views tet.services tet.session tet.sqlalchemy diff --git a/docs/api/tet.security.auth.rst b/docs/api/tet.security.auth.rst new file mode 100644 index 0000000..c5e280e --- /dev/null +++ b/docs/api/tet.security.auth.rst @@ -0,0 +1,7 @@ +tet.security.auth module +======================== + +.. automodule:: tet.security.auth + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.authentication.rst b/docs/api/tet.security.authentication.rst new file mode 100644 index 0000000..0caeccd --- /dev/null +++ b/docs/api/tet.security.authentication.rst @@ -0,0 +1,7 @@ +tet.security.authentication module +==================================== + +.. automodule:: tet.security.authentication + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.authorization.rst b/docs/api/tet.security.authorization.rst index ed0f80a..d0bf4f0 100644 --- a/docs/api/tet.security.authorization.rst +++ b/docs/api/tet.security.authorization.rst @@ -5,3 +5,4 @@ tet.security.authorization module :members: :show-inheritance: :undoc-members: + :no-index: diff --git a/docs/api/tet.security.compat.rst b/docs/api/tet.security.compat.rst new file mode 100644 index 0000000..77271e5 --- /dev/null +++ b/docs/api/tet.security.compat.rst @@ -0,0 +1,7 @@ +tet.security.compat module +========================== + +.. automodule:: tet.security.compat + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.config.rst b/docs/api/tet.security.config.rst new file mode 100644 index 0000000..8ae5d44 --- /dev/null +++ b/docs/api/tet.security.config.rst @@ -0,0 +1,7 @@ +tet.security.config module +========================== + +.. automodule:: tet.security.config + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.events.rst b/docs/api/tet.security.events.rst new file mode 100644 index 0000000..9b89a8c --- /dev/null +++ b/docs/api/tet.security.events.rst @@ -0,0 +1,7 @@ +tet.security.events module +========================== + +.. automodule:: tet.security.events + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.mfa.rst b/docs/api/tet.security.mfa.rst new file mode 100644 index 0000000..a277709 --- /dev/null +++ b/docs/api/tet.security.mfa.rst @@ -0,0 +1,7 @@ +tet.security.mfa module +======================= + +.. automodule:: tet.security.mfa + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.models.rst b/docs/api/tet.security.models.rst new file mode 100644 index 0000000..68d678a --- /dev/null +++ b/docs/api/tet.security.models.rst @@ -0,0 +1,7 @@ +tet.security.models module +========================== + +.. automodule:: tet.security.models + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.policy.rst b/docs/api/tet.security.policy.rst new file mode 100644 index 0000000..764b300 --- /dev/null +++ b/docs/api/tet.security.policy.rst @@ -0,0 +1,7 @@ +tet.security.policy module +========================== + +.. automodule:: tet.security.policy + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.rate_limit.rst b/docs/api/tet.security.rate_limit.rst new file mode 100644 index 0000000..6e32ef7 --- /dev/null +++ b/docs/api/tet.security.rate_limit.rst @@ -0,0 +1,7 @@ +tet.security.rate_limit module +============================== + +.. automodule:: tet.security.rate_limit + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.rst b/docs/api/tet.security.rst index 939d7dc..6ca1d51 100644 --- a/docs/api/tet.security.rst +++ b/docs/api/tet.security.rst @@ -5,3 +5,4 @@ tet.security :members: :show-inheritance: :undoc-members: + :no-index: diff --git a/docs/api/tet.security.tokens.rst b/docs/api/tet.security.tokens.rst new file mode 100644 index 0000000..e9c35ff --- /dev/null +++ b/docs/api/tet.security.tokens.rst @@ -0,0 +1,7 @@ +tet.security.tokens module +========================== + +.. automodule:: tet.security.tokens + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/api/tet.security.views.rst b/docs/api/tet.security.views.rst new file mode 100644 index 0000000..b9f5081 --- /dev/null +++ b/docs/api/tet.security.views.rst @@ -0,0 +1,7 @@ +tet.security.views module +========================= + +.. automodule:: tet.security.views + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/authentication_apis.md b/docs/authentication_apis.md new file mode 100644 index 0000000..4b543ae --- /dev/null +++ b/docs/authentication_apis.md @@ -0,0 +1,411 @@ + +# Authentication Feature APIs + +## 🔑 Auth Routes + +--- + +### **Login** + +#### `POST /login` +_Authenticate and return tokens. Also, handle MFA challenge if enabled._ + +
+MFA Disabled + +- **Permissions:** None + +### Request: +```json +{ + "user_identity": "", + "password": "" +} +``` +### Return + +**200 Response:** +```json +{ + "access_token": "" +} +``` +**Headers:** +``` +Set-Cookie: refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires="datetime"; secure; HttpOnly; SameSite=Strict +``` + +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` +
+ +
+MFA Enabled + +#### First Request _(checking credentials)_ + +- **Permissions:** None + +### Request: +```json +{ + "user_identity": "", + "password": "" +} +``` + +### Return: +**200 Response:** +```json +{ + "mfa_required": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +#### Second Request _(MFA challenge)_ + +- **Permissions:** None + +### Request: +```json +{ + "user_identity": "", + "password": "", + "token": "" +} +``` + +### Return: +**200 Response:** +```json +{ + "success": "", + "access_token": "" +} +``` +**Headers:** +``` +Set-Cookie: refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires="datetime"; secure; HttpOnly; SameSite=Strict +``` + +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` +
+ +--- + +### **Token Refresh** + +#### `POST /token/refresh` +_Retrieve new access token using refresh token._ + +- **Permissions:** None + +### Request: +**Headers:** +``` +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +### Return: +**200 Response:** +```json +{ + "success": true, + "access_token": "" +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **Logout** + +#### `POST /logout` +_Invalidate session or refresh tokens._ + +- **Permissions:** None + +### Request: +**Headers:** +``` +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +### Return: +**200 Response:** + +**Headers:** +``` +Set-Cookie: refresh-token=; Max-Age=0; Path=/api/auth/; expires="datetime" +``` +**Payload:** +```json +{ + "success": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **Change Password** + +#### `POST /users/me/password` +_Change password for authenticated user._ + +- **Permissions:** Authenticated user + +### Request: + +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +**Payload:** +```json +{ + "currentPassword": "", + "newPassword": "" +} +``` +### Return: +**200 Response:** +```json +{ + "success": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **Revoke Other Tokens** + +#### `DELETE /users/me/tokens/others` +_Revoke all refresh tokens except the current session’s._ + +- **Permissions:** Authenticated user + +### Request: +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +**Payload:** +```json +{ + "password": "" +} +``` +### Return: +**200 Response:** +```json +{ + "success": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +## 🔐 Multifactor Authentication (MFA) Routes + +--- + +### **Setup TOTP** + +#### `POST /mfa/app/setup` +_Retrieve QR code and secret for TOTP setup._ + +- **Permissions:** Authenticated user + +### Request: +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +**Payload:** +```json +{ + "method_type": "totp" +} +``` + +### Return: +**200 Response:** +```json +{ + "secret": "", + "qr_code": "" +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **Verify TOTP** + +#### `POST /mfa/app/verify` +_Verify TOTP code during MFA setup._ + +- **Permissions:** Authenticated user + +### Request: +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` + +**Payload:** +```json +{ + "token": "<6-digit TOTP code>" +} +``` +### Return: +**200 Response:** +```json +{ + "success": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **List MFA Methods** + +#### `GET /mfa/methods` +_List all active MFA methods._ + +- **Permissions:** Authenticated user + +### Request: +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` +### Return: +**200 Response:** +```json +{ + "method_types": ["totp"] +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +### **Disable MFA** + +#### `POST /mfa/app/disable` +_Disable the current MFA method._ + +- **Permissions:** Authenticated user + +### Request: +**Headers:** +``` +Authorization: Bearer + +Cookie:refresh-token={project_prefix}{machine_identifier}; Max-Age={timestamp in s}; Path=/api/auth/; +expires=”datetime”; secure; HttpOnly; SameSite=Strict +``` + +**Payload:** +```json +{ + "method_type": "totp" +} +``` +### Return: +**200 Response:** +```json +{ + "success": true +} +``` +**Error Response (400, 401, 403, 500):** +```json +{ + "message": "" +} +``` + +--- + +## 📖 Glossary + +- `< >`: required param +- `[ ]`: optional param (can be null) + +--- diff --git a/docs/conf.py b/docs/conf.py index 8523fa6..b2c1df2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -69,8 +69,23 @@ def autodoc_skip_member(app, what, name, obj, skip, options): return skip +def autodoc_process_signature(app, what, name, obj, options, signature, return_annotation): + from pyramid_di import reify_attr + + if isinstance(obj, reify_attr): + wrapped = obj.wrapped + annotations = getattr(wrapped, "__annotations__", {}) + ret = annotations.get("return") + if ret: + type_name = getattr(ret, "__name__", None) or getattr(ret, "__qualname__", str(ret)) + return ("", f" :class:`{type_name}`") + return ("", None) + return None + + def setup(app): app.connect("autodoc-skip-member", autodoc_skip_member) + app.connect("autodoc-process-signature", autodoc_process_signature) # -- Options for HTML output ---------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 41b7ea2..6664643 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,6 +24,8 @@ Unearthly intelligent batteries-included application framework built on Pyramid. :caption: Contents readme + security_guide + authentication_apis .. toctree:: :maxdepth: 2 diff --git a/docs/security_guide.md b/docs/security_guide.md new file mode 100644 index 0000000..15fcd00 --- /dev/null +++ b/docs/security_guide.md @@ -0,0 +1,377 @@ +# Security Guide + +`tet.security` provides JWT-based authentication, refresh tokens, multi-factor +authentication (TOTP), rate limiting, and password management for Pyramid +applications. It builds on `pyramid_di` for dependency injection and ships +ready-made views that you can mount under any route prefix. + +## Quick start + +### 1. Define your models + +Your application needs three SQLAlchemy models. Tet provides mixins for each; +you add the foreign keys and any extra columns. + +```python +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import declarative_base + +from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + RateLimitAttemptMixin, + TOTPUsedCodeMixin, + TokenMixin, +) +from tet.sqlalchemy.password import UserPasswordMixin + +Base = declarative_base() + + +class User(UserPasswordMixin, Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String, unique=True, nullable=False) + display_name = Column(String, nullable=False) + + +class Token(TokenMixin, Base): + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + +class MultiFactorAuthMethod(MultiFactorAuthenticationMethodMixin, Base): + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + +class TOTPUsedCode(TOTPUsedCodeMixin, Base): + """UNLOGGED table -- survives restarts but not crashes.""" + __table_args__ = {"prefixes": ["UNLOGGED"]} + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + +class RateLimitAttempt(RateLimitAttemptMixin, Base): + """UNLOGGED table for login rate-limiting.""" + __table_args__ = {"prefixes": ["UNLOGGED"]} +``` + +`TokenMixin` stores hashed refresh tokens. `UserPasswordMixin` (from +`tet.sqlalchemy.password`) gives you `password` (a hashed column via passlib) and +`validate_password()`. + +`TOTPUsedCode` and `RateLimitAttempt` are optional. Mark them `UNLOGGED` for +performance -- they hold ephemeral data that is safe to lose on a crash. + +### 2. Write a login callback + +The login callback is where *your* authentication logic lives. Tet calls it +with the current request; you verify credentials and return an +`AuthLoginResult`: + +```python +from pyramid.httpexceptions import HTTPUnauthorized +from tet.security.config import AuthLoginResult + +def login_callback(request): + body = request.json_body + email = body.get("user_identity", "") + password = body.get("password", "") + + db = request.find_service(name="db") # or however you get your session + user = db.query(User).filter_by(email=email).one_or_none() + + if user is None or not user.validate_password(password): + return AuthLoginResult(user_id=None, success=False) + + return AuthLoginResult( + user_id=user.id, + user_identity=email, + totp_token=body.get("token"), # forwarded if the client sent one + success=True, + ) +``` + +If MFA is enabled for the user, `tet.security` handles the challenge +automatically -- you just need to pass through the `token` field from the +request body. + +### 3. Provide a JWK resolver + +The JWK resolver returns the secret used to sign and verify JWTs. The +simplest case is a shared secret from your settings: + +```python +def jwk_resolver(request): + return request.registry.settings["auth.jwt_secret"] +``` + +For asymmetric algorithms (RS256, ES256, ...) return the appropriate key +object. + +### 4. Wire it up in your Pyramid configuration + +```python +from pyramid.config import Configurator +from tet.security.config import ( + CookieAttributes, + JWTRegisteredClaims, +) + + +def main(global_config, **settings): + config = Configurator(settings=settings) + + # Include tet.security -- registers routes, views, and services + config.include("tet.security.authentication", route_prefix="api/auth") + + # Configure token authentication + config.set_token_authentication( + long_term_token_model=Token, + multi_factor_auth_method_model=MultiFactorAuthMethod, + user_model=User, + project_prefix="MYAPP_", + login_callback=login_callback, + jwk_resolver=jwk_resolver, + # Optional -- tune these to your needs: + jwt_token_expiration_mins=15, + long_term_token_expiration_mins=720, + jwt_claims=JWTRegisteredClaims( + iss="myapp", + aud="myapp-api", + ), + cookie_attributes=CookieAttributes( + name="refresh-token", + path="/api/auth/", + secure=True, + httponly=True, + samesite="Strict", + ), + # Optional -- pass models to enable, or omit to disable + totp_used_code_model=TOTPUsedCode, + rate_limit_model=RateLimitAttempt, + ) + + config.scan() + return config.make_wsgi_app() +``` + +That single `config.include` registers all the auth routes and view classes. +`set_token_authentication` stores your models and callbacks on the registry +so the built-in services can find them at request time. + + +## What you get + +After the setup above, the following routes are available (under your chosen +`route_prefix`): + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/login` | Authenticate, return access + refresh tokens | +| POST | `/logout` | Revoke current refresh token | +| POST | `/token/refresh` | Exchange refresh token for new access token | +| POST | `/users/me/password` | Change password (authenticated) | +| DELETE | `/users/me/tokens/others` | Revoke all other refresh tokens | +| POST | `/mfa/app/setup` | Begin TOTP setup (returns QR code) | +| POST | `/mfa/app/verify` | Verify TOTP code to complete setup | +| GET | `/mfa/methods` | List active MFA methods | +| POST | `/mfa/app/disable` | Disable a TOTP method | + +All routes except `/login`, `/token/refresh`, and `/logout` require +authentication (the default permission is `"view"`). + + +## How the token flow works + +1. **Login** -- client sends credentials to `/login`. The login callback + verifies them. On success, the server creates a long-term refresh token + (stored hashed in the database) and a short-lived JWT access token. The + refresh token is set as an `HttpOnly` cookie; the access token is returned + in the JSON body. + +2. **Authenticated requests** -- the client sends the access token in the + `Authorization: Bearer ` header. The `TokenAuthenticationPolicy` + verifies it on every request that has a `permission` set. + +3. **Token refresh** -- when the access token expires, the client calls + `/token/refresh`. The refresh token cookie is validated against the + database and a new access token is issued. + +4. **Logout** -- `/logout` deletes the refresh token from the database and + clears the cookie. + + +## Multi-factor authentication (TOTP) + +TOTP support is built in. The flow from the client's perspective: + +1. **Setup** -- `POST /mfa/app/setup` with `{"method_type": "totp"}`. Returns + a `secret` and a `qr_code` (base64-encoded SVG). The user scans the QR + code with their authenticator app. + +2. **Verify** -- `POST /mfa/app/verify` with `{"token": "<6-digit code>"}`. + If correct, the method is marked active and verified. + +3. **Login with MFA** -- when a user with an active TOTP method logs in, the + first `/login` call returns `{"mfa_required": true}`. The client then + re-sends the login request with the `token` field included. + +### Replay protection + +If you pass a `totp_used_code_model` to `set_token_authentication`, each +time-step is recorded and a code cannot be reused within its validity window. +The model should be an UNLOGGED table for performance. Call +`TetMultiFactorAuthenticationService.cleanup_used_codes()` periodically to +prune old entries. + + +## Rate limiting + +Pass a `rate_limit_model` to `set_token_authentication` to enable login +rate limiting. The defaults are 10 attempts per IP address in a 5-minute +window (configurable via `login_rate_limit_max_attempts` and +`login_rate_limit_window_seconds`). + +When the limit is exceeded, the login endpoint returns `429 Too Many Requests`. + +Rate limit records are written on a separate connection so they survive +transaction rollbacks. Call `TetRateLimitService.cleanup()` periodically to +prune old entries. + + +## Events + +Every authentication action fires a Pyramid event that you can subscribe to +for logging, auditing, or side effects: + +```python +from tet.security.events import AuthnLoginSuccess, AuthnLoginFail + +def on_login_success(event): + log.info("Login: user=%s ip=%s", event.user_identity, event.request.client_addr) + +def on_login_fail(event): + log.warning("Failed login: user=%s ip=%s", event.user_identity, event.request.client_addr) + +config.add_subscriber(on_login_success, AuthnLoginSuccess) +config.add_subscriber(on_login_fail, AuthnLoginFail) +``` + +Available events: + +| Event | Fired when | +|-------|-----------| +| `AuthnLoginSuccess` | Successful login | +| `AuthnLoginFail` | Failed login attempt | +| `AuthnLogoutSuccess` | Successful logout | +| `AuthnLogoutFail` | Failed logout | +| `AuthnPasswordChange` | Password changed | +| `AuthnPasswordChangeFail` | Password change failed | +| `AuthnMfaMethodCreated` | New MFA method set up | +| `AuthnMfaMethodDisabled` | MFA method disabled | +| `AuthnRefreshTokensRevoked` | Other refresh tokens revoked | +| `AuthnCurrentRefreshTokenRevoked` | Current refresh token revoked | +| `AuthzFail` | Authorization denied | + +All events carry the originating `request` and the relevant user identifier. + + +## Password validation + +`TetAuthService.change_password()` enforces: + +- Minimum length of 12 characters, maximum 128 +- A strength score (based on length heuristics) +- A check against the [Have I Been Pwned](https://haveibeenpwned.com/Passwords) + breached passwords API (via k-anonymity -- only a 5-character SHA-1 prefix + is sent) + +Configure the API URL in your settings: + +```ini +pwned_passwords_api_url = https://api.pwnedpasswords.com/range/ +``` + + +## Customising the security policy + +The default `TokenAuthenticationPolicy` reads the JWT from the `Authorization` +header and resolves principals as `[Everyone]` (unauthenticated) or +`[Everyone, "user:", Authenticated]`. + +To add custom principals (roles, groups), subclass and override +`effective_principals`: + +```python +from tet.security.policy import TokenAuthenticationPolicy + +class MyPolicy(TokenAuthenticationPolicy): + def effective_principals(self, request): + principals = super().effective_principals(request) + user_id = self.authenticated_userid(request) + if user_id is not None: + # Add roles from your database, cache, etc. + principals.extend(get_user_roles(request, user_id)) + return principals +``` + +Pass your custom policy to `set_token_authentication`: + +```python +config.set_token_authentication( + ..., + security_policy=MyPolicy(), +) +``` + + +## Services + +The following services are registered and available via `request.find_service()`: + +- **`TetTokenService`** -- create/validate long-term tokens, create/verify JWTs, + delete tokens, clean up expired tokens. +- **`TetAuthService`** -- cookie management, password change/validation, user + lookup. +- **`TetMultiFactorAuthenticationService`** -- TOTP setup, verification, + method management, replay protection. +- **`TetRateLimitService`** -- rate limit checking and cleanup. + +All are request-scoped. In your own services, inject them with `autowired()`: + +```python +from pyramid_di import RequestScopedBaseService, autowired +from tet.security.tokens import TetTokenService + +class MyService(RequestScopedBaseService): + token_service: TetTokenService = autowired(TetTokenService) + + def do_something(self): + jwt = self.token_service.create_short_term_jwt(user_id=42) + ... +``` + + +## Settings reference + +All settings are passed to `set_token_authentication()`. The only ones that +are required have no default: + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `long_term_token_model` | *(required)* | SQLAlchemy model using `TokenMixin` | +| `multi_factor_auth_method_model` | *(required)* | Model using `MultiFactorAuthenticationMethodMixin` | +| `user_model` | *(required)* | Your user model (must have `id`, `validate_password()`, `display_name`) | +| `project_prefix` | *(required)* | Prefix for long-term token strings (e.g. `"MYAPP_"`) | +| `login_callback` | *(required)* | Callable `(request) -> AuthLoginResult` | +| `jwk_resolver` | *(required)* | Callable `(request) -> str | dict` returning the JWT signing key | +| `jwt_algorithm` | `"HS256"` | JWT signing algorithm | +| `jwt_token_expiration_mins` | `15` | Access token lifetime in minutes | +| `long_term_token_expiration_mins` | `720` | Refresh token lifetime in minutes (12 hours) | +| `authorization_header` | `"Authorization"` | Header name for access tokens | +| `long_term_token_cookie_name` | `"refresh-token"` | Cookie name for refresh tokens | +| `jwt_claims` | `JWTRegisteredClaims()` | Default registered claims for JWTs | +| `cookie_attributes` | `None` | `CookieAttributes` for refresh token cookies | +| `security_policy` | `TokenAuthenticationPolicy()` | The Pyramid security policy | +| `totp_used_code_model` | `None` | Model for TOTP replay protection (enables if set) | +| `rate_limit_model` | `None` | Model for rate limiting (enables if set) | +| `login_rate_limit_max_attempts` | `10` | Max login attempts per key in window | +| `login_rate_limit_window_seconds` | `300` | Rate limit window (seconds) | diff --git a/pyproject.toml b/pyproject.toml index f5275f6..e0789b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tet" -version = "0.6a1" +version = "0.6a2" description = "Unearthly intelligent batteries-included application framework built on Pyramid" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = {text = "MIT"} authors = [ {name = "Antti Haapala", email = "antti.haapala@anttipatterns.com"}, @@ -19,8 +19,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -40,16 +38,31 @@ dependencies = [ ] [project.optional-dependencies] +security = [ + "pyjwt", + "pyotp", + "qrcode>=8.0,<9.0", + "requests>=2.28", + "pyramid_tm", + "zope.sqlalchemy", +] dev = [ + "tet[security]", "pytest", "pytest-cov", - "black", + "webtest", + "structlog", + "psycopg2-binary", "ruff", "mypy", ] test = [ + "tet[security]", "pytest", "pytest-cov", + "webtest", + "structlog", + "psycopg2-binary", ] [project.urls] @@ -77,27 +90,9 @@ markers = [ "integration: marks tests as integration tests", ] -[tool.black] -line-length = 88 -target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = [ @@ -110,7 +105,7 @@ select = [ "UP", # pyupgrade ] ignore = [ - "E501", # line too long, handled by black + "E501", # line too long, handled by the formatter "B008", # do not perform function calls in argument defaults "C901", # too complex "W191", # indentation contains tabs @@ -124,7 +119,7 @@ ignore = [ known-third-party = ["pyramid", "sqlalchemy", "passlib", "pyramid_di"] [tool.mypy] -python_version = "3.8" +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false diff --git a/pytest.ini b/pytest.ini new file mode 100755 index 0000000..443a712 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = tests +python_files = *.py +pythonpath = . diff --git a/rebase-helper.sh b/rebase-helper.sh new file mode 100755 index 0000000..9bde668 --- /dev/null +++ b/rebase-helper.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Auto-resolve common conflicts during src-layout rebase +set -e + +MAX_ITERATIONS=300 +i=0 + +while [ $i -lt $MAX_ITERATIONS ]; do + i=$((i + 1)) + + if ! [ -d .git/rebase-merge ] && ! [ -d .git/rebase-apply ]; then + echo "Rebase complete!" + rm -f rebase-helper.sh + exit 0 + fi + + current=$(cat .git/rebase-merge/msgnum 2>/dev/null || echo "?") + total=$(cat .git/rebase-merge/end 2>/dev/null || echo "?") + + conflicts=$(git status --short | grep -E "^(UU|UA|DU|AU|AA)" || true) + + if [ -z "$conflicts" ]; then + git add -A + if ! GIT_EDITOR=true git rebase --continue 2>/dev/null; then + continue + fi + continue + fi + + echo "[$current/$total] Resolving..." + + resolved=true + + while IFS= read -r line; do + status="${line:0:2}" + file="${line:3}" + + case "$status" in + "DU") + # File deleted on HEAD, modified by commit (setup.py mostly) + git rm -f "$file" 2>/dev/null || true + ;; + "UA"|"AU") + git add "$file" 2>/dev/null || true + ;; + "AA") + # Both added — take ours (branch) + git checkout --theirs "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: AA on $file"; resolved=false; } + ;; + "UU") + markers=$(grep -c "<<<<<<" "$file" 2>/dev/null || echo 0) + if [ "$markers" -eq 0 ]; then + git add "$file" + continue + fi + # Security files and tests: take theirs (branch version) + # Non-security files: take ours (master version) + case "$file" in + src/tet/security/*|tests/*|docs/authentication_apis*|docs/security_guide*|CHANGES.md|.github/*) + git checkout --theirs "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: UU on $file"; resolved=false; } + ;; + *) + git checkout --ours "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: UU on $file"; resolved=false; } + ;; + esac + ;; + *) + echo "MANUAL: $status on $file" + resolved=false + ;; + esac + done <<< "$conflicts" + + if [ "$resolved" = false ]; then + echo "Stopping at step $current/$total — manual resolution needed" + git status --short + exit 1 + fi + + git add -A + + # Try continue; handle empty commits + result=$(GIT_EDITOR=true git rebase --continue 2>&1) || { + if echo "$result" | grep -q "No changes"; then + echo "[$current/$total] Empty commit, skipping" + git rebase --skip 2>/dev/null || true + elif echo "$result" | grep -q "could not apply"; then + # Next conflict, loop will handle it + true + else + echo "Unexpected error: $result" + exit 1 + fi + } +done + +echo "Hit max iterations" +exit 1 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..5e3e495 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,46 @@ +exclude = [ + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] +line-length = 100 +indent-width = 4 + +[lint] +select = ["E4", "E7", "E9", "F", "B"] +ignore = ["E501","F403", "B028", "F401", "F821", "E741", "F405", "F522"] +unfixable = ["B"] +fixable = ["ALL"] +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[lint.per-file-ignores] +"__init__.py" = ["E402"] +"**/{tests,docs,tools}/*" = ["E402"] + +[format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" +docstring-code-format = false +docstring-code-line-length = "dynamic" diff --git a/src/tet/security/__init__.py b/src/tet/security/__init__.py index a168684..f2a9e1f 100644 --- a/src/tet/security/__init__.py +++ b/src/tet/security/__init__.py @@ -1,8 +1,51 @@ -""" -Security utilities for Tet applications. +from tet.security.compat import ( # noqa: F401 + Allowed, + Denied, + NO_PERMISSION_REQUIRED, +) +from tet.security.config import ( + AuthLoginResult, + CookieAttributes, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + PasswordChangeData, + TOTPData, +) +from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + RateLimitAttemptMixin, + TOTPUsedCodeMixin, + TokenMixin, +) +from tet.security.policy import TokenAuthenticationPolicy +from tet.security.tokens import TetTokenService +from tet.security.auth import TetAuthService +from tet.security.mfa import TetMultiFactorAuthenticationService +from tet.security.rate_limit import TetRateLimitService +from tet.security.views import AuthViews -This package provides security features including: - -- :mod:`tet.security.authorization` - Custom authorization policy with request access -- :mod:`tet.security.csrf` - CSRF token protection -""" +__all__ = [ + "Allowed", + "AuthLoginResult", + "AuthViews", + "CookieAttributes", + "Denied", + "ILoginCallback", + "ISecretCallback", + "JWTRegisteredClaims", + "MultiFactorAuthMethodType", + "NO_PERMISSION_REQUIRED", + "MultiFactorAuthenticationMethodMixin", + "PasswordChangeData", + "RateLimitAttemptMixin", + "TetAuthService", + "TetMultiFactorAuthenticationService", + "TetRateLimitService", + "TetTokenService", + "TOTPData", + "TOTPUsedCodeMixin", + "TokenAuthenticationPolicy", + "TokenMixin", +] diff --git a/src/tet/security/auth.py b/src/tet/security/auth.py new file mode 100644 index 0000000..55f3e5a --- /dev/null +++ b/src/tet/security/auth.py @@ -0,0 +1,154 @@ +import dataclasses +import hashlib +import logging +import typing as tp + +import requests +from pyramid.httpexceptions import HTTPUnauthorized +from pyramid.request import Request +from pyramid_di import RequestScopedBaseService, autowired +from sqlalchemy.orm import Session + +from tet.security.config import ( + CookieAttributes, + PasswordChangeData, + MIN_PASSWORD_LENGTH, + MAX_PASSWORD_LENGTH, + MIN_SCORE, + KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM, +) +from tet.security.tokens import TetTokenService + +logger = logging.getLogger(__name__) + + +class TetAuthService(RequestScopedBaseService): + db_session: Session = autowired(Session) + token_service = autowired(TetTokenService) + + def __init__(self, request: Request): + super().__init__(request=request) + self.project_prefix: str = self.registry.tet_auth_project_prefix + self.long_term_token_cookie_name = self.registry.tet_auth_long_term_token_cookie_name + self.long_term_token_expiration_mins = ( + self.registry.tet_auth_long_term_token_expiration_mins + ) + self.user_model: tp.Any = self.registry.tet_auth_user_model + self.route_prefix: str = self.registry.tet_auth_route_prefix + + @property + def _cookie_path(self) -> str: + return f"/{self.route_prefix}/" if self.route_prefix else "/" + + def set_cookies( + self, + *, + cookie_attributes: CookieAttributes, + refresh_token: str, + **kwargs, + ): + if cookie_attributes: + cookie_attributes = dataclasses.replace( + cookie_attributes, + value=refresh_token, + max_age=cookie_attributes.max_age or self.long_term_token_expiration_mins * 60, + ) + + cookie_attrs = cookie_attributes or CookieAttributes( + name=self.long_term_token_cookie_name, + value=refresh_token, + max_age=self.long_term_token_expiration_mins * 60, + path=self._cookie_path, + ) + self.request.response.set_cookie( + **cookie_attrs.__dict__, + **kwargs, + ) + + def delete_cookie(self, *, name: str, path: str = None, **kwargs): + self.request.response.delete_cookie( + name=name, + path=path or self._cookie_path, + **kwargs, + ) + + def validate_and_create_jwt(self, *, refresh_token: str) -> str: + try: + token_from_db = self.token_service.retrieve_and_validate_token( + token=refresh_token, prefix=self.project_prefix + ) + except ValueError as e: + logger.exception(f"Error validating token: {e}") + self.delete_cookie(name=self.long_term_token_cookie_name) + raise HTTPUnauthorized() from e + + user_id = getattr(token_from_db, self.token_service.user_id_column) + + return self.token_service.create_short_term_jwt(user_id) + + def verify_password(self, user: tp.Any, password: str) -> bool: + return user.validate_password(password) + + def is_password_breached(self, password: str) -> bool: + sha1_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() + prefix, suffix = sha1_hash[:5], sha1_hash[5:] + url = f"{self.request.registry.settings['pwned_passwords_api_url']}{prefix}" + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + except requests.RequestException: + logger.warning("Password breach check unavailable", exc_info=True) + return False + + for line in response.text.splitlines(): + hash_suffix, count = line.split(":") + if hash_suffix == suffix: + return True + return False + + @staticmethod + def assess_password_strength(password: str) -> int: + strength = 0 + if len(password) > 0: + strength += 1 + if len(password) >= MIN_PASSWORD_LENGTH: + strength += 4 + return strength + + def get_current_user(self, user_id: tp.Any) -> tp.Optional[tp.Any]: + return ( + self.db_session.query(self.user_model) + .filter(self.user_model.id == user_id) + .one_or_none() + ) + + def change_password(self, payload: PasswordChangeData, user: tp.Any) -> bool: + is_valid = self.password_change_validation(payload=payload, user=user) + user.password = payload.new_password + self.db_session.flush() + return is_valid + + def password_change_validation(self, payload: PasswordChangeData, user: tp.Any) -> bool: + if self.is_password_breached(payload.new_password): + raise ValueError( + f"{KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM}.PASSWORD_LEAKED_EASY_TO_GUESS" + ) + + validations = [ + ( + self.assess_password_strength(payload.new_password) >= MIN_SCORE, + f"{KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM}.PASSWORD_STRENGTH_TOO_WEAK", + ), + ( + MIN_PASSWORD_LENGTH <= len(payload.new_password) <= MAX_PASSWORD_LENGTH, + f"{KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM}.INCORRECT_PASSWORD_LENGTH", + ), + ( + self.verify_password(user=user, password=payload.current_password), + f"{KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM}.INVALID_CREDENTIALS", + ), + ] + for condition, error_message in validations: + if not condition: + raise ValueError(error_message) + return True diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py new file mode 100644 index 0000000..7eb5b99 --- /dev/null +++ b/src/tet/security/authentication.py @@ -0,0 +1,261 @@ +import typing as tp + +from pyramid.config import Configurator +from zope.interface import Interface + +from tet.security.compat import NO_PERMISSION_REQUIRED + +from tet.security.config import ( + AuthLoginResult, + CookieAttributes, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + PasswordChangeData, + TOTPData, + DEFAULT_JWT_ALGORITHM, + DEFAULT_JWT_TOKEN_EXPIRATION_MINS, + DEFAULT_LONG_TERM_TOKEN_EXPIRATION_MINS, + DEFAULT_USER_ID_COLUMN, + DEFAULT_LONG_TERM_TOKEN_NAME, + DEFAULT_AUTHORIZATION_HEADER, + DEFAULT_REFRESH_TOKEN_COOKIE_NAME, + DEFAULT_REGISTERED_CLAIMS, + DEFAULT_LOGIN_ATTR, + DEFAULT_LOGIN_RATE_LIMIT_MAX_ATTEMPTS, + DEFAULT_LOGIN_RATE_LIMIT_WINDOW_SECONDS, +) +from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + RateLimitAttemptMixin, + TOTPUsedCodeMixin, + TokenMixin, +) +from tet.security.policy import TokenAuthenticationPolicy +from tet.security.tokens import TetTokenService +from tet.security.auth import TetAuthService +from tet.security.mfa import TetMultiFactorAuthenticationService +from tet.security.rate_limit import TetRateLimitService +from tet.security.views import AuthViews + +__all__ = [ + "AuthLoginResult", + "AuthViews", + "CookieAttributes", + "ILoginCallback", + "ISecretCallback", + "JWTRegisteredClaims", + "MultiFactorAuthMethodType", + "MultiFactorAuthenticationMethodMixin", + "PasswordChangeData", + "TetAuthService", + "TetMultiFactorAuthenticationService", + "TetRateLimitService", + "TetTokenService", + "TOTPData", + "TOTPUsedCodeMixin", + "TokenAuthenticationPolicy", + "TokenMixin", + "RateLimitAttemptMixin", + "NO_PERMISSION_REQUIRED", +] + +DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() + + +def set_token_authentication( + config: Configurator, + *, + long_term_token_model: tp.Any, + multi_factor_auth_method_model: tp.Any, + user_model: tp.Any, + project_prefix: str, + login_callback: ILoginCallback, + jwk_resolver: ISecretCallback, + user_id_column: str = DEFAULT_USER_ID_COLUMN, + jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, + jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, + long_term_token_expiration_mins: int = DEFAULT_LONG_TERM_TOKEN_EXPIRATION_MINS, + authorization_header: str = DEFAULT_AUTHORIZATION_HEADER, + long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN_NAME, + long_term_token_cookie_name: str = DEFAULT_REFRESH_TOKEN_COOKIE_NAME, + jwt_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, + cookie_attributes: tp.Optional[CookieAttributes] = None, + security_policy: tp.Optional[type["TokenAuthenticationPolicy"]] = DEFAULT_SECURITY_POLICY, + totp_used_code_model: tp.Any = None, + rate_limit_model: tp.Any = None, + login_rate_limit_max_attempts: int = DEFAULT_LOGIN_RATE_LIMIT_MAX_ATTEMPTS, + login_rate_limit_window_seconds: int = DEFAULT_LOGIN_RATE_LIMIT_WINDOW_SECONDS, +) -> None: + """ + Configure token-based authentication for a Pyramid application (with conflict detection). + + Args: + config: The current Pyramid :class:`pyramid.config.Configurator` instance. + long_term_token_model: A token model class or object representing user tokens. + multi_factor_auth_method_model: A model class for storing MFA methods. + user_model: The user model class. + project_prefix: A project-specific prefix (could be used for namespacing). + login_callback: A callable to verify user credentials/status from the database. + jwk_resolver: A callable that returns a secret key or keys for token signing. + user_id_column: Column name or attribute for user ID in the token model. + jwt_algorithm: The JWT algorithm to use (default: ``"HS256"``). + jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). + long_term_token_expiration_mins: Long-term token expiration in minutes (default: 720). + authorization_header: The header name for the access token. + long_term_token_header: The header name for the long-term token. + long_term_token_cookie_name: Cookie name for the refresh token. + jwt_claims: Default JWT registered claims to include in the token payload. + cookie_attributes: Optional cookie attributes for refresh token cookies. + security_policy: A security policy instance to use for token authentication. + totp_used_code_model: Optional model for TOTP replay protection (UNLOGGED table). + rate_limit_model: Optional model for rate limiting (UNLOGGED table). + login_rate_limit_max_attempts: Max login attempts per IP within the window (default: 10). + login_rate_limit_window_seconds: Rate limit window in seconds (default: 300). + """ + + def register(): + config.registry.tet_auth_long_term_token_model = long_term_token_model + config.registry.tet_multi_factor_auth_method_model = multi_factor_auth_method_model + config.registry.tet_auth_user_model = user_model + config.registry.tet_auth_project_prefix = project_prefix + config.registry.tet_auth_user_id_column = user_id_column + config.registry.tet_authz_header = authorization_header + config.registry.tet_auth_long_term_token_header = long_term_token_header + config.registry.tet_auth_long_term_token_cookie_name = long_term_token_cookie_name + config.registry.tet_auth_jwt_claims = jwt_claims + config.registry.tet_auth_cookie_attributes = cookie_attributes + + config.registry.tet_auth_login_callback = login_callback + config.registry.tet_auth_jwk_resolver = jwk_resolver + config.registry.tet_auth_jwt_algorithm = jwt_algorithm + config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins + config.registry.tet_auth_long_term_token_expiration_mins = long_term_token_expiration_mins + config.registry.tet_auth_security_policy = security_policy + + config.registry.tet_auth_totp_used_code_model = totp_used_code_model + config.registry.tet_auth_rate_limit_model = rate_limit_model + config.registry.tet_auth_login_rate_limit_max_attempts = login_rate_limit_max_attempts + config.registry.tet_auth_login_rate_limit_window_seconds = login_rate_limit_window_seconds + + config.action(discriminator="set_token_authentication", callable=register) + + config.set_security_policy(security_policy) + + +def includeme(config: Configurator): + """Routes and stuff to register maybe under a prefix""" + config.registry.tet_auth_route_prefix = config.route_prefix or "" + + config.add_route("tet_auth_login", "/login") + config.add_route("tet_auth_logout", "/logout") + config.add_route("tet_auth_refresh_token", "/token/refresh") + config.add_route("tet_auth_change_password", "/users/me/password") + config.add_route("tet_auth_revoke_other_tokens", "/users/me/tokens/others") + config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") + config.add_route("tet_auth_disable_mfa_method", "/mfa/app/disable") + config.add_route("tet_auth_generate_mfa_totp", "/mfa/app/setup") + config.add_route("tet_auth_get_mfa_methods", "/mfa/methods") + + config.add_view( + AuthViews, + attr="generate_mfa_totp", + route_name="tet_auth_generate_mfa_totp", + request_method="POST", + renderer="json", + require_csrf=False, + ) + config.add_view( + AuthViews, + attr="get_mfa_methods", + route_name="tet_auth_get_mfa_methods", + request_method="GET", + renderer="json", + require_csrf=False, + ) + config.add_view( + AuthViews, + attr="revoke_other_tokens", + route_name="tet_auth_revoke_other_tokens", + request_method="DELETE", + renderer="json", + require_csrf=False, + ) + config.add_view( + AuthViews, + attr="disable_mfa_method", + route_name="tet_auth_disable_mfa_method", + request_method="POST", + renderer="json", + require_csrf=False, + ) + config.add_view( + AuthViews, + attr="logout", + route_name="tet_auth_logout", + request_method="POST", + renderer="json", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + config.add_view( + AuthViews, + attr="change_password", + route_name="tet_auth_change_password", + request_method="POST", + renderer="json", + require_csrf=False, + ) + config.add_view( + AuthViews, + attr="refresh_token", + route_name="tet_auth_refresh_token", + renderer="json", + request_method="POST", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + + config.add_view( + AuthViews, + attr="mfa_verify", + route_name="tet_auth_mfa_verify", + renderer="json", + request_method="POST", + require_csrf=False, + ) + + config.add_view( + AuthViews, + attr=DEFAULT_LOGIN_ATTR, + route_name="tet_auth_login", + renderer="json", + request_method="POST", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + + config.add_directive("set_token_authentication", set_token_authentication) + + config.include("pyramid_di") + config.register_service_factory( + lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface + ) + config.register_service_factory( + lambda ctx, req: TetMultiFactorAuthenticationService(request=req), + TetMultiFactorAuthenticationService, + Interface, + ) + config.register_service_factory( + lambda ctx, req: TetAuthService(request=req), + TetAuthService, + Interface, + ) + config.register_service_factory( + lambda ctx, req: TetRateLimitService(request=req), + TetRateLimitService, + Interface, + ) + + config.set_default_permission("view") diff --git a/src/tet/security/authorization.py b/src/tet/security/authorization.py index b85ef33..82ff25c 100644 --- a/src/tet/security/authorization.py +++ b/src/tet/security/authorization.py @@ -47,7 +47,29 @@ def main(config): from pyramid.threadlocal import get_current_request from zope.interface import Interface, implementer -__all__ = ["INewAuthorizationPolicy"] +from tet.security.compat import ( # noqa: F401 — re-exported + ACLHelper, + Allow, + Allowed, + Authenticated, + Denied, + Deny, + Everyone, + NO_PERMISSION_REQUIRED, +) + +__all__ = [ + "ACLHelper", + "Allow", + "Allowed", + "Authenticated", + "AuthorizationPolicyWrapper", + "Denied", + "Deny", + "Everyone", + "INewAuthorizationPolicy", + "NO_PERMISSION_REQUIRED", +] class INewAuthorizationPolicy(Interface): @@ -87,9 +109,7 @@ def permits(self, context, principals, permission): def principals_allowed_by_permission(self, context, permission): """Return principals allowed the permission on context.""" request = get_current_request() - return self.wrapped.principals_allowed_by_permission( - request, context, permission - ) + return self.wrapped.principals_allowed_by_permission(request, context, permission) def includeme(config: Configurator): @@ -103,7 +123,7 @@ def includeme(config: Configurator): def set_authorization_policy(config: Configurator, policy: Any) -> None: """Set the authorization policy, wrapping INewAuthorizationPolicy if needed.""" policy = config.maybe_dotted(policy) - if isinstance(policy, INewAuthorizationPolicy): + if INewAuthorizationPolicy.providedBy(policy): policy = AuthorizationPolicyWrapper(policy) # noinspection PyCallByClass diff --git a/src/tet/security/compat.py b/src/tet/security/compat.py new file mode 100644 index 0000000..b0b87ea --- /dev/null +++ b/src/tet/security/compat.py @@ -0,0 +1,53 @@ +""" +Single source of truth for Pyramid security/authorization imports. + +Pyramid 2.0 moved ``Allow``, ``Deny``, ``Everyone``, ``Authenticated``, +``ALL_PERMISSIONS``, ``DENY_ALL``, ``AllPermissionsList``, ``ACLAllowed``, +and ``ACLDenied`` from ``pyramid.security`` to ``pyramid.authorization``. + +``NO_PERMISSION_REQUIRED``, ``Allowed``, and ``Denied`` remain in +``pyramid.security`` as of Pyramid 2.0. + +If ``pyramid.security`` is removed in a future Pyramid version, update +this file only. +""" + +try: + from pyramid.authorization import ( + ACLHelper, + Allow, + Authenticated, + Deny, + Everyone, + ALL_PERMISSIONS, + DENY_ALL, + ) +except ImportError: + from pyramid.security import ( + Allow, + Authenticated, + Deny, + Everyone, + ALL_PERMISSIONS, + DENY_ALL, + ) + from pyramid.authorization import ACLHelper + +from pyramid.security import ( + Allowed, + Denied, + NO_PERMISSION_REQUIRED, +) + +__all__ = [ + "ACLHelper", + "ALL_PERMISSIONS", + "Allow", + "Allowed", + "Authenticated", + "DENY_ALL", + "Denied", + "Deny", + "Everyone", + "NO_PERMISSION_REQUIRED", +] diff --git a/src/tet/security/config.py b/src/tet/security/config.py new file mode 100644 index 0000000..b37116c --- /dev/null +++ b/src/tet/security/config.py @@ -0,0 +1,206 @@ +import dataclasses +import enum +import typing as tp + +from datetime import datetime, timedelta, timezone + +from pyramid.request import Request + +DEFAULT_JWT_ALGORITHM = "HS256" +DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 +DEFAULT_LONG_TERM_TOKEN_EXPIRATION_MINS = 60 * 12 +DEFAULT_USER_ID_COLUMN = "user_id" +DEFAULT_LONG_TERM_TOKEN_NAME = "X-Long-Token" +DEFAULT_AUTHORIZATION_HEADER = "Authorization" +DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "refresh-token" +DEFAULT_PATH = "/" +DEFAULT_UNAUTHORIZED_MESSAGE = """Access denied. You are not authorised to access this resource. +Please ensure that your credentials are correct and try again. +""" + +DEFAULT_LOGIN_ATTR = "login" +UTC = timezone.utc +MIN_PASSWORD_LENGTH = 12 +MAX_PASSWORD_LENGTH = 128 +MIN_SCORE = 2 +KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM = "settings.profile.changePasswordForm" +MFA_REQUIRED_KEY = "mfa_required" +TOKEN_ID_BYTE_LENGTH = 8 +DEFAULT_LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 10 +DEFAULT_LOGIN_RATE_LIMIT_WINDOW_SECONDS = 300 + + +@dataclasses.dataclass +class PasswordChangeData: + current_password: str + new_password: str + + +@dataclasses.dataclass +class JWTRegisteredClaims: + """ + A dataclass representing the registered claims in a JSON Web Token (JWT). + + These claims are defined by the JWT specification (RFC 7519) and are commonly + used for token validation. The fields are optional and can be included as needed. + + More info about the registered claims can be found here: + https://pyjwt.readthedocs.io/en/2.0.1/usage.html?highlight=datetime#registered-claim-names + + Attributes: + user_id (Any): User ID - The unique identifier for the user. + iss (str): Issuer - Identifies the principal that issued the JWT. + sub (str): Subject - Identifies the principal that is the subject of the JWT. + aud (Union[str, list]): Audience - Identifies the recipients that the JWT is intended for. + exp (datetime): Expiration Time - Identifies when the JWT expires. + nbf (datetime): Not Before - Identifies when the JWT becomes valid. + iat (datetime): Issued At - Identifies when the JWT was issued. + jti (str): JWT ID - A unique identifier for the JWT. + leeway (int): The amount of time (in seconds) that the token is valid before/after the specified time. + + Methods: + to_dict() -> dict[str, Any]: + Converts the dataclass instance into a dictionary + + Example: + + .. code-block:: python + + claims = JWTRegisteredClaims( + iss="my-auth-service", + sub="user123", + aud="my-api.example.com", + exp=datetime.now(UTC) + timedelta(hours=1), + iat=datetime.now(UTC), + jti="unique-token-id-456" + ) + + payload = claims.to_dict() + """ + + user_id: tp.Any = None + iss: str = None + sub: str = None + aud: tp.Union[str, list] = None + exp: datetime = None + nbf: datetime = None + iat: datetime = None + jti: str = None + leeway: int = 0 + + def to_dict(self) -> tp.Dict[str, tp.Any]: + """ + Converts the JWTRegisteredClaims instance into a dictionary. + + Ensures that datetime fields (`exp`, `nbf`, `iat`) are represented + as Unix timestamps (seconds since epoch) or datetime objects. + + Returns: + dict[str, Any]: A dictionary representation of the registered claims. + """ + return {k: v for k, v in dataclasses.asdict(self).items() if v is not None and k != "leeway"} + + +@dataclasses.dataclass +class CookieAttributes: + name: str = None + value: tp.Optional[str] = None + max_age: tp.Optional[int | timedelta] = None + domain: tp.Optional[str] = None + path: str = DEFAULT_PATH + secure: bool = True + httponly: bool = True + samesite: str = "Lax" + overwrite: bool = True + + +@dataclasses.dataclass +class TOTPData: + """ + Dataclass for storing TOTP-specific configuration data. + + Attributes: + secret: The shared secret key for TOTP generation. + issuer: The name of the service or application issuing the TOTP code. + digits: The number of digits in the generated TOTP code. + period: The time period (in seconds) for TOTP code generation. + algorithm: The hash algorithm used for TOTP generation. + """ + + secret: str + issuer: str + digits: int = 6 + period: int = 30 + algorithm: str = "SHA1" + + def to_dict(self) -> dict: + return dataclasses.asdict(self) + + +class MultiFactorAuthMethodType(enum.Enum): + """ + Enum for the available multi-factor authentication methods. + + Attributes: + HOTP: HMAC-based One Time Password + TOTP: Time-based One Time Password + U2F: Universal 2nd Factor + HMAC: Hash-based Message Authentication Code + OTP: One Time Password + SMS: Short Message Service + """ + + TOTP = "totp" + HOTP = "hotp" + U2F = "u2f" + HMAC = "hmac" + OTP = "otp" + SMS = "sms" + + +@dataclasses.dataclass +class AuthLoginResult: + """ + Dataclass for storing login data. + + Attributes: + user_id: Unique identifier of the user. + totp_token: Optional TOTP (Time-based One-Time Password) token for MFA. + user_identity: Optional user identity (e.g., email, username, or id). + mfa_required_key: Key indicating if MFA is required. + success: Boolean indicating whether the login was successful. + """ + + user_id: tp.Any + totp_token: tp.Optional[str] = None + user_identity: tp.Optional[str] = None + mfa_required_key: str = MFA_REQUIRED_KEY + success: bool = False + + def __bool__(self) -> bool: + """Returns True if login was successful, otherwise False.""" + return self.success + + +class ILoginCallback(tp.Protocol): + """ + Authenticates a user and returns the user_id. + + **Returns:** ``user_id`` + """ + + def __call__(self, request: Request) -> AuthLoginResult: + pass + + +class ISecretCallback(tp.Protocol): + """ + **Returns:** The secret key for JWT + """ + + def __call__(self, request: Request) -> tp.Union[str, dict]: + pass + + +DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() +DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() diff --git a/src/tet/security/events.py b/src/tet/security/events.py new file mode 100644 index 0000000..b1a8d82 --- /dev/null +++ b/src/tet/security/events.py @@ -0,0 +1,258 @@ +import dataclasses +import typing as tp + +from pyramid.request import Request + +__all__ = [ + "TetAuthEvent", + "AuthnPasswordChange", + "AuthnPasswordChangeFail", + "AuthnLoginSuccess", + "AuthnLoginFail", + "AuthnLogoutSuccess", + "AuthnLogoutFail", + "AuthnMfaMethodDisabled", + "AuthnMfaMethodDisableFail", + "AuthnMfaMethodCreated", + "AuthnRefreshTokensRevoked", + "AuthnRefreshTokenRevokeFail", + "AuthnCurrentRefreshTokenRevoked", + "AuthnCurrentRefreshTokenRevokeFail", + "AuthnInputValidationFail", + "AuthzFail", +] + + +@dataclasses.dataclass(kw_only=True, slots=True) +class TetAuthEvent: + """ + Base class for all authentication and authorisation event types. + + Attributes: + request (Request): The originating request object. + """ + + request: Request + + +# Auth events + + +@dataclasses.dataclass() +class AuthzFail(TetAuthEvent): + """ + AuthzFail[:userid,resource] + Event for authorisation failure. + + Attributes: + user_id (Any): Identifier of the user denied access. + resource (str): Name of the resource denied. + """ + + user_id: tp.Any + resource: str + + +# Change password events + + +@dataclasses.dataclass() +class AuthnPasswordChange(TetAuthEvent): + """ + AuthnPasswordChange[:authenticated_userid] + Event for successful password change. + + Attributes: + authenticated_userid (Any): Identifier of the authenticated user. + """ + + authenticated_userid: tp.Any + + +@dataclasses.dataclass() +class AuthnPasswordChangeFail(TetAuthEvent): + """ + AuthnPasswordChangeFail[:authenticated_userid] + Event for failed password change. + + Attributes: + authenticated_userid (Any): Identifier of the user. + """ + + authenticated_userid: tp.Any + + +# Login events + + +@dataclasses.dataclass() +class AuthnLoginSuccess(TetAuthEvent): + """ + AuthnLoginSuccess[:user_identity] + Event for successful login. + + Attributes: + user_identity (Any): Identifier of the user. + """ + + user_identity: tp.Any + + +@dataclasses.dataclass() +class AuthnLoginFail(TetAuthEvent): + """ + AuthnLoginFail[:user_identity] + Event for failed login attempt. + + Attributes: + user_identity (Any): Identifier of the user. + """ + + user_identity: tp.Any + + +# Logout events + + +@dataclasses.dataclass() +class AuthnLogoutSuccess(TetAuthEvent): + """ + AuthnLogoutSuccess[:userid] + Event for successful logout. + + Attributes: + user_id (Any): Identifier of the user. + """ + + user_id: tp.Any + + +@dataclasses.dataclass() +class AuthnLogoutFail(TetAuthEvent): + """ + AuthnLogoutFail[:userid] + Event for failed logout. + + Attributes: + user_id (Any): Identifier of the user. + """ + + user_id: tp.Any + + +# MFA events + + +@dataclasses.dataclass() +class AuthnMfaMethodDisabled(TetAuthEvent): + """ + AuthnMfaMethodDisabled[:authenticated_userid, method] + Event for successful MFA method disable. + + Attributes: + authenticated_userid (Any): Identifier of the user. + method (str): MFA method disabled. + """ + + authenticated_userid: tp.Any + method: str + + +@dataclasses.dataclass() +class AuthnMfaMethodDisableFail(TetAuthEvent): + """ + AuthnMfaMethodDisableFail[:authenticated_userid, method] + Event for failed MFA method disable. + + Attributes: + authenticated_userid (Any): Identifier of the user. + method (str): MFA method attempted. + """ + + authenticated_userid: tp.Any + method: str + + +@dataclasses.dataclass() +class AuthnMfaMethodCreated(TetAuthEvent): + """ + AuthnMfaMethodCreated[:authenticated_userid, method] + Event for successful creation of an MFA method. + + Attributes: + authenticated_userid (Any): Identifier of the user. + method (str): MFA method created. + """ + + authenticated_userid: tp.Any + method: str + + +# Token events + + +@dataclasses.dataclass() +class AuthnRefreshTokensRevoked(TetAuthEvent): + """ + AuthnRefreshTokensRevoked[:authenticated_userid] + Event for revoking all refresh tokens. + + Attributes: + authenticated_userid (Any): Identifier of the user. + """ + + authenticated_userid: tp.Any + + +@dataclasses.dataclass() +class AuthnRefreshTokenRevokeFail(TetAuthEvent): + """ + AuthnRefreshTokenRevokeFail[:authenticated_userid] + Event for failed revocation of all refresh tokens. + + Attributes: + authenticated_userid (Any): Identifier of the user. + """ + + authenticated_userid: tp.Any + + +@dataclasses.dataclass() +class AuthnCurrentRefreshTokenRevoked(TetAuthEvent): + """ + AuthnCurrentRefreshTokenRevoked[:authenticated_userid] + Event for revoking the current refresh token. + + Attributes: + authenticated_userid (Any): Identifier of the user. + """ + + authenticated_userid: tp.Any + + +@dataclasses.dataclass() +class AuthnCurrentRefreshTokenRevokeFail(TetAuthEvent): + """ + AuthnCurrentRefreshTokenRevokeFail[:authenticated_userid] + Event for failed revocation of the current refresh token. + + Attributes: + authenticated_userid (Any): Identifier of the user. + """ + + authenticated_userid: tp.Any + + +@dataclasses.dataclass() +class AuthnInputValidationFail(TetAuthEvent): + """ + AuthnInputValidationFail:[(fieldone,fieldtwo...),userid] + Event for input validation failure during authentication. + + Attributes: + userid (Any): Identifier of the user. + fields (List[str]): List of field names that failed validation. + """ + + userid: tp.Any + fields: tp.List[str] diff --git a/src/tet/security/mfa.py b/src/tet/security/mfa.py new file mode 100644 index 0000000..f182538 --- /dev/null +++ b/src/tet/security/mfa.py @@ -0,0 +1,307 @@ +import base64 +import io +import logging +import time +import typing as tp +from datetime import datetime, timedelta + +import pyotp +import qrcode +import qrcode.image.svg +from pyramid.httpexceptions import ( + HTTPForbidden, + HTTPBadRequest, + HTTPException, + HTTPInternalServerError, +) +from pyramid.request import Request +from pyramid_di import RequestScopedBaseService, autowired +from sqlalchemy.orm import Session + +import tet.security.events as security_events +from tet.security.config import ( + CookieAttributes, + TOTPData, + MultiFactorAuthMethodType, + UTC, +) +from tet.security.tokens import TetTokenService +from tet.security.auth import TetAuthService + +logger = logging.getLogger(__name__) + + +class TetMultiFactorAuthenticationService(RequestScopedBaseService): + session: Session = autowired(Session) + token_service: TetTokenService = autowired(TetTokenService) + auth_service: TetAuthService = autowired(TetAuthService) + + def __init__(self, request: Request): + super().__init__(request=request) + self.tet_multi_factor_auth_method_model: tp.Any = ( + self.registry.tet_multi_factor_auth_method_model + ) + self.totp_used_code_model: tp.Any = getattr( + self.registry, "tet_auth_totp_used_code_model", None + ) + self.project_prefix: str = self.registry.tet_auth_project_prefix + self.long_term_token_cookie_name = self.registry.tet_auth_long_term_token_cookie_name + self.long_term_token_expiration_mins = ( + self.registry.tet_auth_long_term_token_expiration_mins + ) + + def create_method(self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict): + """ + Create a new multifactor authentication method for a user. + """ + new_mfa_method = self.tet_multi_factor_auth_method_model( + method_type=method_type, user_id=user_id, data=data + ) + self.session.add(new_mfa_method) + self.session.flush() + return new_mfa_method + + def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType): + """ + Disable a multifactor authentication method for a user. + """ + self.session.query(self.tet_multi_factor_auth_method_model).filter_by( + user_id=user_id, method_type=method_type.value + ).update({"is_active": False, "verified": False, "data": {}}) + + @staticmethod + def verify_totp(secret: tp.Any, token: tp.Any) -> bool: + """ + Verify a one-time password for multifactor authentication. + """ + totp = pyotp.TOTP(secret) + return totp.verify(token) + + def get_method( + self, + *, + user_id: tp.Any, + method_type: MultiFactorAuthMethodType, + is_active: bool = True, + verified: bool = True, + for_update: bool = False, + ): + """ + Retrieve a multifactor authentication method for a user. + """ + conditions = [ + self.tet_multi_factor_auth_method_model.user_id == user_id, + self.tet_multi_factor_auth_method_model.method_type == method_type, + ] + if is_active: + conditions.append(self.tet_multi_factor_auth_method_model.is_active == is_active) + if verified: + conditions.append(self.tet_multi_factor_auth_method_model.verified == verified) + query = ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter(*conditions) + ) + if for_update: + query = query.with_for_update() + return query.one_or_none() + + def get_active_methods_by_user_id(self, *, user_id: tp.Any): + """ + Retrieve all multifactor authentication methods by user id. + """ + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter_by(user_id=user_id, is_active=True, verified=True) + .all() + ) + + def is_totp_mfa_enabled(self, user_id: tp.Any = None) -> bool: + """ + Check if multifactor authentication is enabled for the user. + """ + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter( + self.tet_multi_factor_auth_method_model.user_id == user_id, + self.tet_multi_factor_auth_method_model.is_active, + self.tet_multi_factor_auth_method_model.verified, + ) + .count() + > 0 + ) + + def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any) -> dict: + try: + totp_mfa_method = self.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=False, + verified=False, + ) + if not totp_mfa_method: + raise HTTPForbidden( + json_body={"message": "Two-factor authentication method not found."} + ) + + setup_key = totp_mfa_method.data.get("secret") if totp_mfa_method.data else None + if not setup_key: + raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) + + is_valid = self.verify_totp(secret=setup_key, token=token) + + if not is_valid: + raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + + totp_mfa_method.mark_used() + totp_mfa_method.verified = True + totp_mfa_method.is_active = True + return {"success": is_valid} + except KeyError as e: + logger.exception(f"details {str(e)}") + raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e + except HTTPException: + raise + except Exception as e: + logger.exception(f"details {str(e)}") + raise HTTPInternalServerError(json_body={"message": "TOTP verification failed."}) from e + + def _check_totp_replay(self, user_id: tp.Any, time_step: int) -> None: + if not self.totp_used_code_model: + return + existing = ( + self.session.query(self.totp_used_code_model) + .filter( + self.totp_used_code_model.user_id == user_id, + self.totp_used_code_model.time_step == time_step, + ) + .one_or_none() + ) + if existing: + raise HTTPForbidden( + json_body={"message": "TOTP code already used."} + ) + + def _record_totp_use(self, user_id: tp.Any, time_step: int) -> None: + if not self.totp_used_code_model: + return + used = self.totp_used_code_model() + used.user_id = user_id + used.time_step = time_step + self.session.add(used) + self.session.flush() + + def cleanup_used_codes(self, older_than_seconds: int = 120) -> int: + if not self.totp_used_code_model: + return 0 + cutoff = datetime.now(UTC) - timedelta(seconds=older_than_seconds) + count = ( + self.session.query(self.totp_used_code_model) + .filter(self.totp_used_code_model.used_at < cutoff) + .delete() + ) + self.session.flush() + return count + + def handle_totp_challenge( + self, + *, + user_id: tp.Any, + totp_token: str = None, + cookie_attributes: CookieAttributes = None, + ) -> dict[str, tp.Any]: + replay_protection = self.totp_used_code_model is not None + totp_mfa_method = self.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + for_update=replay_protection, + ) + if not totp_mfa_method: + raise HTTPForbidden( + json_body={"message": "Two-factor authentication method not found."} + ) + + secret = totp_mfa_method.data.get("secret") + + if not secret: + raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) + + is_valid = self.verify_totp(secret=secret, token=totp_token) + + if not is_valid: + raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + + current_time_step = int(time.time()) // 30 + self._check_totp_replay(user_id, current_time_step) + self._record_totp_use(user_id, current_time_step) + + totp_mfa_method.mark_used() + + refresh_token = self.token_service.create_long_term_token(user_id=user_id, project_prefix=self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) + + self.auth_service.set_cookies( + cookie_attributes=cookie_attributes, + refresh_token=refresh_token, + ) + self.registry.notify( + security_events.AuthnLoginSuccess( + request=self.request, + user_identity=self.request.json_body.get("user_identity", user_id), + ) + ) + return {"success": is_valid, "access_token": access_token, "refresh_token": refresh_token} + + @staticmethod + def _create_totp_data(issuer: str) -> TOTPData: + secret = pyotp.random_base32() + return TOTPData( + secret=secret, + issuer=issuer, + ) + + @staticmethod + def generate_qr_img(user: tp.Any, mfa_secret: str, data: TOTPData) -> str: + otp_uri = pyotp.totp.TOTP(mfa_secret).provisioning_uri( + name=user.display_name, issuer_name=data.issuer + ) + factory = qrcode.image.svg.SvgImage + qr = qrcode.QRCode(box_size=15, border=4) + qr.add_data(otp_uri) + qr.make(fit=True) + img = qr.make_image(image_factory=factory) + buffer = io.BytesIO() + img.save(buffer) + return base64.b64encode(buffer.getvalue()).decode("utf-8") + + def handle_totp_setup(self, *, user: tp.Any, project_prefix: str) -> dict: + try: + data: TOTPData = self._create_totp_data(issuer=project_prefix) + existing_method = self.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=False, + verified=False, + ) + if existing_method: + existing_method.data = data.to_dict() + else: + self.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data=data.to_dict(), + ) + self.request.registry.notify( + security_events.AuthnMfaMethodCreated( + request=self.request, + authenticated_userid=user.id, + method=MultiFactorAuthMethodType.TOTP.value, + ) + ) + mfa_secret = data.secret + img_str = self.generate_qr_img(user=user, mfa_secret=mfa_secret, data=data) + return {"secret": mfa_secret, "qr_code": f"data:image/svg+xml;base64,{img_str}"} + except Exception as e: + logger.exception(e) + return dict(success=False, message="Error generating TOTP method") diff --git a/src/tet/security/models.py b/src/tet/security/models.py new file mode 100644 index 0000000..525ef65 --- /dev/null +++ b/src/tet/security/models.py @@ -0,0 +1,89 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Enum, Boolean +from sqlalchemy.dialects.postgresql import JSONB + +from tet.security.config import MultiFactorAuthMethodType, UTC + + +class MultiFactorAuthenticationMethodMixin: + """ + Mixin to store and manage a user's multi-factor authentication method. + + Attributes: + id (int): Primary key for the Multi-factor authentication record. + method_type (MultiFactorAuthMethodType): Enum indicating the type of 2FA method (e.g. TOTP, U2F, etc.). + data (dict): JSONB field holding method-specific configuration or secret data. + is_active (bool): Flag indicating if the 2FA method is currently enabled. + verified (bool): Flag indicating if the 2FA method has been verified for the user. + created_at (datetime): Time when the record was created (timezone-aware). + last_used_at (datetime, optional): Timestamp of the most recent use of the 2FA method. + """ + + __tablename__ = "multi_factor_authentication_method" + id = Column(Integer, primary_key=True) + method_type = Column( + Enum(MultiFactorAuthMethodType, values_callable=lambda cls: [e.value for e in cls]), + nullable=False, + index=True, + ) + data = Column(JSONB, nullable=False, default=dict) + is_active = Column(Boolean, default=False, nullable=False) + verified = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(True), default=lambda: datetime.now(UTC)) + last_used_at = Column(DateTime(True), nullable=True) + + def mark_used(self): + self.last_used_at = datetime.now(UTC) + + +class TOTPUsedCodeMixin: + """ + Tracks used TOTP time steps to prevent replay attacks. + + The consuming application must add a ``user_id`` foreign key column and + a unique constraint on ``(user_id, time_step)``. The table should be + created as ``UNLOGGED`` for performance (``__table_args__ = {'prefixes': ['UNLOGGED']}``). + """ + + __tablename__ = "totp_used_code" + id = Column(Integer, primary_key=True) + time_step = Column(BigInteger, nullable=False) + used_at = Column(DateTime(True), default=lambda: datetime.now(UTC)) + + +class RateLimitAttemptMixin: + """ + Records individual rate-limited attempts keyed by an arbitrary string. + + The table should be created as ``UNLOGGED`` + (``__table_args__ = {'prefixes': ['UNLOGGED']}``). + """ + + __tablename__ = "rate_limit_attempt" + id = Column(Integer, primary_key=True) + key = Column(String, nullable=False, index=True) + attempted_at = Column(DateTime(True), default=lambda: datetime.now(UTC), nullable=False) + + +class TokenMixin: + """ + Stores long-term tokens for users with creation and optional expiration timestamps. + + User ID foreign key needs to be provided by the application. + + + **Attributes:** + + * ``id:`` Primary key for the token. + * ``secret_hash:`` The SHA-256 hashed secret. + * ``created_at:`` Timestamp when the token was created. + * ``expires_at:`` Optional timestamp for token expiration. + + """ + + __tablename__ = "tokens" + id = Column(Integer, primary_key=True) + secret_hash = Column(String, nullable=False) + created_at = Column(DateTime(True), default=lambda: datetime.now(UTC)) + expires_at = Column(DateTime(True), nullable=True) diff --git a/src/tet/security/policy.py b/src/tet/security/policy.py new file mode 100644 index 0000000..f15ff69 --- /dev/null +++ b/src/tet/security/policy.py @@ -0,0 +1,74 @@ +import typing as tp + +from pyramid.authentication import CallbackAuthenticationPolicy +from pyramid.interfaces import ISecurityPolicy +from pyramid.request import Request +from zope.interface import implementer + +from tet.security.compat import ACLHelper, Everyone, Authenticated + + +@implementer(ISecurityPolicy) +class TokenAuthenticationPolicy(CallbackAuthenticationPolicy): + """ + A Pyramid security policy for token-based authentication. + + All methods in this class are only invoked if the view has a `permission` set in `@view_config()`. + This ensures that authentication and authorization checks are enforced before access is granted. + + Example: + + .. code-block:: python + + @view_config(route_name="home", renderer="json", permission="view") + def home_view(request): + user_id = request.authenticated_userid + return {"message": f"Hello, User {user_id}"} + """ + + def __init__(self): + self.acl = ACLHelper() + + def authenticated_userid(self, request: Request) -> tp.Optional[int]: + """This method of the policy should + only return a value if the request has been successfully authenticated. + + Returns: + - Return the ``userid`` of the currently authenticated user + - ``None`` if no user is authenticated. + """ + from tet.security.tokens import TetTokenService + + token_service: TetTokenService = request.find_service(TetTokenService) + + auth_header = request.headers.get(request.registry.tet_authz_header, "") + scheme, _, access_token = auth_header.partition(" ") + if scheme.lower() != "bearer" or not access_token: + return None + + payload = token_service.verify_jwt(access_token) + return payload.get("user_id") if payload else None + + def permits(self, request, context, permission): + principals = self.effective_principals(request) + return self.acl.permits(context, principals, permission) + + def effective_principals(self, request) -> tp.List[str]: + """This method of the policy should return at least one principal + in the list: the userid of the user (and usually 'system.Authenticated' + as well). + + Returns: + A sequence representing the groups that the current user is in + """ + principals = [Everyone] + user_id = self.authenticated_userid(request) + if user_id is not None: + principals.extend([f"user:{user_id}", Authenticated]) + return principals + + def forget(self, request) -> tp.List[tuple[str, str]]: + """ + This method does not need to be implemented for header-based authentication. + """ + return [] diff --git a/src/tet/security/rate_limit.py b/src/tet/security/rate_limit.py new file mode 100644 index 0000000..a2adfc4 --- /dev/null +++ b/src/tet/security/rate_limit.py @@ -0,0 +1,70 @@ +import logging +import typing as tp + +from datetime import datetime, timedelta + +from sqlalchemy import func, select +from sqlalchemy.orm import Session +from pyramid.request import Request +from pyramid_di import RequestScopedBaseService, autowired + +from tet.security.config import UTC + +logger = logging.getLogger(__name__) + + +class TetRateLimitService(RequestScopedBaseService): + session: Session = autowired(Session) + + def __init__(self, request: Request): + super().__init__(request=request) + self.rate_limit_model: tp.Any = getattr( + self.registry, "tet_auth_rate_limit_model", None + ) + + @property + def enabled(self) -> bool: + return self.rate_limit_model is not None + + def check_rate_limit( + self, key: str, max_attempts: int, window_seconds: int + ) -> bool: + """Record an attempt and return True if the rate limit is exceeded. + + Uses a separate connection so the record survives transaction rollback + (e.g. on a 401 response). + """ + if not self.enabled: + return False + + table = self.rate_limit_model.__table__ + engine = self.session.get_bind() + now = datetime.now(UTC) + cutoff = now - timedelta(seconds=window_seconds) + + with engine.begin() as conn: + conn.execute(table.insert().values(key=key, attempted_at=now)) + + with engine.connect() as conn: + count = conn.execute( + select(func.count(table.c.id)).where( + table.c.key == key, + table.c.attempted_at >= cutoff, + ) + ).scalar() + + return count > max_attempts + + def cleanup(self, older_than_seconds: int = 3600) -> int: + """Delete rate-limit entries older than *older_than_seconds*.""" + if not self.enabled: + return 0 + + table = self.rate_limit_model.__table__ + cutoff = datetime.now(UTC) - timedelta(seconds=older_than_seconds) + engine = self.session.get_bind() + with engine.begin() as conn: + result = conn.execute( + table.delete().where(table.c.attempted_at < cutoff) + ) + return result.rowcount diff --git a/src/tet/security/tokens.py b/src/tet/security/tokens.py new file mode 100644 index 0000000..32a07e4 --- /dev/null +++ b/src/tet/security/tokens.py @@ -0,0 +1,195 @@ +import dataclasses +import hashlib +import hmac +import logging +import secrets +import typing as tp + +from datetime import datetime, timedelta + +import jwt +from pyramid.request import Request +from pyramid_di import RequestScopedBaseService, autowired +from sqlalchemy.orm import Session +from sqlalchemy.sql import delete + +from tet.security.config import JWTRegisteredClaims, UTC, TOKEN_ID_BYTE_LENGTH + +logger = logging.getLogger(__name__) + + +class TetTokenService(RequestScopedBaseService): + db_session: Session = autowired(Session) + + def __init__(self, request: Request): + super().__init__(request=request) + self.project_prefix: str = self.registry.tet_auth_project_prefix + self.long_term_token_model: tp.Any = self.registry.tet_auth_long_term_token_model + self.long_term_token_cookie_name: str = self.registry.tet_auth_long_term_token_cookie_name + self.user_id_column: str = self.registry.tet_auth_user_id_column + self.jwt_expiration_mins: int = self.registry.tet_auth_jwt_expiration_mins + self.jwt_algorithm: str = self.registry.tet_auth_jwt_algorithm + self.jwt_claims: JWTRegisteredClaims = self.registry.tet_auth_jwt_claims + + def create_long_term_token( + self, *, user_id: tp.Any, project_prefix: str, expire_timestamp: tp.Optional[datetime] = None + ) -> str: + """ + Generates a long-term token for a user with a project-specific prefix and stores it in the database. + + Args: + user_id: The ID of the user for whom the token is generated. + project_prefix: A prefix indicating the project this token is for. + expire_timestamp: (Optional) Expiration timestamp for the token. + + Returns: + The plaintext long-term token with the project-specific prefix. + """ + if not expire_timestamp: + expire_timestamp = datetime.now(UTC) + timedelta(hours=12) + + secret = secrets.token_bytes(32) + hashed_secret = hashlib.sha256(secret).digest() + + stored_token = self.long_term_token_model( + secret_hash=hashed_secret.hex(), + created_at=datetime.now(UTC), + expires_at=expire_timestamp, + ) + setattr(stored_token, self.user_id_column, user_id) + + self.db_session.add(stored_token) + self.db_session.flush() + + token_id = stored_token.id.to_bytes(TOKEN_ID_BYTE_LENGTH, "little") + payload = token_id + secret + token = f"{project_prefix}{payload.hex().upper()}" + + return token + + def retrieve_and_validate_token(self, *, token: str, prefix: str) -> tp.Any: + """ + Retrieves and validates a long-term token from the database. + + Args: + token: The token string to validate. + prefix: The expected project-specific prefix for the token. + + Returns: + The validated Token object from the database. + + Raises: + ValueError: If the token is invalid, expired, or not found. + """ + if not token.startswith(prefix): + raise ValueError("Invalid token prefix") + + payload_hex = token[len(prefix):] + payload = bytes.fromhex(payload_hex) + token_id_bytes = payload[:TOKEN_ID_BYTE_LENGTH] + secret = payload[TOKEN_ID_BYTE_LENGTH:] + + token_id = int.from_bytes(token_id_bytes, "little") + + token_from_db = ( + self.db_session.query(self.long_term_token_model) + .filter(self.long_term_token_model.id == token_id) + .one_or_none() + ) + + if not token_from_db: + raise ValueError("Token not found") + + if not hmac.compare_digest(token_from_db.secret_hash, hashlib.sha256(secret).digest().hex()): + raise ValueError("Invalid token") + + if token_from_db.expires_at and token_from_db.expires_at < datetime.now(UTC): + raise ValueError("Token expired") + + return token_from_db + + def create_short_term_jwt(self, user_id: tp.Any) -> str: + """ + Generates a short-term JWT with a configurable expiration. + + Args: + user_id: The ID of the user for whom the JWT is generated. + Returns: + The encoded JWT as a string. + """ + if not user_id: + raise ValueError("User ID is required") + + payload = dataclasses.replace(self.jwt_claims) + payload.user_id = user_id + payload.iat = datetime.now(UTC) + payload.exp = payload.iat + timedelta(minutes=self.jwt_expiration_mins) + return jwt.encode( + payload.to_dict(), + self.registry.tet_auth_jwk_resolver(self.request), + algorithm=self.jwt_algorithm, + ) + + def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: + """ + Verifies and decodes a JWT, ensuring it is valid and not expired. + + Args: + token (str): The JWT to verify. + + Returns: + - The ``decoded payload`` if the JWT is valid + - ``None`` if the JWT is invalid or expired + """ + try: + payload = jwt.decode( + token, + self.registry.tet_auth_jwk_resolver(self.request), + algorithms=[self.jwt_algorithm], + leeway=self.jwt_claims.leeway, + audience=self.jwt_claims.aud, + subject=self.jwt_claims.sub, + issuer=self.jwt_claims.iss, + ) + return payload + except jwt.InvalidTokenError: + return None + + def _get_current_token(self) -> tp.Any: + return self.retrieve_and_validate_token( + token=self.request.cookies.get(self.long_term_token_cookie_name), + prefix=self.project_prefix, + ) + + def _delete_execution(self, condition: list) -> None: + stmt = delete(self.long_term_token_model).where(*condition) + self.db_session.execute(stmt) + self.db_session.flush() + + def delete_other_tokens(self, *, user: tp.Any = None) -> None: + current_token = self._get_current_token() + condition = [ + self.long_term_token_model.user_id == user.id, + self.long_term_token_model.id != current_token.id, + ] + self._delete_execution(condition) + + def cleanup_expired_tokens(self) -> int: + """Delete all expired long-term tokens. Returns the number deleted.""" + stmt = delete(self.long_term_token_model).where( + self.long_term_token_model.expires_at < datetime.now(UTC) + ) + result = self.db_session.execute(stmt) + self.db_session.flush() + return result.rowcount + + def delete_token(self, *, user: tp.Any = None) -> None: + current_token = self.retrieve_and_validate_token( + token=self.request.cookies.get(self.long_term_token_cookie_name), + prefix=self.project_prefix, + ) + condition = [ + self.long_term_token_model.user_id == user.id, + self.long_term_token_model.id == current_token.id, + ] + self._delete_execution(condition) diff --git a/src/tet/security/views.py b/src/tet/security/views.py new file mode 100644 index 0000000..6ad7bca --- /dev/null +++ b/src/tet/security/views.py @@ -0,0 +1,375 @@ +import logging +import typing as tp + +from pyramid.httpexceptions import ( + HTTPForbidden, + HTTPUnauthorized, + HTTPBadRequest, + HTTPException, + HTTPInternalServerError, + HTTPTooManyRequests, +) +from pyramid.request import Request +from pyramid.response import Response +from pyramid_di import autowired +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +import tet.security.events as security_events +from tet.security.config import ( + CookieAttributes, + PasswordChangeData, + AuthLoginResult, + MultiFactorAuthMethodType, + DEFAULT_UNAUTHORIZED_MESSAGE, +) +from tet.security.tokens import TetTokenService +from tet.security.auth import TetAuthService +from tet.security.mfa import TetMultiFactorAuthenticationService +from tet.security.rate_limit import TetRateLimitService + +logger = logging.getLogger(__name__) + + +class AuthViews: + token_service: TetTokenService = autowired(TetTokenService) + auth_service: TetAuthService = autowired(TetAuthService) + multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( + TetMultiFactorAuthenticationService + ) + rate_limit_service: TetRateLimitService = autowired(TetRateLimitService) + db_session: Session = autowired(Session) + + def __init__(self, request: Request): + self.request = request + self.registry = request.registry + self.response = request.response + self.project_prefix = self.registry.tet_auth_project_prefix + self.long_term_token_cookie_name = self.registry.tet_auth_long_term_token_cookie_name + self.long_term_token_expiration_mins = ( + self.registry.tet_auth_long_term_token_expiration_mins + ) + self.route_prefix = self.registry.tet_auth_route_prefix + self.login_callback = self.registry.tet_auth_login_callback + self.cookie_attributes: tp.Optional[CookieAttributes] = ( + self.registry.tet_auth_cookie_attributes + ) + + def _require_authenticated_userid(self) -> tp.Any: + user_id = self.request.authenticated_userid + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + return user_id + + def login(self) -> dict[str, tp.Any]: + client_addr = self.request.client_addr or "unknown" + rate_limit_key = f"login:{client_addr}" + max_attempts = getattr( + self.registry, "tet_auth_login_rate_limit_max_attempts", 10 + ) + window = getattr( + self.registry, "tet_auth_login_rate_limit_window_seconds", 300 + ) + if self.rate_limit_service.check_rate_limit(rate_limit_key, max_attempts, window): + raise HTTPTooManyRequests( + json_body={"message": "Too many login attempts. Please try again later."} + ) + + auth_result: AuthLoginResult = self.login_callback(self.request) + user_id = auth_result.user_id + user_identity = auth_result.user_identity + totp_token = auth_result.totp_token + response_payload: dict[str, tp.Any] = {"success": True} + + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): + if not totp_token: + response_payload[auth_result.mfa_required_key] = True + return response_payload + + return self.multi_factor_auth_service.handle_totp_challenge( + user_id=user_id, + totp_token=totp_token, + cookie_attributes=self.cookie_attributes, + ) + + refresh_token = self.token_service.create_long_term_token(user_id=user_id, project_prefix=self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) + + self.auth_service.set_cookies( + cookie_attributes=self.cookie_attributes, + refresh_token=refresh_token, + ) + response_payload["access_token"] = access_token + response_payload["refresh_token"] = refresh_token + + self.registry.notify( + security_events.AuthnLoginSuccess( + request=self.request, user_identity=user_identity + ) + ) + return response_payload + + except KeyError as e: + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) + ) + logger.exception(f"Missing required field during login: {str(e)}") + return HTTPBadRequest(json_body={"message": "Missing required field."}) + except HTTPException as e: + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) + ) + return e + except Exception as e: + logger.exception(f"Error during login: {str(e)}") + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) + ) + return HTTPInternalServerError(json_body={"message": "Login failed"}) + + def mfa_verify(self) -> dict: + """ + Verifies the TOTP code for the currently authenticated user. + + Raises: + HTTPUnauthorized: If no authenticated user ID is found in the request. + + Returns: + dict: Result of the TOTP verification for the user. + """ + user_id = self._require_authenticated_userid() + payload = self.request.json_body + token = payload["token"] + try: + return self.multi_factor_auth_service.handle_totp_verify( + user_id=user_id, token=token + ) + except HTTPException as e: + raise e + except Exception as e: + logger.exception(f"Error verifying MFA: {e}") + raise HTTPInternalServerError( + json_body={"message": "Failed to verify MFA"} + ) from e + + def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: + refresh_token = self.request.cookies.get(self.long_term_token_cookie_name) + if not refresh_token: + try: + refresh_token = self.request.json_body.get("refresh_token") + except Exception: + pass + if not refresh_token: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + access_token = self.auth_service.validate_and_create_jwt( + refresh_token=refresh_token + ) + return {"success": True, "access_token": access_token} + + def change_password(self): + user_id = None + try: + user_id = self._require_authenticated_userid() + data = self.request.json_body + payload = PasswordChangeData( + current_password=data["currentPassword"], + new_password=data["newPassword"], + ) + user = self.auth_service.get_current_user(user_id) + is_valid = self.auth_service.change_password(payload=payload, user=user) + self.token_service.delete_other_tokens(user=user) + self.registry.notify( + security_events.AuthnPasswordChange( + request=self.request, authenticated_userid=user_id + ) + ) + return {"success": is_valid} + except ValueError as e: + self.registry.notify( + security_events.AuthnPasswordChangeFail( + request=self.request, authenticated_userid=user_id + ) + ) + logger.error(f"Error while validating password change: {e}") + return HTTPForbidden( + json_body={"message": "Invalid password change request", "success": False} + ) + except HTTPException as e: + self.registry.notify( + security_events.AuthnPasswordChangeFail( + request=self.request, authenticated_userid=user_id + ) + ) + return e + except Exception as e: + logger.exception(f"Error changing password: {e}") + self.registry.notify( + security_events.AuthnPasswordChangeFail( + request=self.request, authenticated_userid=user_id + ) + ) + return HTTPForbidden( + json_body={"message": "Failed to change password", "success": False} + ) + + def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: + user_id = None + try: + user_id = self._require_authenticated_userid() + user = self.auth_service.get_current_user(user_id=user_id) + if not user: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + self.token_service.delete_token(user=user) + self.auth_service.delete_cookie(name=self.long_term_token_cookie_name) + self.registry.notify( + security_events.AuthnCurrentRefreshTokenRevoked( + request=self.request, authenticated_userid=user_id + ) + ) + self.registry.notify( + security_events.AuthnLogoutSuccess( + request=self.request, user_id=user_id + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnLogoutFail( + request=self.request, user_id=user_id + ) + ) + return e + except SQLAlchemyError as e: + logger.exception(f"Database error during logout: {e}") + self.registry.notify( + security_events.AuthnCurrentRefreshTokenRevokeFail( + request=self.request, authenticated_userid=user_id + ) + ) + return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) + except Exception as e: + logger.exception(f"Error logging out: {e}") + self.registry.notify( + security_events.AuthnLogoutFail( + request=self.request, user_id=user_id + ) + ) + return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) + + def disable_mfa_method(self): + user_id = None + mfa_method_type = None + try: + user_id = self._require_authenticated_userid() + payload = self.request.json_body + mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) + self.multi_factor_auth_service.disable_method( + user_id=user_id, method_type=mfa_method_type + ) + self.registry.notify( + security_events.AuthnMfaMethodDisabled( + request=self.request, + authenticated_userid=user_id, + method=mfa_method_type.value, + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnMfaMethodDisableFail( + request=self.request, + authenticated_userid=user_id, + method=mfa_method_type.value if mfa_method_type else None, + ) + ) + return e + except Exception as e: + logger.exception(f"Error disabling MFA method: {e}") + self.registry.notify( + security_events.AuthnMfaMethodDisableFail( + request=self.request, + authenticated_userid=user_id, + method=mfa_method_type.value if mfa_method_type else None, + ) + ) + return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) + + def revoke_other_tokens(self): + user_id = None + try: + user_id = self._require_authenticated_userid() + payload = self.request.json_body + user = self.auth_service.get_current_user(user_id=user_id) + if user is None: + raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) + + if not self.auth_service.verify_password( + user=user, password=payload.get("password", "") + ): + raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) + + self.token_service.delete_other_tokens(user=user) + self.registry.notify( + security_events.AuthnRefreshTokensRevoked( + request=self.request, + authenticated_userid=user_id, + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnRefreshTokenRevokeFail( + request=self.request, + authenticated_userid=user_id, + ) + ) + return e + except Exception as e: + logger.exception(f"Error revoking other tokens: {e}") + self.registry.notify( + security_events.AuthnRefreshTokenRevokeFail( + request=self.request, + authenticated_userid=user_id, + ) + ) + return HTTPForbidden( + json_body={"message": "Failed to revoke other tokens", "success": False} + ) + + def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: + user_id = self._require_authenticated_userid() + try: + mfa_methods: tp.List[tp.Any] = ( + self.multi_factor_auth_service.get_active_methods_by_user_id(user_id=user_id) + ) + return {"method_types": [mfa_method.method_type.value for mfa_method in mfa_methods]} + except HTTPException as e: + raise e + except Exception as e: + logger.exception(f"Error retrieving MFA methods: {e}") + raise HTTPInternalServerError( + json_body={"message": "Failed to retrieve MFA methods"} + ) from e + + def generate_mfa_totp(self): + user_id = self._require_authenticated_userid() + user = self.auth_service.get_current_user(user_id=user_id) + payload = self.request.json_body + try: + if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: + return self.multi_factor_auth_service.handle_totp_setup( + user=user, project_prefix=self.project_prefix + ) + raise HTTPBadRequest(json_body={"message": f"Unsupported MFA method type: {payload['method_type']}"}) + except HTTPException as e: + raise e + except Exception as e: + logger.exception(f"Error generating TOTP method: {e}") + raise HTTPInternalServerError( + json_body={"message": "Failed to generate TOTP method"} + ) from e diff --git a/src/tet/sqlalchemy/password.py b/src/tet/sqlalchemy/password.py index bcb3ffd..09523f4 100644 --- a/src/tet/sqlalchemy/password.py +++ b/src/tet/sqlalchemy/password.py @@ -33,7 +33,6 @@ class User(UserPasswordMixin, Base): import sqlalchemy as sa from sqlalchemy import orm as orm -from sqlalchemy.ext import declarative from ..util.crypt import crypt, verify @@ -67,7 +66,7 @@ def validate_password(self, password): return verify(password, self._password) - @declarative.declared_attr + @orm.declared_attr def password(cls): """Password property that hashes on set and returns hash on get.""" return orm.synonym( diff --git a/src/tet/util/crypt.py b/src/tet/util/crypt.py index 88ddebd..7e13d80 100644 --- a/src/tet/util/crypt.py +++ b/src/tet/util/crypt.py @@ -42,7 +42,7 @@ def crypt(password): else: password_8bit = password - rv = password_hash.encrypt(password_8bit) + rv = password_hash.hash(password_8bit) if not isinstance(rv, str): rv = rv.decode() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cce19f4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,26 @@ +Running all test suites + +We need dependencies for the tests +``` +pip install -e '.[dev]' +``` + +Create test database +```bash +sudo -u postgres createuser test_tet +sudo -u postgres createdb test_tet -O test_tet +``` + +Run all tests +```bash +pytest --verbose -rP -vv -s +``` +Ignore DeprecationWarning +```bash +pytest --verbose -rP -vv -s -W ignore::DeprecationWarning +``` + +Run it as a module if you have a problem with the path +```bash +python -m pytest ./tests --verbose -rP -vv -s +``` diff --git a/tests/conftest.py b/tests/conftest.py index 8eafe32..65f4f54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,55 +1,209 @@ -""" -Pytest configuration and fixtures for Tet framework tests. -""" - -from unittest.mock import Mock +import json +import logging +import os +import typing as tp import pytest -from pyramid import testing -from pyramid.config import Configurator +from pyramid.request import Request +from pyramid.response import Response +from tet.security.compat import Allow, Authenticated, Everyone, Deny +from pyramid.testing import setUp, tearDown +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from tests.models.accounts import ( + Base, + Token, + User, + MultiFactorAuthenticationMethod, + TOTPUsedCode, + RateLimitAttempt, +) +from tet.config import Configurator as tetConfigurator +from tet.security.authentication import ( + TokenAuthenticationPolicy, + AuthLoginResult, +) +from tet.view import view_config + +DB_NAME = "test_tet" +DB_URL = os.environ.get( + "TET_TEST_DB_URL", + f"postgresql+psycopg2://test_tet:test_tet@localhost:5432/{DB_NAME}", +) + +logger = logging.getLogger(__name__) + + +def create_test_database(): + # TODO: create the DB, but for now on we assume it must exists + pass + + +@pytest.fixture() +def database(): + create_test_database() + yield + # could drop the db here, but it's probably not necessary + + +@pytest.fixture() +def db_engine(database): + engine = create_engine(DB_URL) + Base.metadata.create_all(engine) + yield engine + with engine.begin() as conn: + conn.execute(RateLimitAttempt.__table__.delete()) + conn.execute(TOTPUsedCode.__table__.delete()) + engine.dispose() + + +@pytest.fixture() +def transaction_manager(pyramid_request): + return pyramid_request.tm + + +@pytest.fixture() +def db_session(db_engine, pyramid_request, transaction_manager): + with transaction_manager: + session = pyramid_request.find_service(Session) + yield session + + +def login_callback(request: Request) -> AuthLoginResult: + """This is just an example of a login callback. It should be defined by the pyramid app.""" + if not request.content_length: + return AuthLoginResult(user_id=None) + + db_session = request.find_service(Session) + + payload = request.json_body + user_identity = payload.get("user_identity") + totp_token = payload.get("totp_token") + if not user_identity: + return AuthLoginResult(user_id=None) -@pytest.fixture -def pyramid_config(): - """Create a Pyramid configurator for testing.""" - config = Configurator() - config.begin() + user: User = db_session.query(User).filter(User.email == user_identity).one_or_none() + if not user or not user.validate_password(payload.get("password", "")): + return AuthLoginResult( + user_id=None, + user_identity=user_identity, + ) + + return AuthLoginResult( + user_id=user.id, + user_identity=user_identity, + totp_token=totp_token, + ) + + +def jwk_resolver(request: Request) -> str: + """Get it from the settings or elsewhere""" + return request.registry.settings["tet.security.authentication.secret"] + + +@pytest.fixture() +def pyramid_request(pyramid_app, db_engine): + with pyramid_app.request_context({}) as request: + setUp(registry=request.registry, request=request) + yield request + tearDown() + + +class RootFactory(object): + __acl__ = [ + (Allow, Authenticated, "view"), + (Allow, "group:editors", "edit"), + (Allow, Everyone, "login"), + (Deny, Everyone, "delete"), + ] + + def __init__(self, request): + self.request = request + + +@pytest.fixture() +def pyramid_config(db_engine): + """Fixture to create and configure a Pyramid application.""" + settings = { + "sqlalchemy.url": DB_URL, + "project_prefix": "tet", + "pyramid.includes": ["pyramid_tm"], + "tet.security.authentication.secret": "test-jwt-secret-key-at-least-32-bytes", + } + with tetConfigurator() as config: + config.add_settings(settings) + config.include("tet.sqlalchemy.simple") + config.include("pyramid_tm") + config.include("pyramid_di") + config.setup_sqlalchemy(engine=db_engine) + config.set_root_factory(RootFactory) + config.include("tet.security.authentication", route_prefix="/api/v1/auth") yield config - config.end() - - -@pytest.fixture -def pyramid_request(): - """Create a dummy Pyramid request for testing.""" - request = testing.DummyRequest() - request.registry = Mock() - return request - - -@pytest.fixture -def pyramid_request_with_json(): - """Create a dummy Pyramid request with JSON body.""" - request = testing.DummyRequest() - request.json_body = {} - request.registry = Mock() - return request - - -@pytest.fixture -def mock_db_session(): - """Create a mock database session.""" - session = Mock() - session.query = Mock() - session.add = Mock() - session.commit = Mock() - session.rollback = Mock() - session.flush = Mock() - return session - - -@pytest.fixture -def mock_model(): - """Create a mock SQLAlchemy model.""" - model = Mock() - model.__tablename__ = "test_model" - return model + + +@view_config(route_name="home", renderer="json", permission="view") +def home_view(request: Request): + response: Response = request.response + response.text = json.dumps({"message": "Hello, World!"}) + response.content_type = "application/json" + return response + + +@pytest.fixture() +def pyramid_app(pyramid_config): + pyramid_config.set_token_authentication( + long_term_token_model=Token, + project_prefix=pyramid_config.registry.settings["project_prefix"], + login_callback=login_callback, + jwk_resolver=jwk_resolver, + security_policy=TokenAuthenticationPolicy(), + user_model=User, + multi_factor_auth_method_model=MultiFactorAuthenticationMethod, + totp_used_code_model=TOTPUsedCode, + rate_limit_model=RateLimitAttempt, + ) + pyramid_config.add_route("home", "/") + pyramid_config.add_view( + home_view, + route_name="home", + renderer="json", + permission="view", + ) + pyramid_config.scan("tests.services.security.subscribers.auth_subscribers") + app = pyramid_config.make_wsgi_app() + yield app + + +@pytest.fixture() +def pyramid_event_request(pyramid_event_app, db_engine): + with pyramid_event_app.request_context({}) as request: + setUp(registry=request.registry, request=request) + yield request + tearDown() + + +@pytest.fixture() +def pyramid_event_app(pyramid_config): + pyramid_config.set_token_authentication( + long_term_token_model=Token, + project_prefix=pyramid_config.registry.settings["project_prefix"], + login_callback=login_callback, + jwk_resolver=jwk_resolver, + security_policy=TokenAuthenticationPolicy(), + user_model=User, + multi_factor_auth_method_model=MultiFactorAuthenticationMethod, + totp_used_code_model=TOTPUsedCode, + rate_limit_model=RateLimitAttempt, + ) + pyramid_config.add_route("home", "/") + pyramid_config.add_view( + home_view, + route_name="home", + renderer="json", + permission="view", + ) + pyramid_config.scan("tests.services.security.subscribers.auth_subscribers") + app = pyramid_config.make_wsgi_app() + yield app diff --git a/tests/models/accounts.py b/tests/models/accounts.py new file mode 100644 index 0000000..583d6f6 --- /dev/null +++ b/tests/models/accounts.py @@ -0,0 +1,66 @@ +from tet.security.authentication import ( + TokenMixin, + MultiFactorAuthenticationMethodMixin, + TOTPUsedCodeMixin, + RateLimitAttemptMixin, +) +from tet.sqlalchemy.password import UserPasswordMixin + +from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey, UniqueConstraint +from sqlalchemy import orm +from sqlalchemy.orm import declarative_base +from sqlalchemy.schema import MetaData + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +metadata = MetaData(naming_convention=NAMING_CONVENTION) +Base = declarative_base(metadata=metadata) + + +class User(UserPasswordMixin, Base): + __tablename__ = "user" + id = Column(Integer, primary_key=True) + email = Column(Text, nullable=False, unique=True) + name = Column(Text, nullable=False, unique=True) + display_name = Column(Text, nullable=False, default="") + is_admin = Column(Boolean, nullable=False, default=False, server_default="false") + + +class Token(TokenMixin, Base): + __tablename__ = "token" + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + user = orm.relationship(User, backref="tokens") + + +class MultiFactorAuthenticationMethod(MultiFactorAuthenticationMethodMixin, Base): + __tablename__ = "multi_factor_authentication_method" + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + user = orm.relationship(User, backref="multi_factor_authentication_methods") + + # Unique constraint on (user_id, method_type) + __table_args__ = ( + UniqueConstraint("user_id", "method_type", name="unique_mfa_method_type_per_user"), + ) + + +class TOTPUsedCode(TOTPUsedCodeMixin, Base): + __tablename__ = "totp_used_code" + user_id = Column(Integer, ForeignKey(User.id), nullable=False) + __table_args__ = ( + UniqueConstraint("user_id", "time_step", name="uq_totp_used_code_user_time_step"), + {"prefixes": ["UNLOGGED"]}, + ) + + +class RateLimitAttempt(RateLimitAttemptMixin, Base): + __tablename__ = "rate_limit_attempt" + __table_args__ = {"prefixes": ["UNLOGGED"]} + + +__all__ = ["User", "Token", "Base", "metadata", "TOTPUsedCode", "RateLimitAttempt"] diff --git a/tests/services/constants.py b/tests/services/constants.py new file mode 100644 index 0000000..4cd0afb --- /dev/null +++ b/tests/services/constants.py @@ -0,0 +1,5 @@ +LOGIN_ENDPOINT = "/api/v1/auth/login" +ACCESS_TOKEN_HEADER_NAME = "Authorization" +LONG_TERM_TOKEN_COOKIE_NAME = "refresh-token" +ACCESS_TOKEN_COOKIE_NAME = "access-token" +HOME_ROUTE = "/" diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py new file mode 100644 index 0000000..7aba2f8 --- /dev/null +++ b/tests/services/security/conftest.py @@ -0,0 +1,37 @@ +import os + +import pytest +from sqlalchemy import create_engine, text + +TARGET_MODULE = "test_authentication.py" +DB_URL = os.environ.get( + "TET_TEST_DB_URL", + "postgresql+psycopg2://test_tet:test_tet@localhost:5432/test_tet", +) + + +def pytest_collection_modifyitems(config, items): + """ + Pre-filter: split items into those in test_authentication.py and others. + + More detail about this hook https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_collection + """ + auth_items = [item for item in items if TARGET_MODULE in str(item.fspath)] + other_items = [item for item in items if TARGET_MODULE not in str(item.fspath)] + + kept_items = other_items + auth_items + items[:] = kept_items + + +@pytest.fixture(autouse=True, scope="session") +def _cleanup_mfa_from_previous_runs(): + """Remove MFA methods left over from a previous test run.""" + engine = create_engine(DB_URL) + try: + with engine.connect() as conn: + conn.execute(text("DELETE FROM multi_factor_authentication_method")) + conn.commit() + except Exception: + pass # Table doesn't exist yet (fresh CI database) + finally: + engine.dispose() diff --git a/tests/services/security/subscribers/auth_subscribers.py b/tests/services/security/subscribers/auth_subscribers.py new file mode 100644 index 0000000..dd58dc4 --- /dev/null +++ b/tests/services/security/subscribers/auth_subscribers.py @@ -0,0 +1,46 @@ +import logging + +import structlog +from pyramid.events import subscriber + +from tet.security.events import AuthnLoginFail, AuthnLoginSuccess + +struct_logger = structlog.get_logger("audit") + + +def struct_log(event_name: str, description: str, level: int = None, **extra_fields) -> None: + try: + struct_logger.log( + level=level, + event=event_name, + description=description, + **extra_fields, + ) + except Exception as e: + struct_logger.exception( + event="audit_log_error", + description=f"Failed to log event: {e}", + ) + + +# Login events +@subscriber(AuthnLoginFail) +def handle_login_failed_event(event: AuthnLoginFail): + user_identity = event.user_identity + description = f"User {user_identity} failed to log in." + struct_log( + event_name=f"authn_login_fail:{user_identity}", + description=description, + level=logging.WARNING, + ) + + +@subscriber(AuthnLoginSuccess) +def handle_login_success_event(event: AuthnLoginSuccess): + user_identity = event.user_identity + description = f"User {user_identity} logged in successfully." + struct_log( + event_name=f"authn_login_success:{user_identity}", + description=description, + level=logging.INFO, + ) diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py new file mode 100644 index 0000000..ed8b79f --- /dev/null +++ b/tests/services/security/test_auth_events.py @@ -0,0 +1,258 @@ +import json +import logging as l +from unittest.mock import patch, MagicMock + +import pytest +import structlog +from pyramid import testing +from pyramid.events import subscriber +from pyramid.httpexceptions import HTTPUnauthorized +from webtest import TestApp + +from tests.models.accounts import User +from tests.services.constants import LOGIN_ENDPOINT +from tet.security.tokens import TetTokenService +from tet.security.views import AuthViews +from tet.security.config import AuthLoginResult +from tet.security.events import AuthnLoginSuccess, AuthnLoginFail + +logger = l.getLogger(__name__) + +DEFAULT_MESSAGE = "Event triggers the simulated audit log:" + + +@subscriber(AuthnLoginSuccess) +def login_success_event_handler(event: AuthnLoginSuccess): + """ + Handle the LoginSuccessEvent. + This is a placeholder for any additional logic you want to execute + when a user successfully logs in. + """ + logger.info(f"{DEFAULT_MESSAGE} {event.request.message}") + + +@subscriber(AuthnLoginFail) +def login_failed_event_handler(event: AuthnLoginFail): + """ + Handle the LoginFailEvent. + This is a placeholder for any additional logic you want to execute + when a user failed to log in. + """ + logger.warning(f"{DEFAULT_MESSAGE} {event.request.message}") + + +@pytest.fixture +def pyramid_request_with_event(request): + def _make(handler, event_class): + config = testing.setUp() + config.add_subscriber(handler, event_class) + req = testing.DummyRequest() + request.addfinalizer(testing.tearDown) + return req + + return _make + + +def test_login_success_event_with_fake_request(pyramid_request_with_event, caplog): + req = pyramid_request_with_event(login_success_event_handler, AuthnLoginSuccess) + user_identity = "example@gmail.invalid" + message = f"User {user_identity} Login successful" + req.message = message + with caplog.at_level("INFO", logger=__name__): + req.registry.notify(AuthnLoginSuccess(request=req, user_identity=user_identity)) + assert f"{DEFAULT_MESSAGE} {message}" in caplog.text + + +def test_login_failed_event_with_fake_request(pyramid_request_with_event, caplog): + req = pyramid_request_with_event(login_failed_event_handler, AuthnLoginFail) + user_identity = "user1@gmail.invalid" + message = f"User {user_identity} Login failed" + req.message = message + with caplog.at_level("WARNING", logger=__name__): + req.registry.notify(AuthnLoginFail(request=req, user_identity=user_identity)) + assert f"{DEFAULT_MESSAGE} {message}" in caplog.text + + +# More test with the actual views + + +@pytest.fixture() +def structlog_security_config(): + """ + Configure structlog for security-related logging. + + More info about structlog configuration: + https://www.structlog.org/en/stable/configuration.html + + For more detail on each processor: + https://www.structlog.org/en/stable/processors.html#module-structlog.processors + + For logger factory: + https://www.structlog.org/en/stable/api.html#structlog.stdlib.LoggerFactory + """ + structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.dev.ConsoleRenderer(), + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + yield + structlog.reset_defaults() + + +@pytest.fixture() +def pyramid_test_app(pyramid_app): + return TestApp(pyramid_app) + + +@pytest.fixture() +def token_service(pyramid_request): + return pyramid_request.find_service(TetTokenService) + + +@pytest.fixture +def capture_token(monkeypatch, token_service, db_session): + captured_data = {} + + create_long_term_token = TetTokenService.create_long_term_token + create_short_term_jwt = TetTokenService.create_short_term_jwt + + def create_long_term_token_wrapper(*args, **kwargs): + token = create_long_term_token(*args, **kwargs) + captured_data["refresh_token"] = token + return token + + def create_short_term_jwt_wrapper(*args, **kwargs): + token = create_short_term_jwt(*args, **kwargs) + captured_data["access_token"] = token + return token + + monkeypatch.setattr(TetTokenService, "create_long_term_token", create_long_term_token_wrapper) + monkeypatch.setattr(TetTokenService, "create_short_term_jwt", create_short_term_jwt_wrapper) + return captured_data + + +DEFAULT_USER_IDENTITY = "exampple2@invalid.invalid" +DEFAULT_USER_PASSWORD = "1234@abcd" + + +@pytest.fixture(autouse=True) +def ensure_test_user(db_session): + """Ensure the test user exists before any test in this module.""" + user = db_session.query(User).filter(User.email == DEFAULT_USER_IDENTITY).one_or_none() + if not user: + user = User(email=DEFAULT_USER_IDENTITY, name="example2", is_admin=True) + user.password = DEFAULT_USER_PASSWORD + db_session.add(user) + db_session.flush() + return user + + +def test_login_view_emits_success_event( + pyramid_test_app, + capture_token, + pyramid_request, + caplog, + structlog_security_config, +): + app = pyramid_test_app + data = json.dumps({"user_identity": DEFAULT_USER_IDENTITY, "password": DEFAULT_USER_PASSWORD}) + expected_description = f"User {DEFAULT_USER_IDENTITY} logged in successfully." + + with caplog.at_level("INFO", logger="audit"): + app.post( + LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + + matched = [ + r + for r in caplog.records + if r.levelname == "INFO" and r.name == "audit" and expected_description in r.getMessage() + ] + assert matched, f"No INFO log with description '{expected_description}' found in 'audit' logger" + + +def test_login_view_emits_fail_event( + pyramid_test_app, pyramid_request, caplog, structlog_security_config +): + app = pyramid_test_app + data = json.dumps({"user_identity": DEFAULT_USER_IDENTITY, "password": "wrong_password"}) + + with caplog.at_level("WARNING", logger="audit"): + app.post( + LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=401, + ) + expected_description = f"User {DEFAULT_USER_IDENTITY} failed to log in." + matched = [ + r + for r in caplog.records + if r.levelname == "WARNING" and r.name == "audit" and expected_description in r.getMessage() + ] + assert matched, ( + f"No WARNING log with description '{expected_description}' found in 'audit' logger" + ) + + +def _setup_event_request(request): + """Set up registry attributes needed by AuthViews on a DummyRequest.""" + request.registry.tet_auth_route_prefix = "/auth" + + +def test_login_notify_success(pyramid_event_request): + request = pyramid_event_request + _setup_event_request(request) + request.registry.tet_auth_login_callback = lambda req: AuthLoginResult( + user_id=1, user_identity=DEFAULT_USER_IDENTITY, success=True + ) + + view = AuthViews(request) + view.token_service.create_long_term_token = MagicMock(return_value="refresh") + view.token_service.create_short_term_jwt = MagicMock(return_value="access") + view.multi_factor_auth_service.is_totp_mfa_enabled = MagicMock(return_value=False) + view.auth_service.set_cookies = MagicMock() + + with patch.object(request.registry, "notify") as mock_notify: + view.login() + expected_event = AuthnLoginSuccess( + user_identity=DEFAULT_USER_IDENTITY, + request=request, + ) + mock_notify.assert_called_once_with(expected_event) + + +def test_login_notify_fail(pyramid_event_request): + request = pyramid_event_request + _setup_event_request(request) + request.registry.tet_auth_login_callback = lambda req: AuthLoginResult( + user_id=None, user_identity=DEFAULT_USER_IDENTITY + ) + + view = AuthViews(request) + view.token_service.create_long_term_token = MagicMock(return_value="refresh") + view.token_service.create_short_term_jwt = MagicMock(return_value="access") + view.multi_factor_auth_service.is_totp_mfa_enabled = MagicMock(return_value=False) + view.auth_service.set_cookies = MagicMock() + + with patch.object(request.registry, "notify") as mock_notify: + result = view.login() + assert isinstance(result, HTTPUnauthorized) + expected_event = AuthnLoginFail( + user_identity=DEFAULT_USER_IDENTITY, + request=request, + ) + mock_notify.assert_called_once_with(expected_event) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py new file mode 100644 index 0000000..0c77609 --- /dev/null +++ b/tests/services/security/test_authentication.py @@ -0,0 +1,1881 @@ +import base64 +import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch, MagicMock + +import jwt as pyjwt +import pyotp +import pytest +import requests as req_lib +from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPInternalServerError, HTTPUnauthorized +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from webtest import TestApp + +from tests.models.accounts import MultiFactorAuthenticationMethod, User +from tests.services.constants import LOGIN_ENDPOINT, ACCESS_TOKEN_HEADER_NAME, HOME_ROUTE +from tests.services.utils.authentication import get_cookie +from tet.security.auth import TetAuthService +from tet.security.config import MultiFactorAuthMethodType, PasswordChangeData, TOTPData +from tet.security.mfa import TetMultiFactorAuthenticationService +from tet.security.tokens import TetTokenService + + +@pytest.fixture() +def pyramid_test_app(pyramid_app): + return TestApp(pyramid_app) + + +@pytest.fixture() +def authentication_tokens(pyramid_test_app, capture_token, pyramid_request): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + data = response.json + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + refresh_token = get_cookie(pyramid_test_app.cookiejar, refresh_token_cookie_name) + access_token = data["access_token"] + return refresh_token, access_token + + +def create_user(db_session: Session): + user = db_session.query(User).filter(User.email == "exampple2@invalid.invalid").one_or_none() + if user: + # Always reset password to known state + user.password = "1234@abcd" + db_session.flush() + return user + + user = User(email="exampple2@invalid.invalid", name="example2", display_name="example2", is_admin=True) + user.password = "1234@abcd" + db_session.add(user) + db_session.flush() + return user + + +@pytest.fixture() +def token_service(pyramid_request): + return pyramid_request.find_service(TetTokenService) + + +def test_create_user(db_session, pyramid_test_app): + default_user = create_user(db_session) + user = db_session.query(User).filter(User.id == default_user.id).first() + assert user is not None + + +@pytest.fixture +def capture_token(monkeypatch, token_service, db_session): + captured_data = {} + + create_long_term_token = TetTokenService.create_long_term_token + create_short_term_jwt = TetTokenService.create_short_term_jwt + + def create_long_term_token_wrapper(*args, **kwargs): + token = create_long_term_token(*args, **kwargs) + captured_data["refresh_token"] = token + return token + + def create_short_term_jwt_wrapper(*args, **kwargs): + token = create_short_term_jwt(*args, **kwargs) + captured_data["access_token"] = token + return token + + monkeypatch.setattr(TetTokenService, "create_long_term_token", create_long_term_token_wrapper) + monkeypatch.setattr(TetTokenService, "create_short_term_jwt", create_short_term_jwt_wrapper) + return captured_data + + +def test_login_view_should_return_long_term_token(pyramid_test_app, capture_token, pyramid_request): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + url=LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + # Validate the token captured by monkeypatch + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + refresh_token = get_cookie(pyramid_test_app.cookiejar, refresh_token_cookie_name) + assert capture_token["refresh_token"] == refresh_token + + assert isinstance(refresh_token, str) + assert len(refresh_token) > 0 + + +def test_login_should_return_refresh_token_in_body(pyramid_test_app, capture_token, pyramid_request): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + url=LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + response_data = response.json + assert "refresh_token" in response_data + assert response_data["refresh_token"] == capture_token["refresh_token"] + + +def test_auth_should_return_access_token(pyramid_test_app, capture_token, pyramid_request): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + response_data = response.json + assert "success" in response_data + assert "access_token" in response_data + assert response_data["access_token"] == capture_token["access_token"] + + +def test_access_token_should_work_to_access_protected_route( + authentication_tokens, pyramid_test_app +): + refresh_token, access_token = authentication_tokens + headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + response = pyramid_test_app.get(HOME_ROUTE, headers=headers, status=200) + + assert response.status_code == 200 + assert "message" in response.json + assert response.json["message"] == "Hello, World!" + + +def test_login_view_should_raise_401_when_identity_not_found_in_the_db( + pyramid_test_app, pyramid_request +): + response = pyramid_test_app.post( + url=LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_it_should_store_the_token_in_the_database( + capture_token, pyramid_test_app, pyramid_request +): + project_prefix = pyramid_request.registry.settings["project_prefix"] + tet_token_service = TetTokenService(request=pyramid_request) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + url=LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + data = response.json + assert response.status_code == 200 + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + refresh_token = get_cookie(pyramid_test_app.cookiejar, refresh_token_cookie_name) + # Validate the token captured by monkeypatch + assert capture_token["refresh_token"] == refresh_token + assert capture_token["access_token"] == data["access_token"] + assert isinstance(refresh_token, str) + assert len(refresh_token) > 0 + + token = tet_token_service.retrieve_and_validate_token(token=refresh_token, prefix=project_prefix) + assert token is not None + + +def test_it_should_fail_to_access_the_protected_route_without_the_access_token( + pyramid_test_app, +): + response = pyramid_test_app.get(HOME_ROUTE, status=403, expect_errors=True) + assert response.status_code == 403 + + +def test_it_should_fail_to_access_the_protected_route_with_invalid_access_token( + pyramid_test_app, +): + headers = { + ACCESS_TOKEN_HEADER_NAME: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" + ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" + } + response = pyramid_test_app.get( + HOME_ROUTE, + headers=headers, + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_verify_jwt_returns_none_for_expired_token(token_service, pyramid_request): + """Expired JWT should return None, not raise.""" + secret = pyramid_request.registry.settings["tet.security.authentication.secret"] + expired_payload = { + "user_id": 1, + "exp": datetime.now(timezone.utc) - timedelta(hours=1), + "iat": datetime.now(timezone.utc) - timedelta(hours=2), + } + expired_token = pyjwt.encode(expired_payload, secret, algorithm="HS256") + result = token_service.verify_jwt(expired_token) + assert result is None + + +def test_verify_jwt_returns_none_for_invalid_signature(token_service): + """JWT signed with wrong key should return None, not raise.""" + wrong_payload = { + "user_id": 1, + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + "iat": datetime.now(timezone.utc), + } + bad_token = pyjwt.encode(wrong_payload, "wrong-secret-key-at-least-32-bytes!", algorithm="HS256") + result = token_service.verify_jwt(bad_token) + assert result is None + + +def test_verify_jwt_returns_none_for_malformed_token(token_service): + """Completely malformed token should return None, not raise.""" + result = token_service.verify_jwt("not.a.valid.jwt.token") + assert result is None + + +def test_refresh_token_endpoint_returns_401_when_no_token(pyramid_test_app): + """Missing refresh token should return 401.""" + response = pyramid_test_app.post( + "/api/v1/auth/token/refresh", + params=json.dumps({}), + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_refresh_token_from_request_body(pyramid_test_app, capture_token, pyramid_request): + """Refresh token should be accepted from request body as well as cookie.""" + # First login to get a refresh token + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + refresh_token = login_response.json["refresh_token"] + + # Clear cookies so it must come from request body + pyramid_test_app.cookiejar.clear() + + response = pyramid_test_app.post( + "/api/v1/auth/token/refresh", + params=json.dumps({"refresh_token": refresh_token}), + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + assert "access_token" in response.json + assert response.json["success"] is True + + +def test_breach_api_timeout_graceful_degradation(pyramid_request): + """Breach API timeout should return False, not crash.""" + auth_service = TetAuthService(request=pyramid_request) + pyramid_request.registry.settings["pwned_passwords_api_url"] = "https://api.pwnedpasswords.com/range/" + + with patch("tet.security.auth.requests.get", side_effect=req_lib.ConnectionError("timeout")): + result = auth_service.is_password_breached("test_password_123") + assert result is False + + +# --- Token validation edge cases --- + + +def test_retrieve_token_with_invalid_prefix(token_service, pyramid_request): + """Token with wrong prefix should raise ValueError.""" + with pytest.raises(ValueError, match="Invalid token prefix"): + token_service.retrieve_and_validate_token(token="WRONG_PREFIX_ABC123", prefix="tet") + + +def test_retrieve_token_not_found_in_db(token_service, capture_token, pyramid_request, db_session): + """Token ID not in DB should raise ValueError.""" + # Create a valid-looking token with a non-existent ID + import secrets, hashlib + from tet.security.config import TOKEN_ID_BYTE_LENGTH + + prefix = pyramid_request.registry.settings["project_prefix"] + fake_id = (999999).to_bytes(TOKEN_ID_BYTE_LENGTH, "little") + fake_secret = secrets.token_bytes(32) + payload = fake_id + fake_secret + fake_token = f"{prefix}{payload.hex().upper()}" + + with pytest.raises(ValueError, match="Token not found"): + token_service.retrieve_and_validate_token(token=fake_token, prefix=prefix) + + +def test_retrieve_token_with_wrong_secret(token_service, capture_token, pyramid_request, db_session): + """Token with wrong secret should raise ValueError.""" + import secrets + from tet.security.config import TOKEN_ID_BYTE_LENGTH + + prefix = pyramid_request.registry.settings["project_prefix"] + user = create_user(db_session) + + # Create a real token to get a valid token ID + real_token = token_service.create_long_term_token(user_id=user.id, project_prefix=prefix) + payload_hex = real_token[len(prefix):] + payload_bytes = bytes.fromhex(payload_hex) + token_id_bytes = payload_bytes[:TOKEN_ID_BYTE_LENGTH] + + # Replace the secret with garbage + wrong_secret = secrets.token_bytes(32) + tampered_payload = token_id_bytes + wrong_secret + tampered_token = f"{prefix}{tampered_payload.hex().upper()}" + + with pytest.raises(ValueError, match="Invalid token"): + token_service.retrieve_and_validate_token(token=tampered_token, prefix=prefix) + + +def test_create_short_term_jwt_requires_user_id(token_service): + """create_short_term_jwt should raise ValueError when user_id is falsy.""" + with pytest.raises(ValueError, match="User ID is required"): + token_service.create_short_term_jwt(None) + + +# --- Auth service tests --- + + +@pytest.fixture() +def auth_service(pyramid_request): + return pyramid_request.find_service(TetAuthService) + + +def test_validate_and_create_jwt(auth_service, capture_token, pyramid_test_app, pyramid_request, db_session): + """validate_and_create_jwt should return a valid JWT from a refresh token.""" + create_user(db_session) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_response = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + refresh_token = login_response.json["refresh_token"] + access_token = auth_service.validate_and_create_jwt(refresh_token=refresh_token) + assert access_token is not None + assert isinstance(access_token, str) + + +def test_validate_and_create_jwt_invalid_token_raises_401(auth_service): + """Invalid refresh token should raise HTTPUnauthorized.""" + with pytest.raises(HTTPUnauthorized): + auth_service.validate_and_create_jwt(refresh_token="tet_INVALID_TOKEN_DATA") + + +def test_verify_password(auth_service, db_session): + """verify_password should delegate to user model.""" + user = create_user(db_session) + # The test User model uses validate_password (from UserPasswordMixin), + # but TetAuthService.verify_password calls user.verify_password. + # Downstream apps must provide verify_password on their user model. + user.verify_password = user.validate_password + assert auth_service.verify_password(user=user, password="1234@abcd") is True + assert auth_service.verify_password(user=user, password="wrong") is False + + +def test_get_current_user(auth_service, db_session): + """get_current_user should return the user or None.""" + user = create_user(db_session) + found = auth_service.get_current_user(user.id) + assert found is not None + assert found.id == user.id + + not_found = auth_service.get_current_user(999999) + assert not_found is None + + +def test_assess_password_strength(): + """Password strength scoring.""" + assert TetAuthService.assess_password_strength("") == 0 + assert TetAuthService.assess_password_strength("short") == 1 + assert TetAuthService.assess_password_strength("a_long_enough_pw") == 5 + + +def test_is_password_breached_returns_true_when_found(pyramid_request): + """Should return True when password hash suffix is found in API response.""" + auth_service = TetAuthService(request=pyramid_request) + pyramid_request.registry.settings["pwned_passwords_api_url"] = "https://api.pwnedpasswords.com/range/" + + # SHA1 of "password" starts with 5BAA6 -> suffix is 1E4C9B93F3F0682250B6CF8331B7EE68FD8 + import hashlib + sha1 = hashlib.sha1(b"password").hexdigest().upper() + suffix = sha1[5:] + + mock_response = MagicMock() + mock_response.text = f"{suffix}:12345\nOTHERHASH:1" + mock_response.raise_for_status = MagicMock() + + with patch("tet.security.auth.requests.get", return_value=mock_response): + assert auth_service.is_password_breached("password") is True + + +def test_is_password_breached_returns_false_when_not_found(pyramid_request): + """Should return False when password hash suffix is not in API response.""" + auth_service = TetAuthService(request=pyramid_request) + pyramid_request.registry.settings["pwned_passwords_api_url"] = "https://api.pwnedpasswords.com/range/" + + mock_response = MagicMock() + mock_response.text = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0:1\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB0:2" + mock_response.raise_for_status = MagicMock() + + with patch("tet.security.auth.requests.get", return_value=mock_response): + assert auth_service.is_password_breached("test_password_unique_42") is False + + +def test_change_password_success(auth_service, db_session): + """Successful password change.""" + user = create_user(db_session) + user.verify_password = user.validate_password + payload = PasswordChangeData(current_password="1234@abcd", new_password="new_secure_password_123") + + with patch.object(auth_service, "is_password_breached", return_value=False): + result = auth_service.change_password(payload=payload, user=user) + assert result is True + + +def test_change_password_wrong_current_password(auth_service, db_session): + """Wrong current password should raise ValueError.""" + user = create_user(db_session) + user.verify_password = user.validate_password + payload = PasswordChangeData(current_password="wrong_password", new_password="new_secure_password_123") + + with patch.object(auth_service, "is_password_breached", return_value=False): + with pytest.raises(ValueError, match="INVALID_CREDENTIALS"): + auth_service.change_password(payload=payload, user=user) + + +def test_change_password_too_short(auth_service, db_session): + """Password shorter than MIN_PASSWORD_LENGTH should fail validation.""" + user = create_user(db_session) + user.verify_password = user.validate_password + payload = PasswordChangeData(current_password="1234@abcd", new_password="short") + + with patch.object(auth_service, "is_password_breached", return_value=False): + with pytest.raises(ValueError, match="PASSWORD_STRENGTH_TOO_WEAK|INCORRECT_PASSWORD_LENGTH"): + auth_service.change_password(payload=payload, user=user) + + +def test_change_password_breached(auth_service, db_session): + """Breached password should raise ValueError.""" + user = create_user(db_session) + payload = PasswordChangeData(current_password="1234@abcd", new_password="new_secure_password_123") + + with patch.object(auth_service, "is_password_breached", return_value=True): + with pytest.raises(ValueError, match="PASSWORD_LEAKED"): + auth_service.change_password(payload=payload, user=user) + + +# --- View endpoint integration tests --- + + +def _set_refresh_cookie(test_app, refresh_token, cookie_name="refresh-token"): + """Manually set the refresh token cookie to work around webtest domain matching.""" + test_app.set_cookie(cookie_name, refresh_token) + + +def test_refresh_token_from_cookie(pyramid_test_app, capture_token, pyramid_request): + """Refresh token in cookie should work for token refresh.""" + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + refresh_token = login_resp.json["refresh_token"] + + # Webtest has domain matching issues with localhost, so set cookie manually + pyramid_test_app.cookiejar.clear() + _set_refresh_cookie(pyramid_test_app, refresh_token) + + response = pyramid_test_app.post( + "/api/v1/auth/token/refresh", + params=json.dumps({}), + content_type="application/json", + status=200, + ) + assert response.json["success"] is True + assert "access_token" in response.json + + +def test_login_mfa_required_returns_mfa_flag(pyramid_test_app, capture_token, pyramid_request, db_session): + """When MFA is enabled and no TOTP token provided, should return mfa_required.""" + create_user(db_session) + from tet.security.mfa import TetMultiFactorAuthenticationService + + with patch.object(TetMultiFactorAuthenticationService, "is_totp_mfa_enabled", return_value=True): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + assert response.json["success"] is True + assert response.json.get("mfa_required") is True + assert "access_token" not in response.json + + +def test_logout_endpoint(pyramid_test_app, capture_token, pyramid_request, db_session): + """Logout should succeed for an authenticated user.""" + create_user(db_session) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + + # Set cookie manually for webtest domain compatibility + _set_refresh_cookie(pyramid_test_app, refresh_token) + + response = pyramid_test_app.post( + "/api/v1/auth/logout", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=200, + ) + assert response.json["success"] is True + + +def test_change_password_endpoint(pyramid_test_app, capture_token, pyramid_request, db_session): + """Change password via HTTP endpoint.""" + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + # Mock change_password to avoid actual DB mutation (unit-tested separately above) + with patch.object(TetAuthService, "change_password", return_value=True), \ + patch.object(TetTokenService, "delete_other_tokens"): + response = pyramid_test_app.post( + "/api/v1/auth/users/me/password", + params=json.dumps({ + "currentPassword": "1234@abcd", + "newPassword": "new_secure_password_123", + }), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=200, + ) + assert response.json["success"] is True + + +def test_change_password_endpoint_wrong_current(pyramid_test_app, capture_token, pyramid_request, db_session): + """Change password with wrong current password should return 403.""" + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetAuthService, "change_password", side_effect=ValueError("INVALID_CREDENTIALS")): + response = pyramid_test_app.post( + "/api/v1/auth/users/me/password", + params=json.dumps({ + "currentPassword": "wrong_password", + "newPassword": "new_secure_password_123", + }), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +# --- MFA / TOTP service tests --- + + +@pytest.fixture() +def mfa_service(pyramid_request): + return pyramid_request.find_service(TetMultiFactorAuthenticationService) + + +def _cleanup_mfa_methods(db_session, user_id): + """Remove all MFA methods for a user to avoid unique constraint violations.""" + db_session.query(MultiFactorAuthenticationMethod).filter_by(user_id=user_id).delete() + db_session.flush() + + +@pytest.fixture() +def clean_mfa(db_engine): + """Clean up all MFA methods via committed transaction (visible to webtest).""" + from sqlalchemy import text + with db_engine.connect() as conn: + conn.execute(text("DELETE FROM multi_factor_authentication_method")) + conn.commit() + yield + with db_engine.connect() as conn: + conn.execute(text("DELETE FROM multi_factor_authentication_method")) + conn.commit() + + +def test_verify_totp_valid_token(): + """verify_totp should return True for a currently valid token.""" + secret = pyotp.random_base32() + token = pyotp.TOTP(secret).now() + assert TetMultiFactorAuthenticationService.verify_totp(secret=secret, token=token) is True + + +def test_verify_totp_invalid_token(): + """verify_totp should return False for an invalid token.""" + secret = pyotp.random_base32() + assert TetMultiFactorAuthenticationService.verify_totp(secret=secret, token="000000") is False + + +def test_create_mfa_method(mfa_service, db_session): + """create_method should persist a new MFA method.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": "test_secret"}, + ) + assert method is not None + assert method.method_type == MultiFactorAuthMethodType.TOTP + assert method.user_id == user.id + assert method.data == {"secret": "test_secret"} + assert method.is_active is False + assert method.verified is False + + +def test_get_method_unverified(mfa_service, db_session): + """get_method should find an unverified method.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": "test_secret"}, + ) + found = mfa_service.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=False, + verified=False, + ) + assert found is not None + assert found.user_id == user.id + + +def test_get_method_returns_none_when_not_found(mfa_service, db_session): + """get_method should return None when no matching method exists.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + result = mfa_service.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + assert result is None + + +def test_is_totp_mfa_enabled_false(mfa_service, db_session): + """is_totp_mfa_enabled should return False when no active TOTP exists.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + assert mfa_service.is_totp_mfa_enabled(user_id=user.id) is False + + +def test_is_totp_mfa_enabled_true(mfa_service, db_session): + """is_totp_mfa_enabled should return True when active verified TOTP exists.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": pyotp.random_base32()}, + ) + method.is_active = True + method.verified = True + db_session.flush() + + assert mfa_service.is_totp_mfa_enabled(user_id=user.id) is True + + +def test_get_active_methods_by_user_id(mfa_service, db_session): + """get_active_methods_by_user_id should return only active+verified methods.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": pyotp.random_base32()}, + ) + + # Inactive method should not be returned + methods = mfa_service.get_active_methods_by_user_id(user_id=user.id) + assert len(methods) == 0 + + # Activate it + method.is_active = True + method.verified = True + db_session.flush() + + methods = mfa_service.get_active_methods_by_user_id(user_id=user.id) + assert len(methods) == 1 + assert methods[0].method_type == MultiFactorAuthMethodType.TOTP + + +def test_disable_method(mfa_service, db_session): + """disable_method should deactivate and unverify the method.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": "test_secret"}, + ) + method.is_active = True + method.verified = True + db_session.flush() + + mfa_service.disable_method(user_id=user.id, method_type=MultiFactorAuthMethodType.TOTP) + db_session.flush() + + active = mfa_service.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + assert active is None + + +def test_handle_totp_setup(mfa_service, db_session): + """handle_totp_setup should create a TOTP method and return secret + QR code.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + result = mfa_service.handle_totp_setup(user=user, project_prefix="tet") + assert "secret" in result + assert "qr_code" in result + assert result["qr_code"].startswith("data:image/svg+xml;base64,") + assert len(result["secret"]) > 0 + + +def test_handle_totp_verify_success(mfa_service, db_session): + """handle_totp_verify should activate the method on valid TOTP token.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + setup_result = mfa_service.handle_totp_setup(user=user, project_prefix="tet") + secret = setup_result["secret"] + + # Generate a valid TOTP token for the current time + valid_token = pyotp.TOTP(secret).now() + + result = mfa_service.handle_totp_verify( + user_id=user.id, token=valid_token, + ) + assert result["success"] is True + + # Method should now be active and verified + method = mfa_service.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + assert method is not None + + +def test_handle_totp_verify_invalid_token(mfa_service, db_session): + """handle_totp_verify should raise HTTPForbidden on invalid TOTP token.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + setup_result = mfa_service.handle_totp_setup(user=user, project_prefix="tet") + secret = setup_result["secret"] + + with pytest.raises(HTTPForbidden): + mfa_service.handle_totp_verify( + user_id=user.id, token="000000", + ) + + +def test_handle_totp_verify_no_method(mfa_service, db_session): + """handle_totp_verify should raise HTTPForbidden when no unverified method exists.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + with pytest.raises(HTTPForbidden): + mfa_service.handle_totp_verify( + user_id=user.id, token="123456", + ) + + +def test_handle_totp_verify_missing_secret_in_db(mfa_service, db_session): + """handle_totp_verify should raise HTTPBadRequest when DB record has no secret.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={}, + ) + + with pytest.raises(HTTPBadRequest): + mfa_service.handle_totp_verify( + user_id=user.id, token="123456", + ) + + +def test_handle_totp_challenge_success(mfa_service, db_session, pyramid_request): + """handle_totp_challenge should verify TOTP and return access + refresh tokens.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + secret = pyotp.random_base32() + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": secret}, + ) + method.is_active = True + method.verified = True + db_session.flush() + + # Set up request body for the event notification + pyramid_request.body = json.dumps( + {"user_identity": "exampple2@invalid.invalid"} + ).encode("utf-8") + pyramid_request.content_type = "application/json" + + valid_token = pyotp.TOTP(secret).now() + result = mfa_service.handle_totp_challenge( + user_id=user.id, totp_token=valid_token, + ) + assert result["success"] is True + assert "access_token" in result + assert "refresh_token" in result + + +def test_handle_totp_challenge_invalid_token(mfa_service, db_session): + """handle_totp_challenge should raise HTTPForbidden on invalid TOTP.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + secret = pyotp.random_base32() + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={"secret": secret}, + ) + method.is_active = True + method.verified = True + db_session.flush() + + with pytest.raises(HTTPForbidden): + mfa_service.handle_totp_challenge( + user_id=user.id, totp_token="000000", + ) + + +def test_handle_totp_challenge_no_method(mfa_service, db_session): + """handle_totp_challenge should raise HTTPForbidden when no active method exists.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + with pytest.raises(HTTPForbidden): + mfa_service.handle_totp_challenge( + user_id=user.id, totp_token="123456", + ) + + +def test_handle_totp_challenge_missing_secret_in_data(mfa_service, db_session): + """handle_totp_challenge should raise HTTPBadRequest when method data has no secret.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + method = mfa_service.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data={}, # No secret + ) + method.is_active = True + method.verified = True + db_session.flush() + + with pytest.raises(HTTPBadRequest): + mfa_service.handle_totp_challenge( + user_id=user.id, totp_token="123456", + ) + + +def test_generate_qr_img(): + """generate_qr_img should return a base64-encoded SVG.""" + class FakeUser: + display_name = "Test User" + + secret = pyotp.random_base32() + data = TOTPData(secret=secret, issuer="test") + + result = TetMultiFactorAuthenticationService.generate_qr_img( + user=FakeUser(), mfa_secret=secret, data=data, + ) + decoded = base64.b64decode(result) + assert b"svg" in decoded.lower() + + +# --- MFA view integration tests --- + + +def test_mfa_setup_and_verify_flow(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Full TOTP setup + verify flow through HTTP endpoints.""" + # Login to get access token + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + auth_headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + + # Setup TOTP + setup_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + secret = setup_resp.json["secret"] + assert "qr_code" in setup_resp.json + assert setup_resp.json["qr_code"].startswith("data:image/svg+xml;base64,") + + # Generate valid TOTP token and verify + valid_token = pyotp.TOTP(secret).now() + verify_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/verify", + params=json.dumps({"token": valid_token}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + assert verify_resp.json["success"] is True + + +def test_mfa_get_methods_returns_active(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """GET /mfa/methods should list active MFA methods after setup+verify.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + auth_headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + + # Setup + verify TOTP + setup_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + secret = setup_resp.json["secret"] + valid_token = pyotp.TOTP(secret).now() + pyramid_test_app.post( + "/api/v1/auth/mfa/app/verify", + params=json.dumps({"token": valid_token}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + + # Check methods + methods_resp = pyramid_test_app.get( + "/api/v1/auth/mfa/methods", + headers=auth_headers, + status=200, + ) + assert "method_types" in methods_resp.json + assert "totp" in methods_resp.json["method_types"] + + +def test_mfa_disable_method(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """POST /mfa/app/disable should deactivate a TOTP method.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + auth_headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + + # Setup + verify TOTP + setup_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + secret = setup_resp.json["secret"] + valid_token = pyotp.TOTP(secret).now() + pyramid_test_app.post( + "/api/v1/auth/mfa/app/verify", + params=json.dumps({"token": valid_token}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + + # Disable TOTP + disable_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/disable", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + assert disable_resp.json["success"] is True + + # Verify it's gone + methods_resp = pyramid_test_app.get( + "/api/v1/auth/mfa/methods", + headers=auth_headers, + status=200, + ) + assert "totp" not in methods_resp.json.get("method_types", []) + + +def test_login_with_totp_challenge(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Login with TOTP token should succeed when MFA is enabled.""" + # Login without MFA first + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + auth_headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + + # Setup + verify TOTP + setup_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + secret = setup_resp.json["secret"] + valid_token = pyotp.TOTP(secret).now() + pyramid_test_app.post( + "/api/v1/auth/mfa/app/verify", + params=json.dumps({"token": valid_token}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + + # Now login with TOTP - generate a fresh token + totp_token = pyotp.TOTP(secret).now() + mfa_login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({ + "user_identity": "exampple2@invalid.invalid", + "password": "1234@abcd", + "totp_token": totp_token, + }), + content_type="application/json", + status=200, + ) + assert mfa_login_resp.json["success"] is True + assert "access_token" in mfa_login_resp.json + assert "refresh_token" in mfa_login_resp.json + + +def test_login_without_totp_returns_mfa_required(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Login without TOTP token should return mfa_required when MFA is enabled.""" + # Login and setup TOTP + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + auth_headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} + + setup_resp = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + secret = setup_resp.json["secret"] + valid_token = pyotp.TOTP(secret).now() + pyramid_test_app.post( + "/api/v1/auth/mfa/app/verify", + params=json.dumps({"token": valid_token}), + headers=auth_headers, + content_type="application/json", + status=200, + ) + + # Login WITHOUT totp_token -> should get mfa_required + mfa_login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({ + "user_identity": "exampple2@invalid.invalid", + "password": "1234@abcd", + }), + content_type="application/json", + status=200, + ) + assert mfa_login_resp.json["success"] is True + assert mfa_login_resp.json.get("mfa_required") is True + assert "access_token" not in mfa_login_resp.json + + +# --- MFA service error path tests --- + + +def test_handle_totp_verify_key_error(mfa_service, db_session): + """KeyError in handle_totp_verify should raise HTTPBadRequest.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + mfa_service.handle_totp_setup(user=user, project_prefix="tet") + + with patch.object( + TetMultiFactorAuthenticationService, "verify_totp", side_effect=KeyError("missing") + ): + with pytest.raises(HTTPBadRequest): + mfa_service.handle_totp_verify( + user_id=user.id, token="123456", + ) + + +def test_handle_totp_verify_generic_exception(mfa_service, db_session): + """Generic exception in handle_totp_verify should raise HTTPInternalServerError.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + mfa_service.handle_totp_setup(user=user, project_prefix="tet") + + with patch.object( + TetMultiFactorAuthenticationService, "verify_totp", side_effect=RuntimeError("unexpected") + ): + with pytest.raises(HTTPInternalServerError): + mfa_service.handle_totp_verify( + user_id=user.id, token="123456", + ) + + +def test_handle_totp_setup_generic_exception(mfa_service, db_session): + """Generic exception in handle_totp_setup should return error dict.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + + with patch.object( + TetMultiFactorAuthenticationService, "_create_totp_data", side_effect=RuntimeError("fail") + ): + result = mfa_service.handle_totp_setup(user=user, project_prefix="tet") + assert result["success"] is False + + +# --- View error path tests --- + + +def test_refresh_token_with_no_body(pyramid_test_app): + """refresh_token with empty body and no cookie should hit except path and return 401.""" + pyramid_test_app.cookiejar.clear() + response = pyramid_test_app.post( + "/api/v1/auth/token/refresh", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_login_key_error_returns_400(pyramid_test_app, capture_token, pyramid_request): + """KeyError inside login try block should return 400.""" + with patch.object(TetTokenService, "create_long_term_token", side_effect=KeyError("field")): + response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=400, + expect_errors=True, + ) + assert response.status_code == 400 + + +def test_login_generic_exception_returns_500(pyramid_test_app, capture_token, pyramid_request): + """Generic exception inside login try block should return 500.""" + with patch.object(TetTokenService, "create_long_term_token", side_effect=RuntimeError("unexpected")): + response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=500, + expect_errors=True, + ) + assert response.status_code == 500 + + +def test_logout_unauthenticated_returns_401(pyramid_test_app): + """Logout without authentication should return 401 (user not found).""" + pyramid_test_app.cookiejar.clear() + response = pyramid_test_app.post( + "/api/v1/auth/logout", + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_logout_db_error_returns_403(pyramid_test_app, capture_token, pyramid_request): + """SQLAlchemy error during logout should return 403.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetTokenService, "delete_token", side_effect=SQLAlchemyError("db error")): + response = pyramid_test_app.post( + "/api/v1/auth/logout", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_logout_generic_exception_returns_403(pyramid_test_app, capture_token, pyramid_request): + """Generic exception during logout should return 403.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetTokenService, "delete_token", side_effect=RuntimeError("unexpected")): + response = pyramid_test_app.post( + "/api/v1/auth/logout", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_change_password_http_exception(pyramid_test_app, capture_token, pyramid_request): + """HTTPException during change_password should be re-raised.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetAuthService, "change_password", side_effect=HTTPUnauthorized()): + response = pyramid_test_app.post( + "/api/v1/auth/users/me/password", + params=json.dumps({"currentPassword": "x", "newPassword": "new_pw_123456"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_change_password_generic_exception(pyramid_test_app, capture_token, pyramid_request): + """Generic exception during change_password should return 403.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetAuthService, "change_password", side_effect=RuntimeError("unexpected")): + response = pyramid_test_app.post( + "/api/v1/auth/users/me/password", + params=json.dumps({"currentPassword": "x", "newPassword": "new_pw_123456"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_revoke_other_tokens_success(pyramid_test_app, capture_token, pyramid_request): + """Revoking other tokens with correct password should succeed.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object(TetAuthService, "verify_password", return_value=True), \ + patch.object(TetTokenService, "delete_other_tokens"): + response = pyramid_test_app.delete( + "/api/v1/auth/users/me/tokens/others", + params=json.dumps({"password": "1234@abcd"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=200, + ) + assert response.json["success"] is True + + +def test_revoke_other_tokens_wrong_password(pyramid_test_app, capture_token, pyramid_request): + """Revoking other tokens with wrong password should return 401.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object(TetAuthService, "verify_password", return_value=False): + response = pyramid_test_app.delete( + "/api/v1/auth/users/me/tokens/others", + params=json.dumps({"password": "wrong"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_revoke_other_tokens_generic_exception(pyramid_test_app, capture_token, pyramid_request): + """Generic exception during revoke should return 403.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object(TetAuthService, "verify_password", return_value=True), \ + patch.object(TetTokenService, "delete_other_tokens", side_effect=RuntimeError("fail")): + response = pyramid_test_app.delete( + "/api/v1/auth/users/me/tokens/others", + params=json.dumps({"password": "1234@abcd"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_disable_mfa_generic_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Generic exception during disable_mfa should return 403.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, "disable_method", side_effect=RuntimeError("fail") + ): + response = pyramid_test_app.post( + "/api/v1/auth/mfa/app/disable", + params=json.dumps({"method_type": "totp"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_get_mfa_methods_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Exception in get_mfa_methods should return 500.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, + "get_active_methods_by_user_id", + side_effect=RuntimeError("fail"), + ): + response = pyramid_test_app.get( + "/api/v1/auth/mfa/methods", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + status=500, + expect_errors=True, + ) + assert response.status_code == 500 + + +def test_generate_mfa_totp_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """Exception in generate_mfa_totp should return 500.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, "handle_totp_setup", side_effect=RuntimeError("fail") + ): + response = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=500, + expect_errors=True, + ) + assert response.status_code == 500 + + +# --- views.py: additional coverage --- + + +def test_logout_user_not_found_returns_401(pyramid_test_app, capture_token, pyramid_request): + """Logout with valid token but user not found in DB should return 401.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + refresh_token = login_resp.json["refresh_token"] + _set_refresh_cookie(pyramid_test_app, refresh_token) + + with patch.object(TetAuthService, "get_current_user", return_value=None): + response = pyramid_test_app.post( + "/api/v1/auth/logout", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_disable_mfa_http_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """HTTPException in disable_mfa should fire audit event and return error.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, "disable_method", side_effect=HTTPForbidden() + ): + response = pyramid_test_app.post( + "/api/v1/auth/mfa/app/disable", + params=json.dumps({"method_type": "totp"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_revoke_other_tokens_user_not_found(pyramid_test_app, capture_token, pyramid_request): + """Revoke tokens when user not found in DB should return 401.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object(TetAuthService, "get_current_user", return_value=None): + response = pyramid_test_app.delete( + "/api/v1/auth/users/me/tokens/others", + params=json.dumps({"password": "1234@abcd"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=401, + expect_errors=True, + ) + assert response.status_code == 401 + + +def test_get_mfa_methods_http_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """HTTPException in get_mfa_methods should be re-raised.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, + "get_active_methods_by_user_id", + side_effect=HTTPForbidden(), + ): + response = pyramid_test_app.get( + "/api/v1/auth/mfa/methods", + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_generate_mfa_totp_non_totp_method(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """generate_mfa_totp with non-TOTP method_type should return 400.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + response = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "hotp"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=400, + expect_errors=True, + ) + assert response.status_code == 400 + + +def test_generate_mfa_totp_http_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): + """HTTPException in generate_mfa_totp should be re-raised.""" + login_resp = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + access_token = login_resp.json["access_token"] + + with patch.object( + TetMultiFactorAuthenticationService, "handle_totp_setup", side_effect=HTTPForbidden() + ): + response = pyramid_test_app.post( + "/api/v1/auth/mfa/app/setup", + params=json.dumps({"method_type": "totp"}), + headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +# --- tokens.py: remaining uncovered lines --- + + +def test_retrieve_and_validate_token_expired(token_service, db_session): + """Expired token should raise ValueError.""" + expired_time = datetime.now(timezone.utc) - timedelta(hours=1) + token = token_service.create_long_term_token( + user_id=1, + project_prefix="tet", + expire_timestamp=expired_time, + ) + with pytest.raises(ValueError, match="Token expired"): + token_service.retrieve_and_validate_token(token=token, prefix="tet") + + +def test_delete_other_tokens(token_service, db_session, pyramid_request): + """delete_other_tokens should delete all tokens except the current one.""" + user = create_user(db_session) + # Create two tokens for the same user + token1 = token_service.create_long_term_token(user_id=user.id, project_prefix="tet") + token2 = token_service.create_long_term_token(user_id=user.id, project_prefix="tet") + + # Set the cookie to the second token (current session) + pyramid_request.cookies["refresh-token"] = token2 + + token_service.delete_other_tokens(user=user) + + # token1 should be gone (deleted) + with pytest.raises(ValueError, match="Token not found"): + token_service.retrieve_and_validate_token(token=token1, prefix="tet") + + # token2 should still be valid (current session token) + result = token_service.retrieve_and_validate_token(token=token2, prefix="tet") + assert result is not None + + +# --- auth.py: cookie_attributes with no max_age --- + + +def test_set_cookies_with_cookie_attributes_no_max_age(auth_service, pyramid_request): + """set_cookies should set default max_age when cookie_attributes has no max_age.""" + from tet.security.config import CookieAttributes + + attrs = CookieAttributes(name="refresh-token") + assert attrs.max_age is None + + auth_service.set_cookies(cookie_attributes=attrs, refresh_token="test-token") + + # Original attrs should be unchanged (dataclasses.replace creates a copy) + assert attrs.max_age is None + assert attrs.value is None + + # The cookie should have been set on the response with the correct max_age + expected_max_age = auth_service.long_term_token_expiration_mins * 60 + response_headers = pyramid_request.response.headerlist + set_cookie_headers = [v for k, v in response_headers if k == "Set-Cookie"] + assert any("test-token" in h for h in set_cookie_headers) + assert any(f"Max-Age={expected_max_age}" in h for h in set_cookie_headers) + + +# --- config.py: AuthLoginResult.__bool__ --- + + +def test_auth_login_result_bool(): + """AuthLoginResult.__bool__ should return success value.""" + from tet.security.config import AuthLoginResult + + success_result = AuthLoginResult(user_id=1, success=True) + assert bool(success_result) is True + + fail_result = AuthLoginResult(user_id=None, success=False) + assert bool(fail_result) is False + + +# --- policy.py: forget() --- + + +def test_policy_forget_returns_empty_list(pyramid_request): + """TokenAuthenticationPolicy.forget should return an empty list.""" + from tet.security.policy import TokenAuthenticationPolicy + + policy = TokenAuthenticationPolicy() + result = policy.forget(pyramid_request) + assert result == [] + + +# --- TOTP replay protection --- + + +def test_totp_replay_protection_blocks_reuse(mfa_service, db_session, pyramid_request): + """Using the same TOTP code twice should raise HTTPForbidden on the second attempt.""" + user = create_user(db_session) + _cleanup_mfa_methods(db_session, user.id) + user.display_name = "Test User" + db_session.flush() + + mfa_service.handle_totp_setup(user=user, project_prefix="tet") + totp_mfa_method = mfa_service.get_method( + user_id=user.id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=False, + verified=False, + ) + secret = totp_mfa_method.data["secret"] + token = pyotp.TOTP(secret).now() + + # Verify and activate + mfa_service.handle_totp_verify(user_id=user.id, token=token) + + # Set json_body on the request (needed by event notification in handle_totp_challenge) + pyramid_request.json_body = {"user_identity": user.email} + + # Login with TOTP — first attempt succeeds + from tet.security.config import CookieAttributes + + valid_token = pyotp.TOTP(secret).now() + result = mfa_service.handle_totp_challenge( + user_id=user.id, + totp_token=valid_token, + cookie_attributes=CookieAttributes(name="refresh-token"), + ) + assert result["success"] is True + + # Same code again — should be blocked (replay or verification failure) + with pytest.raises(HTTPForbidden): + mfa_service.handle_totp_challenge( + user_id=user.id, + totp_token=valid_token, + cookie_attributes=CookieAttributes(name="refresh-token"), + ) + + +def test_totp_replay_check_and_record(mfa_service, db_session): + """_check_totp_replay + _record_totp_use should block the same time step.""" + user = create_user(db_session) + time_step = 99999999 + + # First use — should not raise + mfa_service._check_totp_replay(user.id, time_step) + mfa_service._record_totp_use(user.id, time_step) + db_session.flush() + + # Second use — same time step — should raise + with pytest.raises(HTTPForbidden): + mfa_service._check_totp_replay(user.id, time_step) + + +def test_totp_replay_cleanup(mfa_service, db_session): + """cleanup_used_codes should delete old entries.""" + from tests.models.accounts import TOTPUsedCode + + user = create_user(db_session) + used = TOTPUsedCode() + used.user_id = user.id + used.time_step = 1 + used.used_at = datetime.now(timezone.utc) - timedelta(seconds=300) + db_session.add(used) + db_session.flush() + + count = mfa_service.cleanup_used_codes(older_than_seconds=120) + assert count == 1 + + +# --- Rate limiting --- + + +def test_rate_limit_blocks_excessive_login(pyramid_test_app, capture_token, pyramid_request): + """Login should return 429 after exceeding rate limit.""" + # Set a very low rate limit for testing + pyramid_request.registry.tet_auth_login_rate_limit_max_attempts = 2 + pyramid_request.registry.tet_auth_login_rate_limit_window_seconds = 60 + + for _ in range(2): + pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=200, + ) + + response = pyramid_test_app.post( + LOGIN_ENDPOINT, + params=json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}), + content_type="application/json", + status=429, + expect_errors=True, + ) + assert response.status_code == 429 + + +def test_rate_limit_service_disabled(pyramid_request): + """Rate limit service with no model should always return False.""" + from tet.security.rate_limit import TetRateLimitService + + service = TetRateLimitService(request=pyramid_request) + original = service.rate_limit_model + service.rate_limit_model = None + assert service.check_rate_limit("test", 1, 60) is False + assert service.cleanup() == 0 + service.rate_limit_model = original + + +def test_rate_limit_cleanup(pyramid_request, db_session): + """cleanup should delete old rate limit entries.""" + from tet.security.rate_limit import TetRateLimitService + from tests.models.accounts import RateLimitAttempt + + service = TetRateLimitService(request=pyramid_request) + + engine = db_session.get_bind() + table = RateLimitAttempt.__table__ + old_time = datetime.now(timezone.utc) - timedelta(hours=2) + with engine.begin() as conn: + conn.execute(table.insert().values(key="old_key", attempted_at=old_time)) + + deleted = service.cleanup(older_than_seconds=3600) + assert deleted >= 1 + + +# --- Token cleanup --- + + +def test_cleanup_expired_tokens(token_service, db_session): + """cleanup_expired_tokens should delete expired tokens and leave valid ones.""" + user = create_user(db_session) + expired_time = datetime.now(timezone.utc) - timedelta(hours=1) + valid_time = datetime.now(timezone.utc) + timedelta(hours=12) + + token_service.create_long_term_token( + user_id=user.id, project_prefix="tet", expire_timestamp=expired_time + ) + valid_token = token_service.create_long_term_token( + user_id=user.id, project_prefix="tet", expire_timestamp=valid_time + ) + + deleted = token_service.cleanup_expired_tokens() + assert deleted >= 1 + + # Valid token should still work + result = token_service.retrieve_and_validate_token(token=valid_token, prefix="tet") + assert result is not None diff --git a/tests/services/security/test_authorization.py b/tests/services/security/test_authorization.py new file mode 100644 index 0000000..1cf5f37 --- /dev/null +++ b/tests/services/security/test_authorization.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock, patch, call + +from pyramid.config import Configurator +from zope.interface import implementer + +from tet.security.authorization import ( + AuthorizationPolicyWrapper, + INewAuthorizationPolicy, + includeme, +) + + +def test_authorization_policy_wrapper_permits(): + """AuthorizationPolicyWrapper.permits should pass request from threadlocal.""" + mock_policy = MagicMock() + mock_policy.permits.return_value = True + wrapper = AuthorizationPolicyWrapper(mock_policy) + + mock_request = MagicMock() + context = MagicMock() + principals = ["system.Everyone", "user:1"] + permission = "view" + + with patch("tet.security.authorization.get_current_request", return_value=mock_request): + result = wrapper.permits(context, principals, permission) + + assert result is True + mock_policy.permits.assert_called_once_with(mock_request, context, principals, permission) + + +def test_authorization_policy_wrapper_principals_allowed_by_permission(): + """AuthorizationPolicyWrapper should delegate principals_allowed_by_permission.""" + mock_policy = MagicMock() + mock_policy.principals_allowed_by_permission.return_value = {"user:1", "group:admin"} + wrapper = AuthorizationPolicyWrapper(mock_policy) + + mock_request = MagicMock() + context = MagicMock() + permission = "edit" + + with patch("tet.security.authorization.get_current_request", return_value=mock_request): + result = wrapper.principals_allowed_by_permission(context, permission) + + assert result == {"user:1", "group:admin"} + mock_policy.principals_allowed_by_permission.assert_called_once_with( + mock_request, context, permission + ) + + +def test_includeme_adds_directive(): + """includeme should add set_authorization_policy directive to config.""" + config = MagicMock(spec=Configurator) + includeme(config) + config.add_directive.assert_called_once() + directive_name = config.add_directive.call_args[0][0] + assert directive_name == "set_authorization_policy" + # Second arg should be callable + directive_fn = config.add_directive.call_args[0][1] + assert callable(directive_fn) + + +def test_set_authorization_policy_directive_with_standard_policy(): + """The registered directive should pass through a standard policy.""" + config = MagicMock(spec=Configurator) + includeme(config) + + directive_fn = config.add_directive.call_args[0][1] + + mock_policy = MagicMock() + config.maybe_dotted.return_value = mock_policy + + with patch( + "tet.security.authorization.SecurityConfiguratorMixin.set_authorization_policy" + ) as mock_set: + directive_fn(config, mock_policy) + + config.maybe_dotted.assert_called_once_with(mock_policy) + mock_set.assert_called_once_with(config, mock_policy) + + +def test_set_authorization_policy_directive_wraps_new_policy(): + """The directive should wrap INewAuthorizationPolicy implementations.""" + + @implementer(INewAuthorizationPolicy) + class NewPolicy: + def permits(self, request, context, principals, permission): + return True + + def principals_allowed_by_permission(self, request, context, permission): + return set() + + config = MagicMock(spec=Configurator) + includeme(config) + + directive_fn = config.add_directive.call_args[0][1] + + new_policy = NewPolicy() + config.maybe_dotted.return_value = new_policy + + with patch( + "tet.security.authorization.SecurityConfiguratorMixin.set_authorization_policy" + ) as mock_set: + directive_fn(config, new_policy) + + actual_policy = mock_set.call_args[0][1] + assert isinstance(actual_policy, AuthorizationPolicyWrapper) + assert actual_policy.wrapped is new_policy diff --git a/tests/services/security/test_csrf.py b/tests/services/security/test_csrf.py new file mode 100644 index 0000000..a3e9604 --- /dev/null +++ b/tests/services/security/test_csrf.py @@ -0,0 +1,12 @@ +from unittest.mock import MagicMock + +from pyramid.config import Configurator + +from tet.security.csrf import includeme + + +def test_includeme_sets_csrf_options(): + """includeme should call set_default_csrf_options(require_csrf=True).""" + config = MagicMock(spec=Configurator) + includeme(config) + config.set_default_csrf_options.assert_called_once_with(require_csrf=True) diff --git a/tests/services/utils/authentication.py b/tests/services/utils/authentication.py new file mode 100644 index 0000000..1480a5e --- /dev/null +++ b/tests/services/utils/authentication.py @@ -0,0 +1,3 @@ +def get_cookie(cookiejar, name): + matching_cookies = [cookie for cookie in cookiejar if cookie.name == name] + return matching_cookies[0].value if matching_cookies else None diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..fe00aec --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,256 @@ +""" +Public API contract tests. + +Every symbol listed here is part of the published API. If an import +fails or a type check breaks, a downstream consumer's code would break +too — fix the library, not this test. +""" + +import dataclasses +import enum +import types + +import pytest +from pyramid.request import Request + + +# ── tet.services ──────────────────────────────────────────────────── + +def test_tet_services_exports(): + from tet.services import ( + ApplicationScopedBaseService, + BaseService, + RequestScopedBaseService, + autowired, + includeme, + service, + ) + + assert callable(service) + assert callable(autowired) + assert callable(includeme) + assert isinstance(RequestScopedBaseService, type) + assert isinstance(ApplicationScopedBaseService, type) + assert isinstance(BaseService, type) + + +# ── tet.security (package) ────────────────────────────────────────── + +def test_tet_security_exports(): + from tet.security import ( + Allowed, + AuthLoginResult, + AuthViews, + CookieAttributes, + Denied, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + MultiFactorAuthenticationMethodMixin, + NO_PERMISSION_REQUIRED, + PasswordChangeData, + RateLimitAttemptMixin, + TetAuthService, + TetMultiFactorAuthenticationService, + TetRateLimitService, + TetTokenService, + TOTPData, + TOTPUsedCodeMixin, + TokenAuthenticationPolicy, + TokenMixin, + ) + + assert dataclasses.is_dataclass(AuthLoginResult) + assert dataclasses.is_dataclass(CookieAttributes) + assert dataclasses.is_dataclass(JWTRegisteredClaims) + assert dataclasses.is_dataclass(PasswordChangeData) + assert dataclasses.is_dataclass(TOTPData) + assert issubclass(MultiFactorAuthMethodType, enum.Enum) + assert isinstance(NO_PERMISSION_REQUIRED, str) + + +# ── tet.security.authentication ───────────────────────────────────── + +def test_tet_security_authentication_exports(): + from tet.security.authentication import ( + AuthLoginResult, + AuthViews, + CookieAttributes, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + MultiFactorAuthenticationMethodMixin, + NO_PERMISSION_REQUIRED, + PasswordChangeData, + RateLimitAttemptMixin, + TetAuthService, + TetMultiFactorAuthenticationService, + TetRateLimitService, + TetTokenService, + TOTPData, + TOTPUsedCodeMixin, + TokenAuthenticationPolicy, + TokenMixin, + includeme, + set_token_authentication, + ) + + assert callable(includeme) + assert callable(set_token_authentication) + + +# ── tet.security.authorization ─────────────────────────────────────── + +def test_tet_security_authorization_exports(): + from tet.security.authorization import ( + ACLHelper, + Allow, + Allowed, + Authenticated, + AuthorizationPolicyWrapper, + Denied, + Deny, + Everyone, + INewAuthorizationPolicy, + NO_PERMISSION_REQUIRED, + includeme, + ) + + assert callable(includeme) + + +# ── tet.security.compat ────────────────────────────────────────────── + +def test_tet_security_compat_exports(): + from tet.security.compat import ( + ACLHelper, + ALL_PERMISSIONS, + Allow, + Allowed, + Authenticated, + DENY_ALL, + Denied, + Deny, + Everyone, + NO_PERMISSION_REQUIRED, + ) + + assert isinstance(NO_PERMISSION_REQUIRED, str) + + +# ── tet.security.events ───────────────────────────────────────────── + +def test_tet_security_events_exports(): + from tet.security.events import ( + AuthnCurrentRefreshTokenRevokeFail, + AuthnCurrentRefreshTokenRevoked, + AuthnInputValidationFail, + AuthnLoginFail, + AuthnLoginSuccess, + AuthnLogoutFail, + AuthnLogoutSuccess, + AuthnMfaMethodCreated, + AuthnMfaMethodDisableFail, + AuthnMfaMethodDisabled, + AuthnPasswordChange, + AuthnPasswordChangeFail, + AuthnRefreshTokenRevokeFail, + AuthnRefreshTokensRevoked, + AuthzFail, + TetAuthEvent, + ) + + assert dataclasses.is_dataclass(TetAuthEvent) + for cls in ( + AuthnLoginSuccess, + AuthnLoginFail, + AuthnLogoutSuccess, + AuthnLogoutFail, + AuthnPasswordChange, + AuthnPasswordChangeFail, + AuthnMfaMethodCreated, + AuthnMfaMethodDisabled, + AuthnMfaMethodDisableFail, + AuthnRefreshTokensRevoked, + AuthnRefreshTokenRevokeFail, + AuthnCurrentRefreshTokenRevoked, + AuthnCurrentRefreshTokenRevokeFail, + AuthzFail, + AuthnInputValidationFail, + ): + assert dataclasses.is_dataclass(cls), f"{cls.__name__} is not a dataclass" + + +# ── tet.security.models ───────────────────────────────────────────── + +def test_tet_security_models_exports(): + from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + RateLimitAttemptMixin, + TOTPUsedCodeMixin, + TokenMixin, + ) + + for mixin in ( + MultiFactorAuthenticationMethodMixin, + RateLimitAttemptMixin, + TOTPUsedCodeMixin, + TokenMixin, + ): + assert hasattr(mixin, "__tablename__"), f"{mixin.__name__} missing __tablename__" + + +# ── tet.security.config (dataclasses & enums) ─────────────────────── + +def test_tet_security_config_exports(): + from tet.security.config import ( + AuthLoginResult, + CookieAttributes, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + PasswordChangeData, + TOTPData, + ) + + claims = JWTRegisteredClaims() + assert isinstance(claims.to_dict(), dict) + + cookie = CookieAttributes() + assert cookie.secure is True + assert cookie.httponly is True + + totp = TOTPData(secret="JBSWY3DPEHPK3PXP", issuer="test") + assert isinstance(totp.to_dict(), dict) + + result = AuthLoginResult(user_id=None) + assert not bool(result) + + assert MultiFactorAuthMethodType.TOTP.value == "totp" + + +# ── tet.security.policy ───────────────────────────────────────────── + +def test_tet_security_policy_exports(): + from tet.security.policy import TokenAuthenticationPolicy + + policy = TokenAuthenticationPolicy() + assert callable(getattr(policy, "authenticated_userid", None)) + assert callable(getattr(policy, "permits", None)) + assert callable(getattr(policy, "effective_principals", None)) + assert callable(getattr(policy, "forget", None)) + + +# ── Service classes ────────────────────────────────────────────────── + +def test_service_classes_importable(): + from tet.security.tokens import TetTokenService + from tet.security.auth import TetAuthService + from tet.security.mfa import TetMultiFactorAuthenticationService + from tet.security.rate_limit import TetRateLimitService + + for svc in (TetTokenService, TetAuthService, TetMultiFactorAuthenticationService, TetRateLimitService): + assert isinstance(svc, type), f"{svc} is not a class" diff --git a/tests/test_security_authorization.py b/tests/test_security_authorization.py index b8e8966..568cd19 100644 --- a/tests/test_security_authorization.py +++ b/tests/test_security_authorization.py @@ -50,9 +50,7 @@ def test_permits_adds_request(self, mock_get_request): result = wrapper.permits(context, principals, permission) assert result is True - policy.permits.assert_called_once_with( - mock_request, context, principals, permission - ) + policy.permits.assert_called_once_with(mock_request, context, principals, permission) mock_get_request.assert_called_once() @patch("tet.security.authorization.get_current_request") @@ -62,9 +60,7 @@ def test_principals_allowed_by_permission_adds_request(self, mock_get_request): mock_get_request.return_value = mock_request policy = MockNewAuthorizationPolicy() - policy.principals_allowed_by_permission = Mock( - return_value={"user:1", "group:admin"} - ) + policy.principals_allowed_by_permission = Mock(return_value={"user:1", "group:admin"}) wrapper = AuthorizationPolicyWrapper(policy) @@ -130,9 +126,7 @@ def test_set_authorization_policy_with_new_policy(self, pyramid_config): # Check if the policy implements the interface assert INewAuthorizationPolicy.providedBy(new_policy) - with patch.object( - SecurityConfiguratorMixin, "set_authorization_policy" - ) as mock_set: + with patch.object(SecurityConfiguratorMixin, "set_authorization_policy") as mock_set: # Call the directive set_auth_policy(pyramid_config, new_policy) @@ -145,19 +139,10 @@ def test_set_authorization_policy_with_new_policy(self, pyramid_config): assert call_args[0] is pyramid_config wrapped_policy = call_args[1] - # The check in the actual code is isinstance(policy, INewAuthorizationPolicy) - # but INewAuthorizationPolicy is an Interface, not a class - # So this will always be False for isinstance - # This appears to be a bug in the original code - # For now, test what actually happens - assert ( - wrapped_policy is new_policy - ) # No wrapping because isinstance check fails + assert isinstance(wrapped_policy, AuthorizationPolicyWrapper) @patch("tet.security.authorization.SecurityConfiguratorMixin") - def test_set_authorization_policy_with_old_policy( - self, mock_security_mixin, pyramid_config - ): + def test_set_authorization_policy_with_old_policy(self, mock_security_mixin, pyramid_config): """Test setting authorization policy with old-style policy.""" pyramid_config.add_directive = Mock() pyramid_config.maybe_dotted = Mock(side_effect=lambda x: x) @@ -194,9 +179,7 @@ def test_set_authorization_policy_with_dotted_name(self, pyramid_config): from pyramid.config.security import SecurityConfiguratorMixin - with patch.object( - SecurityConfiguratorMixin, "set_authorization_policy" - ) as mock_set: + with patch.object(SecurityConfiguratorMixin, "set_authorization_policy") as mock_set: # Call with a dotted name set_auth_policy(pyramid_config, "my.module.Policy") @@ -206,10 +189,7 @@ def test_set_authorization_policy_with_dotted_name(self, pyramid_config): # Check what was passed to set_authorization_policy call_args = mock_set.call_args[0] wrapped_policy = call_args[1] - # Same issue - isinstance check with Interface doesn't work as expected - assert ( - wrapped_policy is resolved_policy - ) # No wrapping because isinstance check fails + assert isinstance(wrapped_policy, AuthorizationPolicyWrapper) class TestINewAuthorizationPolicy: @@ -227,8 +207,6 @@ def test_interface_signatures(self): # The interface should have these methods defined as part of the interface # In Zope interfaces, methods are defined in the interface namespace permits_spec = INewAuthorizationPolicy.get("permits") - principals_spec = INewAuthorizationPolicy.get( - "principals_allowed_by_permission" - ) + principals_spec = INewAuthorizationPolicy.get("principals_allowed_by_permission") assert permits_spec is not None assert principals_spec is not None