From 26fea7f92d90883dd7387a9ab8d64b61e2632714 Mon Sep 17 00:00:00 2001 From: Pete Sevander Date: Thu, 9 Jan 2025 14:02:53 +0000 Subject: [PATCH 001/139] Initial implementation --- rebase-helper.sh | 119 ++++++++++++ src/tet/security/authentication.py | 285 +++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100755 rebase-helper.sh create mode 100644 src/tet/security/authentication.py diff --git a/rebase-helper.sh b/rebase-helper.sh new file mode 100755 index 0000000..141894d --- /dev/null +++ b/rebase-helper.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Auto-resolve common conflicts during src-layout rebase +# Stops on conflicts it can't handle + +set -e + +MAX_ITERATIONS=200 +i=0 + +while [ $i -lt $MAX_ITERATIONS ]; do + i=$((i + 1)) + + # Check if rebase is still in progress + if ! [ -d .git/rebase-merge ] && ! [ -d .git/rebase-apply ]; then + echo "Rebase complete!" + exit 0 + fi + + # Get current step info + if [ -d .git/rebase-merge ]; then + current=$(cat .git/rebase-merge/msgnum 2>/dev/null || echo "?") + total=$(cat .git/rebase-merge/end 2>/dev/null || echo "?") + else + current="?" + total="?" + fi + + # Get conflicting files + conflicts=$(git status --short | grep -E "^(UU|UA|DU|AU|AA)" || true) + + if [ -z "$conflicts" ]; then + # No conflicts, just unmerged paths — add all and continue + git add -A + if ! GIT_EDITOR=true git rebase --continue 2>/dev/null; then + continue + fi + continue + fi + + echo "[$current/$total] Conflicts: $conflicts" + + resolved=true + + while IFS= read -r line; do + status="${line:0:2}" + file="${line:3}" + + case "$status" in + "DU") + # File deleted on HEAD (master), modified by our commit + # setup.py was deleted in src-layout migration + if [ "$file" = "setup.py" ]; then + git rm -f setup.py 2>/dev/null || true + else + echo "MANUAL: DU conflict on $file" + resolved=false + fi + ;; + "UA") + # File added by our commit in a renamed directory + # Git already suggests the right location, just add it + git add "$file" 2>/dev/null || true + ;; + "AU") + # Added on HEAD, unmerged by us + git add "$file" 2>/dev/null || true + ;; + "AA") + # Both added — take ours (the branch version) + if git checkout --theirs "$file" 2>/dev/null; then + git add "$file" + else + echo "MANUAL: AA conflict on $file" + resolved=false + fi + ;; + "UU") + # Both modified — check if it's a simple case + markers=$(grep -c "<<<<<<" "$file" 2>/dev/null || echo 0) + if [ "$markers" -eq 0 ]; then + git add "$file" + else + # Try taking ours for known files + case "$file" in + tests/conftest.py|tests/*) + git checkout --theirs "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: UU on $file"; resolved=false; } + ;; + *) + echo "MANUAL: UU conflict ($markers markers) on $file" + resolved=false + ;; + esac + fi + ;; + *) + echo "MANUAL: Unknown status $status on $file" + resolved=false + ;; + esac + done <<< "$conflicts" + + if [ "$resolved" = false ]; then + echo "Stopping — manual resolution needed at step $current/$total" + exit 1 + fi + + git add -A + if ! GIT_EDITOR=true git rebase --continue 2>/dev/null; then + # rebase --continue might fail if there are no changes (empty commit) + # Try skip in that case + if git diff --cached --quiet 2>/dev/null; then + echo "[$current/$total] Empty commit, skipping" + git rebase --skip 2>/dev/null || true + fi + fi +done + +echo "Hit max iterations ($MAX_ITERATIONS)" +exit 1 diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py new file mode 100644 index 0000000..14a1ff2 --- /dev/null +++ b/src/tet/security/authentication.py @@ -0,0 +1,285 @@ +import hashlib +import secrets +import typing as tp +from datetime import UTC, datetime, timedelta + +import jwt +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.config import Configurator +from pyramid.httpexceptions import HTTPForbidden +from pyramid.request import Request +from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid_di import RequestScopedBaseService, autowired +from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy.orm import Session +from zope.interface import Interface + +__all__ = [ + "TokenAuthenticationPolicy", + "TokenMixin", + "auth_include", +] + + +SECRET_KEY = "hiddensecret" +JWT_ALGORITHM = "HS256" +DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 + + +def tet_config_auth( + config: Configurator, + token_model: tp.Any, + user_id_column: str, + user_verification: tp.Callable[[Request], tp.Any], +) -> None: + """Configuration directive to set up the authentication system.""" + config.registry.tet_auth_token_model = token_model + config.registry.tet_auth_user_id_column = user_id_column + + config.registry.tet_auth_user_verification = user_verification + + +class TokenAuthenticationPolicy: + def authenticated_userid(self, request) -> int | None: + """Return the userid of the currently authenticated user or ``None`` if + no user is currently authenticated. This method of the policy should + only return a value if the request has been successfully authenticated. + """ + token_service: TetTokenService = request.find_service(TetTokenService) + jwt_token = request.headers.get("x-jwt-token") + + if not jwt_token: + return None + + payload = token_service.verify_jwt(jwt_token) + + return payload.get("user_id") if payload else None + + def effective_principals(self, request) -> list[str]: + """Return a sequence representing the groups that the current user + is in. 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). + """ + user_id = self.authenticated_userid(request) + if user_id is not None: + return [f"user:{user_id}", "system.Authenticated"] + return ["system.Everyone"] + + def forget(self, request) -> list[tuple[str, str]]: + """Return a set of headers suitable for 'forgetting' the current user + on subsequent requests. An argument may be passed which can be used to + modify the headers that are set. + """ + return [] + + +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) + + +class TetTokenService(RequestScopedBaseService): + session: Session = autowired(Session) + + def __init__(self, request: Request): + super().__init__(request=request) + + self.token_model = self.registry.tet_auth_token_model + self.user_id_column = self.registry.tet_auth_user_id_column + self.jwt_expiration_mins = self.registry.get("tet_auth_jwt_expiration_mins", DEFAULT_JWT_TOKEN_EXPIRATION_MINS) + + def create_long_term_token(self, user_id: int, project_prefix: str, expire_timestamp=None, description=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. + - description: (Optional) Description for the token. + + Returns: + - The plaintext long-term token with the project-specific prefix. + """ + secret = secrets.token_bytes(32) + hashed_secret = hashlib.sha256(secret).digest() + + stored_token = self.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.session.add(stored_token) + self.session.flush() + + token_id = stored_token.id.to_bytes(8, "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[:8] + secret = payload[8:] + + token_id = int.from_bytes(token_id_bytes, "little") + + token_from_db = self.session.query(self.token_model).filter(self.token_model.id == token_id).one_or_none() + + if not token_from_db: + raise ValueError("Token not found") + + if 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: int) -> str: + """ + Generates a short-term JWT with a 15-minute expiration. + + Args: + - user_id: The ID of the user for whom the JWT is generated. + + Returns: + - The encoded JWT as a string. + """ + payload = { + "user_id": user_id, + "exp": datetime.now(UTC) + timedelta(minutes=15), + } + return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) + + def verify_jwt(self, token: str) -> dict | None: + """ + Verifies and decodes a JWT, ensuring it is valid and not expired. + + Args: + - token: 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, SECRET_KEY, algorithms=[JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + + +class AuthViews: + token_service: TetTokenService = autowired(TetTokenService) + + def __init__(self, request: Request): + self.request = request + self.registry = request.registry + + def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: + request = self.request + user_verification = request.registry.tet_auth_user_verification + + user_id = user_verification(request) + + if user_id is None: + return HTTPForbidden() + + token = self.token_service.create_long_term_token(user_id, "what", expire_timestamp=None, description=None) + + resp = request.response + resp.headers["x-long-token"] = token + + return dict( + user_id=user_id, + token=token, + ) + + def jwt_token_view(self) -> str: + request = self.request + token = request.headers.get("x-long-token") + + try: + token_from_db = self.token_service.retrieve_and_validate_token(token, "what") + except ValueError as e: + request.response.status = 401 + return str(e) + + user_id = getattr(token_from_db, self.token_service.user_id_column) + + jwt_token = self.token_service.create_short_term_jwt(user_id) + + request.response.headers["x-jwt-token"] = jwt_token + + return "ok" + + +def auth_include(config: Configurator): + """Routes and stuff to register maybe under a prefix""" + config.add_view( + AuthViews, + attr="login_view", + route_name="tet_auth_login", + renderer="json", + request_method="POST", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + config.add_route("tet_auth_login", "login") + + config.add_view( + AuthViews, + attr="jwt_token_view", + route_name="tet_auth_jwt", + renderer="string", + request_method="GET", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + config.add_route("tet_auth_jwt", "jwt-token") + + config.add_directive("tet_config_auth", tet_config_auth) + + config.register_service_factory(lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface) + + config.set_default_permission("view") + config.set_authentication_policy(TokenAuthenticationPolicy()) + config.set_authorization_policy(ACLAuthorizationPolicy()) From 934e56b1af4357da8a5b15217be4c53f9ab02e4f Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 14 Jan 2025 16:07:01 +0200 Subject: [PATCH 002/139] Add more configs to the registry. --- src/tet/security/authentication.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 14a1ff2..b8c33b5 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -9,10 +9,11 @@ from pyramid.httpexceptions import HTTPForbidden from pyramid.request import Request from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid.interfaces import ISecurityPolicy from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.orm import Session -from zope.interface import Interface +from zope.interface import Interface, implementer __all__ = [ "TokenAuthenticationPolicy", @@ -20,9 +21,7 @@ "auth_include", ] - -SECRET_KEY = "hiddensecret" -JWT_ALGORITHM = "HS256" +DEFAULT_JWT_ALGORITHM = "HS256" DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 @@ -31,14 +30,20 @@ def tet_config_auth( token_model: tp.Any, user_id_column: str, user_verification: tp.Callable[[Request], tp.Any], + secret_callback: tp.Callable[[], str], + jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, + jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, ) -> None: """Configuration directive to set up the authentication system.""" config.registry.tet_auth_token_model = token_model config.registry.tet_auth_user_id_column = user_id_column config.registry.tet_auth_user_verification = user_verification + config.registry.tet_auth_secret_callback = secret_callback + config.registry.tet_auth_jwt_algorithm = jwt_algorithm + config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins - +@implementer(ISecurityPolicy) class TokenAuthenticationPolicy: def authenticated_userid(self, request) -> int | None: """Return the userid of the currently authenticated user or ``None`` if @@ -102,7 +107,8 @@ def __init__(self, request: Request): self.token_model = self.registry.tet_auth_token_model self.user_id_column = self.registry.tet_auth_user_id_column - self.jwt_expiration_mins = self.registry.get("tet_auth_jwt_expiration_mins", DEFAULT_JWT_TOKEN_EXPIRATION_MINS) + self.jwt_expiration_mins = self.registry.tet_auth_jwt_expiration_mins + self.jwt_algorithm = self.registry.tet_auth_jwt_algorithm def create_long_term_token(self, user_id: int, project_prefix: str, expire_timestamp=None, description=None) -> str: """ @@ -187,7 +193,7 @@ def create_short_term_jwt(self, user_id: int) -> str: "user_id": user_id, "exp": datetime.now(UTC) + timedelta(minutes=15), } - return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM) + return jwt.encode(payload, self.registry.tet_auth_secret_callback(), algorithm=self.jwt_algorithm) def verify_jwt(self, token: str) -> dict | None: """ @@ -201,7 +207,7 @@ def verify_jwt(self, token: str) -> dict | None: - None if the JWT is invalid or expired. """ try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM]) + payload = jwt.decode(token, self.registry.tet_auth_secret_callback(), algorithms=[self.jwt_algorithm]) return payload except jwt.ExpiredSignatureError: return None From 7befdf3dd038ad92c906eccf713e01c2a2938c54 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 14 Jan 2025 16:30:57 +0200 Subject: [PATCH 003/139] Define Protocol interfaces. --- src/tet/security/authentication.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index b8c33b5..424039f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -25,12 +25,30 @@ DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 +class IUserAuthenticationService(tp.Protocol): + """ + Authenticates a user and returns their ID. + """ + + def __call__(self, request: Request) -> tp.Any | None: + pass + + +class ISecretCallback(tp.Protocol): + """ + returns the secret key for JWT + """ + + def __call__(self) -> str: + pass + + def tet_config_auth( config: Configurator, token_model: tp.Any, user_id_column: str, - user_verification: tp.Callable[[Request], tp.Any], - secret_callback: tp.Callable[[], str], + user_verification: IUserAuthenticationService, + secret_callback: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, ) -> None: @@ -43,6 +61,7 @@ def tet_config_auth( config.registry.tet_auth_jwt_algorithm = jwt_algorithm config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins + @implementer(ISecurityPolicy) class TokenAuthenticationPolicy: def authenticated_userid(self, request) -> int | None: @@ -159,7 +178,7 @@ def retrieve_and_validate_token(self, token: str, prefix: str) -> tp.Any: if not token.startswith(prefix): raise ValueError("Invalid token prefix") - payload_hex = token[len(prefix) :] + payload_hex = token[len(prefix):] payload = bytes.fromhex(payload_hex) token_id_bytes = payload[:8] secret = payload[8:] From 3faa61b8ef592b8994d7c1fc1b1df22a9ec2e92a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 16 Jan 2025 16:27:45 +0200 Subject: [PATCH 004/139] Update authentication: - Add pyjwt to the list of dependencies - Rename IUserAuthenticationService - Use includeme instead of auth_include - Add request to serect_callback --- src/tet/security/authentication.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 424039f..1d42e60 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -18,14 +18,13 @@ __all__ = [ "TokenAuthenticationPolicy", "TokenMixin", - "auth_include", ] DEFAULT_JWT_ALGORITHM = "HS256" DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 -class IUserAuthenticationService(tp.Protocol): +class IUserAuthenticationCallback(tp.Protocol): """ Authenticates a user and returns their ID. """ @@ -39,15 +38,17 @@ class ISecretCallback(tp.Protocol): returns the secret key for JWT """ - def __call__(self) -> str: + def __call__(self, request: Request) -> str: pass +# TODO change name into Configure Authentication Token def tet_config_auth( config: Configurator, + *, token_model: tp.Any, - user_id_column: str, - user_verification: IUserAuthenticationService, + user_id_column: str = "user_id", + user_verification: IUserAuthenticationCallback, secret_callback: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, @@ -212,7 +213,7 @@ def create_short_term_jwt(self, user_id: int) -> str: "user_id": user_id, "exp": datetime.now(UTC) + timedelta(minutes=15), } - return jwt.encode(payload, self.registry.tet_auth_secret_callback(), algorithm=self.jwt_algorithm) + return jwt.encode(payload, self.registry.tet_auth_secret_callback(self.request), algorithm=self.jwt_algorithm) def verify_jwt(self, token: str) -> dict | None: """ @@ -226,7 +227,8 @@ def verify_jwt(self, token: str) -> dict | None: - None if the JWT is invalid or expired. """ try: - payload = jwt.decode(token, self.registry.tet_auth_secret_callback(), algorithms=[self.jwt_algorithm]) + payload = jwt.decode(token, self.registry.tet_auth_secret_callback(self.request), + algorithms=[self.jwt_algorithm]) return payload except jwt.ExpiredSignatureError: return None @@ -277,7 +279,7 @@ def jwt_token_view(self) -> str: return "ok" -def auth_include(config: Configurator): +def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_view( AuthViews, @@ -303,6 +305,7 @@ def auth_include(config: Configurator): config.add_directive("tet_config_auth", tet_config_auth) + config.include("pyramid_di") config.register_service_factory(lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface) config.set_default_permission("view") From be79cc91e7ed09c0062fd3cec7becd7c7f300146 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 20 Jan 2025 17:30:37 +0200 Subject: [PATCH 005/139] Update authentication: - Update docstrings. - Use config.action() to register directives, enabling conflict detection. - Improve variable names, headers, and other elements for clarity and consistency. --- src/tet/security/authentication.py | 185 +++++++++++++++++++++-------- 1 file changed, 133 insertions(+), 52 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 1d42e60..45ced88 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -4,10 +4,11 @@ from datetime import UTC, datetime, timedelta import jwt + from pyramid.authorization import ACLAuthorizationPolicy from pyramid.config import Configurator from pyramid.httpexceptions import HTTPForbidden -from pyramid.request import Request +from pyramid.request import Request, Response from pyramid.security import NO_PERMISSION_REQUIRED from pyramid.interfaces import ISecurityPolicy from pyramid_di import RequestScopedBaseService, autowired @@ -26,7 +27,9 @@ class IUserAuthenticationCallback(tp.Protocol): """ - Authenticates a user and returns their ID. + Authenticates a user and returns the user_id. + + **Returns:** ``user_id`` """ def __call__(self, request: Request) -> tp.Any | None: @@ -35,7 +38,7 @@ def __call__(self, request: Request) -> tp.Any | None: class ISecretCallback(tp.Protocol): """ - returns the secret key for JWT + **Returns:** The secret key for JWT """ def __call__(self, request: Request) -> str: @@ -43,35 +46,110 @@ def __call__(self, request: Request) -> str: # TODO change name into Configure Authentication Token -def tet_config_auth( +def tet_configure_authentication_token( config: Configurator, *, token_model: tp.Any, + project_prefix: str, user_id_column: str = "user_id", - user_verification: IUserAuthenticationCallback, + user_verification_callback: IUserAuthenticationCallback, secret_callback: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, ) -> None: - """Configuration directive to set up the authentication system.""" - config.registry.tet_auth_token_model = token_model - config.registry.tet_auth_user_id_column = user_id_column + """ + Configure token-based authentication for a Pyramid application (with conflict detection). + + .. note:: + + This function is intended to be used as a Pyramid configuration directive. By calling + :meth:`pyramid.config.Configurator.action` with a unique ``discriminator``, it ensures + that conflicts are detected if multiple parts of the application try to register the + same directive. + + **Usage Example** + + 1. **Add the directive** (typically in your ``includeme`` function): + + .. code-block:: python + + from pyramid.config import Configurator + from myproject.auth import tet_configure_authentication_token + + def includeme(config: Configurator): + # Register the custom directive + config.add_directive( + 'tet_configure_authentication_token', + tet_configure_authentication_token + ) + + 2. **Use the directive** somewhere after including it: + + .. code-block:: python + + def main(global_config, **settings): + config = Configurator(settings=settings) + config.include('myproject') # calls includeme(...) + + config.tet_configure_authentication_token( + token_model=MyTokenModel, + project_prefix='my_project', + user_verification_callback=verify_user, + secret_callback=get_secret, + jwt_algorithm='HS256', + jwt_token_expiration_mins=120 + ) + + return config.make_wsgi_app() + + **Accessing the Configured Values** - config.registry.tet_auth_user_verification = user_verification - config.registry.tet_auth_secret_callback = secret_callback - config.registry.tet_auth_jwt_algorithm = jwt_algorithm - config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins + Later in the application code, it can retrieve these values from ``request.registry``: + + .. code-block:: python + + @view_config(route_name='home') + def home_view(request): + token_model = request.registry.tet_auth_token_model + prefix = request.registry.tet_auth_project_prefix + # ... do something with these values ... + + Args: + config: The current Pyramid :class:`pyramid.config.Configurator` instance. + token_model: A token model class or object representing user tokens. + project_prefix: A project-specific prefix (could be used for namespacing). + user_id_column: Column name or attribute for user ID in the token model. Defaults to ``"user_id"``. + user_verification_callback: A callable to verify user credentials/status. + secret_callback: A callable that returns a secret key or keys for token signing. + jwt_algorithm: The JWT algorithm to use (default: ``"HS256"``). + jwt_token_expiration_mins: JWT expiration time in minutes (default: 60). + """ + + def register(): + config.registry.tet_auth_token_model = token_model + config.registry.tet_auth_project_prefix = project_prefix + config.registry.tet_auth_user_id_column = user_id_column + + config.registry.tet_auth_user_verification_callback = user_verification_callback + config.registry.tet_auth_secret_callback = secret_callback + config.registry.tet_auth_jwt_algorithm = jwt_algorithm + config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins + + config.action(discriminator="tet_configure_authentication_token", callable=register) @implementer(ISecurityPolicy) class TokenAuthenticationPolicy: def authenticated_userid(self, request) -> int | None: - """Return the userid of the currently authenticated user or ``None`` if - no user is currently authenticated. This method of the policy should + """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. """ token_service: TetTokenService = request.find_service(TetTokenService) - jwt_token = request.headers.get("x-jwt-token") + jwt_token = request.headers.get("x-access-token") if not jwt_token: return None @@ -81,10 +159,11 @@ def authenticated_userid(self, request) -> int | None: return payload.get("user_id") if payload else None def effective_principals(self, request) -> list[str]: - """Return a sequence representing the groups that the current user - is in. This method of the policy should return at least one principal + """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 """ user_id = self.authenticated_userid(request) if user_id is not None: @@ -92,9 +171,9 @@ def effective_principals(self, request) -> list[str]: return ["system.Everyone"] def forget(self, request) -> list[tuple[str, str]]: - """Return a set of headers suitable for 'forgetting' the current user - on subsequent requests. An argument may be passed which can be used to - modify the headers that are set. + """An argument may be passed which can be used to modify the headers that are set. + Returns: + A set of headers suitable for 'forgetting' the current user on subsequent requests. """ return [] @@ -105,11 +184,14 @@ class TokenMixin: 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. + + **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" @@ -133,15 +215,14 @@ def __init__(self, request: Request): def create_long_term_token(self, user_id: int, project_prefix: str, expire_timestamp=None, description=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. - - description: (Optional) Description for the token. + 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. + description: (Optional) Description for the token. Returns: - - The plaintext long-term token with the project-specific prefix. + The plaintext long-term token with the project-specific prefix. """ secret = secrets.token_bytes(32) hashed_secret = hashlib.sha256(secret).digest() @@ -167,14 +248,14 @@ 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. + token: The token string to validate. + prefix: The expected project-specific prefix for the token. Returns: - - The validated Token object from the database. + The validated Token object from the database. Raises: - - ValueError: If the token is invalid, expired, or not found. + ValueError: If the token is invalid, expired, or not found. """ if not token.startswith(prefix): raise ValueError("Invalid token prefix") @@ -204,10 +285,9 @@ def create_short_term_jwt(self, user_id: int) -> str: Generates a short-term JWT with a 15-minute expiration. Args: - - user_id: The ID of the user for whom the JWT is generated. - + user_id: The ID of the user for whom the JWT is generated. Returns: - - The encoded JWT as a string. + The encoded JWT as a string. """ payload = { "user_id": user_id, @@ -220,11 +300,11 @@ def verify_jwt(self, token: str) -> dict | None: Verifies and decodes a JWT, ensuring it is valid and not expired. Args: - - token: The JWT to verify. + token (str): The JWT to verify. Returns: - - The decoded payload if the JWT is valid. - - None if the JWT is invalid or expired. + - 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_secret_callback(self.request), @@ -240,19 +320,21 @@ class AuthViews: 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 def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: - request = self.request - user_verification = request.registry.tet_auth_user_verification + user_verification_callback = self.registry.tet_auth_user_verification_callback - user_id = user_verification(request) + user_id = user_verification_callback(self.request) if user_id is None: return HTTPForbidden() - token = self.token_service.create_long_term_token(user_id, "what", expire_timestamp=None, description=None) + token = self.token_service.create_long_term_token(user_id, self.project_prefix, expire_timestamp=None, + description=None) - resp = request.response + resp: Response = self.response resp.headers["x-long-token"] = token return dict( @@ -261,20 +343,19 @@ def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: ) def jwt_token_view(self) -> str: - request = self.request - token = request.headers.get("x-long-token") + token = self.request.headers.get("x-long-token") try: - token_from_db = self.token_service.retrieve_and_validate_token(token, "what") + token_from_db = self.token_service.retrieve_and_validate_token(token, self.project_prefix) except ValueError as e: - request.response.status = 401 + self.request.response.status = 401 return str(e) user_id = getattr(token_from_db, self.token_service.user_id_column) jwt_token = self.token_service.create_short_term_jwt(user_id) - request.response.headers["x-jwt-token"] = jwt_token + self.response.headers["x-access-token"] = jwt_token return "ok" @@ -303,7 +384,7 @@ def includeme(config: Configurator): ) config.add_route("tet_auth_jwt", "jwt-token") - config.add_directive("tet_config_auth", tet_config_auth) + config.add_directive("tet_configure_authentication_token", tet_configure_authentication_token) config.include("pyramid_di") config.register_service_factory(lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface) From 587907730f3af6b23203606d67fc3cf94a320840 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 21 Jan 2025 12:53:06 +0200 Subject: [PATCH 006/139] Add route_prefix. --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 45ced88..cb7569f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -371,7 +371,7 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) - config.add_route("tet_auth_login", "login") + config.add_route("tet_auth_login", "login", route_prefix="api/v1/auth") config.add_view( AuthViews, @@ -382,7 +382,7 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) - config.add_route("tet_auth_jwt", "jwt-token") + config.add_route("tet_auth_jwt", "access-token", route_prefix="api/v1/auth") config.add_directive("tet_configure_authentication_token", tet_configure_authentication_token) From f640626e2544d5eb1fa72a7793e357df92925285 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 22 Jan 2025 12:47:31 +0200 Subject: [PATCH 007/139] Update authentication module: - Fix argument duplication in create_long_term_token call - config: Setup the ACLAuthorizationPolicy before AuthenticationPolicy --- src/tet/security/authentication.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index cb7569f..ed46582 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -331,8 +331,7 @@ def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: if user_id is None: return HTTPForbidden() - token = self.token_service.create_long_term_token(user_id, self.project_prefix, expire_timestamp=None, - description=None) + token = self.token_service.create_long_term_token(user_id, self.project_prefix) resp: Response = self.response resp.headers["x-long-token"] = token @@ -362,6 +361,9 @@ def jwt_token_view(self) -> str: def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" + with config.route_prefix_context("api/v1/auth"): + config.add_route("tet_auth_login", "login") + config.add_route("tet_auth_jwt", "access-token") config.add_view( AuthViews, attr="login_view", @@ -371,7 +373,6 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) - config.add_route("tet_auth_login", "login", route_prefix="api/v1/auth") config.add_view( AuthViews, @@ -382,7 +383,6 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) - config.add_route("tet_auth_jwt", "access-token", route_prefix="api/v1/auth") config.add_directive("tet_configure_authentication_token", tet_configure_authentication_token) @@ -390,5 +390,5 @@ def includeme(config: Configurator): config.register_service_factory(lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface) config.set_default_permission("view") - config.set_authentication_policy(TokenAuthenticationPolicy()) config.set_authorization_policy(ACLAuthorizationPolicy()) + config.set_authentication_policy(TokenAuthenticationPolicy()) From fe2b457ef76b6e6c882e0fd61f98f8023d930596 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 23 Jan 2025 17:21:01 +0200 Subject: [PATCH 008/139] Add test suites. --- tests/README.md | 5 + tests/conftest.py | 151 +++++++++++++++++--------- tests/models/accounts.py | 46 ++++++++ tests/pytest.ini | 3 + tests/services/test_authentication.py | 108 ++++++++++++++++++ 5 files changed, 260 insertions(+), 53 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/models/accounts.py create mode 100755 tests/pytest.ini create mode 100644 tests/services/test_authentication.py diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..da15bb7 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,5 @@ +Running all test suites + +``` +python -m pytest ./tests -v -W ignore::DeprecationWarning +``` diff --git a/tests/conftest.py b/tests/conftest.py index 8eafe32..12977a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,55 +1,100 @@ -""" -Pytest configuration and fixtures for Tet framework tests. -""" +import pytest +from pyramid.request import Request +from pyramid.security import Allow, Authenticated, Everyone, Deny +from pyramid.testing import setUp, tearDown +from sqlalchemy import create_engine +from sqlalchemy.orm import Session -from unittest.mock import Mock +from tests.models.accounts import Base, Token, User +from tet.config import Configurator as tetConfigurator -import pytest -from pyramid import testing -from pyramid.config import Configurator - - -@pytest.fixture -def pyramid_config(): - """Create a Pyramid configurator for testing.""" - config = Configurator() - config.begin() - 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 +DB_NAME = "test_tet" +DB_URL = f"postgresql:///{DB_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 + # Base.metadata.drop_all(engine) + 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 authentication_callback(request: Request) -> User.id: + # TODO: Implement the actual callback here. + return 1 + + +def secret_callback(request: Request) -> str: + # TODO: Get it from the settings or elsewhere + return "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'), + (Deny, Everyone, 'delete') + ] + + def __init__(self, request): + self.request = request + +@pytest.fixture() +def pyramid_app(db_engine): + """Fixture to create and configure a Pyramid application.""" + settings = { + 'sqlalchemy.url': DB_URL, + "project_prefix": "tet", + "pyramid.includes": ["pyramid_tm"], + } + 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") + config.tet_configure_authentication_token( + token_model=Token, + project_prefix=config.registry.settings['project_prefix'], + user_verification_callback=authentication_callback, + secret_callback=secret_callback, + ) + config.add_route('home', '/') + config.add_view(lambda request: {'message': 'Hello, World!'}, route_name='home', renderer='json') + app = config.make_wsgi_app() + yield app diff --git a/tests/models/accounts.py b/tests/models/accounts.py new file mode 100644 index 0000000..da05286 --- /dev/null +++ b/tests/models/accounts.py @@ -0,0 +1,46 @@ +from tet.security.authentication import TokenMixin +from tet.sqlalchemy.password import UserPasswordMixin + +from sqlalchemy import ( + Column, + Integer, + Text, + Boolean, + ForeignKey +) +from sqlalchemy import orm +from sqlalchemy.ext.declarative 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, 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') + + +__all__ = [ + "User", + "Token", + "Base", + "metadata" +] diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100755 index 0000000..1b714c4 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +python_files = *.py diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py new file mode 100644 index 0000000..3d54d05 --- /dev/null +++ b/tests/services/test_authentication.py @@ -0,0 +1,108 @@ +import pytest +from sqlalchemy.orm import Session +from webtest import TestApp + +from tests.conftest import pyramid_app +from tests.models.accounts import User +from tet.security.authentication import TetTokenService + +ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" +LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" +HOME_ROUTE = "/" + +@pytest.fixture() +def test_app(pyramid_app): + return TestApp(pyramid_app) + + +@pytest.fixture() +def long_term_token(pyramid_app, test_app, capture_token): + response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, status=200) + + assert response.status_code == 200 + assert "user_id" in response.json + assert "token" in response.json + + # Validate the token captured by monkeypatch + assert "token" in capture_token + assert capture_token["token"] == response.json["token"] + + token = response.json['token'] + assert isinstance(token, str) + assert len(token) > 0 + return token + + +def create_user(db_session: Session): + user = User(email="exampple2@invalid.invalid", name="example2", is_admin=True) + user.password = "1234@abcd" + default_user = db_session.query(User).filter(User.email == user.email).first() + if default_user: + return default_user + + 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): + 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 + + def wrapper(*args, **kwargs): + token = create_long_term_token(*args, **kwargs) + captured_data["token"] = token + return token + + monkeypatch.setattr(TetTokenService, "create_long_term_token", wrapper) + + return captured_data + + +def test_login_view_should_return_long_term_token(long_term_token): + assert long_term_token is not None + assert len(long_term_token) > 0 + + +def test_auth_should_return_access_token(long_term_token, test_app): + headers = {"x-long-token": long_term_token} + response = test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) + + assert response.status_code == 200 + + assert "x-access-token" in response.headers + assert response.headers["x-access-token"] is not None + + +def test_access_token_should_work_to_access_protected_route(long_term_token, test_app): + headers = {"x-long-token": long_term_token} + response = test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) + + assert response.status_code == 200 + + access_token = response.headers["x-access-token"] + assert access_token is not None + + headers = {"x-access-token": access_token} + response = 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!" + +# Test it should stored the token in the database +# Test it should failed to access the protected route without the access token +# Test it should failed to access the protected route with invalid access token From 0ed86237f3f66791df80cde22ae0ebf97fb959b8 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 24 Jan 2025 18:18:52 +0200 Subject: [PATCH 009/139] Update pytest configuration: - Move it to the root level directory - Add pythonpath so it can identify the tests module itself - Add test_requires to the dependencies --- tests/pytest.ini => pytest.ini | 1 + 1 file changed, 1 insertion(+) rename tests/pytest.ini => pytest.ini (75%) diff --git a/tests/pytest.ini b/pytest.ini similarity index 75% rename from tests/pytest.ini rename to pytest.ini index 1b714c4..443a712 100755 --- a/tests/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] testpaths = tests python_files = *.py +pythonpath = . From 601f778a6bc20ec74f3d97ab37eb7edffac1e5b5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 24 Jan 2025 18:21:06 +0200 Subject: [PATCH 010/139] Update the tests's README --- tests/README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index da15bb7..4b867d5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,20 @@ Running all test suites +We need dependencies for the tests ``` -python -m pytest ./tests -v -W ignore::DeprecationWarning +pip install -e '.[test]' +``` + +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 ``` From e814e9e61baaa4faf7dbc0f13a4f9b8328222d10 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 24 Jan 2025 18:21:46 +0200 Subject: [PATCH 011/139] Update authentication test: - Ensure the fixture does not doing extra works such as asserting on every request and return the desired values instead. --- tests/services/test_authentication.py | 37 +++++++++++++-------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py index 3d54d05..32021d1 100644 --- a/tests/services/test_authentication.py +++ b/tests/services/test_authentication.py @@ -10,6 +10,7 @@ LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" HOME_ROUTE = "/" + @pytest.fixture() def test_app(pyramid_app): return TestApp(pyramid_app) @@ -19,18 +20,7 @@ def test_app(pyramid_app): def long_term_token(pyramid_app, test_app, capture_token): response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, status=200) - assert response.status_code == 200 - assert "user_id" in response.json - assert "token" in response.json - - # Validate the token captured by monkeypatch - assert "token" in capture_token - assert capture_token["token"] == response.json["token"] - - token = response.json['token'] - assert isinstance(token, str) - assert len(token) > 0 - return token + return response.json['token'] def create_user(db_session: Session): @@ -72,9 +62,19 @@ def wrapper(*args, **kwargs): return captured_data -def test_login_view_should_return_long_term_token(long_term_token): - assert long_term_token is not None - assert len(long_term_token) > 0 +def test_login_view_should_return_long_term_token(test_app, capture_token): + response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, status=200) + assert response.status_code == 200 + assert "user_id" in response.json + assert "token" in response.json + + # Validate the token captured by monkeypatch + assert "token" in capture_token + assert capture_token["token"] == response.json["token"] + + token = response.json['token'] + assert isinstance(token, str) + assert len(token) > 0 def test_auth_should_return_access_token(long_term_token, test_app): @@ -90,7 +90,6 @@ def test_auth_should_return_access_token(long_term_token, test_app): def test_access_token_should_work_to_access_protected_route(long_term_token, test_app): headers = {"x-long-token": long_term_token} response = test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) - assert response.status_code == 200 access_token = response.headers["x-access-token"] @@ -103,6 +102,6 @@ def test_access_token_should_work_to_access_protected_route(long_term_token, tes assert "message" in response.json assert response.json["message"] == "Hello, World!" -# Test it should stored the token in the database -# Test it should failed to access the protected route without the access token -# Test it should failed to access the protected route with invalid access token +# Test it should store the token in the database +# Test it should fail to access the protected route without the access token +# Test it should fail to access the protected route with invalid access token From 2dd4ad49592d21c44c531427d5d55697af442d97 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 24 Jan 2025 18:23:32 +0200 Subject: [PATCH 012/139] Update tet.security.authentication: - Make it possible to set the name of long_term_token, and access_token headers. --- src/tet/security/authentication.py | 101 +++++++++++++++-------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index ed46582..f979388 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -23,6 +23,9 @@ DEFAULT_JWT_ALGORITHM = "HS256" DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 +DEFAULT_USER_ID_COLUMN = "user_id" +DEFAULT_LONG_TERM_TOKEN = "X-Long-Token" +DEFAULT_ACCESS_TOKEN = "X-Access-Token" class IUserAuthenticationCallback(tp.Protocol): @@ -45,17 +48,18 @@ def __call__(self, request: Request) -> str: pass -# TODO change name into Configure Authentication Token def tet_configure_authentication_token( config: Configurator, *, token_model: tp.Any, project_prefix: str, - user_id_column: str = "user_id", + user_id_column: str = DEFAULT_USER_ID_COLUMN, user_verification_callback: IUserAuthenticationCallback, secret_callback: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, + access_token_header: str = DEFAULT_ACCESS_TOKEN, + long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN, ) -> None: """ Configure token-based authentication for a Pyramid application (with conflict detection). @@ -66,53 +70,51 @@ def tet_configure_authentication_token( :meth:`pyramid.config.Configurator.action` with a unique ``discriminator``, it ensures that conflicts are detected if multiple parts of the application try to register the same directive. + Example: + 1. **Add the directive** (typically in your ``includeme`` function): - **Usage Example** + .. code-block:: python - 1. **Add the directive** (typically in your ``includeme`` function): + from pyramid.config import Configurator + from myproject.auth import tet_configure_authentication_token - .. code-block:: python + def includeme(config: Configurator): + # Register the custom directive + config.add_directive( + 'tet_configure_authentication_token', + tet_configure_authentication_token + ) - from pyramid.config import Configurator - from myproject.auth import tet_configure_authentication_token + 2. **Use the directive** somewhere after including it: - def includeme(config: Configurator): - # Register the custom directive - config.add_directive( - 'tet_configure_authentication_token', - tet_configure_authentication_token - ) + .. code-block:: python - 2. **Use the directive** somewhere after including it: + def main(global_config, **settings): + config = Configurator(settings=settings) + config.include('myproject') # calls includeme(...) - .. code-block:: python + config.tet_configure_authentication_token( + token_model=MyTokenModel, + project_prefix='my_project', + user_verification_callback=verify_user, + secret_callback=get_secret, + jwt_algorithm='HS256', + jwt_token_expiration_mins=120 + ) - def main(global_config, **settings): - config = Configurator(settings=settings) - config.include('myproject') # calls includeme(...) + return config.make_wsgi_app() - config.tet_configure_authentication_token( - token_model=MyTokenModel, - project_prefix='my_project', - user_verification_callback=verify_user, - secret_callback=get_secret, - jwt_algorithm='HS256', - jwt_token_expiration_mins=120 - ) + **Accessing the Configured Values** - return config.make_wsgi_app() + Later in the application code, it can retrieve these values from ``request.registry``: - **Accessing the Configured Values** + .. code-block:: python - Later in the application code, it can retrieve these values from ``request.registry``: - - .. code-block:: python - - @view_config(route_name='home') - def home_view(request): - token_model = request.registry.tet_auth_token_model - prefix = request.registry.tet_auth_project_prefix - # ... do something with these values ... + @view_config(route_name='home') + def home_view(request): + token_model = request.registry.tet_auth_token_model + prefix = request.registry.tet_auth_project_prefix + # ... do something with these values ... Args: config: The current Pyramid :class:`pyramid.config.Configurator` instance. @@ -122,13 +124,17 @@ def home_view(request): user_verification_callback: A callable to verify user credentials/status. secret_callback: A callable that returns a secret key or keys for token signing. jwt_algorithm: The JWT algorithm to use (default: ``"HS256"``). - jwt_token_expiration_mins: JWT expiration time in minutes (default: 60). + jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). + access_token_header: The header name for the access token (default: ``"X-Access-Token"``). + long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). """ def register(): config.registry.tet_auth_token_model = token_model config.registry.tet_auth_project_prefix = project_prefix config.registry.tet_auth_user_id_column = user_id_column + config.registry.tet_auth_access_token_header = access_token_header + config.registry.tet_auth_long_term_token_header = long_term_token_header config.registry.tet_auth_user_verification_callback = user_verification_callback config.registry.tet_auth_secret_callback = secret_callback @@ -140,7 +146,7 @@ def register(): @implementer(ISecurityPolicy) class TokenAuthenticationPolicy: - def authenticated_userid(self, request) -> int | None: + def authenticated_userid(self, request: Request) -> int | None: """This method of the policy should only return a value if the request has been successfully authenticated. @@ -149,7 +155,7 @@ def authenticated_userid(self, request) -> int | None: - ``None`` if no user is authenticated. """ token_service: TetTokenService = request.find_service(TetTokenService) - jwt_token = request.headers.get("x-access-token") + jwt_token = request.headers.get(request.registry.tet_auth_access_token_header) if not jwt_token: return None @@ -171,9 +177,8 @@ def effective_principals(self, request) -> list[str]: return ["system.Everyone"] def forget(self, request) -> list[tuple[str, str]]: - """An argument may be passed which can be used to modify the headers that are set. - Returns: - A set of headers suitable for 'forgetting' the current user on subsequent requests. + """ + This method does not need to be implemented for header-based authentication. """ return [] @@ -291,7 +296,7 @@ def create_short_term_jwt(self, user_id: int) -> str: """ payload = { "user_id": user_id, - "exp": datetime.now(UTC) + timedelta(minutes=15), + "exp": datetime.now(UTC) + timedelta(minutes=self.jwt_expiration_mins), } return jwt.encode(payload, self.registry.tet_auth_secret_callback(self.request), algorithm=self.jwt_algorithm) @@ -321,6 +326,8 @@ def __init__(self, request: Request): self.request = request self.registry = request.registry self.response = request.response + self.long_term_token_header = self.registry.tet_auth_long_term_token_header + self.access_token_header = self.registry.tet_auth_access_token_header self.project_prefix = self.registry.tet_auth_project_prefix def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: @@ -334,7 +341,7 @@ def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: token = self.token_service.create_long_term_token(user_id, self.project_prefix) resp: Response = self.response - resp.headers["x-long-token"] = token + resp.headers[self.long_term_token_header] = token return dict( user_id=user_id, @@ -342,7 +349,7 @@ def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: ) def jwt_token_view(self) -> str: - token = self.request.headers.get("x-long-token") + token = self.request.headers.get(self.long_term_token_header) try: token_from_db = self.token_service.retrieve_and_validate_token(token, self.project_prefix) @@ -354,7 +361,7 @@ def jwt_token_view(self) -> str: jwt_token = self.token_service.create_short_term_jwt(user_id) - self.response.headers["x-access-token"] = jwt_token + self.response.headers[self.access_token_header] = jwt_token return "ok" From bdfd7d8b0be5070a165ae80edb80499f9aa9e16c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 28 Jan 2025 15:12:23 +0200 Subject: [PATCH 013/139] update test's README --- tests/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/README.md b/tests/README.md index 4b867d5..f9b64d2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,6 +5,12 @@ We need dependencies for the tests pip install -e '.[test]' ``` +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 From e0b6c1da92d8be038e10e524fecf593828c5bb5a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 28 Jan 2025 15:18:43 +0200 Subject: [PATCH 014/139] Refactor authentication: Rename callback interface, add ACL-based permissions, improve logging and exception handling. --- src/tet/security/authentication.py | 49 ++++++++++++++++++------------ 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index f979388..d482873 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -4,18 +4,20 @@ from datetime import UTC, datetime, timedelta import jwt +import logging -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authorization import ACLAuthorizationPolicy, ACLHelper from pyramid.config import Configurator -from pyramid.httpexceptions import HTTPForbidden +from pyramid.httpexceptions import HTTPForbidden, HTTPUnauthorized from pyramid.request import Request, Response -from pyramid.security import NO_PERMISSION_REQUIRED +from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid.interfaces import ISecurityPolicy from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.orm import Session from zope.interface import Interface, implementer +logger = logging.getLogger(__name__) __all__ = [ "TokenAuthenticationPolicy", "TokenMixin", @@ -28,7 +30,7 @@ DEFAULT_ACCESS_TOKEN = "X-Access-Token" -class IUserAuthenticationCallback(tp.Protocol): +class ILoginCallback(tp.Protocol): """ Authenticates a user and returns the user_id. @@ -54,7 +56,7 @@ def tet_configure_authentication_token( token_model: tp.Any, project_prefix: str, user_id_column: str = DEFAULT_USER_ID_COLUMN, - user_verification_callback: IUserAuthenticationCallback, + login_callback: ILoginCallback, secret_callback: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, @@ -96,7 +98,7 @@ def main(global_config, **settings): config.tet_configure_authentication_token( token_model=MyTokenModel, project_prefix='my_project', - user_verification_callback=verify_user, + login_callback=verify_user, secret_callback=get_secret, jwt_algorithm='HS256', jwt_token_expiration_mins=120 @@ -121,7 +123,7 @@ def home_view(request): token_model: A token model class or object representing user tokens. project_prefix: A project-specific prefix (could be used for namespacing). user_id_column: Column name or attribute for user ID in the token model. Defaults to ``"user_id"``. - user_verification_callback: A callable to verify user credentials/status. + login_callback: A callable to verify user credentials/status from the database. secret_callback: A callable that returns a secret key or keys for token signing. jwt_algorithm: The JWT algorithm to use (default: ``"HS256"``). jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). @@ -136,7 +138,7 @@ def register(): config.registry.tet_auth_access_token_header = access_token_header config.registry.tet_auth_long_term_token_header = long_term_token_header - config.registry.tet_auth_user_verification_callback = user_verification_callback + config.registry.tet_auth_login_callback = login_callback config.registry.tet_auth_secret_callback = secret_callback config.registry.tet_auth_jwt_algorithm = jwt_algorithm config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins @@ -146,6 +148,9 @@ def register(): @implementer(ISecurityPolicy) class TokenAuthenticationPolicy: + def __init__(self): + self.acl = ACLHelper() + def authenticated_userid(self, request: Request) -> int | None: """This method of the policy should only return a value if the request has been successfully authenticated. @@ -164,6 +169,10 @@ def authenticated_userid(self, request: Request) -> int | None: 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) -> 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' @@ -171,10 +180,11 @@ def effective_principals(self, request) -> list[str]: 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: - return [f"user:{user_id}", "system.Authenticated"] - return ["system.Everyone"] + principals.extend([f"user:{user_id}", Authenticated]) + return principals def forget(self, request) -> list[tuple[str, str]]: """ @@ -217,7 +227,7 @@ def __init__(self, request: Request): self.jwt_expiration_mins = self.registry.tet_auth_jwt_expiration_mins self.jwt_algorithm = self.registry.tet_auth_jwt_algorithm - def create_long_term_token(self, user_id: int, project_prefix: str, expire_timestamp=None, description=None) -> str: + def create_long_term_token(self, user_id: tp.Any, project_prefix: str, expire_timestamp=None, description=None) -> str: """ Generates a long-term token for a user with a project-specific prefix and stores it in the database. Args: @@ -285,7 +295,7 @@ def retrieve_and_validate_token(self, token: str, prefix: str) -> tp.Any: return token_from_db - def create_short_term_jwt(self, user_id: int) -> str: + def create_short_term_jwt(self, user_id: tp.Any) -> str: """ Generates a short-term JWT with a 15-minute expiration. @@ -331,12 +341,12 @@ def __init__(self, request: Request): self.project_prefix = self.registry.tet_auth_project_prefix def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: - user_verification_callback = self.registry.tet_auth_user_verification_callback + login_callback = self.registry.tet_auth_login_callback - user_id = user_verification_callback(self.request) + user_id = login_callback(self.request) if user_id is None: - return HTTPForbidden() + raise HTTPForbidden() token = self.token_service.create_long_term_token(user_id, self.project_prefix) @@ -354,8 +364,8 @@ def jwt_token_view(self) -> str: try: token_from_db = self.token_service.retrieve_and_validate_token(token, self.project_prefix) except ValueError as e: - self.request.response.status = 401 - return str(e) + logger.exception(f"Error validating token: {e}") + raise HTTPUnauthorized() user_id = getattr(token_from_db, self.token_service.user_id_column) @@ -368,9 +378,8 @@ def jwt_token_view(self) -> str: def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" - with config.route_prefix_context("api/v1/auth"): - config.add_route("tet_auth_login", "login") - config.add_route("tet_auth_jwt", "access-token") + config.add_route("tet_auth_login", "login") + config.add_route("tet_auth_jwt", "access-token") config.add_view( AuthViews, attr="login_view", From 2a7577002878a1fcde9690e10b9e3a1201c2ebd5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 28 Jan 2025 15:19:46 +0200 Subject: [PATCH 015/139] Update conftest: - Implementing the secrect, and login callback(s) - Adding the route_prefix for callable module --- tests/conftest.py | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 12977a6..b07d0d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,12 @@ +import logging +from functools import wraps + import pytest +from pyramid.httpexceptions import HTTPForbidden from pyramid.request import Request from pyramid.security import Allow, Authenticated, Everyone, Deny from pyramid.testing import setUp, tearDown -from sqlalchemy import create_engine +from sqlalchemy import create_engine, or_ from sqlalchemy.orm import Session from tests.models.accounts import Base, Token, User @@ -11,6 +15,8 @@ DB_NAME = "test_tet" DB_URL = f"postgresql:///{DB_NAME}" +logger = logging.getLogger(__name__) + def create_test_database(): # TODO: create the DB, but for now on we assume it must exists @@ -29,6 +35,8 @@ def db_engine(database): engine = create_engine(DB_URL) Base.metadata.create_all(engine) yield engine + # TODO: Dropping all entities will disrupt the saving of tokens in the security/authentication module. + # Investigate the workflow and resolve the issue. # Base.metadata.drop_all(engine) engine.dispose() @@ -45,14 +53,25 @@ def db_session(db_engine, pyramid_request, transaction_manager): yield session -def authentication_callback(request: Request) -> User.id: - # TODO: Implement the actual callback here. - return 1 +# don't actually print the logger for this callback +def login_callback(request: Request) -> User.id: + """This is just an example of a login callback. It should be defined by the pyramid app.""" + db_session = request.find_service(Session) + payload = request.json_body + # user_identity here could be an email, or username + user_identity = payload['user_identity'] + user = (db_session.query(User) + .filter(or_(User.email == user_identity, + User.name == user_identity)) + .first()) + if not user: + return None + return user.id def secret_callback(request: Request) -> str: - # TODO: Get it from the settings or elsewhere - return "secret" + """Get it from the settings or elsewhere""" + return request.registry.settings['tet.security.authentication.secret'] @pytest.fixture() @@ -62,16 +81,19 @@ def pyramid_request(pyramid_app, db_engine): 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_app(db_engine): """Fixture to create and configure a Pyramid application.""" @@ -79,6 +101,7 @@ def pyramid_app(db_engine): 'sqlalchemy.url': DB_URL, "project_prefix": "tet", "pyramid.includes": ["pyramid_tm"], + "tet.security.authentication.secret": "secret", } with tetConfigurator() as config: config.add_settings(settings) @@ -87,11 +110,11 @@ def pyramid_app(db_engine): config.include("pyramid_di") config.setup_sqlalchemy(engine=db_engine) config.set_root_factory(RootFactory) - config.include("tet.security.authentication") + config.include("tet.security.authentication", route_prefix="/api/v1/auth") config.tet_configure_authentication_token( token_model=Token, project_prefix=config.registry.settings['project_prefix'], - user_verification_callback=authentication_callback, + login_callback=login_callback, secret_callback=secret_callback, ) config.add_route('home', '/') From 107776a6ac302ae211316533803b7e3c9aecb725 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 28 Jan 2025 15:22:21 +0200 Subject: [PATCH 016/139] Update tests: - Adding more tests. - Update models. --- tests/models/accounts.py | 3 +- tests/services/test_authentication.py | 55 ++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tests/models/accounts.py b/tests/models/accounts.py index da05286..ee70070 100644 --- a/tests/models/accounts.py +++ b/tests/models/accounts.py @@ -28,7 +28,8 @@ class User(UserPasswordMixin, Base): __tablename__ = 'user' id = Column(Integer, primary_key=True) email = Column(Text, nullable=False, unique=True) - name = Column(Text, nullable=False, default='') + 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') diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py index 32021d1..88b1e01 100644 --- a/tests/services/test_authentication.py +++ b/tests/services/test_authentication.py @@ -1,4 +1,7 @@ +import json + import pytest +from jwt import InvalidSignatureError from sqlalchemy.orm import Session from webtest import TestApp @@ -18,7 +21,8 @@ def test_app(pyramid_app): @pytest.fixture() def long_term_token(pyramid_app, test_app, capture_token): - response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, status=200) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) return response.json['token'] @@ -63,7 +67,8 @@ def wrapper(*args, **kwargs): def test_login_view_should_return_long_term_token(test_app, capture_token): - response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, status=200) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = test_app.post(url=LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) assert response.status_code == 200 assert "user_id" in response.json assert "token" in response.json @@ -102,6 +107,46 @@ def test_access_token_should_work_to_access_protected_route(long_term_token, tes assert "message" in response.json assert response.json["message"] == "Hello, World!" -# Test it should store the token in the database -# Test it should fail to access the protected route without the access token -# Test it should fail to access the protected route with invalid access token + +def test_login_view_should_raise_403_when_identity_not_found_in_the_db(test_app, pyramid_request): + response = test_app.post( + url=LONG_TERM_TOKEN_ENDPOINT, + params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), + content_type="application/json", + status=403, + expect_errors=True, + ) + assert response.status_code == 403 + + +def test_it_should_store_the_token_in_the_database(capture_token, 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 = test_app.post(url=LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) + assert response.status_code == 200 + assert "user_id" in response.json + assert "token" in response.json + + # Validate the token captured by monkeypatch + assert "token" in capture_token + assert capture_token["token"] == response.json["token"] + + response_token = response.json['token'] + assert isinstance(response_token, str) + assert len(response_token) > 0 + + token = tet_token_service.retrieve_and_validate_token(response_token, project_prefix) + assert token is not None + + +def test_it_should_fail_to_access_the_protected_route_without_the_access_token(test_app): + response = 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(test_app): + headers = { + "x-access-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" + ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa"} + pytest.raises(InvalidSignatureError, test_app.get, HOME_ROUTE, headers=headers, expect_errors=True) From c0e412222124fe75c4140c57649fbfa0ffe824f2 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 28 Jan 2025 15:28:09 +0200 Subject: [PATCH 017/139] Remove un-used imports, and unnecessary comments --- tests/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b07d0d5..05ce23a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,6 @@ import logging -from functools import wraps import pytest -from pyramid.httpexceptions import HTTPForbidden from pyramid.request import Request from pyramid.security import Allow, Authenticated, Everyone, Deny from pyramid.testing import setUp, tearDown @@ -53,7 +51,6 @@ def db_session(db_engine, pyramid_request, transaction_manager): yield session -# don't actually print the logger for this callback def login_callback(request: Request) -> User.id: """This is just an example of a login callback. It should be defined by the pyramid app.""" db_session = request.find_service(Session) From 2e05d334b4d8b5239f7fd217e428a6ce6e34abbe Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 29 Jan 2025 15:39:08 +0200 Subject: [PATCH 018/139] Some update for tests: - Update requires packages in setup.py - Update README - Update the return type for the mock callback - Meaningful name for the secret_callback --- tests/README.md | 2 +- tests/conftest.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/README.md b/tests/README.md index f9b64d2..cce19f4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -2,7 +2,7 @@ Running all test suites We need dependencies for the tests ``` -pip install -e '.[test]' +pip install -e '.[dev]' ``` Create test database diff --git a/tests/conftest.py b/tests/conftest.py index 05ce23a..9d3fd5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import logging +import typing as tp import pytest from pyramid.request import Request from pyramid.security import Allow, Authenticated, Everyone, Deny @@ -51,7 +52,7 @@ def db_session(db_engine, pyramid_request, transaction_manager): yield session -def login_callback(request: Request) -> User.id: +def login_callback(request: Request) -> tp.Any: """This is just an example of a login callback. It should be defined by the pyramid app.""" db_session = request.find_service(Session) payload = request.json_body @@ -66,7 +67,7 @@ def login_callback(request: Request) -> User.id: return user.id -def secret_callback(request: Request) -> str: +def jwt_secret_callback(request: Request) -> str: """Get it from the settings or elsewhere""" return request.registry.settings['tet.security.authentication.secret'] @@ -112,7 +113,7 @@ def pyramid_app(db_engine): token_model=Token, project_prefix=config.registry.settings['project_prefix'], login_callback=login_callback, - secret_callback=secret_callback, + secret_callback=jwt_secret_callback, ) config.add_route('home', '/') config.add_view(lambda request: {'message': 'Hello, World!'}, route_name='home', renderer='json') From f9ff8386024b7850c9391efabb9b653b55a2d98a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 29 Jan 2025 16:14:20 +0200 Subject: [PATCH 019/139] Add ruff and pre-commit. --- .pre-commit-config.yaml | 18 ++------------- .ruff.toml | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 .ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dcdff27..1d3f32d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +1,7 @@ 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 - - # General file checks - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-json - - id: check-merge-conflict - - id: check-added-large-files - - id: check-case-conflict - - id: debug-statements diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..46aeb50 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,49 @@ +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] +line-length = 88 +indent-width = 4 + +[lint] +select = ["E4", "E7", "E9", "F", "B"] +ignore = ["E501"] +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" From 8fa749b917a90af62661f430c798725187082d25 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 29 Jan 2025 16:24:22 +0200 Subject: [PATCH 020/139] Add rules for ruff, and run formatting for all files. --- .ruff.toml | 2 +- src/tet/security/authentication.py | 43 +++++++++++---- tests/conftest.py | 33 +++++++----- tests/models/accounts.py | 27 +++------- tests/services/test_authentication.py | 77 ++++++++++++++++++++------- 5 files changed, 119 insertions(+), 63 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 46aeb50..26bb7c0 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -31,7 +31,7 @@ indent-width = 4 [lint] select = ["E4", "E7", "E9", "F", "B"] -ignore = ["E501"] +ignore = ["E501","F403", "B028", "F401", "F821"] unfixable = ["B"] fixable = ["ALL"] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index d482873..5549e0c 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -227,7 +227,13 @@ def __init__(self, request: Request): self.jwt_expiration_mins = self.registry.tet_auth_jwt_expiration_mins self.jwt_algorithm = self.registry.tet_auth_jwt_algorithm - def create_long_term_token(self, user_id: tp.Any, project_prefix: str, expire_timestamp=None, description=None) -> str: + def create_long_term_token( + self, + user_id: tp.Any, + project_prefix: str, + expire_timestamp=None, + description=None, + ) -> str: """ Generates a long-term token for a user with a project-specific prefix and stores it in the database. Args: @@ -275,14 +281,18 @@ def retrieve_and_validate_token(self, token: str, prefix: str) -> tp.Any: if not token.startswith(prefix): raise ValueError("Invalid token prefix") - payload_hex = token[len(prefix):] + payload_hex = token[len(prefix) :] payload = bytes.fromhex(payload_hex) token_id_bytes = payload[:8] secret = payload[8:] token_id = int.from_bytes(token_id_bytes, "little") - token_from_db = self.session.query(self.token_model).filter(self.token_model.id == token_id).one_or_none() + token_from_db = ( + self.session.query(self.token_model) + .filter(self.token_model.id == token_id) + .one_or_none() + ) if not token_from_db: raise ValueError("Token not found") @@ -308,7 +318,11 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: "user_id": user_id, "exp": datetime.now(UTC) + timedelta(minutes=self.jwt_expiration_mins), } - return jwt.encode(payload, self.registry.tet_auth_secret_callback(self.request), algorithm=self.jwt_algorithm) + return jwt.encode( + payload, + self.registry.tet_auth_secret_callback(self.request), + algorithm=self.jwt_algorithm, + ) def verify_jwt(self, token: str) -> dict | None: """ @@ -322,8 +336,11 @@ def verify_jwt(self, token: str) -> dict | None: - ``None`` if the JWT is invalid or expired """ try: - payload = jwt.decode(token, self.registry.tet_auth_secret_callback(self.request), - algorithms=[self.jwt_algorithm]) + payload = jwt.decode( + token, + self.registry.tet_auth_secret_callback(self.request), + algorithms=[self.jwt_algorithm], + ) return payload except jwt.ExpiredSignatureError: return None @@ -362,10 +379,12 @@ def jwt_token_view(self) -> str: token = self.request.headers.get(self.long_term_token_header) try: - token_from_db = self.token_service.retrieve_and_validate_token(token, self.project_prefix) + token_from_db = self.token_service.retrieve_and_validate_token( + token, self.project_prefix + ) except ValueError as e: logger.exception(f"Error validating token: {e}") - raise HTTPUnauthorized() + raise HTTPUnauthorized() from e user_id = getattr(token_from_db, self.token_service.user_id_column) @@ -400,10 +419,14 @@ def includeme(config: Configurator): permission=NO_PERMISSION_REQUIRED, ) - config.add_directive("tet_configure_authentication_token", tet_configure_authentication_token) + config.add_directive( + "tet_configure_authentication_token", tet_configure_authentication_token + ) config.include("pyramid_di") - config.register_service_factory(lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface) + config.register_service_factory( + lambda ctx, req: TetTokenService(request=req), TetTokenService, Interface + ) config.set_default_permission("view") config.set_authorization_policy(ACLAuthorizationPolicy()) diff --git a/tests/conftest.py b/tests/conftest.py index 9d3fd5e..6635875 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,11 +57,12 @@ def login_callback(request: Request) -> tp.Any: db_session = request.find_service(Session) payload = request.json_body # user_identity here could be an email, or username - user_identity = payload['user_identity'] - user = (db_session.query(User) - .filter(or_(User.email == user_identity, - User.name == user_identity)) - .first()) + user_identity = payload["user_identity"] + user = ( + db_session.query(User) + .filter(or_(User.email == user_identity, User.name == user_identity)) + .first() + ) if not user: return None return user.id @@ -69,7 +70,7 @@ def login_callback(request: Request) -> tp.Any: def jwt_secret_callback(request: Request) -> str: """Get it from the settings or elsewhere""" - return request.registry.settings['tet.security.authentication.secret'] + return request.registry.settings["tet.security.authentication.secret"] @pytest.fixture() @@ -82,10 +83,10 @@ def pyramid_request(pyramid_app, db_engine): class RootFactory(object): __acl__ = [ - (Allow, Authenticated, 'view'), - (Allow, 'group:editors', 'edit'), - (Allow, Everyone, 'login'), - (Deny, Everyone, 'delete') + (Allow, Authenticated, "view"), + (Allow, "group:editors", "edit"), + (Allow, Everyone, "login"), + (Deny, Everyone, "delete"), ] def __init__(self, request): @@ -96,7 +97,7 @@ def __init__(self, request): def pyramid_app(db_engine): """Fixture to create and configure a Pyramid application.""" settings = { - 'sqlalchemy.url': DB_URL, + "sqlalchemy.url": DB_URL, "project_prefix": "tet", "pyramid.includes": ["pyramid_tm"], "tet.security.authentication.secret": "secret", @@ -111,11 +112,15 @@ def pyramid_app(db_engine): config.include("tet.security.authentication", route_prefix="/api/v1/auth") config.tet_configure_authentication_token( token_model=Token, - project_prefix=config.registry.settings['project_prefix'], + project_prefix=config.registry.settings["project_prefix"], login_callback=login_callback, secret_callback=jwt_secret_callback, ) - config.add_route('home', '/') - config.add_view(lambda request: {'message': 'Hello, World!'}, route_name='home', renderer='json') + config.add_route("home", "/") + config.add_view( + lambda request: {"message": "Hello, World!"}, + route_name="home", + renderer="json", + ) app = config.make_wsgi_app() yield app diff --git a/tests/models/accounts.py b/tests/models/accounts.py index ee70070..d1a5d33 100644 --- a/tests/models/accounts.py +++ b/tests/models/accounts.py @@ -1,13 +1,7 @@ from tet.security.authentication import TokenMixin from tet.sqlalchemy.password import UserPasswordMixin -from sqlalchemy import ( - Column, - Integer, - Text, - Boolean, - ForeignKey -) +from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.schema import MetaData @@ -25,23 +19,18 @@ class User(UserPasswordMixin, Base): - __tablename__ = 'user' + __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') + 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') + __tablename__ = "token" + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + user = orm.relationship(User, backref="tokens") -__all__ = [ - "User", - "Token", - "Base", - "metadata" -] +__all__ = ["User", "Token", "Base", "metadata"] diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py index 88b1e01..2a134e1 100644 --- a/tests/services/test_authentication.py +++ b/tests/services/test_authentication.py @@ -5,7 +5,6 @@ from sqlalchemy.orm import Session from webtest import TestApp -from tests.conftest import pyramid_app from tests.models.accounts import User from tet.security.authentication import TetTokenService @@ -21,10 +20,17 @@ def test_app(pyramid_app): @pytest.fixture() def long_term_token(pyramid_app, test_app, capture_token): - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = test_app.post(LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) + data = json.dumps( + {"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"} + ) + response = test_app.post( + LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) - return response.json['token'] + return response.json["token"] def create_user(db_session: Session): @@ -67,8 +73,15 @@ def wrapper(*args, **kwargs): def test_login_view_should_return_long_term_token(test_app, capture_token): - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = test_app.post(url=LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) + data = json.dumps( + {"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"} + ) + response = test_app.post( + url=LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) assert response.status_code == 200 assert "user_id" in response.json assert "token" in response.json @@ -77,7 +90,7 @@ def test_login_view_should_return_long_term_token(test_app, capture_token): assert "token" in capture_token assert capture_token["token"] == response.json["token"] - token = response.json['token'] + token = response.json["token"] assert isinstance(token, str) assert len(token) > 0 @@ -108,10 +121,14 @@ def test_access_token_should_work_to_access_protected_route(long_term_token, tes assert response.json["message"] == "Hello, World!" -def test_login_view_should_raise_403_when_identity_not_found_in_the_db(test_app, pyramid_request): +def test_login_view_should_raise_403_when_identity_not_found_in_the_db( + test_app, pyramid_request +): response = test_app.post( url=LONG_TERM_TOKEN_ENDPOINT, - params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), + params=json.dumps( + {"user_identity": "invalid_user", "password": "wrong_password"} + ), content_type="application/json", status=403, expect_errors=True, @@ -119,11 +136,20 @@ def test_login_view_should_raise_403_when_identity_not_found_in_the_db(test_app, assert response.status_code == 403 -def test_it_should_store_the_token_in_the_database(capture_token, test_app, pyramid_request): - project_prefix = pyramid_request.registry.settings['project_prefix'] +def test_it_should_store_the_token_in_the_database( + capture_token, 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 = test_app.post(url=LONG_TERM_TOKEN_ENDPOINT, params=data, content_type="application/json", status=200) + data = json.dumps( + {"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"} + ) + response = test_app.post( + url=LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) assert response.status_code == 200 assert "user_id" in response.json assert "token" in response.json @@ -132,21 +158,34 @@ def test_it_should_store_the_token_in_the_database(capture_token, test_app, pyra assert "token" in capture_token assert capture_token["token"] == response.json["token"] - response_token = response.json['token'] + response_token = response.json["token"] assert isinstance(response_token, str) assert len(response_token) > 0 - token = tet_token_service.retrieve_and_validate_token(response_token, project_prefix) + token = tet_token_service.retrieve_and_validate_token( + response_token, project_prefix + ) assert token is not None -def test_it_should_fail_to_access_the_protected_route_without_the_access_token(test_app): +def test_it_should_fail_to_access_the_protected_route_without_the_access_token( + test_app, +): response = 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(test_app): +def test_it_should_fail_to_access_the_protected_route_with_invalid_access_token( + test_app, +): headers = { "x-access-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" - ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa"} - pytest.raises(InvalidSignatureError, test_app.get, HOME_ROUTE, headers=headers, expect_errors=True) + ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" + } + pytest.raises( + InvalidSignatureError, + test_app.get, + HOME_ROUTE, + headers=headers, + expect_errors=True, + ) From 845a0ed3eecae0afdd3d2c0a3920fd52c0a6d928 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 30 Jan 2025 12:09:08 +0200 Subject: [PATCH 021/139] Minor update: - Rename Ruff configuration - Reverse the Ruff formatting --- .ruff.toml => ruff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .ruff.toml => ruff.toml (92%) diff --git a/.ruff.toml b/ruff.toml similarity index 92% rename from .ruff.toml rename to ruff.toml index 26bb7c0..e5d80fb 100644 --- a/.ruff.toml +++ b/ruff.toml @@ -31,7 +31,7 @@ indent-width = 4 [lint] select = ["E4", "E7", "E9", "F", "B"] -ignore = ["E501","F403", "B028", "F401", "F821"] +ignore = ["E501","F403", "B028", "F401", "F821", "E741", "F405", "F522"] unfixable = ["B"] fixable = ["ALL"] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" From aebc7faf5d46edccc263a30ce90dd12730b329a2 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 30 Jan 2025 12:48:05 +0200 Subject: [PATCH 022/139] Authentication: Rename methods and params --- src/tet/security/authentication.py | 44 +++++++++++++++--------------- tests/conftest.py | 8 +++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 5549e0c..645ce35 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -50,14 +50,14 @@ def __call__(self, request: Request) -> str: pass -def tet_configure_authentication_token( +def tet_configure_token_authentication( config: Configurator, *, - token_model: tp.Any, + long_term_token_model: tp.Any, project_prefix: str, user_id_column: str = DEFAULT_USER_ID_COLUMN, login_callback: ILoginCallback, - secret_callback: ISecretCallback, + jwk_resolver: ISecretCallback, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, access_token_header: str = DEFAULT_ACCESS_TOKEN, @@ -78,13 +78,13 @@ def tet_configure_authentication_token( .. code-block:: python from pyramid.config import Configurator - from myproject.auth import tet_configure_authentication_token + from myproject.auth import tet_configure_token_authentication def includeme(config: Configurator): # Register the custom directive config.add_directive( - 'tet_configure_authentication_token', - tet_configure_authentication_token + 'tet_configure_token_authentication', + tet_configure_token_authentication ) 2. **Use the directive** somewhere after including it: @@ -95,11 +95,11 @@ def main(global_config, **settings): config = Configurator(settings=settings) config.include('myproject') # calls includeme(...) - config.tet_configure_authentication_token( - token_model=MyTokenModel, + config.tet_configure_token_authentication( + long_term_token_model=MyTokenModel, project_prefix='my_project', login_callback=verify_user, - secret_callback=get_secret, + jwk_resolver=get_secret, jwt_algorithm='HS256', jwt_token_expiration_mins=120 ) @@ -114,17 +114,17 @@ def main(global_config, **settings): @view_config(route_name='home') def home_view(request): - token_model = request.registry.tet_auth_token_model + long_term_token_model = request.registry.tet_auth_long_term_token_model prefix = request.registry.tet_auth_project_prefix # ... do something with these values ... Args: config: The current Pyramid :class:`pyramid.config.Configurator` instance. - token_model: A token model class or object representing user tokens. + long_term_token_model: A token model class or object representing user tokens. project_prefix: A project-specific prefix (could be used for namespacing). user_id_column: Column name or attribute for user ID in the token model. Defaults to ``"user_id"``. login_callback: A callable to verify user credentials/status from the database. - secret_callback: A callable that returns a secret key or keys for token signing. + jwk_resolver: A callable that returns a secret key or keys for token signing. jwt_algorithm: The JWT algorithm to use (default: ``"HS256"``). jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). access_token_header: The header name for the access token (default: ``"X-Access-Token"``). @@ -132,18 +132,18 @@ def home_view(request): """ def register(): - config.registry.tet_auth_token_model = token_model + config.registry.tet_auth_long_term_token_model = long_term_token_model config.registry.tet_auth_project_prefix = project_prefix config.registry.tet_auth_user_id_column = user_id_column config.registry.tet_auth_access_token_header = access_token_header config.registry.tet_auth_long_term_token_header = long_term_token_header config.registry.tet_auth_login_callback = login_callback - config.registry.tet_auth_secret_callback = secret_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.action(discriminator="tet_configure_authentication_token", callable=register) + config.action(discriminator="tet_configure_token_authentication", callable=register) @implementer(ISecurityPolicy) @@ -222,7 +222,7 @@ class TetTokenService(RequestScopedBaseService): def __init__(self, request: Request): super().__init__(request=request) - self.token_model = self.registry.tet_auth_token_model + self.long_term_token_model = self.registry.tet_auth_long_term_token_model self.user_id_column = self.registry.tet_auth_user_id_column self.jwt_expiration_mins = self.registry.tet_auth_jwt_expiration_mins self.jwt_algorithm = self.registry.tet_auth_jwt_algorithm @@ -248,7 +248,7 @@ def create_long_term_token( secret = secrets.token_bytes(32) hashed_secret = hashlib.sha256(secret).digest() - stored_token = self.token_model( + stored_token = self.long_term_token_model( secret_hash=hashed_secret.hex(), created_at=datetime.now(UTC), expires_at=expire_timestamp, @@ -289,8 +289,8 @@ def retrieve_and_validate_token(self, token: str, prefix: str) -> tp.Any: token_id = int.from_bytes(token_id_bytes, "little") token_from_db = ( - self.session.query(self.token_model) - .filter(self.token_model.id == token_id) + self.session.query(self.long_term_token_model) + .filter(self.long_term_token_model.id == token_id) .one_or_none() ) @@ -320,7 +320,7 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: } return jwt.encode( payload, - self.registry.tet_auth_secret_callback(self.request), + self.registry.tet_auth_jwk_resolver(self.request), algorithm=self.jwt_algorithm, ) @@ -338,7 +338,7 @@ def verify_jwt(self, token: str) -> dict | None: try: payload = jwt.decode( token, - self.registry.tet_auth_secret_callback(self.request), + self.registry.tet_auth_jwk_resolver(self.request), algorithms=[self.jwt_algorithm], ) return payload @@ -420,7 +420,7 @@ def includeme(config: Configurator): ) config.add_directive( - "tet_configure_authentication_token", tet_configure_authentication_token + "tet_configure_token_authentication", tet_configure_token_authentication ) config.include("pyramid_di") diff --git a/tests/conftest.py b/tests/conftest.py index 6635875..071bec9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,7 +68,7 @@ def login_callback(request: Request) -> tp.Any: return user.id -def jwt_secret_callback(request: Request) -> str: +def jwk_resolver(request: Request) -> str: """Get it from the settings or elsewhere""" return request.registry.settings["tet.security.authentication.secret"] @@ -110,11 +110,11 @@ def pyramid_app(db_engine): config.setup_sqlalchemy(engine=db_engine) config.set_root_factory(RootFactory) config.include("tet.security.authentication", route_prefix="/api/v1/auth") - config.tet_configure_authentication_token( - token_model=Token, + config.tet_configure_token_authentication( + long_term_token_model=Token, project_prefix=config.registry.settings["project_prefix"], login_callback=login_callback, - secret_callback=jwt_secret_callback, + jwk_resolver=jwk_resolver, ) config.add_route("home", "/") config.add_view( From d3c2b0c74f2ddd942e612d1904cae0c2e1a99175 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 31 Jan 2025 14:14:51 +0200 Subject: [PATCH 023/139] Update authentication: - Increase the line length for Ruff and format it. - Add a dataclass for the default registered claims and include them in the declaratives. - Update the payload claims for JWT encoding. --- ruff.toml | 2 +- src/tet/security/authentication.py | 111 +++++++++++++++++++++----- src/tet/security/authorization.py | 4 +- tests/services/test_authentication.py | 28 ++----- 4 files changed, 102 insertions(+), 43 deletions(-) diff --git a/ruff.toml b/ruff.toml index e5d80fb..254e922 100644 --- a/ruff.toml +++ b/ruff.toml @@ -26,7 +26,7 @@ exclude = [ "site-packages", "venv", ] -line-length = 88 +line-length = 100 indent-width = 4 [lint] diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 645ce35..e5a7025 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,3 +1,4 @@ +import dataclasses import hashlib import secrets import typing as tp @@ -18,16 +19,80 @@ from zope.interface import Interface, implementer logger = logging.getLogger(__name__) -__all__ = [ - "TokenAuthenticationPolicy", - "TokenMixin", -] +__all__ = ["TokenAuthenticationPolicy", "TokenMixin", "JWTRegisteredClaims"] + + +@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.utcnow() + timedelta(hours=1), + iat=datetime.utcnow(), + 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) -> 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} + DEFAULT_JWT_ALGORITHM = "HS256" DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 DEFAULT_USER_ID_COLUMN = "user_id" DEFAULT_LONG_TERM_TOKEN = "X-Long-Token" DEFAULT_ACCESS_TOKEN = "X-Access-Token" +DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() class ILoginCallback(tp.Protocol): @@ -46,7 +111,7 @@ class ISecretCallback(tp.Protocol): **Returns:** The secret key for JWT """ - def __call__(self, request: Request) -> str: + def __call__(self, request: Request) -> tp.Union[str, dict]: pass @@ -55,13 +120,14 @@ def tet_configure_token_authentication( *, long_term_token_model: tp.Any, project_prefix: str, - user_id_column: str = DEFAULT_USER_ID_COLUMN, 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, access_token_header: str = DEFAULT_ACCESS_TOKEN, long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN, + default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, ) -> None: """ Configure token-based authentication for a Pyramid application (with conflict detection). @@ -129,6 +195,7 @@ def home_view(request): jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). access_token_header: The header name for the access token (default: ``"X-Access-Token"``). long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). + default_claims: Default JWT registered claims to include in the token payload. """ def register(): @@ -137,6 +204,7 @@ def register(): config.registry.tet_auth_user_id_column = user_id_column config.registry.tet_auth_access_token_header = access_token_header config.registry.tet_auth_long_term_token_header = long_term_token_header + config.registry.tet_auth_default_claims = default_claims config.registry.tet_auth_login_callback = login_callback config.registry.tet_auth_jwk_resolver = jwk_resolver @@ -222,10 +290,11 @@ class TetTokenService(RequestScopedBaseService): def __init__(self, request: Request): super().__init__(request=request) - self.long_term_token_model = self.registry.tet_auth_long_term_token_model - self.user_id_column = self.registry.tet_auth_user_id_column - self.jwt_expiration_mins = self.registry.tet_auth_jwt_expiration_mins - self.jwt_algorithm = self.registry.tet_auth_jwt_algorithm + self.long_term_token_model: tp.Any = self.registry.tet_auth_long_term_token_model + 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.default_claims: JWTRegisteredClaims = self.registry.tet_auth_default_claims def create_long_term_token( self, @@ -314,12 +383,16 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: Returns: The encoded JWT as a string. """ - payload = { - "user_id": user_id, - "exp": datetime.now(UTC) + timedelta(minutes=self.jwt_expiration_mins), - } + # TODO: In the next update, we can add more encoding options here, such as headers, json_encoder. + if not user_id: + raise ValueError("User ID is required") + + payload = self.default_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, + payload.to_dict(), self.registry.tet_auth_jwk_resolver(self.request), algorithm=self.jwt_algorithm, ) @@ -340,6 +413,10 @@ def verify_jwt(self, token: str) -> dict | None: token, self.registry.tet_auth_jwk_resolver(self.request), algorithms=[self.jwt_algorithm], + leeway=self.default_claims.leeway, + audience=self.default_claims.aud, + subject=self.default_claims.sub, + issuer=self.default_claims.iss, ) return payload except jwt.ExpiredSignatureError: @@ -419,9 +496,7 @@ def includeme(config: Configurator): permission=NO_PERMISSION_REQUIRED, ) - config.add_directive( - "tet_configure_token_authentication", tet_configure_token_authentication - ) + config.add_directive("tet_configure_token_authentication", tet_configure_token_authentication) config.include("pyramid_di") config.register_service_factory( diff --git a/src/tet/security/authorization.py b/src/tet/security/authorization.py index b85ef33..d18fbac 100644 --- a/src/tet/security/authorization.py +++ b/src/tet/security/authorization.py @@ -87,9 +87,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): diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py index 2a134e1..a8de4cc 100644 --- a/tests/services/test_authentication.py +++ b/tests/services/test_authentication.py @@ -20,9 +20,7 @@ def test_app(pyramid_app): @pytest.fixture() def long_term_token(pyramid_app, test_app, capture_token): - data = json.dumps( - {"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"} - ) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = test_app.post( LONG_TERM_TOKEN_ENDPOINT, params=data, @@ -73,9 +71,7 @@ def wrapper(*args, **kwargs): def test_login_view_should_return_long_term_token(test_app, capture_token): - data = json.dumps( - {"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"} - ) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = test_app.post( url=LONG_TERM_TOKEN_ENDPOINT, params=data, @@ -121,14 +117,10 @@ def test_access_token_should_work_to_access_protected_route(long_term_token, tes assert response.json["message"] == "Hello, World!" -def test_login_view_should_raise_403_when_identity_not_found_in_the_db( - test_app, pyramid_request -): +def test_login_view_should_raise_403_when_identity_not_found_in_the_db(test_app, pyramid_request): response = test_app.post( url=LONG_TERM_TOKEN_ENDPOINT, - params=json.dumps( - {"user_identity": "invalid_user", "password": "wrong_password"} - ), + params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), content_type="application/json", status=403, expect_errors=True, @@ -136,14 +128,10 @@ def test_login_view_should_raise_403_when_identity_not_found_in_the_db( assert response.status_code == 403 -def test_it_should_store_the_token_in_the_database( - capture_token, test_app, pyramid_request -): +def test_it_should_store_the_token_in_the_database(capture_token, 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"} - ) + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = test_app.post( url=LONG_TERM_TOKEN_ENDPOINT, params=data, @@ -162,9 +150,7 @@ def test_it_should_store_the_token_in_the_database( assert isinstance(response_token, str) assert len(response_token) > 0 - token = tet_token_service.retrieve_and_validate_token( - response_token, project_prefix - ) + token = tet_token_service.retrieve_and_validate_token(response_token, project_prefix) assert token is not None From 9e4077db8423c7652af0c4acfef1f69d7c949b64 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 31 Jan 2025 14:25:35 +0200 Subject: [PATCH 024/139] Rename tet_configure_token_authentication. --- src/tet/security/authentication.py | 14 +++++++------- tests/conftest.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index e5a7025..146c181 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -115,7 +115,7 @@ def __call__(self, request: Request) -> tp.Union[str, dict]: pass -def tet_configure_token_authentication( +def set_token_authentication( config: Configurator, *, long_term_token_model: tp.Any, @@ -144,13 +144,13 @@ def tet_configure_token_authentication( .. code-block:: python from pyramid.config import Configurator - from myproject.auth import tet_configure_token_authentication + from myproject.auth import set_token_authentication def includeme(config: Configurator): # Register the custom directive config.add_directive( - 'tet_configure_token_authentication', - tet_configure_token_authentication + 'set_token_authentication', + set_token_authentication ) 2. **Use the directive** somewhere after including it: @@ -161,7 +161,7 @@ def main(global_config, **settings): config = Configurator(settings=settings) config.include('myproject') # calls includeme(...) - config.tet_configure_token_authentication( + config.set_token_authentication( long_term_token_model=MyTokenModel, project_prefix='my_project', login_callback=verify_user, @@ -211,7 +211,7 @@ def register(): config.registry.tet_auth_jwt_algorithm = jwt_algorithm config.registry.tet_auth_jwt_expiration_mins = jwt_token_expiration_mins - config.action(discriminator="tet_configure_token_authentication", callable=register) + config.action(discriminator="set_token_authentication", callable=register) @implementer(ISecurityPolicy) @@ -496,7 +496,7 @@ def includeme(config: Configurator): permission=NO_PERMISSION_REQUIRED, ) - config.add_directive("tet_configure_token_authentication", tet_configure_token_authentication) + config.add_directive("set_token_authentication", set_token_authentication) config.include("pyramid_di") config.register_service_factory( diff --git a/tests/conftest.py b/tests/conftest.py index 071bec9..38e28bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -110,7 +110,7 @@ def pyramid_app(db_engine): config.setup_sqlalchemy(engine=db_engine) config.set_root_factory(RootFactory) config.include("tet.security.authentication", route_prefix="/api/v1/auth") - config.tet_configure_token_authentication( + config.set_token_authentication( long_term_token_model=Token, project_prefix=config.registry.settings["project_prefix"], login_callback=login_callback, From 0a6b42fe5c4b952dc98526000c5b83acc1fbf3e9 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 31 Jan 2025 16:33:53 +0200 Subject: [PATCH 025/139] Fix missing psycopg2 dependency error. Sqlalchemy required. --- rebase-helper.sh | 93 +++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/rebase-helper.sh b/rebase-helper.sh index 141894d..9bde668 100755 --- a/rebase-helper.sh +++ b/rebase-helper.sh @@ -1,35 +1,25 @@ #!/bin/bash # Auto-resolve common conflicts during src-layout rebase -# Stops on conflicts it can't handle - set -e -MAX_ITERATIONS=200 +MAX_ITERATIONS=300 i=0 while [ $i -lt $MAX_ITERATIONS ]; do i=$((i + 1)) - # Check if rebase is still in progress if ! [ -d .git/rebase-merge ] && ! [ -d .git/rebase-apply ]; then echo "Rebase complete!" + rm -f rebase-helper.sh exit 0 fi - # Get current step info - if [ -d .git/rebase-merge ]; then - current=$(cat .git/rebase-merge/msgnum 2>/dev/null || echo "?") - total=$(cat .git/rebase-merge/end 2>/dev/null || echo "?") - else - current="?" - total="?" - fi + current=$(cat .git/rebase-merge/msgnum 2>/dev/null || echo "?") + total=$(cat .git/rebase-merge/end 2>/dev/null || echo "?") - # Get conflicting files conflicts=$(git status --short | grep -E "^(UU|UA|DU|AU|AA)" || true) if [ -z "$conflicts" ]; then - # No conflicts, just unmerged paths — add all and continue git add -A if ! GIT_EDITOR=true git rebase --continue 2>/dev/null; then continue @@ -37,7 +27,7 @@ while [ $i -lt $MAX_ITERATIONS ]; do continue fi - echo "[$current/$total] Conflicts: $conflicts" + echo "[$current/$total] Resolving..." resolved=true @@ -47,73 +37,62 @@ while [ $i -lt $MAX_ITERATIONS ]; do case "$status" in "DU") - # File deleted on HEAD (master), modified by our commit - # setup.py was deleted in src-layout migration - if [ "$file" = "setup.py" ]; then - git rm -f setup.py 2>/dev/null || true - else - echo "MANUAL: DU conflict on $file" - resolved=false - fi - ;; - "UA") - # File added by our commit in a renamed directory - # Git already suggests the right location, just add it - git add "$file" 2>/dev/null || true + # File deleted on HEAD, modified by commit (setup.py mostly) + git rm -f "$file" 2>/dev/null || true ;; - "AU") - # Added on HEAD, unmerged by us + "UA"|"AU") git add "$file" 2>/dev/null || true ;; "AA") - # Both added — take ours (the branch version) - if git checkout --theirs "$file" 2>/dev/null; then - git add "$file" - else - echo "MANUAL: AA conflict on $file" - resolved=false - fi + # Both added — take ours (branch) + git checkout --theirs "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: AA on $file"; resolved=false; } ;; "UU") - # Both modified — check if it's a simple case markers=$(grep -c "<<<<<<" "$file" 2>/dev/null || echo 0) if [ "$markers" -eq 0 ]; then git add "$file" - else - # Try taking ours for known files - case "$file" in - tests/conftest.py|tests/*) - git checkout --theirs "$file" 2>/dev/null && git add "$file" || { echo "MANUAL: UU on $file"; resolved=false; } - ;; - *) - echo "MANUAL: UU conflict ($markers markers) on $file" - resolved=false - ;; - esac + 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: Unknown status $status on $file" + echo "MANUAL: $status on $file" resolved=false ;; esac done <<< "$conflicts" if [ "$resolved" = false ]; then - echo "Stopping — manual resolution needed at step $current/$total" + echo "Stopping at step $current/$total — manual resolution needed" + git status --short exit 1 fi git add -A - if ! GIT_EDITOR=true git rebase --continue 2>/dev/null; then - # rebase --continue might fail if there are no changes (empty commit) - # Try skip in that case - if git diff --cached --quiet 2>/dev/null; then + + # 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 - fi + } done -echo "Hit max iterations ($MAX_ITERATIONS)" +echo "Hit max iterations" exit 1 From 4ac1fe5c7270041551497605e753aed7b7312c51 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 4 Feb 2025 11:09:35 +0200 Subject: [PATCH 026/139] Update CI: - Add postgresql for testing - Update python matrix - Ensure datetime with UTC work in python 3.9+ --- .github/workflows/ci.yml | 12 +++++++++++- src/tet/security/authentication.py | 3 ++- tests/conftest.py | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03afac7..8745b54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,20 @@ on: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:13 + 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" diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 146c181..caecee9 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -2,7 +2,7 @@ import hashlib import secrets import typing as tp -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone import jwt import logging @@ -93,6 +93,7 @@ def to_dict(self) -> dict[str, tp.Any]: DEFAULT_LONG_TERM_TOKEN = "X-Long-Token" DEFAULT_ACCESS_TOKEN = "X-Access-Token" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() +UTC = timezone.utc class ILoginCallback(tp.Protocol): diff --git a/tests/conftest.py b/tests/conftest.py index 38e28bc..a3e5cbd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ from tet.config import Configurator as tetConfigurator DB_NAME = "test_tet" -DB_URL = f"postgresql:///{DB_NAME}" +DB_URL = f"postgresql+psycopg2://test_tet:test_tet@localhost:5432/{DB_NAME}" logger = logging.getLogger(__name__) From d88696768d743812ed18e9b8f8f654f5c1d4cc50 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 4 Feb 2025 11:34:07 +0200 Subject: [PATCH 027/139] Make sure return type work in python 3.9+. --- src/tet/security/authentication.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index caecee9..649ce55 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -74,7 +74,7 @@ class JWTRegisteredClaims: jti: str = None leeway: int = 0 - def to_dict(self) -> dict[str, tp.Any]: + def to_dict(self) -> tp.Dict[str, tp.Any]: """ Converts the JWTRegisteredClaims instance into a dictionary. @@ -103,7 +103,7 @@ class ILoginCallback(tp.Protocol): **Returns:** ``user_id`` """ - def __call__(self, request: Request) -> tp.Any | None: + def __call__(self, request: Request) -> tp.Optional[tp.Any]: pass @@ -220,7 +220,7 @@ class TokenAuthenticationPolicy: def __init__(self): self.acl = ACLHelper() - def authenticated_userid(self, request: Request) -> int | None: + 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. @@ -242,7 +242,7 @@ def permits(self, request, context, permission): principals = self.effective_principals(request) return self.acl.permits(context, principals, permission) - def effective_principals(self, request) -> list[str]: + 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). @@ -255,7 +255,7 @@ def effective_principals(self, request) -> list[str]: principals.extend([f"user:{user_id}", Authenticated]) return principals - def forget(self, request) -> list[tuple[str, str]]: + def forget(self, request) -> tp.List[tuple[str, str]]: """ This method does not need to be implemented for header-based authentication. """ @@ -398,7 +398,7 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: algorithm=self.jwt_algorithm, ) - def verify_jwt(self, token: str) -> dict | None: + 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. @@ -435,7 +435,7 @@ def __init__(self, request: Request): self.access_token_header = self.registry.tet_auth_access_token_header self.project_prefix = self.registry.tet_auth_project_prefix - def login_view(self) -> dict[str, tp.Any] | HTTPForbidden: + def login_view(self) -> tp.Dict[str, tp.Any] | HTTPForbidden: login_callback = self.registry.tet_auth_login_callback user_id = login_callback(self.request) From ad8c1adc0d4b7b80dc712efbe05e163ee1c41229 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 4 Feb 2025 11:47:09 +0200 Subject: [PATCH 028/139] Update return type which also supported by python version 3.9 --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 649ce55..b514f20 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -435,7 +435,7 @@ def __init__(self, request: Request): self.access_token_header = self.registry.tet_auth_access_token_header self.project_prefix = self.registry.tet_auth_project_prefix - def login_view(self) -> tp.Dict[str, tp.Any] | HTTPForbidden: + def login_view(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden]: login_callback = self.registry.tet_auth_login_callback user_id = login_callback(self.request) From 15e9a47b52b5063b4f54c5d4b9aca4f62a5334e4 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 12 Feb 2025 17:35:51 +0200 Subject: [PATCH 029/139] Update authentication: - Add more tests for the app that applied JWTCookieAuthenticationPolicy - Add collection hooks to ignore test items if it does not match the required condition - Add JWTCookieAuthenticationPolicy - Automatically select the login view base on the security policy during the setup stage - Ability to set cookie name of the refresh token and access token --- src/tet/security/authentication.py | 274 ++++++++++++------ tests/conftest.py | 61 +++- tests/services/security/conftest.py | 39 +++ .../services/security/test_authentication.py | 238 +++++++++++++++ tests/services/test_authentication.py | 177 ----------- 5 files changed, 514 insertions(+), 275 deletions(-) create mode 100644 tests/services/security/conftest.py create mode 100644 tests/services/security/test_authentication.py delete mode 100644 tests/services/test_authentication.py diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index b514f20..eac5c34 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,25 +1,30 @@ import dataclasses import hashlib +import logging import secrets import typing as tp from datetime import datetime, timedelta, timezone import jwt -import logging - -from pyramid.authorization import ACLAuthorizationPolicy, ACLHelper +from pyramid.authentication import CallbackAuthenticationPolicy +from pyramid.authorization import ACLHelper from pyramid.config import Configurator from pyramid.httpexceptions import HTTPForbidden, HTTPUnauthorized +from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request, Response from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated -from pyramid.interfaces import ISecurityPolicy from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy.orm import Session from zope.interface import Interface, implementer logger = logging.getLogger(__name__) -__all__ = ["TokenAuthenticationPolicy", "TokenMixin", "JWTRegisteredClaims"] +__all__ = [ + "TokenAuthenticationPolicy", + "JWTCookieAuthenticationPolicy", + "TokenMixin", + "JWTRegisteredClaims", +] @dataclasses.dataclass @@ -87,12 +92,107 @@ def to_dict(self) -> tp.Dict[str, tp.Any]: return {k: v for k, v in dataclasses.asdict(self).items() if v is not None} +@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. + """ + token_service: TetTokenService = request.find_service(TetTokenService) + jwt_token = request.headers.get(request.registry.tet_auth_access_token_header) + + if not jwt_token: + return None + + payload = token_service.verify_jwt(jwt_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 [] + + +@implementer(ISecurityPolicy) +class JWTCookieAuthenticationPolicy(TokenAuthenticationPolicy): + """ + A Pyramid security policy that authenticates users via JWT tokens stored in cookies. + + All methods in this class are only invoked if the view has a `permission` set in `@view_config()`, + ensuring authentication and authorization checks are enforced before access is granted. + + This policy retrieves JWT tokens from cookies instead of headers. + """ + + def __init__(self): + super().__init__() + + def authenticated_userid(self, request: Request) -> tp.Optional[int]: + token_service: TetTokenService = request.find_service(TetTokenService) + access_token_cookie_name = request.registry.tet_auth_access_token_cookie_name + jwt_token = request.cookies.get(access_token_cookie_name) + + if not jwt_token: + return None + + payload = token_service.verify_jwt(jwt_token) + return payload.get("user_id") if payload else None + + DEFAULT_JWT_ALGORITHM = "HS256" DEFAULT_JWT_TOKEN_EXPIRATION_MINS = 15 DEFAULT_USER_ID_COLUMN = "user_id" -DEFAULT_LONG_TERM_TOKEN = "X-Long-Token" -DEFAULT_ACCESS_TOKEN = "X-Access-Token" +DEFAULT_LONG_TERM_TOKEN_NAME = "X-Long-Token" +DEFAULT_ACCESS_TOKEN_NAME = "X-Access-Token" +DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "access-token" +DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "refresh-token" + +DEFAULT_LOGIN_VIEW = "login" +COOKIE_LOGIN_VIEW = "cookie_login" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() +DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() UTC = timezone.utc @@ -126,9 +226,14 @@ def set_token_authentication( user_id_column: str = DEFAULT_USER_ID_COLUMN, jwt_algorithm: str = DEFAULT_JWT_ALGORITHM, jwt_token_expiration_mins: int = DEFAULT_JWT_TOKEN_EXPIRATION_MINS, - access_token_header: str = DEFAULT_ACCESS_TOKEN, - long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN, + access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, + long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN_NAME, + access_token_cookie_name: str = DEFAULT_ACCESS_TOKEN_COOKIE_NAME, + long_term_token_cookie_name: str = DEFAULT_REFRESH_TOKEN_COOKIE_NAME, default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, + security_policy: tp.Optional[ + tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] + ] = DEFAULT_SECURITY_POLICY, ) -> None: """ Configure token-based authentication for a Pyramid application (with conflict detection). @@ -197,6 +302,7 @@ def home_view(request): access_token_header: The header name for the access token (default: ``"X-Access-Token"``). long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). default_claims: Default JWT registered claims to include in the token payload. + security_policy: A custom security policy to use for token authentication. """ def register(): @@ -205,6 +311,8 @@ def register(): config.registry.tet_auth_user_id_column = user_id_column config.registry.tet_auth_access_token_header = access_token_header config.registry.tet_auth_long_term_token_header = long_term_token_header + config.registry.tet_auth_access_token_cookie_name = access_token_cookie_name + config.registry.tet_auth_long_term_token_cookie_name = long_term_token_cookie_name config.registry.tet_auth_default_claims = default_claims config.registry.tet_auth_login_callback = login_callback @@ -214,52 +322,32 @@ def register(): config.action(discriminator="set_token_authentication", callable=register) + config.set_security_policy(security_policy) -@implementer(ISecurityPolicy) -class TokenAuthenticationPolicy: - 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. - """ - token_service: TetTokenService = request.find_service(TetTokenService) - jwt_token = request.headers.get(request.registry.tet_auth_access_token_header) - - if not jwt_token: - return None - - payload = token_service.verify_jwt(jwt_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 + login_view_attr = ( + COOKIE_LOGIN_VIEW + if isinstance(security_policy, JWTCookieAuthenticationPolicy) + else DEFAULT_LOGIN_VIEW + ) + config.add_view( + AuthViews, + attr=login_view_attr, + route_name="tet_auth_login", + renderer="json", + request_method="POST", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) - def forget(self, request) -> tp.List[tuple[str, str]]: - """ - This method does not need to be implemented for header-based authentication. - """ - return [] + config.add_view( + AuthViews, + attr="jwt_token", + route_name="tet_auth_jwt", + renderer="string", + request_method="GET", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) class TokenMixin: @@ -434,26 +522,65 @@ def __init__(self, request: Request): self.long_term_token_header = self.registry.tet_auth_long_term_token_header self.access_token_header = self.registry.tet_auth_access_token_header self.project_prefix = self.registry.tet_auth_project_prefix + self.access_token_cookie_name = self.registry.tet_auth_access_token_cookie_name + self.long_term_token_cookie_name = self.registry.tet_auth_long_term_token_cookie_name - def login_view(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden]: - login_callback = self.registry.tet_auth_login_callback + def _set_cookie( + self, + name, + value, + max_age, + domain=None, + secure=True, + httponly=True, + samesite="Lax", + overwrite=True, + **kwargs, + ): + self.response.set_cookie( + name=name, + value=value, + max_age=max_age, + domain=domain, + secure=secure, + httponly=httponly, + samesite=samesite, + overwrite=overwrite, + **kwargs, + ) + def login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None]: + login_callback = self.registry.tet_auth_login_callback user_id = login_callback(self.request) if user_id is None: - raise HTTPForbidden() + raise HTTPUnauthorized() + + refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) + self.response.headers[self.long_term_token_header] = refresh_token - token = self.token_service.create_long_term_token(user_id, self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) + self.response.headers[self.access_token_header] = access_token - resp: Response = self.response - resp.headers[self.long_term_token_header] = token + return { + "success": True, + } - return dict( - user_id=user_id, - token=token, + def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: + response = self.login() + self._set_cookie( + name=self.access_token_cookie_name, + value=self.response.headers[self.access_token_header], + max_age=self.token_service.jwt_expiration_mins * 60, + ) + self._set_cookie( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=86400, ) + return response - def jwt_token_view(self) -> str: + def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) try: @@ -477,25 +604,6 @@ def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access-token") - config.add_view( - AuthViews, - attr="login_view", - route_name="tet_auth_login", - renderer="json", - request_method="POST", - require_csrf=False, - permission=NO_PERMISSION_REQUIRED, - ) - - config.add_view( - AuthViews, - attr="jwt_token_view", - route_name="tet_auth_jwt", - renderer="string", - request_method="GET", - require_csrf=False, - permission=NO_PERMISSION_REQUIRED, - ) config.add_directive("set_token_authentication", set_token_authentication) @@ -505,5 +613,3 @@ def includeme(config: Configurator): ) config.set_default_permission("view") - config.set_authorization_policy(ACLAuthorizationPolicy()) - config.set_authentication_policy(TokenAuthenticationPolicy()) diff --git a/tests/conftest.py b/tests/conftest.py index a3e5cbd..fa5e845 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ +import json import logging import typing as tp import pytest from pyramid.request import Request +from pyramid.response import Response from pyramid.security import Allow, Authenticated, Everyone, Deny from pyramid.testing import setUp, tearDown from sqlalchemy import create_engine, or_ @@ -10,6 +12,8 @@ from tests.models.accounts import Base, Token, User from tet.config import Configurator as tetConfigurator +from tet.security.authentication import TokenAuthenticationPolicy, JWTCookieAuthenticationPolicy +from tet.view import view_config DB_NAME = "test_tet" DB_URL = f"postgresql+psycopg2://test_tet:test_tet@localhost:5432/{DB_NAME}" @@ -94,7 +98,7 @@ def __init__(self, request): @pytest.fixture() -def pyramid_app(db_engine): +def pyramid_config(db_engine): """Fixture to create and configure a Pyramid application.""" settings = { "sqlalchemy.url": DB_URL, @@ -110,17 +114,46 @@ def pyramid_app(db_engine): config.setup_sqlalchemy(engine=db_engine) config.set_root_factory(RootFactory) config.include("tet.security.authentication", route_prefix="/api/v1/auth") - config.set_token_authentication( - long_term_token_model=Token, - project_prefix=config.registry.settings["project_prefix"], - login_callback=login_callback, - jwk_resolver=jwk_resolver, - ) - config.add_route("home", "/") - config.add_view( - lambda request: {"message": "Hello, World!"}, - route_name="home", - renderer="json", - ) - app = config.make_wsgi_app() + yield config + + +JWT_AUTH = "TOKEN_AUTH" +JWT_COOKIE_AUTH = "JWT_COOKIE_AUTH" + + +@pytest.fixture( + params=[ + pytest.param({"security_policy": TokenAuthenticationPolicy}, id=JWT_AUTH), + pytest.param({"security_policy": JWTCookieAuthenticationPolicy}, id=JWT_COOKIE_AUTH), + ] +) +def security_policy(request): + return request.param["security_policy"] + + +@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(security_policy, 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=security_policy(), + ) + pyramid_config.add_route("home", "/") + pyramid_config.add_view( + home_view, + route_name="home", + renderer="json", + permission="view", + ) + app = pyramid_config.make_wsgi_app() yield app diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py new file mode 100644 index 0000000..2f64a18 --- /dev/null +++ b/tests/services/security/conftest.py @@ -0,0 +1,39 @@ +from tet.security.authentication import JWTCookieAuthenticationPolicy, TokenAuthenticationPolicy + +TARGET_MODULE = "test_authentication.py" +PYRAMID_TEST_APP = "pyramid_test_app" +PYRAMID_TEST_APP_WITH_JWT_COOKIE_POLICY = "pyramid_test_app_with_jwt_cookie_policy" +SECURITY_POLICY = "security_policy" + + +def pytest_collection_modifyitems(config, items): + """ + Pre-filter: split items into those in test_authentication.py and others. + Filter: remove items that require a security policy that is not TokenAuthenticationPolicy. + + 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)] + + deselected_items = [] + kept_auth_items = [] + for item in auth_items: + if hasattr(item, "callspec") and SECURITY_POLICY in item.callspec.params: + param = item.callspec.params[SECURITY_POLICY] + policy = param.get(SECURITY_POLICY) if isinstance(param, dict) else param + if PYRAMID_TEST_APP in item.fixturenames and policy is not TokenAuthenticationPolicy: + deselected_items.append(item) + continue + if ( + PYRAMID_TEST_APP_WITH_JWT_COOKIE_POLICY in item.fixturenames + and policy is not JWTCookieAuthenticationPolicy + ): + deselected_items.append(item) + continue + kept_auth_items.append(item) + + kept_items = other_items + kept_auth_items + if deselected_items: + config.hook.pytest_deselected(items=deselected_items) + items[:] = kept_items diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py new file mode 100644 index 0000000..f2d9c69 --- /dev/null +++ b/tests/services/security/test_authentication.py @@ -0,0 +1,238 @@ +import json + +import pytest +from jwt import InvalidSignatureError +from sqlalchemy.orm import Session +from webtest import TestApp + +from tests.models.accounts import User +from tet.security.authentication import TetTokenService, JWTCookieAuthenticationPolicy + +ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" +LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" +LONG_TERM_TOKEN_HEADER_NAME = "x-long-token" +ACCESS_TOKEN_HEADER_NAME = "x-access-token" +LONG_TERM_TOKEN_COOKIE_NAME = "refresh-token" +ACCESS_TOKEN_COOKIE_NAME = "access-token" +HOME_ROUTE = "/" + + +@pytest.fixture() +def pyramid_test_app(request, pyramid_app): + return TestApp(pyramid_app) + + +@pytest.fixture() +def long_term_token(pyramid_test_app, capture_token): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + return response.headers[LONG_TERM_TOKEN_HEADER_NAME] + + +def create_user(db_session: Session): + user = User(email="exampple2@invalid.invalid", name="example2", is_admin=True) + user.password = "1234@abcd" + default_user = db_session.query(User).filter(User.email == user.email).first() + if default_user: + return default_user + + 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 + + def wrapper(*args, **kwargs): + token = create_long_term_token(*args, **kwargs) + captured_data["token"] = token + return token + + monkeypatch.setattr(TetTokenService, "create_long_term_token", wrapper) + + return captured_data + + +def test_login_view_should_return_long_term_token(pyramid_test_app, capture_token): + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = pyramid_test_app.post( + url=LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + + # Validate the token captured by monkeypatch + refresh_token = response.headers[LONG_TERM_TOKEN_HEADER_NAME] + assert capture_token["token"] == refresh_token + + assert isinstance(refresh_token, str) + assert len(refresh_token) > 0 + + +def test_auth_should_return_access_token(long_term_token, pyramid_test_app): + headers = {LONG_TERM_TOKEN_HEADER_NAME: long_term_token} + response = pyramid_test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) + + assert response.status_code == 200 + + assert "x-access-token" in response.headers + assert response.headers["x-access-token"] is not None + + +def test_access_token_should_work_to_access_protected_route(long_term_token, pyramid_test_app): + headers = {"x-long-token": long_term_token} + response = pyramid_test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) + assert response.status_code == 200 + + access_token = response.headers["x-access-token"] + assert access_token is not None + + headers = {"x-access-token": 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=LONG_TERM_TOKEN_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=LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + assert response.status_code == 200 + refresh_token = response.headers[LONG_TERM_TOKEN_HEADER_NAME] + # Validate the token captured by monkeypatch + assert "token" in capture_token + assert capture_token["token"] == refresh_token + + assert isinstance(refresh_token, str) + assert len(refresh_token) > 0 + + token = tet_token_service.retrieve_and_validate_token(refresh_token, 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 = { + "x-access-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" + ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" + } + pytest.raises( + InvalidSignatureError, + pyramid_test_app.get, + HOME_ROUTE, + headers=headers, + expect_errors=True, + ) + + +@pytest.fixture() +def pyramid_test_app_with_jwt_cookie_policy(request, pyramid_app): + return TestApp(pyramid_app) + + +def get_cookie(cookiejar, name): + founded_cookie = [cookie for cookie in cookiejar if cookie.name == name] + return founded_cookie[0].value if founded_cookie else None + + +def test_login_view_should_return_refresh_and_access_tokens_within_cookie( + pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request +): + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + access_token_cookie_name = pyramid_request.registry.tet_auth_access_token_cookie_name + app = pyramid_test_app_with_jwt_cookie_policy + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = app.post( + LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + access_token = get_cookie(app.cookiejar, access_token_cookie_name) + refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) + assert response.status_code == 200 + assert refresh_token == capture_token["token"] + assert access_token is not None + assert len(access_token) > 0 + + +def test_access_token_should_work_to_access_protected_route_with_cookie( + pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request +): + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + access_token_cookie_name = pyramid_request.registry.tet_auth_access_token_cookie_name + app = pyramid_test_app_with_jwt_cookie_policy + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = app.post( + LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + access_token = get_cookie(app.cookiejar, access_token_cookie_name) + refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) + assert response.status_code == 200 + assert refresh_token == capture_token["token"] + assert access_token is not None + assert len(access_token) > 0 + + app.set_cookie(access_token_cookie_name, access_token) + response = app.get(HOME_ROUTE, status=200) + + assert response.status_code == 200 + + +# TODO: Test it should be able to decode the access token using JWT diff --git a/tests/services/test_authentication.py b/tests/services/test_authentication.py deleted file mode 100644 index a8de4cc..0000000 --- a/tests/services/test_authentication.py +++ /dev/null @@ -1,177 +0,0 @@ -import json - -import pytest -from jwt import InvalidSignatureError -from sqlalchemy.orm import Session -from webtest import TestApp - -from tests.models.accounts import User -from tet.security.authentication import TetTokenService - -ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" -LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" -HOME_ROUTE = "/" - - -@pytest.fixture() -def test_app(pyramid_app): - return TestApp(pyramid_app) - - -@pytest.fixture() -def long_term_token(pyramid_app, test_app, capture_token): - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = test_app.post( - LONG_TERM_TOKEN_ENDPOINT, - params=data, - content_type="application/json", - status=200, - ) - - return response.json["token"] - - -def create_user(db_session: Session): - user = User(email="exampple2@invalid.invalid", name="example2", is_admin=True) - user.password = "1234@abcd" - default_user = db_session.query(User).filter(User.email == user.email).first() - if default_user: - return default_user - - 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): - 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 - - def wrapper(*args, **kwargs): - token = create_long_term_token(*args, **kwargs) - captured_data["token"] = token - return token - - monkeypatch.setattr(TetTokenService, "create_long_term_token", wrapper) - - return captured_data - - -def test_login_view_should_return_long_term_token(test_app, capture_token): - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = test_app.post( - url=LONG_TERM_TOKEN_ENDPOINT, - params=data, - content_type="application/json", - status=200, - ) - assert response.status_code == 200 - assert "user_id" in response.json - assert "token" in response.json - - # Validate the token captured by monkeypatch - assert "token" in capture_token - assert capture_token["token"] == response.json["token"] - - token = response.json["token"] - assert isinstance(token, str) - assert len(token) > 0 - - -def test_auth_should_return_access_token(long_term_token, test_app): - headers = {"x-long-token": long_term_token} - response = test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) - - assert response.status_code == 200 - - assert "x-access-token" in response.headers - assert response.headers["x-access-token"] is not None - - -def test_access_token_should_work_to_access_protected_route(long_term_token, test_app): - headers = {"x-long-token": long_term_token} - response = test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) - assert response.status_code == 200 - - access_token = response.headers["x-access-token"] - assert access_token is not None - - headers = {"x-access-token": access_token} - response = 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_403_when_identity_not_found_in_the_db(test_app, pyramid_request): - response = test_app.post( - url=LONG_TERM_TOKEN_ENDPOINT, - params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), - content_type="application/json", - status=403, - expect_errors=True, - ) - assert response.status_code == 403 - - -def test_it_should_store_the_token_in_the_database(capture_token, 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 = test_app.post( - url=LONG_TERM_TOKEN_ENDPOINT, - params=data, - content_type="application/json", - status=200, - ) - assert response.status_code == 200 - assert "user_id" in response.json - assert "token" in response.json - - # Validate the token captured by monkeypatch - assert "token" in capture_token - assert capture_token["token"] == response.json["token"] - - response_token = response.json["token"] - assert isinstance(response_token, str) - assert len(response_token) > 0 - - token = tet_token_service.retrieve_and_validate_token(response_token, project_prefix) - assert token is not None - - -def test_it_should_fail_to_access_the_protected_route_without_the_access_token( - test_app, -): - response = 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( - test_app, -): - headers = { - "x-access-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" - ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" - } - pytest.raises( - InvalidSignatureError, - test_app.get, - HOME_ROUTE, - headers=headers, - expect_errors=True, - ) From 654246b4532791f24a75998d5e46217f6ce0cb7c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 13 Feb 2025 19:49:41 +0200 Subject: [PATCH 030/139] Update authentication: - Add refresh token endpoint - Set cookie for long term token only - Ability to set max age of the long term token - Bind the long term token cookie to specific route. e.g: /refresh route by default --- src/tet/security/authentication.py | 117 ++++++++++-------- .../services/security/test_authentication.py | 36 ++++-- 2 files changed, 93 insertions(+), 60 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index eac5c34..62b0b6d 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -169,25 +169,20 @@ class JWTCookieAuthenticationPolicy(TokenAuthenticationPolicy): def __init__(self): super().__init__() - def authenticated_userid(self, request: Request) -> tp.Optional[int]: - token_service: TetTokenService = request.find_service(TetTokenService) - access_token_cookie_name = request.registry.tet_auth_access_token_cookie_name - jwt_token = request.cookies.get(access_token_cookie_name) - - if not jwt_token: - return None - - payload = token_service.verify_jwt(jwt_token) - return payload.get("user_id") if payload else None - 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_ACCESS_TOKEN_NAME = "X-Access-Token" DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "access-token" DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "refresh-token" +DEFAULT_PATH = "/" +DEFAULT_REFRESH_TOKEN_ROUTE = "refresh" +DEFAULT_UNAUTHORIZED_MESSAGE = """Access denied. You are not authorised to access this resource. +Please ensure that your credientials are correct and try again. +""" DEFAULT_LOGIN_VIEW = "login" COOKIE_LOGIN_VIEW = "cookie_login" @@ -226,11 +221,12 @@ def set_token_authentication( 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, access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN_NAME, - access_token_cookie_name: str = DEFAULT_ACCESS_TOKEN_COOKIE_NAME, long_term_token_cookie_name: str = DEFAULT_REFRESH_TOKEN_COOKIE_NAME, default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, + refresh_token_route: str = DEFAULT_REFRESH_TOKEN_ROUTE, security_policy: tp.Optional[ tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] ] = DEFAULT_SECURITY_POLICY, @@ -311,7 +307,6 @@ def register(): config.registry.tet_auth_user_id_column = user_id_column config.registry.tet_auth_access_token_header = access_token_header config.registry.tet_auth_long_term_token_header = long_term_token_header - config.registry.tet_auth_access_token_cookie_name = access_token_cookie_name config.registry.tet_auth_long_term_token_cookie_name = long_term_token_cookie_name config.registry.tet_auth_default_claims = default_claims @@ -319,6 +314,8 @@ def register(): 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_refresh_token_route = refresh_token_route config.action(discriminator="set_token_authentication", callable=register) @@ -339,16 +336,6 @@ def register(): permission=NO_PERMISSION_REQUIRED, ) - config.add_view( - AuthViews, - attr="jwt_token", - route_name="tet_auth_jwt", - renderer="string", - request_method="GET", - require_csrf=False, - permission=NO_PERMISSION_REQUIRED, - ) - class TokenMixin: """ @@ -522,8 +509,12 @@ def __init__(self, request: Request): self.long_term_token_header = self.registry.tet_auth_long_term_token_header self.access_token_header = self.registry.tet_auth_access_token_header self.project_prefix = self.registry.tet_auth_project_prefix - self.access_token_cookie_name = self.registry.tet_auth_access_token_cookie_name 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.refresh_token_route = self.registry.tet_auth_refresh_token_route + self.route_prefix = self.request.current_route_path().rpartition("/")[0] def _set_cookie( self, @@ -535,6 +526,7 @@ def _set_cookie( httponly=True, samesite="Lax", overwrite=True, + path=DEFAULT_PATH, **kwargs, ): self.response.set_cookie( @@ -546,15 +538,29 @@ def _set_cookie( httponly=httponly, samesite=samesite, overwrite=overwrite, + path=path, **kwargs, ) - def login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None]: + def _create_jwt(self, refresh_token: str) -> str: + try: + token_from_db = self.token_service.retrieve_and_validate_token( + refresh_token, self.project_prefix + ) + except ValueError as e: + logger.exception(f"Error validating token: {e}") + 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 login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized, None]: login_callback = self.registry.tet_auth_login_callback user_id = login_callback(self.request) if user_id is None: - raise HTTPUnauthorized() + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) self.response.headers[self.long_term_token_header] = refresh_token @@ -566,37 +572,31 @@ def login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None]: "success": True, } + def jwt_token(self) -> str: + token = self.request.headers.get(self.long_term_token_header) + access_token = self._create_jwt(token) + self.response.headers[self.access_token_header] = access_token + + return "ok" + def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: response = self.login() - self._set_cookie( - name=self.access_token_cookie_name, - value=self.response.headers[self.access_token_header], - max_age=self.token_service.jwt_expiration_mins * 60, - ) self._set_cookie( name=self.long_term_token_cookie_name, value=self.response.headers[self.long_term_token_header], - max_age=86400, + max_age=self.long_term_token_expiration_mins * 60, + secure=False, + httponly=False, + path=f"{self.route_prefix}/{self.refresh_token_route}", ) return response - def jwt_token(self) -> str: - token = self.request.headers.get(self.long_term_token_header) - - try: - token_from_db = self.token_service.retrieve_and_validate_token( - token, self.project_prefix - ) - except ValueError as e: - logger.exception(f"Error validating token: {e}") - raise HTTPUnauthorized() from e - - user_id = getattr(token_from_db, self.token_service.user_id_column) - - jwt_token = self.token_service.create_short_term_jwt(user_id) - - self.response.headers[self.access_token_header] = jwt_token - + def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, None]: + refresh_token = self.request.cookies.get(self.long_term_token_cookie_name) + if not refresh_token: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + access_token = self._create_jwt(refresh_token) + self.response.headers[self.access_token_header] = access_token return "ok" @@ -604,6 +604,25 @@ def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access-token") + config.add_route("tet_auth_refresh_token", "refresh") + config.add_view( + AuthViews, + attr="jwt_token", + route_name="tet_auth_jwt", + renderer="string", + request_method="GET", + require_csrf=False, + permission=NO_PERMISSION_REQUIRED, + ) + 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_directive("set_token_authentication", set_token_authentication) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index f2d9c69..6326fa8 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -188,11 +188,10 @@ def get_cookie(cookiejar, name): return founded_cookie[0].value if founded_cookie else None -def test_login_view_should_return_refresh_and_access_tokens_within_cookie( +def test_login_view_should_return_refresh_token( pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request ): refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name - access_token_cookie_name = pyramid_request.registry.tet_auth_access_token_cookie_name app = pyramid_test_app_with_jwt_cookie_policy data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = app.post( @@ -201,19 +200,34 @@ def test_login_view_should_return_refresh_and_access_tokens_within_cookie( content_type="application/json", status=200, ) - access_token = get_cookie(app.cookiejar, access_token_cookie_name) refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) assert response.status_code == 200 assert refresh_token == capture_token["token"] - assert access_token is not None - assert len(access_token) > 0 -def test_access_token_should_work_to_access_protected_route_with_cookie( +def test_login_view_should_return_access_token( + pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request +): + refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name + app = pyramid_test_app_with_jwt_cookie_policy + data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) + response = app.post( + LONG_TERM_TOKEN_ENDPOINT, + params=data, + content_type="application/json", + status=200, + ) + refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) + assert response.status_code == 200 + assert refresh_token == capture_token["token"] + assert ACCESS_TOKEN_HEADER_NAME in response.headers + assert response.headers[ACCESS_TOKEN_HEADER_NAME] is not None + + +def test_access_token_should_work_to_access_protected_route_with_new_policy( pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request ): refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name - access_token_cookie_name = pyramid_request.registry.tet_auth_access_token_cookie_name app = pyramid_test_app_with_jwt_cookie_policy data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = app.post( @@ -222,15 +236,15 @@ def test_access_token_should_work_to_access_protected_route_with_cookie( content_type="application/json", status=200, ) - access_token = get_cookie(app.cookiejar, access_token_cookie_name) refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) assert response.status_code == 200 assert refresh_token == capture_token["token"] + + access_token = response.headers[ACCESS_TOKEN_HEADER_NAME] assert access_token is not None - assert len(access_token) > 0 - app.set_cookie(access_token_cookie_name, access_token) - response = app.get(HOME_ROUTE, status=200) + headers = {ACCESS_TOKEN_HEADER_NAME: access_token} + response = app.get(HOME_ROUTE, headers=headers, status=200) assert response.status_code == 200 From 549f4dd85ed2cda61a1a70d16c1b17d2af14d9ed Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 14 Feb 2025 13:29:36 +0200 Subject: [PATCH 031/139] remove unsafe cookie settings --- src/tet/security/authentication.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 62b0b6d..558828b 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -585,8 +585,6 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re name=self.long_term_token_cookie_name, value=self.response.headers[self.long_term_token_header], max_age=self.long_term_token_expiration_mins * 60, - secure=False, - httponly=False, path=f"{self.route_prefix}/{self.refresh_token_route}", ) return response From aca92480adfbfa8390bbd547f5ddda5ca1faf068 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 18 Feb 2025 16:16:45 +0200 Subject: [PATCH 032/139] Update routes pattern. --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 558828b..12e7120 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -601,8 +601,8 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") - config.add_route("tet_auth_jwt", "access-token") - config.add_route("tet_auth_refresh_token", "refresh") + config.add_route("tet_auth_jwt", "access_token") + config.add_route("tet_auth_refresh_token", "refresh_token") config.add_view( AuthViews, attr="jwt_token", From c271696cebdc97289447c9e6607df5f79b839ace Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 18 Feb 2025 17:18:46 +0200 Subject: [PATCH 033/139] Update default refresh token route. --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 12e7120..81f0a68 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -179,7 +179,7 @@ def __init__(self): DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "access-token" DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "refresh-token" DEFAULT_PATH = "/" -DEFAULT_REFRESH_TOKEN_ROUTE = "refresh" +DEFAULT_REFRESH_TOKEN_ROUTE = "refresh_token" DEFAULT_UNAUTHORIZED_MESSAGE = """Access denied. You are not authorised to access this resource. Please ensure that your credientials are correct and try again. """ From a375d85d441a0c75bfc2157ecd46452beb288408 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 18 Feb 2025 17:33:48 +0200 Subject: [PATCH 034/139] refresh token view: return json --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 81f0a68..0f6700f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -595,7 +595,7 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) access_token = self._create_jwt(refresh_token) self.response.headers[self.access_token_header] = access_token - return "ok" + return {"success": True} def includeme(config: Configurator): From cf23740c92f5d48636008fab81ab0bf44d576e8d Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 4 Mar 2025 11:51:12 +0200 Subject: [PATCH 035/139] Allow refresh token to be sent across auth routes. --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 0f6700f..a220b5f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -585,7 +585,7 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re name=self.long_term_token_cookie_name, value=self.response.headers[self.long_term_token_header], max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/{self.refresh_token_route}", + path=f"{self.route_prefix}/", ) return response From 20b2a4ff9fb936d735ccc229b184ea7696d86c2c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 20 Mar 2025 16:42:37 +0200 Subject: [PATCH 036/139] Update authentication: - Add MFA service, and mixin - mfa challenge route to verify token --- src/tet/security/authentication.py | 215 +++++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 14 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index a220b5f..c9c38bb 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,4 +1,5 @@ import dataclasses +import enum import hashlib import logging import secrets @@ -6,15 +7,17 @@ from datetime import datetime, timedelta, timezone import jwt +import pyotp from pyramid.authentication import CallbackAuthenticationPolicy from pyramid.authorization import ACLHelper from pyramid.config import Configurator -from pyramid.httpexceptions import HTTPForbidden, HTTPUnauthorized +from pyramid.httpexceptions import HTTPForbidden, HTTPUnauthorized, HTTPFound from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request, Response from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid_di import RequestScopedBaseService, autowired -from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session from zope.interface import Interface, implementer @@ -24,6 +27,9 @@ "JWTCookieAuthenticationPolicy", "TokenMixin", "JWTRegisteredClaims", + "MultiFactorAuthMethodType", + "MultiFactorAuthenticationMethodMixin", + "TOTPData", ] @@ -189,6 +195,7 @@ def __init__(self): DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() UTC = timezone.utc +DEFAULT_EXPIRY_TIMESTAMP = datetime.now(UTC) + timedelta(hours=12) class ILoginCallback(tp.Protocol): @@ -215,6 +222,8 @@ 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, @@ -303,6 +312,8 @@ def home_view(request): 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_auth_access_token_header = access_token_header @@ -337,6 +348,77 @@ def register(): ) +@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" + + +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), nullable=False, index=True) + data = Column(JSONB, nullable=False, default=dict) + is_active = Column(Boolean, default=True, 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 TokenMixin: """ Stores long-term tokens for users with creation and optional expiration timestamps. @@ -360,6 +442,80 @@ class TokenMixin: expires_at = Column(DateTime(True), nullable=True) +class TetMultiFactorAuthenticationService(RequestScopedBaseService): + session: Session = autowired(Session) + + 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 + ) + + def create_multi_factor_method( + self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict + ): + """ + Generate a new multi-factor authentication method for a user. + """ + existing_method = ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter_by(user_id=user_id, method_type=method_type) + .first() + ) + + if existing_method: + if not existing_method.is_active: + existing_method.data = data + existing_method.is_active = True + return + + 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() + + def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType): + """ + Disable a multi-factor 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 + ).update({"is_active": False}) + + def verify_totp(self, *, secret: tp.Any, token: tp.Any) -> bool: + """ + Verify a one-time password for multi-factor authentication. + """ + totp = pyotp.TOTP(secret) + return totp.verify(token) + + def get_method(self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType): + """ + Retrieve a multi-factor authentication method for a user. + """ + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter_by(user_id=user_id, method_type=method_type, is_active=True) + .first() + ) + + def is_mfa_enabled(self, user_id: tp.Any = None) -> bool: + """ + Check if multi-factor 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, + ) + .count() + > 0 + ) + + class TetTokenService(RequestScopedBaseService): session: Session = autowired(Session) @@ -376,7 +532,7 @@ def create_long_term_token( self, user_id: tp.Any, project_prefix: str, - expire_timestamp=None, + expire_timestamp=DEFAULT_EXPIRY_TIMESTAMP, description=None, ) -> str: """ @@ -501,6 +657,10 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: class AuthViews: token_service: TetTokenService = autowired(TetTokenService) + multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( + TetMultiFactorAuthenticationService + ) + db_session: Session = autowired(Session) def __init__(self, request: Request): self.request = request @@ -515,6 +675,8 @@ def __init__(self, request: Request): ) self.refresh_token_route = self.registry.tet_auth_refresh_token_route self.route_prefix = self.request.current_route_path().rpartition("/")[0] + self.login_callback = self.registry.tet_auth_login_callback + self.user_id = self.login_callback(self.request) def _set_cookie( self, @@ -555,22 +717,38 @@ def _create_jwt(self, refresh_token: str) -> str: return self.token_service.create_short_term_jwt(user_id) - def login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized, None]: - login_callback = self.registry.tet_auth_login_callback - user_id = login_callback(self.request) + def _set_tokens(self, user_id: str) -> dict: + refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) + self.response.headers[self.long_term_token_header] = refresh_token + self.response.headers[self.access_token_header] = access_token + return {"success": True} - if user_id is None: + def login(self) -> HTTPFound | dict: + if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) - self.response.headers[self.long_term_token_header] = refresh_token + if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): + payload = {"success": True, "mfa_enabled": True} + redirect_url = self.request.route_url("mfa_challenge") + return HTTPFound(location=redirect_url, json_body=payload) - access_token = self.token_service.create_short_term_jwt(user_id) - self.response.headers[self.access_token_header] = access_token + return self._set_tokens(self.user_id) - return { - "success": True, - } + def mfa_challenge(self) -> dict: + if self.user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + payload = self.request.json_body + token = payload["token"] + method_type = payload["method_type"] + mfa_method = self.multi_factor_auth_service.get_method( + user_id=self.user_id, method_type=method_type + ) + secret = mfa_method.data.get("secret") + is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) + self._set_tokens(self.user_id) + return {"success": is_valid} def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) @@ -603,6 +781,7 @@ def includeme(config: Configurator): config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access_token") config.add_route("tet_auth_refresh_token", "refresh_token") + config.add_route("tet_auth_mfa_challenge", "mfa_challenge") config.add_view( AuthViews, attr="jwt_token", @@ -621,6 +800,14 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) + config.add_view( + AuthViews, + attr="mfa_challenge", + route_name="tet_auth_mfa_challenge", + renderer="json", + request_method="POST", + require_csrf=False, + ) config.add_directive("set_token_authentication", set_token_authentication) From 7a4210734e1ee26e508bd28fc246af1f9820b076 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 20 Mar 2025 17:29:29 +0200 Subject: [PATCH 037/139] Update authentication: - Update return type of login route --- src/tet/security/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index c9c38bb..788ec03 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -29,6 +29,7 @@ "JWTRegisteredClaims", "MultiFactorAuthMethodType", "MultiFactorAuthenticationMethodMixin", + "TetMultiFactorAuthenticationService", "TOTPData", ] @@ -724,7 +725,7 @@ def _set_tokens(self, user_id: str) -> dict: self.response.headers[self.access_token_header] = access_token return {"success": True} - def login(self) -> HTTPFound | dict: + def login(self) -> tp.Union[HTTPFound, dict]: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) From be1b24b96cf4368af44f454d5054997ed7261946 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 20 Mar 2025 17:33:37 +0200 Subject: [PATCH 038/139] Register TetMultiFactorAuthenticationService --- src/tet/security/authentication.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 788ec03..9f23b6e 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -816,5 +816,10 @@ def includeme(config: Configurator): 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.set_default_permission("view") From d476144b3dcab849e4228b7e30b744f33e09f3d5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 21 Mar 2025 17:44:41 +0200 Subject: [PATCH 039/139] Update the request route_url --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9f23b6e..0be950c 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -731,7 +731,7 @@ def login(self) -> tp.Union[HTTPFound, dict]: if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): payload = {"success": True, "mfa_enabled": True} - redirect_url = self.request.route_url("mfa_challenge") + redirect_url = self.request.route_url("tet_auth_mfa_challenge") return HTTPFound(location=redirect_url, json_body=payload) return self._set_tokens(self.user_id) From 59a75df3474540f611380b436f442cf41dd56cbe Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 13:46:45 +0200 Subject: [PATCH 040/139] Add early return on HTTPFound in cookie_login to skip setting cookie. --- src/tet/security/authentication.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 0be950c..3543dc6 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -736,6 +736,20 @@ def login(self) -> tp.Union[HTTPFound, dict]: return self._set_tokens(self.user_id) + def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: + response = self.login() + + if isinstance(response, HTTPFound): + return response + + self._set_cookie( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ) + return response + def mfa_challenge(self) -> dict: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) @@ -758,16 +772,6 @@ def jwt_token(self) -> str: return "ok" - def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: - response = self.login() - self._set_cookie( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", - ) - return response - def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, None]: refresh_token = self.request.cookies.get(self.long_term_token_cookie_name) if not refresh_token: From f0460b4a616ae78941430428b8bf95937785cf0b Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 17:06:36 +0200 Subject: [PATCH 041/139] Update mfa feature: - Simplify the login logic if mfa was enabled - Rename methods in the TetMultiFactorAuthenticationService --- src/tet/security/authentication.py | 35 +++++++++--------------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 3543dc6..4f84a70 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -452,11 +452,11 @@ def __init__(self, request: Request): self.registry.tet_multi_factor_auth_method_model ) - def create_multi_factor_method( + def get_or_create_method( self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict ): """ - Generate a new multi-factor authentication method for a user. + Get or create a multi-factor authentication method for a user. """ existing_method = ( self.session.query(self.tet_multi_factor_auth_method_model) @@ -723,25 +723,19 @@ def _set_tokens(self, user_id: str) -> dict: access_token = self.token_service.create_short_term_jwt(user_id) self.response.headers[self.long_term_token_header] = refresh_token self.response.headers[self.access_token_header] = access_token - return {"success": True} def login(self) -> tp.Union[HTTPFound, dict]: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): - payload = {"success": True, "mfa_enabled": True} - redirect_url = self.request.route_url("tet_auth_mfa_challenge") - return HTTPFound(location=redirect_url, json_body=payload) + return self._mfa_challenge() - return self._set_tokens(self.user_id) + self._set_tokens(self.user_id) + return {"success": True} def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: response = self.login() - - if isinstance(response, HTTPFound): - return response - self._set_cookie( name=self.long_term_token_cookie_name, value=self.response.headers[self.long_term_token_header], @@ -750,10 +744,7 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def mfa_challenge(self) -> dict: - if self.user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - + def _mfa_challenge(self) -> dict: payload = self.request.json_body token = payload["token"] method_type = payload["method_type"] @@ -762,6 +753,10 @@ def mfa_challenge(self) -> dict: ) secret = mfa_method.data.get("secret") is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) + + if not is_valid: + raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + self._set_tokens(self.user_id) return {"success": is_valid} @@ -786,7 +781,6 @@ def includeme(config: Configurator): config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access_token") config.add_route("tet_auth_refresh_token", "refresh_token") - config.add_route("tet_auth_mfa_challenge", "mfa_challenge") config.add_view( AuthViews, attr="jwt_token", @@ -805,15 +799,6 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) - config.add_view( - AuthViews, - attr="mfa_challenge", - route_name="tet_auth_mfa_challenge", - renderer="json", - request_method="POST", - require_csrf=False, - ) - config.add_directive("set_token_authentication", set_token_authentication) config.include("pyramid_di") From deae6a456d0623096ff01cc56035741287e12993 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 17:27:47 +0200 Subject: [PATCH 042/139] Update mfa feature: - Re-added mfa_challenge endpoint --- src/tet/security/authentication.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 4f84a70..2f8328d 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -718,24 +718,26 @@ def _create_jwt(self, refresh_token: str) -> str: return self.token_service.create_short_term_jwt(user_id) - def _set_tokens(self, user_id: str) -> dict: + def _set_tokens(self, user_id: str) -> None: refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) access_token = self.token_service.create_short_term_jwt(user_id) self.response.headers[self.long_term_token_header] = refresh_token self.response.headers[self.access_token_header] = access_token - def login(self) -> tp.Union[HTTPFound, dict]: + def login(self) -> dict[str, bool] | None: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - + response_payload = {"success": True} if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): - return self._mfa_challenge() + return response_payload.update({"mfa_required": True}) self._set_tokens(self.user_id) - return {"success": True} + return response_payload def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: response = self.login() + if isinstance(response, dict) and response.get("mfa_required"): + return response self._set_cookie( name=self.long_term_token_cookie_name, value=self.response.headers[self.long_term_token_header], @@ -744,7 +746,10 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def _mfa_challenge(self) -> dict: + def mfa_challenge(self) -> dict: + if self.user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + payload = self.request.json_body token = payload["token"] method_type = payload["method_type"] @@ -781,6 +786,7 @@ def includeme(config: Configurator): config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access_token") config.add_route("tet_auth_refresh_token", "refresh_token") + config.add_route("tet_auth_mfa_challenge", "mfa_challenge") config.add_view( AuthViews, attr="jwt_token", @@ -799,6 +805,15 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) + config.add_view( + AuthViews, + attr="mfa_challenge", + route_name="tet_auth_mfa_challenge", + 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") From d35a8c600dfc531ed8556ca5cb0eb8c998a6d5ff Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 17:31:17 +0200 Subject: [PATCH 043/139] Update mfa feature: - Make verify_totp a static method --- src/tet/security/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 2f8328d..8f168eb 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -485,7 +485,8 @@ def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType user_id=user_id, method_type=method_type ).update({"is_active": False}) - def verify_totp(self, *, secret: tp.Any, token: tp.Any) -> bool: + @staticmethod + def verify_totp(secret: tp.Any, token: tp.Any) -> bool: """ Verify a one-time password for multi-factor authentication. """ From 4f4d3e47e5d78ad3d21d3a3ea78b7cc5b10bf360 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 17:48:47 +0200 Subject: [PATCH 044/139] Fix login flow: correctly return mfa_required response payload instead of None --- src/tet/security/authentication.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 8f168eb..676c678 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -730,7 +730,8 @@ def login(self) -> dict[str, bool] | None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) response_payload = {"success": True} if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): - return response_payload.update({"mfa_required": True}) + response_payload["mfa_required"] = True + return response_payload self._set_tokens(self.user_id) return response_payload From 0559dbda8a8f56756bbc33e3fc6381734479e84e Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 18:59:21 +0200 Subject: [PATCH 045/139] Update mfa feature: - Add default method type for mfa_challenge endpoint --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 676c678..36b386c 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -754,9 +754,9 @@ def mfa_challenge(self) -> dict: payload = self.request.json_body token = payload["token"] - method_type = payload["method_type"] + # TODO: Add support for other MFA methods mfa_method = self.multi_factor_auth_service.get_method( - user_id=self.user_id, method_type=method_type + user_id=self.user_id, method_type=MultiFactorAuthMethodType.TOTP ) secret = mfa_method.data.get("secret") is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) From 8f3354dea1568f6132c47669c1e3ef9c14e1a562 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 19:22:00 +0200 Subject: [PATCH 046/139] Update mfa authentication: - Add mfa verify API endpoint. --- src/tet/security/authentication.py | 31 +++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 36b386c..be91e42 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -748,15 +748,11 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def mfa_challenge(self) -> dict: - if self.user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - + def _verify_mfa(self, user_id: str) -> dict: payload = self.request.json_body token = payload["token"] - # TODO: Add support for other MFA methods mfa_method = self.multi_factor_auth_service.get_method( - user_id=self.user_id, method_type=MultiFactorAuthMethodType.TOTP + user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP ) secret = mfa_method.data.get("secret") is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) @@ -764,9 +760,20 @@ def mfa_challenge(self) -> dict: if not is_valid: raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) - self._set_tokens(self.user_id) + self._set_tokens(user_id) return {"success": is_valid} + def mfa_challenge(self) -> dict: + if self.user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + return self._verify_mfa(self.user_id) + + def mfa_verify(self) -> dict: + user_id = self.request.authenticated_userid + if not user_id: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + return self._verify_mfa(user_id) + def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) access_token = self._create_jwt(token) @@ -789,6 +796,7 @@ def includeme(config: Configurator): config.add_route("tet_auth_jwt", "access_token") config.add_route("tet_auth_refresh_token", "refresh_token") config.add_route("tet_auth_mfa_challenge", "mfa_challenge") + config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") config.add_view( AuthViews, attr="jwt_token", @@ -816,6 +824,15 @@ def includeme(config: Configurator): 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_directive("set_token_authentication", set_token_authentication) config.include("pyramid_di") From 025c9f1e229816d5e67c4d614e21c8ed6cb34674 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 19:28:40 +0200 Subject: [PATCH 047/139] Update get_or_create_method should return mfa method data --- src/tet/security/authentication.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index be91e42..db9516f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -454,7 +454,7 @@ def __init__(self, request: Request): def get_or_create_method( self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict - ): + ) -> tp.Any: """ Get or create a multi-factor authentication method for a user. """ @@ -468,7 +468,7 @@ def get_or_create_method( if not existing_method.is_active: existing_method.data = data existing_method.is_active = True - return + return existing_method new_mfa_method = self.tet_multi_factor_auth_method_model( method_type=method_type, user_id=user_id, data=data @@ -476,6 +476,7 @@ def get_or_create_method( self.session.add(new_mfa_method) self.session.flush() + return new_mfa_method def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType): """ From 3ab692aae9c9cb0fbe58162a7669501e63ef05cc Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 24 Mar 2025 19:47:49 +0200 Subject: [PATCH 048/139] Update verify_mfa: - Set cookie for the long-term token when using JWTCookieAuthenticationPolicy. --- src/tet/security/authentication.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index db9516f..3b3803e 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -328,6 +328,7 @@ def register(): 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_refresh_token_route = refresh_token_route + config.registry.tet_auth_security_policy = security_policy config.action(discriminator="set_token_authentication", callable=register) @@ -680,6 +681,7 @@ def __init__(self, request: Request): self.route_prefix = self.request.current_route_path().rpartition("/")[0] self.login_callback = self.registry.tet_auth_login_callback self.user_id = self.login_callback(self.request) + self.security_policy = self.registry.tet_auth_security_policy def _set_cookie( self, @@ -749,7 +751,7 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def _verify_mfa(self, user_id: str) -> dict: + def _verify_mfa(self, user_id: tp.Any) -> dict: payload = self.request.json_body token = payload["token"] mfa_method = self.multi_factor_auth_service.get_method( @@ -762,6 +764,13 @@ def _verify_mfa(self, user_id: str) -> dict: raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) self._set_tokens(user_id) + if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): + self._set_cookie( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ) return {"success": is_valid} def mfa_challenge(self) -> dict: From 21e63c322853f2d70be85ba02aac85cd727f2f52 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 27 Mar 2025 13:39:39 +0200 Subject: [PATCH 049/139] Update MFA service: - add a method to get all active mfa approaches. --- src/tet/security/authentication.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 3b3803e..e49383a 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -505,6 +505,18 @@ def get_method(self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType) .first() ) + def get_active_methods_by_user_id( + self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType + ): + """ + Retrieve all multi-factor authentication methods by user id. + """ + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter_by(user_id=user_id, method_type=method_type, is_active=True) + .all() + ) + def is_mfa_enabled(self, user_id: tp.Any = None) -> bool: """ Check if multi-factor authentication is enabled for the user. @@ -751,7 +763,7 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def _verify_mfa(self, user_id: tp.Any) -> dict: + def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: payload = self.request.json_body token = payload["token"] mfa_method = self.multi_factor_auth_service.get_method( @@ -776,13 +788,13 @@ def _verify_mfa(self, user_id: tp.Any) -> dict: def mfa_challenge(self) -> dict: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - return self._verify_mfa(self.user_id) + return self._verify_totp_by_user_id(self.user_id) def mfa_verify(self) -> dict: user_id = self.request.authenticated_userid if not user_id: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - return self._verify_mfa(user_id) + return self._verify_totp_by_user_id(user_id) def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) From fe7920457f269e87065a0e70ed37c603324a670c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 27 Mar 2025 13:42:53 +0200 Subject: [PATCH 050/139] Update get_active_methods_by_user_id: - Lift the filter by removing the method_types; this should fetch all active methods added by the user. --- src/tet/security/authentication.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index e49383a..f277244 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -505,15 +505,13 @@ def get_method(self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType) .first() ) - def get_active_methods_by_user_id( - self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType - ): + def get_active_methods_by_user_id(self, *, user_id: tp.Any): """ Retrieve all multi-factor authentication methods by user id. """ return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type, is_active=True) + .filter_by(user_id=user_id, is_active=True) .all() ) From 143fa4b1b68c5a4ed368a0f3a16f00976299383b Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 27 Mar 2025 18:02:49 +0200 Subject: [PATCH 051/139] Update filter condition of get_active_methods_by_user_id. --- src/tet/security/authentication.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index f277244..3a382e7 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -466,9 +466,8 @@ def get_or_create_method( ) if existing_method: - if not existing_method.is_active: - existing_method.data = data - existing_method.is_active = True + existing_method.data = data + existing_method.is_active = True return existing_method new_mfa_method = self.tet_multi_factor_auth_method_model( @@ -511,7 +510,7 @@ def get_active_methods_by_user_id(self, *, user_id: tp.Any): """ return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, is_active=True) + .filter_by(user_id=user_id, is_active=True, verified=True) .all() ) From 8cff0fce24f6e8d70018890134c3a3efd39ccc06 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 27 Mar 2025 18:16:42 +0200 Subject: [PATCH 052/139] Update the MFA method's verified state if valid. --- src/tet/security/authentication.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 3a382e7..87f1135 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -772,6 +772,9 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: if not is_valid: raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + mfa_method.mark_used() + mfa_method.verified = True + self._set_tokens(user_id) if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): self._set_cookie( From f8dffb87e8e99eedfdac5ec63cfa2fa101008e88 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 1 Apr 2025 16:03:13 +0300 Subject: [PATCH 053/139] Remove long term token from the cookie if invalid. --- src/tet/security/authentication.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 87f1135..bbcd634 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -725,6 +725,12 @@ def _create_jwt(self, refresh_token: str) -> str: ) except ValueError as e: logger.exception(f"Error validating token: {e}") + self._set_cookie( + name=self.long_term_token_cookie_name, + value=None, + path=f"{self.route_prefix}/", + max_age=None, + ) raise HTTPUnauthorized() from e user_id = getattr(token_from_db, self.token_service.user_id_column) From 37f1e8415fcbbc8c89c57113d3858ca937faef9f Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 15 Apr 2025 13:49:54 +0300 Subject: [PATCH 054/139] Update security module: - Make the cookie attributes configurable --- src/tet/security/authentication.py | 91 +++++++++++++++++------------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index bbcd634..ee6b3ee 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -31,6 +31,7 @@ "MultiFactorAuthenticationMethodMixin", "TetMultiFactorAuthenticationService", "TOTPData", + "CookieAttributes", ] @@ -191,10 +192,25 @@ def __init__(self): Please ensure that your credientials are correct and try again. """ + +@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 = False + httponly: bool = False + samesite: str = "Lax" + overwrite: bool = True + + DEFAULT_LOGIN_VIEW = "login" COOKIE_LOGIN_VIEW = "cookie_login" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() +DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() UTC = timezone.utc DEFAULT_EXPIRY_TIMESTAMP = datetime.now(UTC) + timedelta(hours=12) @@ -235,8 +251,9 @@ def set_token_authentication( access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN_NAME, long_term_token_cookie_name: str = DEFAULT_REFRESH_TOKEN_COOKIE_NAME, - default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, + jwt_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, refresh_token_route: str = DEFAULT_REFRESH_TOKEN_ROUTE, + cookie_attributes: tp.Optional[CookieAttributes] = None, security_policy: tp.Optional[ tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] ] = DEFAULT_SECURITY_POLICY, @@ -307,7 +324,7 @@ def home_view(request): jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). access_token_header: The header name for the access token (default: ``"X-Access-Token"``). long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). - default_claims: Default JWT registered claims to include in the token payload. + jwt_claims: Default JWT registered claims to include in the token payload. security_policy: A custom security policy to use for token authentication. """ @@ -320,7 +337,8 @@ def register(): config.registry.tet_auth_access_token_header = access_token_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_default_claims = default_claims + 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 @@ -539,7 +557,7 @@ def __init__(self, request: Request): 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.default_claims: JWTRegisteredClaims = self.registry.tet_auth_default_claims + self.jwt_claims: JWTRegisteredClaims = self.registry.tet_auth_jwt_claims def create_long_term_token( self, @@ -632,7 +650,7 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: if not user_id: raise ValueError("User ID is required") - payload = self.default_claims + payload = self.jwt_claims payload.user_id = user_id payload.iat = datetime.now(UTC) payload.exp = payload.iat + timedelta(minutes=self.jwt_expiration_mins) @@ -658,10 +676,10 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: token, self.registry.tet_auth_jwk_resolver(self.request), algorithms=[self.jwt_algorithm], - leeway=self.default_claims.leeway, - audience=self.default_claims.aud, - subject=self.default_claims.sub, - issuer=self.default_claims.iss, + leeway=self.jwt_claims.leeway, + audience=self.jwt_claims.aud, + subject=self.jwt_claims.sub, + issuer=self.jwt_claims.iss, ) return payload except jwt.ExpiredSignatureError: @@ -691,30 +709,17 @@ def __init__(self, request: Request): self.login_callback = self.registry.tet_auth_login_callback self.user_id = self.login_callback(self.request) self.security_policy = self.registry.tet_auth_security_policy + self.cookie_attributes: tp.Optional[CookieAttributes] = ( + self.registry.tet_auth_cookie_attributes + ) def _set_cookie( self, - name, - value, - max_age, - domain=None, - secure=True, - httponly=True, - samesite="Lax", - overwrite=True, - path=DEFAULT_PATH, + cookie_attrs: CookieAttributes, **kwargs, ): self.response.set_cookie( - name=name, - value=value, - max_age=max_age, - domain=domain, - secure=secure, - httponly=httponly, - samesite=samesite, - overwrite=overwrite, - path=path, + **cookie_attrs.__dict__, **kwargs, ) @@ -726,10 +731,12 @@ def _create_jwt(self, refresh_token: str) -> str: except ValueError as e: logger.exception(f"Error validating token: {e}") self._set_cookie( - name=self.long_term_token_cookie_name, - value=None, - path=f"{self.route_prefix}/", - max_age=None, + cookie_attrs=CookieAttributes( + name=self.long_term_token_cookie_name, + value=None, + path=f"{self.route_prefix}/", + max_age=None, + ) ) raise HTTPUnauthorized() from e @@ -759,10 +766,13 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re if isinstance(response, dict) and response.get("mfa_required"): return response self._set_cookie( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + cookie_attrs=self.cookie_attributes + or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ), ) return response @@ -784,10 +794,13 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: self._set_tokens(user_id) if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): self._set_cookie( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + cookie_attrs=self.cookie_attributes + or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ) ) return {"success": is_valid} From 2f792b71ddf55580fc89cbf9c742820da5decae7 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 15 Apr 2025 15:56:37 +0300 Subject: [PATCH 055/139] Revert "Update security module:" This reverts commit 340d6cfc2e3d4ac2acfcfd0a7902a78ce3e9dfc1. --- src/tet/security/authentication.py | 91 +++++++++++++----------------- 1 file changed, 39 insertions(+), 52 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index ee6b3ee..bbcd634 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -31,7 +31,6 @@ "MultiFactorAuthenticationMethodMixin", "TetMultiFactorAuthenticationService", "TOTPData", - "CookieAttributes", ] @@ -192,25 +191,10 @@ def __init__(self): Please ensure that your credientials are correct and try again. """ - -@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 = False - httponly: bool = False - samesite: str = "Lax" - overwrite: bool = True - - DEFAULT_LOGIN_VIEW = "login" COOKIE_LOGIN_VIEW = "cookie_login" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() -DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() UTC = timezone.utc DEFAULT_EXPIRY_TIMESTAMP = datetime.now(UTC) + timedelta(hours=12) @@ -251,9 +235,8 @@ def set_token_authentication( access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, 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, + default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, refresh_token_route: str = DEFAULT_REFRESH_TOKEN_ROUTE, - cookie_attributes: tp.Optional[CookieAttributes] = None, security_policy: tp.Optional[ tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] ] = DEFAULT_SECURITY_POLICY, @@ -324,7 +307,7 @@ def home_view(request): jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). access_token_header: The header name for the access token (default: ``"X-Access-Token"``). long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). - jwt_claims: Default JWT registered claims to include in the token payload. + default_claims: Default JWT registered claims to include in the token payload. security_policy: A custom security policy to use for token authentication. """ @@ -337,8 +320,7 @@ def register(): config.registry.tet_auth_access_token_header = access_token_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_default_claims = default_claims config.registry.tet_auth_login_callback = login_callback config.registry.tet_auth_jwk_resolver = jwk_resolver @@ -557,7 +539,7 @@ def __init__(self, request: Request): 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 + self.default_claims: JWTRegisteredClaims = self.registry.tet_auth_default_claims def create_long_term_token( self, @@ -650,7 +632,7 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: if not user_id: raise ValueError("User ID is required") - payload = self.jwt_claims + payload = self.default_claims payload.user_id = user_id payload.iat = datetime.now(UTC) payload.exp = payload.iat + timedelta(minutes=self.jwt_expiration_mins) @@ -676,10 +658,10 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: 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, + leeway=self.default_claims.leeway, + audience=self.default_claims.aud, + subject=self.default_claims.sub, + issuer=self.default_claims.iss, ) return payload except jwt.ExpiredSignatureError: @@ -709,17 +691,30 @@ def __init__(self, request: Request): self.login_callback = self.registry.tet_auth_login_callback self.user_id = self.login_callback(self.request) self.security_policy = self.registry.tet_auth_security_policy - self.cookie_attributes: tp.Optional[CookieAttributes] = ( - self.registry.tet_auth_cookie_attributes - ) def _set_cookie( self, - cookie_attrs: CookieAttributes, + name, + value, + max_age, + domain=None, + secure=True, + httponly=True, + samesite="Lax", + overwrite=True, + path=DEFAULT_PATH, **kwargs, ): self.response.set_cookie( - **cookie_attrs.__dict__, + name=name, + value=value, + max_age=max_age, + domain=domain, + secure=secure, + httponly=httponly, + samesite=samesite, + overwrite=overwrite, + path=path, **kwargs, ) @@ -731,12 +726,10 @@ def _create_jwt(self, refresh_token: str) -> str: except ValueError as e: logger.exception(f"Error validating token: {e}") self._set_cookie( - cookie_attrs=CookieAttributes( - name=self.long_term_token_cookie_name, - value=None, - path=f"{self.route_prefix}/", - max_age=None, - ) + name=self.long_term_token_cookie_name, + value=None, + path=f"{self.route_prefix}/", + max_age=None, ) raise HTTPUnauthorized() from e @@ -766,13 +759,10 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re if isinstance(response, dict) and response.get("mfa_required"): return response self._set_cookie( - cookie_attrs=self.cookie_attributes - or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", - ), + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", ) return response @@ -794,13 +784,10 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: self._set_tokens(user_id) if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): self._set_cookie( - cookie_attrs=self.cookie_attributes - or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", - ) + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", ) return {"success": is_valid} From 17f9955b0f2571ac100863a8ccfacc7ba7e8e4b4 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 15 Apr 2025 16:26:25 +0300 Subject: [PATCH 056/139] Update security module: - Make the cookie attributes configurable --- src/tet/security/authentication.py | 85 ++++++++++++++++-------------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index bbcd634..64a518f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -27,10 +27,7 @@ "JWTCookieAuthenticationPolicy", "TokenMixin", "JWTRegisteredClaims", - "MultiFactorAuthMethodType", - "MultiFactorAuthenticationMethodMixin", - "TetMultiFactorAuthenticationService", - "TOTPData", + "CookieAttributes", ] @@ -191,10 +188,25 @@ def __init__(self): Please ensure that your credientials are correct and try again. """ + +@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 = False + httponly: bool = False + samesite: str = "Lax" + overwrite: bool = True + + DEFAULT_LOGIN_VIEW = "login" COOKIE_LOGIN_VIEW = "cookie_login" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() +DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() UTC = timezone.utc DEFAULT_EXPIRY_TIMESTAMP = datetime.now(UTC) + timedelta(hours=12) @@ -235,8 +247,9 @@ def set_token_authentication( access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, long_term_token_header: str = DEFAULT_LONG_TERM_TOKEN_NAME, long_term_token_cookie_name: str = DEFAULT_REFRESH_TOKEN_COOKIE_NAME, - default_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, + jwt_claims: JWTRegisteredClaims = DEFAULT_REGISTERED_CLAIMS, refresh_token_route: str = DEFAULT_REFRESH_TOKEN_ROUTE, + cookie_attributes: tp.Optional[CookieAttributes] = None, security_policy: tp.Optional[ tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] ] = DEFAULT_SECURITY_POLICY, @@ -307,7 +320,7 @@ def home_view(request): jwt_token_expiration_mins: JWT expiration time in minutes (default: 15). access_token_header: The header name for the access token (default: ``"X-Access-Token"``). long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). - default_claims: Default JWT registered claims to include in the token payload. + jwt_claims: Default JWT registered claims to include in the token payload. security_policy: A custom security policy to use for token authentication. """ @@ -320,7 +333,8 @@ def register(): config.registry.tet_auth_access_token_header = access_token_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_default_claims = default_claims + 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 @@ -539,7 +553,7 @@ def __init__(self, request: Request): 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.default_claims: JWTRegisteredClaims = self.registry.tet_auth_default_claims + self.jwt_claims: JWTRegisteredClaims = self.registry.tet_auth_jwt_claims def create_long_term_token( self, @@ -632,7 +646,7 @@ def create_short_term_jwt(self, user_id: tp.Any) -> str: if not user_id: raise ValueError("User ID is required") - payload = self.default_claims + payload = self.jwt_claims payload.user_id = user_id payload.iat = datetime.now(UTC) payload.exp = payload.iat + timedelta(minutes=self.jwt_expiration_mins) @@ -658,10 +672,10 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: token, self.registry.tet_auth_jwk_resolver(self.request), algorithms=[self.jwt_algorithm], - leeway=self.default_claims.leeway, - audience=self.default_claims.aud, - subject=self.default_claims.sub, - issuer=self.default_claims.iss, + leeway=self.jwt_claims.leeway, + audience=self.jwt_claims.aud, + subject=self.jwt_claims.sub, + issuer=self.jwt_claims.iss, ) return payload except jwt.ExpiredSignatureError: @@ -690,31 +704,17 @@ def __init__(self, request: Request): self.route_prefix = self.request.current_route_path().rpartition("/")[0] self.login_callback = self.registry.tet_auth_login_callback self.user_id = self.login_callback(self.request) - self.security_policy = self.registry.tet_auth_security_policy + self.cookie_attributes: tp.Optional[CookieAttributes] = ( + self.registry.tet_auth_cookie_attributes + ) def _set_cookie( self, - name, - value, - max_age, - domain=None, - secure=True, - httponly=True, - samesite="Lax", - overwrite=True, - path=DEFAULT_PATH, + cookie_attrs: CookieAttributes, **kwargs, ): self.response.set_cookie( - name=name, - value=value, - max_age=max_age, - domain=domain, - secure=secure, - httponly=httponly, - samesite=samesite, - overwrite=overwrite, - path=path, + **cookie_attrs.__dict__, **kwargs, ) @@ -726,10 +726,12 @@ def _create_jwt(self, refresh_token: str) -> str: except ValueError as e: logger.exception(f"Error validating token: {e}") self._set_cookie( - name=self.long_term_token_cookie_name, - value=None, - path=f"{self.route_prefix}/", - max_age=None, + cookie_attrs=CookieAttributes( + name=self.long_term_token_cookie_name, + value=None, + path=f"{self.route_prefix}/", + max_age=None, + ) ) raise HTTPUnauthorized() from e @@ -759,10 +761,13 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re if isinstance(response, dict) and response.get("mfa_required"): return response self._set_cookie( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + cookie_attrs=self.cookie_attributes + or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ), ) return response From 924bb5228d1c6f74adc0f04479130c8481afd504 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 15 Apr 2025 17:12:57 +0300 Subject: [PATCH 057/139] Fix broken tests --- src/tet/security/authentication.py | 6 +++++ tests/conftest.py | 25 +++++++++++-------- .../services/security/test_authentication.py | 3 +-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 64a518f..edf28fe 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -756,6 +756,12 @@ def login(self) -> dict[str, bool] | None: self._set_tokens(self.user_id) return response_payload + def jwt_token(self) -> str: + token = self.request.headers.get(self.long_term_token_header) + access_token = self._create_jwt(token) + self.response.headers[self.access_token_header] = access_token + return "ok" + def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: response = self.login() if isinstance(response, dict) and response.get("mfa_required"): diff --git a/tests/conftest.py b/tests/conftest.py index fa5e845..7956bb2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,13 @@ import json import logging - import typing as tp + import pytest from pyramid.request import Request from pyramid.response import Response from pyramid.security import Allow, Authenticated, Everyone, Deny from pyramid.testing import setUp, tearDown -from sqlalchemy import create_engine, or_ +from sqlalchemy import create_engine from sqlalchemy.orm import Session from tests.models.accounts import Base, Token, User @@ -58,15 +58,20 @@ def db_session(db_engine, pyramid_request, transaction_manager): def login_callback(request: Request) -> tp.Any: """This is just an example of a login callback. It should be defined by the pyramid app.""" + if not request.content_length: + return None + db_session = request.find_service(Session) - payload = request.json_body - # user_identity here could be an email, or username - user_identity = payload["user_identity"] - user = ( - db_session.query(User) - .filter(or_(User.email == user_identity, User.name == user_identity)) - .first() - ) + try: + payload = request.json_body + except Exception: + return None + + user_identity = payload.get("user_identity") + if not user_identity: + return None + + user: User = db_session.query(User).filter(User.email == user_identity).one_or_none() if not user: return None return user.id diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 6326fa8..e8ae2d3 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -8,7 +8,7 @@ from tests.models.accounts import User from tet.security.authentication import TetTokenService, JWTCookieAuthenticationPolicy -ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" +ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access_token" LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" LONG_TERM_TOKEN_HEADER_NAME = "x-long-token" ACCESS_TOKEN_HEADER_NAME = "x-access-token" @@ -94,7 +94,6 @@ def test_login_view_should_return_long_term_token(pyramid_test_app, capture_toke def test_auth_should_return_access_token(long_term_token, pyramid_test_app): headers = {LONG_TERM_TOKEN_HEADER_NAME: long_term_token} response = pyramid_test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) - assert response.status_code == 200 assert "x-access-token" in response.headers From c7df0de943dda796b88dba2f56d666a686fd2da9 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 15 Apr 2025 17:20:08 +0300 Subject: [PATCH 058/139] Update tests: - Add MFA models - Add required params for the set_token_authentication method --- src/tet/security/authentication.py | 11 +++++++---- tests/conftest.py | 4 +++- tests/models/accounts.py | 15 +++++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index edf28fe..7887dfd 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -795,10 +795,13 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: self._set_tokens(user_id) if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): self._set_cookie( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + cookie_attrs=self.cookie_attributes + or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ) ) return {"success": is_valid} diff --git a/tests/conftest.py b/tests/conftest.py index 7956bb2..cda5a26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session -from tests.models.accounts import Base, Token, User +from tests.models.accounts import Base, Token, User, MultiFactorAuthenticationMethod from tet.config import Configurator as tetConfigurator from tet.security.authentication import TokenAuthenticationPolicy, JWTCookieAuthenticationPolicy from tet.view import view_config @@ -152,6 +152,8 @@ def pyramid_app(security_policy, pyramid_config): login_callback=login_callback, jwk_resolver=jwk_resolver, security_policy=security_policy(), + user_model=User, + multi_factor_auth_method_model=MultiFactorAuthenticationMethod, ) pyramid_config.add_route("home", "/") pyramid_config.add_view( diff --git a/tests/models/accounts.py b/tests/models/accounts.py index d1a5d33..517317d 100644 --- a/tests/models/accounts.py +++ b/tests/models/accounts.py @@ -1,7 +1,7 @@ -from tet.security.authentication import TokenMixin +from tet.security.authentication import TokenMixin, MultiFactorAuthenticationMethodMixin from tet.sqlalchemy.password import UserPasswordMixin -from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey +from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey, UniqueConstraint from sqlalchemy import orm from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.schema import MetaData @@ -33,4 +33,15 @@ class Token(TokenMixin, Base): 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"), + ) + + __all__ = ["User", "Token", "Base", "metadata"] From d4a6ab78bec13667590c57be8709fae23f666710 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 11:00:18 +0300 Subject: [PATCH 059/139] Passing string in the query filter instead of Enum type --- src/tet/security/authentication.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 7887dfd..2ce5766 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -475,8 +475,8 @@ def get_or_create_method( """ existing_method = ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type) - .first() + .filter_by(user_id=user_id, method_type=method_type.value) + .one_or_none() ) if existing_method: @@ -497,7 +497,7 @@ def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType Disable a multi-factor 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 + user_id=user_id, method_type=method_type.value ).update({"is_active": False}) @staticmethod @@ -514,8 +514,8 @@ def get_method(self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType) """ return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type, is_active=True) - .first() + .filter_by(user_id=user_id, method_type=method_type.value, is_active=True) + .one_or_none() ) def get_active_methods_by_user_id(self, *, user_id: tp.Any): From 40187e94da73fc40332f1ac15e202ab7828908b0 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 11:21:12 +0300 Subject: [PATCH 060/139] Using string instead of method type when instantiate new mfa method. --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 2ce5766..dc987fa 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -485,7 +485,7 @@ def get_or_create_method( return existing_method new_mfa_method = self.tet_multi_factor_auth_method_model( - method_type=method_type, user_id=user_id, data=data + method_type=method_type.value, user_id=user_id, data=data ) self.session.add(new_mfa_method) From e38d19785437b1ed693b4900fa027a18960984e1 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 13:36:36 +0300 Subject: [PATCH 061/139] Switch back to enum type instead of string value when instantiate the mfa method --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index dc987fa..2ce5766 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -485,7 +485,7 @@ def get_or_create_method( return existing_method new_mfa_method = self.tet_multi_factor_auth_method_model( - method_type=method_type.value, user_id=user_id, data=data + method_type=method_type, user_id=user_id, data=data ) self.session.add(new_mfa_method) From 66dbbb03e7d96d4853c5b597532e1f82fd9cd430 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 15:23:36 +0300 Subject: [PATCH 062/139] PostgreSQL enum labels were uppercase (e.g. 'TOTP') due to default Enum behaviour. This caused a mismatch when comparing with lowercase values (e.g. 'totp'). Fix by setting values_callable to ensure lowercase enum values are used in DB. --- src/tet/security/authentication.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 2ce5766..d54b065 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -424,7 +424,11 @@ class MultiFactorAuthenticationMethodMixin: __tablename__ = "multi_factor_authentication_method" id = Column(Integer, primary_key=True) - method_type = Column(Enum(MultiFactorAuthMethodType), nullable=False, index=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=True, nullable=False) verified = Column(Boolean, default=False, nullable=False) @@ -475,7 +479,7 @@ def get_or_create_method( """ existing_method = ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type.value) + .filter_by(user_id=user_id, method_type=method_type) .one_or_none() ) @@ -508,13 +512,15 @@ def verify_totp(secret: tp.Any, token: tp.Any) -> bool: totp = pyotp.TOTP(secret) return totp.verify(token) - def get_method(self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType): + def get_method( + self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType, is_active: bool = True + ): """ Retrieve a multi-factor authentication method for a user. """ return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type.value, is_active=True) + .filter_by(user_id=user_id, method_type=method_type, is_active=is_active) .one_or_none() ) From 13cf17aba0ff19b54ecd4b9d673e93ee039c59a1 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 16:03:47 +0300 Subject: [PATCH 063/139] Update security module: - Only set cookie if the request come to mfa_challenge route in _verify_totp_by_user_id() --- src/tet/security/authentication.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index d54b065..9cc3830 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -724,6 +724,13 @@ def _set_cookie( **kwargs, ) + def _delete_cookie(self, *, name: str, path: str = "/", **kwargs): + self.response.delete_cookie( + name=name, + path=path, + **kwargs, + ) + def _create_jwt(self, refresh_token: str) -> str: try: token_from_db = self.token_service.retrieve_and_validate_token( @@ -731,13 +738,9 @@ def _create_jwt(self, refresh_token: str) -> str: ) except ValueError as e: logger.exception(f"Error validating token: {e}") - self._set_cookie( - cookie_attrs=CookieAttributes( - name=self.long_term_token_cookie_name, - value=None, - path=f"{self.route_prefix}/", - max_age=None, - ) + self._delete_cookie( + name=self.long_term_token_cookie_name, + path=f"{self.route_prefix}/", ) raise HTTPUnauthorized() from e @@ -799,7 +802,11 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: mfa_method.verified = True self._set_tokens(user_id) - if isinstance(self.security_policy, JWTCookieAuthenticationPolicy): + + if ( + isinstance(self.security_policy, JWTCookieAuthenticationPolicy) + and self.request.matched_route.name == "tet_auth_mfa_challenge" + ): self._set_cookie( cookie_attrs=self.cookie_attributes or CookieAttributes( From f14e999af7bcb5b4e05e746c0664d10d50432736 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 16:13:49 +0300 Subject: [PATCH 064/139] Set the cookie value from the response header if the cookie_attributes instance is present. --- src/tet/security/authentication.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9cc3830..3abfdad 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -775,6 +775,8 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re response = self.login() if isinstance(response, dict) and response.get("mfa_required"): return response + + self.cookie_attributes.value = self.response.headers[self.long_term_token_header] self._set_cookie( cookie_attrs=self.cookie_attributes or CookieAttributes( @@ -807,6 +809,7 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: isinstance(self.security_policy, JWTCookieAuthenticationPolicy) and self.request.matched_route.name == "tet_auth_mfa_challenge" ): + self.cookie_attributes.value = self.response.headers[self.long_term_token_header] self._set_cookie( cookie_attrs=self.cookie_attributes or CookieAttributes( From 5bab2139afbf9aae61a67d1f19c8970276ecc5b7 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 17 Apr 2025 16:22:13 +0300 Subject: [PATCH 065/139] Set cookie_attributes value only if it exist --- src/tet/security/authentication.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 3abfdad..523267a 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -775,8 +775,9 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re response = self.login() if isinstance(response, dict) and response.get("mfa_required"): return response + if self.cookie_attributes: + self.cookie_attributes.value = self.response.headers[self.long_term_token_header] - self.cookie_attributes.value = self.response.headers[self.long_term_token_header] self._set_cookie( cookie_attrs=self.cookie_attributes or CookieAttributes( @@ -809,7 +810,9 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: isinstance(self.security_policy, JWTCookieAuthenticationPolicy) and self.request.matched_route.name == "tet_auth_mfa_challenge" ): - self.cookie_attributes.value = self.response.headers[self.long_term_token_header] + if self.cookie_attributes: + self.cookie_attributes.value = self.response.headers[self.long_term_token_header] + self._set_cookie( cookie_attrs=self.cookie_attributes or CookieAttributes( From 67b0aaa759670e64b5ffc9d7579593bc37b8c0e7 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 22 Apr 2025 09:57:18 +0300 Subject: [PATCH 066/139] Update security module: - Add a default max_age if it is not set --- src/tet/security/authentication.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 523267a..9e4cd04 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -777,6 +777,8 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re return response if self.cookie_attributes: self.cookie_attributes.value = self.response.headers[self.long_term_token_header] + if not self.cookie_attributes.max_age: + self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 self._set_cookie( cookie_attrs=self.cookie_attributes @@ -812,6 +814,8 @@ def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: ): if self.cookie_attributes: self.cookie_attributes.value = self.response.headers[self.long_term_token_header] + if not self.cookie_attributes.max_age: + self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 self._set_cookie( cookie_attrs=self.cookie_attributes From e91189a6c897990fce174dd8459da7f0226b714a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 23 Apr 2025 15:43:09 +0300 Subject: [PATCH 067/139] Update security: - Disabling the MFA method should also set the verified state to False. --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9e4cd04..9499e47 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -502,7 +502,7 @@ def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType """ 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}) + ).update({"is_active": False, "verified": False}) @staticmethod def verify_totp(secret: tp.Any, token: tp.Any) -> bool: From 0a4429849595065624efdca5adf26d8e86df2532 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 23 Apr 2025 16:00:23 +0300 Subject: [PATCH 068/139] Update security module: - get_method should also check for verified state --- src/tet/security/authentication.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9499e47..154d097 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -513,14 +513,21 @@ def verify_totp(secret: tp.Any, token: tp.Any) -> bool: return totp.verify(token) def get_method( - self, *, user_id: tp.Any, method_type: MultiFactorAuthMethodType, is_active: bool = True + self, + *, + user_id: tp.Any, + method_type: MultiFactorAuthMethodType, + is_active: bool = True, + verified: bool = True, ): """ Retrieve a multi-factor authentication method for a user. """ return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type, is_active=is_active) + .filter_by( + user_id=user_id, method_type=method_type, is_active=is_active, verified=verified + ) .one_or_none() ) From b1aac816a753275c8241e0d04d97dec38ef87253 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 23 Apr 2025 16:07:43 +0300 Subject: [PATCH 069/139] Update security: - When calling the MFA verify route, it should fetch the desired method that has not yet been verified. --- src/tet/security/authentication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 154d097..9df9f15 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -798,11 +798,11 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def _verify_totp_by_user_id(self, user_id: tp.Any) -> dict: + def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dict: payload = self.request.json_body token = payload["token"] mfa_method = self.multi_factor_auth_service.get_method( - user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP + user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP, verified=verified ) secret = mfa_method.data.get("secret") is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) @@ -844,7 +844,7 @@ def mfa_verify(self) -> dict: user_id = self.request.authenticated_userid if not user_id: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - return self._verify_totp_by_user_id(user_id) + return self._verify_totp_by_user_id(user_id=user_id, verified=False) def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) From db22a5c0f40f528d354c86033cb2b20cdad700d9 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 23 Apr 2025 16:16:36 +0300 Subject: [PATCH 070/139] Refactor MFA method retrieval to improve filter logic --- src/tet/security/authentication.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9df9f15..7ea9be4 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -523,11 +523,16 @@ def get_method( """ Retrieve a multi-factor 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, + self.tet_multi_factor_auth_method_model.is_active == is_active, + ] + if verified: + conditions.append(self.tet_multi_factor_auth_method_model.verified == verified) return ( self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by( - user_id=user_id, method_type=method_type, is_active=is_active, verified=verified - ) + .filter(*conditions) .one_or_none() ) From c1bd9529ed558ec4f1fdc092837e37c6726a9673 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 09:56:31 +0300 Subject: [PATCH 071/139] Rename _set_tokens to _set_session_tokens for clarity --- src/tet/security/authentication.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 7ea9be4..b9a6604 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -760,7 +760,7 @@ def _create_jwt(self, refresh_token: str) -> str: return self.token_service.create_short_term_jwt(user_id) - def _set_tokens(self, user_id: str) -> None: + def _set_session_tokens(self, user_id: str) -> None: refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) access_token = self.token_service.create_short_term_jwt(user_id) self.response.headers[self.long_term_token_header] = refresh_token @@ -774,7 +774,7 @@ def login(self) -> dict[str, bool] | None: response_payload["mfa_required"] = True return response_payload - self._set_tokens(self.user_id) + self._set_session_tokens(self.user_id) return response_payload def jwt_token(self) -> str: @@ -818,7 +818,7 @@ def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dic mfa_method.mark_used() mfa_method.verified = True - self._set_tokens(user_id) + self._set_session_tokens(user_id) if ( isinstance(self.security_policy, JWTCookieAuthenticationPolicy) From 29fc0c31b49fdb454812534cf9fb8a13cbdc5f67 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 14:47:55 +0300 Subject: [PATCH 072/139] Refactor MFA method naming and behaviour for clarity and consistency - Renamed "multi-factor" to "multifactor" - Renamed `is_mfa_enabled` to `is_totp_mfa_enabled` to reflect method specificity - Modified `disable_method` to also clear stored MFA method data - Removed unused `description` argument in token generation - Updated docstrings for `mfa_challenge` and `mfa_verify` with clarified purpose - Ensured TOTP is the only supported MFA method for challenge/verify paths (For now) --- src/tet/security/authentication.py | 52 ++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index b9a6604..9a7270b 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -475,7 +475,7 @@ def get_or_create_method( self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict ) -> tp.Any: """ - Get or create a multi-factor authentication method for a user. + Get or create a multifactor authentication method for a user. """ existing_method = ( self.session.query(self.tet_multi_factor_auth_method_model) @@ -484,7 +484,6 @@ def get_or_create_method( ) if existing_method: - existing_method.data = data existing_method.is_active = True return existing_method @@ -498,16 +497,16 @@ def get_or_create_method( def disable_method(self, user_id: tp.Any, method_type: MultiFactorAuthMethodType): """ - Disable a multi-factor authentication method for a user. + 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}) + ).update({"is_active": False, "verified": False, "data": {}}) @staticmethod def verify_totp(secret: tp.Any, token: tp.Any) -> bool: """ - Verify a one-time password for multi-factor authentication. + Verify a one-time password for multifactor authentication. """ totp = pyotp.TOTP(secret) return totp.verify(token) @@ -521,7 +520,7 @@ def get_method( verified: bool = True, ): """ - Retrieve a multi-factor authentication method for a user. + Retrieve a multifactor authentication method for a user. """ conditions = [ self.tet_multi_factor_auth_method_model.user_id == user_id, @@ -538,7 +537,7 @@ def get_method( def get_active_methods_by_user_id(self, *, user_id: tp.Any): """ - Retrieve all multi-factor authentication methods by user id. + Retrieve all multifactor authentication methods by user id. """ return ( self.session.query(self.tet_multi_factor_auth_method_model) @@ -546,15 +545,16 @@ def get_active_methods_by_user_id(self, *, user_id: tp.Any): .all() ) - def is_mfa_enabled(self, user_id: tp.Any = None) -> bool: + def is_totp_mfa_enabled(self, user_id: tp.Any = None) -> bool: """ - Check if multi-factor authentication is enabled for the user. + 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 @@ -574,19 +574,14 @@ def __init__(self, request: Request): 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=DEFAULT_EXPIRY_TIMESTAMP, - description=None, + self, user_id: tp.Any, project_prefix: str, expire_timestamp=DEFAULT_EXPIRY_TIMESTAMP ) -> 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. - description: (Optional) Description for the token. + Expire_timestamp: (Optional) Expiration timestamp for the token. Returns: The plaintext long-term token with the project-specific prefix. @@ -770,7 +765,7 @@ def login(self) -> dict[str, bool] | None: if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) response_payload = {"success": True} - if self.multi_factor_auth_service.is_mfa_enabled(self.user_id): + if self.multi_factor_auth_service.is_totp_mfa_enabled(self.user_id): response_payload["mfa_required"] = True return response_payload @@ -841,14 +836,37 @@ def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dic return {"success": is_valid} def mfa_challenge(self) -> dict: + """ + Perform a multi-factor authentication (MFA) challenge during the login phase. + + This method verifies a time-based one-time password (TOTP) for the current user. + It raises an HTTP 401 error if no user ID is available, indicating that the + user is not authenticated. + + Returns: + dict: A dictionary with the verification result of the TOTP challenge. + """ if self.user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + # We only support the TOTP method for now return self._verify_totp_by_user_id(self.user_id) 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.request.authenticated_userid if not user_id: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + # We only support the TOTP method for now return self._verify_totp_by_user_id(user_id=user_id, verified=False) def jwt_token(self) -> str: From 6a714e6c6c2469e1f398440453da5c024b7c8b8a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 15:09:24 +0300 Subject: [PATCH 073/139] Update _verify_totp_by_user_id: - The secret should be loaded from the database or the user payload, depending on the request endpoint. --- src/tet/security/authentication.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9a7270b..625b222 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -804,7 +804,7 @@ def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dic mfa_method = self.multi_factor_auth_service.get_method( user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP, verified=verified ) - secret = mfa_method.data.get("secret") + secret = mfa_method.data.get("secret") if verified else payload["setup_key"] is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) if not is_valid: @@ -824,15 +824,14 @@ def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dic if not self.cookie_attributes.max_age: self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 - self._set_cookie( - cookie_attrs=self.cookie_attributes - or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", - ) + cookie_attrs = self.cookie_attributes or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", ) + + self._set_cookie(cookie_attrs=cookie_attrs) return {"success": is_valid} def mfa_challenge(self) -> dict: From d0ce7eb9b451719afdff39a31a268de2defb0b88 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 15:34:55 +0300 Subject: [PATCH 074/139] Update _verify_totp_by_user_id: - It should update the TOTP data from the user payload if the method has not yet been verified. --- src/tet/security/authentication.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 625b222..f12d64e 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -811,7 +811,14 @@ def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dic raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) mfa_method.mark_used() - mfa_method.verified = True + + if not verified: + data = TOTPData( + secret=secret, + issuer=self.project_prefix, + ) + mfa_method.verified = True + mfa_method.data = data.to_dict() self._set_session_tokens(user_id) From 6cfdb110f8ec18270a496b63e1433071d9dfb05e Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 15:41:54 +0300 Subject: [PATCH 075/139] Rename the MFA challenge route using a hyphen instead of an underscore --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index f12d64e..c7494d4 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -896,7 +896,7 @@ def includeme(config: Configurator): config.add_route("tet_auth_login", "login") config.add_route("tet_auth_jwt", "access_token") config.add_route("tet_auth_refresh_token", "refresh_token") - config.add_route("tet_auth_mfa_challenge", "mfa_challenge") + config.add_route("tet_auth_mfa_challenge", "mfa-challenge") config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") config.add_view( AuthViews, From 0fe64845e022bcfd044c82606ab948a643557325 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 15:43:45 +0300 Subject: [PATCH 076/139] Rename the get access token, and refresh token routes using a hyphen instead of an underscore. --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index c7494d4..b948457 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -894,8 +894,8 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") - config.add_route("tet_auth_jwt", "access_token") - config.add_route("tet_auth_refresh_token", "refresh_token") + config.add_route("tet_auth_jwt", "access-token") + config.add_route("tet_auth_refresh_token", "refresh-token") config.add_route("tet_auth_mfa_challenge", "mfa-challenge") config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") config.add_view( From c4dc172c36b485e6b4a47bf9ff62e3dddeb2c02c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 16:09:23 +0300 Subject: [PATCH 077/139] Update test: - Update the access token route --- tests/services/security/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index e8ae2d3..a0e4ab5 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -8,7 +8,7 @@ from tests.models.accounts import User from tet.security.authentication import TetTokenService, JWTCookieAuthenticationPolicy -ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access_token" +ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" LONG_TERM_TOKEN_HEADER_NAME = "x-long-token" ACCESS_TOKEN_HEADER_NAME = "x-access-token" From 35d9aeada9ca6d120ed3924c07aba686ff165c57 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 16:18:26 +0300 Subject: [PATCH 078/139] Refine MFA method activation logic for TOTP verification - Avoid reactivating already existing TOTP methods unnecessarily - Add `is_active` conditionally in method lookup queries - Ensure method is marked active upon successful TOTP verification - Rename variables for clarity and consistency --- src/tet/security/authentication.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index b948457..57227b2 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -484,7 +484,6 @@ def get_or_create_method( ) if existing_method: - existing_method.is_active = True return existing_method new_mfa_method = self.tet_multi_factor_auth_method_model( @@ -525,8 +524,9 @@ def get_method( conditions = [ self.tet_multi_factor_auth_method_model.user_id == user_id, self.tet_multi_factor_auth_method_model.method_type == method_type, - self.tet_multi_factor_auth_method_model.is_active == is_active, ] + 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) return ( @@ -798,27 +798,33 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re ) return response - def _verify_totp_by_user_id(self, user_id: tp.Any, verified: bool = True) -> dict: + def _verify_totp_by_user_id( + self, user_id: tp.Any, is_active: bool = True, verified: bool = True + ) -> dict: payload = self.request.json_body token = payload["token"] - mfa_method = self.multi_factor_auth_service.get_method( - user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP, verified=verified + totp_mfa_method = self.multi_factor_auth_service.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=is_active, + verified=verified, ) - secret = mfa_method.data.get("secret") if verified else payload["setup_key"] + secret = totp_mfa_method.data.get("secret") if verified else payload["setup_key"] is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) if not is_valid: raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) - mfa_method.mark_used() + totp_mfa_method.mark_used() if not verified: data = TOTPData( secret=secret, issuer=self.project_prefix, ) - mfa_method.verified = True - mfa_method.data = data.to_dict() + totp_mfa_method.verified = True + totp_mfa_method.is_active = True + totp_mfa_method.data = data.to_dict() self._set_session_tokens(user_id) @@ -873,7 +879,7 @@ def mfa_verify(self) -> dict: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) # We only support the TOTP method for now - return self._verify_totp_by_user_id(user_id=user_id, verified=False) + return self._verify_totp_by_user_id(user_id=user_id, verified=False, is_active=False) def jwt_token(self) -> str: token = self.request.headers.get(self.long_term_token_header) From ba7391b007f5245b0f6cee4ee8bb472b90f52918 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 24 Apr 2025 16:23:04 +0300 Subject: [PATCH 079/139] Update MultiFactorAuthenticationMethodMixin: - Set the default is_active state as False --- src/tet/security/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 57227b2..4c6c6b7 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -430,7 +430,7 @@ class MultiFactorAuthenticationMethodMixin: index=True, ) data = Column(JSONB, nullable=False, default=dict) - is_active = Column(Boolean, default=True, nullable=False) + 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) From b68a55077e21d0a0274028f93cfcc345b5a2b694 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 7 May 2025 15:24:47 +0300 Subject: [PATCH 080/139] Update security module: - Generate expiry_timestamp within the function scope instead of using the constant --- src/tet/security/authentication.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 4c6c6b7..96d9bef 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -208,7 +208,6 @@ class CookieAttributes: DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() UTC = timezone.utc -DEFAULT_EXPIRY_TIMESTAMP = datetime.now(UTC) + timedelta(hours=12) class ILoginCallback(tp.Protocol): @@ -574,7 +573,7 @@ def __init__(self, request: Request): 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=DEFAULT_EXPIRY_TIMESTAMP + 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. @@ -586,6 +585,9 @@ def create_long_term_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() From b06e0b49e588c0c67cca224f2354ad838fad41fe Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 7 May 2025 16:29:29 +0300 Subject: [PATCH 081/139] break everything --- src/tet/security/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 96d9bef..2a48b28 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -585,6 +585,7 @@ def create_long_term_token( Returns: The plaintext long-term token with the project-specific prefix. """ + raise TabError("break everything") if not expire_timestamp: expire_timestamp = datetime.now(UTC) + timedelta(hours=12) From 716ffe20facf2d9fbf74dda3576cd79ec25b9f1f Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 7 May 2025 16:40:03 +0300 Subject: [PATCH 082/139] Revert "break everything" This reverts commit 95eb623daee86a5aeec75339a5b1db7abc6f4975. --- src/tet/security/authentication.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 2a48b28..96d9bef 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -585,7 +585,6 @@ def create_long_term_token( Returns: The plaintext long-term token with the project-specific prefix. """ - raise TabError("break everything") if not expire_timestamp: expire_timestamp = datetime.now(UTC) + timedelta(hours=12) From 5f492b8c3817fc665ced683ab469e75a601bdbb3 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 16 May 2025 11:38:24 +0300 Subject: [PATCH 083/139] Add try/catch block for the _verify_totp_by_user_id method --- src/tet/security/authentication.py | 115 ++++++++++++++++++----------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 96d9bef..4650f47 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -11,7 +11,14 @@ from pyramid.authentication import CallbackAuthenticationPolicy from pyramid.authorization import ACLHelper from pyramid.config import Configurator -from pyramid.httpexceptions import HTTPForbidden, HTTPUnauthorized, HTTPFound +from pyramid.httpexceptions import ( + HTTPForbidden, + HTTPUnauthorized, + HTTPFound, + HTTPBadRequest, + HTTPException, + HTTPInternalServerError, +) from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request, Response from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated @@ -803,51 +810,69 @@ def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Re def _verify_totp_by_user_id( self, user_id: tp.Any, is_active: bool = True, verified: bool = True ) -> dict: - payload = self.request.json_body - token = payload["token"] - totp_mfa_method = self.multi_factor_auth_service.get_method( - user_id=user_id, - method_type=MultiFactorAuthMethodType.TOTP, - is_active=is_active, - verified=verified, - ) - secret = totp_mfa_method.data.get("secret") if verified else payload["setup_key"] - is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) - - if not is_valid: - raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) - - totp_mfa_method.mark_used() - - if not verified: - data = TOTPData( - secret=secret, - issuer=self.project_prefix, - ) - totp_mfa_method.verified = True - totp_mfa_method.is_active = True - totp_mfa_method.data = data.to_dict() - - self._set_session_tokens(user_id) - - if ( - isinstance(self.security_policy, JWTCookieAuthenticationPolicy) - and self.request.matched_route.name == "tet_auth_mfa_challenge" - ): - if self.cookie_attributes: - self.cookie_attributes.value = self.response.headers[self.long_term_token_header] - if not self.cookie_attributes.max_age: - self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 - - cookie_attrs = self.cookie_attributes or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + try: + payload = self.request.json_body + token = payload["token"] + totp_mfa_method = self.multi_factor_auth_service.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=is_active, + verified=verified, ) - - self._set_cookie(cookie_attrs=cookie_attrs) - return {"success": is_valid} + secret = totp_mfa_method.data.get("secret") if verified else payload.get("setup_key") + + if not secret: + raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) + + is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) + + if not is_valid: + raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + + totp_mfa_method.mark_used() + + if not verified: + data = TOTPData( + secret=secret, + issuer=self.project_prefix, + ) + totp_mfa_method.verified = True + totp_mfa_method.is_active = True + totp_mfa_method.data = data.to_dict() + + self._set_session_tokens(user_id) + + if ( + isinstance(self.security_policy, JWTCookieAuthenticationPolicy) + and self.request.matched_route.name == "tet_auth_mfa_challenge" + ): + if self.cookie_attributes: + self.cookie_attributes.value = self.response.headers[ + self.long_term_token_header + ] + if not self.cookie_attributes.max_age: + self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 + + cookie_attrs = self.cookie_attributes or CookieAttributes( + name=self.long_term_token_cookie_name, + value=self.response.headers[self.long_term_token_header], + max_age=self.long_term_token_expiration_mins * 60, + path=f"{self.route_prefix}/", + ) + + self._set_cookie(cookie_attrs=cookie_attrs) + return {"success": is_valid} + + except KeyError as e: + raise HTTPBadRequest( + json_body={"message": "Missing required field.", "details": str(e)} + ) from e + except HTTPException: + raise + except Exception as e: + raise HTTPInternalServerError( + json_body={"message": "TOTP verification failed.", "details": str(e)} + ) from e def mfa_challenge(self) -> dict: """ From fc776812cc3a80dae511225c542bce6f0d98a8d7 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 24 Jun 2025 15:39:11 +0300 Subject: [PATCH 084/139] Add api docs for the authentication module --- docs/authentication_apis.md | 413 ++++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/authentication_apis.md diff --git a/docs/authentication_apis.md b/docs/authentication_apis.md new file mode 100644 index 0000000..d39a4a7 --- /dev/null +++ b/docs/authentication_apis.md @@ -0,0 +1,413 @@ + +# 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 +{ + "current_password": "", + "new_password": "" +} +``` +### Return: +**200 Response:** +```json +{ + "success": true, + "message": "" +} +``` +**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": "", + "setup_key": "" +} +``` +### 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 +{ + "mfa_methods": [ /* list of methods */ ] +} +``` +**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) + +--- From 901fdabaaede7a76c9cc837670b52d164f7efe8b Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 24 Jun 2025 15:40:22 +0300 Subject: [PATCH 085/139] refactor(auth): rework authentication and MFA service classes, simplify views - Replaced AuthViews logic with service-based classes (TetAuthService, TetMultiFactorAuthenticationService). - Reduced view code duplication, moved business logic to services. - Improved JWT and cookie handling, set and validate tokens via service methods. - Updated MFA flow: now uses a single endpoint for challenge/verify, aligns with stateless auth. - Refactored Pyramid view registrations,and dropped unused views. - Removed legacy code, clarified naming, and ensured correct header/cookie usage throughout. --- src/tet/security/authentication.py | 538 +++++++++++++++-------------- 1 file changed, 274 insertions(+), 264 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 4650f47..9049bf1 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -14,13 +14,12 @@ from pyramid.httpexceptions import ( HTTPForbidden, HTTPUnauthorized, - HTTPFound, HTTPBadRequest, HTTPException, HTTPInternalServerError, ) from pyramid.interfaces import ISecurityPolicy -from pyramid.request import Request, Response +from pyramid.request import Request from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean @@ -34,6 +33,11 @@ "JWTCookieAuthenticationPolicy", "TokenMixin", "JWTRegisteredClaims", + "MultiFactorAuthMethodType", + "MultiFactorAuthenticationMethodMixin", + "TetMultiFactorAuthenticationService", + "TetTokenService", + "TOTPData", "CookieAttributes", ] @@ -209,7 +213,7 @@ class CookieAttributes: overwrite: bool = True -DEFAULT_LOGIN_VIEW = "login" +DEFAULT_LOGIN_ATTR = "login" COOKIE_LOGIN_VIEW = "cookie_login" DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() @@ -354,21 +358,6 @@ def register(): config.set_security_policy(security_policy) - login_view_attr = ( - COOKIE_LOGIN_VIEW - if isinstance(security_policy, JWTCookieAuthenticationPolicy) - else DEFAULT_LOGIN_VIEW - ) - config.add_view( - AuthViews, - attr=login_view_attr, - route_name="tet_auth_login", - renderer="json", - request_method="POST", - require_csrf=False, - permission=NO_PERMISSION_REQUIRED, - ) - @dataclasses.dataclass class TOTPData: @@ -468,105 +457,6 @@ class TokenMixin: expires_at = Column(DateTime(True), nullable=True) -class TetMultiFactorAuthenticationService(RequestScopedBaseService): - session: Session = autowired(Session) - - 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 - ) - - def get_or_create_method( - self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict - ) -> tp.Any: - """ - Get or create a multifactor authentication method for a user. - """ - existing_method = ( - self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type) - .one_or_none() - ) - - if existing_method: - return existing_method - - 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, - ): - """ - 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) - return ( - self.session.query(self.tet_multi_factor_auth_method_model) - .filter(*conditions) - .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 - ) - - class TetTokenService(RequestScopedBaseService): session: Session = autowired(Session) @@ -704,59 +594,59 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: return None -class AuthViews: - token_service: TetTokenService = autowired(TetTokenService) - multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( - TetMultiFactorAuthenticationService - ) - db_session: Session = autowired(Session) +class TetAuthService(RequestScopedBaseService): + session: Session = autowired(Session) + token_service = autowired(TetTokenService) def __init__(self, request: Request): - self.request = request - self.registry = request.registry - self.response = request.response - self.long_term_token_header = self.registry.tet_auth_long_term_token_header - self.access_token_header = self.registry.tet_auth_access_token_header - self.project_prefix = self.registry.tet_auth_project_prefix + 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.refresh_token_route = self.registry.tet_auth_refresh_token_route - self.route_prefix = self.request.current_route_path().rpartition("/")[0] - self.login_callback = self.registry.tet_auth_login_callback - self.user_id = self.login_callback(self.request) - self.cookie_attributes: tp.Optional[CookieAttributes] = ( - self.registry.tet_auth_cookie_attributes - ) - def _set_cookie( + def set_cookies( self, - cookie_attrs: CookieAttributes, + cookie_attributes: CookieAttributes, **kwargs, ): - self.response.set_cookie( + route_prefix = kwargs.pop("route_prefix") + refresh_token = kwargs.pop("refresh_token") + + if cookie_attributes: + cookie_attributes.value = refresh_token + if not cookie_attributes.max_age: + cookie_attributes.max_age = 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=f"{route_prefix}/", + ) + self.request.response.set_cookie( **cookie_attrs.__dict__, **kwargs, ) - def _delete_cookie(self, *, name: str, path: str = "/", **kwargs): - self.response.delete_cookie( + def delete_cookie(self, *, name: str, path: str = "/", **kwargs): + self.request.response.delete_cookie( name=name, path=path, **kwargs, ) - def _create_jwt(self, refresh_token: str) -> str: + def validate_and_create_jwt(self, refresh_token: str, route_prefix: str) -> str: try: token_from_db = self.token_service.retrieve_and_validate_token( refresh_token, self.project_prefix ) except ValueError as e: logger.exception(f"Error validating token: {e}") - self._delete_cookie( + self.delete_cookie( name=self.long_term_token_cookie_name, - path=f"{self.route_prefix}/", + path=f"{route_prefix}/", ) raise HTTPUnauthorized() from e @@ -764,105 +654,194 @@ def _create_jwt(self, refresh_token: str) -> str: return self.token_service.create_short_term_jwt(user_id) - def _set_session_tokens(self, user_id: str) -> None: - refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) - access_token = self.token_service.create_short_term_jwt(user_id) - self.response.headers[self.long_term_token_header] = refresh_token - self.response.headers[self.access_token_header] = access_token - def login(self) -> dict[str, bool] | None: - if self.user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - response_payload = {"success": True} - if self.multi_factor_auth_service.is_totp_mfa_enabled(self.user_id): - response_payload["mfa_required"] = True - return response_payload +class TetMultiFactorAuthenticationService(RequestScopedBaseService): + session: Session = autowired(Session) + token_service: TetTokenService = autowired(TetTokenService) + auth_service: TetAuthService = autowired(TetAuthService) - self._set_session_tokens(self.user_id) - return response_payload + 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.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 jwt_token(self) -> str: - token = self.request.headers.get(self.long_term_token_header) - access_token = self._create_jwt(token) - self.response.headers[self.access_token_header] = access_token - return "ok" - - def cookie_login(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, None, Response]: - response = self.login() - if isinstance(response, dict) and response.get("mfa_required"): - return response - if self.cookie_attributes: - self.cookie_attributes.value = self.response.headers[self.long_term_token_header] - if not self.cookie_attributes.max_age: - self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 - - self._set_cookie( - cookie_attrs=self.cookie_attributes - or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", - ), + def get_or_create_method( + self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict + ) -> tp.Any: + """ + Get or create a multifactor authentication method for a user. + """ + existing_method = ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter_by(user_id=user_id, method_type=method_type) + .one_or_none() ) - return response - def _verify_totp_by_user_id( - self, user_id: tp.Any, is_active: bool = True, verified: bool = True - ) -> dict: + if existing_method: + return existing_method + + 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, + ): + """ + 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) + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter(*conditions) + .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, setup_key: tp.Any) -> dict: try: - payload = self.request.json_body - token = payload["token"] - totp_mfa_method = self.multi_factor_auth_service.get_method( + totp_mfa_method = self.get_method( user_id=user_id, method_type=MultiFactorAuthMethodType.TOTP, - is_active=is_active, - verified=verified, + is_active=False, + verified=False, ) - secret = totp_mfa_method.data.get("secret") if verified else payload.get("setup_key") + if not totp_mfa_method: + raise HTTPForbidden( + json_body={"message": "Two-factor authentication method not found."} + ) - if not secret: + if not setup_key: raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) - is_valid = self.multi_factor_auth_service.verify_totp(secret=secret, token=token) + 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() - if not verified: - data = TOTPData( - secret=secret, - issuer=self.project_prefix, - ) - totp_mfa_method.verified = True - totp_mfa_method.is_active = True - totp_mfa_method.data = data.to_dict() - - self._set_session_tokens(user_id) - - if ( - isinstance(self.security_policy, JWTCookieAuthenticationPolicy) - and self.request.matched_route.name == "tet_auth_mfa_challenge" - ): - if self.cookie_attributes: - self.cookie_attributes.value = self.response.headers[ - self.long_term_token_header - ] - if not self.cookie_attributes.max_age: - self.cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 - - cookie_attrs = self.cookie_attributes or CookieAttributes( - name=self.long_term_token_cookie_name, - value=self.response.headers[self.long_term_token_header], - max_age=self.long_term_token_expiration_mins * 60, - path=f"{self.route_prefix}/", + data = TOTPData( + secret=setup_key, + issuer=self.project_prefix, + ) + totp_mfa_method.verified = True + totp_mfa_method.is_active = True + totp_mfa_method.data = data.to_dict() + return {"success": is_valid} + except KeyError as e: + raise HTTPBadRequest( + json_body={"message": "Missing required field.", "details": str(e)} + ) from e + except HTTPException: + raise + except Exception as e: + raise HTTPInternalServerError( + json_body={"message": "TOTP verification failed.", "details": str(e)} + ) from e + + def handle_totp_challenge( + self, + user_id: tp.Any, + totp_token: str = None, + cookie_attributes: CookieAttributes = None, + route_prefix: str = None, + ) -> dict[str, tp.Any]: + try: + totp_mfa_method = self.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + if not totp_mfa_method: + raise HTTPForbidden( + json_body={"message": "Two-factor authentication method not found."} ) - self._set_cookie(cookie_attrs=cookie_attrs) - return {"success": is_valid} + 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."}) + + totp_mfa_method.mark_used() + refresh_token = self.token_service.create_long_term_token(user_id, 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, + route_prefix=route_prefix, + ) + return {"success": is_valid, "access_token": access_token} except KeyError as e: raise HTTPBadRequest( json_body={"message": "Missing required field.", "details": str(e)} @@ -874,22 +853,58 @@ def _verify_totp_by_user_id( json_body={"message": "TOTP verification failed.", "details": str(e)} ) from e - def mfa_challenge(self) -> dict: - """ - Perform a multi-factor authentication (MFA) challenge during the login phase. - This method verifies a time-based one-time password (TOTP) for the current user. - It raises an HTTP 401 error if no user ID is available, indicating that the - user is not authenticated. +class AuthViews: + token_service: TetTokenService = autowired(TetTokenService) + auth_service: TetAuthService = autowired(TetAuthService) + multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( + TetMultiFactorAuthenticationService + ) + db_session: Session = autowired(Session) - Returns: - dict: A dictionary with the verification result of the TOTP challenge. - """ - if self.user_id is None: + 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.refresh_token_route = self.registry.tet_auth_refresh_token_route + self.route_prefix = self.request.current_route_path().rpartition("/")[0] + self.login_callback = self.registry.tet_auth_login_callback + self.cookie_attributes: tp.Optional[CookieAttributes] = ( + self.registry.tet_auth_cookie_attributes + ) + + def login(self) -> dict[str, tp.Any]: + user_id = self.login_callback(self.request) + if user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - # We only support the TOTP method for now - return self._verify_totp_by_user_id(self.user_id) + payload = self.request.json_body + totp_token = payload.get("token") + response_payload: dict[str, tp.Any] = {"success": True} + refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) + + if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): + if not totp_token: + response_payload["mfa_required"] = True + return response_payload + + return self.multi_factor_auth_service.handle_totp_challenge( + user_id=user_id, totp_token=totp_token + ) + + self.auth_service.set_cookies( + cookie_attributes=self.cookie_attributes, + refresh_token=refresh_token, + route_prefix=self.route_prefix, + ) + response_payload["access_token"] = access_token + return response_payload def mfa_verify(self) -> dict: """ @@ -905,41 +920,29 @@ def mfa_verify(self) -> dict: if not user_id: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - # We only support the TOTP method for now - return self._verify_totp_by_user_id(user_id=user_id, verified=False, is_active=False) - - def jwt_token(self) -> str: - token = self.request.headers.get(self.long_term_token_header) - access_token = self._create_jwt(token) - self.response.headers[self.access_token_header] = access_token - - return "ok" + payload = self.request.json_body + token = payload["token"] + setup_key = payload["setup_key"] + return self.multi_factor_auth_service.handle_totp_verify( + user_id=user_id, token=token, setup_key=setup_key + ) - def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], str, HTTPUnauthorized, None]: + 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: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - access_token = self._create_jwt(refresh_token) - self.response.headers[self.access_token_header] = access_token - return {"success": True} + + access_token = self.auth_service.validate_and_create_jwt( + refresh_token=refresh_token, route_prefix=self.route_prefix + ) + return {"success": True, "access_token": access_token} def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") - config.add_route("tet_auth_jwt", "access-token") - config.add_route("tet_auth_refresh_token", "refresh-token") - config.add_route("tet_auth_mfa_challenge", "mfa-challenge") + config.add_route("tet_auth_refresh_token", "/token/refresh") config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") - config.add_view( - AuthViews, - attr="jwt_token", - route_name="tet_auth_jwt", - renderer="string", - request_method="GET", - require_csrf=False, - permission=NO_PERMISSION_REQUIRED, - ) config.add_view( AuthViews, attr="refresh_token", @@ -949,24 +952,26 @@ def includeme(config: Configurator): require_csrf=False, permission=NO_PERMISSION_REQUIRED, ) + config.add_view( AuthViews, - attr="mfa_challenge", - route_name="tet_auth_mfa_challenge", + attr="mfa_verify", + route_name="tet_auth_mfa_verify", 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", + 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") @@ -978,5 +983,10 @@ def includeme(config: Configurator): TetMultiFactorAuthenticationService, Interface, ) + config.register_service_factory( + lambda ctx, req: TetAuthService(request=req), + TetAuthService, + Interface, + ) config.set_default_permission("view") From eabcf9fb24a125f49f689bafaa90873c6b0db672 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 24 Jun 2025 15:42:36 +0300 Subject: [PATCH 086/139] test(auth): update authentication tests for new login and token endpoints - Refactored to use unified /login endpoint for both refresh and access token retrieval. - Adjusted fixtures and assertions to reflect new cookie-based refresh token and returned access token in JSON. - Removed tests for deprecated endpoints. - Updated monkeypatching for both refresh and access token capture. - Ensured compatibility with new authentication service logic. --- .../services/security/test_authentication.py | 103 ++++++++++-------- 1 file changed, 57 insertions(+), 46 deletions(-) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index a0e4ab5..63c3284 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -6,11 +6,9 @@ from webtest import TestApp from tests.models.accounts import User -from tet.security.authentication import TetTokenService, JWTCookieAuthenticationPolicy +from tet.security.authentication import TetTokenService -ACCESS_TOKEN_ENDPOINT = "/api/v1/auth/access-token" -LONG_TERM_TOKEN_ENDPOINT = "/api/v1/auth/login" -LONG_TERM_TOKEN_HEADER_NAME = "x-long-token" +LOGIN_ENDPOINT = "/api/v1/auth/login" ACCESS_TOKEN_HEADER_NAME = "x-access-token" LONG_TERM_TOKEN_COOKIE_NAME = "refresh-token" ACCESS_TOKEN_COOKIE_NAME = "access-token" @@ -23,15 +21,19 @@ def pyramid_test_app(request, pyramid_app): @pytest.fixture() -def long_term_token(pyramid_test_app, capture_token): +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( - LONG_TERM_TOKEN_ENDPOINT, + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) - return response.headers[LONG_TERM_TOKEN_HEADER_NAME] + 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): @@ -62,52 +64,60 @@ 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 wrapper(*args, **kwargs): + def create_long_term_token_wrapper(*args, **kwargs): token = create_long_term_token(*args, **kwargs) - captured_data["token"] = token + captured_data["refresh_token"] = token return token - monkeypatch.setattr(TetTokenService, "create_long_term_token", wrapper) + 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): +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=LONG_TERM_TOKEN_ENDPOINT, + url=LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) assert response.status_code == 200 - # Validate the token captured by monkeypatch - refresh_token = response.headers[LONG_TERM_TOKEN_HEADER_NAME] - assert capture_token["token"] == refresh_token + 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_auth_should_return_access_token(long_term_token, pyramid_test_app): - headers = {LONG_TERM_TOKEN_HEADER_NAME: long_term_token} - response = pyramid_test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) - assert response.status_code == 200 - - assert "x-access-token" in response.headers - assert response.headers["x-access-token"] is not None - - -def test_access_token_should_work_to_access_protected_route(long_term_token, pyramid_test_app): - headers = {"x-long-token": long_term_token} - response = pyramid_test_app.get(ACCESS_TOKEN_ENDPOINT, headers=headers, status=200) +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"] - access_token = response.headers["x-access-token"] - assert access_token is not None +def test_access_token_should_work_to_access_protected_route( + authentication_tokens, pyramid_test_app +): + refresh_token, access_token = authentication_tokens headers = {"x-access-token": access_token} response = pyramid_test_app.get(HOME_ROUTE, headers=headers, status=200) @@ -120,7 +130,7 @@ 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=LONG_TERM_TOKEN_ENDPOINT, + url=LOGIN_ENDPOINT, params=json.dumps({"user_identity": "invalid_user", "password": "wrong_password"}), content_type="application/json", status=401, @@ -136,17 +146,18 @@ def test_it_should_store_the_token_in_the_database( tet_token_service = TetTokenService(request=pyramid_request) data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = pyramid_test_app.post( - url=LONG_TERM_TOKEN_ENDPOINT, + url=LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) + data = response.json assert response.status_code == 200 - refresh_token = response.headers[LONG_TERM_TOKEN_HEADER_NAME] + 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 "token" in capture_token - assert capture_token["token"] == refresh_token - + 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 @@ -194,14 +205,14 @@ def test_login_view_should_return_refresh_token( app = pyramid_test_app_with_jwt_cookie_policy data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = app.post( - LONG_TERM_TOKEN_ENDPOINT, + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) assert response.status_code == 200 - assert refresh_token == capture_token["token"] + assert refresh_token == capture_token["refresh_token"] def test_login_view_should_return_access_token( @@ -211,16 +222,16 @@ def test_login_view_should_return_access_token( app = pyramid_test_app_with_jwt_cookie_policy data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = app.post( - LONG_TERM_TOKEN_ENDPOINT, + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) + data = response.json refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) assert response.status_code == 200 - assert refresh_token == capture_token["token"] - assert ACCESS_TOKEN_HEADER_NAME in response.headers - assert response.headers[ACCESS_TOKEN_HEADER_NAME] is not None + assert refresh_token == capture_token["refresh_token"] + assert capture_token["access_token"] == data["access_token"] def test_access_token_should_work_to_access_protected_route_with_new_policy( @@ -230,17 +241,17 @@ def test_access_token_should_work_to_access_protected_route_with_new_policy( app = pyramid_test_app_with_jwt_cookie_policy data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) response = app.post( - LONG_TERM_TOKEN_ENDPOINT, + LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) + response_data = response.json refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) + access_token = response_data.get("access_token") assert response.status_code == 200 - assert refresh_token == capture_token["token"] - - access_token = response.headers[ACCESS_TOKEN_HEADER_NAME] - assert access_token is not None + assert refresh_token == capture_token["refresh_token"] + assert capture_token["access_token"] == access_token headers = {ACCESS_TOKEN_HEADER_NAME: access_token} response = app.get(HOME_ROUTE, headers=headers, status=200) From 92c4647b1c924a305c6db7d1f56a0e26117a0f17 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 24 Jun 2025 17:02:17 +0300 Subject: [PATCH 087/139] feat(auth): add password change endpoint and enforce password security - Introduce `change_password` endpoint for users - Add password strength validation and breach check - Require current password for change - Invalidate other active tokens upon change - Refactor: use `db_session` consistently for clarity --- src/tet/security/authentication.py | 136 +++++++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 8 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9049bf1..41b1476 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -3,11 +3,12 @@ import hashlib import logging import secrets +import requests import typing as tp -from datetime import datetime, timedelta, timezone - import jwt import pyotp + +from datetime import datetime, timedelta, timezone from pyramid.authentication import CallbackAuthenticationPolicy from pyramid.authorization import ACLHelper from pyramid.config import Configurator @@ -23,6 +24,7 @@ from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean +from sqlalchemy.sql import delete from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session from zope.interface import Interface, implementer @@ -42,6 +44,12 @@ ] +@dataclasses.dataclass +class PasswordChangeData: + current_password: str + new_password: str + + @dataclasses.dataclass class JWTRegisteredClaims: """ @@ -219,6 +227,10 @@ class CookieAttributes: DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() UTC = timezone.utc +MIN_PASSWORD_LENGTH = 12 +MAX_PASSWORD_LENGTH = 128 +MIN_SCORE = 2 +KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM = "settings.profile.changePasswordForm" class ILoginCallback(tp.Protocol): @@ -458,12 +470,13 @@ class TokenMixin: class TetTokenService(RequestScopedBaseService): - session: Session = autowired(Session) + 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 @@ -495,8 +508,8 @@ def create_long_term_token( ) setattr(stored_token, self.user_id_column, user_id) - self.session.add(stored_token) - self.session.flush() + self.db_session.add(stored_token) + self.db_session.flush() token_id = stored_token.id.to_bytes(8, "little") payload = token_id + secret @@ -529,7 +542,7 @@ def retrieve_and_validate_token(self, token: str, prefix: str) -> tp.Any: token_id = int.from_bytes(token_id_bytes, "little") token_from_db = ( - self.session.query(self.long_term_token_model) + self.db_session.query(self.long_term_token_model) .filter(self.long_term_token_model.id == token_id) .one_or_none() ) @@ -593,9 +606,28 @@ def verify_jwt(self, token: str) -> tp.Optional[tp.Dict[str, tp.Any]]: except jwt.ExpiredSignatureError: 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) + class TetAuthService(RequestScopedBaseService): - session: Session = autowired(Session) + db_session: Session = autowired(Session) token_service = autowired(TetTokenService) def __init__(self, request: Request): @@ -605,6 +637,7 @@ def __init__(self, request: Request): 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 def set_cookies( self, @@ -654,6 +687,68 @@ def validate_and_create_jwt(self, refresh_token: str, route_prefix: str) -> str: return self.token_service.create_short_term_jwt(user_id) + def verify_password(self, user: tp.Any, password: str) -> bool: + return user.verify_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}" + response = requests.get(url) + response.raise_for_status() + + 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) -> dict: + self.password_change_validation(payload=payload, user=user) + user.password = payload.new_password + self.db_session.flush() + + 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 + class TetMultiFactorAuthenticationService(RequestScopedBaseService): session: Session = autowired(Session) @@ -937,12 +1032,37 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: ) return {"success": True, "access_token": access_token} + def change_password(self): + user_id = self.request.authenticated_userid + if user_id is None: + raise HTTPUnauthorized( + json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} + ) + data = self.request.json_body + payload = PasswordChangeData( + current_password=data["currentPassword"], + new_password=data["newPassword"], + ) + user = self.auth_service.get_current_user(user_id) + response = self.auth_service.change_password(payload=payload, user=user) + self.token_service.delete_other_tokens(user=user) + return response + def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" config.add_route("tet_auth_login", "login") config.add_route("tet_auth_refresh_token", "/token/refresh") config.add_route("tet_auth_mfa_verify", "/mfa/app/verify") + config.add_route("tet_auth_change_password", "/users/me/password") + 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", From c81c7d06aa116dbb35a3d82ea34fd06a093b847c Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 25 Jun 2025 19:19:16 +0300 Subject: [PATCH 088/139] Add full MFA management endpoints, TOTP setup with QR, and token revocation - Add endpoints for listing, disabling, and generating MFA methods (TOTP) - Implement TOTP setup incl. QR code SVG image, secret generation, and provisioning URI - Add endpoint to revoke all other tokens after password confirmation - Add logout endpoint that deletes current token and clears cookie - Refactor password change to return validation result - Register new routes and views in includeme --- src/tet/security/authentication.py | 186 ++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 41b1476..a25e541 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -4,6 +4,10 @@ import logging import secrets import requests +import io +import base64 +import qrcode +import qrcode.image.svg import typing as tp import jwt import pyotp @@ -21,6 +25,7 @@ ) from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request +from pyramid.response import Response from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean @@ -625,6 +630,17 @@ def delete_other_tokens(self, *, user: tp.Any = None) -> None: ] self._delete_execution(condition) + 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) + class TetAuthService(RequestScopedBaseService): db_session: Session = autowired(Session) @@ -720,9 +736,10 @@ def get_current_user(self, user_id: tp.Any) -> tp.Optional[tp.Any]: ) def change_password(self, payload: PasswordChangeData, user: tp.Any) -> dict: - self.password_change_validation(payload=payload, user=user) + 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): @@ -789,6 +806,17 @@ def get_or_create_method( self.session.flush() return new_mfa_method + 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. @@ -948,6 +976,50 @@ def handle_totp_challenge( json_body={"message": "TOTP verification failed.", "details": str(e)} ) from e + @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: tp.Union[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 not existing_method: + self.create_method( + method_type=MultiFactorAuthMethodType.TOTP, + user_id=user.id, + data=data.to_dict(), + ) + 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") + class AuthViews: token_service: TetTokenService = autowired(TetTokenService) @@ -1048,13 +1120,123 @@ def change_password(self): self.token_service.delete_other_tokens(user=user) return response + def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: + try: + user_id = self.request.authenticated_userid + user = self.auth_service.get_current_user(user_id=user_id) + self.token_service.delete_token(user=user) + self.response.delete_cookie( + name=self.long_term_token_cookie_name, + path=f"{self.route_prefix}/", + ) + except Exception as e: + logger.exception(e) + return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) + return {"success": True} + + def disable_mfa_method(self): + user_id = self.request.authenticated_userid + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + payload = self.request.json_body + mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) + if not mfa_method_type: + raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) + try: + self.multi_factor_auth_service.disable_method( + user_id=user_id, method_type=mfa_method_type + ) + except Exception as e: + logger.exception(e) + return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) + return {"success": True} + + def revoke_other_tokens(self): + user_id = self.request.authenticated_userid + user = self.auth_service.get_current_user(user_id=user_id) + payload = self.request.json_body + if user is None or 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) + return {"success": True} + + def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: + user_id = self.request.authenticated_userid + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + 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]} + + def generate_mfa_totp(self): + user_id = self.request.authenticated_userid + user = self.auth_service.get_current_user(user_id=user_id) + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + payload = self.request.json_body + if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: + return self.multi_factor_auth_service.handle_totp_setup( + user=user, project_prefix=self.project_prefix + ) + return None + def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" 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_mfa_verify", "/mfa/app/verify") 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, + ) config.add_view( AuthViews, attr="change_password", From b0a67c877543bb6f0bd6325dd4ab80608deff85a Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 26 Jun 2025 14:06:09 +0300 Subject: [PATCH 089/139] Make logout API public. --- src/tet/security/authentication.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index a25e541..00c6808 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1236,6 +1236,7 @@ def includeme(config: Configurator): request_method="POST", renderer="json", require_csrf=False, + permission=NO_PERMISSION_REQUIRED, ) config.add_view( AuthViews, From e29b48f1e6960765e4b1366b482419eb8910cf67 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 26 Jun 2025 15:46:40 +0300 Subject: [PATCH 090/139] Update the return data of change_password --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 00c6808..a0db56b 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1116,9 +1116,9 @@ def change_password(self): new_password=data["newPassword"], ) user = self.auth_service.get_current_user(user_id) - response = self.auth_service.change_password(payload=payload, user=user) + is_valid = self.auth_service.change_password(payload=payload, user=user) self.token_service.delete_other_tokens(user=user) - return response + return {"success": is_valid} def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: try: From 3caac546504f7cfad1367d1e36e8fef9dbaa2d59 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 26 Jun 2025 16:02:38 +0300 Subject: [PATCH 091/139] Add a proper error handling for the change_password view --- src/tet/security/authentication.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index a0db56b..d351483 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -735,7 +735,7 @@ def get_current_user(self, user_id: tp.Any) -> tp.Optional[tp.Any]: .one_or_none() ) - def change_password(self, payload: PasswordChangeData, user: tp.Any) -> dict: + 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() @@ -1110,15 +1110,18 @@ def change_password(self): raise HTTPUnauthorized( json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} ) - 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) - return {"success": is_valid} + try: + 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) + return {"success": is_valid} + except ValueError as e: + return HTTPForbidden(json_body={"message": str(e), "success": False}) def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: try: From b1da82a5b4961d93cbfac90d393ac2643a98c7be Mon Sep 17 00:00:00 2001 From: longnguyen Date: Tue, 1 Jul 2025 17:15:25 +0300 Subject: [PATCH 092/139] feat: add structured auth events and registry notifications - Introduce tet/security/events.py with typed Pyramid events for auth flows - Emit events on login, logout, MFA, password change, and token revocation - Add registry.notify calls in relevant AuthViews and services - Fix login route path - Remove unused get_or_create_method for MFA - Improve code ordering and import style --- src/tet/security/authentication.py | 69 +++++++++---------- src/tet/security/events.py | 103 +++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 38 deletions(-) create mode 100644 src/tet/security/events.py diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index d351483..7ea42f0 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,18 +1,18 @@ +import base64 import dataclasses import enum import hashlib +import io import logging import secrets -import requests -import io -import base64 -import qrcode -import qrcode.image.svg import typing as tp +from datetime import datetime, timedelta, timezone + import jwt import pyotp - -from datetime import datetime, timedelta, timezone +import qrcode +import qrcode.image.svg +import requests from pyramid.authentication import CallbackAuthenticationPolicy from pyramid.authorization import ACLHelper from pyramid.config import Configurator @@ -29,11 +29,13 @@ from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated from pyramid_di import RequestScopedBaseService, autowired from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean -from sqlalchemy.sql import delete from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session +from sqlalchemy.sql import delete from zope.interface import Interface, implementer +from tet.security.events import * + logger = logging.getLogger(__name__) __all__ = [ "TokenAuthenticationPolicy", @@ -783,29 +785,6 @@ def __init__(self, request: Request): self.registry.tet_auth_long_term_token_expiration_mins ) - def get_or_create_method( - self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict - ) -> tp.Any: - """ - Get or create a multifactor authentication method for a user. - """ - existing_method = ( - self.session.query(self.tet_multi_factor_auth_method_model) - .filter_by(user_id=user_id, method_type=method_type) - .one_or_none() - ) - - if existing_method: - return existing_method - - 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 create_method(self, *, method_type: MultiFactorAuthMethodType, user_id: tp.Any, data: dict): """ Create a new multifactor authentication method for a user. @@ -964,14 +943,15 @@ def handle_totp_challenge( refresh_token=refresh_token, route_prefix=route_prefix, ) + self.registry.notify(MfaLoginSuccessEvent(request=self.request)) return {"success": is_valid, "access_token": access_token} except KeyError as e: + self.registry.notify(MfaLoginFailedEvent(request=self.request)) raise HTTPBadRequest( json_body={"message": "Missing required field.", "details": str(e)} ) from e - except HTTPException: - raise except Exception as e: + self.registry.notify(MfaLoginFailedEvent(request=self.request)) raise HTTPInternalServerError( json_body={"message": "TOTP verification failed.", "details": str(e)} ) from e @@ -1046,8 +1026,12 @@ def __init__(self, request: Request): ) def login(self) -> dict[str, tp.Any]: + self.registry.notify(LoginSuccessEvent(request=self.request)) + return {"success": True} + user_id = self.login_callback(self.request) if user_id is None: + self.registry.notify(LoginFailedEvent(request=self.request)) raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) payload = self.request.json_body @@ -1071,6 +1055,8 @@ def login(self) -> dict[str, tp.Any]: route_prefix=self.route_prefix, ) response_payload["access_token"] = access_token + + self.registry.notify(LoginSuccessEvent(request=self.request)) return response_payload def mfa_verify(self) -> dict: @@ -1107,6 +1093,7 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: def change_password(self): user_id = self.request.authenticated_userid if user_id is None: + self.registry.notify(ChangePasswordFailedEvent(request=self.request)) raise HTTPUnauthorized( json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} ) @@ -1119,8 +1106,10 @@ def change_password(self): 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(ChangePasswordSuccessEvent(request=self.request)) return {"success": is_valid} except ValueError as e: + self.registry.notify(ChangePasswordFailedEvent(request=self.request)) return HTTPForbidden(json_body={"message": str(e), "success": False}) def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: @@ -1132,8 +1121,9 @@ def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: name=self.long_term_token_cookie_name, path=f"{self.route_prefix}/", ) - except Exception as e: - logger.exception(e) + self.registry.notify(LogoutSuccessEvent(request=self.request)) + except Exception: + self.registry.notify(LogoutFailedEvent(request=self.request)) return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) return {"success": True} @@ -1149,8 +1139,9 @@ def disable_mfa_method(self): self.multi_factor_auth_service.disable_method( user_id=user_id, method_type=mfa_method_type ) - except Exception as e: - logger.exception(e) + self.registry.notify(DisableMfaSuccessEvent(request=self.request)) + except Exception: + self.registry.notify(DisableMfaFailedEvent(request=self.request)) return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) return {"success": True} @@ -1161,8 +1152,10 @@ def revoke_other_tokens(self): if user is None or not self.auth_service.verify_password( user=user, password=payload.get("password", "") ): + self.registry.notify(RevokeOtherRefreshTokensFailedEvent(request=self.request)) raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) self.token_service.delete_other_tokens(user=user) + self.registry.notify(RevokeOtherRefreshTokensSuccessEvent(request=self.request)) return {"success": True} def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: @@ -1190,7 +1183,7 @@ def generate_mfa_totp(self): def includeme(config: Configurator): """Routes and stuff to register maybe under a prefix""" - config.add_route("tet_auth_login", "login") + 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") diff --git a/src/tet/security/events.py b/src/tet/security/events.py new file mode 100644 index 0000000..6362166 --- /dev/null +++ b/src/tet/security/events.py @@ -0,0 +1,103 @@ +import dataclasses +import typing as tp + +from pyramid.request import Request + +__all__ = [ + "TetAuthEvent", + "ChangePasswordSuccessEvent", + "ChangePasswordFailedEvent", + "LoginSuccessEvent", + "LoginFailedEvent", + "MfaLoginSuccessEvent", + "MfaLoginFailedEvent", + "LogoutSuccessEvent", + "LogoutFailedEvent", + "DisableMfaSuccessEvent", + "DisableMfaFailedEvent", + "RevokeOtherRefreshTokensSuccessEvent", + "RevokeOtherRefreshTokensFailedEvent", + "RevokeCurrentRefreshTokensSuccessEvent", + "RevokeCurrentRefreshTokensFailedEvent", +] + + +@dataclasses.dataclass(kw_only=True, slots=True) +class TetAuthEvent: + request: Request + extra_fields: tp.Dict[str, tp.Any] = dataclasses.field(default_factory=dict) + + +# Change password events +@dataclasses.dataclass() +class ChangePasswordSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class ChangePasswordFailedEvent(TetAuthEvent): + pass + + +# Login events +@dataclasses.dataclass() +class LoginSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class LoginFailedEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class MfaLoginSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class MfaLoginFailedEvent(TetAuthEvent): + pass + + +# Logout events +@dataclasses.dataclass() +class LogoutSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class LogoutFailedEvent(TetAuthEvent): + pass + + +# MFA events +@dataclasses.dataclass() +class DisableMfaSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class DisableMfaFailedEvent(TetAuthEvent): + pass + + +# Token events +@dataclasses.dataclass() +class RevokeOtherRefreshTokensSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class RevokeOtherRefreshTokensFailedEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class RevokeCurrentRefreshTokensSuccessEvent(TetAuthEvent): + pass + + +@dataclasses.dataclass() +class RevokeCurrentRefreshTokensFailedEvent(TetAuthEvent): + pass From 06ea6063ae6e9bbd4b04c4f92164568fd74498b5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 2 Jul 2025 11:11:07 +0300 Subject: [PATCH 093/139] Remove redundant LoginSuccessEvent notification and fix login return path to avoid premature response before authentication logic. --- src/tet/security/authentication.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 7ea42f0..fa436ff 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1026,9 +1026,6 @@ def __init__(self, request: Request): ) def login(self) -> dict[str, tp.Any]: - self.registry.notify(LoginSuccessEvent(request=self.request)) - return {"success": True} - user_id = self.login_callback(self.request) if user_id is None: self.registry.notify(LoginFailedEvent(request=self.request)) From cc1b7115146f887e20813df73b6fde0ea1f9ab7d Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 2 Jul 2025 12:01:39 +0300 Subject: [PATCH 094/139] Add CreateTotpMethodSuccessEvent and improve error handling in authentication - Introduce CreateTotpMethodSuccessEvent for TOTP method creation notifications. - Notify registry on TOTP creation success. - Improve error handling and logging in password change flow. - Minor formatting fix for token payload slicing. --- src/tet/security/authentication.py | 9 ++++++++- src/tet/security/events.py | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index fa436ff..c6da4f4 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -993,6 +993,7 @@ def handle_totp_setup(self, *, user: tp.Any, project_prefix: str) -> dict: user_id=user.id, data=data.to_dict(), ) + self.request.registry.notify(CreateTotpMethodSuccessEvent(request=self.request)) 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}"} @@ -1106,8 +1107,14 @@ def change_password(self): self.registry.notify(ChangePasswordSuccessEvent(request=self.request)) return {"success": is_valid} except ValueError as e: - self.registry.notify(ChangePasswordFailedEvent(request=self.request)) + logger.error(f"Error while validating password change: {e}") return HTTPForbidden(json_body={"message": str(e), "success": False}) + except Exception as e: + logger.exception(f"Error changing password: {e}") + self.registry.notify(ChangePasswordFailedEvent(request=self.request)) + return HTTPForbidden( + json_body={"message": "Failed to change password", "success": False} + ) def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: try: diff --git a/src/tet/security/events.py b/src/tet/security/events.py index 6362166..b00c5fb 100644 --- a/src/tet/security/events.py +++ b/src/tet/security/events.py @@ -19,6 +19,7 @@ "RevokeOtherRefreshTokensFailedEvent", "RevokeCurrentRefreshTokensSuccessEvent", "RevokeCurrentRefreshTokensFailedEvent", + "CreateTotpMethodSuccessEvent", ] @@ -82,6 +83,11 @@ class DisableMfaFailedEvent(TetAuthEvent): pass +@dataclasses.dataclass() +class CreateTotpMethodSuccessEvent(TetAuthEvent): + pass + + # Token events @dataclasses.dataclass() class RevokeOtherRefreshTokensSuccessEvent(TetAuthEvent): From 4aeb66babecc5a350d371607e6ac9d55fba807a8 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Wed, 2 Jul 2025 16:36:20 +0300 Subject: [PATCH 095/139] Add test for authentication events: - test login success and failed events --- tests/services/security/test_auth_events.py | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/services/security/test_auth_events.py diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py new file mode 100644 index 0000000..c839dca --- /dev/null +++ b/tests/services/security/test_auth_events.py @@ -0,0 +1,64 @@ +import logging + +import pytest +from pyramid import testing +from pyramid.events import subscriber + +from tet.security.events import LoginSuccessEvent, LoginFailedEvent + +logger = logging.getLogger(__name__) + +DEFAULT_MESSAGE = "Event triggers the simulated audit log:" + + +@subscriber(LoginSuccessEvent) +def login_success_event_handler(event: LoginSuccessEvent): + """ + 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(LoginFailedEvent) +def login_failed_event_handler(event: LoginFailedEvent): + """ + Handle the LoginSuccessEvent. + This is a placeholder for any additional logic you want to execute + when a user successfully logs 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(pyramid_request_with_event, caplog): + req = pyramid_request_with_event(login_success_event_handler, LoginSuccessEvent) + message = "Login successful" + req.message = message + with caplog.at_level("INFO", logger=__name__): + req.registry.notify(LoginSuccessEvent(request=req)) + assert f"{DEFAULT_MESSAGE} {message}" in caplog.text + + +def test_login_failed_event(pyramid_request_with_event, caplog): + req = pyramid_request_with_event(login_failed_event_handler, LoginFailedEvent) + message = "Login failed" + req.message = message + with caplog.at_level("WARNING", logger=__name__): + req.registry.notify(LoginFailedEvent(request=req)) + assert f"{DEFAULT_MESSAGE} {message}" in caplog.text + + +# TODO: More test with the actual views From 40ae528ba1f945a766bd199eb17756164496bb17 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 3 Jul 2025 13:39:07 +0300 Subject: [PATCH 096/139] Refactor security event notifications to use explicit security_events namespace - Replace wildcard import of events with an aliased import as security_events. - Update all event notifications to use security_events.X syntax for clarity and maintainability. - Minor logic improvement in password verification to fail fast if user is None. --- src/tet/security/authentication.py | 47 +++++++++++++++++------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index c6da4f4..f57e1e0 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -6,6 +6,7 @@ import logging import secrets import typing as tp +import tet.security.events as security_events from datetime import datetime, timedelta, timezone import jwt @@ -34,8 +35,6 @@ from sqlalchemy.sql import delete from zope.interface import Interface, implementer -from tet.security.events import * - logger = logging.getLogger(__name__) __all__ = [ "TokenAuthenticationPolicy", @@ -943,15 +942,15 @@ def handle_totp_challenge( refresh_token=refresh_token, route_prefix=route_prefix, ) - self.registry.notify(MfaLoginSuccessEvent(request=self.request)) + self.registry.notify(security_events.MfaLoginSuccessEvent(request=self.request)) return {"success": is_valid, "access_token": access_token} except KeyError as e: - self.registry.notify(MfaLoginFailedEvent(request=self.request)) + self.registry.notify(security_events.MfaLoginFailedEvent(request=self.request)) raise HTTPBadRequest( json_body={"message": "Missing required field.", "details": str(e)} ) from e except Exception as e: - self.registry.notify(MfaLoginFailedEvent(request=self.request)) + self.registry.notify(security_events.MfaLoginFailedEvent(request=self.request)) raise HTTPInternalServerError( json_body={"message": "TOTP verification failed.", "details": str(e)} ) from e @@ -993,7 +992,9 @@ def handle_totp_setup(self, *, user: tp.Any, project_prefix: str) -> dict: user_id=user.id, data=data.to_dict(), ) - self.request.registry.notify(CreateTotpMethodSuccessEvent(request=self.request)) + self.request.registry.notify( + security_events.CreateTotpMethodSuccessEvent(request=self.request) + ) 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}"} @@ -1029,7 +1030,7 @@ def __init__(self, request: Request): def login(self) -> dict[str, tp.Any]: user_id = self.login_callback(self.request) if user_id is None: - self.registry.notify(LoginFailedEvent(request=self.request)) + self.registry.notify(security_events.LoginFailedEvent(request=self.request)) raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) payload = self.request.json_body @@ -1054,7 +1055,7 @@ def login(self) -> dict[str, tp.Any]: ) response_payload["access_token"] = access_token - self.registry.notify(LoginSuccessEvent(request=self.request)) + self.registry.notify(security_events.LoginSuccessEvent(request=self.request)) return response_payload def mfa_verify(self) -> dict: @@ -1091,7 +1092,7 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: def change_password(self): user_id = self.request.authenticated_userid if user_id is None: - self.registry.notify(ChangePasswordFailedEvent(request=self.request)) + self.registry.notify(security_events.ChangePasswordFailedEvent(request=self.request)) raise HTTPUnauthorized( json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} ) @@ -1104,14 +1105,14 @@ def change_password(self): 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(ChangePasswordSuccessEvent(request=self.request)) + self.registry.notify(security_events.ChangePasswordSuccessEvent(request=self.request)) return {"success": is_valid} except ValueError as e: logger.error(f"Error while validating password change: {e}") return HTTPForbidden(json_body={"message": str(e), "success": False}) except Exception as e: logger.exception(f"Error changing password: {e}") - self.registry.notify(ChangePasswordFailedEvent(request=self.request)) + self.registry.notify(security_events.ChangePasswordFailedEvent(request=self.request)) return HTTPForbidden( json_body={"message": "Failed to change password", "success": False} ) @@ -1125,9 +1126,9 @@ def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: name=self.long_term_token_cookie_name, path=f"{self.route_prefix}/", ) - self.registry.notify(LogoutSuccessEvent(request=self.request)) + self.registry.notify(security_events.LogoutSuccessEvent(request=self.request)) except Exception: - self.registry.notify(LogoutFailedEvent(request=self.request)) + self.registry.notify(security_events.LogoutFailedEvent(request=self.request)) return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) return {"success": True} @@ -1143,9 +1144,9 @@ def disable_mfa_method(self): self.multi_factor_auth_service.disable_method( user_id=user_id, method_type=mfa_method_type ) - self.registry.notify(DisableMfaSuccessEvent(request=self.request)) + self.registry.notify(security_events.DisableMfaSuccessEvent(request=self.request)) except Exception: - self.registry.notify(DisableMfaFailedEvent(request=self.request)) + self.registry.notify(security_events.DisableMfaFailedEvent(request=self.request)) return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) return {"success": True} @@ -1153,13 +1154,19 @@ def revoke_other_tokens(self): user_id = self.request.authenticated_userid user = self.auth_service.get_current_user(user_id=user_id) payload = self.request.json_body - if user is None or not self.auth_service.verify_password( - user=user, password=payload.get("password", "") - ): - self.registry.notify(RevokeOtherRefreshTokensFailedEvent(request=self.request)) + 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", "")): + self.registry.notify( + security_events.RevokeOtherRefreshTokensFailedEvent(request=self.request) + ) + raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) + self.token_service.delete_other_tokens(user=user) - self.registry.notify(RevokeOtherRefreshTokensSuccessEvent(request=self.request)) + self.registry.notify( + security_events.RevokeOtherRefreshTokensSuccessEvent(request=self.request) + ) return {"success": True} def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: From d295c5060b2b6d8c36575086d73b4f418a569e5b Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 3 Jul 2025 17:38:33 +0300 Subject: [PATCH 097/139] refactor(authn-events): unify security events under new Authn* classes - Replaced old event types (LoginSuccessEvent, LoginFailedEvent, etc.) with new Authn* dataclasses, and richer metadata and docstrings. - Updated authentication views and tests to use the new events, adding user context where appropriate and improving error handling. - Improves audit logging, consistency and clarity of security events. --- src/tet/security/authentication.py | 348 ++++++++++++++------ src/tet/security/events.py | 245 +++++++++++--- tests/services/security/test_auth_events.py | 18 +- 3 files changed, 445 insertions(+), 166 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index f57e1e0..32f23ce 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -6,6 +6,9 @@ import logging import secrets import typing as tp + +from sqlalchemy.exc import SQLAlchemyError + import tet.security.events as security_events from datetime import datetime, timedelta, timezone @@ -910,50 +913,41 @@ def handle_totp_challenge( cookie_attributes: CookieAttributes = None, route_prefix: str = None, ) -> dict[str, tp.Any]: - try: - totp_mfa_method = self.get_method( - user_id=user_id, - method_type=MultiFactorAuthMethodType.TOTP, - is_active=True, - verified=True, + totp_mfa_method = self.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + if not totp_mfa_method: + raise HTTPForbidden( + json_body={"message": "Two-factor authentication method not found."} ) - if not totp_mfa_method: - raise HTTPForbidden( - json_body={"message": "Two-factor authentication method not found."} - ) - secret = totp_mfa_method.data.get("secret") + secret = totp_mfa_method.data.get("secret") - if not secret: - raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) + if not secret: + raise HTTPBadRequest(json_body={"message": "Missing TOTP secret."}) - is_valid = self.verify_totp(secret=secret, token=totp_token) + is_valid = self.verify_totp(secret=secret, token=totp_token) - if not is_valid: - raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) + if not is_valid: + raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) - totp_mfa_method.mark_used() + totp_mfa_method.mark_used() - refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) - access_token = self.token_service.create_short_term_jwt(user_id) + refresh_token = self.token_service.create_long_term_token(user_id, 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, - route_prefix=route_prefix, - ) - self.registry.notify(security_events.MfaLoginSuccessEvent(request=self.request)) - return {"success": is_valid, "access_token": access_token} - except KeyError as e: - self.registry.notify(security_events.MfaLoginFailedEvent(request=self.request)) - raise HTTPBadRequest( - json_body={"message": "Missing required field.", "details": str(e)} - ) from e - except Exception as e: - self.registry.notify(security_events.MfaLoginFailedEvent(request=self.request)) - raise HTTPInternalServerError( - json_body={"message": "TOTP verification failed.", "details": str(e)} - ) from e + self.auth_service.set_cookies( + cookie_attributes=cookie_attributes, + refresh_token=refresh_token, + route_prefix=route_prefix, + ) + self.registry.notify( + security_events.AuthnLoginSuccess(request=self.request, user_id=user_id) + ) + return {"success": is_valid, "access_token": access_token} @staticmethod def _create_totp_data(issuer: str) -> TOTPData: @@ -993,7 +987,11 @@ def handle_totp_setup(self, *, user: tp.Any, project_prefix: str) -> dict: data=data.to_dict(), ) self.request.registry.notify( - security_events.CreateTotpMethodSuccessEvent(request=self.request) + 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) @@ -1029,34 +1027,56 @@ def __init__(self, request: Request): def login(self) -> dict[str, tp.Any]: user_id = self.login_callback(self.request) - if user_id is None: - self.registry.notify(security_events.LoginFailedEvent(request=self.request)) - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + payload = self.request.json_body + totp_token = payload.get("token") + response_payload: dict[str, tp.Any] = {"success": True} + refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) + access_token = self.token_service.create_short_term_jwt(user_id) - payload = self.request.json_body - totp_token = payload.get("token") - response_payload: dict[str, tp.Any] = {"success": True} - refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) - access_token = self.token_service.create_short_term_jwt(user_id) + if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): + if not totp_token: + response_payload["mfa_required"] = True + return response_payload - if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): - if not totp_token: - response_payload["mfa_required"] = True - return response_payload + return self.multi_factor_auth_service.handle_totp_challenge( + user_id=user_id, totp_token=totp_token + ) - return self.multi_factor_auth_service.handle_totp_challenge( - user_id=user_id, totp_token=totp_token + self.auth_service.set_cookies( + cookie_attributes=self.cookie_attributes, + refresh_token=refresh_token, + route_prefix=self.route_prefix, ) + response_payload["access_token"] = access_token - self.auth_service.set_cookies( - cookie_attributes=self.cookie_attributes, - refresh_token=refresh_token, - route_prefix=self.route_prefix, - ) - response_payload["access_token"] = access_token + self.registry.notify( + security_events.AuthnLoginSuccess(request=self.request, user_id=user_id) + ) + return response_payload - self.registry.notify(security_events.LoginSuccessEvent(request=self.request)) - return response_payload + except KeyError as e: + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_id=user_id) + ) + raise HTTPBadRequest( + json_body={"message": "Missing required field.", "details": str(e)} + ) from e + except HTTPException as e: + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_id=user_id) + ) + raise e + except Exception as e: + logger.exception(f"Error during login: {e}") + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_id=user_id) + ) + raise HTTPInternalServerError( + json_body={"message": "Login failed", "details": str(e)} + ) from e def mfa_verify(self) -> dict: """ @@ -1091,105 +1111,215 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: def change_password(self): user_id = self.request.authenticated_userid - if user_id is None: - self.registry.notify(security_events.ChangePasswordFailedEvent(request=self.request)) - raise HTTPUnauthorized( - json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} - ) + data = self.request.json_body + payload = PasswordChangeData( + current_password=data["currentPassword"], + new_password=data["newPassword"], + ) try: - data = self.request.json_body - payload = PasswordChangeData( - current_password=data["currentPassword"], - new_password=data["newPassword"], - ) + if user_id is None: + raise HTTPUnauthorized( + json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} + ) 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.ChangePasswordSuccessEvent(request=self.request)) + self.registry.notify( + security_events.AuthnPasswordChange( # type: ignore + request=self.request, authenticated_userid=user_id + ) + ) return {"success": is_valid} except ValueError as e: + self.registry.notify( + security_events.AuthnPasswordChangeFail( # type: ignore + request=self.request, authenticated_userid=user_id + ) + ) logger.error(f"Error while validating password change: {e}") return HTTPForbidden(json_body={"message": str(e), "success": False}) + except HTTPException as e: + self.registry.notify( + security_events.AuthnPasswordChangeFail( # type: ignore + request=self.request, authenticated_userid=user_id + ) + ) + raise e except Exception as e: logger.exception(f"Error changing password: {e}") - self.registry.notify(security_events.ChangePasswordFailedEvent(request=self.request)) + self.registry.notify( + security_events.AuthnPasswordChangeFail( # type: ignore + 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 = self.request.authenticated_userid try: - user_id = self.request.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.response.delete_cookie( name=self.long_term_token_cookie_name, path=f"{self.route_prefix}/", ) - self.registry.notify(security_events.LogoutSuccessEvent(request=self.request)) - except Exception: - self.registry.notify(security_events.LogoutFailedEvent(request=self.request)) + self.registry.notify( + security_events.AuthnCurrentRefreshTokenRevoked( + request=self.request, authenticated_userid=user_id + ) + ) + self.registry.notify( + security_events.AuthnLogoutSuccess( # type: ignore + request=self.request, user_id=user_id + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnLogoutFail( # type: ignore + request=self.request, user_id=user_id + ) + ) + raise e + except SQLAlchemyError as e: + logger.exception(f"Database error during logout: {e}") + self.registry.notify( + security_events.AuthnCurrentRefreshTokenRevokeFail( # type: ignore + 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( # type: ignore + request=self.request, user_id=user_id + ) + ) return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) - return {"success": True} def disable_mfa_method(self): user_id = self.request.authenticated_userid - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) payload = self.request.json_body mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) - if not mfa_method_type: - raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + if not mfa_method_type: + raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) self.multi_factor_auth_service.disable_method( user_id=user_id, method_type=mfa_method_type ) - self.registry.notify(security_events.DisableMfaSuccessEvent(request=self.request)) - except Exception: - self.registry.notify(security_events.DisableMfaFailedEvent(request=self.request)) + self.registry.notify( + security_events.AuthnMfaMethodDisabled( # type: ignore + request=self.request, + authenticated_userid=user_id, + method=mfa_method_type.value if mfa_method_type else None, + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnMfaMethodDisableFail( # type: ignore + request=self.request, + authenticated_userid=user_id, + method=mfa_method_type.value if mfa_method_type else None, + ) + ) + raise e + except Exception as e: + logger.exception(f"Error disabling MFA method: {e}") + self.registry.notify( + security_events.AuthnMfaMethodDisableFail( # type: ignore + 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"}) - return {"success": True} def revoke_other_tokens(self): user_id = self.request.authenticated_userid user = self.auth_service.get_current_user(user_id=user_id) payload = self.request.json_body - if user is None: - raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) + try: + 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", "")): + 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.RevokeOtherRefreshTokensFailedEvent(request=self.request) + security_events.AuthnRefreshTokensRevoked( # type: ignore + request=self.request, + authenticated_userid=user_id, + ) + ) + return {"success": True} + except HTTPException as e: + self.registry.notify( + security_events.AuthnRefreshTokenRevokeFail( # type: ignore + request=self.request, + authenticated_userid=user_id, + ) + ) + raise e + except Exception as e: + logger.exception(f"Error revoking other tokens: {e}") + self.registry.notify( + security_events.AuthnRefreshTokenRevokeFail( # type: ignore + request=self.request, + authenticated_userid=user_id, + ) + ) + return HTTPForbidden( + json_body={"message": "Failed to revoke other tokens", "success": False} ) - raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) - - self.token_service.delete_other_tokens(user=user) - self.registry.notify( - security_events.RevokeOtherRefreshTokensSuccessEvent(request=self.request) - ) - return {"success": True} def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: user_id = self.request.authenticated_userid - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - 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]} + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + 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.request.authenticated_userid user = self.auth_service.get_current_user(user_id=user_id) - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - payload = self.request.json_body - if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: - return self.multi_factor_auth_service.handle_totp_setup( - user=user, project_prefix=self.project_prefix - ) - return None + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: + return self.multi_factor_auth_service.handle_totp_setup( + user=user, project_prefix=self.project_prefix + ) + return None + 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 def includeme(config: Configurator): diff --git a/src/tet/security/events.py b/src/tet/security/events.py index b00c5fb..f4a3623 100644 --- a/src/tet/security/events.py +++ b/src/tet/security/events.py @@ -5,105 +5,254 @@ __all__ = [ "TetAuthEvent", - "ChangePasswordSuccessEvent", - "ChangePasswordFailedEvent", - "LoginSuccessEvent", - "LoginFailedEvent", - "MfaLoginSuccessEvent", - "MfaLoginFailedEvent", - "LogoutSuccessEvent", - "LogoutFailedEvent", - "DisableMfaSuccessEvent", - "DisableMfaFailedEvent", - "RevokeOtherRefreshTokensSuccessEvent", - "RevokeOtherRefreshTokensFailedEvent", - "RevokeCurrentRefreshTokensSuccessEvent", - "RevokeCurrentRefreshTokensFailedEvent", - "CreateTotpMethodSuccessEvent", + "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 - extra_fields: tp.Dict[str, tp.Any] = dataclasses.field(default_factory=dict) + + +# 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 ChangePasswordSuccessEvent(TetAuthEvent): - pass +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 ChangePasswordFailedEvent(TetAuthEvent): - pass +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 LoginSuccessEvent(TetAuthEvent): - pass @dataclasses.dataclass() -class LoginFailedEvent(TetAuthEvent): - pass +class AuthnLoginSuccess(TetAuthEvent): + """ + AuthnLoginSuccess[:userid] + Event for successful login. + Attributes: + user_id (Any): Identifier of the user. + """ -@dataclasses.dataclass() -class MfaLoginSuccessEvent(TetAuthEvent): - pass + user_id: tp.Any @dataclasses.dataclass() -class MfaLoginFailedEvent(TetAuthEvent): - pass +class AuthnLoginFail(TetAuthEvent): + """ + AuthnLoginFail[:userid] + Event for failed login attempt. + + Attributes: + user_id (Any): Identifier of the user. + """ + + user_id: tp.Any # Logout events + + @dataclasses.dataclass() -class LogoutSuccessEvent(TetAuthEvent): - pass +class AuthnLogoutSuccess(TetAuthEvent): + """ + AuthnLogoutSuccess[:userid] + Event for successful logout. + + Attributes: + user_id (Any): Identifier of the user. + """ + + user_id: tp.Any @dataclasses.dataclass() -class LogoutFailedEvent(TetAuthEvent): - pass +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 DisableMfaSuccessEvent(TetAuthEvent): - pass +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 DisableMfaFailedEvent(TetAuthEvent): - pass +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 CreateTotpMethodSuccessEvent(TetAuthEvent): - pass +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 RevokeOtherRefreshTokensSuccessEvent(TetAuthEvent): - pass +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 RevokeOtherRefreshTokensFailedEvent(TetAuthEvent): - pass +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 RevokeCurrentRefreshTokensSuccessEvent(TetAuthEvent): - pass +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 RevokeCurrentRefreshTokensFailedEvent(TetAuthEvent): - pass +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/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index c839dca..c07e83f 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -4,15 +4,15 @@ from pyramid import testing from pyramid.events import subscriber -from tet.security.events import LoginSuccessEvent, LoginFailedEvent +from tet.security.events import AuthnLoginSuccess, AuthnLoginFail logger = logging.getLogger(__name__) DEFAULT_MESSAGE = "Event triggers the simulated audit log:" -@subscriber(LoginSuccessEvent) -def login_success_event_handler(event: LoginSuccessEvent): +@subscriber(AuthnLoginSuccess) +def login_success_event_handler(event: AuthnLoginSuccess): """ Handle the LoginSuccessEvent. This is a placeholder for any additional logic you want to execute @@ -21,8 +21,8 @@ def login_success_event_handler(event: LoginSuccessEvent): logger.info(f"{DEFAULT_MESSAGE} {event.request.message}") -@subscriber(LoginFailedEvent) -def login_failed_event_handler(event: LoginFailedEvent): +@subscriber(AuthnLoginFail) +def login_failed_event_handler(event: AuthnLoginFail): """ Handle the LoginSuccessEvent. This is a placeholder for any additional logic you want to execute @@ -44,20 +44,20 @@ def _make(handler, event_class): def test_login_success_event(pyramid_request_with_event, caplog): - req = pyramid_request_with_event(login_success_event_handler, LoginSuccessEvent) + req = pyramid_request_with_event(login_success_event_handler, AuthnLoginSuccess) message = "Login successful" req.message = message with caplog.at_level("INFO", logger=__name__): - req.registry.notify(LoginSuccessEvent(request=req)) + req.registry.notify(AuthnLoginSuccess(request=req)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text def test_login_failed_event(pyramid_request_with_event, caplog): - req = pyramid_request_with_event(login_failed_event_handler, LoginFailedEvent) + req = pyramid_request_with_event(login_failed_event_handler, AuthnLoginFail) message = "Login failed" req.message = message with caplog.at_level("WARNING", logger=__name__): - req.registry.notify(LoginFailedEvent(request=req)) + req.registry.notify(AuthnLoginFail(request=req)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text From 275a29eedbd1537162bccc9711c95fca185bb4de Mon Sep 17 00:00:00 2001 From: longnguyen Date: Thu, 3 Jul 2025 17:42:08 +0300 Subject: [PATCH 098/139] tests(security): include user_id in login event tests - Updated login success and failure event tests to include user_id in event payload and log messages. - Ensures tests cover logging with user context. --- tests/services/security/test_auth_events.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index c07e83f..d726812 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -45,19 +45,19 @@ def _make(handler, event_class): def test_login_success_event(pyramid_request_with_event, caplog): req = pyramid_request_with_event(login_success_event_handler, AuthnLoginSuccess) - message = "Login successful" + message = "User 1 Login successful" req.message = message with caplog.at_level("INFO", logger=__name__): - req.registry.notify(AuthnLoginSuccess(request=req)) + req.registry.notify(AuthnLoginSuccess(request=req, user_id=1)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text def test_login_failed_event(pyramid_request_with_event, caplog): req = pyramid_request_with_event(login_failed_event_handler, AuthnLoginFail) - message = "Login failed" + message = "User 1 Login failed" req.message = message with caplog.at_level("WARNING", logger=__name__): - req.registry.notify(AuthnLoginFail(request=req)) + req.registry.notify(AuthnLoginFail(request=req, user_id=1)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text From cb97e01451af78ff73dec6b6316ee65173ff9f02 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 4 Jul 2025 15:11:37 +0300 Subject: [PATCH 099/139] Refactor login events to use user_identity instead of user_id - Replaced all usage of user_id with user_identity for AuthnLoginSuccess and AuthnLoginFail events - Updated event classes, handlers, and test cases accordingly - Ensured consistency across authentication service and event logging --- src/tet/security/authentication.py | 22 +++++++++++++-------- src/tet/security/events.py | 12 +++++------ tests/services/security/test_auth_events.py | 10 ++++++---- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 32f23ce..6bf0e48 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -945,7 +945,10 @@ def handle_totp_challenge( route_prefix=route_prefix, ) self.registry.notify( - security_events.AuthnLoginSuccess(request=self.request, user_id=user_id) + 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} @@ -1027,12 +1030,13 @@ def __init__(self, request: Request): def login(self) -> dict[str, tp.Any]: user_id = self.login_callback(self.request) + payload = self.request.json_body + user_identity = payload.get("user_identity", user_id) + totp_token = payload.get("token") + response_payload: dict[str, tp.Any] = {"success": True} try: if user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - payload = self.request.json_body - totp_token = payload.get("token") - response_payload: dict[str, tp.Any] = {"success": True} refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) access_token = self.token_service.create_short_term_jwt(user_id) @@ -1053,26 +1057,28 @@ def login(self) -> dict[str, tp.Any]: response_payload["access_token"] = access_token self.registry.notify( - security_events.AuthnLoginSuccess(request=self.request, user_id=user_id) + security_events.AuthnLoginSuccess( # type: ignore + request=self.request, user_identity=user_identity + ) ) return response_payload except KeyError as e: self.registry.notify( - security_events.AuthnLoginFail(request=self.request, user_id=user_id) + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) raise HTTPBadRequest( json_body={"message": "Missing required field.", "details": str(e)} ) from e except HTTPException as e: self.registry.notify( - security_events.AuthnLoginFail(request=self.request, user_id=user_id) + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) raise e except Exception as e: logger.exception(f"Error during login: {e}") self.registry.notify( - security_events.AuthnLoginFail(request=self.request, user_id=user_id) + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) raise HTTPInternalServerError( json_body={"message": "Login failed", "details": str(e)} diff --git a/src/tet/security/events.py b/src/tet/security/events.py index f4a3623..b1a8d82 100644 --- a/src/tet/security/events.py +++ b/src/tet/security/events.py @@ -88,27 +88,27 @@ class AuthnPasswordChangeFail(TetAuthEvent): @dataclasses.dataclass() class AuthnLoginSuccess(TetAuthEvent): """ - AuthnLoginSuccess[:userid] + AuthnLoginSuccess[:user_identity] Event for successful login. Attributes: - user_id (Any): Identifier of the user. + user_identity (Any): Identifier of the user. """ - user_id: tp.Any + user_identity: tp.Any @dataclasses.dataclass() class AuthnLoginFail(TetAuthEvent): """ - AuthnLoginFail[:userid] + AuthnLoginFail[:user_identity] Event for failed login attempt. Attributes: - user_id (Any): Identifier of the user. + user_identity (Any): Identifier of the user. """ - user_id: tp.Any + user_identity: tp.Any # Logout events diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index d726812..55387de 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -45,19 +45,21 @@ def _make(handler, event_class): def test_login_success_event(pyramid_request_with_event, caplog): req = pyramid_request_with_event(login_success_event_handler, AuthnLoginSuccess) - message = "User 1 Login successful" + 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_id=1)) + req.registry.notify(AuthnLoginSuccess(request=req, user_identity=user_identity)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text def test_login_failed_event(pyramid_request_with_event, caplog): req = pyramid_request_with_event(login_failed_event_handler, AuthnLoginFail) - message = "User 1 Login failed" + 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_id=1)) + req.registry.notify(AuthnLoginFail(request=req, user_identity=user_identity)) assert f"{DEFAULT_MESSAGE} {message}" in caplog.text From 38400c455a90d4852b690629304e11b3594d3a68 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 4 Jul 2025 15:47:19 +0300 Subject: [PATCH 100/139] refactor: adopt industry standard Bearer scheme for JWT extraction - Use 'Bearer' token extraction from the authorization header to align with industry best practices and improve interoperability. --- src/tet/security/authentication.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 6bf0e48..82e9577 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -154,13 +154,13 @@ def authenticated_userid(self, request: Request) -> tp.Optional[int]: - ``None`` if no user is authenticated. """ token_service: TetTokenService = request.find_service(TetTokenService) - jwt_token = request.headers.get(request.registry.tet_auth_access_token_header) - if not jwt_token: + auth_header = request.headers.get(request.registry.tet_auth_access_token_header, "") + scheme, _, access_token = auth_header.partition(" ") + if scheme.lower() != "bearer" or not access_token: return None - payload = token_service.verify_jwt(jwt_token) - + payload = token_service.verify_jwt(access_token) return payload.get("user_id") if payload else None def permits(self, request, context, permission): From a1149fccf63ea7151800cb5a8f5afdebc07c64a5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 4 Jul 2025 16:03:08 +0300 Subject: [PATCH 101/139] Refactor auth headers: use 'Authorization' for access tokens, remove custom header names - Replace `X-Access-Token` with standard `Authorization` header for access token handling - Rename registry var to `tet_authz_header` for clarity - Remove unused access/refresh token route names and related config - Update `set_token_authentication` to use `authorization_header` - Simplify token header management, align with HTTP standards --- src/tet/security/authentication.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 82e9577..5e7c114 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -155,7 +155,7 @@ def authenticated_userid(self, request: Request) -> tp.Optional[int]: """ token_service: TetTokenService = request.find_service(TetTokenService) - auth_header = request.headers.get(request.registry.tet_auth_access_token_header, "") + 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 @@ -207,11 +207,9 @@ def __init__(self): DEFAULT_LONG_TERM_TOKEN_EXPIRATION_MINS = 60 * 12 DEFAULT_USER_ID_COLUMN = "user_id" DEFAULT_LONG_TERM_TOKEN_NAME = "X-Long-Token" -DEFAULT_ACCESS_TOKEN_NAME = "X-Access-Token" -DEFAULT_ACCESS_TOKEN_COOKIE_NAME = "access-token" +DEFAULT_AUTHORIZATION_HEADER = "Authorization" DEFAULT_REFRESH_TOKEN_COOKIE_NAME = "refresh-token" DEFAULT_PATH = "/" -DEFAULT_REFRESH_TOKEN_ROUTE = "refresh_token" DEFAULT_UNAUTHORIZED_MESSAGE = """Access denied. You are not authorised to access this resource. Please ensure that your credientials are correct and try again. """ @@ -275,11 +273,10 @@ def set_token_authentication( 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, - access_token_header: str = DEFAULT_ACCESS_TOKEN_NAME, + 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, - refresh_token_route: str = DEFAULT_REFRESH_TOKEN_ROUTE, cookie_attributes: tp.Optional[CookieAttributes] = None, security_policy: tp.Optional[ tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] @@ -361,7 +358,7 @@ def register(): 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_auth_access_token_header = access_token_header + 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 @@ -372,7 +369,6 @@ def register(): 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_refresh_token_route = refresh_token_route config.registry.tet_auth_security_policy = security_policy config.action(discriminator="set_token_authentication", callable=register) @@ -1021,7 +1017,6 @@ def __init__(self, request: Request): self.long_term_token_expiration_mins = ( self.registry.tet_auth_long_term_token_expiration_mins ) - self.refresh_token_route = self.registry.tet_auth_refresh_token_route self.route_prefix = self.request.current_route_path().rpartition("/")[0] self.login_callback = self.registry.tet_auth_login_callback self.cookie_attributes: tp.Optional[CookieAttributes] = ( From f4f9be5fb4a20928b5a04797a7f290e8739beb7d Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 7 Jul 2025 11:30:03 +0300 Subject: [PATCH 102/139] Refactor: re-raise HTTPException directly without binding to variable - Removes unnecessary binding of HTTPException to variable before re-raising. --- src/tet/security/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 5e7c114..c5bf27f 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1065,11 +1065,11 @@ def login(self) -> dict[str, tp.Any]: raise HTTPBadRequest( json_body={"message": "Missing required field.", "details": str(e)} ) from e - except HTTPException as e: + except HTTPException: self.registry.notify( security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) - raise e + raise except Exception as e: logger.exception(f"Error during login: {e}") self.registry.notify( From 347244311630c044e80797f2fc2d7419328ef8be Mon Sep 17 00:00:00 2001 From: longnguyen Date: Mon, 7 Jul 2025 11:54:45 +0300 Subject: [PATCH 103/139] Use 'Authorization' header with Bearer tokens in authentication tests --- tests/services/security/test_authentication.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 63c3284..41fb934 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -9,7 +9,7 @@ from tet.security.authentication import TetTokenService LOGIN_ENDPOINT = "/api/v1/auth/login" -ACCESS_TOKEN_HEADER_NAME = "x-access-token" +ACCESS_TOKEN_HEADER_NAME = "Authorization" LONG_TERM_TOKEN_COOKIE_NAME = "refresh-token" ACCESS_TOKEN_COOKIE_NAME = "access-token" HOME_ROUTE = "/" @@ -118,7 +118,7 @@ def test_access_token_should_work_to_access_protected_route( authentication_tokens, pyramid_test_app ): refresh_token, access_token = authentication_tokens - headers = {"x-access-token": access_token} + 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 @@ -176,7 +176,7 @@ def test_it_should_fail_to_access_the_protected_route_with_invalid_access_token( pyramid_test_app, ): headers = { - "x-access-token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" + ACCESS_TOKEN_HEADER_NAME: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" } pytest.raises( @@ -253,7 +253,7 @@ def test_access_token_should_work_to_access_protected_route_with_new_policy( assert refresh_token == capture_token["refresh_token"] assert capture_token["access_token"] == access_token - headers = {ACCESS_TOKEN_HEADER_NAME: access_token} + headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} response = app.get(HOME_ROUTE, headers=headers, status=200) assert response.status_code == 200 From 12a5c2df430eccb7ef4f9381ce7f8ee328002e22 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 11 Jul 2025 11:18:05 +0300 Subject: [PATCH 104/139] Refactor login flow and error handling in authentication service - Introduce AuthLoginResult and AuthLoginFailure dataclasses for clearer login result handling - Pass full login result from callback to view, enabling easier MFA requirement detection and improved response structure - Remove detailed error messages from responses for improved security; log full exceptions instead - Improve logging of exception details - Fix minor docstring and typo issues in event handlers --- src/tet/security/authentication.py | 72 +++++++++++++++------ tests/services/security/test_auth_events.py | 4 +- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index c5bf27f..8533f27 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -238,6 +238,40 @@ class CookieAttributes: MAX_PASSWORD_LENGTH = 128 MIN_SCORE = 2 KEY_PREFIX_PROFILE_CHANGE_PASSWORD_FORM = "settings.profile.changePasswordForm" +MFA_REQUIRED_KEY = "mfa_required" + + +@dataclasses.dataclass +class AuthLoginFailure: + """ + Dataclass for storing login failure information. + + Attributes: + message: The error message describing the failure. + status_code: The HTTP status code associated with the failure. + """ + + message: str + status_code: int + + +@dataclasses.dataclass +class AuthLoginResult: + """ + Dataclass for storing login data. + + Attributes: + user: user object + user_identity: The identity of the user attempting to log in (e.g., email, user_name or id). + login_failure: Optional LoginFailure object containing details of the login failure, if any. + """ + + user_id: tp.Any + totp_token: tp.Optional[str] = None + user: tp.Optional[tp.Any] = None + user_identity: tp.Optional[str] = None + login_failure: tp.Optional[AuthLoginFailure] = None + mfa_required_key: str = MFA_REQUIRED_KEY class ILoginCallback(tp.Protocol): @@ -247,7 +281,7 @@ class ILoginCallback(tp.Protocol): **Returns:** ``user_id`` """ - def __call__(self, request: Request) -> tp.Optional[tp.Any]: + def __call__(self, request: Request, LoginD) -> tp.Optional[tp.Any]: pass @@ -892,15 +926,13 @@ def handle_totp_verify(self, user_id: tp.Any, token: tp.Any, setup_key: tp.Any) totp_mfa_method.data = data.to_dict() return {"success": is_valid} except KeyError as e: - raise HTTPBadRequest( - json_body={"message": "Missing required field.", "details": str(e)} - ) from e + logger.exception(f"details {str(e)}") + raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e except HTTPException: raise except Exception as e: - raise HTTPInternalServerError( - json_body={"message": "TOTP verification failed.", "details": str(e)} - ) from e + logger.exception(f"details {str(e)}") + raise HTTPInternalServerError(json_body={"message": "TOTP verification failed."}) from e def handle_totp_challenge( self, @@ -1024,11 +1056,12 @@ def __init__(self, request: Request): ) def login(self) -> dict[str, tp.Any]: - user_id = self.login_callback(self.request) - payload = self.request.json_body - user_identity = payload.get("user_identity", user_id) - totp_token = payload.get("token") + 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}) @@ -1037,7 +1070,7 @@ def login(self) -> dict[str, tp.Any]: if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): if not totp_token: - response_payload["mfa_required"] = True + response_payload[auth_result.mfa_required_key] = True return response_payload return self.multi_factor_auth_service.handle_totp_challenge( @@ -1062,22 +1095,19 @@ def login(self) -> dict[str, tp.Any]: self.registry.notify( security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) - raise HTTPBadRequest( - json_body={"message": "Missing required field.", "details": str(e)} - ) from e + logger.exception(f"Missing required field during login: {str(e)}") + raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e except HTTPException: self.registry.notify( security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) raise except Exception as e: - logger.exception(f"Error during login: {e}") + logger.exception(f"Error during login: {str(e)}") self.registry.notify( security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) - raise HTTPInternalServerError( - json_body={"message": "Login failed", "details": str(e)} - ) from e + raise HTTPInternalServerError(json_body={"message": "Login failed"}) from e def mfa_verify(self) -> dict: """ @@ -1138,7 +1168,9 @@ def change_password(self): ) ) logger.error(f"Error while validating password change: {e}") - return HTTPForbidden(json_body={"message": str(e), "success": False}) + return HTTPForbidden( + json_body={"message": "Invalid password change request", "success": False} + ) except HTTPException as e: self.registry.notify( security_events.AuthnPasswordChangeFail( # type: ignore diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index 55387de..80f4b8c 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -24,9 +24,9 @@ def login_success_event_handler(event: AuthnLoginSuccess): @subscriber(AuthnLoginFail) def login_failed_event_handler(event: AuthnLoginFail): """ - Handle the LoginSuccessEvent. + Handle the LoginFailEvent. This is a placeholder for any additional logic you want to execute - when a user successfully logs in. + when a user failed to log in. """ logger.warning(f"{DEFAULT_MESSAGE} {event.request.message}") From 704bd0c7236db94c254d42b7ba9f941120875de5 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 11 Jul 2025 14:13:52 +0300 Subject: [PATCH 105/139] Refactor authentication tests and login callback - Introduce AuthLoginResult for structured login callback results. - Update login_callback to use AuthLoginResult and add TOTP parsing. - Add success flag and __bool__ method to AuthLoginResult for clear login status. - Move authentication test constants and cookie utilities to separate modules. - Add structlog security logging with per-module config and teardown. - Add audit event subscribers for login success and failure. - Improve test reliability and clarity. - Add structlog to the dev_dependencies --- src/tet/security/authentication.py | 32 ++--- tests/conftest.py | 34 +++-- tests/services/constants.py | 5 + .../security/subscribers/auth_subscribers.py | 46 ++++++ tests/services/security/test_auth_events.py | 135 +++++++++++++++++- .../services/security/test_authentication.py | 15 +- tests/services/utils/authentication.py | 3 + 7 files changed, 222 insertions(+), 48 deletions(-) create mode 100644 tests/services/constants.py create mode 100644 tests/services/security/subscribers/auth_subscribers.py create mode 100644 tests/services/utils/authentication.py diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 8533f27..11073d0 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -50,6 +50,7 @@ "TetTokenService", "TOTPData", "CookieAttributes", + "AuthLoginResult", ] @@ -241,37 +242,28 @@ class CookieAttributes: MFA_REQUIRED_KEY = "mfa_required" -@dataclasses.dataclass -class AuthLoginFailure: - """ - Dataclass for storing login failure information. - - Attributes: - message: The error message describing the failure. - status_code: The HTTP status code associated with the failure. - """ - - message: str - status_code: int - - @dataclasses.dataclass class AuthLoginResult: """ Dataclass for storing login data. Attributes: - user: user object - user_identity: The identity of the user attempting to log in (e.g., email, user_name or id). - login_failure: Optional LoginFailure object containing details of the login failure, if any. + 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: tp.Optional[tp.Any] = None user_identity: tp.Optional[str] = None - login_failure: tp.Optional[AuthLoginFailure] = 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): @@ -281,7 +273,7 @@ class ILoginCallback(tp.Protocol): **Returns:** ``user_id`` """ - def __call__(self, request: Request, LoginD) -> tp.Optional[tp.Any]: + def __call__(self, request: Request) -> AuthLoginResult: pass diff --git a/tests/conftest.py b/tests/conftest.py index cda5a26..c3f2976 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,11 @@ from tests.models.accounts import Base, Token, User, MultiFactorAuthenticationMethod from tet.config import Configurator as tetConfigurator -from tet.security.authentication import TokenAuthenticationPolicy, JWTCookieAuthenticationPolicy +from tet.security.authentication import ( + TokenAuthenticationPolicy, + JWTCookieAuthenticationPolicy, + AuthLoginResult, +) from tet.view import view_config DB_NAME = "test_tet" @@ -56,25 +60,32 @@ def db_session(db_engine, pyramid_request, transaction_manager): yield session -def login_callback(request: Request) -> tp.Any: +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 None + return AuthLoginResult(user_id=None) db_session = request.find_service(Session) - try: - payload = request.json_body - except Exception: - return None + payload = request.json_body user_identity = payload.get("user_identity") + totp_token = payload.get("totp_token") + if not user_identity: - return None + return AuthLoginResult(user_id=None) user: User = db_session.query(User).filter(User.email == user_identity).one_or_none() - if not user: - return None - return user.id + 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: @@ -162,5 +173,6 @@ def pyramid_app(security_policy, pyramid_config): 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/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/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 index 80f4b8c..2d37525 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -1,12 +1,17 @@ -import logging +import json +import logging as l import pytest +import structlog from pyramid import testing from pyramid.events import subscriber +from webtest import TestApp +from tests.services.constants import LOGIN_ENDPOINT +from tet.security.authentication import TetTokenService from tet.security.events import AuthnLoginSuccess, AuthnLoginFail -logger = logging.getLogger(__name__) +logger = l.getLogger(__name__) DEFAULT_MESSAGE = "Event triggers the simulated audit log:" @@ -43,7 +48,7 @@ def _make(handler, event_class): return _make -def test_login_success_event(pyramid_request_with_event, caplog): +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" @@ -53,7 +58,7 @@ def test_login_success_event(pyramid_request_with_event, caplog): assert f"{DEFAULT_MESSAGE} {message}" in caplog.text -def test_login_failed_event(pyramid_request_with_event, caplog): +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" @@ -63,4 +68,124 @@ def test_login_failed_event(pyramid_request_with_event, caplog): assert f"{DEFAULT_MESSAGE} {message}" in caplog.text -# TODO: More test with the actual views +# 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_with_jwt_cookie_policy(request, 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" + + +def test_login_view_emits_success_event( + pyramid_test_app_with_jwt_cookie_policy, + capture_token, + pyramid_request, + caplog, + structlog_security_config, +): + app = pyramid_test_app_with_jwt_cookie_policy + 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_with_jwt_cookie_policy, pyramid_request, caplog, structlog_security_config +): + app = pyramid_test_app_with_jwt_cookie_policy + 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" + ) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 41fb934..30c395c 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -6,14 +6,10 @@ from webtest import TestApp from tests.models.accounts import 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.authentication import TetTokenService -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 = "/" - @pytest.fixture() def pyramid_test_app(request, pyramid_app): @@ -39,7 +35,7 @@ def authentication_tokens(pyramid_test_app, capture_token, pyramid_request): def create_user(db_session: Session): user = User(email="exampple2@invalid.invalid", name="example2", is_admin=True) user.password = "1234@abcd" - default_user = db_session.query(User).filter(User.email == user.email).first() + default_user = db_session.query(User).filter(User.email == user.email).one_or_none() if default_user: return default_user @@ -193,11 +189,6 @@ def pyramid_test_app_with_jwt_cookie_policy(request, pyramid_app): return TestApp(pyramid_app) -def get_cookie(cookiejar, name): - founded_cookie = [cookie for cookie in cookiejar if cookie.name == name] - return founded_cookie[0].value if founded_cookie else None - - def test_login_view_should_return_refresh_token( pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request ): diff --git a/tests/services/utils/authentication.py b/tests/services/utils/authentication.py new file mode 100644 index 0000000..3ffa13c --- /dev/null +++ b/tests/services/utils/authentication.py @@ -0,0 +1,3 @@ +def get_cookie(cookiejar, name): + founded_cookie = [cookie for cookie in cookiejar if cookie.name == name] + return founded_cookie[0].value if founded_cookie else None From ec87ac7ad4054b891825c9c7c2c02c9f5e80161f Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 11 Jul 2025 15:49:23 +0300 Subject: [PATCH 106/139] Update tests: - Add pyramid_event_app and pyramid_event_request fixtures for event-driven Pyramid auth tests - Add tests for AuthViews.login to verify login success and fail events are notified correctly --- tests/conftest.py | 31 ++++++++++++++ tests/services/security/test_auth_events.py | 47 ++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index c3f2976..8d14e6e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -176,3 +176,34 @@ def pyramid_app(security_policy, pyramid_config): 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, + ) + 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/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index 2d37525..cde38cc 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -1,14 +1,16 @@ 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.services.constants import LOGIN_ENDPOINT -from tet.security.authentication import TetTokenService +from tet.security.authentication import TetTokenService, AuthViews, AuthLoginResult from tet.security.events import AuthnLoginSuccess, AuthnLoginFail logger = l.getLogger(__name__) @@ -189,3 +191,46 @@ def test_login_view_emits_fail_event( assert matched, ( f"No WARNING log with description '{expected_description}' found in 'audit' logger" ) + + +def test_login_notify_success(pyramid_event_request): + request = pyramid_event_request + request.registry.tet_auth_login_callback = lambda req: AuthLoginResult( + user_id=1, user_identity=DEFAULT_USER_IDENTITY, success=True + ) + + view = AuthViews(request, route_prefix="/auth") + 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 + request.registry.tet_auth_login_callback = lambda req: AuthLoginResult( + user_id=None, user_identity=DEFAULT_USER_IDENTITY + ) + + view = AuthViews(request, route_prefix="/auth") + 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: + with pytest.raises(HTTPUnauthorized): + view.login() + expected_event = AuthnLoginFail( + user_identity=DEFAULT_USER_IDENTITY, + request=request, + ) + mock_notify.assert_called_once_with(expected_event) From f51b1aa1d3dca64f8d2e64586985f80a9bb7fdf4 Mon Sep 17 00:00:00 2001 From: longnguyen Date: Fri, 11 Jul 2025 15:52:10 +0300 Subject: [PATCH 107/139] Update tests utils: - rename founded_cookie to improve readability. --- tests/services/utils/authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/services/utils/authentication.py b/tests/services/utils/authentication.py index 3ffa13c..1480a5e 100644 --- a/tests/services/utils/authentication.py +++ b/tests/services/utils/authentication.py @@ -1,3 +1,3 @@ def get_cookie(cookiejar, name): - founded_cookie = [cookie for cookie in cookiejar if cookie.name == name] - return founded_cookie[0].value if founded_cookie else None + matching_cookies = [cookie for cookie in cookiejar if cookie.name == name] + return matching_cookies[0].value if matching_cookies else None From e9a111c416dddab1a60d596527342d5b03f5f6ef Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:13:22 +0000 Subject: [PATCH 108/139] docs: add TODO for JWT auth fixes needed before merge Co-Authored-By: Claude Opus 4.6 --- TODO.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..86924a2 --- /dev/null +++ b/TODO.md @@ -0,0 +1,78 @@ +# TODO: JWT Token Auth PR + +## Critical — must fix before merge + +### Remove `JWTCookieAuthenticationPolicy` +- Remove the entire `JWTCookieAuthenticationPolicy` class and related code +- JWT access tokens belong in `Authorization: Bearer` header only, never in cookies +- Putting JWT in a cookie requires CSRF protection (currently disabled!) and defeats + the purpose of bearer tokens +- Only the **refresh token** belongs in a cookie (httpOnly, secure) +- Remove `COOKIE_LOGIN_VIEW` and any views/config that wire up cookie-based JWT auth + +### Fix cookie defaults +- `CookieAttributes` must default to `httponly=True` and `secure=True` +- Current defaults (`False`/`False`) completely defeat the security benefit of + storing the refresh token in a cookie (XSS protection) +- Consider adding a development mode that allows `secure=False` for localhost + +### Fix shared mutable JWT claims object +- `create_short_term_jwt()` does `payload = self.jwt_claims` — this is a reference, + not a copy, then mutates `user_id`, `iat`, `exp` on it +- Thread-unsafe: concurrent requests will corrupt each other's claims +- Fix: use `dataclasses.replace(self.jwt_claims)` or `copy.deepcopy()` +- Same issue with `DEFAULT_REGISTERED_CLAIMS`, `DEFAULT_COOKIE_ATTRIBUTES`, + `DEFAULT_SECURITY_POLICY` — shared mutable instances + +### Fix JWT exception handling +- `verify_jwt()` only catches `jwt.ExpiredSignatureError` +- `InvalidSignatureError`, `DecodeError`, `InvalidAudienceError`, `InvalidIssuerError` + etc. will crash instead of returning `None` +- Catch `jwt.InvalidTokenError` (base class for all PyJWT exceptions) + +### Add timeout to password breach API +- `requests.get(url)` in `is_password_breached()` has no timeout +- If Pwned Passwords API is down, the entire password change flow hangs +- Add `timeout=5` and wrap in try/except with graceful degradation: + ```python + try: + response = requests.get(url, timeout=5) + response.raise_for_status() + except requests.RequestException: + logger.warning("Password breach check unavailable") + return False + ``` + +## High priority + +### Split `authentication.py` (1457 lines) +- This single file contains token service, auth service, MFA service, views, + config dataclasses, security policies, and Pyramid configuration +- Suggested split: + - `tet/security/config.py` — dataclasses (`CookieAttributes`, `JWTRegisteredClaims`, etc.) + - `tet/security/tokens.py` — `TetTokenService` (create/validate tokens) + - `tet/security/auth.py` — `TetAuthService` (password verification, breach check) + - `tet/security/mfa.py` — `TetMFAService` (TOTP setup/verification) + - `tet/security/views.py` — `AuthViews` (login, logout, refresh, MFA endpoints) + - `tet/security/policy.py` — `TokenAuthenticationPolicy` + - `tet/security/authentication.py` — top-level `includeme()` wiring it all together + +### Add missing tests +- Invalid JWT signature → should return `None`, not crash +- Expired JWT → should return `None` +- Malformed/garbage token → should return `None` +- Missing refresh token cookie → should return 401 +- Breach API timeout/failure → should degrade gracefully +- Concurrent token creation → verify no shared state corruption + +### 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 + +## Minor + +- Typo: "credientials" → "credentials" in `DEFAULT_UNAUTHORIZED_MESSAGE` +- Magic numbers (token ID length `8`, hash prefix `[:5]`) should be named constants +- Document why `require_csrf=False` on all auth endpoints (stateless Bearer token auth) +- Consider rate limiting guidance in docs (login, refresh, MFA endpoints) From 183a1e97f89c8c1d034912b18e5e30ff760e8220 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:14:28 +0000 Subject: [PATCH 109/139] =?UTF-8?q?docs:=20add=20TODO=20item=20=E2=80=94?= =?UTF-8?q?=20return=20refresh=20token=20in=20response=20body=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-browser clients (mobile, CLI, APIs) can't use cookies. The refresh token must be returned in JSON alongside the access token, and the refresh endpoint should accept it from the request body as well. Co-Authored-By: Claude Opus 4.6 --- TODO.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 86924a2..ea94bbe 100644 --- a/TODO.md +++ b/TODO.md @@ -7,9 +7,18 @@ - JWT access tokens belong in `Authorization: Bearer` header only, never in cookies - Putting JWT in a cookie requires CSRF protection (currently disabled!) and defeats the purpose of bearer tokens -- Only the **refresh token** belongs in a cookie (httpOnly, secure) - Remove `COOKIE_LOGIN_VIEW` and any views/config that wire up cookie-based JWT auth +### Refresh token must be returned in the response body +- Currently the refresh token is only set as a cookie +- The login and refresh endpoints must also return the refresh token in the JSON + response body (alongside the access token), so non-browser clients (mobile apps, + CLI tools, other APIs) can store and manage it themselves +- The cookie is one delivery mechanism, not the only one — the client decides + how to store the refresh token +- The `/token/refresh` endpoint should accept the refresh token from the request + body as well, not only from cookies + ### Fix cookie defaults - `CookieAttributes` must default to `httponly=True` and `secure=True` - Current defaults (`False`/`False`) completely defeat the security benefit of From a7214c49ff5d38a5f4a2e25f19e2c0eb27ae2b8b Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:18:06 +0000 Subject: [PATCH 110/139] =?UTF-8?q?docs:=20clarify=20magic=20number=20note?= =?UTF-8?q?=20=E2=80=94=20prefix=20handling=20is=20actually=20correct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token prefix stripping uses len(prefix), not a hardcoded value. Token ID 8 bytes is consistent between creation and parsing. HIBP [:5] is a fixed API contract. Co-Authored-By: Claude Opus 4.6 --- TODO.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index ea94bbe..037e348 100644 --- a/TODO.md +++ b/TODO.md @@ -82,6 +82,8 @@ ## Minor - Typo: "credientials" → "credentials" in `DEFAULT_UNAUTHORIZED_MESSAGE` -- Magic numbers (token ID length `8`, hash prefix `[:5]`) should be named constants +- Token ID byte length `8` (line 551/576) could be a named constant for clarity, + though it is internally consistent. The HIBP `[:5]` hash prefix is a fixed API + contract, not a magic number. - Document why `require_csrf=False` on all auth endpoints (stateless Bearer token auth) - Consider rate limiting guidance in docs (login, refresh, MFA endpoints) From f41c5788936a886c55c916cec5468b4462e73b09 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:55:23 +0000 Subject: [PATCH 111/139] refactor: split security monolith into modules, fix auth bugs Split tet/security/authentication.py (1457 lines) into focused modules: - config.py: dataclasses, constants, protocols, enums - models.py: SQLAlchemy model mixins (TokenMixin, MFA mixin) - policy.py: TokenAuthenticationPolicy (removed JWTCookieAuthenticationPolicy) - tokens.py: TetTokenService (JWT + long-term token management) - auth.py: TetAuthService (cookies, password, breach check) - mfa.py: TetMultiFactorAuthenticationService (TOTP setup/verify/challenge) - views.py: AuthViews (all HTTP endpoints) - authentication.py: slim directive + includeme + backward-compat re-exports Security fixes: - Cookie defaults: secure=True, httponly=True (were both False) - Fix shared mutable JWT claims via dataclasses.replace() - Catch jwt.InvalidTokenError instead of just ExpiredSignatureError - Add timeout=5 to breach API call with graceful degradation - Return refresh_token in login response body (not just cookie) - Accept refresh_token from request body in refresh endpoint - Store route_prefix on registry at config time (not fragile runtime lookup) Other improvements: - Remove JWTCookieAuthenticationPolicy entirely - Use keyword-only arguments on service methods - Move auth deps to optional extras group in setup.py - Add pytest-cov, update CI with coverage summary - Add tests for JWT edge cases, refresh token, breach API timeout Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 15 +- TODO.md | 84 +- src/tet/security/__init__.py | 43 +- src/tet/security/auth.py | 151 ++ src/tet/security/authentication.py | 1326 +---------------- src/tet/security/config.py | 204 +++ src/tet/security/mfa.py | 258 ++++ src/tet/security/models.py | 60 + src/tet/security/policy.py | 73 + src/tet/security/tokens.py | 184 +++ src/tet/security/views.py | 354 +++++ tests/conftest.py | 24 +- tests/services/security/conftest.py | 27 +- tests/services/security/test_auth_events.py | 25 +- .../services/security/test_authentication.py | 133 +- 15 files changed, 1490 insertions(+), 1471 deletions(-) create mode 100644 src/tet/security/auth.py create mode 100644 src/tet/security/config.py create mode 100644 src/tet/security/mfa.py create mode 100644 src/tet/security/models.py create mode 100644 src/tet/security/policy.py create mode 100644 src/tet/security/tokens.py create mode 100644 src/tet/security/views.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8745b54..0893357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:13 + image: postgres:16 env: POSTGRES_USER: test_tet POSTGRES_PASSWORD: test_tet @@ -24,7 +24,6 @@ jobs: strategy: matrix: python-version: - - "3.9" - "3.10" - "3.11" - "3.12" @@ -39,8 +38,16 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install -e '.[dev]' - - name: Run tests - run: pytest + - 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 release: name: Build and publish to PyPI diff --git a/TODO.md b/TODO.md index 037e348..b128b87 100644 --- a/TODO.md +++ b/TODO.md @@ -1,89 +1,17 @@ -# TODO: JWT Token Auth PR +# TODO -## Critical — must fix before merge +## Upstream -### Remove `JWTCookieAuthenticationPolicy` -- Remove the entire `JWTCookieAuthenticationPolicy` class and related code -- JWT access tokens belong in `Authorization: Bearer` header only, never in cookies -- Putting JWT in a cookie requires CSRF protection (currently disabled!) and defeats - the purpose of bearer tokens -- Remove `COOKIE_LOGIN_VIEW` and any views/config that wire up cookie-based JWT auth +- Pyramid depends on `pkg_resources` which was removed in setuptools 82. + Pin `setuptools<82` until Pyramid releases a fix. -### Refresh token must be returned in the response body -- Currently the refresh token is only set as a cookie -- The login and refresh endpoints must also return the refresh token in the JSON - response body (alongside the access token), so non-browser clients (mobile apps, - CLI tools, other APIs) can store and manage it themselves -- The cookie is one delivery mechanism, not the only one — the client decides - how to store the refresh token -- The `/token/refresh` endpoint should accept the refresh token from the request - body as well, not only from cookies - -### Fix cookie defaults -- `CookieAttributes` must default to `httponly=True` and `secure=True` -- Current defaults (`False`/`False`) completely defeat the security benefit of - storing the refresh token in a cookie (XSS protection) -- Consider adding a development mode that allows `secure=False` for localhost - -### Fix shared mutable JWT claims object -- `create_short_term_jwt()` does `payload = self.jwt_claims` — this is a reference, - not a copy, then mutates `user_id`, `iat`, `exp` on it -- Thread-unsafe: concurrent requests will corrupt each other's claims -- Fix: use `dataclasses.replace(self.jwt_claims)` or `copy.deepcopy()` -- Same issue with `DEFAULT_REGISTERED_CLAIMS`, `DEFAULT_COOKIE_ATTRIBUTES`, - `DEFAULT_SECURITY_POLICY` — shared mutable instances - -### Fix JWT exception handling -- `verify_jwt()` only catches `jwt.ExpiredSignatureError` -- `InvalidSignatureError`, `DecodeError`, `InvalidAudienceError`, `InvalidIssuerError` - etc. will crash instead of returning `None` -- Catch `jwt.InvalidTokenError` (base class for all PyJWT exceptions) - -### Add timeout to password breach API -- `requests.get(url)` in `is_password_breached()` has no timeout -- If Pwned Passwords API is down, the entire password change flow hangs -- Add `timeout=5` and wrap in try/except with graceful degradation: - ```python - try: - response = requests.get(url, timeout=5) - response.raise_for_status() - except requests.RequestException: - logger.warning("Password breach check unavailable") - return False - ``` - -## High priority - -### Split `authentication.py` (1457 lines) -- This single file contains token service, auth service, MFA service, views, - config dataclasses, security policies, and Pyramid configuration -- Suggested split: - - `tet/security/config.py` — dataclasses (`CookieAttributes`, `JWTRegisteredClaims`, etc.) - - `tet/security/tokens.py` — `TetTokenService` (create/validate tokens) - - `tet/security/auth.py` — `TetAuthService` (password verification, breach check) - - `tet/security/mfa.py` — `TetMFAService` (TOTP setup/verification) - - `tet/security/views.py` — `AuthViews` (login, logout, refresh, MFA endpoints) - - `tet/security/policy.py` — `TokenAuthenticationPolicy` - - `tet/security/authentication.py` — top-level `includeme()` wiring it all together - -### Add missing tests -- Invalid JWT signature → should return `None`, not crash -- Expired JWT → should return `None` -- Malformed/garbage token → should return `None` -- Missing refresh token cookie → should return 401 -- Breach API timeout/failure → should degrade gracefully -- Concurrent token creation → verify no shared state corruption +## Remaining improvements ### 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 -## Minor - -- Typo: "credientials" → "credentials" in `DEFAULT_UNAUTHORIZED_MESSAGE` -- Token ID byte length `8` (line 551/576) could be a named constant for clarity, - though it is internally consistent. The HIBP `[:5]` hash prefix is a fixed API - contract, not a magic number. +### Documentation - Document why `require_csrf=False` on all auth endpoints (stateless Bearer token auth) - Consider rate limiting guidance in docs (login, refresh, MFA endpoints) diff --git a/src/tet/security/__init__.py b/src/tet/security/__init__.py index a168684..e59f73c 100644 --- a/src/tet/security/__init__.py +++ b/src/tet/security/__init__.py @@ -1,8 +1,37 @@ -""" -Security utilities for Tet applications. +from tet.security.config import ( + AuthLoginResult, + CookieAttributes, + ILoginCallback, + ISecretCallback, + JWTRegisteredClaims, + MultiFactorAuthMethodType, + PasswordChangeData, + TOTPData, +) +from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + 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.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__ = [ + "AuthLoginResult", + "AuthViews", + "CookieAttributes", + "ILoginCallback", + "ISecretCallback", + "JWTRegisteredClaims", + "MultiFactorAuthMethodType", + "MultiFactorAuthenticationMethodMixin", + "PasswordChangeData", + "TetAuthService", + "TetMultiFactorAuthenticationService", + "TetTokenService", + "TOTPData", + "TokenAuthenticationPolicy", + "TokenMixin", +] diff --git a/src/tet/security/auth.py b/src/tet/security/auth.py new file mode 100644 index 0000000..c8f3ac2 --- /dev/null +++ b/src/tet/security/auth.py @@ -0,0 +1,151 @@ +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}/" + + def set_cookies( + self, + *, + cookie_attributes: CookieAttributes, + refresh_token: str, + **kwargs, + ): + if cookie_attributes: + cookie_attributes.value = refresh_token + if not cookie_attributes.max_age: + cookie_attributes.max_age = 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.verify_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") + 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 index 11073d0..9310db1 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,289 +1,57 @@ -import base64 -import dataclasses -import enum -import hashlib -import io -import logging -import secrets import typing as tp -from sqlalchemy.exc import SQLAlchemyError - -import tet.security.events as security_events -from datetime import datetime, timedelta, timezone - -import jwt -import pyotp -import qrcode -import qrcode.image.svg -import requests -from pyramid.authentication import CallbackAuthenticationPolicy -from pyramid.authorization import ACLHelper from pyramid.config import Configurator -from pyramid.httpexceptions import ( - HTTPForbidden, - HTTPUnauthorized, - HTTPBadRequest, - HTTPException, - HTTPInternalServerError, +from pyramid.security import NO_PERMISSION_REQUIRED +from zope.interface import Interface + +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, ) -from pyramid.interfaces import ISecurityPolicy -from pyramid.request import Request -from pyramid.response import Response -from pyramid.security import NO_PERMISSION_REQUIRED, Everyone, Authenticated -from pyramid_di import RequestScopedBaseService, autowired -from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Session -from sqlalchemy.sql import delete -from zope.interface import Interface, implementer +from tet.security.models import ( + MultiFactorAuthenticationMethodMixin, + 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.views import AuthViews -logger = logging.getLogger(__name__) __all__ = [ - "TokenAuthenticationPolicy", - "JWTCookieAuthenticationPolicy", - "TokenMixin", + "AuthLoginResult", + "AuthViews", + "CookieAttributes", + "ILoginCallback", + "ISecretCallback", "JWTRegisteredClaims", "MultiFactorAuthMethodType", "MultiFactorAuthenticationMethodMixin", + "PasswordChangeData", + "TetAuthService", "TetMultiFactorAuthenticationService", "TetTokenService", "TOTPData", - "CookieAttributes", - "AuthLoginResult", + "TokenAuthenticationPolicy", + "TokenMixin", ] - -@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.utcnow() + timedelta(hours=1), - iat=datetime.utcnow(), - 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} - - -@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. - """ - 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 [] - - -@implementer(ISecurityPolicy) -class JWTCookieAuthenticationPolicy(TokenAuthenticationPolicy): - """ - A Pyramid security policy that authenticates users via JWT tokens stored in cookies. - - All methods in this class are only invoked if the view has a `permission` set in `@view_config()`, - ensuring authentication and authorization checks are enforced before access is granted. - - This policy retrieves JWT tokens from cookies instead of headers. - """ - - def __init__(self): - super().__init__() - - -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 credientials are correct and try again. -""" - - -@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 = False - httponly: bool = False - samesite: str = "Lax" - overwrite: bool = True - - -DEFAULT_LOGIN_ATTR = "login" -COOKIE_LOGIN_VIEW = "cookie_login" -DEFAULT_REGISTERED_CLAIMS = JWTRegisteredClaims() DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() -DEFAULT_COOKIE_ATTRIBUTES = CookieAttributes() -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" - - -@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 def set_token_authentication( @@ -304,78 +72,29 @@ def set_token_authentication( 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[ - tp.Union[type["TokenAuthenticationPolicy"], type["JWTCookieAuthenticationPolicy"]] - ] = DEFAULT_SECURITY_POLICY, + security_policy: tp.Optional[type["TokenAuthenticationPolicy"]] = DEFAULT_SECURITY_POLICY, ) -> None: """ Configure token-based authentication for a Pyramid application (with conflict detection). - .. note:: - - This function is intended to be used as a Pyramid configuration directive. By calling - :meth:`pyramid.config.Configurator.action` with a unique ``discriminator``, it ensures - that conflicts are detected if multiple parts of the application try to register the - same directive. - Example: - 1. **Add the directive** (typically in your ``includeme`` function): - - .. code-block:: python - - from pyramid.config import Configurator - from myproject.auth import set_token_authentication - - def includeme(config: Configurator): - # Register the custom directive - config.add_directive( - 'set_token_authentication', - set_token_authentication - ) - - 2. **Use the directive** somewhere after including it: - - .. code-block:: python - - def main(global_config, **settings): - config = Configurator(settings=settings) - config.include('myproject') # calls includeme(...) - - config.set_token_authentication( - long_term_token_model=MyTokenModel, - project_prefix='my_project', - login_callback=verify_user, - jwk_resolver=get_secret, - jwt_algorithm='HS256', - jwt_token_expiration_mins=120 - ) - - return config.make_wsgi_app() - - **Accessing the Configured Values** - - Later in the application code, it can retrieve these values from ``request.registry``: - - .. code-block:: python - - @view_config(route_name='home') - def home_view(request): - long_term_token_model = request.registry.tet_auth_long_term_token_model - prefix = request.registry.tet_auth_project_prefix - # ... do something with these values ... - 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). - user_id_column: Column name or attribute for user ID in the token model. Defaults to ``"user_id"``. 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). - access_token_header: The header name for the access token (default: ``"X-Access-Token"``). - long_term_token_header: The header name for the long-term token (default: ``"X-Long-Token"``). + 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. - security_policy: A custom security policy to use for token authentication. + cookie_attributes: Optional cookie attributes for refresh token cookies. + security_policy: A security policy instance to use for token authentication. """ def register(): @@ -402,953 +121,10 @@ def register(): config.set_security_policy(security_policy) -@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" - - -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 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) - - -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(8, "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[:8] - secret = payload[8:] - - 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 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 15-minute expiration. - - Args: - user_id: The ID of the user for whom the JWT is generated. - Returns: - The encoded JWT as a string. - """ - # TODO: In the next update, we can add more encoding options here, such as headers, json_encoder. - if not user_id: - raise ValueError("User ID is required") - - payload = 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.ExpiredSignatureError: - 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 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) - - -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 - - def set_cookies( - self, - cookie_attributes: CookieAttributes, - **kwargs, - ): - route_prefix = kwargs.pop("route_prefix") - refresh_token = kwargs.pop("refresh_token") - - if cookie_attributes: - cookie_attributes.value = refresh_token - if not cookie_attributes.max_age: - cookie_attributes.max_age = 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=f"{route_prefix}/", - ) - self.request.response.set_cookie( - **cookie_attrs.__dict__, - **kwargs, - ) - - def delete_cookie(self, *, name: str, path: str = "/", **kwargs): - self.request.response.delete_cookie( - name=name, - path=path, - **kwargs, - ) - - def validate_and_create_jwt(self, refresh_token: str, route_prefix: str) -> str: - try: - token_from_db = self.token_service.retrieve_and_validate_token( - refresh_token, self.project_prefix - ) - except ValueError as e: - logger.exception(f"Error validating token: {e}") - self.delete_cookie( - name=self.long_term_token_cookie_name, - path=f"{route_prefix}/", - ) - 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.verify_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}" - response = requests.get(url) - response.raise_for_status() - - 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 - - -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.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, - ): - """ - 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) - return ( - self.session.query(self.tet_multi_factor_auth_method_model) - .filter(*conditions) - .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, setup_key: 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."} - ) - - 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() - - data = TOTPData( - secret=setup_key, - issuer=self.project_prefix, - ) - totp_mfa_method.verified = True - totp_mfa_method.is_active = True - totp_mfa_method.data = data.to_dict() - 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 handle_totp_challenge( - self, - user_id: tp.Any, - totp_token: str = None, - cookie_attributes: CookieAttributes = None, - route_prefix: str = None, - ) -> dict[str, tp.Any]: - totp_mfa_method = self.get_method( - user_id=user_id, - method_type=MultiFactorAuthMethodType.TOTP, - is_active=True, - verified=True, - ) - 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."}) - - totp_mfa_method.mark_used() - - refresh_token = self.token_service.create_long_term_token(user_id, 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, - route_prefix=route_prefix, - ) - 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} - - @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: tp.Union[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 not existing_method: - 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") - - -class AuthViews: - token_service: TetTokenService = autowired(TetTokenService) - auth_service: TetAuthService = autowired(TetAuthService) - multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( - TetMultiFactorAuthenticationService - ) - 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.request.current_route_path().rpartition("/")[0] - self.login_callback = self.registry.tet_auth_login_callback - self.cookie_attributes: tp.Optional[CookieAttributes] = ( - self.registry.tet_auth_cookie_attributes - ) - - def login(self) -> dict[str, tp.Any]: - 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}) - refresh_token = self.token_service.create_long_term_token(user_id, self.project_prefix) - access_token = self.token_service.create_short_term_jwt(user_id) - - 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 - ) - - self.auth_service.set_cookies( - cookie_attributes=self.cookie_attributes, - refresh_token=refresh_token, - route_prefix=self.route_prefix, - ) - response_payload["access_token"] = access_token - - self.registry.notify( - security_events.AuthnLoginSuccess( # type: ignore - 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)}") - raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e - except HTTPException: - self.registry.notify( - security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) - ) - raise - 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) - ) - raise HTTPInternalServerError(json_body={"message": "Login failed"}) from e - - 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.request.authenticated_userid - if not user_id: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - - payload = self.request.json_body - token = payload["token"] - setup_key = payload["setup_key"] - return self.multi_factor_auth_service.handle_totp_verify( - user_id=user_id, token=token, setup_key=setup_key - ) - - 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: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - - access_token = self.auth_service.validate_and_create_jwt( - refresh_token=refresh_token, route_prefix=self.route_prefix - ) - return {"success": True, "access_token": access_token} - - def change_password(self): - user_id = self.request.authenticated_userid - data = self.request.json_body - payload = PasswordChangeData( - current_password=data["currentPassword"], - new_password=data["newPassword"], - ) - try: - if user_id is None: - raise HTTPUnauthorized( - json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} - ) - 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( # type: ignore - request=self.request, authenticated_userid=user_id - ) - ) - return {"success": is_valid} - except ValueError as e: - self.registry.notify( - security_events.AuthnPasswordChangeFail( # type: ignore - 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( # type: ignore - request=self.request, authenticated_userid=user_id - ) - ) - raise e - except Exception as e: - logger.exception(f"Error changing password: {e}") - self.registry.notify( - security_events.AuthnPasswordChangeFail( # type: ignore - 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 = self.request.authenticated_userid - try: - 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.response.delete_cookie( - name=self.long_term_token_cookie_name, - path=f"{self.route_prefix}/", - ) - self.registry.notify( - security_events.AuthnCurrentRefreshTokenRevoked( - request=self.request, authenticated_userid=user_id - ) - ) - self.registry.notify( - security_events.AuthnLogoutSuccess( # type: ignore - request=self.request, user_id=user_id - ) - ) - return {"success": True} - except HTTPException as e: - self.registry.notify( - security_events.AuthnLogoutFail( # type: ignore - request=self.request, user_id=user_id - ) - ) - raise e - except SQLAlchemyError as e: - logger.exception(f"Database error during logout: {e}") - self.registry.notify( - security_events.AuthnCurrentRefreshTokenRevokeFail( # type: ignore - 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( # type: ignore - request=self.request, user_id=user_id - ) - ) - return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) - - def disable_mfa_method(self): - user_id = self.request.authenticated_userid - payload = self.request.json_body - mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) - try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - if not mfa_method_type: - raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) - self.multi_factor_auth_service.disable_method( - user_id=user_id, method_type=mfa_method_type - ) - self.registry.notify( - security_events.AuthnMfaMethodDisabled( # type: ignore - request=self.request, - authenticated_userid=user_id, - method=mfa_method_type.value if mfa_method_type else None, - ) - ) - return {"success": True} - except HTTPException as e: - self.registry.notify( - security_events.AuthnMfaMethodDisableFail( # type: ignore - request=self.request, - authenticated_userid=user_id, - method=mfa_method_type.value if mfa_method_type else None, - ) - ) - raise e - except Exception as e: - logger.exception(f"Error disabling MFA method: {e}") - self.registry.notify( - security_events.AuthnMfaMethodDisableFail( # type: ignore - 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 = self.request.authenticated_userid - user = self.auth_service.get_current_user(user_id=user_id) - payload = self.request.json_body - try: - 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( # type: ignore - request=self.request, - authenticated_userid=user_id, - ) - ) - return {"success": True} - except HTTPException as e: - self.registry.notify( - security_events.AuthnRefreshTokenRevokeFail( # type: ignore - request=self.request, - authenticated_userid=user_id, - ) - ) - raise e - except Exception as e: - logger.exception(f"Error revoking other tokens: {e}") - self.registry.notify( - security_events.AuthnRefreshTokenRevokeFail( # type: ignore - 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.request.authenticated_userid - try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - 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.request.authenticated_userid - user = self.auth_service.get_current_user(user_id=user_id) - payload = self.request.json_body - try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - - if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: - return self.multi_factor_auth_service.handle_totp_setup( - user=user, project_prefix=self.project_prefix - ) - return None - 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 - - 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") diff --git a/src/tet/security/config.py b/src/tet/security/config.py new file mode 100644 index 0000000..40340ea --- /dev/null +++ b/src/tet/security/config.py @@ -0,0 +1,204 @@ +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 + + +@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.utcnow() + timedelta(hours=1), + iat=datetime.utcnow(), + 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} + + +@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/mfa.py b/src/tet/security/mfa.py new file mode 100644 index 0000000..d92b192 --- /dev/null +++ b/src/tet/security/mfa.py @@ -0,0 +1,258 @@ +import base64 +import io +import logging +import typing as tp + +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, +) +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.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, + ): + """ + 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) + return ( + self.session.query(self.tet_multi_factor_auth_method_model) + .filter(*conditions) + .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, setup_key: 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."} + ) + + 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() + + data = TOTPData( + secret=setup_key, + issuer=self.project_prefix, + ) + totp_mfa_method.verified = True + totp_mfa_method.is_active = True + totp_mfa_method.data = data.to_dict() + 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 handle_totp_challenge( + self, + *, + user_id: tp.Any, + totp_token: str = None, + cookie_attributes: CookieAttributes = None, + ) -> dict[str, tp.Any]: + totp_mfa_method = self.get_method( + user_id=user_id, + method_type=MultiFactorAuthMethodType.TOTP, + is_active=True, + verified=True, + ) + 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."}) + + 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: tp.Union[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 not existing_method: + 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..27399f0 --- /dev/null +++ b/src/tet/security/models.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from sqlalchemy import 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 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..fd10778 --- /dev/null +++ b/src/tet/security/policy.py @@ -0,0 +1,73 @@ +import typing as tp + +from pyramid.authentication import CallbackAuthenticationPolicy +from pyramid.authorization import ACLHelper +from pyramid.interfaces import ISecurityPolicy +from pyramid.request import Request +from pyramid.security import Everyone, Authenticated +from zope.interface import implementer + + +@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/tokens.py b/src/tet/security/tokens.py new file mode 100644 index 0000000..a643a64 --- /dev/null +++ b/src/tet/security/tokens.py @@ -0,0 +1,184 @@ +import dataclasses +import hashlib +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 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 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..8c8750e --- /dev/null +++ b/src/tet/security/views.py @@ -0,0 +1,354 @@ +import logging +import typing as tp + +from pyramid.httpexceptions import ( + HTTPForbidden, + HTTPUnauthorized, + HTTPBadRequest, + HTTPException, + HTTPInternalServerError, +) +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 + +logger = logging.getLogger(__name__) + + +class AuthViews: + token_service: TetTokenService = autowired(TetTokenService) + auth_service: TetAuthService = autowired(TetAuthService) + multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( + TetMultiFactorAuthenticationService + ) + 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 login(self) -> dict[str, tp.Any]: + 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}) + 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) + + 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 + ) + + 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)}") + raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e + except HTTPException: + self.registry.notify( + security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) + ) + raise + 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) + ) + raise HTTPInternalServerError(json_body={"message": "Login failed"}) from e + + 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.request.authenticated_userid + if not user_id: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + payload = self.request.json_body + token = payload["token"] + setup_key = payload["setup_key"] + return self.multi_factor_auth_service.handle_totp_verify( + user_id=user_id, token=token, setup_key=setup_key + ) + + 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 = self.request.authenticated_userid + data = self.request.json_body + payload = PasswordChangeData( + current_password=data["currentPassword"], + new_password=data["newPassword"], + ) + try: + if user_id is None: + raise HTTPUnauthorized( + json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} + ) + 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 + ) + ) + raise 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 = self.request.authenticated_userid + try: + 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 + ) + ) + raise 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 = self.request.authenticated_userid + payload = self.request.json_body + mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + if not mfa_method_type: + raise HTTPForbidden(json_body={"message": "Invalid MFA 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 if mfa_method_type else None, + ) + ) + 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, + ) + ) + raise 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 = self.request.authenticated_userid + user = self.auth_service.get_current_user(user_id=user_id) + payload = self.request.json_body + try: + 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, + ) + ) + raise 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.request.authenticated_userid + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + 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.request.authenticated_userid + user = self.auth_service.get_current_user(user_id=user_id) + payload = self.request.json_body + try: + if user_id is None: + raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + + if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: + return self.multi_factor_auth_service.handle_totp_setup( + user=user, project_prefix=self.project_prefix + ) + return None + 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/tests/conftest.py b/tests/conftest.py index 8d14e6e..68f3160 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,6 @@ from tet.config import Configurator as tetConfigurator from tet.security.authentication import ( TokenAuthenticationPolicy, - JWTCookieAuthenticationPolicy, AuthLoginResult, ) from tet.view import view_config @@ -42,9 +41,6 @@ def db_engine(database): engine = create_engine(DB_URL) Base.metadata.create_all(engine) yield engine - # TODO: Dropping all entities will disrupt the saving of tokens in the security/authentication module. - # Investigate the workflow and resolve the issue. - # Base.metadata.drop_all(engine) engine.dispose() @@ -133,20 +129,6 @@ def pyramid_config(db_engine): yield config -JWT_AUTH = "TOKEN_AUTH" -JWT_COOKIE_AUTH = "JWT_COOKIE_AUTH" - - -@pytest.fixture( - params=[ - pytest.param({"security_policy": TokenAuthenticationPolicy}, id=JWT_AUTH), - pytest.param({"security_policy": JWTCookieAuthenticationPolicy}, id=JWT_COOKIE_AUTH), - ] -) -def security_policy(request): - return request.param["security_policy"] - - @view_config(route_name="home", renderer="json", permission="view") def home_view(request: Request): response: Response = request.response @@ -156,13 +138,13 @@ def home_view(request: Request): @pytest.fixture() -def pyramid_app(security_policy, pyramid_config): +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=security_policy(), + security_policy=TokenAuthenticationPolicy(), user_model=User, multi_factor_auth_method_model=MultiFactorAuthenticationMethod, ) @@ -193,7 +175,7 @@ def pyramid_event_app(pyramid_config): project_prefix=pyramid_config.registry.settings["project_prefix"], login_callback=login_callback, jwk_resolver=jwk_resolver, - security_policy=TokenAuthenticationPolicy, + security_policy=TokenAuthenticationPolicy(), user_model=User, multi_factor_auth_method_model=MultiFactorAuthenticationMethod, ) diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py index 2f64a18..8d873f8 100644 --- a/tests/services/security/conftest.py +++ b/tests/services/security/conftest.py @@ -1,39 +1,14 @@ -from tet.security.authentication import JWTCookieAuthenticationPolicy, TokenAuthenticationPolicy - TARGET_MODULE = "test_authentication.py" -PYRAMID_TEST_APP = "pyramid_test_app" -PYRAMID_TEST_APP_WITH_JWT_COOKIE_POLICY = "pyramid_test_app_with_jwt_cookie_policy" -SECURITY_POLICY = "security_policy" def pytest_collection_modifyitems(config, items): """ Pre-filter: split items into those in test_authentication.py and others. - Filter: remove items that require a security policy that is not TokenAuthenticationPolicy. 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)] - deselected_items = [] - kept_auth_items = [] - for item in auth_items: - if hasattr(item, "callspec") and SECURITY_POLICY in item.callspec.params: - param = item.callspec.params[SECURITY_POLICY] - policy = param.get(SECURITY_POLICY) if isinstance(param, dict) else param - if PYRAMID_TEST_APP in item.fixturenames and policy is not TokenAuthenticationPolicy: - deselected_items.append(item) - continue - if ( - PYRAMID_TEST_APP_WITH_JWT_COOKIE_POLICY in item.fixturenames - and policy is not JWTCookieAuthenticationPolicy - ): - deselected_items.append(item) - continue - kept_auth_items.append(item) - - kept_items = other_items + kept_auth_items - if deselected_items: - config.hook.pytest_deselected(items=deselected_items) + kept_items = other_items + auth_items items[:] = kept_items diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index cde38cc..fdd19f4 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -10,7 +10,9 @@ from webtest import TestApp from tests.services.constants import LOGIN_ENDPOINT -from tet.security.authentication import TetTokenService, AuthViews, AuthLoginResult +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__) @@ -107,7 +109,7 @@ def structlog_security_config(): @pytest.fixture() -def pyramid_test_app_with_jwt_cookie_policy(request, pyramid_app): +def pyramid_test_app(pyramid_app): return TestApp(pyramid_app) @@ -143,13 +145,13 @@ def create_short_term_jwt_wrapper(*args, **kwargs): def test_login_view_emits_success_event( - pyramid_test_app_with_jwt_cookie_policy, + pyramid_test_app, capture_token, pyramid_request, caplog, structlog_security_config, ): - app = pyramid_test_app_with_jwt_cookie_policy + 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." @@ -170,9 +172,9 @@ def test_login_view_emits_success_event( def test_login_view_emits_fail_event( - pyramid_test_app_with_jwt_cookie_policy, pyramid_request, caplog, structlog_security_config + pyramid_test_app, pyramid_request, caplog, structlog_security_config ): - app = pyramid_test_app_with_jwt_cookie_policy + app = pyramid_test_app data = json.dumps({"user_identity": DEFAULT_USER_IDENTITY, "password": "wrong_password"}) with caplog.at_level("WARNING", logger="audit"): @@ -193,13 +195,19 @@ def test_login_view_emits_fail_event( ) +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, route_prefix="/auth") + 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) @@ -216,11 +224,12 @@ def test_login_notify_success(pyramid_event_request): 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, route_prefix="/auth") + 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) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 30c395c..74de116 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -1,18 +1,20 @@ import json +from datetime import datetime, timedelta, timezone +from unittest.mock import patch, MagicMock +import jwt as pyjwt import pytest -from jwt import InvalidSignatureError from sqlalchemy.orm import Session from webtest import TestApp from tests.models.accounts import 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.authentication import TetTokenService +from tet.security.tokens import TetTokenService @pytest.fixture() -def pyramid_test_app(request, pyramid_app): +def pyramid_test_app(pyramid_app): return TestApp(pyramid_app) @@ -95,6 +97,20 @@ def test_login_view_should_return_long_term_token(pyramid_test_app, capture_toke 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( @@ -157,7 +173,7 @@ def test_it_should_store_the_token_in_the_database( assert isinstance(refresh_token, str) assert len(refresh_token) > 0 - token = tet_token_service.retrieve_and_validate_token(refresh_token, project_prefix) + token = tet_token_service.retrieve_and_validate_token(token=refresh_token, prefix=project_prefix) assert token is not None @@ -175,79 +191,92 @@ def test_it_should_fail_to_access_the_protected_route_with_invalid_access_token( ACCESS_TOKEN_HEADER_NAME: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJleHAiOjE3MzgwNjk5ODd9" ".oeTClyh2CDWH1eHJPuxlm8TwR4zzBK4QZkop17fROa" } - pytest.raises( - InvalidSignatureError, - pyramid_test_app.get, + response = pyramid_test_app.get( HOME_ROUTE, headers=headers, + status=403, expect_errors=True, ) + assert response.status_code == 403 -@pytest.fixture() -def pyramid_test_app_with_jwt_cookie_policy(request, pyramid_app): - return TestApp(pyramid_app) +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_login_view_should_return_refresh_token( - pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request -): - refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name - app = pyramid_test_app_with_jwt_cookie_policy - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = app.post( - LOGIN_ENDPOINT, - params=data, +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", 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=200, + status=401, + expect_errors=True, ) - refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) - assert response.status_code == 200 - assert refresh_token == capture_token["refresh_token"] + assert response.status_code == 401 -def test_login_view_should_return_access_token( - pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request -): - refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name - app = pyramid_test_app_with_jwt_cookie_policy +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"}) - response = app.post( + login_response = pyramid_test_app.post( LOGIN_ENDPOINT, params=data, content_type="application/json", status=200, ) - data = response.json - refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) - assert response.status_code == 200 - assert refresh_token == capture_token["refresh_token"] - assert capture_token["access_token"] == data["access_token"] + refresh_token = login_response.json["refresh_token"] + # Clear cookies so it must come from request body + pyramid_test_app.cookiejar.clear() -def test_access_token_should_work_to_access_protected_route_with_new_policy( - pyramid_test_app_with_jwt_cookie_policy, capture_token, pyramid_request -): - refresh_token_cookie_name = pyramid_request.registry.tet_auth_long_term_token_cookie_name - app = pyramid_test_app_with_jwt_cookie_policy - data = json.dumps({"user_identity": "exampple2@invalid.invalid", "password": "1234@abcd"}) - response = app.post( - LOGIN_ENDPOINT, - params=data, + response = pyramid_test_app.post( + "/api/v1/auth/token/refresh", + params=json.dumps({"refresh_token": refresh_token}), content_type="application/json", status=200, ) - response_data = response.json - refresh_token = get_cookie(app.cookiejar, refresh_token_cookie_name) - access_token = response_data.get("access_token") assert response.status_code == 200 - assert refresh_token == capture_token["refresh_token"] - assert capture_token["access_token"] == access_token + assert "access_token" in response.json + assert response.json["success"] is True - headers = {ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"} - response = app.get(HOME_ROUTE, headers=headers, status=200) - assert response.status_code == 200 +def test_breach_api_timeout_graceful_degradation(pyramid_request): + """Breach API timeout should return False, not crash.""" + import requests as req_lib + from tet.security.auth import TetAuthService + auth_service = TetAuthService(request=pyramid_request) + pyramid_request.registry.settings["pwned_passwords_api_url"] = "https://api.pwnedpasswords.com/range/" -# TODO: Test it should be able to decode the access token using JWT + 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 From b4ea409d1e4d2c44473236c9aa4010682c1c1bfd Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:57:02 +0000 Subject: [PATCH 112/139] fix(ci): pin setuptools<82 for pkg_resources compatibility Pyramid still depends on pkg_resources which was removed in setuptools 82. Pin to <82 until Pyramid releases a fix. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0893357..7f5c64d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -e '.[dev]' + 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 From 1a919049906a9f8f4ef1c57a9220a75f86fadb4b Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 08:59:41 +0000 Subject: [PATCH 113/139] fix(tests): ensure test user exists before auth event tests The test_login_view_emits_success_event test relied on test ordering (user created by test_authentication.py running first). Add autouse fixture to create the test user when it doesn't exist. Co-Authored-By: Claude Opus 4.6 --- tests/services/security/test_auth_events.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index fdd19f4..521697d 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -9,6 +9,7 @@ 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 @@ -144,6 +145,18 @@ def create_short_term_jwt_wrapper(*args, **kwargs): 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, From a5862c0eb1de83bd80cf0b65b4ed6a6d225ae68a Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 09:41:51 +0000 Subject: [PATCH 114/139] test: add coverage tests, fix cookie path bug - Fix cookie path: prepend / to route_prefix in _cookie_path - Add 20 new tests covering token validation, auth service, breach API, password change, refresh from cookie, MFA required flag, logout, and change_password endpoints - Use separate user for password change endpoint tests to avoid DB lock conflicts - Security coverage: 63% -> 71% (auth.py 96%, tokens.py 94%, config.py 95%, policy.py 97%) - Total: 42 tests passing Co-Authored-By: Claude Opus 4.6 --- src/tet/security/auth.py | 2 +- .../services/security/test_authentication.py | 341 +++++++++++++++++- 2 files changed, 335 insertions(+), 8 deletions(-) diff --git a/src/tet/security/auth.py b/src/tet/security/auth.py index c8f3ac2..9a0955b 100644 --- a/src/tet/security/auth.py +++ b/src/tet/security/auth.py @@ -37,7 +37,7 @@ def __init__(self, request: Request): @property def _cookie_path(self) -> str: - return f"{self.route_prefix}/" + return f"/{self.route_prefix}/" if self.route_prefix else "/" def set_cookies( self, diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 74de116..be2545a 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -4,12 +4,16 @@ import jwt as pyjwt import pytest +import requests as req_lib +from pyramid.httpexceptions import HTTPUnauthorized from sqlalchemy.orm import Session from webtest import TestApp from tests.models.accounts import 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 PasswordChangeData from tet.security.tokens import TetTokenService @@ -35,12 +39,15 @@ def authentication_tokens(pyramid_test_app, capture_token, pyramid_request): 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", is_admin=True) user.password = "1234@abcd" - default_user = db_session.query(User).filter(User.email == user.email).one_or_none() - if default_user: - return default_user - db_session.add(user) db_session.flush() return user @@ -271,12 +278,332 @@ def test_refresh_token_from_request_body(pyramid_test_app, capture_token, pyrami def test_breach_api_timeout_graceful_degradation(pyramid_request): """Breach API timeout should return False, not crash.""" - import requests as req_lib - from tet.security.auth import TetAuthService - 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 + + +PASSWORD_CHANGE_EMAIL = "pwchange@invalid.invalid" +PASSWORD_CHANGE_PASSWORD = "old_password_1234" + + +def create_password_change_user(db_session: Session): + """Separate user for password change tests to avoid DB lock issues.""" + user = db_session.query(User).filter(User.email == PASSWORD_CHANGE_EMAIL).one_or_none() + if user: + user.password = PASSWORD_CHANGE_PASSWORD + db_session.flush() + return user + + user = User(email=PASSWORD_CHANGE_EMAIL, name="pwchange_user", is_admin=True) + user.password = PASSWORD_CHANGE_PASSWORD + db_session.add(user) + db_session.flush() + return user + + +def test_change_password_endpoint(pyramid_test_app, capture_token, pyramid_request, db_session): + """Change password via HTTP endpoint.""" + create_password_change_user(db_session) + data = json.dumps({"user_identity": PASSWORD_CHANGE_EMAIL, "password": PASSWORD_CHANGE_PASSWORD}) + 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": PASSWORD_CHANGE_PASSWORD, + "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.""" + create_password_change_user(db_session) + data = json.dumps({"user_identity": PASSWORD_CHANGE_EMAIL, "password": PASSWORD_CHANGE_PASSWORD}) + 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 From 3f58fd83c491267269808a56db9edc2154e2f5ce Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 09:44:39 +0000 Subject: [PATCH 115/139] fix(tests): use main test user for password endpoint tests The separate password-change user wasn't visible to HTTP requests in CI because db_session.flush() doesn't commit across transactions. Use the main test user with mocked change_password instead. Co-Authored-By: Claude Opus 4.6 --- .../services/security/test_authentication.py | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index be2545a..59063fb 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -537,29 +537,9 @@ def test_logout_endpoint(pyramid_test_app, capture_token, pyramid_request, db_se assert response.json["success"] is True -PASSWORD_CHANGE_EMAIL = "pwchange@invalid.invalid" -PASSWORD_CHANGE_PASSWORD = "old_password_1234" - - -def create_password_change_user(db_session: Session): - """Separate user for password change tests to avoid DB lock issues.""" - user = db_session.query(User).filter(User.email == PASSWORD_CHANGE_EMAIL).one_or_none() - if user: - user.password = PASSWORD_CHANGE_PASSWORD - db_session.flush() - return user - - user = User(email=PASSWORD_CHANGE_EMAIL, name="pwchange_user", is_admin=True) - user.password = PASSWORD_CHANGE_PASSWORD - db_session.add(user) - db_session.flush() - return user - - def test_change_password_endpoint(pyramid_test_app, capture_token, pyramid_request, db_session): """Change password via HTTP endpoint.""" - create_password_change_user(db_session) - data = json.dumps({"user_identity": PASSWORD_CHANGE_EMAIL, "password": PASSWORD_CHANGE_PASSWORD}) + 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, ) @@ -573,7 +553,7 @@ def test_change_password_endpoint(pyramid_test_app, capture_token, pyramid_reque response = pyramid_test_app.post( "/api/v1/auth/users/me/password", params=json.dumps({ - "currentPassword": PASSWORD_CHANGE_PASSWORD, + "currentPassword": "1234@abcd", "newPassword": "new_secure_password_123", }), headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, @@ -585,8 +565,7 @@ def test_change_password_endpoint(pyramid_test_app, capture_token, pyramid_reque 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.""" - create_password_change_user(db_session) - data = json.dumps({"user_identity": PASSWORD_CHANGE_EMAIL, "password": PASSWORD_CHANGE_PASSWORD}) + 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, ) From df3668e2bac2007aa7949dabdcfd797bdc8ffb9e Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 10:07:51 +0000 Subject: [PATCH 116/139] Add comprehensive MFA/TOTP test coverage 24 new tests covering the full TOTP lifecycle: - Service-level: verify_totp, create/get/disable methods, is_totp_mfa_enabled, handle_totp_setup, handle_totp_verify (success + invalid + no method + missing key), handle_totp_challenge (success + invalid + no method + no secret), generate_qr_img - View integration: full setup+verify flow, get_mfa_methods, disable_mfa, login with TOTP challenge, login without TOTP returns mfa_required Adds session-scoped MFA cleanup fixture to prevent test pollution across runs. Security module coverage: 84% (mfa.py 35% -> 93%). Co-Authored-By: Claude Opus 4.6 --- tests/services/security/conftest.py | 14 + .../services/security/test_authentication.py | 576 +++++++++++++++++- 2 files changed, 586 insertions(+), 4 deletions(-) diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py index 8d873f8..6cffbe4 100644 --- a/tests/services/security/conftest.py +++ b/tests/services/security/conftest.py @@ -1,4 +1,8 @@ +import pytest +from sqlalchemy import create_engine, text + TARGET_MODULE = "test_authentication.py" +DB_URL = "postgresql+psycopg2://test_tet:test_tet@localhost:5432/test_tet" def pytest_collection_modifyitems(config, items): @@ -12,3 +16,13 @@ def pytest_collection_modifyitems(config, items): 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) + with engine.connect() as conn: + conn.execute(text("DELETE FROM multi_factor_authentication_method")) + conn.commit() + engine.dispose() diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 59063fb..73140b7 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -1,19 +1,22 @@ +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 HTTPUnauthorized +from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized from sqlalchemy.orm import Session from webtest import TestApp -from tests.models.accounts import User +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 PasswordChangeData +from tet.security.config import MultiFactorAuthMethodType, PasswordChangeData, TOTPData +from tet.security.mfa import TetMultiFactorAuthenticationService from tet.security.tokens import TetTokenService @@ -46,7 +49,7 @@ def create_user(db_session: Session): db_session.flush() return user - user = User(email="exampple2@invalid.invalid", name="example2", is_admin=True) + 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() @@ -586,3 +589,568 @@ def test_change_password_endpoint_wrong_current(pyramid_test_app, capture_token, 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, setup_key=secret, + ) + 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", setup_key=secret, + ) + + +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", setup_key="some_secret", + ) + + +def test_handle_totp_verify_missing_setup_key(mfa_service, db_session): + """handle_totp_verify should raise HTTPBadRequest when setup_key is None.""" + 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 pytest.raises(HTTPBadRequest): + mfa_service.handle_totp_verify( + user_id=user.id, token="123456", setup_key=None, + ) + + +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, "setup_key": secret}), + 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, "setup_key": secret}), + 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, "setup_key": secret}), + 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, "setup_key": secret}), + 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, "setup_key": secret}), + 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 From 22e44c53fbfc4dfc7130431798cd56f825915ca9 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 10:10:27 +0000 Subject: [PATCH 117/139] Handle missing table in session-scoped MFA cleanup In CI the database is fresh; tables are created per-test by db_engine. The session-scoped cleanup runs before any test, so the table may not exist yet. Catch the exception gracefully. Co-Authored-By: Claude Opus 4.6 --- tests/services/security/conftest.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py index 6cffbe4..6f22119 100644 --- a/tests/services/security/conftest.py +++ b/tests/services/security/conftest.py @@ -22,7 +22,11 @@ def pytest_collection_modifyitems(config, items): def _cleanup_mfa_from_previous_runs(): """Remove MFA methods left over from a previous test run.""" engine = create_engine(DB_URL) - with engine.connect() as conn: - conn.execute(text("DELETE FROM multi_factor_authentication_method")) - conn.commit() - engine.dispose() + 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() From b61c7ed9360fcc9eec89df5de03912f4c1630e5e Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 10:23:39 +0000 Subject: [PATCH 118/139] Add error path and view coverage tests 16 new tests covering exception handlers and remaining view endpoints: - mfa.py: KeyError/Exception paths in handle_totp_verify/setup (now 100%) - views.py: login KeyError/Exception, logout unauthenticated/db-error/ generic-error, change_password HTTPException/generic, refresh_token with no body, revoke_other_tokens success/wrong-password/exception, disable_mfa/get_mfa_methods/generate_mfa_totp exceptions (now 94%) Security module total coverage: 93% (83 tests). Co-Authored-By: Claude Opus 4.6 --- .../services/security/test_authentication.py | 339 +++++++++++++++++- 1 file changed, 338 insertions(+), 1 deletion(-) diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 73140b7..2d5272b 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -7,7 +7,8 @@ import pyotp import pytest import requests as req_lib -from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized +from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPInternalServerError, HTTPUnauthorized +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from webtest import TestApp @@ -1154,3 +1155,339 @@ def test_login_without_totp_returns_mfa_required(pyramid_test_app, capture_token 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", setup_key="some_secret", + ) + + +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", setup_key="some_secret", + ) + + +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 From d2f428f64ffa5702cdc9bbd549dbb1fb80a9a7ab Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:05:50 +0000 Subject: [PATCH 119/139] Refactor views auth checks, fix audit logging, boost coverage to 99% Extract repeated user_id-is-None checks into _require_authenticated_userid() helper. Change raise to return for HTTPException in except handlers so pyramid_tm commits the transaction and audit events persist in PostgreSQL. Add tests for authorization.py, csrf.py, tokens (expired/delete_other), auth (cookie_attributes), config (AuthLoginResult.__bool__), policy (forget), and remaining views error paths. 100 tests, 99% coverage. Co-Authored-By: Claude Opus 4.6 --- src/tet/security/views.py | 55 +++-- tests/services/security/test_auth_events.py | 4 +- .../services/security/test_authentication.py | 220 ++++++++++++++++++ tests/services/security/test_authorization.py | 101 ++++++++ tests/services/security/test_csrf.py | 12 + 5 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 tests/services/security/test_authorization.py create mode 100644 tests/services/security/test_csrf.py diff --git a/src/tet/security/views.py b/src/tet/security/views.py index 8c8750e..0c3665f 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -52,6 +52,12 @@ def __init__(self, request: Request): 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]: auth_result: AuthLoginResult = self.login_callback(self.request) user_id = auth_result.user_id @@ -93,18 +99,18 @@ def login(self) -> dict[str, tp.Any]: security_events.AuthnLoginFail(request=self.request, user_identity=user_identity) ) logger.exception(f"Missing required field during login: {str(e)}") - raise HTTPBadRequest(json_body={"message": "Missing required field."}) from e - except HTTPException: + 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) ) - raise + 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) ) - raise HTTPInternalServerError(json_body={"message": "Login failed"}) from e + return HTTPInternalServerError(json_body={"message": "Login failed"}) def mfa_verify(self) -> dict: """ @@ -116,9 +122,7 @@ def mfa_verify(self) -> dict: Returns: dict: Result of the TOTP verification for the user. """ - user_id = self.request.authenticated_userid - if not user_id: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + user_id = self._require_authenticated_userid() payload = self.request.json_body token = payload["token"] @@ -143,17 +147,14 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: return {"success": True, "access_token": access_token} def change_password(self): - user_id = self.request.authenticated_userid + user_id = None data = self.request.json_body payload = PasswordChangeData( current_password=data["currentPassword"], new_password=data["newPassword"], ) try: - if user_id is None: - raise HTTPUnauthorized( - json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE, "success": False} - ) + user_id = self._require_authenticated_userid() 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) @@ -179,7 +180,7 @@ def change_password(self): request=self.request, authenticated_userid=user_id ) ) - raise e + return e except Exception as e: logger.exception(f"Error changing password: {e}") self.registry.notify( @@ -192,8 +193,9 @@ def change_password(self): ) def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: - user_id = self.request.authenticated_userid + 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}) @@ -216,7 +218,7 @@ def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: request=self.request, user_id=user_id ) ) - raise e + return e except SQLAlchemyError as e: logger.exception(f"Database error during logout: {e}") self.registry.notify( @@ -235,12 +237,11 @@ def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: return HTTPForbidden(json_body={"message": "Failed to logout", "success": False}) def disable_mfa_method(self): - user_id = self.request.authenticated_userid + user_id = None payload = self.request.json_body mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) + user_id = self._require_authenticated_userid() if not mfa_method_type: raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) self.multi_factor_auth_service.disable_method( @@ -262,7 +263,7 @@ def disable_mfa_method(self): method=mfa_method_type.value if mfa_method_type else None, ) ) - raise e + return e except Exception as e: logger.exception(f"Error disabling MFA method: {e}") self.registry.notify( @@ -275,10 +276,11 @@ def disable_mfa_method(self): return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) def revoke_other_tokens(self): - user_id = self.request.authenticated_userid - user = self.auth_service.get_current_user(user_id=user_id) + user_id = None payload = self.request.json_body try: + user_id = self._require_authenticated_userid() + user = self.auth_service.get_current_user(user_id=user_id) if user is None: raise HTTPUnauthorized(json_body={"message": "Unauthorized", "success": False}) @@ -302,7 +304,7 @@ def revoke_other_tokens(self): authenticated_userid=user_id, ) ) - raise e + return e except Exception as e: logger.exception(f"Error revoking other tokens: {e}") self.registry.notify( @@ -316,10 +318,8 @@ def revoke_other_tokens(self): ) def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: - user_id = self.request.authenticated_userid + user_id = self._require_authenticated_userid() try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) mfa_methods: tp.List[tp.Any] = ( self.multi_factor_auth_service.get_active_methods_by_user_id(user_id=user_id) ) @@ -333,13 +333,10 @@ def get_mfa_methods(self) -> dict[str, tp.List[tp.Any]]: ) from e def generate_mfa_totp(self): - user_id = self.request.authenticated_userid + user_id = self._require_authenticated_userid() user = self.auth_service.get_current_user(user_id=user_id) payload = self.request.json_body try: - if user_id is None: - raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - if payload["method_type"] == MultiFactorAuthMethodType.TOTP.value: return self.multi_factor_auth_service.handle_totp_setup( user=user, project_prefix=self.project_prefix diff --git a/tests/services/security/test_auth_events.py b/tests/services/security/test_auth_events.py index 521697d..ed8b79f 100644 --- a/tests/services/security/test_auth_events.py +++ b/tests/services/security/test_auth_events.py @@ -249,8 +249,8 @@ def test_login_notify_fail(pyramid_event_request): view.auth_service.set_cookies = MagicMock() with patch.object(request.registry, "notify") as mock_notify: - with pytest.raises(HTTPUnauthorized): - view.login() + result = view.login() + assert isinstance(result, HTTPUnauthorized) expected_event = AuthnLoginFail( user_identity=DEFAULT_USER_IDENTITY, request=request, diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 2d5272b..d1fb4ec 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -1491,3 +1491,223 @@ def test_generate_mfa_totp_exception(pyramid_test_app, capture_token, pyramid_re 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 None (200).""" + 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=200, + ) + assert response.json is None + + +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") + + # max_age should be set to the default (expiration_mins * 60) + expected_max_age = auth_service.long_term_token_expiration_mins * 60 + assert attrs.max_age == expected_max_age + assert attrs.value == "test-token" + + +# --- 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 == [] diff --git a/tests/services/security/test_authorization.py b/tests/services/security/test_authorization.py new file mode 100644 index 0000000..0d3c1c0 --- /dev/null +++ b/tests/services/security/test_authorization.py @@ -0,0 +1,101 @@ +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_isinstance_check_is_broken(): + """The isinstance check with zope Interface never triggers. + + authorization.py line 48 uses ``isinstance(policy, INewAuthorizationPolicy)`` + but zope Interfaces don't support Python's isinstance(); it should use + ``INewAuthorizationPolicy.providedBy(policy)`` instead. As a result, the + AuthorizationPolicyWrapper wrapping branch is dead code. + """ + + @implementer(INewAuthorizationPolicy) + class NewPolicy: + def permits(self, request, context, principals, permission): + return True + + def principals_allowed_by_permission(self, request, context, permission): + return set() + + policy = NewPolicy() + # providedBy works, isinstance does not + assert INewAuthorizationPolicy.providedBy(policy) is True + assert isinstance(policy, INewAuthorizationPolicy) is False 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) From d8611f7e8ad4394a20f8af1cb77c46325dc53aa9 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:11:17 +0000 Subject: [PATCH 120/139] Remove dead mfa_method_type check in disable_mfa_method MultiFactorAuthMethodType() enum constructor raises ValueError for invalid values, and all valid enum values are truthy, so the `if not mfa_method_type` branch was unreachable. Co-Authored-By: Claude Opus 4.6 --- src/tet/security/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/tet/security/views.py b/src/tet/security/views.py index 0c3665f..1c6b583 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -242,8 +242,6 @@ def disable_mfa_method(self): mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) try: user_id = self._require_authenticated_userid() - if not mfa_method_type: - raise HTTPForbidden(json_body={"message": "Invalid MFA method type"}) self.multi_factor_auth_service.disable_method( user_id=user_id, method_type=mfa_method_type ) From 291854b0c2e2af63bf7ec8b8c9b310d45df7c5d9 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:13:05 +0000 Subject: [PATCH 121/139] Fix authorization policy wrapping to use providedBy instead of isinstance zope Interface does not support Python's isinstance(); use INewAuthorizationPolicy.providedBy() which correctly checks whether an object implements the interface. Co-Authored-By: Claude Opus 4.6 --- src/tet/security/authorization.py | 2 +- tests/services/security/test_authorization.py | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/tet/security/authorization.py b/src/tet/security/authorization.py index d18fbac..8110e56 100644 --- a/src/tet/security/authorization.py +++ b/src/tet/security/authorization.py @@ -101,7 +101,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/tests/services/security/test_authorization.py b/tests/services/security/test_authorization.py index 0d3c1c0..1cf5f37 100644 --- a/tests/services/security/test_authorization.py +++ b/tests/services/security/test_authorization.py @@ -78,14 +78,8 @@ def test_set_authorization_policy_directive_with_standard_policy(): mock_set.assert_called_once_with(config, mock_policy) -def test_set_authorization_policy_isinstance_check_is_broken(): - """The isinstance check with zope Interface never triggers. - - authorization.py line 48 uses ``isinstance(policy, INewAuthorizationPolicy)`` - but zope Interfaces don't support Python's isinstance(); it should use - ``INewAuthorizationPolicy.providedBy(policy)`` instead. As a result, the - AuthorizationPolicyWrapper wrapping branch is dead code. - """ +def test_set_authorization_policy_directive_wraps_new_policy(): + """The directive should wrap INewAuthorizationPolicy implementations.""" @implementer(INewAuthorizationPolicy) class NewPolicy: @@ -95,7 +89,19 @@ def permits(self, request, context, principals, permission): def principals_allowed_by_permission(self, request, context, permission): return set() - policy = NewPolicy() - # providedBy works, isinstance does not - assert INewAuthorizationPolicy.providedBy(policy) is True - assert isinstance(policy, INewAuthorizationPolicy) is False + 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 From 4a14ab8d9ba5d283298f2001016de45ba241ef9e Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:26:02 +0000 Subject: [PATCH 122/139] Clean up views: fix auth ordering, add error handling, fix test secret - Move json_body parsing after auth check in change_password - Add error handling to mfa_verify (HTTPException passthrough + generic) - Simplify mfa_method_type.value ternaries (enum is always truthy) - Use RFC 7518-compliant HMAC key length in test secret Co-Authored-By: Claude Opus 4.6 --- src/tet/security/views.py | 31 +++++++++++++++++++------------ tests/conftest.py | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/tet/security/views.py b/src/tet/security/views.py index 1c6b583..ade04ad 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -123,13 +123,20 @@ def mfa_verify(self) -> dict: dict: Result of the TOTP verification for the user. """ user_id = self._require_authenticated_userid() - payload = self.request.json_body token = payload["token"] setup_key = payload["setup_key"] - return self.multi_factor_auth_service.handle_totp_verify( - user_id=user_id, token=token, setup_key=setup_key - ) + try: + return self.multi_factor_auth_service.handle_totp_verify( + user_id=user_id, token=token, setup_key=setup_key + ) + 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) @@ -148,13 +155,13 @@ def refresh_token(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPUnauthorized]: def change_password(self): user_id = None - data = self.request.json_body - payload = PasswordChangeData( - current_password=data["currentPassword"], - new_password=data["newPassword"], - ) 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) @@ -249,7 +256,7 @@ def disable_mfa_method(self): security_events.AuthnMfaMethodDisabled( request=self.request, authenticated_userid=user_id, - method=mfa_method_type.value if mfa_method_type else None, + method=mfa_method_type.value, ) ) return {"success": True} @@ -258,7 +265,7 @@ def disable_mfa_method(self): security_events.AuthnMfaMethodDisableFail( request=self.request, authenticated_userid=user_id, - method=mfa_method_type.value if mfa_method_type else None, + method=mfa_method_type.value, ) ) return e @@ -268,7 +275,7 @@ def disable_mfa_method(self): security_events.AuthnMfaMethodDisableFail( request=self.request, authenticated_userid=user_id, - method=mfa_method_type.value if mfa_method_type else None, + method=mfa_method_type.value, ) ) return HTTPForbidden(json_body={"message": "Failed to disable MFA method"}) diff --git a/tests/conftest.py b/tests/conftest.py index 68f3160..118e1ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -116,7 +116,7 @@ def pyramid_config(db_engine): "sqlalchemy.url": DB_URL, "project_prefix": "tet", "pyramid.includes": ["pyramid_tm"], - "tet.security.authentication.secret": "secret", + "tet.security.authentication.secret": "test-jwt-secret-key-at-least-32-bytes", } with tetConfigurator() as config: config.add_settings(settings) From cfeff0830105c0f201fe3c7a856a720ef2759904 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:27:35 +0000 Subject: [PATCH 123/139] Fix deprecated pyramid.security imports and test key length warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use pyramid.authorization for Allow, Authenticated, Everyone, Deny (NO_PERMISSION_REQUIRED stays in pyramid.security — not moved yet) - Use 32+ byte wrong key in invalid signature test to avoid InsecureKeyLengthWarning Co-Authored-By: Claude Opus 4.6 --- src/tet/security/policy.py | 2 +- tests/conftest.py | 2 +- tests/services/security/test_authentication.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tet/security/policy.py b/src/tet/security/policy.py index fd10778..1c0a405 100644 --- a/src/tet/security/policy.py +++ b/src/tet/security/policy.py @@ -4,7 +4,7 @@ from pyramid.authorization import ACLHelper from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request -from pyramid.security import Everyone, Authenticated +from pyramid.authorization import Everyone, Authenticated from zope.interface import implementer diff --git a/tests/conftest.py b/tests/conftest.py index 118e1ec..df16e08 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest from pyramid.request import Request from pyramid.response import Response -from pyramid.security import Allow, Authenticated, Everyone, Deny +from pyramid.authorization import Allow, Authenticated, Everyone, Deny from pyramid.testing import setUp, tearDown from sqlalchemy import create_engine from sqlalchemy.orm import Session diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index d1fb4ec..9082dc8 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -231,7 +231,7 @@ def test_verify_jwt_returns_none_for_invalid_signature(token_service): "exp": datetime.now(timezone.utc) + timedelta(hours=1), "iat": datetime.now(timezone.utc), } - bad_token = pyjwt.encode(wrong_payload, "wrong-secret", algorithm="HS256") + 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 From 4913fda24230307cbad24f22dcee32f295a143d4 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:28:31 +0000 Subject: [PATCH 124/139] Fix passlib deprecation: use hash() instead of encrypt() passlib.hash.sha256_crypt.encrypt() was deprecated in Passlib 1.7 in favor of .hash(). Co-Authored-By: Claude Opus 4.6 --- src/tet/util/crypt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From b7b3da4fe63d15721b889878b86922c45f92e95f Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:30:11 +0000 Subject: [PATCH 125/139] Move json_body parsing inside try blocks for disable_mfa and revoke_tokens Same pattern as change_password: parse request body after auth check and inside try block so malformed requests still trigger audit events. Co-Authored-By: Claude Opus 4.6 --- src/tet/security/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/tet/security/views.py b/src/tet/security/views.py index ade04ad..7a2cccd 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -245,10 +245,11 @@ def logout(self) -> tp.Union[tp.Dict[str, tp.Any], HTTPForbidden, Response]: def disable_mfa_method(self): user_id = None - payload = self.request.json_body - mfa_method_type = MultiFactorAuthMethodType(payload["method_type"]) + 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 ) @@ -265,7 +266,7 @@ def disable_mfa_method(self): security_events.AuthnMfaMethodDisableFail( request=self.request, authenticated_userid=user_id, - method=mfa_method_type.value, + method=mfa_method_type.value if mfa_method_type else None, ) ) return e @@ -275,16 +276,16 @@ def disable_mfa_method(self): security_events.AuthnMfaMethodDisableFail( request=self.request, authenticated_userid=user_id, - method=mfa_method_type.value, + 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 - payload = self.request.json_body 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}) From 23fb9633977ec970f190106ec1aa839a900a8276 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 15 Feb 2026 20:41:44 +0000 Subject: [PATCH 126/139] Fix SQLAlchemy deprecation warnings for declarative imports - tests/models/accounts.py: use sqlalchemy.orm.declarative_base - tet/sqlalchemy/password.py: use orm.declared_attr directly, remove redundant sqlalchemy.ext.declarative import Co-Authored-By: Claude Opus 4.6 --- src/tet/sqlalchemy/password.py | 3 +-- tests/models/accounts.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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/tests/models/accounts.py b/tests/models/accounts.py index 517317d..b1bd5a2 100644 --- a/tests/models/accounts.py +++ b/tests/models/accounts.py @@ -3,7 +3,7 @@ from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey, UniqueConstraint from sqlalchemy import orm -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from sqlalchemy.schema import MetaData NAMING_CONVENTION = { From d4eb247d6abd726d0d5b8bf3299db97d8b044ab5 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sat, 13 Jun 2026 08:04:44 +0000 Subject: [PATCH 127/139] Fix security issues in auth module - Move MFA check before token creation to prevent orphaned tokens - Use hmac.compare_digest for constant-time token comparison - Use dataclasses.replace to avoid mutating shared CookieAttributes - Read TOTP secret from DB instead of trusting client-provided setup_key - Fix verify_password to call user.validate_password (matching UserPasswordMixin) - Return HTTP 400 instead of None for unsupported MFA method types - Update tests to match new signatures and behaviors Co-Authored-By: Claude Opus 4.6 --- src/tet/security/auth.py | 11 ++-- src/tet/security/mfa.py | 13 ++--- src/tet/security/tokens.py | 3 +- src/tet/security/views.py | 14 ++--- .../services/security/test_authentication.py | 51 +++++++++++-------- 5 files changed, 53 insertions(+), 39 deletions(-) diff --git a/src/tet/security/auth.py b/src/tet/security/auth.py index 9a0955b..edc69d8 100644 --- a/src/tet/security/auth.py +++ b/src/tet/security/auth.py @@ -1,3 +1,4 @@ +import dataclasses import hashlib import logging import typing as tp @@ -47,9 +48,11 @@ def set_cookies( **kwargs, ): if cookie_attributes: - cookie_attributes.value = refresh_token - if not cookie_attributes.max_age: - cookie_attributes.max_age = self.long_term_token_expiration_mins * 60 + 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, @@ -84,7 +87,7 @@ def validate_and_create_jwt(self, *, refresh_token: str) -> str: return self.token_service.create_short_term_jwt(user_id) def verify_password(self, user: tp.Any, password: str) -> bool: - return user.verify_password(password) + return user.validate_password(password) def is_password_breached(self, password: str) -> bool: sha1_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() diff --git a/src/tet/security/mfa.py b/src/tet/security/mfa.py index d92b192..174645d 100644 --- a/src/tet/security/mfa.py +++ b/src/tet/security/mfa.py @@ -121,7 +121,7 @@ def is_totp_mfa_enabled(self, user_id: tp.Any = None) -> bool: > 0 ) - def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any, setup_key: tp.Any) -> dict: + def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any) -> dict: try: totp_mfa_method = self.get_method( user_id=user_id, @@ -134,6 +134,7 @@ def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any, setup_key: tp.An 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."}) @@ -143,14 +144,8 @@ def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any, setup_key: tp.An raise HTTPForbidden(json_body={"message": "Two-factor authentication failed."}) totp_mfa_method.mark_used() - - data = TOTPData( - secret=setup_key, - issuer=self.project_prefix, - ) totp_mfa_method.verified = True totp_mfa_method.is_active = True - totp_mfa_method.data = data.to_dict() return {"success": is_valid} except KeyError as e: logger.exception(f"details {str(e)}") @@ -237,7 +232,9 @@ def handle_totp_setup(self, *, user: tp.Any, project_prefix: str) -> dict: is_active=False, verified=False, ) - if not existing_method: + if existing_method: + existing_method.data = data.to_dict() + else: self.create_method( method_type=MultiFactorAuthMethodType.TOTP, user_id=user.id, diff --git a/src/tet/security/tokens.py b/src/tet/security/tokens.py index a643a64..0cd01d9 100644 --- a/src/tet/security/tokens.py +++ b/src/tet/security/tokens.py @@ -1,5 +1,6 @@ import dataclasses import hashlib +import hmac import logging import secrets import typing as tp @@ -98,7 +99,7 @@ def retrieve_and_validate_token(self, *, token: str, prefix: str) -> tp.Any: if not token_from_db: raise ValueError("Token not found") - if token_from_db.secret_hash != hashlib.sha256(secret).digest().hex(): + 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): diff --git a/src/tet/security/views.py b/src/tet/security/views.py index 7a2cccd..94ebc3a 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -68,8 +68,6 @@ def login(self) -> dict[str, tp.Any]: try: if user_id is None: raise HTTPUnauthorized(json_body={"message": DEFAULT_UNAUTHORIZED_MESSAGE}) - 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) if self.multi_factor_auth_service.is_totp_mfa_enabled(user_id): if not totp_token: @@ -77,9 +75,14 @@ def login(self) -> dict[str, tp.Any]: return response_payload return self.multi_factor_auth_service.handle_totp_challenge( - user_id=user_id, totp_token=totp_token + 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, @@ -125,10 +128,9 @@ def mfa_verify(self) -> dict: user_id = self._require_authenticated_userid() payload = self.request.json_body token = payload["token"] - setup_key = payload["setup_key"] try: return self.multi_factor_auth_service.handle_totp_verify( - user_id=user_id, token=token, setup_key=setup_key + user_id=user_id, token=token ) except HTTPException as e: raise e @@ -347,7 +349,7 @@ def generate_mfa_totp(self): return self.multi_factor_auth_service.handle_totp_setup( user=user, project_prefix=self.project_prefix ) - return None + raise HTTPBadRequest(json_body={"message": f"Unsupported MFA method type: {payload['method_type']}"}) except HTTPException as e: raise e except Exception as e: diff --git a/tests/services/security/test_authentication.py b/tests/services/security/test_authentication.py index 9082dc8..6dce308 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -787,7 +787,7 @@ def test_handle_totp_verify_success(mfa_service, db_session): valid_token = pyotp.TOTP(secret).now() result = mfa_service.handle_totp_verify( - user_id=user.id, token=valid_token, setup_key=secret, + user_id=user.id, token=valid_token, ) assert result["success"] is True @@ -813,7 +813,7 @@ def test_handle_totp_verify_invalid_token(mfa_service, db_session): with pytest.raises(HTTPForbidden): mfa_service.handle_totp_verify( - user_id=user.id, token="000000", setup_key=secret, + user_id=user.id, token="000000", ) @@ -824,22 +824,26 @@ def test_handle_totp_verify_no_method(mfa_service, db_session): with pytest.raises(HTTPForbidden): mfa_service.handle_totp_verify( - user_id=user.id, token="123456", setup_key="some_secret", + user_id=user.id, token="123456", ) -def test_handle_totp_verify_missing_setup_key(mfa_service, db_session): - """handle_totp_verify should raise HTTPBadRequest when setup_key is None.""" +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.handle_totp_setup(user=user, project_prefix="tet") + 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", setup_key=None, + user_id=user.id, token="123456", ) @@ -971,7 +975,7 @@ def test_mfa_setup_and_verify_flow(pyramid_test_app, capture_token, pyramid_requ valid_token = pyotp.TOTP(secret).now() verify_resp = pyramid_test_app.post( "/api/v1/auth/mfa/app/verify", - params=json.dumps({"token": valid_token, "setup_key": secret}), + params=json.dumps({"token": valid_token}), headers=auth_headers, content_type="application/json", status=200, @@ -1002,7 +1006,7 @@ def test_mfa_get_methods_returns_active(pyramid_test_app, capture_token, pyramid valid_token = pyotp.TOTP(secret).now() pyramid_test_app.post( "/api/v1/auth/mfa/app/verify", - params=json.dumps({"token": valid_token, "setup_key": secret}), + params=json.dumps({"token": valid_token}), headers=auth_headers, content_type="application/json", status=200, @@ -1041,7 +1045,7 @@ def test_mfa_disable_method(pyramid_test_app, capture_token, pyramid_request, cl valid_token = pyotp.TOTP(secret).now() pyramid_test_app.post( "/api/v1/auth/mfa/app/verify", - params=json.dumps({"token": valid_token, "setup_key": secret}), + params=json.dumps({"token": valid_token}), headers=auth_headers, content_type="application/json", status=200, @@ -1090,7 +1094,7 @@ def test_login_with_totp_challenge(pyramid_test_app, capture_token, pyramid_requ valid_token = pyotp.TOTP(secret).now() pyramid_test_app.post( "/api/v1/auth/mfa/app/verify", - params=json.dumps({"token": valid_token, "setup_key": secret}), + params=json.dumps({"token": valid_token}), headers=auth_headers, content_type="application/json", status=200, @@ -1136,7 +1140,7 @@ def test_login_without_totp_returns_mfa_required(pyramid_test_app, capture_token valid_token = pyotp.TOTP(secret).now() pyramid_test_app.post( "/api/v1/auth/mfa/app/verify", - params=json.dumps({"token": valid_token, "setup_key": secret}), + params=json.dumps({"token": valid_token}), headers=auth_headers, content_type="application/json", status=200, @@ -1174,7 +1178,7 @@ def test_handle_totp_verify_key_error(mfa_service, db_session): ): with pytest.raises(HTTPBadRequest): mfa_service.handle_totp_verify( - user_id=user.id, token="123456", setup_key="some_secret", + user_id=user.id, token="123456", ) @@ -1192,7 +1196,7 @@ def test_handle_totp_verify_generic_exception(mfa_service, db_session): ): with pytest.raises(HTTPInternalServerError): mfa_service.handle_totp_verify( - user_id=user.id, token="123456", setup_key="some_secret", + user_id=user.id, token="123456", ) @@ -1590,7 +1594,7 @@ def test_get_mfa_methods_http_exception(pyramid_test_app, capture_token, pyramid 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 None (200).""" + """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"}), @@ -1604,9 +1608,10 @@ def test_generate_mfa_totp_non_totp_method(pyramid_test_app, capture_token, pyra params=json.dumps({"method_type": "hotp"}), headers={ACCESS_TOKEN_HEADER_NAME: f"Bearer {access_token}"}, content_type="application/json", - status=200, + status=400, + expect_errors=True, ) - assert response.json is None + assert response.status_code == 400 def test_generate_mfa_totp_http_exception(pyramid_test_app, capture_token, pyramid_request, clean_mfa): @@ -1681,10 +1686,16 @@ def test_set_cookies_with_cookie_attributes_no_max_age(auth_service, pyramid_req auth_service.set_cookies(cookie_attributes=attrs, refresh_token="test-token") - # max_age should be set to the default (expiration_mins * 60) + # 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 - assert attrs.max_age == expected_max_age - assert attrs.value == "test-token" + 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__ --- From 91c3f59a8d5b580026296cb3088d04a397d00b93 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 14 Jun 2026 06:47:18 +0000 Subject: [PATCH 128/139] Add TOTP replay protection, rate limiting, token cleanup, and Pyramid 2.0 compat - TOTP replay protection via UNLOGGED table + FOR UPDATE serialization - Rate limiting on login with separate DB connection (survives tx rollback) - Token cleanup for expired long-term tokens - Pyramid 2.0 compat module as single source of truth for moved imports - Public API contract tests (tests/test_public_api.py) Co-Authored-By: Claude Opus 4.6 --- TODO.md | 50 +++- src/tet/security/__init__.py | 8 + src/tet/security/authentication.py | 30 ++- src/tet/security/authorization.py | 24 +- src/tet/security/compat.py | 53 ++++ src/tet/security/config.py | 2 + src/tet/security/mfa.py | 56 ++++- src/tet/security/models.py | 31 ++- src/tet/security/policy.py | 4 +- src/tet/security/rate_limit.py | 70 ++++++ src/tet/security/tokens.py | 9 + src/tet/security/views.py | 16 ++ tests/conftest.py | 18 +- tests/models/accounts.py | 23 +- .../services/security/test_authentication.py | 157 ++++++++++++ tests/test_public_api.py | 233 ++++++++++++++++++ 16 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 src/tet/security/compat.py create mode 100644 src/tet/security/rate_limit.py create mode 100644 tests/test_public_api.py diff --git a/TODO.md b/TODO.md index b128b87..7f6c71c 100644 --- a/TODO.md +++ b/TODO.md @@ -5,13 +5,55 @@ - Pyramid depends on `pkg_resources` which was removed in setuptools 82. Pin `setuptools<82` until Pyramid releases a fix. -## Remaining improvements +## Security -### Input validation +### 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 -### Documentation +## 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) -- Consider rate limiting guidance in docs (login, refresh, MFA endpoints) +- Document the security model: token lifecycle, MFA flow, cookie handling +- API endpoint documentation (beyond the existing `docs/authentication_apis.md`) diff --git a/src/tet/security/__init__.py b/src/tet/security/__init__.py index e59f73c..22947c6 100644 --- a/src/tet/security/__init__.py +++ b/src/tet/security/__init__.py @@ -1,3 +1,8 @@ +from tet.security.compat import ( # noqa: F401 + Allowed, + Denied, + NO_PERMISSION_REQUIRED, +) from tet.security.config import ( AuthLoginResult, CookieAttributes, @@ -19,13 +24,16 @@ from tet.security.views import AuthViews __all__ = [ + "Allowed", "AuthLoginResult", "AuthViews", "CookieAttributes", + "Denied", "ILoginCallback", "ISecretCallback", "JWTRegisteredClaims", "MultiFactorAuthMethodType", + "NO_PERMISSION_REQUIRED", "MultiFactorAuthenticationMethodMixin", "PasswordChangeData", "TetAuthService", diff --git a/src/tet/security/authentication.py b/src/tet/security/authentication.py index 9310db1..7eb5b99 100644 --- a/src/tet/security/authentication.py +++ b/src/tet/security/authentication.py @@ -1,9 +1,10 @@ import typing as tp from pyramid.config import Configurator -from pyramid.security import NO_PERMISSION_REQUIRED from zope.interface import Interface +from tet.security.compat import NO_PERMISSION_REQUIRED + from tet.security.config import ( AuthLoginResult, CookieAttributes, @@ -22,15 +23,20 @@ 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__ = [ @@ -45,10 +51,14 @@ "PasswordChangeData", "TetAuthService", "TetMultiFactorAuthenticationService", + "TetRateLimitService", "TetTokenService", "TOTPData", + "TOTPUsedCodeMixin", "TokenAuthenticationPolicy", "TokenMixin", + "RateLimitAttemptMixin", + "NO_PERMISSION_REQUIRED", ] DEFAULT_SECURITY_POLICY = TokenAuthenticationPolicy() @@ -73,6 +83,10 @@ def set_token_authentication( 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). @@ -95,6 +109,10 @@ def set_token_authentication( 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(): @@ -116,6 +134,11 @@ def register(): 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) @@ -229,5 +252,10 @@ def includeme(config: Configurator): 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 8110e56..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): 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 index 40340ea..41fc87f 100644 --- a/src/tet/security/config.py +++ b/src/tet/security/config.py @@ -26,6 +26,8 @@ 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 diff --git a/src/tet/security/mfa.py b/src/tet/security/mfa.py index 174645d..de05b57 100644 --- a/src/tet/security/mfa.py +++ b/src/tet/security/mfa.py @@ -1,7 +1,9 @@ import base64 import io import logging +import time import typing as tp +from datetime import datetime, timedelta import pyotp import qrcode @@ -21,6 +23,7 @@ CookieAttributes, TOTPData, MultiFactorAuthMethodType, + UTC, ) from tet.security.tokens import TetTokenService from tet.security.auth import TetAuthService @@ -38,6 +41,9 @@ def __init__(self, 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 = ( @@ -78,6 +84,7 @@ def get_method( method_type: MultiFactorAuthMethodType, is_active: bool = True, verified: bool = True, + for_update: bool = False, ): """ Retrieve a multifactor authentication method for a user. @@ -90,11 +97,13 @@ def get_method( 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) - return ( + query = ( self.session.query(self.tet_multi_factor_auth_method_model) .filter(*conditions) - .one_or_none() ) + 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): """ @@ -156,6 +165,43 @@ def handle_totp_verify(self, *, user_id: tp.Any, token: tp.Any) -> dict: 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, *, @@ -163,11 +209,13 @@ def handle_totp_challenge( 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( @@ -184,6 +232,10 @@ def handle_totp_challenge( 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) diff --git a/src/tet/security/models.py b/src/tet/security/models.py index 27399f0..525ef65 100644 --- a/src/tet/security/models.py +++ b/src/tet/security/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Column, DateTime, Integer, String, Enum, Boolean +from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Enum, Boolean from sqlalchemy.dialects.postgresql import JSONB from tet.security.config import MultiFactorAuthMethodType, UTC @@ -37,6 +37,35 @@ 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. diff --git a/src/tet/security/policy.py b/src/tet/security/policy.py index 1c0a405..004ccbb 100644 --- a/src/tet/security/policy.py +++ b/src/tet/security/policy.py @@ -1,12 +1,12 @@ import typing as tp from pyramid.authentication import CallbackAuthenticationPolicy -from pyramid.authorization import ACLHelper from pyramid.interfaces import ISecurityPolicy from pyramid.request import Request -from pyramid.authorization import Everyone, Authenticated from zope.interface import implementer +from tet.security.compat import ACLHelper, Everyone, Authenticated + @implementer(ISecurityPolicy) class TokenAuthenticationPolicy(CallbackAuthenticationPolicy): 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 index 0cd01d9..e9192ef 100644 --- a/src/tet/security/tokens.py +++ b/src/tet/security/tokens.py @@ -173,6 +173,15 @@ def delete_other_tokens(self, *, user: tp.Any = None) -> None: ] 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), diff --git a/src/tet/security/views.py b/src/tet/security/views.py index 94ebc3a..6ad7bca 100644 --- a/src/tet/security/views.py +++ b/src/tet/security/views.py @@ -7,6 +7,7 @@ HTTPBadRequest, HTTPException, HTTPInternalServerError, + HTTPTooManyRequests, ) from pyramid.request import Request from pyramid.response import Response @@ -25,6 +26,7 @@ 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__) @@ -35,6 +37,7 @@ class AuthViews: multi_factor_auth_service: TetMultiFactorAuthenticationService = autowired( TetMultiFactorAuthenticationService ) + rate_limit_service: TetRateLimitService = autowired(TetRateLimitService) db_session: Session = autowired(Session) def __init__(self, request: Request): @@ -59,6 +62,19 @@ def _require_authenticated_userid(self) -> tp.Any: 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 diff --git a/tests/conftest.py b/tests/conftest.py index df16e08..273b951 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,19 @@ import pytest from pyramid.request import Request from pyramid.response import Response -from pyramid.authorization import Allow, Authenticated, Everyone, Deny +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 +from tests.models.accounts import ( + Base, + Token, + User, + MultiFactorAuthenticationMethod, + TOTPUsedCode, + RateLimitAttempt, +) from tet.config import Configurator as tetConfigurator from tet.security.authentication import ( TokenAuthenticationPolicy, @@ -41,6 +48,9 @@ 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() @@ -147,6 +157,8 @@ def pyramid_app(pyramid_config): 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( @@ -178,6 +190,8 @@ def pyramid_event_app(pyramid_config): 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( diff --git a/tests/models/accounts.py b/tests/models/accounts.py index b1bd5a2..583d6f6 100644 --- a/tests/models/accounts.py +++ b/tests/models/accounts.py @@ -1,4 +1,9 @@ -from tet.security.authentication import TokenMixin, MultiFactorAuthenticationMethodMixin +from tet.security.authentication import ( + TokenMixin, + MultiFactorAuthenticationMethodMixin, + TOTPUsedCodeMixin, + RateLimitAttemptMixin, +) from tet.sqlalchemy.password import UserPasswordMixin from sqlalchemy import Column, Integer, Text, Boolean, ForeignKey, UniqueConstraint @@ -44,4 +49,18 @@ class MultiFactorAuthenticationMethod(MultiFactorAuthenticationMethodMixin, Base ) -__all__ = ["User", "Token", "Base", "metadata"] +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/security/test_authentication.py b/tests/services/security/test_authentication.py index 6dce308..0c77609 100644 --- a/tests/services/security/test_authentication.py +++ b/tests/services/security/test_authentication.py @@ -1722,3 +1722,160 @@ def test_policy_forget_returns_empty_list(pyramid_request): 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/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..912aa49 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,233 @@ +""" +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.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, + TetAuthService, + TetMultiFactorAuthenticationService, + TetTokenService, + TOTPData, + 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" From addf4bf641229a4cd141947ee6cb8c243a1020f2 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 14 Jun 2026 07:03:20 +0000 Subject: [PATCH 129/139] Add tet.services to public API contract tests Co-Authored-By: Claude Opus 4.6 --- tests/test_public_api.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 912aa49..5f2395a 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -14,6 +14,26 @@ 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 7f33fa080654cc5aa53cd71454d764d1d5a33508 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 14 Jun 2026 07:31:41 +0000 Subject: [PATCH 130/139] Add Sphinx autodoc for all new security modules - Add RST stubs for auth, authentication, compat, config, events, mfa, models, policy, rate_limit, tokens, views - Fix duplicate object warnings with :no-index: on re-export modules - Fix docstring indentation for RST parsing in tokens.py and policy.py - Add authentication_apis guide to docs toctree Co-Authored-By: Claude Opus 4.6 --- docs/api/modules.rst | 11 +++++++++++ docs/api/tet.security.auth.rst | 7 +++++++ docs/api/tet.security.authentication.rst | 7 +++++++ docs/api/tet.security.authorization.rst | 1 + docs/api/tet.security.compat.rst | 7 +++++++ docs/api/tet.security.config.rst | 7 +++++++ docs/api/tet.security.events.rst | 7 +++++++ docs/api/tet.security.mfa.rst | 7 +++++++ docs/api/tet.security.models.rst | 7 +++++++ docs/api/tet.security.policy.rst | 7 +++++++ docs/api/tet.security.rate_limit.rst | 7 +++++++ docs/api/tet.security.rst | 1 + docs/api/tet.security.tokens.rst | 7 +++++++ docs/api/tet.security.views.rst | 7 +++++++ docs/index.rst | 1 + src/tet/security/policy.py | 1 + src/tet/security/tokens.py | 1 + 17 files changed, 93 insertions(+) create mode 100644 docs/api/tet.security.auth.rst create mode 100644 docs/api/tet.security.authentication.rst create mode 100644 docs/api/tet.security.compat.rst create mode 100644 docs/api/tet.security.config.rst create mode 100644 docs/api/tet.security.events.rst create mode 100644 docs/api/tet.security.mfa.rst create mode 100644 docs/api/tet.security.models.rst create mode 100644 docs/api/tet.security.policy.rst create mode 100644 docs/api/tet.security.rate_limit.rst create mode 100644 docs/api/tet.security.tokens.rst create mode 100644 docs/api/tet.security.views.rst 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/index.rst b/docs/index.rst index 41b7ea2..897db04 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,6 +24,7 @@ Unearthly intelligent batteries-included application framework built on Pyramid. :caption: Contents readme + authentication_apis .. toctree:: :maxdepth: 2 diff --git a/src/tet/security/policy.py b/src/tet/security/policy.py index 004ccbb..f15ff69 100644 --- a/src/tet/security/policy.py +++ b/src/tet/security/policy.py @@ -57,6 +57,7 @@ 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 """ diff --git a/src/tet/security/tokens.py b/src/tet/security/tokens.py index e9192ef..32a07e4 100644 --- a/src/tet/security/tokens.py +++ b/src/tet/security/tokens.py @@ -36,6 +36,7 @@ def create_long_term_token( ) -> 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. From be68dbe8e76e14195057e27705075fa03e55fb32 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Mon, 15 Jun 2026 19:38:28 +0000 Subject: [PATCH 131/139] Add security guide and fix pyramid_di Sphinx warnings Add narrative getting-started documentation for tet.security covering setup, token flow, MFA, rate limiting, events, and configuration. Fix Sphinx autodoc warnings for pyramid_di reify_attr descriptors by adding an autodoc-process-signature hook that renders them as typed attributes instead of methods. Co-Authored-By: Claude Opus 4.6 --- docs/index.rst | 1 + docs/security_guide.md | 377 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 docs/security_guide.md diff --git a/docs/index.rst b/docs/index.rst index 897db04..6664643 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,6 +24,7 @@ Unearthly intelligent batteries-included application framework built on Pyramid. :caption: Contents readme + security_guide authentication_apis .. toctree:: diff --git a/docs/security_guide.md b/docs/security_guide.md new file mode 100644 index 0000000..cc16507 --- /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 bcrypt-hashed column) 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) | From 3f18f4328f4a46fae9bf4672bfb399c89f5e66bb Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Mon, 15 Jun 2026 19:49:46 +0000 Subject: [PATCH 132/139] Prepare 0.6a1 release - Bump version to 0.6a1 - Add CHANGES.md entry for the security module - Fix leeway field leaking into JWT payload (to_dict excluded it) - Remove phantom structlog dependency from [auth] extras - Fix qrcode[pil] -> qrcode (we use SVG, not PIL) - Add missing exports to tet.security.__init__ (TetRateLimitService, RateLimitAttemptMixin, TOTPUsedCodeMixin) - Remove Python 3.8/3.9 classifiers, add python_requires>=3.10 - Fix docs: method_type case (TOTP -> totp), change password field names (camelCase), MFA verify payload, list methods response key - Fix Union[TOTPData] -> TOTPData - Fix deprecated utcnow() in JWTRegisteredClaims docstring - Fix bcrypt -> passlib in security guide Co-Authored-By: Claude Opus 4.6 --- CHANGES.md | 19 +++++++++++++++++++ docs/authentication_apis.md | 16 +++++++--------- docs/security_guide.md | 4 ++-- src/tet/security/__init__.py | 6 ++++++ src/tet/security/config.py | 6 +++--- src/tet/security/mfa.py | 2 +- tests/test_public_api.py | 3 +++ 7 files changed, 41 insertions(+), 15 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 530dd98..9696161 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,25 @@ # Changes +2026-06-14 Antti Haapala + + * 0.6a1: Add ``tet.security`` module with JWT-based authentication, + refresh tokens, TOTP multi-factor authentication, login rate limiting, + password management, and Pyramid security policy integration. + * 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. + * Remove unused ``structlog`` dependency from ``[auth]`` extras. + + 2026-05-28 Antti Haapala * 0.5.0: ``tet.services`` now re-exports ``service``, diff --git a/docs/authentication_apis.md b/docs/authentication_apis.md index d39a4a7..4b543ae 100644 --- a/docs/authentication_apis.md +++ b/docs/authentication_apis.md @@ -196,16 +196,15 @@ expires=”datetime”; secure; HttpOnly; SameSite=Strict **Payload:** ```json { - "current_password": "", - "new_password": "" + "currentPassword": "", + "newPassword": "" } ``` ### Return: **200 Response:** ```json { - "success": true, - "message": "" + "success": true } ``` **Error Response (400, 401, 403, 500):** @@ -276,7 +275,7 @@ expires=”datetime”; secure; HttpOnly; SameSite=Strict **Payload:** ```json { - "method_type": "TOTP" + "method_type": "totp" } ``` @@ -316,8 +315,7 @@ expires=”datetime”; secure; HttpOnly; SameSite=Strict **Payload:** ```json { - "token": "", - "setup_key": "" + "token": "<6-digit TOTP code>" } ``` ### Return: @@ -355,7 +353,7 @@ expires=”datetime”; secure; HttpOnly; SameSite=Strict **200 Response:** ```json { - "mfa_methods": [ /* list of methods */ ] + "method_types": ["totp"] } ``` **Error Response (400, 401, 403, 500):** @@ -386,7 +384,7 @@ expires=”datetime”; secure; HttpOnly; SameSite=Strict **Payload:** ```json { - "method_type": "TOTP" + "method_type": "totp" } ``` ### Return: diff --git a/docs/security_guide.md b/docs/security_guide.md index cc16507..15fcd00 100644 --- a/docs/security_guide.md +++ b/docs/security_guide.md @@ -54,7 +54,7 @@ class RateLimitAttempt(RateLimitAttemptMixin, Base): ``` `TokenMixin` stores hashed refresh tokens. `UserPasswordMixin` (from -`tet.sqlalchemy.password`) gives you `password` (a bcrypt-hashed column) and +`tet.sqlalchemy.password`) gives you `password` (a hashed column via passlib) and `validate_password()`. `TOTPUsedCode` and `RateLimitAttempt` are optional. Mark them `UNLOGGED` for @@ -203,7 +203,7 @@ authentication (the default permission is `"view"`). TOTP support is built in. The flow from the client's perspective: -1. **Setup** -- `POST /mfa/app/setup` with `{"method_type": "TOTP"}`. Returns +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. diff --git a/src/tet/security/__init__.py b/src/tet/security/__init__.py index 22947c6..f2a9e1f 100644 --- a/src/tet/security/__init__.py +++ b/src/tet/security/__init__.py @@ -15,12 +15,15 @@ ) 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__ = [ @@ -36,10 +39,13 @@ "NO_PERMISSION_REQUIRED", "MultiFactorAuthenticationMethodMixin", "PasswordChangeData", + "RateLimitAttemptMixin", "TetAuthService", "TetMultiFactorAuthenticationService", + "TetRateLimitService", "TetTokenService", "TOTPData", + "TOTPUsedCodeMixin", "TokenAuthenticationPolicy", "TokenMixin", ] diff --git a/src/tet/security/config.py b/src/tet/security/config.py index 41fc87f..b37116c 100644 --- a/src/tet/security/config.py +++ b/src/tet/security/config.py @@ -70,8 +70,8 @@ class JWTRegisteredClaims: iss="my-auth-service", sub="user123", aud="my-api.example.com", - exp=datetime.utcnow() + timedelta(hours=1), - iat=datetime.utcnow(), + exp=datetime.now(UTC) + timedelta(hours=1), + iat=datetime.now(UTC), jti="unique-token-id-456" ) @@ -98,7 +98,7 @@ def to_dict(self) -> tp.Dict[str, tp.Any]: 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} + return {k: v for k, v in dataclasses.asdict(self).items() if v is not None and k != "leeway"} @dataclasses.dataclass diff --git a/src/tet/security/mfa.py b/src/tet/security/mfa.py index de05b57..f182538 100644 --- a/src/tet/security/mfa.py +++ b/src/tet/security/mfa.py @@ -262,7 +262,7 @@ def _create_totp_data(issuer: str) -> TOTPData: ) @staticmethod - def generate_qr_img(user: tp.Any, mfa_secret: str, data: tp.Union[TOTPData]) -> str: + 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 ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 5f2395a..fe00aec 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -50,10 +50,13 @@ def test_tet_security_exports(): MultiFactorAuthenticationMethodMixin, NO_PERMISSION_REQUIRED, PasswordChangeData, + RateLimitAttemptMixin, TetAuthService, TetMultiFactorAuthenticationService, + TetRateLimitService, TetTokenService, TOTPData, + TOTPUsedCodeMixin, TokenAuthenticationPolicy, TokenMixin, ) From 112e960705f876db67d2a96e2429d55ce1d83393 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Mon, 15 Jun 2026 21:28:39 +0000 Subject: [PATCH 133/139] Update pyproject.toml for 0.6a1, restore Sphinx hook, fix auth tests - Bump version to 0.6a1 in pyproject.toml (replaces setup.py) - Add [auth] extras (pyjwt, pyotp, qrcode, requests) - Set python_requires>=3.10, update tool targets - Restore autodoc-process-signature hook for pyramid_di descriptors - Fix authorization tests: providedBy works, so wrapping happens Co-Authored-By: Claude Opus 4.6 --- docs/conf.py | 15 +++++++++++ pyproject.toml | 18 ++++++++----- tests/test_security_authorization.py | 38 ++++++---------------------- 3 files changed, 34 insertions(+), 37 deletions(-) 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/pyproject.toml b/pyproject.toml index 4e8b5e3..d0a4015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tet" -version = "0.5.0" +version = "0.6a1" 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", @@ -47,6 +45,12 @@ dev = [ "ruff", "mypy", ] +auth = [ + "pyjwt", + "pyotp", + "qrcode>=8.0,<9.0", + "requests>=2.28", +] test = [ "pytest", "pytest-cov", @@ -79,7 +83,7 @@ markers = [ [tool.black] line-length = 88 -target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py310', 'py311', 'py312'] include = '\.pyi?$' extend-exclude = ''' /( @@ -97,7 +101,7 @@ extend-exclude = ''' [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = [ @@ -124,7 +128,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/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 From fc10fc461142475fce30ea48ecb09ef7a544937d Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Tue, 16 Jun 2026 00:58:47 +0300 Subject: [PATCH 134/139] Add security extra and missing test deps so CI installs them The tet.security modules require pyjwt and pyotp; expose them as a 'security' optional extra and have dev/test pull it in (CI installs .[dev]). Add the test-only deps the security suite needs: webtest, structlog, psycopg2-binary (tests run against PostgreSQL). Fixes 'No module named jwt' CI failures. --- pyproject.toml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d0a4015..9b82f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,22 +38,30 @@ dependencies = [ ] [project.optional-dependencies] +security = [ + "pyjwt", + "pyotp", + "qrcode>=8.0,<9.0", + "requests>=2.28", +] dev = [ + "tet[security]", "pytest", "pytest-cov", + "webtest", + "structlog", + "psycopg2-binary", "black", "ruff", "mypy", ] -auth = [ - "pyjwt", - "pyotp", - "qrcode>=8.0,<9.0", - "requests>=2.28", -] test = [ + "tet[security]", "pytest", "pytest-cov", + "webtest", + "structlog", + "psycopg2-binary", ] [project.urls] From 0340a93e6783d6a82f61c4f66e0af680e3d2e1ba Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Tue, 16 Jun 2026 01:07:01 +0300 Subject: [PATCH 135/139] Add pyramid_tm/zope.sqlalchemy to security extra; make test DB URL configurable The security DB tests need pyramid_tm and zope.sqlalchemy (transaction-managed sessions) in addition to pyjwt/pyotp; add them to the security extra. Make the test DB URL overridable via TET_TEST_DB_URL (default unchanged: localhost:5432 for CI) so local runs don't collide with a host postgres on 5432. --- pyproject.toml | 2 ++ tests/conftest.py | 6 +++++- tests/services/security/conftest.py | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9b82f79..87a2d24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ security = [ "pyotp", "qrcode>=8.0,<9.0", "requests>=2.28", + "pyramid_tm", + "zope.sqlalchemy", ] dev = [ "tet[security]", diff --git a/tests/conftest.py b/tests/conftest.py index 273b951..65f4f54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import json import logging +import os import typing as tp import pytest @@ -26,7 +27,10 @@ from tet.view import view_config DB_NAME = "test_tet" -DB_URL = f"postgresql+psycopg2://test_tet:test_tet@localhost:5432/{DB_NAME}" +DB_URL = os.environ.get( + "TET_TEST_DB_URL", + f"postgresql+psycopg2://test_tet:test_tet@localhost:5432/{DB_NAME}", +) logger = logging.getLogger(__name__) diff --git a/tests/services/security/conftest.py b/tests/services/security/conftest.py index 6f22119..7aba2f8 100644 --- a/tests/services/security/conftest.py +++ b/tests/services/security/conftest.py @@ -1,8 +1,13 @@ +import os + import pytest from sqlalchemy import create_engine, text TARGET_MODULE = "test_authentication.py" -DB_URL = "postgresql+psycopg2://test_tet:test_tet@localhost:5432/test_tet" +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): From 8597888c2c2089e654ab1a0b79bc1f390d992bd1 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Tue, 16 Jun 2026 01:47:16 +0300 Subject: [PATCH 136/139] CI: add Python 3.15 nightly (non-blocking) Add 3.15-dev to the test matrix with allow-prereleases, marked continue-on-error so the unreleased nightly doesn't block the build. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f5c64d..5c0cb70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ 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 @@ -29,6 +31,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15-dev" steps: - name: Checkout uses: actions/checkout@v6 @@ -36,6 +39,7 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install dependencies run: | pip install 'setuptools<82' From 19d97123f61c348fcf7d39d01dcf509b734ca036 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Thu, 18 Jun 2026 15:51:58 +0300 Subject: [PATCH 137/139] ruff: drop .bzr/.hg/.svn from exclude list (unused VCS) Claude-Session: https://claude.ai/code/session_011KKd5BtHMWfrF9WRRMwg7F --- ruff.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/ruff.toml b/ruff.toml index 254e922..5e3e495 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,10 +1,8 @@ exclude = [ - ".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", - ".hg", ".ipynb_checkpoints", ".mypy_cache", ".nox", @@ -13,7 +11,6 @@ exclude = [ ".pytest_cache", ".pytype", ".ruff_cache", - ".svn", ".tox", ".venv", ".vscode", From e5d07bffdd36d83499613be425ff3e40a2620ee7 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Thu, 18 Jun 2026 15:53:09 +0300 Subject: [PATCH 138/139] Drop black entirely; use ruff/ruff-format Removes the black dev dependency and [tool.black] config block. Formatting is handled by ruff-format. Claude-Session: https://claude.ai/code/session_011KKd5BtHMWfrF9WRRMwg7F --- pyproject.toml | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 363faa8..e0789b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,6 @@ dev = [ "webtest", "structlog", "psycopg2-binary", - "black", "ruff", "mypy", ] @@ -91,24 +90,6 @@ markers = [ "integration: marks tests as integration tests", ] -[tool.black] -line-length = 88 -target-version = ['py310', 'py311', 'py312'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - [tool.ruff] line-length = 88 target-version = "py310" @@ -124,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 From 460587f4efe63bb43b05fd5fe4dae79949714aaa Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Fri, 19 Jun 2026 09:21:54 +0300 Subject: [PATCH 139/139] security: log the actual error when the breach check fails is_password_breached swallowed the RequestException and logged a bare "unavailable" line with no detail. Add exc_info=True so the actual error (timeout, DNS, HTTP status) is captured. Behaviour is unchanged: a failed check still degrades gracefully to False (see test_breach_api_timeout_graceful_degradation). Claude-Session: https://claude.ai/code/session_011KKd5BtHMWfrF9WRRMwg7F --- src/tet/security/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tet/security/auth.py b/src/tet/security/auth.py index edc69d8..55f3e5a 100644 --- a/src/tet/security/auth.py +++ b/src/tet/security/auth.py @@ -97,7 +97,7 @@ def is_password_breached(self, password: str) -> bool: response = requests.get(url, timeout=5) response.raise_for_status() except requests.RequestException: - logger.warning("Password breach check unavailable") + logger.warning("Password breach check unavailable", exc_info=True) return False for line in response.text.splitlines():