From 7fbff30214bfc886b91975f2024c27902b3debda Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 3 Sep 2020 22:29:14 +0200 Subject: [PATCH] [API BREAK] Changed authentication routes Authentication is now on /auth/... and using REST pathes and methods. AccessToken are now having a expires field instead of timestamp, more usefull for automatic removal of expired ones. --- flaschengeist/modules/auth/__init__.py | 169 ++++++++---------- .../controller/accessTokenController.py | 116 ++++++------ flaschengeist/system/exceptions/__init__.py | 5 +- flaschengeist/system/models/accessToken.py | 46 ++--- 4 files changed, 156 insertions(+), 180 deletions(-) diff --git a/flaschengeist/modules/auth/__init__.py b/flaschengeist/modules/auth/__init__.py index b9c5b34..1152ffc 100644 --- a/flaschengeist/modules/auth/__init__.py +++ b/flaschengeist/modules/auth/__init__.py @@ -4,17 +4,15 @@ # authentication, login, logout, etc # ############################################# -from flask import Blueprint, current_app, request, jsonify +from flask import Blueprint, request, jsonify +from werkzeug.exceptions import Forbidden, BadRequest from werkzeug.local import LocalProxy +from flaschengeist import logger from flaschengeist.system.decorator import login_required -from flaschengeist.system.exceptions import PermissionDenied from flaschengeist.system.controller import mainController as mc import flaschengeist.system.controller.accessTokenController as ac -from flaschengeist.system.models.accessToken import AccessToken - -logger = LocalProxy(lambda: current_app.logger) access_controller = LocalProxy(lambda: ac.AccessTokenController()) auth_bp = Blueprint('auth', __name__) @@ -23,13 +21,19 @@ auth_bp = Blueprint('auth', __name__) def register(): return auth_bp -############################################ -# Routes # -############################################ +################################################# +# Routes # +# # +# /auth POST: login (new token) # +# GET: get all tokens for user # +# /auth/ GET: get lifetime of token # +# PUT: set new lifetime # +# DELETE: logout / delete token # +################################################# -@auth_bp.route("/login", methods=['POST']) -def _login(): +@auth_bp.route("/auth", methods=['POST']) +def _create_token(): """ Login User Login in User and create an AccessToken for the User. @@ -38,95 +42,70 @@ def _login(): """ logger.debug("Start log in.") data = request.get_json() - logger.info(request) username = data['username'] password = data['password'] - logger.debug("username is {{ {} }}".format(username)) - try: - logger.debug("search {{ {} }} in database".format(username)) - main_controller = mc.MainController() - user = main_controller.login_user(username, password) - logger.debug("user is {{ {} }}".format(user)) - token = access_controller.create(user, user_agent=request.user_agent) - logger.debug("access token is {{ {} }}".format(token)) - logger.debug("validate access token") - dic = user.default() - dic["accessToken"] = token.token - logger.info("User {{ {} }} success login.".format(username)) - logger.debug("return login {{ {} }}".format(dic)) - return jsonify(dic) - except PermissionDenied as err: - logger.debug("permission denied exception in login", exc_info=True) - return jsonify({"error": str(err)}), 401 - except Exception as err: - logger.error("exception in login.", exc_info=True) - return jsonify({"error": "permission denied"}), 401 + + logger.debug("search user {{ {} }} in database".format(username)) + main_controller = mc.MainController() + user = main_controller.login_user(username, password) + logger.debug("user is {{ {} }}".format(user)) + token = access_controller.create(user, user_agent=request.user_agent) + logger.debug("access token is {{ {} }}".format(token)) + dic = user.serialize() + dic["access_token"] = token.token + logger.info("User {{ {} }} success login.".format(username)) + + # Lets cleanup the DB + access_controller.clear_expired() + return jsonify(dic) -@auth_bp.route("/logout", methods=['GET']) +@auth_bp.route("/auth", methods=['GET']) @login_required() -def _logout(**kwargs): +def _get_tokens(access_token, **kwargs): + tokens = access_controller.get_users_tokens(access_token.user) + return jsonify(tokens) + + +@auth_bp.route("/auth/", methods=['DELETE']) +@login_required() +def _delete_token(token, access_token, **kwargs): + logger.debug("Try to delete access token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + logger.debug("Token not found in database!") + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + access_controller.delete_token(token) + access_controller.clear_expired() + return jsonify({"ok": "ok"}) + + +@auth_bp.route("/auth/", methods=['GET']) +@login_required() +def _get_token(token, access_token, **kwargs): + logger.debug("get token {{ {} }}".format(token)) + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden + return jsonify(token) + + +@auth_bp.route("/auth/", methods=['PUT']) +@login_required() +def _set_lifetime(token, access_token, **kwargs): + token = access_controller.get_token(token, access_token.user) + if not token: + # Return 403 error, so that users can not bruteforce tokens + # Valid tokens from other users and invalid tokens now are looking the same + raise Forbidden try: - logger.debug("logout user") - token = kwargs['accToken'] - logger.debug("access token is {{ {} }}".format(token)) - logger.debug("delete access token") - access_controller.deleteAccessToken(token) - access_controller.clearExpired() - logger.info("return ok logout user") + lifetime = request.get_json()['value'] + logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token)) + access_controller.set_lifetime(token, lifetime) return jsonify({"ok": "ok"}) - except Exception as err: - logger.warning("exception in logout user.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/user/getAccessTokens", methods=['GET', 'POST']) -# @auth_bp.route("/accessTokens", methods=['GET', 'POST']) -@login_required() -def _getAccessTokens(**kwargs): - try: - if request.method == 'POST': - data = request.get_json() - token = AccessToken(data['id'], kwargs['accToken'].user, None, None, None) - access_controller.delete_token(token) - tokens = access_controller.getAccessTokensFromUser(kwargs['accToken'].user) - r = [t.toJSON() for t in tokens] - logger.debug("return {{ {} }}".format(r)) - return jsonify(r) - except Exception as err: - logger.debug("exception", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/getLifetime", methods=['GET']) -@login_required() -def _getLifeTime(**kwargs): - try: - logger.debug("get lifetime of access token") - token = kwargs['accToken'] - logger.debug("accessToken is {{ {} }}".format(token)) - return jsonify({"value": token.lifetime}) - except Exception as err: - logger.warning("exception in get lifetime of access token.", exc_info=True) - return jsonify({"error": str(err)}), 500 - - -@auth_bp.route("/setLifetime", methods=['POST']) -@login_required() -def _saveLifeTime(**kwargs): - try: - token = kwargs['accToken'] - logger.debug("save lifetime for access token {{ {} }}".format(token)) - data = request.get_json() - lifetime = data['value'] - logger.debug("lifetime is {{ {} }}".format(lifetime)) - logger.info("set lifetime {{ {} }} to access token {{ {} }}".format( - lifetime, token)) - token.lifetime = lifetime - logger.info("update access token timestamp") - token = access_controller.update(token) - return jsonify({"value": token.lifetime}) - except Exception as err: - logger.warning( - "exception in save lifetime for access token.", exc_info=True) - return jsonify({"error": str(err)}), 500 + except (KeyError, TypeError): + raise BadRequest diff --git a/flaschengeist/system/controller/accessTokenController.py b/flaschengeist/system/controller/accessTokenController.py index f0cf293..e3d375e 100644 --- a/flaschengeist/system/controller/accessTokenController.py +++ b/flaschengeist/system/controller/accessTokenController.py @@ -1,12 +1,10 @@ +import secrets from ..models.accessToken import AccessToken from flaschengeist.system.database import db - +from flaschengeist import logger +from werkzeug.exceptions import Forbidden from datetime import datetime, timedelta -import secrets from . import Singleton -import logging - -logger = logging.getLogger("flaschenpost") class AccessTokenController(metaclass=Singleton): @@ -17,99 +15,111 @@ class AccessTokenController(metaclass=Singleton): Attributes: lifetime: Variable for the Lifetime of one AccessToken in seconds. """ - instance = None - tokenList = None def __init__(self, lifetime=1800): - """ Initialize AccessTokenController - - Initialize Thread and set tokenList empty. - """ - logger.debug("init access token controller") self.lifetime = lifetime - def validate_token(self, token, roles): + def validate_token(self, token, user_agent, roles): """ Verify access token - Verify an AccessToken and Group so if the User has permission or not. + Verify an AccessToken and Roles so if the User has permission or not. Retrieves the access token if valid else retrieves False Args: token: Token to verify. + user_agent: User agent of browser to check roles: Roles needed to access restricted routes Returns: An the AccessToken for this given Token or False. """ logger.debug("check token {{ {} }} is valid".format(token)) - for access_token in AccessToken.query.filter_by(token=token): - time_end = access_token.timestamp + timedelta(seconds=access_token.lifetime) - now = datetime.utcnow() - logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, time_end)) - if now <= time_end: - logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, access_token)) - if not roles or (roles and self.userHasRole(access_token.user, roles)): - access_token.updateTimestamp() + access_token = AccessToken.query.filter_by(token=token).one_or_none() + if access_token: + logger.debug("token found, check if expired or invalid user agent differs") + if access_token.expires >= datetime.utcnow() and ( + access_token.browser == user_agent.browser and + access_token.platform == user_agent.platform): + if not roles or (roles and self.user_has_role(access_token.user, roles)): + access_token.refresh() db.session.commit() return access_token else: - logger.debug("access token is {{ {} }} out of date".format(access_token)) - db.session.delete(access_token) - db.session.commit() - logger.debug("no valid access token with token: {{ {} }} and group: {{ {} }}".format(token, roles)) - return False - - def userHasRole(self, user, roles): - for group in user.groups: - for role in group.roles: - if role.name in roles: - return True + logger.debug("access token is out of date or invalid client used") + self.delete_token(access_token) + logger.debug("no valid access token with token: {{ {} }} and roles: {{ {} }}".format(token, roles)) return False def create(self, user, user_agent=None) -> AccessToken: """ Create an AccessToken - Create an AccessToken for an User and add it to the tokenList. + Args: + user: For which User is to create an AccessToken + user_agent: User agent to identify session - Args: - user: For which User is to create an AccessToken - user_agent: User agent to identify session - - Returns: + Returns: AccessToken: A created Token for User """ logger.debug("create access token") token_str = secrets.token_hex(16) token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, browser=user_agent.browser, platform=user_agent.platform) + token.refresh() db.session.add(token) db.session.commit() logger.debug("access token is {{ {} }}".format(token)) return token - def getAccessTokensFromUser(self, user): + def get_token(self, token, owner=None): + """Retrieves AccessToken from token string + + Args: + token (str): Token string + owner (User, optional): User owning the token + + Raises: + Forbidden: Raised if owner is set but does not match + Returns: + AccessToken: Token object identified by given token string + """ + access_token = AccessToken.query.filter(AccessToken.token == token).one_or_none() + if access_token and (owner and owner != access_token.user): + raise Forbidden + return access_token + + def get_users_tokens(self, user): return AccessToken.query.filter(AccessToken.user == user) @staticmethod - def delete_token(token): - if token is isinstance(token, AccessToken): - db.session.delete(token) - else: - AccessToken.query.filter_by(token=token).delete() + def delete_token(token: AccessToken): + """Deletes given AccessToken + + Args: + token (AccessToken): Token to delete + """ + db.session.delete(token) db.session.commit() @staticmethod def update_token(token): - token.update_timestamp() + token.refresh() db.session.commit() + def set_lifetime(self, token, lifetime): + token.lifetime = lifetime + self.update_token(token) + def clear_expired(self): + """Remove expired tokens from database""" logger.debug("Clear expired AccessToken") - might_expired = datetime.utcnow() - timedelta(seconds=self.lifetime) - tokens = AccessToken.query.filter(AccessToken.timestamp < might_expired) - logger.debug(tokens) - for token in tokens: - if token.timestamp < datetime.utcnow() - timedelta(seconds=token.lifetime): - logger.debug("Delete token %s", token.token) - db.session.delete(token) + deleted = AccessToken.query.filter(AccessToken.expires < datetime.utcnow()).delete() + logger.debug("{} tokens have been removed".format(deleted)) db.session.commit() + + # TODO: is this needed? + def user_has_role(self, user, roles): + for group in user.groups: + for role in group.roles: + if role.name in roles: + return True + return False diff --git a/flaschengeist/system/exceptions/__init__.py b/flaschengeist/system/exceptions/__init__.py index 4d18a6b..0972d22 100644 --- a/flaschengeist/system/exceptions/__init__.py +++ b/flaschengeist/system/exceptions/__init__.py @@ -1,5 +1,8 @@ class PermissionDenied(Exception): - pass + def __init__(self, message=None): + if not message: + message = "PermissionDenied" + super().__init__(message) class UsernameExistDB(Exception): diff --git a/flaschengeist/system/models/accessToken.py b/flaschengeist/system/models/accessToken.py index 1383dd7..af80281 100644 --- a/flaschengeist/system/models/accessToken.py +++ b/flaschengeist/system/models/accessToken.py @@ -1,69 +1,53 @@ -from datetime import datetime +from datetime import datetime, timedelta from ..database import db -from flask import current_app -from werkzeug.local import LocalProxy from secrets import compare_digest - -logger = LocalProxy(lambda: current_app.logger) +from flaschengeist import logger class AccessToken(db.Model): """ Model for an AccessToken - Attributes: - timestamp: Is a Datetime from current Time. + Args: + expires: Is a Datetime from current Time. user: Is an User. token: String to verify access later. """ __tablename__ = 'session' id = db.Column(db.Integer, primary_key=True) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) user = db.relationship("User", back_populates="sessions") - token = db.Column(db.String(30)) + + expires = db.Column(db.DateTime) + token = db.Column(db.String(30), unique=True) lifetime = db.Column(db.Integer) browser = db.Column(db.String(30)) platform = db.Column(db.String(30)) - def update_timestamp(self): + def refresh(self): """ Update the Timestamp Update the Timestamp to the current Time. """ logger.debug("update timestamp from access token {{ {} }}".format(self)) - self.timestamp = datetime.utcnow() + self.expires = datetime.utcnow() + timedelta(seconds=self.lifetime) - def default(self): + def serialize(self): """ Create Dic to dump in JSON Returns: A Dic with static Attributes. """ - dic = { - "id": self.id, - "timestamp": {'year': self.timestamp.year, - 'month': self.timestamp.month, - 'day': self.timestamp.day, - 'hour': self.timestamp.hour, - 'minute': self.timestamp.minute, - 'second': self.timestamp.second - }, + return { + "token": self.token, + "expires": self.expires, "lifetime": self.lifetime, "browser": self.browser, "platform": self.platform } - return dic def __eq__(self, token): return compare_digest(self.token, token) - def __sub__(self, other): - return other - self.timestamp - def __str__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( - self.user, self.token, self.timestamp, self.lifetime) - - def __repr__(self): - return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( - self.user, self.token, self.timestamp, self.lifetime) + return "AccessToken(user={}, token={}, expires={}, lifetime={})".format( + self.user, self.token, self.expires, self.lifetime)