Skip to content

Commit

Permalink
Merge branch '1654-application-token' into 'develop'
Browse files Browse the repository at this point in the history
Resolve "Application Token"

Closes #1654

See merge request nomad-lab/nomad-FAIR!1460
  • Loading branch information
Sideboard committed Sep 19, 2023
2 parents def05d2 + 8713445 commit 4a32027
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 46 deletions.
15 changes: 15 additions & 0 deletions docs/apis/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access token>`. The API will return
an app token, which is valid for 24 hours in subsequent request headers with the format
`Authorization: Bearer <app token>`. 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
Expand Down
117 changes: 83 additions & 34 deletions nomad/app/v1/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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('''
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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 <app token>`.
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'),
Expand Down
4 changes: 4 additions & 0 deletions nomad/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
29 changes: 19 additions & 10 deletions tests/app/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)}


Expand Down
27 changes: 27 additions & 0 deletions tests/app/v1/routers/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions tests/app/v1/routers/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down

0 comments on commit 4a32027

Please sign in to comment.