diff --git a/server/atheneum/api/authentication_api.py b/server/atheneum/api/authentication_api.py index 410782a..2016aa1 100644 --- a/server/atheneum/api/authentication_api.py +++ b/server/atheneum/api/authentication_api.py @@ -1,13 +1,15 @@ """Authentication API blueprint and endpoint definitions.""" -from flask import Blueprint, g +from flask import Blueprint, g, request from atheneum.api.decorators import return_json from atheneum.api.model import APIMessage, APIResponse from atheneum.middleware import authentication_middleware +from atheneum.model import UserToken from atheneum.service import ( - user_token_service, authentication_service, - user_service + transformation_service, + user_service, + user_token_service, ) AUTH_BLUEPRINT = Blueprint( @@ -23,7 +25,12 @@ def login() -> APIResponse: :return: A login token for continued authentication """ - user_token = user_token_service.create(g.user) + new_token_options: UserToken = transformation_service.deserialize_model( + UserToken, request.json, ['note', 'expirationTime']) + user_token = user_token_service.create( + g.user, + note=new_token_options.note, + expiration_time=new_token_options.expiration_time) return APIResponse(user_token, 200) diff --git a/server/atheneum/middleware/authentication_middleware.py b/server/atheneum/middleware/authentication_middleware.py index 615da90..12abd90 100644 --- a/server/atheneum/middleware/authentication_middleware.py +++ b/server/atheneum/middleware/authentication_middleware.py @@ -79,7 +79,7 @@ def authorization_failed(required_role: str) -> Response: def parse_token_header( header_value: str) -> Optional[Authorization]: """ - Parse the Authorization: Token header for the username and token. + Parse the Authorization: Bearer header for the username and token. :param header_value: :return: @@ -92,13 +92,13 @@ def parse_token_header( auth_type = auth_type.lower() except ValueError: return None - if auth_type == b'token': + if auth_type == b'bearer': try: username, token = base64.b64decode(auth_info).split(b':', 1) except binascii.Error: return None - return Authorization('token', {'username': bytes_to_wsgi(username), - 'password': bytes_to_wsgi(token)}) + return Authorization('bearer', {'username': bytes_to_wsgi(username), + 'password': bytes_to_wsgi(token)}) return None @@ -146,7 +146,7 @@ def require_token_auth(func: Callable) -> Callable: request.headers.get('Authorization', None)) if token and authenticate_with_token(token.username, token.password): return func(*args, **kwargs) - return authentication_failed('Token') + return authentication_failed('Bearer') return decorate diff --git a/server/atheneum/service/transformation_service.py b/server/atheneum/service/transformation_service.py index a5c8acf..2e795b1 100644 --- a/server/atheneum/service/transformation_service.py +++ b/server/atheneum/service/transformation_service.py @@ -36,9 +36,19 @@ class BaseTransformer: return ret def deserialize(self, - json_model: dict, + json_model: Optional[dict], options: Optional[List[str]]) -> Any: - """Convert dict to Model.""" + """ + Convert dict to Model. + + If the dict is None or empty, return an empty model. + + :param json_model: The dict representing the serialized model + :param options: the fields to deserialize + :return: an instance of the model + """ + if json_model is None or not json_model: + return self.model field_factories = self._deserializers() if not options: options = list(field_factories.keys()) @@ -101,7 +111,7 @@ def serialize_model(model_obj: db.Model, def deserialize_model( model_type: Type[db.Model], - json_model_object: dict, + json_model_object: Optional[dict], options: Optional[List[str]] = None) -> db.Model: """Lookup a Model and hand it off to the deserializer.""" try: diff --git a/server/documentation/api/authentication.rst b/server/documentation/api/authentication.rst index 59586d6..1b06c4b 100644 --- a/server/documentation/api/authentication.rst +++ b/server/documentation/api/authentication.rst @@ -51,7 +51,7 @@ Authentication API POST /auth/bump HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer **Example response**: @@ -83,7 +83,7 @@ Authentication API POST /auth/logout HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer **Example response**: diff --git a/server/documentation/api/user.rst b/server/documentation/api/user.rst index 0eea296..8fe00d3 100644 --- a/server/documentation/api/user.rst +++ b/server/documentation/api/user.rst @@ -12,7 +12,7 @@ User API GET /user HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer **Example response**: @@ -65,7 +65,7 @@ User API GET /user/atheneum_administrator HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer **Example response**: @@ -107,7 +107,7 @@ User API PATCH /user/atheneum_administrator HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer Content-Type: application/json { @@ -162,7 +162,7 @@ User API POST /user HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer Content-Type: application/json { @@ -212,7 +212,7 @@ User API DELETE /user/test_user HTTP/1.1 Host: example.tld Accept: application/json - Authorization: Token + Authorization: Bearer **Example response**: diff --git a/server/tests/api/test_authentication_api.py b/server/tests/api/test_authentication_api.py index ba1b5c0..c7a046b 100644 --- a/server/tests/api/test_authentication_api.py +++ b/server/tests/api/test_authentication_api.py @@ -1,3 +1,7 @@ +from datetime import datetime + +import rfc3339 + from tests.conftest import AuthActions @@ -7,6 +11,17 @@ def test_login_happy_path(auth: AuthActions): assert result.json['token'] is not None and len(result.json['token']) > 0 +def test_login_happy_path_with_options(auth: AuthActions): + token_note = 'Test Note' + token_expiration_time = datetime.now() + result = auth.login(token_note, token_expiration_time) + assert result.status_code == 200 + assert result.json['token'] is not None and len(result.json['token']) > 0 + assert result.json['note'] == token_note + assert result.json['expirationTime'] == rfc3339.format( + token_expiration_time) + + def test_bump_happy_path(auth: AuthActions): auth.login() result = auth.bump() diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index 2e7aacb..0659ac2 100644 --- a/server/tests/api/test_user_api.py +++ b/server/tests/api/test_user_api.py @@ -13,7 +13,7 @@ def test_get_users_happy_path(auth: AuthActions, client: FlaskClient): result = client.get( '/user', headers={ - auth_header[0]: auth_header[1] + auth_header.header: auth_header.data }) assert 200 == result.status_code assert result.json is not None @@ -30,7 +30,7 @@ def test_get_users_nonexistent_page(auth: AuthActions, client: FlaskClient): result = client.get( '/user?page=2', headers={ - auth_header[0]: auth_header[1] + auth_header.header: auth_header.data }) assert 404 == result.status_code assert result.json is not None @@ -42,7 +42,7 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient): result = client.get( '/user/{}'.format(client.application.config['test_username']), headers={ - auth_header[0]: auth_header[1] + auth_header.header: auth_header.data }) assert 200 == result.status_code assert result.json is not None @@ -57,7 +57,7 @@ def test_patch_user_happy_path(auth: AuthActions, client: FlaskClient): user = client.get( '/user/{}'.format(client.application.config['test_username']), headers={ - auth_header[0]: auth_header[1] + auth_header.header: auth_header.data }) patched_user = client.patch( @@ -67,7 +67,7 @@ def test_patch_user_happy_path(auth: AuthActions, client: FlaskClient): 'lastLoginTime': last_login_time }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) @@ -85,7 +85,7 @@ def test_register_user_happy_path(auth: AuthActions, client: FlaskClient): 'name': 'test_registered_user' }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) assert 200 == result.status_code @@ -104,7 +104,7 @@ def test_register_user_invalid_password( 'password': '' }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) assert 400 == result.status_code @@ -121,7 +121,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient): 'name': 'test_registered_user' }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) result2 = client.post( @@ -130,7 +130,7 @@ def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient): 'name': 'test_registered_user' }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) assert 200 == result1.status_code @@ -150,13 +150,13 @@ def test_delete_user_happy_path(auth: AuthActions, client: FlaskClient): 'name': 'test_registered_user' }), headers={ - auth_header[0]: auth_header[1], + auth_header.header: auth_header.data, 'Content-Type': 'application/json' }) result2 = client.delete( '/user/'+result1.json['name'], headers={ - auth_header[0]: auth_header[1] + auth_header.header: auth_header.data }) assert 200 == result1.status_code assert result1.json is not None diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 243f02b..9bd7c2d 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -3,10 +3,13 @@ import os import random import string import tempfile -from typing import Tuple, Any +from collections import namedtuple +from datetime import datetime +from typing import Tuple, Any, Optional import pytest -from flask import Flask +import rfc3339 +from flask import Flask, json from flask.testing import FlaskClient, FlaskCliRunner from werkzeug.test import Client @@ -66,6 +69,9 @@ def runner(app: Flask) -> FlaskCliRunner: return app.test_cli_runner() +AuthorizationHeader = namedtuple('AuthorizationHeader', ['header', 'data']) + + class AuthActions(object): def __init__(self, client: Client, @@ -81,13 +87,31 @@ class AuthActions(object): self.password = password return self - def login(self) -> Any: + def login( + self, + note: Optional[str] = None, + expiration_time: Optional[datetime] = None) -> Any: auth_header = self.get_authorization_header_basic() + auth_json = None + + request_headers = { + auth_header.header: auth_header.data + } + + if note is not None or expiration_time is not None: + token_options = {} + if note is not None: + token_options['note'] = note + if expiration_time is not None: + token_options['expirationTime'] = rfc3339.format( + expiration_time) + auth_json = json.dumps(token_options) + request_headers['Content-Type'] = 'application/json' + result = self._client.post( '/auth/login', - headers={ - auth_header[0]: auth_header[1] - } + headers=request_headers, + data=auth_json ) self.token = result.json['token'] return result @@ -110,17 +134,19 @@ class AuthActions(object): } ) - def get_authorization_header_basic(self) -> Tuple[str, str]: + def get_authorization_header_basic(self) -> AuthorizationHeader: credentials = base64.b64encode( '{}:{}'.format(self.username, self.password).encode('utf8')) \ .decode('utf8').strip() - return 'Authorization', 'Basic {}'.format(credentials) + return AuthorizationHeader( + 'Authorization', 'Basic {}'.format(credentials)) - def get_authorization_header_token(self) -> Tuple[str, str]: + def get_authorization_header_token(self) -> AuthorizationHeader: credentials = base64.b64encode( '{}:{}'.format(self.username, self.token).encode('utf8')) \ .decode('utf8').strip() - return 'Authorization', 'Token {}'.format(credentials) + return AuthorizationHeader( + 'Authorization', 'Bearer {}'.format(credentials)) @pytest.fixture