From 437cec38e09269cbc862e28f97a3bb980c0989cb Mon Sep 17 00:00:00 2001 From: Tobias Kurze Date: Thu, 6 Aug 2020 15:23:14 +0200 Subject: [PATCH] added permission checks to user and recorder API --- backend/__init__.py | 4 +++- backend/api/recorder_api.py | 12 +++++++++++- backend/api/user_api.py | 22 +++++++++++++++++++--- backend/auth/utils.py | 30 ++++++++++++------------------ backend/config.py | Bin 5644 -> 6514 bytes backend/models/user_model.py | 15 ++++++++++++++- 6 files changed, 59 insertions(+), 24 deletions(-) diff --git a/backend/__init__.py b/backend/__init__.py index bb500ff..b7453ce 100644 --- a/backend/__init__.py +++ b/backend/__init__.py @@ -4,6 +4,7 @@ Backend base module """ import logging import os +from functools import wraps from io import StringIO from logging.config import dictConfig from logging.handlers import MemoryHandler @@ -14,7 +15,7 @@ import jwt import requests from flask import Flask, jsonify, abort from flask_httpauth import HTTPTokenAuth, HTTPBasicAuth, MultiAuth -from flask_jwt_extended import JWTManager, decode_token +from flask_jwt_extended import JWTManager, decode_token, get_jwt_identity from flask_login import LoginManager from flask_sqlalchemy import SQLAlchemy from flask_cors import CORS @@ -179,6 +180,7 @@ CORS(auth_api_bp) logging.getLogger('flask_cors').level = logging.DEBUG + # Fix jwt_extended by 'duck typing' error handlers # jwt_extended._set_error_handler_callbacks(api_v1) # removed for the moment, might raise new (old) problems diff --git a/backend/api/recorder_api.py b/backend/api/recorder_api.py index 3a8609b..3434d7a 100644 --- a/backend/api/recorder_api.py +++ b/backend/api/recorder_api.py @@ -9,9 +9,10 @@ from pprint import pprint from flask_jwt_extended import jwt_required from flask_restx import fields, Resource, inputs -from backend import db, app, LrcException +from backend import db, app, LrcException, Config from backend.api import api_recorder from backend.api.models import recorder_model, recorder_model_model, recorder_command_model +from backend.auth.utils import requires_permission_level from backend.models.recorder_model import Recorder, RecorderModel, RecorderCommand from backend.models.room_model import Room import backend.recorder_adapters as r_a @@ -25,6 +26,7 @@ logger = logging.getLogger("lrc.api.recorder") @api_recorder.param('id', 'The recorder identifier') class RecorderResource(Resource): @jwt_required + @requires_permission_level(Config.Permissions.RECODER_SHOW) @api_recorder.doc('get_recorder') @api_recorder.marshal_with(recorder_model, skip_none=False) def get(self, id): @@ -35,6 +37,7 @@ class RecorderResource(Resource): api_recorder.abort(404) @jwt_required + @requires_permission_level(Config.Permissions.RECORDER_DELETE) @api_recorder.doc('delete_todo') @api_recorder.response(204, 'Todo deleted') def delete(self, id): @@ -65,6 +68,7 @@ class RecorderResource(Resource): required=False, store_missing=False) @jwt_required + @requires_permission_level(Config.Permissions.RECORDER_EDIT) @api_recorder.doc('update_recorder') @api_recorder.expect(recorder_model) def put(self, id): @@ -85,6 +89,7 @@ class RecorderResource(Resource): @api_recorder.route('') class RecorderList(Resource): @jwt_required + @requires_permission_level(Config.Permissions.RECORDERS_LIST) @api_recorder.doc('recorders') @api_recorder.marshal_list_with(recorder_model, skip_none=False) def get(self): @@ -95,6 +100,7 @@ class RecorderList(Resource): return Recorder.get_all() @jwt_required + @requires_permission_level(Config.Permissions.RECODER_NEW) @api_recorder.doc('create_recorder') @api_recorder.expect(recorder_model) @api_recorder.marshal_with(recorder_model, skip_none=False, code=201) @@ -161,6 +167,7 @@ class RecorderModelResource(Resource): @api_recorder.route('/model') class RecorderModelList(Resource): @jwt_required + @requires_permission_level(Config.Permissions.RECODER_MODELS_LIST) @api_recorder.doc('recorders') @api_recorder.marshal_list_with(recorder_model_model) def get(self): @@ -172,6 +179,7 @@ class RecorderModelList(Resource): @api_recorder.param('id', 'The recorder command identifier') class RecorderCommandResource(Resource): @jwt_required + @requires_permission_level(Config.Permissions.RECORDER_COMMAND_SHOW) @api_recorder.doc('get_recorder_command') @api_recorder.marshal_with(recorder_command_model) def get(self, id): @@ -186,6 +194,7 @@ class RecorderCommandResource(Resource): recorder_command_model_parser.add_argument('alternative_name', type=str, required=False) @jwt_required + @requires_permission_level(Config.Permissions.RECORDER_COMMAND_EDIT) @api_recorder.doc('update_recorder_command') @api_recorder.expect(recorder_command_model_parser) @api_recorder.marshal_with(recorder_command_model) @@ -201,6 +210,7 @@ class RecorderCommandResource(Resource): @api_recorder.route('/command') class RecorderCommandList(Resource): @jwt_required + @requires_permission_level(Config.Permissions.RECORDER_COMMANDS_LIST) @api_recorder.doc('recorder_commands') @api_recorder.marshal_list_with(recorder_command_model) def get(self): diff --git a/backend/api/user_api.py b/backend/api/user_api.py index dbd70ca..c489b26 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -14,7 +14,8 @@ from flask_restx import Resource, fields, inputs, abort from backend import db, app, jwt_auth from backend.api import api_user from backend.api.models import user_model, recorder_model, generic_id_parser -from backend.models import Recorder +from backend.auth.utils import requires_permission_level +from backend.models import Recorder, Config from backend.models.user_model import User, Group @@ -90,18 +91,20 @@ class UserList(Resource): # @jwt_auth.login_required @jwt_required + @requires_permission_level(Config.Permissions.USERS_LIST) @api_user.doc('users') @api_user.marshal_list_with(user_model) def get(self): """ - just a test! - :return: Hello: World + returns all users + :return: all users """ current_user = get_jwt_identity() app.logger.info(current_user) return User.get_all() @jwt_required + @requires_permission_level(Config.Permissions.USER_CREATE) @api_user.doc('create_group') @api_user.expect(user_model) @api_user.marshal_with(user_model, code=201) @@ -117,6 +120,7 @@ class UserList(Resource): @api_user.response(404, 'User not found') class UserResource(Resource): @jwt_auth.login_required + @requires_permission_level(Config.Permissions.USER_SHOW) @api_user.doc('get_user') @api_user.marshal_with(user_model) def get(self, id): @@ -126,4 +130,16 @@ class UserResource(Resource): return user api_user.abort(404) + @jwt_auth.login_required + @requires_permission_level(Config.Permissions.USER_DELETE) + @api_user.doc('delete_user') + def delete(self, id): + """Fetch a user given its identifier""" + user = User.get_by_id(id) + if user is not None: + db.session.delete(user) + db.session.commit() + return "ok" + api_user.abort(404) + # api_user.add_resource(UserResource, '/') diff --git a/backend/auth/utils.py b/backend/auth/utils.py index 8a12ca4..0652dd1 100644 --- a/backend/auth/utils.py +++ b/backend/auth/utils.py @@ -2,6 +2,8 @@ import flask_jwt_extended from flask_jwt_extended import jwt_optional, get_jwt_identity from functools import wraps +from flask_restx import abort + from backend import jwt_auth from backend.models.user_model import User @@ -10,26 +12,16 @@ def requires_permission_level(permission_level): def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): - if flask_jwt_extended.verify_jwt_in_request(): - current_user_id = get_jwt_identity() - user = User.get_by_identifier(current_user_id) - if user is not None: - if user.has_permission(permission_level): - #for g in user.groups: - # if g.permissions - #TODO - pass - else: - pass - # return FALSE - #if not session.get('email'): - # return redirect(url_for('users.login')) - - #user = User.find_by_email(session['email']) - #elif not user.allowed(access_level): - # return redirect(url_for('users.profile', message="You do not have access to that page. Sorry!")) + # if flask_jwt_extended.verify_jwt_in_request(): + current_user_id = get_jwt_identity() + user = User.get_by_identifier(current_user_id) + if user is not None: + if not user.has_permission(permission_level): + abort(401, f"You are missing the permission: {permission_level}") return f(*args, **kwargs) + return decorated_function + return decorator @@ -38,5 +30,7 @@ def require_jwt(): @wraps(f) def decorated_function(*args, **kwargs): return jwt_auth.login_required(jwt_optional(f(*args, **kwargs))) + return decorated_function + return decorator diff --git a/backend/config.py b/backend/config.py index d2a63a96adbbe3bf02134803834bb33f5be12c47..50500f7b847615a6ff1290dcbbaf766c6cf1472d 100644 GIT binary patch literal 6514 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 literal 5644 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

hO*0BUC7@E&8ULJWdMR1Fs5aY0km53OUfU@e~W zLGM|i*|Tz#f;`8==@d+;kX+xpY26GKy6a1fZvna{ihLy4Y0I4fVsVK>5shU%+9LEg z5XF`~2|Z2g>aNi7{-MmMlwkdE-lK3ZS`5y+hXJrJ!CjGGnkvQnBH(RzHDK5OG9kc* z`g`|BY6N(Cinl)aY=ZGspAPrDm6T<(d1gC2h$EuAAH`8RS5#?W7J zQR+9%Y4PMb?-KkbpV>9GUV}?;QJAn3mR_#=z`R(4L0ePMzv-Ggg8spu@))j>ru^bS zwF7bGK(jcJ>IyU=3n&)qqeUS9S}Xc>rW^?rCaemX z^qk<~S)7$+M>RK>AI@a*c&AHteHGf1kB9*Tn>v?edmQqA;=VehMGl<_51s+_l|>@L z2PQy;#9QIT^BWk0{ve7ku3b9CB3n7xc-kRXz4KB-#ZH>h+sUDb+kf}BNtB5*fp zPr}DhJW?*--4Yt{ZZ5`ttGHcJZDC6FWyH@j(33GnQlT;8nm(iz_-wN`)`xq+&%Ee} zJ4z9QnHX$^Q#~?)YdB6Q8(6Y}XjvrGXSw;>h1Sb3x$Vo4!2^8*pzIZ%{nA)RxMz;{ z7iR@1b%c@+T2c;do4$|HtcjzC=eb-DQ9qrdt@|7gK~SzU