├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── config.py ├── middleware │ ├── __init__.py │ ├── auth.py │ ├── body_parser.py │ ├── rate_limit.py │ └── tests.py ├── routes.py ├── user │ ├── __init__.py │ ├── handlers.py │ ├── tests.py │ └── validation.py └── utils │ ├── __init__.py │ ├── auth.py │ ├── database.py │ ├── hooks.py │ ├── misc.py │ ├── rate_limit.py │ ├── testing.py │ └── tests.py ├── docker-compose.yml ├── local_migrate.sh ├── requirements.txt └── sql ├── V1__Create_app_users_table.sql ├── V2__Create_sp_user_insert.sql ├── V3__Create_sp_lookup_user_by_email.sql ├── V4__Create_app_password_reset_table.sql ├── V5__Create_sp_reset_password.sql └── V6__Create_sp_reset_password_request.sql /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | cover/* 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # Sublime project files 61 | *.sublime-project 62 | *.sublime-workspace 63 | 64 | # Flyway 65 | flyway-3.2.1 66 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | 3 | RUN apt-get update && apt-get -y install default-jre unzip socat 4 | RUN wget http://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/3.2.1/flyway-commandline-3.2.1.zip && unzip flyway-commandline-3.2.1.zip -d /opt && chmod a+x /opt/flyway-3.2.1/flyway 5 | ENV PATH $PATH:/opt/flyway-3.2.1 6 | 7 | ADD ./sql /opt/flyway-3.2.1/sql/ 8 | ADD . /src/ 9 | WORKDIR /src 10 | RUN pip install -r requirements.txt 11 | 12 | EXPOSE 5000 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Brian Hines 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A Falcon Seed Project 2 | ==================== 3 | 4 | "Falcon follows the REST architectural style, meaning (among other things) that you think in terms of resources and state transitions, which map to HTTP verbs." - [http://falconframework.org/](http://falconframework.org/) 5 | 6 | * [https://github.com/falconry/falcon](https://github.com/falconry/falcon) 7 | * [http://falcon.readthedocs.org/en/latest/](http://falcon.readthedocs.org/en/latest/) 8 | * [Python 3.5](https://docs.python.org/3/whatsnew/3.5.html) 9 | 10 | 11 | 12 | Environment Variables 13 | ==================== 14 | 15 | There are just a couple of configurations managed as environment variables. In the development environment, these are injected by Docker Compose and managed in the `docker-compose.yml` file. 16 | 17 | * `DATABASE_URL` - This is the connection URL for the PostgreSQL database. It is not used in the **development environment**. 18 | * `DEBUG` - This toggles debug mode for the app to True/False. 19 | * `SECRET_KEY` - This is a secret string that you make up. It is used to encrypt and verify the authentication token on routes that require authentication. This is required. The app won't start without it. 20 | 21 | 22 | 23 | Database Migrations 24 | ==================== 25 | 26 | Database migrations are handled by [Flyway](http://flywaydb.org/) and files are stored in the `/sql` directory. Migrations are automatically applied when running tests with Nose. You can run migrations manually in the development environment using `docker-compose` too. To do this you will first need to identify the IP address assigned to the database by [checking available environment variables](https://docs.docker.com/compose/env/). 27 | 28 | ``` 29 | docker-compose run web ./local_migrate.sh 30 | ``` 31 | 32 | 33 | 34 | Running Tests 35 | ==================== 36 | 37 | Tests, with code coverage reporting can be ran with the following command: 38 | ``` 39 | docker-compose run web nosetests -v --with-coverage --cover-package=app --cover-xml --cover-html 40 | ``` 41 | 42 | 43 | API Routes 44 | ==================== 45 | 46 | 47 | ### Authenticate a user 48 | 49 | **POST:** 50 | ``` 51 | /v1/authenticate 52 | ``` 53 | 54 | **Body:** 55 | ```json 56 | { 57 | "email": "something@email.com", 58 | "password": "12345678" 59 | } 60 | ``` 61 | 62 | **Response:** 63 | ```json 64 | { 65 | "token": "reallylongjsonwebtokenstring" 66 | } 67 | ``` 68 | 69 | **Status Codes:** 70 | * `200` if successful 71 | * `400` if incorrect data provided 72 | * `401` if invalid credentials 73 | 74 | 75 | ### Register a user 76 | 77 | **POST:** 78 | ``` 79 | /v1/user 80 | ``` 81 | 82 | **Body:** 83 | ```json 84 | { 85 | "email": "something@email.com", 86 | "password": "12345678" 87 | } 88 | ``` 89 | 90 | **Response:** 91 | ```json 92 | { 93 | "token": "reallylongjsonwebtokenstring" 94 | } 95 | ``` 96 | 97 | **Status Codes:** 98 | * `201` if successful 99 | * `400` if incorrect data provided 100 | * `409` if email is in use 101 | 102 | 103 | ### Request a password reset 104 | 105 | **POST:** 106 | ``` 107 | /v1/password-reset/request 108 | ``` 109 | 110 | **Body:** 111 | ```json 112 | { 113 | "email": "something@email.com" 114 | } 115 | ``` 116 | 117 | **Response:** None 118 | 119 | **Status Codes:** 120 | * `201` if successful 121 | * `400` if incorrect data provided 122 | 123 | 124 | ### Confirm a password reset 125 | 126 | **POST:** 127 | ``` 128 | /v1/password-reset/confirm 129 | ``` 130 | 131 | **Body:** 132 | ```json 133 | { 134 | "code": "6afc2148-5e2f-4c71-93a9-d250f90fccc2", 135 | "password": "MyNewPassword" 136 | } 137 | ``` 138 | 139 | **Response:** None 140 | 141 | **Status Codes:** 142 | * `200` if successful 143 | * `400` if incorrect data provided 144 | * `401` if code not valid 145 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from app.middleware.body_parser import JSONBodyParser 3 | from app.middleware.auth import AuthUser 4 | from app.utils.database import database_connection 5 | 6 | 7 | db = database_connection() 8 | 9 | middleware = [JSONBodyParser(), AuthUser()] 10 | 11 | api = falcon.API(middleware=middleware) 12 | 13 | 14 | from app import routes 15 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | TWO_WEEKS = 1209600 5 | 6 | SECRET_KEY = os.getenv('SECRET_KEY', None) 7 | assert SECRET_KEY 8 | 9 | TOKEN_EXPIRES = TWO_WEEKS 10 | 11 | DATABASE_URL = os.getenv( 12 | 'DATABASE_URL', 13 | 'postgres://postgres@{0}:5432/postgres'.format(os.getenv('DB_PORT_5432_TCP_ADDR', None))) 14 | assert DATABASE_URL 15 | 16 | REDIS_HOST = os.getenv('REDIS_HOST', os.getenv('REDIS_PORT_6379_TCP_ADDR', None)) 17 | REDIS_PASSWORD = os.getenv('REDIS_PASSWORD', None) 18 | -------------------------------------------------------------------------------- /app/middleware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectweekend/Falcon-PostgreSQL-API-Seed/d898636c6f3feb5a9f802d55ccae704acc5a060e/app/middleware/__init__.py -------------------------------------------------------------------------------- /app/middleware/auth.py: -------------------------------------------------------------------------------- 1 | from app.utils.auth import verify_token 2 | 3 | 4 | class AuthUser(object): 5 | 6 | def process_request(self, req, res): 7 | req.context['auth_user'] = verify_token(req.auth) if req.auth else None 8 | -------------------------------------------------------------------------------- /app/middleware/body_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | 4 | 5 | class JSONBodyParser(object): 6 | 7 | def process_request(self, req, res): 8 | if req.content_type == 'application/json': 9 | body = req.stream.read().decode('utf-8') 10 | try: 11 | req.context['data'] = json.loads(body) 12 | except ValueError: 13 | message = "Request body is not valid 'application/json'" 14 | raise falcon.HTTPBadRequest('Bad request', message) 15 | -------------------------------------------------------------------------------- /app/middleware/rate_limit.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | import falcon 3 | from app.utils.rate_limit import redis_connection 4 | 5 | 6 | class RateLimiter(object): 7 | 8 | def __init__(self, limit=100, window=60): 9 | self.limit = limit 10 | self.window = window 11 | self.redis = redis_connection() 12 | 13 | def process_request(self, req, res): 14 | requester = req.env['REMOTE_ADDR'] 15 | if req.context['auth_user']: 16 | requester = req.context['auth_user']['email'] 17 | key = "{0}: {1}".format(requester, req.path) 18 | 19 | try: 20 | remaining = self.limit - int(self.redis.get(key)) 21 | except (ValueError, TypeError): 22 | remaining = self.limit 23 | self.redis.set(key, 0) 24 | 25 | expires_in = self.redis.ttl(key) 26 | if expires_in == -1: 27 | self.redis.expire(key, self.window) 28 | expires_in = self.window 29 | 30 | res.append_header('X-RateLimit-Remaining', remaining) 31 | res.append_header('X-RateLimit-Limit', self.limit) 32 | res.append_header('X-RateLimit-Reset', time() + expires_in) 33 | 34 | if remaining > 0: 35 | self.redis.incr(key, 1) 36 | else: 37 | raise falcon.HTTPError(status=429, title='Too Many Requests') 38 | -------------------------------------------------------------------------------- /app/middleware/tests.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from falcon.testing import TestBase 3 | from app import api 4 | 5 | 6 | class MiddlewareTestCase(TestBase): 7 | 8 | def test_json_body_parser(self): 9 | self.api = api 10 | self.simulate_request( 11 | path='/user', 12 | method='POST', 13 | headers={'Content-Type': 'application/json'}, 14 | body='not json') 15 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 16 | -------------------------------------------------------------------------------- /app/routes.py: -------------------------------------------------------------------------------- 1 | from app import api 2 | from app.user import handlers as user_handlers 3 | 4 | 5 | api.add_route('/v1/user', user_handlers.UserResource()) 6 | api.add_route('/v1/authenticate', user_handlers.AuthenticationResource()) 7 | api.add_route('/v1/password-reset/request', user_handlers.PasswordResetRequestResource()) 8 | api.add_route('/v1/password-reset/confirm', user_handlers.PasswordResetConfirmResource()) 9 | -------------------------------------------------------------------------------- /app/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectweekend/Falcon-PostgreSQL-API-Seed/d898636c6f3feb5a9f802d55ccae704acc5a060e/app/user/__init__.py -------------------------------------------------------------------------------- /app/user/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import falcon 3 | from psycopg2 import IntegrityError 4 | from app.utils.auth import hash_password, verify_password, generate_token 5 | from app.utils.hooks import open_cursor_hook, close_cursor_hook, auth_required 6 | from app.utils.misc import make_code 7 | from app.user.validation import ( 8 | validate_user_create, validate_user_auth, validate_request_password_reset, 9 | validate_confirm_password_reset) 10 | 11 | 12 | @falcon.before(open_cursor_hook) 13 | @falcon.after(close_cursor_hook) 14 | class UserResource(object): 15 | 16 | @falcon.before(validate_user_create) 17 | def on_post(self, req, res): 18 | email = req.context['data']['email'] 19 | password = hash_password(req.context['data']['password']) 20 | 21 | try: 22 | self.cursor.callproc('sp_user_insert', [email, password]) 23 | except IntegrityError: 24 | title = 'Conflict' 25 | description = 'Email in use' 26 | raise falcon.HTTPConflict(title, description) 27 | 28 | result = self.cursor.fetchone()[0] 29 | 30 | res.status = falcon.HTTP_201 31 | res.body = json.dumps({ 32 | 'token': generate_token(result) 33 | }) 34 | 35 | 36 | @falcon.before(open_cursor_hook) 37 | @falcon.after(close_cursor_hook) 38 | class AuthenticationResource(object): 39 | 40 | @falcon.before(validate_user_auth) 41 | def on_post(self, req, res): 42 | unauthorized_title = 'Unauthorized' 43 | unauthorized_description = 'Invalid credentials' 44 | 45 | email = req.context['data']['email'] 46 | password = req.context['data']['password'] 47 | 48 | self.cursor.callproc('sp_lookup_user_by_email', [email, ]) 49 | 50 | result = self.cursor.fetchone() 51 | if result is None: 52 | raise falcon.HTTPUnauthorized(unauthorized_title, unauthorized_description) 53 | 54 | result = result[0] 55 | 56 | valid_password = verify_password(password, result.pop('password')) 57 | if not valid_password: 58 | raise falcon.HTTPUnauthorized(unauthorized_title, unauthorized_description) 59 | 60 | res.status = falcon.HTTP_200 61 | res.body = json.dumps({ 62 | 'token': generate_token(result) 63 | }) 64 | 65 | 66 | @falcon.before(open_cursor_hook) 67 | @falcon.after(close_cursor_hook) 68 | class PasswordResetRequestResource(object): 69 | 70 | @falcon.before(validate_request_password_reset) 71 | def on_post(self, req, res): 72 | email = req.context['data']['email'] 73 | self.cursor.callproc('sp_reset_password_request', [email, make_code(), ]) 74 | res.status = falcon.HTTP_201 75 | res.body = json.dumps({}) 76 | 77 | 78 | @falcon.before(open_cursor_hook) 79 | @falcon.after(close_cursor_hook) 80 | class PasswordResetConfirmResource(object): 81 | 82 | @falcon.before(validate_confirm_password_reset) 83 | def on_post(self, req, res): 84 | code = req.context['data']['code'] 85 | password = hash_password(req.context['data']['password']) 86 | self.cursor.callproc('sp_reset_password', [code, password, ]) 87 | result = self.cursor.fetchone() 88 | res.status = falcon.HTTP_200 if result[0] else falcon.HTTP_401 89 | res.body = json.dumps({}) 90 | 91 | 92 | # Handlers for test routes 93 | class AuthTestResource(object): 94 | 95 | @falcon.before(auth_required) 96 | def on_get(self, req, res): 97 | res.status = falcon.HTTP_200 98 | res.body = json.dumps({ 99 | 'email': req.context['auth_user']['email'] 100 | }) 101 | -------------------------------------------------------------------------------- /app/user/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | from time import time 3 | import falcon 4 | from app import api, db 5 | from app.utils.testing import APITestCase 6 | from app.user import handlers as user_handlers 7 | from app.middleware.rate_limit import RateLimiter 8 | from app.middleware.body_parser import JSONBodyParser 9 | from app.middleware.auth import AuthUser 10 | 11 | 12 | USER_RESOURCE_ROUTE = '/v1/user' 13 | USER_AUTH_ROUTE = '/v1/authenticate' 14 | PASSWORD_RESET_REQUEST_ROUTE = '/v1/password-reset/request' 15 | PASSWORD_RESET_CONFIRM_ROUTE = '/v1/password-reset/confirm' 16 | AUTH_TEST_ROUTE = '/v1/test/auth' 17 | 18 | VALID_DATA = { 19 | 'email': 'abcd@efgh.com', 20 | 'password': '12345678' 21 | } 22 | 23 | INVALID_DATA = { 24 | 'MISSING_EMAIL': { 25 | 'password': '12345678' 26 | }, 27 | 'BAD_EMAIL': { 28 | 'email': 'not an email', 29 | 'password': '12345678' 30 | }, 31 | 'MISSING_PASSWORD': { 32 | 'email': 'abcd@efgh.com' 33 | }, 34 | 'BAD_PASSWORD': { 35 | 'email': 'abcd@efgh.com', 36 | 'password': 'short' 37 | }, 38 | 'NOT_REGISTERED': { 39 | 'email': 'not@registered.com', 40 | 'password': '11111111' 41 | } 42 | } 43 | 44 | 45 | class UserResourceTestCase(APITestCase): 46 | 47 | def test_create_a_user(self): 48 | body = self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 49 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 50 | self.assertNotEqual(len(body['token']), 0) 51 | 52 | def test_create_a_dup_user(self): 53 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 54 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 55 | 56 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 57 | self.assertEqual(self.srmock.status, falcon.HTTP_409) 58 | 59 | def test_invalid_create_a_user(self): 60 | self.simulate_post(USER_RESOURCE_ROUTE, INVALID_DATA['MISSING_EMAIL']) 61 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 62 | 63 | self.simulate_post(USER_RESOURCE_ROUTE, INVALID_DATA['BAD_EMAIL']) 64 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 65 | 66 | self.simulate_post(USER_RESOURCE_ROUTE, INVALID_DATA['MISSING_PASSWORD']) 67 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 68 | 69 | self.simulate_post(USER_RESOURCE_ROUTE, INVALID_DATA['BAD_PASSWORD']) 70 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 71 | 72 | 73 | class AuthenticationResourceTestCase(APITestCase): 74 | 75 | def test_successful_auth(self): 76 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 77 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 78 | 79 | body = self.simulate_post(USER_AUTH_ROUTE, VALID_DATA) 80 | self.assertEqual(self.srmock.status, falcon.HTTP_200) 81 | self.assertNotEqual(len(body['token']), 0) 82 | 83 | def test_invalid_auth_request(self): 84 | self.simulate_post(USER_AUTH_ROUTE, INVALID_DATA['MISSING_EMAIL']) 85 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 86 | 87 | self.simulate_post(USER_AUTH_ROUTE, INVALID_DATA['MISSING_PASSWORD']) 88 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 89 | 90 | self.simulate_post(USER_AUTH_ROUTE, INVALID_DATA['BAD_EMAIL']) 91 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 92 | 93 | def test_failed_auth(self): 94 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 95 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 96 | 97 | self.simulate_post(USER_AUTH_ROUTE, INVALID_DATA['BAD_PASSWORD']) 98 | self.assertEqual(self.srmock.status, falcon.HTTP_401) 99 | 100 | self.simulate_post(USER_AUTH_ROUTE, INVALID_DATA['NOT_REGISTERED']) 101 | self.assertEqual(self.srmock.status, falcon.HTTP_401) 102 | 103 | 104 | class PasswordResetRequestResourceTestCase(APITestCase): 105 | 106 | def test_password_reset_request_with_matching_user(self): 107 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 108 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 109 | 110 | self.simulate_post(PASSWORD_RESET_REQUEST_ROUTE, {'email': VALID_DATA['email']}) 111 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 112 | 113 | cursor = db.cursor() 114 | cursor.execute('SELECT COUNT(id) FROM app_password_reset') 115 | result = cursor.fetchone() 116 | self.assertEqual(int(result[0]), 1) 117 | cursor.close() 118 | 119 | def test_password_reset_request_with_no_matching_user(self): 120 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 121 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 122 | 123 | self.simulate_post(PASSWORD_RESET_REQUEST_ROUTE, {'email': INVALID_DATA['NOT_REGISTERED']['email']}) 124 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 125 | 126 | def test_password_reset_request_with_invalid_data(self): 127 | self.simulate_post(PASSWORD_RESET_REQUEST_ROUTE, {'email': INVALID_DATA['BAD_EMAIL']['email']}) 128 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 129 | 130 | 131 | class PasswordResetConfirmResourceTestCase(APITestCase): 132 | 133 | def test_password_reset_confirm_with_matching_code(self): 134 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 135 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 136 | 137 | self.simulate_post(PASSWORD_RESET_REQUEST_ROUTE, {'email': VALID_DATA['email']}) 138 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 139 | 140 | cursor = db.cursor() 141 | cursor.execute('SELECT code FROM app_password_reset') 142 | result = cursor.fetchone() 143 | cursor.close() 144 | 145 | request_data = { 146 | 'code': result[0], 147 | 'password': 'newpassword' 148 | } 149 | self.simulate_post(PASSWORD_RESET_CONFIRM_ROUTE, request_data) 150 | self.assertEqual(self.srmock.status, falcon.HTTP_200) 151 | 152 | request_data = { 153 | 'email': VALID_DATA['email'], 154 | 'password': 'newpassword' 155 | } 156 | body = self.simulate_post(USER_AUTH_ROUTE, request_data) 157 | self.assertEqual(self.srmock.status, falcon.HTTP_200) 158 | self.assertNotEqual(len(body['token']), 0) 159 | 160 | def test_password_reset_confirm_with_no_matching_code(self): 161 | self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 162 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 163 | 164 | self.simulate_post(PASSWORD_RESET_REQUEST_ROUTE, {'email': VALID_DATA['email']}) 165 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 166 | 167 | request_data = { 168 | 'code': 'does not exist', 169 | 'password': 'newpassword' 170 | } 171 | self.simulate_post(PASSWORD_RESET_CONFIRM_ROUTE, request_data) 172 | self.assertEqual(self.srmock.status, falcon.HTTP_401) 173 | 174 | def test_password_reset_confirm_with_invalid_data(self): 175 | missing_password = { 176 | 'code': 'some-fake-code' 177 | } 178 | self.simulate_post(PASSWORD_RESET_CONFIRM_ROUTE, missing_password) 179 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 180 | 181 | missing_code = { 182 | 'password': 'newpassword' 183 | } 184 | self.simulate_post(PASSWORD_RESET_CONFIRM_ROUTE, missing_code) 185 | self.assertEqual(self.srmock.status, falcon.HTTP_400) 186 | 187 | 188 | class AuthTestResourceTestCase(APITestCase): 189 | 190 | def setUp(self): 191 | super(AuthTestResourceTestCase, self).setUp() 192 | self.api = api 193 | # Add route to test the AuthUser middleware 194 | self.api.add_route(AUTH_TEST_ROUTE, user_handlers.AuthTestResource()) 195 | 196 | def test_auth_required_with_valid_token(self): 197 | body = self.simulate_post(USER_RESOURCE_ROUTE, VALID_DATA) 198 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 199 | 200 | body = self.simulate_get(AUTH_TEST_ROUTE, body['token']) 201 | self.assertEqual(self.srmock.status, falcon.HTTP_200) 202 | self.assertEqual(body['email'], VALID_DATA['email']) 203 | 204 | def test_auth_required_with_invalid_token(self): 205 | self.simulate_get(AUTH_TEST_ROUTE, 'fake token') 206 | self.assertEqual(self.srmock.status, falcon.HTTP_401) 207 | 208 | def test_auth_required_with_no_token(self): 209 | self.simulate_get(AUTH_TEST_ROUTE) 210 | self.assertEqual(self.srmock.status, falcon.HTTP_401) 211 | 212 | 213 | class RateLimitTestCase(APITestCase): 214 | 215 | def setUp(self): 216 | super(RateLimitTestCase, self).setUp() 217 | self.api = falcon.API(middleware=[JSONBodyParser(), AuthUser(), RateLimiter()]) 218 | # Add route to test the RateLimiter middleware 219 | self.api.add_route(USER_RESOURCE_ROUTE, user_handlers.UserResource()) 220 | self.api.add_route(AUTH_TEST_ROUTE, user_handlers.AuthTestResource()) 221 | 222 | def test_rate_limiter_response_headers(self): 223 | self.simulate_request( 224 | path=USER_RESOURCE_ROUTE, 225 | method='POST', 226 | headers={'Content-Type': 'application/json'}, 227 | body=json.dumps(VALID_DATA)) 228 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 229 | header_keys = set([i[0] for i in self.srmock.headers]) 230 | target_keys = set(['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset']) 231 | self.assertTrue(target_keys < header_keys) 232 | for item in self.srmock.headers: 233 | if item[0] == 'x-ratelimit-limit': 234 | self.assertEqual(item[1], 100) 235 | if item[0] == 'x-ratelimit-remaining': 236 | self.assertTrue(item[1] <= 100) 237 | if item[0] == 'x-ratelimit-reset': 238 | self.assertTrue(item[1] > time()) 239 | 240 | def test_rate_limiter_authenticated(self): 241 | response = self.simulate_request( 242 | path=USER_RESOURCE_ROUTE, 243 | method='POST', 244 | headers={'Content-Type': 'application/json'}, 245 | body=json.dumps(VALID_DATA)) 246 | self.assertEqual(self.srmock.status, falcon.HTTP_201) 247 | data = json.loads(response[0].decode('utf-8')) 248 | response = self.simulate_request( 249 | path=AUTH_TEST_ROUTE, 250 | method='GET', 251 | headers={'Authorization': data['token']}) 252 | self.assertEqual(self.srmock.status, falcon.HTTP_200) 253 | header_keys = set([i[0] for i in self.srmock.headers]) 254 | target_keys = set(['x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset']) 255 | self.assertTrue(target_keys < header_keys) 256 | -------------------------------------------------------------------------------- /app/user/validation.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from cerberus import Validator 3 | 4 | 5 | FIELDS = { 6 | 'email': { 7 | 'type': 'string', 8 | 'regex': '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', 9 | 'required': True 10 | }, 11 | 'password_create': { 12 | 'type': 'string', 13 | 'required': True, 14 | 'minlength': 8 15 | }, 16 | 'password': { 17 | 'type': 'string', 18 | 'required': True 19 | }, 20 | 'code': { 21 | 'type': 'string', 22 | 'required': True 23 | }, 24 | } 25 | 26 | 27 | def validate_user_create(req, res, resource, params): 28 | schema = { 29 | 'email': FIELDS['email'], 30 | 'password': FIELDS['password_create'] 31 | } 32 | 33 | v = Validator(schema) 34 | if not v.validate(req.context['data']): 35 | raise falcon.HTTPBadRequest('Bad request', v.errors) 36 | 37 | 38 | def validate_user_auth(req, res, resource, params): 39 | schema = { 40 | 'email': FIELDS['email'], 41 | 'password': FIELDS['password'] 42 | } 43 | 44 | v = Validator(schema) 45 | if not v.validate(req.context['data']): 46 | raise falcon.HTTPBadRequest('Bad request', v.errors) 47 | 48 | 49 | def validate_request_password_reset(req, res, resource, params): 50 | schema = { 51 | 'email': FIELDS['email'] 52 | } 53 | 54 | v = Validator(schema) 55 | if not v.validate(req.context['data']): 56 | raise falcon.HTTPBadRequest('Bad request', v.errors) 57 | 58 | 59 | def validate_confirm_password_reset(req, res, resource, params): 60 | schema = { 61 | 'code': FIELDS['code'], 62 | 'password': FIELDS['password_create'] 63 | } 64 | 65 | v = Validator(schema) 66 | if not v.validate(req.context['data']): 67 | raise falcon.HTTPBadRequest('Bad request', v.errors) 68 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projectweekend/Falcon-PostgreSQL-API-Seed/d898636c6f3feb5a9f802d55ccae704acc5a060e/app/utils/__init__.py -------------------------------------------------------------------------------- /app/utils/auth.py: -------------------------------------------------------------------------------- 1 | import bcrypt 2 | from itsdangerous import TimedJSONWebSignatureSerializer as TimedSigSerializer 3 | from itsdangerous import SignatureExpired, BadSignature 4 | from app.config import SECRET_KEY, TOKEN_EXPIRES 5 | 6 | 7 | def hash_password(password): 8 | return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') 9 | 10 | 11 | def verify_password(password, hashed): 12 | return bcrypt.hashpw(password.encode('utf-8'), hashed.encode('utf-8')).decode('utf-8') == hashed 13 | 14 | 15 | def generate_token(user_dict, expiration=TOKEN_EXPIRES): 16 | s = TimedSigSerializer(SECRET_KEY, expires_in=expiration) 17 | return s.dumps(user_dict).decode('utf-8') 18 | 19 | 20 | def verify_token(token): 21 | s = TimedSigSerializer(SECRET_KEY) 22 | try: 23 | data = s.loads(token) 24 | except (SignatureExpired, BadSignature): 25 | return None 26 | return data 27 | -------------------------------------------------------------------------------- /app/utils/database.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | from urllib.parse import urlparse 3 | from app.config import DATABASE_URL 4 | 5 | 6 | def database_connection(): 7 | parsed = urlparse(DATABASE_URL) 8 | user = parsed.username 9 | password = parsed.password 10 | host = parsed.hostname 11 | port = parsed.port 12 | database = parsed.path.strip('/') 13 | 14 | connection = psycopg2.connect(host=host, port=port, user=user, password=password, database=database) 15 | connection.set_session(autocommit=True) 16 | 17 | return connection 18 | -------------------------------------------------------------------------------- /app/utils/hooks.py: -------------------------------------------------------------------------------- 1 | import falcon 2 | from app import db 3 | 4 | 5 | def open_cursor_hook(req, res, resource, params): 6 | resource.db = db 7 | resource.cursor = db.cursor() 8 | 9 | 10 | def close_cursor_hook(req, res, resource): 11 | resource.cursor.close() 12 | 13 | 14 | def auth_required(req, res, resource): 15 | if req.context['auth_user'] is None: 16 | raise falcon.HTTPUnauthorized('Unauthorized', "Authentication required") 17 | -------------------------------------------------------------------------------- /app/utils/misc.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | def make_code(): 5 | return str(uuid.uuid4()) 6 | -------------------------------------------------------------------------------- /app/utils/rate_limit.py: -------------------------------------------------------------------------------- 1 | import redis 2 | from app.config import REDIS_HOST, REDIS_PASSWORD 3 | 4 | 5 | def redis_connection(): 6 | if REDIS_HOST: 7 | return redis.StrictRedis( 8 | host=REDIS_HOST, 9 | password=REDIS_PASSWORD) 10 | -------------------------------------------------------------------------------- /app/utils/testing.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from urllib.parse import urlparse 4 | from falcon.testing import TestBase 5 | from app import api, db 6 | from app.config import DATABASE_URL 7 | 8 | 9 | HEADERS = {'Content-Type': 'application/json'} 10 | 11 | 12 | class APITestCase(TestBase): 13 | 14 | def setUp(self): 15 | super(APITestCase, self).setUp() 16 | self._empty_tables() 17 | 18 | @staticmethod 19 | def _empty_tables(): 20 | parsed = urlparse(DATABASE_URL) 21 | 22 | app_tables_query = """ 23 | SELECT table_name 24 | FROM information_schema.tables 25 | WHERE table_schema = 'public' AND 26 | table_catalog = '{0}' AND 27 | table_name != 'schema_version';""".format(parsed.path.strip('/')) 28 | cursor = db.cursor() 29 | cursor.execute(app_tables_query) 30 | tables = [r[0] for r in cursor.fetchall()] 31 | for t in tables: 32 | query = 'TRUNCATE TABLE {0} CASCADE;'.format(t) 33 | cursor.execute(query) 34 | db.commit() 35 | cursor.close() 36 | 37 | def _simulate_request(self, method, path, data, token=None): 38 | if token: 39 | HEADERS['Authorization'] = token 40 | 41 | self.api = api 42 | 43 | result = self.simulate_request( 44 | path=path, 45 | method=method, 46 | headers=HEADERS, 47 | body=json.dumps(data)) 48 | try: 49 | result = result[0] 50 | except IndexError: 51 | return None 52 | return json.loads(result.decode('utf-8')) 53 | 54 | def simulate_get(self, path, token=None): 55 | return self._simulate_request( 56 | method='GET', 57 | path=path, 58 | data=None, 59 | token=token) 60 | 61 | def simulate_post(self, path, data, token=None): 62 | return self._simulate_request( 63 | method='POST', 64 | path=path, 65 | data=data, 66 | token=token) 67 | 68 | def simulate_put(self, path, data, token=None): 69 | return self._simulate_request( 70 | method='PUT', 71 | path=path, 72 | data=data, 73 | token=token) 74 | 75 | def simulate_patch(self, path, data, token=None): 76 | return self._simulate_request( 77 | method='PATCH', 78 | path=path, 79 | data=data, 80 | token=token) 81 | 82 | def simulate_delete(self, path, token=None): 83 | return self._simulate_request( 84 | method='DELETE', 85 | path=path, 86 | data=None, 87 | token=token) 88 | -------------------------------------------------------------------------------- /app/utils/tests.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | db: 2 | image: postgres 3 | ports: 4 | - "5432:5432" 5 | redis: 6 | image: redis 7 | ports: 8 | - "6379:6379" 9 | web: 10 | build: . 11 | command: gunicorn --reload -b 0.0.0.0:5000 app:api 12 | volumes: 13 | - .:/src 14 | - ./sql:/opt/flyway-3.2.1/sql 15 | ports: 16 | - "5000:5000" 17 | links: 18 | - db 19 | - redis 20 | environment: 21 | DEBUG: True 22 | SECRET_KEY: secretkey 23 | -------------------------------------------------------------------------------- /local_migrate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | flyway -url=jdbc:postgresql://$DB_PORT_5432_TCP_ADDR:5432/postgres -user=postgres migrate 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt==1.1.1 2 | Cerberus==0.8.1 3 | coverage==3.7.1 4 | falcon==0.3.0 5 | gunicorn==19.3.0 6 | itsdangerous==0.24 7 | nose==1.3.6 8 | psycopg2==2.6 9 | python-mimeparse==0.1.4 10 | redis==2.10.3 11 | six==1.9.0 12 | -------------------------------------------------------------------------------- /sql/V1__Create_app_users_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "app_users" 2 | ( 3 | id SERIAL PRIMARY KEY, 4 | email varchar(255) NOT NULL, 5 | password varchar(255), 6 | is_active boolean DEFAULT TRUE, 7 | is_admin boolean DEFAULT FALSE, 8 | UNIQUE (email) 9 | ); 10 | -------------------------------------------------------------------------------- /sql/V2__Create_sp_user_insert.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION sp_user_insert 2 | ( 3 | userEmail VARCHAR(255), 4 | userPassword VARCHAR(255) 5 | ) 6 | 7 | RETURNS TABLE 8 | ( 9 | jdoc JSON 10 | ) AS $$ 11 | 12 | BEGIN 13 | RETURN QUERY 14 | WITH inserted as ( 15 | INSERT INTO app_users 16 | ( 17 | email, 18 | password 19 | ) 20 | VALUES ( 21 | userEmail, 22 | userPassword 23 | ) 24 | RETURNING app_users.id, 25 | app_users.email, 26 | app_users.is_active, 27 | app_users.is_admin 28 | ) 29 | SELECT ROW_TO_JSON(inserted.*) 30 | FROM inserted; 31 | END; $$ LANGUAGE plpgsql; 32 | -------------------------------------------------------------------------------- /sql/V3__Create_sp_lookup_user_by_email.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION sp_lookup_user_by_email 2 | ( 3 | userEmail VARCHAR(255) 4 | ) 5 | 6 | RETURNS TABLE 7 | ( 8 | jdoc JSON 9 | ) AS $$ 10 | 11 | BEGIN 12 | RETURN QUERY 13 | WITH result AS ( 14 | SELECT app_users.id, 15 | app_users.email, 16 | app_users.password, 17 | app_users.is_active, 18 | app_users.is_admin 19 | FROM app_users 20 | WHERE app_users.email = userEmail 21 | ) 22 | SELECT ROW_TO_JSON(result.*) 23 | FROM result; 24 | END; $$ LANGUAGE plpgsql; 25 | -------------------------------------------------------------------------------- /sql/V4__Create_app_password_reset_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "app_password_reset" 2 | ( 3 | id SERIAL PRIMARY KEY, 4 | user_id INTEGER REFERENCES app_users(id), 5 | code VARCHAR(255) NOT NULL, 6 | expires TIMESTAMP WITHOUT TIME ZONE DEFAULT (NOW() AT TIME ZONE 'utc' + INTERVAL '1 day'), 7 | UNIQUE (user_id, code) 8 | ); 9 | -------------------------------------------------------------------------------- /sql/V5__Create_sp_reset_password.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION sp_reset_password 2 | ( 3 | resetCode VARCHAR(255), 4 | newPassword VARCHAR(255) 5 | ) RETURNS BOOLEAN AS $$ 6 | 7 | BEGIN 8 | UPDATE app_users 9 | SET password = newPassword 10 | FROM app_password_reset 11 | WHERE app_users.id = app_password_reset.user_id AND 12 | app_password_reset.code = resetCode; 13 | RETURN FOUND; 14 | END; $$ LANGUAGE plpgsql; 15 | -------------------------------------------------------------------------------- /sql/V6__Create_sp_reset_password_request.sql: -------------------------------------------------------------------------------- 1 | CREATE FUNCTION sp_reset_password_request 2 | ( 3 | userEmail VARCHAR(255), 4 | resetCode VARCHAR(255) 5 | ) 6 | 7 | RETURNS BOOLEAN AS $$ 8 | 9 | BEGIN 10 | INSERT INTO app_password_reset 11 | ( 12 | user_id, 13 | code 14 | ) 15 | SELECT app_users.id, 16 | resetCode 17 | FROM app_users 18 | WHERE app_users.email = userEmail; 19 | RETURN FOUND; 20 | END; $$ LANGUAGE plpgsql; 21 | --------------------------------------------------------------------------------