diff --git a/server/corvus/api/authentication_api.py b/server/corvus/api/authentication_api.py index 22b477c..64cd454 100644 --- a/server/corvus/api/authentication_api.py +++ b/server/corvus/api/authentication_api.py @@ -1,5 +1,5 @@ """Authentication API blueprint and endpoint definitions.""" -from flask import Blueprint, g +from flask import Blueprint, g, abort, request from corvus.api.decorators import return_json from corvus.api.model import APIMessage, APIResponse @@ -7,8 +7,12 @@ from corvus.middleware import authentication_middleware from corvus.service import ( user_token_service, authentication_service, - user_service + user_service, + transformation_service ) +from corvus.middleware.authentication_middleware import Auth +from corvus.service.role_service import Role +from corvus.model import UserToken AUTH_BLUEPRINT = Blueprint( name='auth', import_name=__name__, url_prefix='/auth') @@ -16,7 +20,7 @@ AUTH_BLUEPRINT = Blueprint( @AUTH_BLUEPRINT.route('/login', methods=['POST']) @return_json -@authentication_middleware.require_basic_auth +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) def login() -> APIResponse: """ Get a token for continued authentication. @@ -29,7 +33,7 @@ def login() -> APIResponse: @AUTH_BLUEPRINT.route('/bump', methods=['POST']) @return_json -@authentication_middleware.require_token_auth +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) def login_bump() -> APIResponse: """ Update the user last seen timestamp. @@ -42,7 +46,7 @@ def login_bump() -> APIResponse: @AUTH_BLUEPRINT.route('/logout', methods=['POST']) @return_json -@authentication_middleware.require_token_auth +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) def logout() -> APIResponse: """ Logout and delete a token. @@ -51,3 +55,42 @@ def logout() -> APIResponse: """ authentication_service.logout(g.user_token) return APIResponse(APIMessage(True, None), 200) + + +@AUTH_BLUEPRINT.route('/token', methods=['GET']) +@return_json +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) +def get_tokens() -> APIResponse: + user_tokens = user_token_service.find_by_user(g.user) + return APIResponse(user_tokens, 200) + + +@AUTH_BLUEPRINT.route('/token', methods=['POST']) +@return_json +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) +def create_token(): + requested_token: UserToken = transformation_service.deserialize_model( + UserToken, request.json, options=['note', 'enabled', 'expirationTime']) + user_token = user_token_service.create(g.user, requested_token.note, requested_token.enabled, requested_token.expiration_time) + return APIResponse(user_token, 200) + + +@AUTH_BLUEPRINT.route('/token/', methods=['GET']) +@return_json +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) +def get_token(token: str): + user_token = user_token_service.find_by_user_and_token(g.user, token) + if user_token is None: + return abort(404) + return APIResponse(user_token, 200) + + +@AUTH_BLUEPRINT.route('/token/', methods=['DELETE']) +@return_json +@authentication_middleware.require(required_auth=Auth.BASIC, required_role=Role.USER) +def delete_token(token: str): + user_token = user_token_service.find_by_user_and_token(g.user, token) + if user_token is None: + return abort(404) + user_token_service.delete(user_token) + return APIResponse(None, 200) diff --git a/server/corvus/api/user_api.py b/server/corvus/api/user_api.py index fcae864..e883426 100644 --- a/server/corvus/api/user_api.py +++ b/server/corvus/api/user_api.py @@ -5,6 +5,7 @@ from flask import Blueprint, abort, request, g from corvus.api.decorators import return_json from corvus.api.model import APIResponse, APIMessage, APIPage from corvus.middleware import authentication_middleware +from corvus.middleware.authentication_middleware import Auth from corvus.model import User from corvus.service import ( patch_service, @@ -21,8 +22,7 @@ USER_BLUEPRINT = Blueprint( @USER_BLUEPRINT.route('', methods=['GET']) @return_json -@authentication_middleware.require_token_auth -@authentication_middleware.require_role(required_role=Role.USER) +@authentication_middleware.require(required_auth=Auth.TOKEN, required_role=Role.USER) def get_users() -> APIResponse: """ Get a list of users. @@ -38,8 +38,7 @@ def get_users() -> APIResponse: @USER_BLUEPRINT.route('/', methods=['GET']) @return_json -@authentication_middleware.require_token_auth -@authentication_middleware.require_role(required_role=Role.USER) +@authentication_middleware.require(required_auth=Auth.TOKEN, required_role=Role.USER) def get_user(name: str) -> APIResponse: """ Get a user. @@ -54,8 +53,7 @@ def get_user(name: str) -> APIResponse: @USER_BLUEPRINT.route('/', methods=['PATCH']) @return_json -@authentication_middleware.require_token_auth -@authentication_middleware.require_role(required_role=Role.USER) +@authentication_middleware.require(required_auth=Auth.TOKEN, required_role=Role.USER) def patch_user(name: str) -> APIResponse: """ Patch a user. @@ -74,8 +72,7 @@ def patch_user(name: str) -> APIResponse: @USER_BLUEPRINT.route('', methods=['POST']) @return_json -@authentication_middleware.require_token_auth -@authentication_middleware.require_role(required_role=Role.ADMIN) +@authentication_middleware.require(required_auth=Auth.TOKEN, required_role=Role.ADMIN) def register_user() -> APIResponse: """ Register a user with the service. @@ -99,8 +96,7 @@ def register_user() -> APIResponse: @USER_BLUEPRINT.route('/', methods=['DELETE']) @return_json -@authentication_middleware.require_token_auth -@authentication_middleware.require_role(required_role=Role.ADMIN) +@authentication_middleware.require(required_auth=Auth.TOKEN, required_role=Role.ADMIN) def delete_user(name: str) -> APIResponse: """ Delete a user with the service. diff --git a/server/corvus/middleware/authentication_middleware.py b/server/corvus/middleware/authentication_middleware.py index 320f57b..29ef868 100644 --- a/server/corvus/middleware/authentication_middleware.py +++ b/server/corvus/middleware/authentication_middleware.py @@ -1,6 +1,7 @@ """Middleware to handle authentication.""" import base64 import binascii +from enum import Enum from functools import wraps from typing import Optional, Callable, Any @@ -8,6 +9,7 @@ from flask import request, Response, g, json from werkzeug.datastructures import Authorization from werkzeug.http import bytes_to_wsgi, wsgi_to_bytes +from corvus.api.model import APIMessage from corvus.service import ( authentication_service, user_service, @@ -16,6 +18,14 @@ from corvus.service import ( from corvus.service.role_service import ROLES, Role +class Auth(Enum): + """Authentication scheme definitions.""" + + TOKEN = 'TOKEN' + BASIC = 'BASIC' + NONE = 'NONE' + + def authenticate_with_password(name: str, password: str) -> bool: """ Authenticate a username and a password. @@ -86,19 +96,13 @@ def parse_token_header( """ if not header_value: return None - value = wsgi_to_bytes(header_value) + auth_info = wsgi_to_bytes(header_value) try: - auth_type, auth_info = value.split(None, 1) - auth_type = auth_type.lower() - except ValueError: + username, token = base64.b64decode(auth_info).split(b':', 1) + except binascii.Error: return None - if auth_type == b'token': - 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('token', {'username': bytes_to_wsgi(username), + 'password': bytes_to_wsgi(token)}) return None @@ -143,7 +147,7 @@ def require_token_auth(func: Callable) -> Callable: :return: """ token = parse_token_header( - request.headers.get('Authorization', None)) + request.headers.get('X-Auth-Token', None)) if token and authenticate_with_token(token.username, token.password): return func(*args, **kwargs) return authentication_failed('Token') @@ -152,7 +156,12 @@ def require_token_auth(func: Callable) -> Callable: def require_role(required_role: Role) -> Callable: - """Decorate require user role.""" + """ + Decorate require a user role. + + :param required_role: + :return: + """ def required_role_decorator(func: Callable) -> Callable: """Decorate the function.""" @wraps(func) @@ -163,3 +172,31 @@ def require_role(required_role: Role) -> Callable: return authorization_failed(required_role.value) return decorate return required_role_decorator + + +def require(required_auth: Auth, required_role: Role) -> Callable: + """ + Decorate require Auth and Role + + :param required_auth: + :param required_role: + :return: + """ + def require_decorator(func: Callable) -> Callable: + @wraps(func) + def decorate(*args: list, **kwargs: dict) -> Any: + f = require_role(required_role)(func) + if required_auth == Auth.BASIC: + f = require_basic_auth(f) + elif required_auth == Auth.TOKEN: + f = require_token_auth(f) + else: + Response( + response=APIMessage( + message="Unexpected Server Error", + success=False + ), + status=500) + return f(*args, **kwargs) + return decorate + return require_decorator diff --git a/server/corvus/service/transformation_service.py b/server/corvus/service/transformation_service.py index 48526a1..8b7daaa 100644 --- a/server/corvus/service/transformation_service.py +++ b/server/corvus/service/transformation_service.py @@ -53,8 +53,7 @@ class BaseTransformer: if value is not None: factory(self.model, value) except KeyError as key_error: - LOGGER.error( - 'Unable to transform field: %s %s', key, key_error) + LOGGER.error('Unable to transform field: %s %s', key, key_error) return self.model def _serializers(self) -> Dict[str, Callable[[], Any]]: diff --git a/server/corvus/service/user_token_service.py b/server/corvus/service/user_token_service.py index 290f7b6..72aee9b 100644 --- a/server/corvus/service/user_token_service.py +++ b/server/corvus/service/user_token_service.py @@ -1,9 +1,9 @@ """Service to handle user_token operations.""" import uuid from datetime import datetime -from typing import Optional, Dict, Callable, Any +from typing import Optional, Dict, Callable, Any, List -from iso8601 import iso8601 +from iso8601 import iso8601, ParseError from corvus.db import db from corvus.model import User, UserToken @@ -11,6 +11,7 @@ from corvus.service.transformation_service import ( BaseTransformer, register_transformer ) +from corvus import errors @register_transformer @@ -79,7 +80,10 @@ class UserTokenTransformer(BaseTransformer): def deserialize_expiration_time( model: UserToken, expiration_time: datetime) -> None: """User token expiration time.""" - model.expiration_time = iso8601.parse_date(expiration_time) + try: + model.expiration_time = iso8601.parse_date(expiration_time) + except ParseError: + raise errors.ValidationError('Cannot parse datetime from %s' % expiration_time) def serialize_creation_time(self) -> datetime: """User token creation time.""" @@ -89,7 +93,10 @@ class UserTokenTransformer(BaseTransformer): def deserialize_creation_time( model: UserToken, creation_time: datetime) -> None: """User token creation time.""" - model.creation_time = iso8601.parse_date(creation_time) + try: + model.creation_time = iso8601.parse_date(creation_time) + except ParseError: + raise errors.ValidationError('Cannot parse datetime from %s' % creation_time) def serialize_last_usage_time(self) -> datetime: """User token last usage time.""" @@ -177,3 +184,13 @@ def find_by_user_and_token(user: User, token: str) -> Optional[UserToken]: :return: """ return UserToken.query.filter_by(user_id=user.id, token=token).first() + + +def find_by_user(user: User) -> List[UserToken]: + """ + Find all tokens for a user + + :param user: + :return: + """ + return UserToken.query.filter_by(user_id=user.id)