├── .circleci └── config.yml ├── .gitignore ├── Makefile ├── README.md ├── app ├── __init__.py ├── main │ ├── __init__.py │ ├── config.py │ ├── controller │ │ ├── __init__.py │ │ ├── auth_controller.py │ │ └── user_controller.py │ ├── model │ │ ├── __init__.py │ │ ├── blacklist.py │ │ └── user.py │ ├── service │ │ ├── __init__.py │ │ ├── auth_helper.py │ │ ├── blacklist_service.py │ │ └── user_service.py │ └── util │ │ ├── __init__.py │ │ ├── decorator.py │ │ └── dto.py └── test │ ├── __init__.py │ ├── base.py │ ├── test_auth.py │ ├── test_config.py │ └── test_user_model.py ├── manage.py └── requirements.txt /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | python: circleci/python@0.2.1 5 | 6 | jobs: 7 | build-and-test: 8 | executor: python/default 9 | steps: 10 | - checkout 11 | - python/load-cache 12 | - python/install-deps 13 | - python/save-cache 14 | - run: 15 | command: make clean install 16 | name: Install Dependencies 17 | - run: 18 | command: make tests 19 | name: Run Test 20 | 21 | workflows: 22 | main: 23 | jobs: 24 | - build-and-test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # IDE related files 7 | .vscode 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | .idea/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.db 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # dotenv 88 | .env 89 | 90 | # virtualenv 91 | .venv 92 | venv/ 93 | ENV/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | /migrations -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | 3 | .PHONY: clean install tests run all 4 | 5 | clean: 6 | find . -type f -name '*.pyc' -delete 7 | find . -type f -name '*.log' -delete 8 | 9 | install: 10 | virtualenv venv; \ 11 | . venv/bin/activate; \ 12 | pip install -r requirements.txt; 13 | 14 | tests: 15 | . venv/bin/activate; \ 16 | python manage.py test 17 | 18 | run: 19 | . venv/bin/activate; \ 20 | python manage.py run 21 | 22 | all: clean install tests run 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #### FLASK RESTX BOILER-PLATE WITH JWT 2 | 3 | ### Terminal commands 4 | Note: make sure you have `pip` and `virtualenv` installed. 5 | 6 | Initial installation: make install 7 | 8 | To run test: make tests 9 | 10 | To run application: make run 11 | 12 | To run all commands at once : make all 13 | 14 | Make sure to run the initial migration commands to update the database. 15 | 16 | > python manage.py db init 17 | 18 | > python manage.py db migrate --message 'initial database migration' 19 | 20 | > python manage.py db upgrade 21 | 22 | 23 | ### Viewing the app ### 24 | 25 | Open the following url on your browser to view swagger documentation 26 | http://127.0.0.1:5000/ 27 | 28 | 29 | ### Using Postman #### 30 | 31 | Authorization header is in the following format: 32 | 33 | Key: Authorization 34 | Value: "token_generated_during_login" 35 | 36 | For testing authorization, url for getting all user requires an admin token while url for getting a single 37 | user by public_id requires just a regular authentication. 38 | 39 | ### Full description and guide ### 40 | https://medium.freecodecamp.org/structuring-a-flask-restplus-web-service-for-production-builds-c2ec676de563 41 | 42 | 43 | ### Contributing 44 | If you want to contribute to this flask restplus boilerplate, clone the repository and just start making pull requests. 45 | 46 | ``` 47 | https://github.com/cosmic-byte/flask-restplus-boilerplate.git 48 | ``` 49 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Api 2 | from flask import Blueprint 3 | 4 | from .main.controller.user_controller import api as user_ns 5 | from .main.controller.auth_controller import api as auth_ns 6 | 7 | blueprint = Blueprint('api', __name__) 8 | authorizations = { 9 | 'apikey': { 10 | 'type': 'apiKey', 11 | 'in': 'header', 12 | 'name': 'Authorization' 13 | } 14 | } 15 | 16 | api = Api( 17 | blueprint, 18 | title='FLASK RESTPLUS(RESTX) API BOILER-PLATE WITH JWT', 19 | version='1.0', 20 | description='a boilerplate for flask restplus (restx) web service', 21 | authorizations=authorizations, 22 | security='apikey' 23 | ) 24 | 25 | api.add_namespace(user_ns, path='/user') 26 | api.add_namespace(auth_ns) 27 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_bcrypt import Bcrypt 4 | 5 | from .config import config_by_name 6 | from flask.app import Flask 7 | 8 | db = SQLAlchemy() 9 | flask_bcrypt = Bcrypt() 10 | 11 | 12 | def create_app(config_name: str) -> Flask: 13 | app = Flask(__name__) 14 | app.config.from_object(config_by_name[config_name]) 15 | db.init_app(app) 16 | flask_bcrypt.init_app(app) 17 | 18 | return app 19 | -------------------------------------------------------------------------------- /app/main/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # uncomment the line below for postgres database url from environment variable 4 | # postgres_local_base = os.environ['DATABASE_URL'] 5 | 6 | basedir = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | class Config: 10 | SECRET_KEY = os.getenv('SECRET_KEY', 'my_precious_secret_key') 11 | DEBUG = False 12 | # Swagger 13 | RESTX_MASK_SWAGGER = False 14 | 15 | 16 | 17 | class DevelopmentConfig(Config): 18 | # uncomment the line below to use postgres 19 | # SQLALCHEMY_DATABASE_URI = postgres_local_base 20 | DEBUG = True 21 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'flask_boilerplate_main.db') 22 | SQLALCHEMY_TRACK_MODIFICATIONS = False 23 | 24 | 25 | class TestingConfig(Config): 26 | DEBUG = True 27 | TESTING = True 28 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'flask_boilerplate_test.db') 29 | PRESERVE_CONTEXT_ON_EXCEPTION = False 30 | SQLALCHEMY_TRACK_MODIFICATIONS = False 31 | 32 | 33 | class ProductionConfig(Config): 34 | DEBUG = False 35 | # uncomment the line below to use postgres 36 | # SQLALCHEMY_DATABASE_URI = postgres_local_base 37 | 38 | 39 | config_by_name = dict( 40 | dev=DevelopmentConfig, 41 | test=TestingConfig, 42 | prod=ProductionConfig 43 | ) 44 | 45 | key = Config.SECRET_KEY 46 | -------------------------------------------------------------------------------- /app/main/controller/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-byte/flask-restplus-boilerplate/3bd1d508a3c457b6a19c709baef47496467e5f2b/app/main/controller/__init__.py -------------------------------------------------------------------------------- /app/main/controller/auth_controller.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask_restx import Resource 3 | 4 | from app.main.service.auth_helper import Auth 5 | from ..util.dto import AuthDto 6 | from typing import Dict, Tuple 7 | 8 | api = AuthDto.api 9 | user_auth = AuthDto.user_auth 10 | 11 | 12 | @api.route('/login') 13 | class UserLogin(Resource): 14 | """ 15 | User Login Resource 16 | """ 17 | @api.doc('user login') 18 | @api.expect(user_auth, validate=True) 19 | def post(self) -> Tuple[Dict[str, str], int]: 20 | # get the post data 21 | post_data = request.json 22 | return Auth.login_user(data=post_data) 23 | 24 | 25 | @api.route('/logout') 26 | class LogoutAPI(Resource): 27 | """ 28 | Logout Resource 29 | """ 30 | @api.doc('logout a user') 31 | def post(self) -> Tuple[Dict[str, str], int]: 32 | # get auth token 33 | auth_header = request.headers.get('Authorization') 34 | return Auth.logout_user(data=auth_header) 35 | -------------------------------------------------------------------------------- /app/main/controller/user_controller.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask_restx import Resource 3 | 4 | from app.main.util.decorator import admin_token_required 5 | from ..util.dto import UserDto 6 | from ..service.user_service import save_new_user, get_all_users, get_a_user 7 | from typing import Dict, Tuple 8 | 9 | api = UserDto.api 10 | _user = UserDto.user 11 | 12 | 13 | @api.route('/') 14 | class UserList(Resource): 15 | @api.doc('list_of_registered_users') 16 | @admin_token_required 17 | @api.marshal_list_with(_user, envelope='data') 18 | def get(self): 19 | """List all registered users""" 20 | return get_all_users() 21 | 22 | @api.expect(_user, validate=True) 23 | @api.response(201, 'User successfully created.') 24 | @api.doc('create a new user') 25 | def post(self) -> Tuple[Dict[str, str], int]: 26 | """Creates a new User """ 27 | data = request.json 28 | return save_new_user(data=data) 29 | 30 | 31 | @api.route('/') 32 | @api.param('public_id', 'The User identifier') 33 | @api.response(404, 'User not found.') 34 | class User(Resource): 35 | @api.doc('get a user') 36 | @api.marshal_with(_user) 37 | def get(self, public_id): 38 | """get a user given its identifier""" 39 | user = get_a_user(public_id) 40 | if not user: 41 | api.abort(404) 42 | else: 43 | return user 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /app/main/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-byte/flask-restplus-boilerplate/3bd1d508a3c457b6a19c709baef47496467e5f2b/app/main/model/__init__.py -------------------------------------------------------------------------------- /app/main/model/blacklist.py: -------------------------------------------------------------------------------- 1 | from .. import db 2 | import datetime 3 | 4 | 5 | class BlacklistToken(db.Model): 6 | """ 7 | Token Model for storing JWT tokens 8 | """ 9 | __tablename__ = 'blacklist_tokens' 10 | 11 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 12 | token = db.Column(db.String(500), unique=True, nullable=False) 13 | blacklisted_on = db.Column(db.DateTime, nullable=False) 14 | 15 | def __init__(self, token): 16 | self.token = token 17 | self.blacklisted_on = datetime.datetime.now() 18 | 19 | def __repr__(self): 20 | return ' bool: 24 | # check whether auth token has been blacklisted 25 | res = BlacklistToken.query.filter_by(token=str(auth_token)).first() 26 | if res: 27 | return True 28 | else: 29 | return False 30 | -------------------------------------------------------------------------------- /app/main/model/user.py: -------------------------------------------------------------------------------- 1 | 2 | from .. import db, flask_bcrypt 3 | import datetime 4 | from app.main.model.blacklist import BlacklistToken 5 | from ..config import key 6 | import jwt 7 | from typing import Union 8 | 9 | 10 | class User(db.Model): 11 | """ User Model for storing user related details """ 12 | __tablename__ = "user" 13 | 14 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 15 | email = db.Column(db.String(255), unique=True, nullable=False) 16 | registered_on = db.Column(db.DateTime, nullable=False) 17 | admin = db.Column(db.Boolean, nullable=False, default=False) 18 | public_id = db.Column(db.String(100), unique=True) 19 | username = db.Column(db.String(50), unique=True) 20 | password_hash = db.Column(db.String(100)) 21 | 22 | @property 23 | def password(self): 24 | raise AttributeError('password: write-only field') 25 | 26 | @password.setter 27 | def password(self, password): 28 | self.password_hash = flask_bcrypt.generate_password_hash(password).decode('utf-8') 29 | 30 | def check_password(self, password: str) -> bool: 31 | return flask_bcrypt.check_password_hash(self.password_hash, password) 32 | 33 | @staticmethod 34 | def encode_auth_token(user_id: int) -> bytes: 35 | """ 36 | Generates the Auth Token 37 | :return: string 38 | """ 39 | try: 40 | payload = { 41 | 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=1, seconds=5), 42 | 'iat': datetime.datetime.utcnow(), 43 | 'sub': user_id 44 | } 45 | return jwt.encode( 46 | payload, 47 | key, 48 | algorithm='HS256' 49 | ) 50 | except Exception as e: 51 | return e 52 | 53 | @staticmethod 54 | def decode_auth_token(auth_token: str) -> Union[str, int]: 55 | """ 56 | Decodes the auth token 57 | :param auth_token: 58 | :return: integer|string 59 | """ 60 | try: 61 | payload = jwt.decode(auth_token, key) 62 | is_blacklisted_token = BlacklistToken.check_blacklist(auth_token) 63 | if is_blacklisted_token: 64 | return 'Token blacklisted. Please log in again.' 65 | else: 66 | return payload['sub'] 67 | except jwt.ExpiredSignatureError: 68 | return 'Signature expired. Please log in again.' 69 | except jwt.InvalidTokenError: 70 | return 'Invalid token. Please log in again.' 71 | 72 | def __repr__(self): 73 | return "".format(self.username) 74 | -------------------------------------------------------------------------------- /app/main/service/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-byte/flask-restplus-boilerplate/3bd1d508a3c457b6a19c709baef47496467e5f2b/app/main/service/__init__.py -------------------------------------------------------------------------------- /app/main/service/auth_helper.py: -------------------------------------------------------------------------------- 1 | from app.main.model.user import User 2 | from ..service.blacklist_service import save_token 3 | from typing import Dict, Tuple 4 | 5 | 6 | class Auth: 7 | 8 | @staticmethod 9 | def login_user(data: Dict[str, str]) -> Tuple[Dict[str, str], int]: 10 | try: 11 | # fetch the user data 12 | user = User.query.filter_by(email=data.get('email')).first() 13 | if user and user.check_password(data.get('password')): 14 | auth_token = User.encode_auth_token(user.id) 15 | if auth_token: 16 | response_object = { 17 | 'status': 'success', 18 | 'message': 'Successfully logged in.', 19 | 'Authorization': auth_token.decode() 20 | } 21 | return response_object, 200 22 | else: 23 | response_object = { 24 | 'status': 'fail', 25 | 'message': 'email or password does not match.' 26 | } 27 | return response_object, 401 28 | 29 | except Exception as e: 30 | print(e) 31 | response_object = { 32 | 'status': 'fail', 33 | 'message': 'Try again' 34 | } 35 | return response_object, 500 36 | 37 | @staticmethod 38 | def logout_user(data: str) -> Tuple[Dict[str, str], int]: 39 | if data: 40 | auth_token = data.split(" ")[1] 41 | else: 42 | auth_token = '' 43 | if auth_token: 44 | resp = User.decode_auth_token(auth_token) 45 | if not isinstance(resp, str): 46 | # mark the token as blacklisted 47 | return save_token(token=auth_token) 48 | else: 49 | response_object = { 50 | 'status': 'fail', 51 | 'message': resp 52 | } 53 | return response_object, 401 54 | else: 55 | response_object = { 56 | 'status': 'fail', 57 | 'message': 'Provide a valid auth token.' 58 | } 59 | return response_object, 403 60 | 61 | @staticmethod 62 | def get_logged_in_user(new_request): 63 | # get the auth token 64 | auth_token = new_request.headers.get('Authorization') 65 | if auth_token: 66 | resp = User.decode_auth_token(auth_token) 67 | if not isinstance(resp, str): 68 | user = User.query.filter_by(id=resp).first() 69 | response_object = { 70 | 'status': 'success', 71 | 'data': { 72 | 'user_id': user.id, 73 | 'email': user.email, 74 | 'admin': user.admin, 75 | 'registered_on': str(user.registered_on) 76 | } 77 | } 78 | return response_object, 200 79 | response_object = { 80 | 'status': 'fail', 81 | 'message': resp 82 | } 83 | return response_object, 401 84 | else: 85 | response_object = { 86 | 'status': 'fail', 87 | 'message': 'Provide a valid auth token.' 88 | } 89 | return response_object, 401 90 | -------------------------------------------------------------------------------- /app/main/service/blacklist_service.py: -------------------------------------------------------------------------------- 1 | from app.main import db 2 | 3 | from app.main.model.blacklist import BlacklistToken 4 | from typing import Dict, Tuple 5 | 6 | 7 | def save_token(token: str) -> Tuple[Dict[str, str], int]: 8 | blacklist_token = BlacklistToken(token=token) 9 | try: 10 | # insert the token 11 | db.session.add(blacklist_token) 12 | db.session.commit() 13 | response_object = { 14 | 'status': 'success', 15 | 'message': 'Successfully logged out.' 16 | } 17 | return response_object, 200 18 | except Exception as e: 19 | response_object = { 20 | 'status': 'fail', 21 | 'message': e 22 | } 23 | return response_object, 200 24 | -------------------------------------------------------------------------------- /app/main/service/user_service.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import datetime 3 | 4 | from app.main import db 5 | from app.main.model.user import User 6 | from typing import Dict, Tuple 7 | 8 | 9 | def save_new_user(data: Dict[str, str]) -> Tuple[Dict[str, str], int]: 10 | user = User.query.filter_by(email=data['email']).first() 11 | if not user: 12 | new_user = User( 13 | public_id=str(uuid.uuid4()), 14 | email=data['email'], 15 | username=data['username'], 16 | password=data['password'], 17 | registered_on=datetime.datetime.utcnow() 18 | ) 19 | save_changes(new_user) 20 | return generate_token(new_user) 21 | else: 22 | response_object = { 23 | 'status': 'fail', 24 | 'message': 'User already exists. Please Log in.', 25 | } 26 | return response_object, 409 27 | 28 | 29 | def get_all_users(): 30 | return User.query.all() 31 | 32 | 33 | def get_a_user(public_id): 34 | return User.query.filter_by(public_id=public_id).first() 35 | 36 | 37 | def generate_token(user: User) -> Tuple[Dict[str, str], int]: 38 | try: 39 | # generate the auth token 40 | auth_token = User.encode_auth_token(user.id) 41 | response_object = { 42 | 'status': 'success', 43 | 'message': 'Successfully registered.', 44 | 'Authorization': auth_token.decode() 45 | } 46 | return response_object, 201 47 | except Exception as e: 48 | response_object = { 49 | 'status': 'fail', 50 | 'message': 'Some error occurred. Please try again.' 51 | } 52 | return response_object, 401 53 | 54 | 55 | def save_changes(data: User) -> None: 56 | db.session.add(data) 57 | db.session.commit() 58 | 59 | -------------------------------------------------------------------------------- /app/main/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-byte/flask-restplus-boilerplate/3bd1d508a3c457b6a19c709baef47496467e5f2b/app/main/util/__init__.py -------------------------------------------------------------------------------- /app/main/util/decorator.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import request 4 | 5 | from app.main.service.auth_helper import Auth 6 | from typing import Callable 7 | 8 | 9 | def token_required(f) -> Callable: 10 | @wraps(f) 11 | def decorated(*args, **kwargs): 12 | 13 | data, status = Auth.get_logged_in_user(request) 14 | token = data.get('data') 15 | 16 | if not token: 17 | return data, status 18 | 19 | return f(*args, **kwargs) 20 | 21 | return decorated 22 | 23 | 24 | def admin_token_required(f: Callable) -> Callable: 25 | @wraps(f) 26 | def decorated(*args, **kwargs): 27 | 28 | data, status = Auth.get_logged_in_user(request) 29 | token = data.get('data') 30 | 31 | if not token: 32 | return data, status 33 | 34 | admin = token.get('admin') 35 | if not admin: 36 | response_object = { 37 | 'status': 'fail', 38 | 'message': 'admin token required' 39 | } 40 | return response_object, 401 41 | 42 | return f(*args, **kwargs) 43 | 44 | return decorated 45 | -------------------------------------------------------------------------------- /app/main/util/dto.py: -------------------------------------------------------------------------------- 1 | from flask_restx import Namespace, fields 2 | 3 | 4 | class UserDto: 5 | api = Namespace('user', description='user related operations') 6 | user = api.model('user', { 7 | 'email': fields.String(required=True, description='user email address'), 8 | 'username': fields.String(required=True, description='user username'), 9 | 'password': fields.String(required=True, description='user password'), 10 | 'public_id': fields.String(description='user Identifier') 11 | }) 12 | 13 | 14 | class AuthDto: 15 | api = Namespace('auth', description='authentication related operations') 16 | user_auth = api.model('auth_details', { 17 | 'email': fields.String(required=True, description='The email address'), 18 | 'password': fields.String(required=True, description='The user password '), 19 | }) 20 | -------------------------------------------------------------------------------- /app/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmic-byte/flask-restplus-boilerplate/3bd1d508a3c457b6a19c709baef47496467e5f2b/app/test/__init__.py -------------------------------------------------------------------------------- /app/test/base.py: -------------------------------------------------------------------------------- 1 | 2 | from flask_testing import TestCase 3 | 4 | from app.main import db 5 | from manage import app 6 | 7 | 8 | class BaseTestCase(TestCase): 9 | """ Base Tests """ 10 | 11 | def create_app(self): 12 | app.config.from_object('app.main.config.TestingConfig') 13 | return app 14 | 15 | def setUp(self): 16 | db.create_all() 17 | db.session.commit() 18 | 19 | def tearDown(self): 20 | db.session.remove() 21 | db.drop_all() 22 | -------------------------------------------------------------------------------- /app/test/test_auth.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from app.main import db 4 | from app.main.model.blacklist import BlacklistToken 5 | import json 6 | from app.test.base import BaseTestCase 7 | 8 | 9 | def register_user(self): 10 | return self.client.post( 11 | '/user/', 12 | data=json.dumps(dict( 13 | email='joe@gmail.com', 14 | username='username', 15 | password='123456' 16 | )), 17 | content_type='application/json' 18 | ) 19 | 20 | 21 | def login_user(self): 22 | return self.client.post( 23 | '/auth/login', 24 | data=json.dumps(dict( 25 | email='joe@gmail.com', 26 | password='123456' 27 | )), 28 | content_type='application/json' 29 | ) 30 | 31 | 32 | class TestAuthBlueprint(BaseTestCase): 33 | def test_registration(self): 34 | """ Test for user registration """ 35 | with self.client: 36 | response = register_user(self) 37 | data = json.loads(response.data.decode()) 38 | self.assertTrue(data['status'] == 'success') 39 | self.assertTrue(data['message'] == 'Successfully registered.') 40 | self.assertTrue(data['Authorization']) 41 | self.assertTrue(response.content_type == 'application/json') 42 | self.assertEqual(response.status_code, 201) 43 | 44 | def test_registered_with_already_registered_user(self): 45 | """ Test registration with already registered email""" 46 | register_user(self) 47 | with self.client: 48 | response = register_user(self) 49 | data = json.loads(response.data.decode()) 50 | self.assertTrue(data['status'] == 'fail') 51 | self.assertTrue( 52 | data['message'] == 'User already exists. Please Log in.') 53 | self.assertTrue(response.content_type == 'application/json') 54 | self.assertEqual(response.status_code, 409) 55 | 56 | def test_registered_user_login(self): 57 | """ Test for login of registered-user login """ 58 | with self.client: 59 | # user registration 60 | resp_register = register_user(self) 61 | data_register = json.loads(resp_register.data.decode()) 62 | self.assertTrue(data_register['status'] == 'success') 63 | self.assertTrue( 64 | data_register['message'] == 'Successfully registered.' 65 | ) 66 | self.assertTrue(data_register['Authorization']) 67 | self.assertTrue(resp_register.content_type == 'application/json') 68 | self.assertEqual(resp_register.status_code, 201) 69 | # registered user login 70 | response = login_user(self) 71 | data = json.loads(response.data.decode()) 72 | self.assertTrue(data['status'] == 'success') 73 | self.assertTrue(data['message'] == 'Successfully logged in.') 74 | self.assertTrue(data['Authorization']) 75 | self.assertTrue(response.content_type == 'application/json') 76 | self.assertEqual(response.status_code, 200) 77 | 78 | def test_non_registered_user_login(self): 79 | """ Test for login of non-registered user """ 80 | with self.client: 81 | response = login_user(self) 82 | data = json.loads(response.data.decode()) 83 | self.assertTrue(data['status'] == 'fail') 84 | print(data['message']) 85 | self.assertTrue(data['message'] == 'email or password does not match.') 86 | self.assertTrue(response.content_type == 'application/json') 87 | self.assertEqual(response.status_code, 401) 88 | 89 | def test_valid_logout(self): 90 | """ Test for logout before token expires """ 91 | with self.client: 92 | # user registration 93 | resp_register = register_user(self) 94 | data_register = json.loads(resp_register.data.decode()) 95 | self.assertTrue(data_register['status'] == 'success') 96 | self.assertTrue( 97 | data_register['message'] == 'Successfully registered.') 98 | self.assertTrue(data_register['Authorization']) 99 | self.assertTrue(resp_register.content_type == 'application/json') 100 | self.assertEqual(resp_register.status_code, 201) 101 | # user login 102 | resp_login = login_user(self) 103 | data_login = json.loads(resp_login.data.decode()) 104 | self.assertTrue(data_login['status'] == 'success') 105 | self.assertTrue(data_login['message'] == 'Successfully logged in.') 106 | self.assertTrue(data_login['Authorization']) 107 | self.assertTrue(resp_login.content_type == 'application/json') 108 | self.assertEqual(resp_login.status_code, 200) 109 | # valid token logout 110 | response = self.client.post( 111 | '/auth/logout', 112 | headers=dict( 113 | Authorization='Bearer ' + json.loads( 114 | resp_login.data.decode() 115 | )['Authorization'] 116 | ) 117 | ) 118 | data = json.loads(response.data.decode()) 119 | self.assertTrue(data['status'] == 'success') 120 | self.assertTrue(data['message'] == 'Successfully logged out.') 121 | self.assertEqual(response.status_code, 200) 122 | 123 | def test_valid_blacklisted_token_logout(self): 124 | """ Test for logout after a valid token gets blacklisted """ 125 | with self.client: 126 | # user registration 127 | resp_register = register_user(self) 128 | data_register = json.loads(resp_register.data.decode()) 129 | self.assertTrue(data_register['status'] == 'success') 130 | self.assertTrue( 131 | data_register['message'] == 'Successfully registered.') 132 | self.assertTrue(data_register['Authorization']) 133 | self.assertTrue(resp_register.content_type == 'application/json') 134 | self.assertEqual(resp_register.status_code, 201) 135 | # user login 136 | resp_login = login_user(self) 137 | data_login = json.loads(resp_login.data.decode()) 138 | self.assertTrue(data_login['status'] == 'success') 139 | self.assertTrue(data_login['message'] == 'Successfully logged in.') 140 | self.assertTrue(data_login['Authorization']) 141 | self.assertTrue(resp_login.content_type == 'application/json') 142 | self.assertEqual(resp_login.status_code, 200) 143 | # blacklist a valid token 144 | blacklist_token = BlacklistToken( 145 | token=json.loads(resp_login.data.decode())['Authorization']) 146 | db.session.add(blacklist_token) 147 | db.session.commit() 148 | # blacklisted valid token logout 149 | response = self.client.post( 150 | '/auth/logout', 151 | headers=dict( 152 | Authorization='Bearer ' + json.loads( 153 | resp_login.data.decode() 154 | )['Authorization'] 155 | ) 156 | ) 157 | data = json.loads(response.data.decode()) 158 | self.assertTrue(data['status'] == 'fail') 159 | self.assertTrue(data['message'] == 'Token blacklisted. Please log in again.') 160 | self.assertEqual(response.status_code, 401) 161 | 162 | 163 | if __name__ == '__main__': 164 | unittest.main() 165 | -------------------------------------------------------------------------------- /app/test/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from flask import current_app 5 | from flask_testing import TestCase 6 | 7 | from manage import app 8 | from app.main.config import basedir 9 | 10 | 11 | class TestDevelopmentConfig(TestCase): 12 | def create_app(self): 13 | app.config.from_object('app.main.config.DevelopmentConfig') 14 | return app 15 | 16 | def test_app_is_development(self): 17 | self.assertFalse(app.config['SECRET_KEY'] is 'my_precious') 18 | self.assertTrue(app.config['DEBUG'] is True) 19 | self.assertFalse(current_app is None) 20 | self.assertTrue( 21 | app.config['SQLALCHEMY_DATABASE_URI'] == 'sqlite:///' + os.path.join(basedir, 'flask_boilerplate_main.db') 22 | ) 23 | 24 | 25 | class TestTestingConfig(TestCase): 26 | def create_app(self): 27 | app.config.from_object('app.main.config.TestingConfig') 28 | return app 29 | 30 | def test_app_is_testing(self): 31 | self.assertFalse(app.config['SECRET_KEY'] is 'my_precious') 32 | self.assertTrue(app.config['DEBUG']) 33 | self.assertTrue( 34 | app.config['SQLALCHEMY_DATABASE_URI'] == 'sqlite:///' + os.path.join(basedir, 'flask_boilerplate_test.db') 35 | ) 36 | 37 | 38 | class TestProductionConfig(TestCase): 39 | def create_app(self): 40 | app.config.from_object('app.main.config.ProductionConfig') 41 | return app 42 | 43 | def test_app_is_production(self): 44 | self.assertTrue(app.config['DEBUG'] is False) 45 | 46 | 47 | if __name__ == '__main__': 48 | unittest.main() -------------------------------------------------------------------------------- /app/test/test_user_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import datetime 4 | 5 | from app.main import db 6 | from app.main.model.user import User 7 | from app.test.base import BaseTestCase 8 | 9 | 10 | class TestUserModel(BaseTestCase): 11 | 12 | def test_encode_auth_token(self): 13 | user = User( 14 | email='test@test.com', 15 | password='test', 16 | registered_on=datetime.datetime.utcnow() 17 | ) 18 | db.session.add(user) 19 | db.session.commit() 20 | auth_token = User.encode_auth_token(user.id) 21 | self.assertTrue(isinstance(auth_token, bytes)) 22 | 23 | def test_decode_auth_token(self): 24 | user = User( 25 | email='test@test.com', 26 | password='test', 27 | registered_on=datetime.datetime.utcnow() 28 | ) 29 | db.session.add(user) 30 | db.session.commit() 31 | auth_token = User.encode_auth_token(user.id) 32 | self.assertTrue(isinstance(auth_token, bytes)) 33 | self.assertTrue(User.decode_auth_token(auth_token.decode("utf-8") ) == 1) 34 | 35 | 36 | if __name__ == '__main__': 37 | unittest.main() 38 | 39 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from flask_migrate import Migrate, MigrateCommand 5 | from flask_script import Manager 6 | 7 | from app import blueprint 8 | from app.main import create_app, db 9 | from app.main.model import user, blacklist 10 | 11 | app = create_app(os.getenv('BOILERPLATE_ENV') or 'dev') 12 | app.register_blueprint(blueprint) 13 | 14 | app.app_context().push() 15 | 16 | manager = Manager(app) 17 | 18 | migrate = Migrate(app, db) 19 | 20 | manager.add_command('db', MigrateCommand) 21 | 22 | 23 | @manager.command 24 | def run(): 25 | app.run() 26 | 27 | 28 | @manager.command 29 | def test(): 30 | """Runs the unit tests.""" 31 | tests = unittest.TestLoader().discover('app/test', pattern='test*.py') 32 | result = unittest.TextTestRunner(verbosity=2).run(tests) 33 | if result.wasSuccessful(): 34 | return 0 35 | return 1 36 | 37 | if __name__ == '__main__': 38 | manager.run() 39 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==0.9.6 2 | bcrypt==3.1.4 3 | click==6.7 4 | coverage==4.4.2 5 | eventlet==0.31.0 6 | flask>=0.12.3 7 | Flask-Bcrypt==0.7.1 8 | Flask-Cors==3.0.9 9 | Flask-Migrate==2.1.1 10 | flask-restx==0.5.1 11 | Flask-Script==2.0.6 12 | Flask-SQLAlchemy==2.5.1 13 | Flask-Testing==0.7.1 14 | gunicorn==19.7.1 15 | itsdangerous==0.24 16 | Jinja2>=2.10.1 17 | jsonschema==2.6.0 18 | Mako==1.0.7 19 | psycopg2-binary==2.8.5 20 | pycparser==2.18 21 | PyJWT==1.5.3 22 | python-dateutil==2.6.1 23 | pytz==2017.3 24 | selenium==3.8.1 25 | six==1.11.0 26 | SQLAlchemy>=1.3.0 27 | Werkzeug==0.15.5 28 | --------------------------------------------------------------------------------