From 8713445e7edc5e211f7907f3f7cb4cc74719f15f Mon Sep 17 00:00:00 2001 From: Sascha Klawohn Date: Tue, 19 Sep 2023 14:01:51 +0000 Subject: [PATCH] Resolve "Application Token" --- docs/apis/api.md | 15 ++++ nomad/app/v1/routers/auth.py | 117 ++++++++++++++++++++--------- nomad/config/models.py | 4 + tests/app/conftest.py | 29 ++++--- tests/app/v1/routers/test_auth.py | 27 +++++++ tests/app/v1/routers/test_users.py | 7 +- 6 files changed, 153 insertions(+), 46 deletions(-) diff --git a/docs/apis/api.md b/docs/apis/api.md index 509d83f3e..277a6e0ca 100644 --- a/docs/apis/api.md +++ b/docs/apis/api.md @@ -390,6 +390,21 @@ To use authentication in the dashboard, simply use the Authorize button. The dashboard GUI will manage the access token and use it while you try out the various operations. +#### App token + +If the short-term expiration of the default *access token* does not suit your needs, +you can request an *app token* with a user-defined expiration. For example, you can +send the GET request `/auth/app_token?expires_in=86400` together with some way of +authentication, e.g. header `Authorization: Bearer `. The API will return +an app token, which is valid for 24 hours in subsequent request headers with the format +`Authorization: Bearer `. The request will be declined if the expiration is +larger than the maximum expiration defined by the API config. + +!!! warning + Despite the name, the app token is used to impersonate the user who requested it. + It does not discern between different uses and will only become invalid once it + expires (or when the API's secret is changed). + ## Search for entries See [getting started](#getting-started) for a typical search example. Combine the [different diff --git a/nomad/app/v1/routers/auth.py b/nomad/app/v1/routers/auth.py index f5c88d6e6..49da50eb4 100644 --- a/nomad/app/v1/routers/auth.py +++ b/nomad/app/v1/routers/auth.py @@ -20,7 +20,7 @@ import hashlib import uuid import requests -from typing import Callable, cast +from typing import Callable, cast, Union from inspect import Parameter, signature from functools import wraps from fastapi import APIRouter, Depends, Query as FastApiQuery, Request, HTTPException, status @@ -51,6 +51,10 @@ class SignatureToken(BaseModel): signature_token: str +class AppToken(BaseModel): + app_token: str + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f'{root_path}/auth/token', auto_error=False) @@ -64,7 +68,6 @@ def create_user_dependency( Creates a dependency for getting the authenticated user. The parameters define if the authentication is required or not, and which authentication methods are allowed. ''' - def user_dependency(**kwargs) -> User: user = None if basic_auth_allowed: @@ -180,17 +183,26 @@ def _get_user_basic_auth(form_data: OAuth2PasswordRequestForm) -> User: def _get_user_bearer_token_auth(bearer_token: str) -> User: ''' Verifies bearer_token (throwing exception if illegal value provided) and returns the - corresponding user object, or None, if no bearer_token provided. + corresponding user object, or None if no bearer_token provided. ''' - if bearer_token: - try: - user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token)) + if not bearer_token: + return None + + try: + unverified_payload = jwt.decode(bearer_token, options={"verify_signature": False}) + if unverified_payload.keys() == set(['user', 'exp']): + user = _get_user_from_simple_token(bearer_token) return user - except infrastructure.KeycloakError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(e), headers={'WWW-Authenticate': 'Bearer'}) - return None + except jwt.exceptions.DecodeError: + pass # token could be non-JWT, e.g. for testing + + try: + user = cast(datamodel.User, infrastructure.keycloak.tokenauth(bearer_token)) + return user + except infrastructure.KeycloakError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e), headers={'WWW-Authenticate': 'Bearer'}) def _get_user_upload_token_auth(upload_token: str) -> User: @@ -227,21 +239,8 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us corresponding user object, or None, if no upload_token provided. ''' if signature_token: - try: - decoded = jwt.decode(signature_token, config.services.api_secret, algorithms=['HS256']) - return datamodel.User.get(user_id=decoded['user']) - except KeyError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail='Token with invalid/unexpected payload.') - except jwt.ExpiredSignatureError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail='Expired token.') - except jwt.InvalidTokenError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail='Invalid token.') + user = _get_user_from_simple_token(signature_token) + return user elif request: auth_cookie = request.cookies.get('Authorization') if auth_cookie: @@ -261,6 +260,28 @@ def _get_user_signature_token_auth(signature_token: str, request: Request) -> Us return None +def _get_user_from_simple_token(token): + ''' + Verifies a simple token (throwing exception if illegal value provided) and returns the + corresponding user object, or None if no token was provided. + ''' + try: + decoded = jwt.decode(token, config.services.api_secret, algorithms=['HS256']) + return datamodel.User.get(user_id=decoded['user']) + except KeyError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Token with invalid/unexpected payload.') + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Expired token.') + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail='Invalid token.') + + _bad_credentials_response = status.HTTP_401_UNAUTHORIZED, { 'model': HTTPExceptionModel, 'description': strip(''' @@ -287,7 +308,6 @@ async def get_token(form_data: OAuth2PasswordRequestForm = Depends()): You only need to provide `username` and `password` values. You can ignore the other parameters. ''' - try: access_token = infrastructure.keycloak.basicauth( form_data.username, form_data.password) @@ -311,7 +331,6 @@ async def get_token_via_query(username: str, password: str): This is an convenience alternative to the **POST** version of this operation. It allows you to retrieve an *access token* by providing username and password. ''' - try: access_token = infrastructure.keycloak.basicauth(username, password) except infrastructure.KeycloakError: @@ -328,21 +347,51 @@ async def get_token_via_query(username: str, password: str): tags=[default_tag], summary='Get a signature token', response_model=SignatureToken) -async def get_signature_token(user: User = Depends(create_user_dependency())): +async def get_signature_token( + user: Union[User, None] = Depends(create_user_dependency(required=True))): ''' Generates and returns a signature token for the authenticated user. Authentication has to be provided with another method, e.g. access token. ''' + signature_token = generate_simple_token(user.user_id, expires_in=10) + return {'signature_token': signature_token} - expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10) - signature_token = jwt.encode( - dict(user=user.user_id, exp=expires_at), - config.services.api_secret, 'HS256') - return {'signature_token': signature_token} +@router.get( + '/app_token', + tags=[default_tag], + summary='Get an app token', + response_model=AppToken) +async def get_app_token( + expires_in: int = FastApiQuery(gt=0, le=config.services.app_token_max_expires_in), + user: User = Depends(create_user_dependency(required=True))): + ''' + Generates and returns an app token with the requested expiration time for the + authenticated user. Authentication has to be provided with another method, + e.g. access token. + + This app token can be used like the access token (see `/auth/token`) on subsequent API + calls to authenticate you using the HTTP header `Authorization: Bearer `. + It is provided for user convenience as a shorter token with a user-defined (probably + longer) expiration time than the access token. + ''' + app_token = generate_simple_token(user.user_id, expires_in) + return {'app_token': app_token} + + +def generate_simple_token(user_id, expires_in: int): + ''' + Generates and returns JWT encoding just user_id and expiration time, signed with the + API secret. + ''' + expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in) + payload = dict(user=user_id, exp=expires_at) + token = jwt.encode(payload, config.services.api_secret, 'HS256') + return token def generate_upload_token(user): + '''Generates and returns upload token for user.''' payload = uuid.UUID(user.user_id).bytes signature = hmac.new( bytes(config.services.api_secret, 'utf-8'), diff --git a/nomad/config/models.py b/nomad/config/models.py index 7630010c4..10f88a9c8 100644 --- a/nomad/config/models.py +++ b/nomad/config/models.py @@ -202,6 +202,10 @@ class Services(NomadSettings): Value that is used in `results` section Enum fields (e.g. system type, spacegroup, etc.) to indicate that the value could not be determined. ''') + app_token_max_expires_in = Field(1 * 24 * 60 * 60, description=''' + Maximum expiration time for an app token in seconds. Requests with a higher value + will be declined. + ''') class Meta(NomadSettings): diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 035d5de7a..586fa3b16 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -21,34 +21,43 @@ from nomad.app.main import app from nomad.datamodel import User -from nomad.app.v1.routers.auth import generate_upload_token +from nomad.app.v1.routers.auth import generate_upload_token, generate_simple_token -def create_auth_headers(user: User): - return { - 'Authorization': 'Bearer %s' % user.user_id - } +def create_auth_headers(token: str): + return {'Authorization': f'Bearer {token}'} @pytest.fixture(scope='module') def test_user_auth(test_user: User): - return create_auth_headers(test_user) + return create_auth_headers(test_user.user_id) @pytest.fixture(scope='module') def other_test_user_auth(other_test_user: User): - return create_auth_headers(other_test_user) + return create_auth_headers(other_test_user.user_id) @pytest.fixture(scope='module') def admin_user_auth(admin_user: User): - return create_auth_headers(admin_user) + return create_auth_headers(admin_user.user_id) + + +@pytest.fixture(scope='module') +def invalid_user_auth(): + return create_auth_headers("invalid.bearer.token") + + +@pytest.fixture(scope='module') +def app_token_auth(test_user: User): + app_token = generate_simple_token(test_user.user_id, expires_in=3600) + return create_auth_headers(app_token) @pytest.fixture(scope='module') def test_auth_dict( test_user, other_test_user, admin_user, - test_user_auth, other_test_user_auth, admin_user_auth): + test_user_auth, other_test_user_auth, admin_user_auth, invalid_user_auth): ''' Returns a dictionary of the form {user_name: (auth_headers, token)}. The key 'invalid' contains an example of invalid credentials, and the key None contains (None, None). @@ -57,7 +66,7 @@ def test_auth_dict( 'test_user': (test_user_auth, generate_upload_token(test_user)), 'other_test_user': (other_test_user_auth, generate_upload_token(other_test_user)), 'admin_user': (admin_user_auth, generate_upload_token(admin_user)), - 'invalid': ({'Authorization': 'Bearer JUST-MADE-IT-UP'}, 'invalid.token'), + 'invalid': (invalid_user_auth, 'invalid.upload.token'), None: (None, None)} diff --git a/tests/app/v1/routers/test_auth.py b/tests/app/v1/routers/test_auth.py index b82a2f732..9c394ca1a 100644 --- a/tests/app/v1/routers/test_auth.py +++ b/tests/app/v1/routers/test_auth.py @@ -46,3 +46,30 @@ def test_get_signature_token(client, test_user_auth): response = client.get('auth/signature_token', headers=test_user_auth) assert response.status_code == 200 assert response.json().get('signature_token') is not None + + +def test_get_signature_token_unauthorized(client, invalid_user_auth): + response = client.get('auth/signature_token', headers=None) + assert response.status_code == 401 + response = client.get('auth/signature_token', headers=invalid_user_auth) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + 'expires_in, status_code', + [(0, 422), (30 * 60, 200), (2 * 60 * 60, 200), (25 * 60 * 60, 422), (None, 422)]) +def test_get_app_token(client, test_user_auth, expires_in, status_code): + response = client.get('auth/app_token', headers=test_user_auth, + params={'expires_in': expires_in}) + assert response.status_code == status_code + if status_code == 200: + assert response.json().get('app_token') is not None + + +def test_get_app_token_unauthorized(client, invalid_user_auth): + response = client.get('auth/app_token', headers=None, + params={'expires_in': 60}) + assert response.status_code == 401 + response = client.get('auth/app_token', headers=invalid_user_auth, + params={'expires_in': 60}) + assert response.status_code == 401 diff --git a/tests/app/v1/routers/test_users.py b/tests/app/v1/routers/test_users.py index 092ea1379..05d4804a6 100644 --- a/tests/app/v1/routers/test_users.py +++ b/tests/app/v1/routers/test_users.py @@ -18,7 +18,8 @@ # import pytest -from tests.conftest import test_users as conf_test_users, test_user_uuid as conf_test_user_uuid +from tests.conftest import (test_users as conf_test_users, + test_user_uuid as conf_test_user_uuid) def assert_user(user, expected_user): @@ -27,9 +28,11 @@ def assert_user(user, expected_user): assert 'email' not in user -def test_me(client, test_user_auth): +def test_me(client, test_user_auth, app_token_auth): response = client.get('users/me', headers=test_user_auth) assert response.status_code == 200 + response = client.get('users/me', headers=app_token_auth) + assert response.status_code == 200 def test_me_auth_required(client):