move api auth from authorization header to X-API-Key header

This commit is contained in:
Laura Klünder 2023-12-04 23:07:45 +01:00
parent f64e65f297
commit 4491f68dc7
8 changed files with 43 additions and 41 deletions

View file

@ -44,20 +44,20 @@ def get_status(request):
) )
class APITokenSchema(Schema): class APIKeySchema(Schema):
token: NonEmptyStr = APIField( key: NonEmptyStr = APIField(
title="API token", title="API key",
description="API token to be directly used with `Authorization: Bearer <token>` HTTP header." description="API secret to be directly used with `X-API-Key` HTTP header."
) )
@auth_api_router.get('/session/', response=APITokenSchema, auth=None, @auth_api_router.get('/session/', response=APIKeySchema, auth=None,
summary="get session-bound token") summary="get session-bound key")
def session_token(request): def session_key(request):
""" """
Get an API token that is bound to the transmitted session cookie. Get an API key that is bound to the transmitted session cookie.
Keep in mind that this API token will be invalid if the session gets signed out or similar. Keep in mind that this API key will be invalid if the session gets signed out or similar.
""" """
session_id = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None) session_id = request.COOKIES.get(settings.SESSION_COOKIE_NAME, None)
return {"token": "anonymous" if session_id is None else f"session:{session_id}"} return {"key": "anonymous" if session_id is None else f"session:{session_id}"}

View file

@ -6,10 +6,10 @@ from importlib import import_module
from django.contrib.auth import get_user as auth_get_user from django.contrib.auth import get_user as auth_get_user
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.utils.functional import SimpleLazyObject, lazy from django.utils.functional import SimpleLazyObject, lazy
from ninja.security import HttpBearer from ninja.security import APIKeyHeader
from c3nav import settings from c3nav import settings
from c3nav.api.exceptions import APIPermissionDenied, APITokenInvalid from c3nav.api.exceptions import APIPermissionDenied, APIKeyInvalid
from c3nav.api.models import Secret from c3nav.api.models import Secret
from c3nav.api.schema import APIErrorSchema from c3nav.api.schema import APIErrorSchema
from c3nav.control.middleware import UserPermissionsMiddleware from c3nav.control.middleware import UserPermissionsMiddleware
@ -31,16 +31,18 @@ class APIAuthDetails:
description = """ description = """
An API token can be acquired in 4 ways: An API key can be acquired in 4 ways:
* Use `anonymous` for guest access. * Use `anonymous` for guest access.
* Generate a session-bound temporary token using the auth session endpoint. * Generate a session-bound temporary key using the auth session endpoint.
* Create an API secret in your user account settings. * Create an API secret in your user account settings.
""".strip() """.strip()
class APITokenAuth(HttpBearer): class APIKeyAuth(APIKeyHeader):
openapi_name = "api token authentication" param_name = "X-API-Key"
openapi_name = "api key authentication"
openapi_description = description openapi_description = description
def __init__(self, logged_in=False, superuser=False, permissions: set[str] = None, is_readonly=False): def __init__(self, logged_in=False, superuser=False, permissions: set[str] = None, is_readonly=False):
@ -52,32 +54,32 @@ class APITokenAuth(HttpBearer):
engine = import_module(settings.SESSION_ENGINE) engine = import_module(settings.SESSION_ENGINE)
self.SessionStore = engine.SessionStore self.SessionStore = engine.SessionStore
def _authenticate(self, request, token) -> APIAuthDetails: def _authenticate(self, request, key) -> APIAuthDetails:
request.user = AnonymousUser() request.user = AnonymousUser()
request.user_permissions = SimpleLazyObject(lambda: UserPermissionsMiddleware.get_user_permissions(request)) request.user_permissions = SimpleLazyObject(lambda: UserPermissionsMiddleware.get_user_permissions(request))
request.user_space_accesses = lazy(UserPermissionsMiddleware.get_user_space_accesses, dict)(request) request.user_space_accesses = lazy(UserPermissionsMiddleware.get_user_space_accesses, dict)(request)
if token == "anonymous": if key == "anonymous":
return APIAuthDetails( return APIAuthDetails(
key_type=APIKeyType.ANONYMOUS, key_type=APIKeyType.ANONYMOUS,
readonly=True, readonly=True,
) )
elif token.startswith("session:"): elif key.startswith("session:"):
session = self.SessionStore(token.removeprefix("session:")) session = self.SessionStore(key.removeprefix("session:"))
print('session is empty:', request.session.is_empty()) print('session is empty:', request.session.is_empty())
user = auth_get_user(FakeRequest(session=session)) user = auth_get_user(FakeRequest(session=session))
if not user.is_authenticated: if not user.is_authenticated:
raise APITokenInvalid raise APIKeyInvalid
request.user = user request.user = user
return APIAuthDetails( return APIAuthDetails(
key_type=APIKeyType.SESSION, key_type=APIKeyType.SESSION,
readonly=False, readonly=False,
) )
elif token.startswith("secret:"): elif key.startswith("secret:"):
try: try:
secret = Secret.objects.get_by_secret(token.removeprefix("secret:")).get() secret = Secret.objects.get_by_secret(key.removeprefix("secret:")).get()
except Secret.DoesNotExist: except Secret.DoesNotExist:
raise APITokenInvalid raise APIKeyInvalid
# get user permissions and restrict them based on scopes # get user permissions and restrict them based on scopes
user_permissions: UserPermissions = UserPermissions.get_for_user(secret.user) user_permissions: UserPermissions = UserPermissions.get_for_user(secret.user)
@ -95,10 +97,10 @@ class APITokenAuth(HttpBearer):
key_type=APIKeyType.SESSION, key_type=APIKeyType.SESSION,
readonly=secret.readonly readonly=secret.readonly
) )
raise APITokenInvalid raise APIKeyInvalid
def authenticate(self, request, token): def authenticate(self, request, key):
auth_result = self._authenticate(request, token) auth_result = self._authenticate(request, key)
if self.logged_in and not request.user.is_authenticated: if self.logged_in and not request.user.is_authenticated:
raise APIPermissionDenied('You need to be signed in for this request.') raise APIPermissionDenied('You need to be signed in for this request.')
if self.superuser and not request.user.is_superuser: if self.superuser and not request.user.is_superuser:

View file

@ -22,9 +22,9 @@ class APIUnauthorized(CustomAPIException):
detail = "Authorization is required for this endpoint." detail = "Authorization is required for this endpoint."
class APITokenInvalid(CustomAPIException): class APIKeyInvalid(CustomAPIException):
status_code = 401 status_code = 401
detail = "Invalid API token." detail = "Invalid API key."
class APIPermissionDenied(CustomAPIException): class APIPermissionDenied(CustomAPIException):

View file

@ -3,7 +3,7 @@ from ninja.openapi.docs import DocsBase
from ninja.operation import Operation from ninja.operation import Operation
from ninja.schema import NinjaGenerateJsonSchema from ninja.schema import NinjaGenerateJsonSchema
from c3nav.api.auth import APITokenAuth from c3nav.api.auth import APIKeyAuth
from c3nav.api.exceptions import CustomAPIException from c3nav.api.exceptions import CustomAPIException
@ -60,7 +60,7 @@ ninja_api = c3navAPI(
docs_url="/", docs_url="/",
docs=SwaggerAndRedoc(), docs=SwaggerAndRedoc(),
auth=APITokenAuth(), auth=APIKeyAuth(),
openapi_extra={ openapi_extra={
"tags": [ "tags": [

View file

@ -2,7 +2,7 @@ from django.urls import Resolver404, resolve
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ninja import Router as APIRouter from ninja import Router as APIRouter
from c3nav.api.auth import APITokenAuth, auth_permission_responses from c3nav.api.auth import APIKeyAuth, auth_permission_responses
from c3nav.api.exceptions import API404 from c3nav.api.exceptions import API404
from c3nav.editor.api.base import api_etag_with_update_cache_key from c3nav.editor.api.base import api_etag_with_update_cache_key
from c3nav.editor.api.geometries import get_level_geometries_result, get_space_geometries_result from c3nav.editor.api.geometries import get_level_geometries_result, get_space_geometries_result
@ -12,7 +12,7 @@ from c3nav.mapdata.api.base import api_etag
from c3nav.mapdata.models import Source from c3nav.mapdata.models import Source
from c3nav.mapdata.schemas.responses import WithBoundsSchema from c3nav.mapdata.schemas.responses import WithBoundsSchema
editor_api_router = APIRouter(tags=["editor"], auth=APITokenAuth(permissions={"editor_access"})) editor_api_router = APIRouter(tags=["editor"], auth=APIKeyAuth(permissions={"editor_access"}))
@editor_api_router.get('/bounds/', summary="boundaries", @editor_api_router.get('/bounds/', summary="boundaries",

View file

@ -9,13 +9,13 @@ from ninja import Schema, UploadedFile
from ninja.pagination import paginate from ninja.pagination import paginate
from pydantic import PositiveInt, field_validator from pydantic import PositiveInt, field_validator
from c3nav.api.auth import APITokenAuth, auth_permission_responses, auth_responses, validate_responses from c3nav.api.auth import APIKeyAuth, auth_permission_responses, auth_responses, validate_responses
from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed from c3nav.api.exceptions import API404, APIConflict, APIRequestValidationFailed
from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage from c3nav.mesh.dataformats import BoardType, ChipType, FirmwareImage
from c3nav.mesh.messages import MeshMessageType from c3nav.mesh.messages import MeshMessageType
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, NodeMessage
mesh_api_router = APIRouter(tags=["mesh"], auth=APITokenAuth(permissions={"mesh_control"})) mesh_api_router = APIRouter(tags=["mesh"], auth=APIKeyAuth(permissions={"mesh_control"}))
class FirmwareBuildSchema(Schema): class FirmwareBuildSchema(Schema):

View file

@ -9,7 +9,7 @@ from ninja import Router as APIRouter
from ninja import Schema from ninja import Schema
from pydantic import PositiveInt from pydantic import PositiveInt
from c3nav.api.auth import APITokenAuth, auth_responses, validate_responses from c3nav.api.auth import APIKeyAuth, auth_responses, validate_responses
from c3nav.api.exceptions import APIRequestValidationFailed from c3nav.api.exceptions import APIRequestValidationFailed
from c3nav.api.utils import NonEmptyStr from c3nav.api.utils import NonEmptyStr
from c3nav.mapdata.api.base import api_stats_clean_location_value from c3nav.mapdata.api.base import api_stats_clean_location_value
@ -171,7 +171,7 @@ def get_request_pk(location):
return location.slug if isinstance(location, Position) else location.pk return location.slug if isinstance(location, Position) else location.pk
@routing_api_router.post('/route/', summary="query route", auth=APITokenAuth(is_readonly=True), @routing_api_router.post('/route/', summary="query route", auth=APIKeyAuth(is_readonly=True),
description="query route between two locations", description="query route between two locations",
response={200: RouteResponse | NoRouteResponse, **validate_responses, **auth_responses}) response={200: RouteResponse | NoRouteResponse, **validate_responses, **auth_responses})
# todo: route failure responses # todo: route failure responses

View file

@ -1,7 +1,7 @@
(function () { (function () {
class C3NavApi { class C3NavApi {
token = 'anonymous'; key = 'anonymous';
constructor(base ) { constructor(base ) {
this.base = base; this.base = base;
@ -11,7 +11,7 @@
}) })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
this.token = data.token this.key = data.key
}) })
.catch(err => { .catch(err => {
throw err; throw err;
@ -36,7 +36,7 @@
credentials: 'omit', credentials: 'omit',
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${this.token}`, 'X-API-Key': this.key,
'Accept': 'application/json' 'Accept': 'application/json'
} }
}) })
@ -48,7 +48,7 @@
credentials: 'omit', credentials: 'omit',
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${this.token}`, 'Authorization': `Bearer ${this.key}`,
'Accept': 'application/json', 'Accept': 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },