From 8d151ba8c3e91fb325ab4c93d875587df9888069 Mon Sep 17 00:00:00 2001 From: esensar Date: Sun, 6 May 2018 21:42:21 +0200 Subject: [PATCH] Restfulify account creation and login --- app/__init__.py | 20 ++++++-- app/accounts/__init__.py | 51 +++++++++++++++++++ app/{mod_accounts => accounts}/controllers.py | 48 ++++++++--------- app/{mod_accounts => accounts}/models.py | 26 +++++++++- app/api/__init__.py | 11 ++++ app/api/resources/__init__.py | 0 app/api/resources/account.py | 43 ++++++++++++++++ app/api/resources/token.py | 46 +++++++++++++++++ app/{mod_devices => devices}/__init__.py | 6 +-- app/{mod_devices => devices}/models.py | 0 app/{mod_devices => devices}/mqtt_client.py | 0 app/mod_accounts/__init__.py | 6 --- requirements.txt | 4 +- 13 files changed, 219 insertions(+), 42 deletions(-) create mode 100644 app/accounts/__init__.py rename app/{mod_accounts => accounts}/controllers.py (65%) rename app/{mod_accounts => accounts}/models.py (84%) create mode 100644 app/api/__init__.py create mode 100644 app/api/resources/__init__.py create mode 100644 app/api/resources/account.py create mode 100644 app/api/resources/token.py rename app/{mod_devices => devices}/__init__.py (84%) rename app/{mod_devices => devices}/models.py (100%) rename app/{mod_devices => devices}/mqtt_client.py (100%) delete mode 100644 app/mod_accounts/__init__.py diff --git a/app/__init__.py b/app/__init__.py index 2b2d2e3..40fc33f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -11,11 +11,23 @@ bcrypt = Bcrypt(app) def setup_blueprints(app): - from .mod_devices import devices - from .mod_accounts import accounts + """ + Sets up all of the blueprints for application - app.register_blueprint(devices, url_prefix='/devices') - app.register_blueprint(accounts, url_prefix='/accounts') + All blueprints should be imported in this method and then added + + API blueprint should expose all resources, while other + blueprints expose other domain specific functionalities + They are exposed as blueprints just for consistency, otherwise + they are just simple python packages/modules + """ + from .devices import devices_bp + from .accounts import accounts_bp + from .api import api_bp + + app.register_blueprint(devices_bp) + app.register_blueprint(accounts_bp) + app.register_blueprint(api_bp, url_prefix='/api') setup_blueprints(app) diff --git a/app/accounts/__init__.py b/app/accounts/__init__.py new file mode 100644 index 0000000..6d4b6b7 --- /dev/null +++ b/app/accounts/__init__.py @@ -0,0 +1,51 @@ +from app import bcrypt +from flask import Blueprint +from .models import Account + +accounts_bp = Blueprint('accounts', __name__) + + +def create_account(username, email, password): + """ + Tries to create account with given parameters. Raises error on failure + + :param username: Desired username for Account + :param email: Desired email for Account + :param password: Desired password for Account + :type username: string + :type email: string + :type password: string + :returns True if account is successfully created + :rtype Boolean + :raises ValueError if account already exists + """ + if not Account.exists_with_any_of(username=username, email=email): + pw_hash = bcrypt.generate_password_hash(password).decode('utf-8') + account = Account(username, pw_hash, email) + account.save() + return True + + raise ValueError("Account with given parameters already exists") + + +def create_token(username, password): + """ + Tries to create token for account with given parameters. + Raises error on failure + + :param username: username of Account + :param password: password of Account + :type username: string + :type password: string + :returns created token + :rtype string + :raises ValueError if credentials are invalid or account does not exist + """ + if not Account.exists(username=username): + raise ValueError("Invalid credentials") + + account = Account.get(username=username) + if not bcrypt.check_password_hash(account.password, password): + raise ValueError("Invalid credentials") + + return account.create_auth_token() diff --git a/app/mod_accounts/controllers.py b/app/accounts/controllers.py similarity index 65% rename from app/mod_accounts/controllers.py rename to app/accounts/controllers.py index 9faa34c..abc3f6d 100644 --- a/app/mod_accounts/controllers.py +++ b/app/accounts/controllers.py @@ -1,10 +1,10 @@ -from app import bcrypt -from flask import request, jsonify +from app import bcrypt, status +from flask import request from .models import Account def initialize_routes(accounts): - @accounts.route("/", methods=['POST']) + @accounts.route("", methods=['POST']) def create_account(): print(request.data) user = request.data.get('user') @@ -17,54 +17,48 @@ def initialize_routes(accounts): password_hash, user.get('email')) acct.save() - response = jsonify({ + response = { 'status': 'success', 'message': 'Success!' - }) - response.status_code = 200 - return response + } + return response, status.HTTP_200_OK else: - response = jsonify({ + response = { 'status': 'error', 'message': 'User already exists!' - }) - response.status_code = 422 - return response + } + return response, status.HTTP_422_UNPROCESSABLE_ENTITY @accounts.route("/token", methods=['POST']) def create_token(): print(request.data) user = request.data.get('user') if not user: - response = jsonify({ + response = { 'status': 'error', 'message': 'Invalid request' - }) - response.status_code = 400 - return response + } + return response, status.HTTP_400_BAD_REQUEST if not Account.exists(username=user.get('username')): - response = jsonify({ + response = { 'status': 'error', 'message': 'Invalid credentials' - }) - response.status_code = 422 - return response + } + return response, status.HTTP_401_UNAUTHORIZED account = Account.get(username=user.get('username')) if not bcrypt.check_password_hash( account.password, user.get('password')): - response = jsonify({ + response = { 'status': 'error', 'message': 'Invalid credentials' - }) - response.status_code = 422 - return response + } + return response, status.HTTP_401_UNAUTHORIZED - response = jsonify({ + response = { 'status': 'success', 'message': 'Successfully logged in', 'token': account.create_auth_token() - }) - response.status_code = 200 - return response + } + return response, status.HTTP_200_OK diff --git a/app/mod_accounts/models.py b/app/accounts/models.py similarity index 84% rename from app/mod_accounts/models.py rename to app/accounts/models.py index deee03f..0a97cb9 100644 --- a/app/mod_accounts/models.py +++ b/app/accounts/models.py @@ -24,7 +24,7 @@ class Account(db.Model): def save(self): """ - Stores current user to database + Stores this user to database This may raise errors """ db.session.add(self) @@ -52,10 +52,24 @@ class Account(db.Model): @staticmethod def get_all(): + """ + Get all stored accounts + """ return Account.query.all() @staticmethod def get(**kwargs): + """ + Get accounts with given filters + + Available filters: + * username + * email + * role_id + * id + * password (useless, but not forbidden) + + """ return Account.query.filter_by(**kwargs).first() def create_auth_token(self): @@ -92,15 +106,25 @@ class Role(db.Model): self.display_name = str(name) def save(self): + """ + Stores this role to database + This may raise errors + """ db.session.add(self) db.session.commit() @staticmethod def get_all(): + """ + Get all stored roles + """ return Role.query.all() @staticmethod def get(roleId): + """ + Get role with id = roleId + """ return Role.query.filter_by(id=roleId) def __repr__(self): diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..251c937 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,11 @@ +from flask import Blueprint +from flask_restful import Api +from .resources.account import AccountResource +from .resources.token import TokenResource + +api_bp = Blueprint('api', __name__) +api = Api(api_bp) + +# Add resources +api.add_resource(AccountResource, '/v1/accounts') +api.add_resource(TokenResource, '/v1/token') diff --git a/app/api/resources/__init__.py b/app/api/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/resources/account.py b/app/api/resources/account.py new file mode 100644 index 0000000..db85f0b --- /dev/null +++ b/app/api/resources/account.py @@ -0,0 +1,43 @@ +from flask_restful import Resource, reqparse, abort +import app.accounts as accounts + + +def user(user_dict): + """ + Type definition of user object required as a parameter + + Required keys: + * username - string + * password - string + * email - string + + :returns user dictionary with required keys + :rtype dict + :raises ValueError if parameter is not dict or is missing required keys + """ + if not isinstance(user_dict, dict): + raise ValueError("User should contain username, password and email") + if ('username' not in user_dict or + 'password' not in user_dict or + 'email' not in user_dict): + raise ValueError("User should contain username, password and email") + return user_dict + + +class AccountResource(Resource): + parser = reqparse.RequestParser(bundle_errors=True) + parser.add_argument('user', location='json', type=user, + help='User is not valid. Error: {error_msg}', + required=True) + + def post(self): + try: + args = AccountResource.parser.parse_args()['user'] + success = accounts.create_account( + args['username'], + args['email'], + args['password']) + if success: + return '', 201 + except ValueError: + abort(422, message='Account already exists', status='error') diff --git a/app/api/resources/token.py b/app/api/resources/token.py new file mode 100644 index 0000000..926fb7f --- /dev/null +++ b/app/api/resources/token.py @@ -0,0 +1,46 @@ +from flask_restful import Resource, reqparse, abort, fields, marshal_with +import app.accounts as accounts + + +def user(user_dict): + """ + Type definition of user object required as a parameter + + Required keys: + * username - string + * password - string + + :returns user dictionary with required keys + :rtype dict + :raises ValueError if parameter is not dict or is missing required keys + """ + if not isinstance(user_dict, dict): + raise ValueError("User should contain username, password and email") + if ('username' not in user_dict or + 'password' not in user_dict): + raise ValueError("User should contain username, password and email") + return user_dict + + +class TokenResource(Resource): + parser = reqparse.RequestParser(bundle_errors=True) + parser.add_argument('user', location='json', type=user, + help='User is not valid. Error: {error_msg}', + required=True) + + res_fields = { + 'status': fields.String(default='Success'), + 'token': fields.String + } + + @marshal_with(res_fields) + def post(self): + try: + args = TokenResource.parser.parse_args()['user'] + token = accounts.create_token( + args['username'], + args['password']) + if token: + return {'token': token}, 200 + except ValueError: + abort(401, message='Invalid credentials', status='error') diff --git a/app/mod_devices/__init__.py b/app/devices/__init__.py similarity index 84% rename from app/mod_devices/__init__.py rename to app/devices/__init__.py index c4f2595..bf9faa4 100644 --- a/app/mod_devices/__init__.py +++ b/app/devices/__init__.py @@ -2,7 +2,7 @@ import atexit from flask import Blueprint from .mqtt_client import MqttClient -devices = Blueprint('devices', __name__) +devices_bp = Blueprint('devices', __name__) mqtt_client = None @@ -16,12 +16,12 @@ atexit.register(on_stop) # Routes -@devices.route("/") +@devices_bp.route("/") def hello(): return "Hello from devices!" -@devices.record +@devices_bp.record def on_blueprint_setup(setup_state): print('Blueprint setup') mqtt_client = MqttClient() diff --git a/app/mod_devices/models.py b/app/devices/models.py similarity index 100% rename from app/mod_devices/models.py rename to app/devices/models.py diff --git a/app/mod_devices/mqtt_client.py b/app/devices/mqtt_client.py similarity index 100% rename from app/mod_devices/mqtt_client.py rename to app/devices/mqtt_client.py diff --git a/app/mod_accounts/__init__.py b/app/mod_accounts/__init__.py deleted file mode 100644 index 347b528..0000000 --- a/app/mod_accounts/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from flask import Blueprint -from .controllers import initialize_routes - -accounts = Blueprint('accounts', __name__) - -initialize_routes(accounts) diff --git a/requirements.txt b/requirements.txt index 7656d74..ca7de3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,14 @@ alembic==0.9.9 +aniso8601==3.0.0 bcrypt==3.1.4 cffi==1.11.5 click==6.7 Flask==0.12.2 Flask-API==1.0 Flask-Bcrypt==0.7.1 -Flask-JWT==0.3.2 Flask-Migrate==2.1.1 Flask-MQTT==1.0.3 +Flask-RESTful==0.3.6 Flask-Script==2.0.6 Flask-SQLAlchemy==2.3.2 gunicorn==19.8.1 @@ -21,6 +22,7 @@ pycparser==2.18 PyJWT==1.4.2 python-dateutil==2.7.2 python-editor==1.0.3 +pytz==2018.4 six==1.11.0 SQLAlchemy==1.2.7 typing==3.6.4