Browse Source

Merge branch 'add-api-jwt-authentication' into 'develop-1.101'

Tildes API | JWT Bearer Token Authentication

See merge request tildes/tildes!165
merge-requests/165/merge
Polle 5 months ago
parent
commit
083a2113f8
  1. 3
      tildes/development.ini
  2. 73
      tildes/openapi_beta.yaml
  3. 3
      tildes/production.ini.example
  4. 2
      tildes/requirements-dev.txt
  5. 2
      tildes/requirements.in
  6. 2
      tildes/requirements.txt
  7. 106
      tildes/tildes/auth.py
  8. 2
      tildes/tildes/routes.py
  9. 124
      tildes/tildes/views/api/beta/auth.py

3
tildes/development.ini

@ -45,3 +45,6 @@ webassets.base_dir = %(here)s/static
webassets.base_url = / webassets.base_url = /
webassets.cache = false webassets.cache = false
webassets.manifest = json webassets.manifest = json
# JWT settings for the API authentication
jwt.secret = completely_insecure_jwt_secret_that_is_at_least_256_bits_long

73
tildes/openapi_beta.yaml

@ -7,9 +7,68 @@ info:
The beta API is subject to change and may not be fully stable. The beta API is subject to change and may not be fully stable.
Future updates WILL include breaking changes. Future updates WILL include breaking changes.
Use at your own risk. 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: servers:
- url: /api/beta - url: /api/beta
paths: 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"
/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: /topics:
get: get:
summary: Get a list of topics summary: Get a list of topics
@ -207,6 +266,12 @@ paths:
$ref: "#/components/responses/AuthorizationError" $ref: "#/components/responses/AuthorizationError"
components: components:
securitySchemes:
tokenAuth:
type: http
scheme: bearer
description: Bearer token authentication
bearerFormat: JWT
parameters: parameters:
paginationBefore: paginationBefore:
in: query in: query
@ -243,6 +308,14 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
AuthenticationError:
description: Authentication failed or token is invalid
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Error"
AuthorizationError: AuthorizationError:
description: You are not authorized to perform this action description: You are not authorized to perform this action
content: content:

3
tildes/production.ini.example

@ -42,6 +42,9 @@ webassets.base_url = /
webassets.cache = false webassets.cache = false
webassets.manifest = json webassets.manifest = json
# JWT settings for the API authentication
jwt.secret = SomeReallyLongSecretDifferentFromTheSessionSecretAtLeast256BitsLong
# API keys for external APIs # API keys for external APIs
api_keys.embedly = embedlykeygoeshere api_keys.embedly = embedlykeygoeshere
api_keys.stripe.publishable = pk_live_ActualKeyShouldGoHere api_keys.stripe.publishable = pk_live_ActualKeyShouldGoHere

2
tildes/requirements-dev.txt

@ -79,6 +79,7 @@ pydocstyle==6.3.0
pyflakes==3.4.0 pyflakes==3.4.0
pygit2==1.18.1 pygit2==1.18.1
pygments==2.9.0 pygments==2.9.0
pyjwt==2.10.1
pylint==3.3.8 pylint==3.3.8
pylint-celery==0.3 pylint-celery==0.3
pylint-django==2.6.1 pylint-django==2.6.1
@ -90,6 +91,7 @@ pyramid-debugtoolbar==4.12.1
pyramid-ipython==0.2 pyramid-ipython==0.2
pyramid-jinja2==2.10.1 pyramid-jinja2==2.10.1
pyramid-mako==1.1.0 pyramid-mako==1.1.0
pyramid-multiauth==0.9.0
pyramid-openapi3==0.21.0 pyramid-openapi3==0.21.0
pyramid-session-redis==1.5.0 pyramid-session-redis==1.5.0
pyramid-tm==2.6 pyramid-tm==2.6

2
tildes/requirements.in

@ -18,10 +18,12 @@ psycopg2
publicsuffix2==2.20160818 publicsuffix2==2.20160818
pygit2 pygit2
Pygments==2.9.0 # TODO: Upgrade Pygments, new version causes an error on posting a code block Pygments==2.9.0 # TODO: Upgrade Pygments, new version causes an error on posting a code block
PyJWT
pyotp pyotp
pyramid<2.0 pyramid<2.0
pyramid-ipython pyramid-ipython
pyramid-jinja2 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-openapi3>=0.17.0
pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions pyramid-session-redis==1.5.0 # 1.5.1 has a change that will invalidate current sessions
pyramid-tm pyramid-tm

2
tildes/requirements.txt

@ -56,11 +56,13 @@ pure-eval==0.2.3
pycparser==2.22 pycparser==2.22
pygit2==1.18.1 pygit2==1.18.1
pygments==2.9.0 pygments==2.9.0
pyjwt==2.10.1
pyotp==2.9.0 pyotp==2.9.0
pyproject-hooks==1.2.0 pyproject-hooks==1.2.0
pyramid==1.10.8 pyramid==1.10.8
pyramid-ipython==0.2 pyramid-ipython==0.2
pyramid-jinja2==2.10.1 pyramid-jinja2==2.10.1
pyramid-multiauth==0.9.0
pyramid-openapi3==0.21.0 pyramid-openapi3==0.21.0
pyramid-session-redis==1.5.0 pyramid-session-redis==1.5.0
pyramid-tm==2.6 pyramid-tm==2.6

106
tildes/tildes/auth.py

@ -4,15 +4,24 @@
"""Configuration and functionality related to authentication/authorization.""" """Configuration and functionality related to authentication/authorization."""
from collections.abc import Sequence 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
import jwt
from pyramid.authentication import (
SessionAuthenticationPolicy,
CallbackAuthenticationPolicy,
)
from pyramid.authorization import ACLAuthorizationPolicy from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.config import Configurator from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.interfaces import IAuthenticationPolicy
from pyramid_multiauth import MultiAuthenticationPolicy
from pyramid.request import Request from pyramid.request import Request
from pyramid.security import Allow, Everyone from pyramid.security import Allow, Everyone
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from zope.interface import implementer
from tildes.models.user import User from tildes.models.user import User
@ -80,12 +89,20 @@ def includeme(config: Configurator) -> None:
config.set_authorization_policy(ACLAuthorizationPolicy()) 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"]
# enable CSRF checking globally by default
config.set_default_csrf_options(require_csrf=True)
# 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, 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 # make the logged-in User object available as request.user
config.add_request_method(get_authenticated_user, "user", reify=True) config.add_request_method(get_authenticated_user, "user", reify=True)
@ -101,3 +118,78 @@ def has_any_permission(
return any( return any(
request.has_permission(permission, context) for permission in permissions 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]:
"""Not used for JWT authentication as it is stateless."""
return []
def forget(self, _request: Request) -> Sequence[tuple]:
"""Not used for JWT authentication as it is stateless."""
return []

2
tildes/tildes/routes.py

@ -134,6 +134,8 @@ def includeme(config: Configurator) -> None:
config.pyramid_openapi3_add_explorer(route="/api/beta/ui") config.pyramid_openapi3_add_explorer(route="/api/beta/ui")
with config.route_prefix_context("/api/beta"): 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.topics", "/topics")
config.add_route("apibeta.topic", "/topic/{topic_id36}") config.add_route("apibeta.topic", "/topic/{topic_id36}")
config.add_route("apibeta.user", "/user/{username}") config.add_route("apibeta.user", "/user/{username}")

124
tildes/tildes/views/api/beta/auth.py

@ -0,0 +1,124 @@
# Copyright (c) 2018 Tildes contributors <code@tildes.net>
# 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/<username>), 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}
@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}
Loading…
Cancel
Save