[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.
This commit is contained in:
Ferdinand Thiessen 2020-09-03 22:29:14 +02:00
parent b6157f4953
commit 7fbff30214
4 changed files with 156 additions and 180 deletions

View File

@ -4,17 +4,15 @@
# authentication, login, logout, etc # # 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 werkzeug.local import LocalProxy
from flaschengeist import logger
from flaschengeist.system.decorator import login_required from flaschengeist.system.decorator import login_required
from flaschengeist.system.exceptions import PermissionDenied
from flaschengeist.system.controller import mainController as mc from flaschengeist.system.controller import mainController as mc
import flaschengeist.system.controller.accessTokenController as ac 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()) access_controller = LocalProxy(lambda: ac.AccessTokenController())
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@ -23,13 +21,19 @@ auth_bp = Blueprint('auth', __name__)
def register(): def register():
return auth_bp return auth_bp
############################################ #################################################
# Routes # # Routes #
############################################ # #
# /auth POST: login (new token) #
# GET: get all tokens for user #
# /auth/<token> GET: get lifetime of token #
# PUT: set new lifetime #
# DELETE: logout / delete token #
#################################################
@auth_bp.route("/login", methods=['POST']) @auth_bp.route("/auth", methods=['POST'])
def _login(): def _create_token():
""" Login User """ Login User
Login in User and create an AccessToken for the User. Login in User and create an AccessToken for the User.
@ -38,95 +42,70 @@ def _login():
""" """
logger.debug("Start log in.") logger.debug("Start log in.")
data = request.get_json() data = request.get_json()
logger.info(request)
username = data['username'] username = data['username']
password = data['password'] password = data['password']
logger.debug("username is {{ {} }}".format(username))
try: logger.debug("search user {{ {} }} in database".format(username))
logger.debug("search {{ {} }} in database".format(username)) main_controller = mc.MainController()
main_controller = mc.MainController() user = main_controller.login_user(username, password)
user = main_controller.login_user(username, password) logger.debug("user is {{ {} }}".format(user))
logger.debug("user is {{ {} }}".format(user)) token = access_controller.create(user, user_agent=request.user_agent)
token = access_controller.create(user, user_agent=request.user_agent) logger.debug("access token is {{ {} }}".format(token))
logger.debug("access token is {{ {} }}".format(token)) dic = user.serialize()
logger.debug("validate access token") dic["access_token"] = token.token
dic = user.default() logger.info("User {{ {} }} success login.".format(username))
dic["accessToken"] = token.token
logger.info("User {{ {} }} success login.".format(username)) # Lets cleanup the DB
logger.debug("return login {{ {} }}".format(dic)) access_controller.clear_expired()
return jsonify(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
@auth_bp.route("/logout", methods=['GET']) @auth_bp.route("/auth", methods=['GET'])
@login_required() @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/<token>", 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/<token>", 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/<token>", 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: try:
logger.debug("logout user") lifetime = request.get_json()['value']
token = kwargs['accToken'] logger.debug("set lifetime {{ {} }} to access token {{ {} }}".format(lifetime, token))
logger.debug("access token is {{ {} }}".format(token)) access_controller.set_lifetime(token, lifetime)
logger.debug("delete access token")
access_controller.deleteAccessToken(token)
access_controller.clearExpired()
logger.info("return ok logout user")
return jsonify({"ok": "ok"}) return jsonify({"ok": "ok"})
except Exception as err: except (KeyError, TypeError):
logger.warning("exception in logout user.", exc_info=True) raise BadRequest
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

View File

@ -1,12 +1,10 @@
import secrets
from ..models.accessToken import AccessToken from ..models.accessToken import AccessToken
from flaschengeist.system.database import db from flaschengeist.system.database import db
from flaschengeist import logger
from werkzeug.exceptions import Forbidden
from datetime import datetime, timedelta from datetime import datetime, timedelta
import secrets
from . import Singleton from . import Singleton
import logging
logger = logging.getLogger("flaschenpost")
class AccessTokenController(metaclass=Singleton): class AccessTokenController(metaclass=Singleton):
@ -17,99 +15,111 @@ class AccessTokenController(metaclass=Singleton):
Attributes: Attributes:
lifetime: Variable for the Lifetime of one AccessToken in seconds. lifetime: Variable for the Lifetime of one AccessToken in seconds.
""" """
instance = None
tokenList = None
def __init__(self, lifetime=1800): def __init__(self, lifetime=1800):
""" Initialize AccessTokenController
Initialize Thread and set tokenList empty.
"""
logger.debug("init access token controller")
self.lifetime = lifetime self.lifetime = lifetime
def validate_token(self, token, roles): def validate_token(self, token, user_agent, roles):
""" Verify access token """ 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 Retrieves the access token if valid else retrieves False
Args: Args:
token: Token to verify. token: Token to verify.
user_agent: User agent of browser to check
roles: Roles needed to access restricted routes roles: Roles needed to access restricted routes
Returns: Returns:
An the AccessToken for this given Token or False. An the AccessToken for this given Token or False.
""" """
logger.debug("check token {{ {} }} is valid".format(token)) logger.debug("check token {{ {} }} is valid".format(token))
for access_token in AccessToken.query.filter_by(token=token): access_token = AccessToken.query.filter_by(token=token).one_or_none()
time_end = access_token.timestamp + timedelta(seconds=access_token.lifetime) if access_token:
now = datetime.utcnow() logger.debug("token found, check if expired or invalid user agent differs")
logger.debug("now is {{ {} }}, endtime is {{ {} }}".format(now, time_end)) if access_token.expires >= datetime.utcnow() and (
if now <= time_end: access_token.browser == user_agent.browser and
logger.debug("check if token {{ {} }} is same as {{ {} }}".format(token, access_token)) access_token.platform == user_agent.platform):
if not roles or (roles and self.userHasRole(access_token.user, roles)): if not roles or (roles and self.user_has_role(access_token.user, roles)):
access_token.updateTimestamp() access_token.refresh()
db.session.commit() db.session.commit()
return access_token return access_token
else: else:
logger.debug("access token is {{ {} }} out of date".format(access_token)) logger.debug("access token is out of date or invalid client used")
db.session.delete(access_token) self.delete_token(access_token)
db.session.commit() logger.debug("no valid access token with token: {{ {} }} and roles: {{ {} }}".format(token, roles))
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
return False return False
def create(self, user, user_agent=None) -> AccessToken: def create(self, user, user_agent=None) -> AccessToken:
""" Create an 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: Returns:
user: For which User is to create an AccessToken
user_agent: User agent to identify session
Returns:
AccessToken: A created Token for User AccessToken: A created Token for User
""" """
logger.debug("create access token") logger.debug("create access token")
token_str = secrets.token_hex(16) token_str = secrets.token_hex(16)
token = AccessToken(token=token_str, user=user, lifetime=self.lifetime, token = AccessToken(token=token_str, user=user, lifetime=self.lifetime,
browser=user_agent.browser, platform=user_agent.platform) browser=user_agent.browser, platform=user_agent.platform)
token.refresh()
db.session.add(token) db.session.add(token)
db.session.commit() db.session.commit()
logger.debug("access token is {{ {} }}".format(token)) logger.debug("access token is {{ {} }}".format(token))
return 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) return AccessToken.query.filter(AccessToken.user == user)
@staticmethod @staticmethod
def delete_token(token): def delete_token(token: AccessToken):
if token is isinstance(token, AccessToken): """Deletes given AccessToken
db.session.delete(token)
else: Args:
AccessToken.query.filter_by(token=token).delete() token (AccessToken): Token to delete
"""
db.session.delete(token)
db.session.commit() db.session.commit()
@staticmethod @staticmethod
def update_token(token): def update_token(token):
token.update_timestamp() token.refresh()
db.session.commit() db.session.commit()
def set_lifetime(self, token, lifetime):
token.lifetime = lifetime
self.update_token(token)
def clear_expired(self): def clear_expired(self):
"""Remove expired tokens from database"""
logger.debug("Clear expired AccessToken") logger.debug("Clear expired AccessToken")
might_expired = datetime.utcnow() - timedelta(seconds=self.lifetime) deleted = AccessToken.query.filter(AccessToken.expires < datetime.utcnow()).delete()
tokens = AccessToken.query.filter(AccessToken.timestamp < might_expired) logger.debug("{} tokens have been removed".format(deleted))
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)
db.session.commit() 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

View File

@ -1,5 +1,8 @@
class PermissionDenied(Exception): class PermissionDenied(Exception):
pass def __init__(self, message=None):
if not message:
message = "PermissionDenied"
super().__init__(message)
class UsernameExistDB(Exception): class UsernameExistDB(Exception):

View File

@ -1,69 +1,53 @@
from datetime import datetime from datetime import datetime, timedelta
from ..database import db from ..database import db
from flask import current_app
from werkzeug.local import LocalProxy
from secrets import compare_digest from secrets import compare_digest
from flaschengeist import logger
logger = LocalProxy(lambda: current_app.logger)
class AccessToken(db.Model): class AccessToken(db.Model):
""" Model for an AccessToken """ Model for an AccessToken
Attributes: Args:
timestamp: Is a Datetime from current Time. expires: Is a Datetime from current Time.
user: Is an User. user: Is an User.
token: String to verify access later. token: String to verify access later.
""" """
__tablename__ = 'session' __tablename__ = 'session'
id = db.Column(db.Integer, primary_key=True) 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_id = db.Column(db.Integer, db.ForeignKey('user.id'))
user = db.relationship("User", back_populates="sessions") 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) lifetime = db.Column(db.Integer)
browser = db.Column(db.String(30)) browser = db.Column(db.String(30))
platform = db.Column(db.String(30)) platform = db.Column(db.String(30))
def update_timestamp(self): def refresh(self):
""" Update the Timestamp """ Update the Timestamp
Update the Timestamp to the current Time. Update the Timestamp to the current Time.
""" """
logger.debug("update timestamp from access token {{ {} }}".format(self)) 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 """ Create Dic to dump in JSON
Returns: Returns:
A Dic with static Attributes. A Dic with static Attributes.
""" """
dic = { return {
"id": self.id, "token": self.token,
"timestamp": {'year': self.timestamp.year, "expires": self.expires,
'month': self.timestamp.month,
'day': self.timestamp.day,
'hour': self.timestamp.hour,
'minute': self.timestamp.minute,
'second': self.timestamp.second
},
"lifetime": self.lifetime, "lifetime": self.lifetime,
"browser": self.browser, "browser": self.browser,
"platform": self.platform "platform": self.platform
} }
return dic
def __eq__(self, token): def __eq__(self, token):
return compare_digest(self.token, token) return compare_digest(self.token, token)
def __sub__(self, other):
return other - self.timestamp
def __str__(self): def __str__(self):
return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format( return "AccessToken(user={}, token={}, expires={}, lifetime={})".format(
self.user, self.token, self.timestamp, self.lifetime) self.user, self.token, self.expires, self.lifetime)
def __repr__(self):
return "AccessToken(user={}, token={}, timestamp={}, lifetime={}".format(
self.user, self.token, self.timestamp, self.lifetime)