From 26c8caba025c3922bc94bba1e114dc7eb77c0288 Mon Sep 17 00:00:00 2001 From: pollev Date: Mon, 18 Aug 2025 10:29:30 +0200 Subject: [PATCH 1/4] Add required packages --- tildes/requirements-dev.txt | 2 ++ tildes/requirements.in | 2 ++ tildes/requirements.txt | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tildes/requirements-dev.txt b/tildes/requirements-dev.txt index 468cac2..a7724e9 100644 --- a/tildes/requirements-dev.txt +++ b/tildes/requirements-dev.txt @@ -79,6 +79,7 @@ pydocstyle==6.3.0 pyflakes==3.4.0 pygit2==1.18.1 pygments==2.9.0 +pyjwt==2.10.1 pylint==3.3.8 pylint-celery==0.3 pylint-django==2.6.1 @@ -90,6 +91,7 @@ pyramid-debugtoolbar==4.12.1 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 pyramid-mako==1.1.0 +pyramid-multiauth==0.9.0 pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 diff --git a/tildes/requirements.in b/tildes/requirements.in index 4401b49..c09ce2c 100644 --- a/tildes/requirements.in +++ b/tildes/requirements.in @@ -18,10 +18,12 @@ psycopg2 publicsuffix2==2.20160818 pygit2 Pygments==2.9.0 # TODO: Upgrade Pygments, new version causes an error on posting a code block +PyJWT pyotp pyramid<2.0 pyramid-ipython pyramid-jinja2 +pyramid-multiauth==0.9.0 # Versions after this drop support for Pyramid < 2.0 This can be upgraded when we upgrade pyramid pyramid-openapi3>=0.17.0 pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions pyramid-tm diff --git a/tildes/requirements.txt b/tildes/requirements.txt index f0b1db8..3028a0f 100644 --- a/tildes/requirements.txt +++ b/tildes/requirements.txt @@ -56,11 +56,13 @@ pure-eval==0.2.3 pycparser==2.22 pygit2==1.18.1 pygments==2.9.0 +pyjwt==2.10.1 pyotp==2.9.0 pyproject-hooks==1.2.0 pyramid==1.10.8 pyramid-ipython==0.2 pyramid-jinja2==2.10.1 +pyramid-multiauth==0.9.0 pyramid-openapi3==0.21.0 pyramid-session-redis==1.5.0 pyramid-tm==2.6 From 407788cc16b8871d178f98af39040550ec794a9b Mon Sep 17 00:00:00 2001 From: pollev Date: Mon, 18 Aug 2025 10:30:11 +0200 Subject: [PATCH 2/4] Create JWT security policy --- tildes/development.ini | 3 + tildes/production.ini.example | 3 + tildes/tildes/auth.py | 105 +++++++++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/tildes/development.ini b/tildes/development.ini index f463e17..809bcb6 100644 --- a/tildes/development.ini +++ b/tildes/development.ini @@ -45,3 +45,6 @@ webassets.base_dir = %(here)s/static webassets.base_url = / webassets.cache = false webassets.manifest = json + +# JWT settings for the API authentication +jwt.secret = completely_insecure_jwt_secret_that_is_at_least_256_bits_long \ No newline at end of file diff --git a/tildes/production.ini.example b/tildes/production.ini.example index 3c7ad68..21ad9a1 100644 --- a/tildes/production.ini.example +++ b/tildes/production.ini.example @@ -42,6 +42,9 @@ webassets.base_url = / webassets.cache = false webassets.manifest = json +# JWT settings for the API authentication +jwt.secret = SomeReallyLongSecretDifferentFromTheSessionSecretAtLeast256BitsLong + # API keys for external APIs api_keys.embedly = embedlykeygoeshere api_keys.stripe.publishable = pk_live_ActualKeyShouldGoHere diff --git a/tildes/tildes/auth.py b/tildes/tildes/auth.py index 6b72f63..23109c2 100644 --- a/tildes/tildes/auth.py +++ b/tildes/tildes/auth.py @@ -3,16 +3,24 @@ """Configuration and functionality related to authentication/authorization.""" +import jwt from collections.abc import Sequence -from typing import Any, Optional +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Optional -from pyramid.authentication import SessionAuthenticationPolicy +from pyramid.authentication import ( + SessionAuthenticationPolicy, + CallbackAuthenticationPolicy, +) from pyramid.authorization import ACLAuthorizationPolicy from pyramid.config import Configurator from pyramid.httpexceptions import HTTPFound +from pyramid.interfaces import IAuthenticationPolicy +from pyramid_multiauth import MultiAuthenticationPolicy from pyramid.request import Request from pyramid.security import Allow, Everyone from sqlalchemy.orm import joinedload +from zope.interface import implementer from tildes.models.user import User @@ -80,12 +88,20 @@ def includeme(config: Configurator) -> None: config.set_authorization_policy(ACLAuthorizationPolicy()) - config.set_authentication_policy( - SessionAuthenticationPolicy(callback=auth_callback) - ) + # Get the JWT secret from settings + jwt_secret = config.registry.settings["jwt.secret"] + + # Configure both session and JWT authentication + policies = [ + SessionAuthenticationPolicy(callback=auth_callback), + JWTAuthenticationPolicy(secret=jwt_secret, callback=auth_callback), + ] + config.set_authentication_policy(MultiAuthenticationPolicy(policies)) - # enable CSRF checking globally by default - config.set_default_csrf_options(require_csrf=True) + # enable CSRF checking globally by default, but exclude API endpoints + config.set_default_csrf_options( + require_csrf=True, callback=lambda request: not request.path.startswith("/api/") + ) # make the logged-in User object available as request.user config.add_request_method(get_authenticated_user, "user", reify=True) @@ -101,3 +117,78 @@ def has_any_permission( return any( request.has_permission(permission, context) for permission in permissions ) + + +@implementer(IAuthenticationPolicy) +class JWTAuthenticationPolicy(CallbackAuthenticationPolicy): + """Authentication policy for JWT tokens. + + This policy checks for an Authorization header with a Bearer token. + The token is expected to be a JWT signed with the application's secret key. + """ + + def __init__( + self, + secret: str, + callback: None | Callable[[int, Request], Optional[Sequence[str]]] = None, + ): + """Initialize the policy with a secret key for JWT validation.""" + self.secret = secret + self.callback = callback + + def create_jwt_token(self, user_id: int, expiry: int = 86400) -> str: + """Create a new JWT token for a user.""" + payload = { + "sub": str(user_id), # JWT subjects must be strings + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(seconds=expiry), + } + return jwt.encode(payload, self.secret, algorithm="HS256") + + def validate_jwt_token(self, token: str) -> Optional[dict[str, Any]]: + """Validate a JWT token and return its payload if valid.""" + try: + # jwt.decode() WILL verify the expiration as well. + # This does not need to be checked separately. + return jwt.decode(token, self.secret, algorithms=["HS256"]) + except jwt.InvalidTokenError: + return None + + def unauthenticated_userid(self, request: Request) -> Optional[int]: + """Return the user id from the token without validating it.""" + + if not request.path.startswith("/api/"): + return None + + auth_header = request.headers.get("Authorization") + if not auth_header: + return None + + try: + auth_type, token = auth_header.split(" ", 1) + except ValueError: + return None + + if auth_type.lower() != "bearer": + return None + + payload = self.validate_jwt_token(token) + if not payload: + return None + + try: + user_id = int( + payload["sub"] + ) # JWT subjects must be strings, convert to int + except (KeyError, ValueError): + return None + + return user_id + + def remember(self, _request: Request, _userid: int, **_kw: dict) -> Sequence[tuple]: + """This should not be used for JWT authentication as it is stateless.""" + return [] + + def forget(self, _request: Request) -> Sequence[tuple]: + """This should not be used for JWT authentication as it is stateless.""" + return [] From 22d21690276d98309a35b761451a08fc914dcd1d Mon Sep 17 00:00:00 2001 From: pollev Date: Mon, 18 Aug 2025 10:50:29 +0200 Subject: [PATCH 3/4] add API login endpoint --- tildes/openapi_beta.yaml | 56 ++++++++++++++ tildes/tildes/auth.py | 7 +- tildes/tildes/routes.py | 1 + tildes/tildes/views/api/beta/auth.py | 111 +++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tildes/tildes/views/api/beta/auth.py diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml index 8f4de6e..d526502 100644 --- a/tildes/openapi_beta.yaml +++ b/tildes/openapi_beta.yaml @@ -7,9 +7,51 @@ info: The beta API is subject to change and may not be fully stable. Future updates WILL include breaking changes. Use at your own risk. +security: + # By default, all endpoints can be called with or without authentication + # For example, you can call the /topic/topic_id36 endpoint without authentication + # You will then not get any user-specific information, but the topic itself will still be returned. + # The empty object below is REQUIRED to allow unauthenticated calls. DO NOT REMOVE. + # For endpoints that really require authentication, we could overwrite this, + # but generally we check this in the code and return appropriate errors instead + - tokenAuth: [] # JWT Bearer token authentication + - {} # No authentication servers: - url: /api/beta paths: + /login: + post: + summary: Login with username and password + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + responses: + "200": + description: Login successful + content: + application/json: + schema: + type: object + required: + - token + properties: + token: + type: string + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/AuthenticationError" /topics: get: summary: Get a list of topics @@ -207,6 +249,12 @@ paths: $ref: "#/components/responses/AuthorizationError" components: + securitySchemes: + tokenAuth: + type: http + scheme: bearer + description: Bearer token authentication + bearerFormat: JWT parameters: paginationBefore: in: query @@ -243,6 +291,14 @@ components: type: array items: $ref: "#/components/schemas/Error" + AuthenticationError: + description: Authentication failed or token is invalid + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Error" AuthorizationError: description: You are not authorized to perform this action content: diff --git a/tildes/tildes/auth.py b/tildes/tildes/auth.py index 23109c2..cf7e650 100644 --- a/tildes/tildes/auth.py +++ b/tildes/tildes/auth.py @@ -3,11 +3,12 @@ """Configuration and functionality related to authentication/authorization.""" -import jwt from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any, Callable, Optional +import jwt + from pyramid.authentication import ( SessionAuthenticationPolicy, CallbackAuthenticationPolicy, @@ -186,9 +187,9 @@ class JWTAuthenticationPolicy(CallbackAuthenticationPolicy): return user_id def remember(self, _request: Request, _userid: int, **_kw: dict) -> Sequence[tuple]: - """This should not be used for JWT authentication as it is stateless.""" + """Not used for JWT authentication as it is stateless.""" return [] def forget(self, _request: Request) -> Sequence[tuple]: - """This should not be used for JWT authentication as it is stateless.""" + """Not used for JWT authentication as it is stateless.""" return [] diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index ea77bba..b394530 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -134,6 +134,7 @@ def includeme(config: Configurator) -> None: config.pyramid_openapi3_add_explorer(route="/api/beta/ui") with config.route_prefix_context("/api/beta"): + config.add_route("apibeta.login", "/login") config.add_route("apibeta.topics", "/topics") config.add_route("apibeta.topic", "/topic/{topic_id36}") config.add_route("apibeta.user", "/user/{username}") diff --git a/tildes/tildes/views/api/beta/auth.py b/tildes/tildes/views/api/beta/auth.py new file mode 100644 index 0000000..64b3280 --- /dev/null +++ b/tildes/tildes/views/api/beta/auth.py @@ -0,0 +1,111 @@ +# Copyright (c) 2018 Tildes contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""JSON API endpoints related to authentication.""" + +from datetime import timedelta +from pyramid.interfaces import IAuthenticationPolicy +from pyramid.request import Request +from pyramid.view import view_config + +from tildes.enums import LogEventType +from tildes.metrics import incr_counter +from tildes.models.log import Log +from tildes.models.user import User +from tildes.views.api.beta.api_utils import build_error_response +from tildes.auth import JWTAuthenticationPolicy + + +@view_config(route_name="apibeta.login", openapi=True, renderer="json") +def login(request: Request) -> dict: + """Process a login request and return a JWT token if successful.""" + username = request.openapi_validated.body.get("username") + password = request.openapi_validated.body.get("password") + + incr_counter("logins") + + # Look up the user for the supplied username + user = ( + request.query(User) + .undefer_all_columns() + .filter(User.username == username) + .one_or_none() + ) + + # If the username doesn't exist, tell them so - usually this isn't considered a good + # practice, but it's completely trivial to check if a username exists on Tildes + # anyway (by visiting /user/), so it's better to just let people know if + # they're trying to log in with the wrong username + if not user: + incr_counter("login_failures") + + # log the failure - need to manually commit because of the exception + log_entry = Log( + LogEventType.USER_LOG_IN_FAIL, + request, + {"username": username, "reason": "Nonexistent username"}, + ) + request.db_session.add(log_entry) + request.tm.commit() + + return build_error_response( + "That username does not exist", + field="username", + status=401, + error_type="AuthenticationError", + ) + + if not user.is_correct_password(password): + incr_counter("login_failures") + + # log the failure - need to manually commit because of the exception + log_entry = Log( + LogEventType.USER_LOG_IN_FAIL, + request, + {"username": username, "reason": "Incorrect password"}, + ) + request.db_session.add(log_entry) + request.tm.commit() + + return build_error_response( + "Incorrect password", + field="password", + status=401, + error_type="AuthenticationError", + ) + + # Don't allow banned users to log in + if user.is_banned: + if user.ban_expiry_time: + # add an hour to the ban's expiry time since the cronjob runs hourly + unban_time = user.ban_expiry_time + timedelta(hours=1) + unban_time = unban_time.strftime("%Y-%m-%d %H:%M (UTC)") + + return build_error_response( + "That account is temporarily banned. " + f"The ban will be lifted at {unban_time}", + status=401, + error_type="AuthenticationError", + ) + + return build_error_response( + "That account has been banned", status=401, error_type="AuthenticationError" + ) + + # If 2FA is enabled, save username to session and make user enter code + if user.two_factor_enabled: + return build_error_response( + "Two-factor authentication is enabled for this account. " + "This is currently not supported by the API.", + status=401, + error_type="AuthenticationError", + ) + + # Get the JWT authentication policy and generate a token + auth_policy = request.registry.queryUtility(IAuthenticationPolicy) + # We use MultiAuthenticationPolicy, find the JWT policy + jwt_policy = auth_policy.get_policy(JWTAuthenticationPolicy) + + token = jwt_policy.create_jwt_token(user.user_id) + + return {"token": token} From b6ba19e281eea237682454d0868ff1be8f0f3cfa Mon Sep 17 00:00:00 2001 From: pollev Date: Mon, 18 Aug 2025 15:19:39 +0200 Subject: [PATCH 4/4] add whoami API endpoint --- tildes/openapi_beta.yaml | 17 +++++++++++++++++ tildes/tildes/routes.py | 1 + tildes/tildes/views/api/beta/auth.py | 13 +++++++++++++ 3 files changed, 31 insertions(+) diff --git a/tildes/openapi_beta.yaml b/tildes/openapi_beta.yaml index d526502..c77ecb3 100644 --- a/tildes/openapi_beta.yaml +++ b/tildes/openapi_beta.yaml @@ -52,6 +52,23 @@ paths: $ref: "#/components/responses/ValidationError" "401": $ref: "#/components/responses/AuthenticationError" + + /whoami: + get: + summary: Get username for currently logged in user + responses: + "200": + description: Username of the currently logged in user + content: + application/json: + schema: + type: object + required: + - msg + properties: + msg: + type: string + /topics: get: summary: Get a list of topics diff --git a/tildes/tildes/routes.py b/tildes/tildes/routes.py index b394530..33edbde 100644 --- a/tildes/tildes/routes.py +++ b/tildes/tildes/routes.py @@ -135,6 +135,7 @@ def includeme(config: Configurator) -> None: with config.route_prefix_context("/api/beta"): config.add_route("apibeta.login", "/login") + config.add_route("apibeta.whoami", "/whoami") config.add_route("apibeta.topics", "/topics") config.add_route("apibeta.topic", "/topic/{topic_id36}") config.add_route("apibeta.user", "/user/{username}") diff --git a/tildes/tildes/views/api/beta/auth.py b/tildes/tildes/views/api/beta/auth.py index 64b3280..bb12890 100644 --- a/tildes/tildes/views/api/beta/auth.py +++ b/tildes/tildes/views/api/beta/auth.py @@ -109,3 +109,16 @@ def login(request: Request) -> dict: token = jwt_policy.create_jwt_token(user.user_id) return {"token": token} + + +@view_config(route_name="apibeta.whoami", openapi=True, renderer="json") +def whoami(request: Request) -> dict: + """Endpoint to let a user verify that they are authenticated.""" + + user = request.user + if not user: + msg = "You are not logged in" + else: + msg = f"You are logged in as {user.username}" + + return {"msg": msg}