Browse Source

add API login endpoint

merge-requests/165/head
pollev 2 months ago
parent
commit
22d2169027
  1. 56
      tildes/openapi_beta.yaml
  2. 7
      tildes/tildes/auth.py
  3. 1
      tildes/tildes/routes.py
  4. 111
      tildes/tildes/views/api/beta/auth.py

56
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:

7
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 []

1
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}")

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

@ -0,0 +1,111 @@
# 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}
Loading…
Cancel
Save