Browse Source

add API login endpoint

merge-requests/165/head
pollev 5 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. 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"
/topics: /topics:
get: get:
summary: Get a list of topics summary: Get a list of topics
@ -207,6 +249,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 +291,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:

7
tildes/tildes/auth.py

@ -3,11 +3,12 @@
"""Configuration and functionality related to authentication/authorization.""" """Configuration and functionality related to authentication/authorization."""
import jwt
from collections.abc import Sequence from collections.abc import Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
import jwt
from pyramid.authentication import ( from pyramid.authentication import (
SessionAuthenticationPolicy, SessionAuthenticationPolicy,
CallbackAuthenticationPolicy, CallbackAuthenticationPolicy,
@ -186,9 +187,9 @@ class JWTAuthenticationPolicy(CallbackAuthenticationPolicy):
return user_id return user_id
def remember(self, _request: Request, _userid: int, **_kw: dict) -> Sequence[tuple]: 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 [] return []
def forget(self, _request: Request) -> Sequence[tuple]: 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 [] return []

1
tildes/tildes/routes.py

@ -134,6 +134,7 @@ 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.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}")

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