├── playproject ├── __init__.py ├── migrator_settings.py ├── urls.py ├── wsgi.py └── settings.py ├── plaything ├── __init__.py ├── tests.py ├── views.py ├── admin.py └── models.py ├── _etc_passwords_djangouser.txt ├── _etc_passwords_djangomigrator.txt ├── _etc_passwords_secretkey.txt ├── manage.py ├── migrate.sh ├── roles.sql ├── recreate_db.sh ├── .gitignore ├── create_auth_schema.sql ├── LICENSE └── README.md /playproject/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plaything/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_etc_passwords_djangouser.txt: -------------------------------------------------------------------------------- 1 | userpass 2 | -------------------------------------------------------------------------------- /_etc_passwords_djangomigrator.txt: -------------------------------------------------------------------------------- 1 | migratorpass 2 | -------------------------------------------------------------------------------- /_etc_passwords_secretkey.txt: -------------------------------------------------------------------------------- 1 | )zdd0s&jj97v3p9npo8e(s)-r3_j8av-9!h%i#te3f8-eh9gz&7% 2 | -------------------------------------------------------------------------------- /plaything/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /plaything/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /playproject/migrator_settings.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | DATABASES['default']['USER'] = 'djangomigrator' 4 | 5 | with open(BASE_DIR + '/_etc_passwords_djangomigrator.txt') as fp: 6 | DATABASES['default']['PASSWORD'] = fp.read().strip() 7 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "playproject.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /playproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | # Examples: 6 | # url(r'^$', 'playproject.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', include(admin.site.urls)), 10 | ] 11 | -------------------------------------------------------------------------------- /migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | python manage.py migrate --settings=playproject.migrator_settings 3 | psql -h localhost -U djangomigrator -d playdb -c 'GRANT SELECT, INSERT, DELETE, UPDATE ON ALL TABLES IN SCHEMA playschema TO djangouser;' 4 | psql -h localhost -U djangomigrator -d playdb -c 'GRANT USAGE ON ALL SEQUENCES IN SCHEMA playschema TO djangouser;' 5 | -------------------------------------------------------------------------------- /roles.sql: -------------------------------------------------------------------------------- 1 | CREATE ROLE djangomigrator LOGIN ENCRYPTED PASSWORD 'migratorpass'; 2 | CREATE ROLE djangouser LOGIN ENCRYPTED PASSWORD 'userpass'; 3 | CREATE SCHEMA playschema AUTHORIZATION djangomigrator; 4 | GRANT USAGE ON SCHEMA playschema TO djangouser; 5 | ALTER ROLE djangouser SET SEARCH_PATH TO playschema; 6 | ALTER ROLE djangomigrator SET SEARCH_PATH TO playschema; 7 | -------------------------------------------------------------------------------- /recreate_db.sh: -------------------------------------------------------------------------------- 1 | # FOR DEBUGGING PURPOSES ONLY FOR GODS SAKE 2 | # run as postgres superuser 3 | dropdb playdb 4 | dropuser djangouser 5 | dropuser djangomigrator 6 | createdb playdb 7 | psql -d playdb -c 'DROP SCHEMA public CASCADE;' 8 | psql -d playdb -f roles.sql 9 | psql -d playdb -f create_auth_schema.sql 10 | 11 | # then as a normal unix user. Mind the order of apps. 12 | python manage.py migrate plaything --settings=playproject.migrator_settings 13 | sh migrate.sh 14 | -------------------------------------------------------------------------------- /playproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for playproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "playproject.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /plaything/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from plaything import models 3 | 4 | from django import forms 5 | from django.contrib import admin 6 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 7 | from django.contrib.auth.forms import UserCreationForm 8 | from django.contrib.auth.forms import UserChangeForm 9 | 10 | class CustomUserCreationForm(UserCreationForm): 11 | class Meta(UserCreationForm.Meta): 12 | model = models.CustomUser 13 | 14 | class CustomUserChangeForm(UserChangeForm): 15 | class Meta(UserChangeForm.Meta): 16 | model = models.CustomUser 17 | 18 | class UserAdmin(BaseUserAdmin): 19 | form = CustomUserChangeForm 20 | add_form = CustomUserCreationForm 21 | 22 | admin.site.register(models.CustomUser, UserAdmin) 23 | admin.site.register(models.IntegerTuple) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # swap 65 | [._]*.s[a-w][a-z] 66 | [._]s[a-w][a-z] 67 | # session 68 | Session.vim 69 | # temporary 70 | .netrwhist 71 | *~ 72 | # auto-generated tag files 73 | tags 74 | -------------------------------------------------------------------------------- /create_auth_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA auth_schema; 2 | GRANT USAGE ON SCHEMA auth_schema TO djangouser; 3 | 4 | CREATE TABLE auth_schema.passwords( 5 | uid bigint PRIMARY KEY, 6 | pw_salt bytea, 7 | pw_hash bytea 8 | ); 9 | 10 | CREATE FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) AS 11 | $$ 12 | SELECT exists(SELECT 1 FROM auth_schema.passwords WHERE uid = $1 AND pw_hash = $2); 13 | $$ 14 | LANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER; 15 | 16 | CREATE FUNCTION auth_schema.get_salt(IN bigint, OUT bytea) AS 17 | $$ 18 | SELECT pw_salt FROM auth_schema.passwords WHERE uid = $1; 19 | $$ 20 | LANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER; 21 | 22 | CREATE FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) RETURNS VOID AS 23 | $$ 24 | INSERT INTO auth_schema.passwords (uid, pw_salt, pw_hash) VALUES ($1, $2, $3) ON CONFLICT (uid) DO UPDATE SET pw_hash = EXCLUDED.pw_hash, pw_salt = EXCLUDED.pw_salt; 25 | $$ 26 | LANGUAGE SQL VOLATILE STRICT SECURITY DEFINER; 27 | 28 | -- REVOKE ALL ON auth_schema.passwords FROM PUBLIC; -- I normally delete the public schema. 29 | ALTER TABLE auth_schema.passwords OWNER TO postgres; 30 | REVOKE ALL ON auth_schema.passwords FROM djangouser; 31 | 32 | ALTER FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) OWNER TO postgres; 33 | ALTER FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) OWNER TO postgres; 34 | ALTER FUNCTION auth_schema.get_salt(IN bigint) OWNER TO postgres; 35 | 36 | -------------------------------------------------------------------------------- /plaything/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db import connection 3 | from django.contrib.auth.models import AbstractUser 4 | from django.contrib.auth.hashers import make_password 5 | from django.utils.crypto import get_random_string 6 | 7 | class IntegerTuple(models.Model): 8 | first = models.IntegerField(default=2) 9 | second = models.IntegerField(default=4) 10 | third = models.IntegerField(default=6) 11 | 12 | class CustomUser(AbstractUser): 13 | def make_random_password(self): 14 | length = 35 15 | allowed_chars='abcdefghjkmnpqrstuvwxyz' + 'ABCDEFGHJKLMNPQRSTUVWXYZ' + '23456789' 16 | return get_random_string(length, allowed_chars) 17 | 18 | def save(self, *args, **kwargs): 19 | update_pw = ('update_fields' not in kwargs or 'password' in kwargs['update_fields']) and '$' in self.password 20 | if update_pw: 21 | algo, iterations, salt, pw_hash = self.password.split('$', 3) 22 | # self.password should be unique anyway for get_session_auth_hash() 23 | self.password = self.make_random_password() 24 | 25 | super(CustomUser, self).save(*args, **kwargs) 26 | if update_pw: 27 | cursor = connection.cursor() 28 | cursor.execute("SELECT auth_schema.insert_or_update_password(%d, '%s', '%s');" % (self.id, salt, pw_hash)) 29 | return 30 | 31 | def check_password(self, raw_password): 32 | cursor = connection.cursor() 33 | cursor.execute("SELECT auth_schema.get_salt(%d);" % self.id) 34 | salt = cursor.fetchone()[0] 35 | 36 | algo, iterations, salt, pw_hash = make_password(raw_password, salt=salt).split('$', 3) 37 | cursor.execute("SELECT auth_schema.check_password(%d, '%s');" % (self.id, pw_hash)) 38 | pw_correct = cursor.fetchone()[0] 39 | return bool(pw_correct) 40 | -------------------------------------------------------------------------------- /playproject/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for playproject project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.8. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.8/ref/settings/ 11 | """ 12 | 13 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 14 | import os 15 | 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | AUTH_USER_MODEL = 'plaything.CustomUser' 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | with open('_etc_passwords_secretkey.txt') as fp: 25 | SECRET_KEY = fp.read().strip() 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = ( 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'plaything', 43 | ) 44 | 45 | MIDDLEWARE_CLASSES = ( 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | 'django.middleware.security.SecurityMiddleware', 54 | ) 55 | 56 | ROOT_URLCONF = 'playproject.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'playproject.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 83 | 'NAME': 'playdb', 84 | 'USER': 'djangouser', 85 | 'PASSWORD': 'userpass', 86 | 'HOST': 'localhost', 87 | } 88 | } 89 | 90 | with open(BASE_DIR + '/_etc_passwords_djangouser.txt') as fp: 91 | DATABASES['default']['PASSWORD'] = fp.read().strip() 92 | 93 | 94 | # Internationalization 95 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 96 | 97 | LANGUAGE_CODE = 'en-us' 98 | 99 | TIME_ZONE = 'UTC' 100 | 101 | USE_I18N = True 102 | 103 | USE_L10N = True 104 | 105 | USE_TZ = True 106 | 107 | 108 | # Static files (CSS, JavaScript, Images) 109 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 110 | 111 | STATIC_URL = '/static/' 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | CC0 1.0 Universal 2 | 3 | Statement of Purpose 4 | 5 | The laws of most jurisdictions throughout the world automatically confer 6 | exclusive Copyright and Related Rights (defined below) upon the creator and 7 | subsequent owner(s) (each and all, an "owner") of an original work of 8 | authorship and/or a database (each, a "Work"). 9 | 10 | Certain owners wish to permanently relinquish those rights to a Work for the 11 | purpose of contributing to a commons of creative, cultural and scientific 12 | works ("Commons") that the public can reliably and without fear of later 13 | claims of infringement build upon, modify, incorporate in other works, reuse 14 | and redistribute as freely as possible in any form whatsoever and for any 15 | purposes, including without limitation commercial purposes. These owners may 16 | contribute to the Commons to promote the ideal of a free culture and the 17 | further production of creative, cultural and scientific works, or to gain 18 | reputation or greater distribution for their Work in part through the use and 19 | efforts of others. 20 | 21 | For these and/or other purposes and motivations, and without any expectation 22 | of additional consideration or compensation, the person associating CC0 with a 23 | Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 25 | and publicly distribute the Work under its terms, with knowledge of his or her 26 | Copyright and Related Rights in the Work and the meaning and intended legal 27 | effect of CC0 on those rights. 28 | 29 | 1. Copyright and Related Rights. A Work made available under CC0 may be 30 | protected by copyright and related or neighboring rights ("Copyright and 31 | Related Rights"). Copyright and Related Rights include, but are not limited 32 | to, the following: 33 | 34 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 35 | and translate a Work; 36 | 37 | ii. moral rights retained by the original author(s) and/or performer(s); 38 | 39 | iii. publicity and privacy rights pertaining to a person's image or likeness 40 | depicted in a Work; 41 | 42 | iv. rights protecting against unfair competition in regards to a Work, 43 | subject to the limitations in paragraph 4(a), below; 44 | 45 | v. rights protecting the extraction, dissemination, use and reuse of data in 46 | a Work; 47 | 48 | vi. database rights (such as those arising under Directive 96/9/EC of the 49 | European Parliament and of the Council of 11 March 1996 on the legal 50 | protection of databases, and under any national implementation thereof, 51 | including any amended or successor version of such directive); and 52 | 53 | vii. other similar, equivalent or corresponding rights throughout the world 54 | based on applicable law or treaty, and any national implementations thereof. 55 | 56 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 57 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 58 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 59 | and Related Rights and associated claims and causes of action, whether now 60 | known or unknown (including existing as well as future claims and causes of 61 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 62 | duration provided by applicable law or treaty (including future time 63 | extensions), (iii) in any current or future medium and for any number of 64 | copies, and (iv) for any purpose whatsoever, including without limitation 65 | commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes 66 | the Waiver for the benefit of each member of the public at large and to the 67 | detriment of Affirmer's heirs and successors, fully intending that such Waiver 68 | shall not be subject to revocation, rescission, cancellation, termination, or 69 | any other legal or equitable action to disrupt the quiet enjoyment of the Work 70 | by the public as contemplated by Affirmer's express Statement of Purpose. 71 | 72 | 3. Public License Fallback. Should any part of the Waiver for any reason be 73 | judged legally invalid or ineffective under applicable law, then the Waiver 74 | shall be preserved to the maximum extent permitted taking into account 75 | Affirmer's express Statement of Purpose. In addition, to the extent the Waiver 76 | is so judged Affirmer hereby grants to each affected person a royalty-free, 77 | non transferable, non sublicensable, non exclusive, irrevocable and 78 | unconditional license to exercise Affirmer's Copyright and Related Rights in 79 | the Work (i) in all territories worldwide, (ii) for the maximum duration 80 | provided by applicable law or treaty (including future time extensions), (iii) 81 | in any current or future medium and for any number of copies, and (iv) for any 82 | purpose whatsoever, including without limitation commercial, advertising or 83 | promotional purposes (the "License"). The License shall be deemed effective as 84 | of the date CC0 was applied by Affirmer to the Work. Should any part of the 85 | License for any reason be judged legally invalid or ineffective under 86 | applicable law, such partial invalidity or ineffectiveness shall not 87 | invalidate the remainder of the License, and in such case Affirmer hereby 88 | affirms that he or she will not (i) exercise any of his or her remaining 89 | Copyright and Related Rights in the Work or (ii) assert any associated claims 90 | and causes of action with respect to the Work, in either case contrary to 91 | Affirmer's express Statement of Purpose. 92 | 93 | 4. Limitations and Disclaimers. 94 | 95 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 96 | surrendered, licensed or otherwise affected by this document. 97 | 98 | b. Affirmer offers the Work as-is and makes no representations or warranties 99 | of any kind concerning the Work, express, implied, statutory or otherwise, 100 | including without limitation warranties of title, merchantability, fitness 101 | for a particular purpose, non infringement, or the absence of latent or 102 | other defects, accuracy, or the present or absence of errors, whether or not 103 | discoverable, all to the greatest extent permissible under applicable law. 104 | 105 | c. Affirmer disclaims responsibility for clearing rights of other persons 106 | that may apply to the Work or any use thereof, including without limitation 107 | any person's Copyright and Related Rights in the Work. Further, Affirmer 108 | disclaims responsibility for obtaining any necessary consents, permissions 109 | or other rights required for any use of the Work. 110 | 111 | d. Affirmer understands and acknowledges that Creative Commons is not a 112 | party to this document and has no duty or obligation with respect to this 113 | CC0 or use of the Work. 114 | 115 | For more information, please see 116 | 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django and PostgreSQL security tips and practices 2 | 3 | ## Contents 4 | 5 | * [Database roles, schemas, and migrations](#database-roles-schemas-and-migrations) 6 | * [User passwords](#user-passwords) 7 | * [Firewall](#database-cluster-firewall) 8 | * [Database separation](#database-separation) 9 | * [Further reading](#further-reading) 10 | 11 | ## Purpose and motivation ## 12 | 13 | The aim of this guide/repository is to learn and promote secure system administration tips and practices in the Django community. 14 | My motivation is that most articles that focus on getting a Django application up and running do not talk much about security, yet database security guides often feel too abstract and intimidating for newcomers. 15 | 16 | The scope of the guide is yet to be defined and will depend on the people who will get involved. 17 | Your questions, feedback, and insight well be very welcome! 18 | 19 | ## Before we begin.. 20 | 21 | .. Make sure you have read the 22 | [Django Deployment Checklist](https://docs.djangoproject.com/en/dev/howto/deployment/checklist/) 23 | and 24 | [Security in Django](https://docs.djangoproject.com/en/dev/topics/security/). 25 | 26 | This skeleton has been created by commands 27 | ```sh 28 | django-admin startproject playproject 29 | django-admin startapp plaything 30 | ``` 31 | 32 | ## Database roles, schemas, and migrations 33 | 34 | PostgreSQL has something called [schemas](http://www.postgresql.org/docs/current/static/ddl-schemas.html), which are a bit like folders in the file system. 35 | By default, all tables are created in a schema called `public` where all new users/roles have rather wide permissions. 36 | However, it is advisable to confine your web application to a specific schema and grant it as few privileges as possible. 37 | 38 | To get started, let's create a database and log into it. 39 | 40 | ```sh 41 | sudo su postgres 42 | createdb playdb && psql playdb 43 | ``` 44 | 45 | We will have no need for the public schema, so let's drop it. 46 | (Make sure it's not used by anyone!) 47 | 48 | ```sql 49 | DROP SCHEMA public CASCADE; 50 | ``` 51 | 52 | We'll have two roles, `djangouser` and `djangomigrator`. 53 | The `djangouser` will be used by your application in production, and `djangomigrator` will be used to perform migrations. 54 | The `djangouser` will need permissions to select, insert, delete, and update rows on all the tables. 55 | In addition, she'll need to access the [sequences](http://www.postgresql.org/docs/current/static/functions-sequence.html) to calculate the id of new model instances. 56 | The lesson to learn is that she should have *only* those privileges. 57 | 58 | **Why bother with two roles?** 59 | * When every user's permissions are as narrow as possible, you will have an easier time debugging the system when you suspect a security breach. 60 | * Not everybody is interested in your data. If `djangouser` can create tables, a successful attacker can use your database for their own purposes. 61 | * If an attacker can create [trigger procedures](http://www.postgresql.org/docs/current/static/plpgsql-trigger.html), those procedures will persist even after password rotation and you might not notice for a long time. Considerable harm and snooping will ensue. 62 | 63 | As the `djangouser` will not be able to create or alter tables, we'll need another role for that purpose, `djangomigrator`, who will be the owner of the schema `playschema` of your django project `playproject`. 64 | In addition, we'll set the [search path](http://www.postgresql.org/docs/current/static/runtime-config-client.html) of both users to `playschema`. 65 | 66 | ```sql 67 | CREATE ROLE djangomigrator LOGIN ENCRYPTED PASSWORD 'migratorpass'; 68 | CREATE ROLE djangouser LOGIN ENCRYPTED PASSWORD 'userpass'; 69 | CREATE SCHEMA playschema AUTHORIZATION djangomigrator; 70 | GRANT USAGE ON SCHEMA playschema TO djangouser; 71 | ALTER ROLE djangouser SET SEARCH_PATH TO playschema; 72 | ALTER ROLE djangomigrator SET SEARCH_PATH TO playschema; 73 | ``` 74 | 75 | 76 | In order to juggle between these two roles you can create a special settings file `migrator_settings.py` for your migrator. 77 | It's nothing more than 78 | 79 | ```python 80 | from .settings import * 81 | DATABASES['default']['USER'] = 'djangomigrator' 82 | DATABASES['default']['PASSWORD'] = 'migratorpass' 83 | ``` 84 | 85 | and then you'll be able to run: 86 | 87 | ```sh 88 | python manage.py migrate --settings=playproject.migrator_settings 89 | ``` 90 | 91 | By the way, make sure that `python manage.py migrate` really fails! 92 | We are not quite done yet, though, because `djangouser` will, by default, not have any privileges on the newly created tables or sequences. 93 | Before running `python manage.py runserver` you will have to say 94 | 95 | ```sql 96 | GRANT SELECT, INSERT, DELETE, UPDATE ON ALL TABLES IN SCHEMA playschema TO djangouser; 97 | GRANT USAGE ON ALL SEQUENCES IN SCHEMA playschema TO djangouser; 98 | ``` 99 | 100 | Finally, you probably want the migrations process to be a single simple command. To do that, see for example the file `migrate.sh`. 101 | 102 | ## User passwords 103 | 104 | The default password management in Django depends on your database user, i.e. `djangouser` in our case, to have `SELECT` privileges in the table `auth_user`. 105 | Go ahead, try that by running 106 | 107 | ```sql 108 | SELECT * FROM auth_user; 109 | ``` 110 | 111 | in your dbshell. 112 | That's pretty scary in case you fall victim to an SQL injection attack. 113 | 114 | The easiest way to mitigate that threat is to use state of the art hash functions, as explained in the [Django documentation](http://django.readthedocs.org/en/latest/topics/auth/passwords.html). 115 | However, allowing `SELECT` on your password hashes is fundamentally insecure and you might want to consider an external identity management solution. 116 | 117 | Alternatively, there is an approach I learnt from [tsavola](https://github.com/tsavola). 118 | PostgreSQL has something called SECURITY DEFINER functions which can perform certain activities with special privileges, a bit like `sudo` on Unix systems. 119 | This makes it possible to revoke the `SELECT` privileges on your password hashes but still be able to compare them as a regular user. 120 | More precisely: 121 | 122 | * Make an SQL function called `check_password()` that will take in a user ID and a hash, and will return true if the user's hash in the database matches the one in the arguments. 123 | * That function is defined by the database superuser and flagged as `SECURITY DEFINER`, so that inside the function it can `SELECT` the existing hashes. 124 | * Then `django.contrib.auth.models.User.check_password()` will, instead of taking the hash out of the database, simply call that function. 125 | * While not necessary, to improve readability, I have revoked all permissions from the password hashes and salts from `djangouser` and instead call SQL functions `get_salt()` and `insert_or_update_password()`. 126 | 127 | To continue on our previous example, you'll perform the following actions in the database: 128 | ```sql 129 | CREATE SCHEMA auth_schema; 130 | GRANT USAGE ON SCHEMA auth_schema TO djangouser; 131 | 132 | CREATE TABLE auth_schema.passwords( 133 | uid bigint PRIMARY KEY, 134 | pw_salt bytea, 135 | pw_hash bytea 136 | ); 137 | 138 | CREATE FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) AS 139 | $$ 140 | SELECT exists(SELECT 1 FROM auth_schema.passwords WHERE uid = $1 AND pw_hash = $2); 141 | $$ 142 | LANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER; 143 | 144 | CREATE FUNCTION auth_schema.get_salt(IN bigint, OUT bytea) AS 145 | $$ 146 | SELECT pw_salt FROM auth_schema.passwords WHERE uid = $1; 147 | $$ 148 | LANGUAGE SQL IMMUTABLE STRICT SECURITY DEFINER; 149 | 150 | CREATE FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) RETURNS VOID AS 151 | $$ 152 | INSERT INTO auth_schema.passwords (uid, pw_salt, pw_hash) VALUES ($1, $2, $3) ON CONFLICT (uid) DO UPDATE SET pw_hash = EXCLUDED.pw_hash, pw_salt = EXCLUDED.pw_salt; 153 | $$ 154 | LANGUAGE SQL VOLATILE STRICT SECURITY DEFINER; 155 | 156 | -- REVOKE ALL ON auth_schema.passwords FROM PUBLIC; -- I normally delete the public schema. 157 | ALTER TABLE auth_schema.passwords OWNER TO postgres; 158 | REVOKE ALL ON auth_schema.passwords FROM djangouser; 159 | 160 | ALTER FUNCTION auth_schema.check_password(IN bigint, IN bytea, OUT bool) OWNER TO postgres; 161 | ALTER FUNCTION auth_schema.insert_or_update_password(IN bigint, IN bytea, IN bytea) OWNER TO postgres; 162 | ALTER FUNCTION auth_schema.get_salt(IN bigint) OWNER TO postgres; 163 | 164 | ``` 165 | 166 | In addition, you'll need to extend the User model ([read the docs](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#extending-user)). 167 | 168 | 169 | ```python 170 | from django.db import models 171 | from django.db import connection 172 | from django.contrib.auth.models import AbstractUser 173 | from django.contrib.auth.hashers import make_password 174 | from django.utils.crypto import get_random_string 175 | 176 | class CustomUser(AbstractUser): 177 | def make_random_password(self): 178 | length = 35 179 | allowed_chars='abcdefghjkmnpqrstuvwxyz' + 'ABCDEFGHJKLMNPQRSTUVWXYZ' + '23456789' 180 | return get_random_string(length, allowed_chars) 181 | 182 | def save(self, *args, **kwargs): 183 | update_pw = ('update_fields' not in kwargs or 'password' in kwargs['update_fields']) and '$' in self.password 184 | if update_pw: 185 | algo, iterations, salt, pw_hash = self.password.split('$', 3) 186 | # self.password should be unique anyway for get_session_auth_hash() 187 | self.password = self.make_random_password() 188 | 189 | super(CustomUser, self).save(*args, **kwargs) 190 | if update_pw: 191 | cursor = connection.cursor() 192 | cursor.execute("SELECT auth_schema.insert_or_update_password(%d, '%s', '%s');" % (self.id, salt, pw_hash)) 193 | return 194 | 195 | def check_password(self, raw_password): 196 | cursor = connection.cursor() 197 | cursor.execute("SELECT auth_schema.get_salt(%d);" % self.id) 198 | salt = cursor.fetchone()[0] 199 | 200 | algo, iterations, salt, pw_hash = make_password(raw_password, salt=salt).split('$', 3) 201 | cursor.execute("SELECT auth_schema.check_password(%d, '%s');" % (self.id, pw_hash)) 202 | pw_correct = cursor.fetchone()[0] 203 | return bool(pw_correct) 204 | ``` 205 | 206 | 207 | And put 208 | 209 | ```python 210 | AUTH_USER_MODEL = 'plaything.CustomUser' 211 | ``` 212 | 213 | in your settings.py. 214 | Also, to add users in the admin, you'll need to subclass the default forms, [see the docs](https://docs.djangoproject.com/en/1.9/topics/auth/customizing/#custom-users-and-the-built-in-auth-forms). 215 | 216 | As a result, only the superuser of your database will ever be able to see the password hashes once they have been saved. 217 | 218 | **WARNING:** this solution depends on the function `make_password()` to stay constant. 219 | Future version of Django may for example increase the number of iterations it performs and thus cause the hash comparison to fail. 220 | Make sure you have a plan for that. 221 | 222 | 223 | ## Database cluster firewall 224 | 225 | Your database should be accessible only from certain IP address(es). 226 | Even if you are not afraid of attackers, be afraid of yourself accidentally running `dropdb playdb` instead of `dropdb playdb_test`. 227 | 228 | For example, on AWS, if you have a Virtual Private Cloud with CIDR 172.38.0.0/16 you can allow inbound TCP traffic on port 5432 from source 172.38.0.0/16. 229 | That will allow connections from any machine in that VPC, so you may want to be even more restrictive. 230 | 231 | ## Database separation 232 | 233 | Privileges can be defined on a number of levels in PostgreSQL: e.g. row, table, schema, and database level. 234 | All of them have their uses, but let's highlight the difference between database and other levels. 235 | 236 | * An attacker can change the row, table, or schema with SQL commands, 237 | * but to access a different database, a new connection has to be initiated. 238 | 239 | So if something must be kept out of the reach of your web app but still in the same cluster, consider putting it in a different database. 240 | 241 | ## Read-only replicas 242 | 243 | Very useful for security as well as performance. TODO. 244 | 245 | ## Further reading 246 | 247 | * [IBM develperWorks: Total security in a PostgreSQL database](http://www.ibm.com/developerworks/library/os-postgresecurity/) 248 | * [OpenSCG: Security Hardening PostgreSQL](http://www.openscg.com/wp-content/uploads/2013/04/SecurityHardeningPostgreSQL.pdf) 249 | * [OWASP: Backend Security Project PostgreSQL Hardening](https://www.owasp.org/index.php/OWASP_Backend_Security_Project_PostgreSQL_Hardening) 250 | 251 | --- 252 | --------------------------------------------------------------------------------