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}