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 2 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.cache = false
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.
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"
/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
@ -207,6 +266,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 +308,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:

3
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

2
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

2
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

2
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

106
tildes/tildes/auth.py

@ -4,15 +4,24 @@
"""Configuration and functionality related to authentication/authorization."""
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.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 +89,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"]
# 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
config.add_request_method(get_authenticated_user, "user", reify=True)
@ -101,3 +118,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]:
"""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")
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}")

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