diff --git a/north/app/main.py b/north/app/main.py index cdba8ead14d7f7cb2c8d7a7f3afdd618cb91a8ec..ba52e61742a0e21e7911ec0d7ecdddcd3237b0a7 100644 --- a/north/app/main.py +++ b/north/app/main.py @@ -25,6 +25,7 @@ from north import config from .routes import instances from .routes import tools +from .routes import auth app = FastAPI( title='NOMAD remote tools hub API', @@ -35,6 +36,7 @@ app = FastAPI( app.include_router(tools.router, prefix='/tools') app.include_router(instances.router, prefix='/instances') +app.include_router(auth.router, prefix='/auth') # A default 404 response with a link to the API dashboard for convinience diff --git a/north/app/models.py b/north/app/models.py index eca8c02e28d2a929072ab1303e820632bd3972fe..404f92fa827642b079ea837b5d1b6ce1e68cb18a 100644 --- a/north/app/models.py +++ b/north/app/models.py @@ -16,7 +16,7 @@ # limitations under the License. # -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Any from pydantic import BaseModel, validator, Field # TODO This exemplifies pydantic models a little bit. But, this is just for demonstration. @@ -55,3 +55,9 @@ class InstanceModel(BaseModel): def validate_tool_name(cls, tool_name): # pylint: disable=no-self-argument assert tool_name in tools_map, 'Tool must exist.' return tool_name + + +class TokenModel(BaseModel): + token: str + claims: Optional[Dict[str, Any]] + exprires: Optional[int] diff --git a/north/app/routes/auth.py b/north/app/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..026727d9a6e52f3e7bdbb06c277c457f2dd3468c --- /dev/null +++ b/north/app/routes/auth.py @@ -0,0 +1,86 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from north.app.models import TokenModel +from typing import Optional +from fastapi import APIRouter, Request +from fastapi.exceptions import HTTPException +from fastapi.params import Depends + +from north.auth import create_token, verify_token, TokenError + +router = APIRouter() +router_tag = 'auth' + + +def token(request: Request) -> Optional[TokenModel]: + authorization = request.headers.get('Authorization') + if authorization is None: + return None + + bearer, token = authorization.split(' ', 1) + if bearer != 'Bearer': + raise HTTPException(status_code=401, detail='Invalid authentication scheme.') + + try: + return verify_token(token, claims=['channel']) + except TokenError as e: + raise HTTPException(status_code=401, detail=e.msg) from e + + +def token_required(request: Request): + authorization = request.headers.get('Authorization') + if authorization is None: + raise HTTPException(status_code=401, detail='Authorization is required.') + + return token(request) + + +@router.get( + '/channel/{channel}', + tags=[router_tag]) +async def authorize_channel(channel: int, token=Depends(token_required)): + ''' + Verifies channel token in the header and either raises HTTP 200 or 401. + This is used by a proxy to authorize access to a docker container channel before + relaying requests via that channel. + ''' + if 'channel' not in token.claims: + raise HTTPException( + status_code=401, detail='Need to authorize with a channel token.') + + if token.claims['channel'] != channel: + raise HTTPException( + status_code=401, detail='You are not authorized to access this channel') + + return 'authorized' + + +@router.post( + '/refresh', + tags=[router_tag], + response_model=TokenModel, + response_model_exclude_unset=True, + response_model_exclude_none=True) +async def refresh_token(token: TokenModel): + ''' + Returns a new token. It will have the same claims as the given token, but a new + expiry time. + ''' + token = verify_token(token.token, claims=None) + return create_token(claims=token.claims) diff --git a/north/app/routes/instances.py b/north/app/routes/instances.py index a3b8a3550ef43c4e63795249db64f4434661dfe4..081adb1f8179cc29c92543005ff1ac0ed205f130 100644 --- a/north/app/routes/instances.py +++ b/north/app/routes/instances.py @@ -18,7 +18,9 @@ from typing import List from fastapi import APIRouter +from fastapi.params import Depends +from north.app.routes.auth import token from north.app.models import InstanceModel router = APIRouter() @@ -42,6 +44,8 @@ async def get_instances(): response_model=InstanceModel, response_model_exclude_unset=True, response_model_exclude_none=True) -async def post_instances(instance: InstanceModel): +async def post_instances( + instance: InstanceModel, token=Depends(token)): ''' Create a new tool instance. ''' + return instance diff --git a/north/auth.py b/north/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..189c49d4917039a173af2b1d7448fbde7c86fe15 --- /dev/null +++ b/north/auth.py @@ -0,0 +1,119 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +''' +This module provide the necessary token create/verify functionality. Currently there +are two types of tokens, launch tokens and channel tokens. + +The channel token allows to authorize access to a running container. The container is +accessed through a numbered channel via the north proxy. The token will be issued +when creating the instances. + +The launch token allows to authorize that a container can be created and that certain +resources can be accessed through the container. The launch token might be optional. +In the NOMAD Oasis use-case, launch tokens might be issued by the NOMAD API to authorize +clients to create containers that access a users files and directories. + +All tokens can exprire. Token can be re-issued by the north auth API. +''' + +from north.app.models import TokenModel +from typing import List, Dict, Any, Optional +import jwt +from datetime import datetime, timedelta + +from north import config + + +class TokenError(Exception): + def __init__(self, msg): + super().__init__(msg) + self.msg = msg + + +def create_launch_token(paths: List[str], duration: timedelta = timedelta(minutes=10)) -> TokenModel: + ''' + Creates a JWT token that contains a claim on a set of paths and is valid for the given + duration. + + This could be issued for example by the NOMAD uploads API after authorizing access + to certain paths. It could be used by 3rd-party products to authorize access to + certain paths. + + # TODO more complex claims. E.g. Read/Write access, tool to launch, etc. + + Returns: The token as a string. + ''' + + claims = { + 'paths': paths + } + return create_token(claims, duration) + + +def create_channel_token(channel: int, duration: timedelta = timedelta(minutes=10)) -> TokenModel: + ''' + Creates a JWT token that contains a claim on the given channel for the given + duration starting now. + + Returns: The token as a string. + ''' + + claims = { + 'channel': channel + } + return create_token(claims, duration) + + +def create_token(claims: Optional[Dict[str, Any]], duration: timedelta = timedelta(minutes=10)) -> TokenModel: + ''' Creates a JWT token with the given claims. ''' + claims_to_encode = dict(exp=datetime.utcnow() + duration, **claims) + token = jwt.encode(claims_to_encode, config.secret, algorithm='HS256') + + return TokenModel(token=token, expires=duration.seconds, claims=claims) + + +def verify_token(token: str, claims: List[str] = None) -> TokenModel: + ''' + Verifies the given token. Checks its expiry and if the given claims are present. + + Returns: A dictionary with the given claims as keys and values taken from the token + payload. + + Raises: + TokenError: If the token could not be decoded, is experied, or did not contain + the necessary claims. + ''' + + try: + encoded_claims = jwt.decode( + token, config.secret, algorithms=['HS256'], + options=dict(require=['exp'] + claims if claims else [])) + except jwt.ExpiredSignatureError as e: + raise TokenError('The token is expired.') from e + except jwt.DecodeError as e: + raise TokenError('The token could not be decoded.') from e + + expires = datetime.utcfromtimestamp(encoded_claims['exp']) - datetime.utcnow() + token_claims = None + if claims is not None: + token_claims = { + key: value for key, value in encoded_claims.items() if key in claims + } + + return TokenModel(token=token, claims=token_claims, expires=expires.seconds) diff --git a/requirements.txt b/requirements.txt index 4fca4c4feab7c21db14d7545bbf70176bc15d103..42b5e0dab4b37573b8403fdc4441221a43537852 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ types-PyYAML types-requests uvicorn docker +PyJWT diff --git a/tests/app/routes/test_auth.py b/tests/app/routes/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..12eefabaf6e6da9a9ee554ec7fbcd9cdf1863f70 --- /dev/null +++ b/tests/app/routes/test_auth.py @@ -0,0 +1,49 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from datetime import timedelta + +from north.auth import create_channel_token + + +def test_valid_channel(api): + token = create_channel_token(channel=1) + response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}')) + assert response.status_code == 200 + + +def test_wrong_channel(api): + token = create_channel_token(channel=2) + response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}')) + assert response.status_code == 401 + + +def test_missing_channel_token(api): + response = api.get('auth/channel/1') + assert response.status_code == 401 + + +def test_bad_channel_token(api): + response = api.get('auth/channel/1', headers=dict(Authorization='Bearer not a token')) + assert response.status_code == 401 + + +def test_expired_channel_token(api): + token = create_channel_token(channel=1, duration=-timedelta(days=1)) + response = api.get('auth/channel/1', headers=dict(Authorization=f'Bearer {token.token}')) + assert response.status_code == 401 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..f06fb4d6e8ccc7b93728342183b61ca7d578324b --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,40 @@ +# +# Copyright The NOMAD Authors. +# +# This file is part of NOMAD. See https://nomad-lab.eu for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from datetime import timedelta +import pytest + +from north.auth import TokenError, create_channel_token, create_launch_token, create_token, verify_token + + +def test_channel_token(): + token = create_channel_token(channel=1) + token_model = verify_token(token.token, claims=['channel']) + assert token_model.claims['channel'] == 1 + + +def test_launch_token(): + token = create_launch_token(paths=['/my/folder']) + token_model = verify_token(token.token, claims=['paths']) + assert token_model.claims['paths'] == ['/my/folder'] + + +def test_token_expiry(): + token = create_token(claims={}, duration=-timedelta(days=1)) + with pytest.raises(TokenError): + verify_token(token.token, claims=[])