From 1bccbc413cdff4a9dfbda23f726ea94566fc6678 Mon Sep 17 00:00:00 2001 From: Drew Short Date: Sun, 8 Jul 2018 02:43:29 -0500 Subject: [PATCH] Added error handlers to return APIResponse objects as JSON * Added endpoint to register users * Added decorator to require roles for endpoints * Refactored and cleaned up code --- server/Dockerfile | 23 ++ server/LICENSE | 201 ++++++++++++++++++ server/atheneum/__init__.py | 17 +- server/atheneum/api/decorators.py | 2 +- server/atheneum/api/model.py | 14 +- server/atheneum/api/user_api.py | 29 ++- server/atheneum/errors.py | 36 +++- .../middleware/authentication_middleware.py | 29 ++- .../service/transformation_service.py | 2 +- server/atheneum/service/user_service.py | 32 ++- server/atheneum/utility/json_utility.py | 11 +- server/tests/api/test_user_api.py | 47 ++++ server/tests/conftest.py | 8 +- 13 files changed, 419 insertions(+), 32 deletions(-) create mode 100644 server/Dockerfile create mode 100644 server/LICENSE diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..582ded4 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.6-slim-stretch +MAINTAINER Drew Short + +ENV ATHENEUM_APP_DIRECTORY /opt/atheneum +ENV ATHENEUM_CONFIG_DIRECTORY /srv/atheneum/config +ENV ATHENEUM_DATA_DIRECTORY /srv/atheneum/data + +RUN mkdir -p ${ATHENEUM_APP_DIRECTORY} \ +&& mkdir -p ${ATHENEUM_CONFIG_DIRECTORY} \ +&& mkdir -p ${ATHENEUM_DATA_DIRECTORY} \ +&& pip install pipenv gunicorn + +VOLUME ${ATHENEUM_CONFIG_DIRECTORY} +VOLUME ${ATHENEUM_DATA_DIRECTORY} + +COPY ./server/ ${ATHENEUM_APP_DIRECTORY}/ + +RUN cd ${ATHENEUM_APP_DIRECTORY} \ +&& pipenv install --system --deploy --ignore-pipfile + +WORKDIR ${ATHENEUM_APP_DIRECTORY} + +CMD ./entrypoint.sh diff --git a/server/LICENSE b/server/LICENSE new file mode 100644 index 0000000..4eb2652 --- /dev/null +++ b/server/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Drew Short + + 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. diff --git a/server/atheneum/__init__.py b/server/atheneum/__init__.py index 7432e8c..e22228e 100644 --- a/server/atheneum/__init__.py +++ b/server/atheneum/__init__.py @@ -6,6 +6,7 @@ from flask import Flask from flask_migrate import Migrate from atheneum.db import db +from atheneum.errors import BaseError, handle_atheneum_base_error from atheneum.utility import json_utility, session_utility dictConfig({ @@ -42,7 +43,8 @@ def create_app(test_config: dict = None) -> Flask: app.config.from_mapping( SECRET_KEY='dev', SQLALCHEMY_DATABASE_URI=default_database_uri, - SQLALCHEMY_TRACK_MODIFICATIONS=False + SQLALCHEMY_TRACK_MODIFICATIONS=False, + TRAP_HTTP_EXCEPTIONS=True, ) if test_config is None: @@ -53,7 +55,7 @@ def create_app(test_config: dict = None) -> Flask: app.config.from_envvar('ATHENEUM_SETTINGS') else: app.logger.debug('Loading test configuration') - app.config.from_object(test_config) + app.config.from_mapping(test_config) try: os.makedirs(app.instance_path) @@ -83,8 +85,19 @@ def register_blueprints(app: Flask) -> None: app.register_blueprint(USER_BLUEPRINT) +def register_error_handlers(app: Flask) -> None: + """ + Register error handlers for the application. + + :param app: + :return: + """ + app.register_error_handler(BaseError, handle_atheneum_base_error) + + atheneum = create_app() # pylint: disable=C0103 register_blueprints(atheneum) +register_error_handlers(atheneum) logger = atheneum.logger # pylint: disable=C0103 diff --git a/server/atheneum/api/decorators.py b/server/atheneum/api/decorators.py index 4ae07c3..0911678 100644 --- a/server/atheneum/api/decorators.py +++ b/server/atheneum/api/decorators.py @@ -27,7 +27,7 @@ def return_json(func: Callable) -> Callable: if isinstance(result, Response): return result if isinstance(result, APIResponse): - return jsonify(result.payload), result.status + return jsonify(result), result.status return jsonify(result) return decorate diff --git a/server/atheneum/api/model.py b/server/atheneum/api/model.py index a114d68..fb84b49 100644 --- a/server/atheneum/api/model.py +++ b/server/atheneum/api/model.py @@ -1,9 +1,15 @@ """Model definitions for the api module.""" -from typing import Any, NamedTuple +from typing import Any, List, Optional -class APIResponse(NamedTuple): # pylint: disable=too-few-public-methods +class APIResponse: # pylint: disable=too-few-public-methods """Custom class to wrap api responses.""" - payload: Any - status: int + def __init__(self, + payload: Any, + status: int = 200, + options: Optional[List[str]] = None) -> None: + """Construct an APIResponse object.""" + self.payload = payload + self.status = status + self.options = options diff --git a/server/atheneum/api/user_api.py b/server/atheneum/api/user_api.py index e3aa8c5..d117d44 100644 --- a/server/atheneum/api/user_api.py +++ b/server/atheneum/api/user_api.py @@ -1,10 +1,11 @@ """User API blueprint and endpoint definitions.""" -from flask import Blueprint, abort +from flask import Blueprint, abort, request from atheneum.api.decorators import return_json from atheneum.api.model import APIResponse from atheneum.middleware import authentication_middleware -from atheneum.service import user_service +from atheneum.model import User +from atheneum.service import user_service, transformation_service USER_BLUEPRINT = Blueprint( name='user', import_name=__name__, url_prefix='/user') @@ -15,11 +16,31 @@ USER_BLUEPRINT = Blueprint( @authentication_middleware.require_token_auth def get_user(name: str) -> APIResponse: """ - Get a token for continued authentication. + Get a user. - :return: A login token for continued authentication + :return: user if exists, else 404 """ user = user_service.find_by_name(name) if user is not None: return APIResponse(user, 200) return abort(404) + + +@USER_BLUEPRINT.route('/', methods=['POST']) +@return_json +@authentication_middleware.require_token_auth +@authentication_middleware.require_role(required_role=User.ROLE_ADMIN) +def register_user() -> APIResponse: + """ + Register a user with the service. + + :return: The newly registered User + """ + new_user: User = transformation_service.deserialize_model( + User.__name__, request.json) + registered_user = user_service.register( + name=new_user.name, + password=None, + role=new_user.role + ) + return APIResponse(payload=registered_user, status=200) diff --git a/server/atheneum/errors.py b/server/atheneum/errors.py index e5a89f3..5a579ba 100644 --- a/server/atheneum/errors.py +++ b/server/atheneum/errors.py @@ -1,20 +1,50 @@ """Error definitions for Atheneum.""" from typing import Dict +from atheneum.api.decorators import return_json +from atheneum.api.model import APIResponse -class BaseError(RuntimeError): - """Atheneum Base Error Class.""" + +class BaseError(Exception): + """Atheneum Base Error Class (5xx errors).""" def __init__( self, message: str = 'Unknown error', + status_code: int = 500, extra_fields: Dict[str, str] = None) -> None: """Populate The Error Definition.""" super().__init__(message) + self.message = message + self.status_code = status_code self.extra_fields = extra_fields + def to_dict(self) -> dict: + """Serialize an error message to return.""" + return { + 'message': self.message, + 'status_code': self.status_code + } + + +class ClientError(BaseError): + """Atheneum errors where the client is wrong (4xx errors).""" + + def __init__(self, + message: str = 'Unknown client error', + status_code: int = 400, + extra_fields: Dict[str, str] = None) -> None: + """Init for client originated errors.""" + super().__init__(message, status_code, extra_fields) -class ValidationError(BaseError): + +class ValidationError(ClientError): """Atheneum Validation Error.""" pass + + +@return_json +def handle_atheneum_base_error(error: BaseError) -> APIResponse: + """Error handler for basic Atheneum raised errors.""" + return APIResponse(payload=error, status=error.status_code) diff --git a/server/atheneum/middleware/authentication_middleware.py b/server/atheneum/middleware/authentication_middleware.py index 8051263..92538c3 100644 --- a/server/atheneum/middleware/authentication_middleware.py +++ b/server/atheneum/middleware/authentication_middleware.py @@ -1,10 +1,10 @@ """Middleware to handle authentication.""" import base64 +import binascii from functools import wraps from typing import Optional, Callable, Any -import binascii -from flask import request, Response, g +from flask import request, Response, g, json from werkzeug.datastructures import Authorization from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes @@ -64,6 +64,17 @@ def authentication_failed(auth_type: str) -> Response: }) +def authorization_failed(required_role: str) -> Response: + """Return a correct response for failed authorization.""" + return Response( + status=401, + response=json.dumps({ + 'message': '{} role not present'.format(required_role) + }), + content_type='application/json' + ) + + def parse_token_header( header_value: str) -> Optional[Authorization]: """ @@ -137,3 +148,17 @@ def require_token_auth(func: Callable) -> Callable: return authentication_failed('Bearer') return decorate + + +def require_role(required_role: str) -> Callable: + """Decorate require user role.""" + def required_role_decorator(func: Callable) -> Callable: + """Decorate the function.""" + @wraps(func) + def decorate(*args: list, **kwargs: dict) -> Any: + """Require a user role.""" + if g.user.role == required_role: + return func(*args, **kwargs) + return authorization_failed(required_role) + return decorate + return required_role_decorator diff --git a/server/atheneum/service/transformation_service.py b/server/atheneum/service/transformation_service.py index ac71b44..b86908a 100644 --- a/server/atheneum/service/transformation_service.py +++ b/server/atheneum/service/transformation_service.py @@ -90,7 +90,7 @@ def serialize_model(model_obj: db.Model, def deserialize_model( model_type: str, json_model_object: dict, - options: Optional[List[str]] = None) -> Type[db.Model]: + options: Optional[List[str]] = None) -> db.Model: """Lookup a Model and hand it off to the deserializer.""" try: transformer = _model_transformers[model_type] diff --git a/server/atheneum/service/user_service.py b/server/atheneum/service/user_service.py index 18709e2..5e66df6 100644 --- a/server/atheneum/service/user_service.py +++ b/server/atheneum/service/user_service.py @@ -1,8 +1,11 @@ """Service to handle user operations.""" import logging +import random +import string from datetime import datetime from typing import Optional, Dict, Callable, Any +from atheneum import errors from atheneum.db import db from atheneum.model import User from atheneum.service.transformation_service import ( @@ -91,7 +94,17 @@ class UserTransformer(BaseTransformer): register_transformer(User.__name__, UserTransformer) -def register(name: str, password: str, role: str) -> User: +def find_by_name(name: str) -> Optional[User]: + """ + Find a user by name. + + :param name: + :return: + """ + return User.query.filter_by(name=name).first() + + +def register(name: str, password: Optional[str], role: Optional[str]) -> User: """ Register a new user. @@ -100,6 +113,13 @@ def register(name: str, password: str, role: str) -> User: :param role: Role to assign the user [ROLE_USER, ROLE_ADMIN] :return: """ + password = password if password is not None else ''.join( + random.choices(string.ascii_letters + string.digits, k=32)) + role = role if role is not None else User.ROLE_USER + + if find_by_name(name=name) is not None: + raise errors.ValidationError('User name is already taken.') + pw_hash, pw_revision = authentication_utility.get_password_hash(password) new_user = User( @@ -155,13 +175,3 @@ def update_password(user: User, password: str) -> None: user.password_hash = pw_hash user.password_revision = pw_revision db.session.commit() - - -def find_by_name(name: str) -> Optional[User]: - """ - Find a user by name. - - :param name: - :return: - """ - return User.query.filter_by(name=name).first() diff --git a/server/atheneum/utility/json_utility.py b/server/atheneum/utility/json_utility.py index 807a012..c527a26 100644 --- a/server/atheneum/utility/json_utility.py +++ b/server/atheneum/utility/json_utility.py @@ -5,18 +5,27 @@ from typing import Any import rfc3339 from flask.json import JSONEncoder +from atheneum.api.model import APIResponse from atheneum.db import db +from atheneum.errors import BaseError from atheneum.service.transformation_service import serialize_model class CustomJSONEncoder(JSONEncoder): """Ensure that datetime values are serialized correctly.""" - def default(self, o: Any) -> Any: # pylint: disable=E0202 + def default(self, o: Any) -> Any: # pylint: disable=E0202,R0911 """Handle encoding date and datetime objects according to rfc3339.""" try: if isinstance(o, date): return rfc3339.format(o) + if isinstance(o, APIResponse): + payload = o.payload + if isinstance(payload, db.Model): + return serialize_model(o.payload, o.options) + if isinstance(payload, BaseError): + return payload.to_dict() + return payload if isinstance(o, db.Model): return serialize_model(o) iterable = iter(o) diff --git a/server/tests/api/test_user_api.py b/server/tests/api/test_user_api.py index 135f896..edcc02f 100644 --- a/server/tests/api/test_user_api.py +++ b/server/tests/api/test_user_api.py @@ -1,3 +1,4 @@ +from flask import json from flask.testing import FlaskClient from tests.conftest import AuthActions @@ -14,3 +15,49 @@ def test_get_user_happy_path(auth: AuthActions, client: FlaskClient): assert result.status_code == 200 assert result.json is not None assert result.json['name'] == client.application.config['test_username'] + + +def test_register_user_happy_path(auth: AuthActions, client: FlaskClient): + auth.login() + auth_header = auth.get_authorization_header_token() + result = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert result.status_code == 200 + assert result.json is not None + assert result.json['name'] == 'test_registered_user' + + +def test_register_user_twice_failure(auth: AuthActions, client: FlaskClient): + auth.login() + auth_header = auth.get_authorization_header_token() + result1 = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + result2 = client.post( + '/user/', + data=json.dumps({ + 'name': 'test_registered_user' + }), + headers={ + auth_header[0]: auth_header[1], + 'Content-Type': 'application/json' + }) + assert result1.status_code == 200 + assert result1.json is not None + assert result1.json['name'] == 'test_registered_user' + assert result2.status_code == 400 + assert result2.json is not None + assert result2.json['message'] == 'User name is already taken.' diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 1917a81..9b6310d 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -10,7 +10,7 @@ from flask import Flask from flask.testing import FlaskClient, FlaskCliRunner from werkzeug.test import Client -from atheneum import create_app, register_blueprints +from atheneum import create_app, register_blueprints, register_error_handlers from atheneum.db import init_db from atheneum.model import User from atheneum.service import user_service @@ -29,13 +29,15 @@ def add_test_user() -> Tuple[str, str]: def app() -> Flask: """Create and configure a new atheneum_app instance for each test.""" # create a temporary file to isolate the database for each test - db_fd, db_path = tempfile.mkstemp() + db_fd, db_path = tempfile.mkstemp(suffix='.db') + test_database_uri = 'sqlite:///{}'.format(db_path) # create the atheneum_app with common test config atheneum_app = create_app({ 'TESTING': True, - 'DATABASE': db_path, + 'SQLALCHEMY_DATABASE_URI': test_database_uri, }) register_blueprints(atheneum_app) + register_error_handlers(atheneum_app) # create the database and load test data with atheneum_app.app_context():