[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 #
#############################################
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/<token> 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/<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:
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

View File

@ -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

View File

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

View File

@ -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)