Skip to content
Browse files

Merge branch 'release/r0.1.1'

Conflicts:
	TODO
	skeleton/templates/layout/base.html

Signed-off-by: Sean Chittenden <sean@chittenden.org>
  • Loading branch information...
2 parents d6e2b69 + c11aa90 commit b8667301e5b15c9d546c1bd55127ed7e5b3c80be @sean- sean- committed Jun 10, 2011
Showing with 1,706 additions and 727 deletions.
  1. +8 −0 .enter.local.tcsh.dist
  2. +3 −2 .enter.tcsh → .enter.tcsh.dist
  3. +3 −0 .gitignore
  4. +80 −40 INSTALL
  5. +62 −31 TODO
  6. +1 −1 default_settings.py
  7. +4 −3 doc/COOKIES.txt
  8. +6 −1 requirements.txt
  9. +30 −24 skeleton/__init__.py
  10. +1 −0 skeleton/models/__init__.py
  11. +8 −0 skeleton/models/timezone.py
  12. +10 −0 skeleton/modules/aaa/__init__.py
  13. +24 −3 skeleton/modules/aaa/forms.py
  14. +4 −3 skeleton/modules/aaa/models/__init__.py
  15. +11 −5 skeleton/modules/aaa/models/user.py
  16. +10 −0 skeleton/modules/aaa/models/user_info.py
  17. +22 −0 skeleton/modules/aaa/templates/profile.html
  18. +1 −0 skeleton/modules/aaa/templates/register.html
  19. +36 −0 skeleton/modules/aaa/user.py
  20. +63 −11 skeleton/modules/aaa/views.py
  21. +2 −2 skeleton/modules/home/models/__init__.py
  22. +1 −0 skeleton/modules/home/views.py
  23. +1 −1 skeleton/modules/mod1/models/__init__.py
  24. +3 −2 skeleton/modules/mod1/views.py
  25. +2 −1 skeleton/modules/mod2/views.py
  26. +1 −0 skeleton/modules/mod3/forms.py
  27. +3 −3 skeleton/modules/mod3/models/__init__.py
  28. +2 −1 skeleton/modules/mod3/models/page.py
  29. +2 −0 skeleton/modules/mod3/templates/root.html
  30. +19 −6 skeleton/modules/mod3/views.py
  31. BIN skeleton/static/flask-powered.png
  32. BIN skeleton/static/powered-by-flask-s.png
  33. +1 −1 skeleton/templates/_formhelpers.html
  34. +3 −2 skeleton/templates/layout/base.html
  35. +0 −1 sql/.gitignore
  36. +0 −14 sql/create_roles.sql
  37. +0 −7 sql/fixup_func_owner.sql
  38. +0 −279 sql/funcs.sql.in
  39. +1 −0 sql/initialize/.gitignore
  40. +27 −0 sql/initialize/100_create_roles.sql
  41. +3 −0 sql/initialize/130_create_database.sql
  42. +2 −0 sql/initialize/140_alter_public_schema.sql
  43. +7 −0 sql/initialize/200_schema.sql
  44. +50 −0 sql/initialize/205_integrated_definitions.sql
  45. +122 −0 sql/initialize/210_tables.sql
  46. +47 −0 sql/initialize/280_views.sql
  47. +430 −0 sql/initialize/300_funcs.sql.in
  48. +9 −0 sql/initialize/400_triggers.sql
  49. +9 −0 sql/initialize/500_fixup_func_owner.sql
  50. +38 −0 sql/initialize/600_create_indexes.sql
  51. +22 −0 sql/initialize/700_foreign_keys.sql
  52. +2 −2 sql/{initial_data.sql → initialize/800_initial_data.sql}
  53. +395 −0 sql/initialize/801_timezone_data.sql
  54. +6 −0 sql/initialize/900_cleanup_users.sql
  55. +109 −0 sql/maintenance/100_perms.sql
  56. +0 −93 sql/perms.sql
  57. +0 −183 sql/schema.sql
  58. +0 −5 sql/triggers.sql
View
8 .enter.local.tcsh.dist
@@ -0,0 +1,8 @@
+if ( -d /opt/local/lib/postgresql91/bin ) then
+ setenv PATH /opt/local/lib/postgresql91/bin:${PATH}
+endif
+
+setenv PGDATABASE skeleton
+setenv PGUSER skeleton_${USER}
+setenv PGUSER_RW skeleton_rw_${USER}
+setenv PGUSER_ROOT pgsql
View
5 .enter.tcsh → .enter.tcsh.dist
@@ -1,9 +1,10 @@
-# To activate this file: chmod 600 .enter.tcsh
-# To deactivate this file: chmod 640 .enter.tcsh
# To turn off the virtualenv, type: deactivate
# Don't do anything if a virtual environment is already loaded
if ( ${?VIRTUAL_ENV} == "1" ) exit 0
+# Load a local .tcsh file
+if (-o .enter.local.tcsh && -P22: .enter.local.tcsh == "0") source .enter.local.tcsh
+
printf 'Activating the "%s" virtual environment.\n' `basename $PWD`
source bin/activate.csh >& /dev/null
View
3 .gitignore
@@ -6,6 +6,9 @@ local_settings.py
bin/
include/
lib/
+src/
+.enter.tcsh
+.enter.local.tcsh
# Don't loose ssl.key and ssl.cert... don't add them either.
#ssl.cert
View
120 INSTALL
@@ -14,6 +14,9 @@
$ less ~/.cshrc # _Always_ review changes to your shell config
$ exec /bin/tcsh # Start using tcsh(1)
% chsh /bin/tcsh # Change your default shell once you feel comfortable
+ % mv .enter.tcsh.dist .enter.tcsh; mv .enter.local.tcsh.dist .enter.local.tcsh
+ % ${EDITOR} .enter.tcsh .enter.local.tcsh
+ % cd $PWD # Run this after you finish installing PostgreSQL
0b) Download and install python 2.7.1 (NOTE: python 3.X is a no-go with
@@ -30,13 +33,18 @@
0d) Download and install PostgreSQL. Similar to the above, but since this is
- a database and not a random library, you're on your own for not being a
- nub:
+ a database and not a random library, you're on your own for not being a
+ nub:
- % sudo port install postgresql91-server
+ % sudo port install postgresql91-server
+ % sudo mkdir -p /opt/local/var/db/postgresql91/defaultdb
+ % sudo chown postgres:postgres /opt/local/var/db/postgresql91/defaultdb
- # Not required, but man pages are handy!
- % sudo port install postgresql91-doc
+ # Run this in a different Terminal window
+ % sudo su postgres -c '/opt/local/lib/postgresql91/bin/initdb -D /opt/local/var/db/postgresql91/defaultdb'
+
+ # Not required, but man pages are handy!
+ % sudo port install postgresql91-doc
1) Rename from 'skeleton' to 'myapp':
@@ -52,9 +60,12 @@
2) Start the database:
+ # This should be set by your .enter.local.tcsh file.
+ #setenv PGUSER_ROOT pgsql
+
# MacPorts installed version of PostgreSQL 9.1, I run this in a different
# terminal when developing so I can see what's going on.
- sudo su - pgsql -c '/opt/local/lib/postgresql91/bin/postgres -D /opt/local/var/db/postgresql91/defaultdb'
+ sudo su - ${PGUSER_ROOT} -c '/opt/local/lib/postgresql91/bin/postgres -D /opt/local/var/db/postgresql91/defaultdb'
3) Create a virualenv for the skeleton:
@@ -73,21 +84,18 @@
source bin/activate.csh
-5) Pull in the required packages. This step is a bit goofy due to PostgreSQL's
- pg_config being hidden away out of the normal path.
-
- # Tweak your path to where pg_config(1) is hidden
- setenv PATH /opt/local/lib/postgresql91/bin/:${PATH}
+5) Pull in the required packages.
- # And tweak the build environment slightly for python-libmemcached and
- # M2Crypto
+ # Tweak the build environment slightly for python-libmemcached and
+ # M2Crypto.
setenv CPPFLAGS '-I/opt/local/include -I/opt/local/include/openssl'
# Break the installation down in to two steps: download and install (if
# something fails, you don't want to start over again from scratch). ~/tmp
# is created by .cshrc.
- pip install -U --download-cache=~/tmp -I -r requirements.txt --no-install
- pip install -U --download-cache=~/tmp -I -r requirements.txt --no-download
+ mkdir ~/tmp/pip
+ pip install -U --download-cache=~/tmp/pip -I -r requirements.txt --no-install
+ pip install -U --download-cache=~/tmp/pip -I -r requirements.txt --no-download
# Make sure everything is there (or newer):
pip freeze | sort > requirements.txt
@@ -99,6 +107,7 @@
# applying the following patch to your install (versions 0.6.2 & 0.7.0):
% patch -d lib/python2.7/site-packages/ -p0 < patches/werkzeug::contrib::cache_452e8402d056.patch-0.6.2
+
6) Setup debugging:
echo 'DEBUG = True' >> local_settings.py
@@ -110,22 +119,40 @@
7) Initialize the database (these steps should be moved in to a script for
easy unit testing):
- # l2dba 101
+ # l2dba 101.
+
+ # Environment variables that should be set by your .enter.local.tcsh:
+ # PGUSER_RW, PGUSER and PGDATABASE.
+ setenv PGUSER_DBA skeleton_root
+ setenv PGUSER_ROOT pgsql
+
+ # Create the base roles
+ psql -d template1 -U ${PGUSER_ROOT} -1f sql/initialize/100_create_roles.sql
- # Create the admin ROLE and web user
- psql template1 pgsql -1f sql/create_roles.sql
+ # Create your user roles (use two, one for read-only and the other for
+ # read-write activities)
+ psql -d template1 -U ${PGUSER_ROOT} -c "CREATE ROLE ${PGUSER} CONNECTION LIMIT 2 LOGIN;"
+ psql -d template1 -U ${PGUSER_ROOT} -c "CREATE ROLE ${PGUSER_RW} CONNECTION LIMIT 1 LOGIN;"
- # Create the database. Don't pull a MySQL and use ISO-8895-1
- psql template1 pgsql -c "CREATE DATABASE skeleton OWNER skeleton_admin ENCODING='UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8'"
+ # Add the ROLEs to their respective DBA ROLE/GROUPs
+ psql -d template1 -U ${PGUSER_ROOT} -c "ALTER GROUP skeleton_dba ADD USER ${PGUSER};"
+ psql -d template1 -U ${PGUSER_ROOT} -c "ALTER GROUP skeleton_root ADD USER ${PGUSER_RW};"
+
+ # Create the rest of the DB objects
+ psql -d template1 -U ${PGUSER_ROOT} -f sql/initialize/130_create_database.sql
+ psql -U ${PGUSER_ROOT} -1f sql/initialize/140_alter_public_schema.sql
# Load the pgcrypto and uuid functions in to skeleton
cat /opt/local/share/postgresql91/extension/pgcrypto--1.0.sql | sed -e 's#MODULE_PATHNAME#pgcrypto#' > ~/tmp/pgcrypto.sql
cat /opt/local/share/postgresql91/extension/uuid-ossp--1.0.sql | sed -e 's#MODULE_PATHNAME#uuid-ossp#' > ~/tmp/uuid-ossp.sql
- psql skeleton pgsql -1f ~/tmp/pgcrypto.sql
- psql skeleton pgsql -1f ~/tmp/uuid-ossp.sql
+ psql -U ${PGUSER_ROOT} -1f ~/tmp/pgcrypto.sql
+ psql -U ${PGUSER_ROOT} -1f ~/tmp/uuid-ossp.sql
# Load the schema
- psql skeleton skeleton_admin -1 -f sql/schema.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/200_schema.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/205_integrated_definitions.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/210_tables.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/280_views.sql
# Load the functions. This loads the functions and changes their
# permissions. Change DATABASE_PASSWORD_HASH to a random string of bytes
@@ -134,42 +161,48 @@
# on your database servers and is not present (in any capacity) along side
# with your webserver password keys.
pwgen -acn 32 1 > sql/db_password.hash
- psql -At skeleton skeleton -c 'SELECT uuid_generate_v4();' > sql/db_email.uuid
+ psql -At -U ${PGUSER_DBA} -c 'SELECT uuid_generate_v4();' > sql/db_email.uuid
+ chmod 600 sql/db_password.hash sql/db_email.uuid
sed -e "s#DATABASE_PASSWORD_HASH#`cat sql/db_password.hash`#" \
- -e "s#DATABASE_EMAIL_UUID#`cat sql/db_email.uuid`#" sql/funcs.sql.in > sql/funcs.sql
- psql skeleton skeleton_admin -1f sql/funcs.sql
+ -e "s#DATABASE_EMAIL_UUID#`cat sql/db_email.uuid`#" \
+ sql/initialize/300_funcs.sql.in > sql/initialize/300_funcs.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/300_funcs.sql
# CREATE TRIGGER commands are run outside of funcs.sql because there is no
# "CREATE OR REPLACE TRIGGER" syntax.
- psql skeleton skeleton_admin -1f sql/triggers.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/400_triggers.sql
- # ALTER FUNCTION ... OWNER TO ... commands must be run by the pgsql user.
- psql skeleton pgsql -1f sql/fixup_func_owner.sql
+ # ALTER FUNCTION ... OWNER TO ... commands must be run by the -U ${PGUSER_ROOT} user.
+ psql -U ${PGUSER_ROOT} -1f sql/initialize/500_fixup_func_owner.sql
# Add aaa and mod1 to the skeleton_www user's default search_path. This
# only needs to be done when you create a new user.
- psql skeleton pgsql -c 'ALTER ROLE skeleton_www SET search_path = aaa, mod1, public'
+ psql -U ${PGUSER_ROOT} -c "ALTER ROLE skeleton_www IN DATABASE ${PGDATABASE} SET search_path = aaa, mod1, public"
# Populate some initial data
- psql skeleton skeleton_admin -1f sql/initial_data.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/800_initial_data.sql
+ psql -U ${PGUSER_DBA} -1f sql/initialize/801_timezone_data.sql
# Setup the GRANTS. Be sure to audit the database permissions after the
# fact via the psql(1) commands: \dp, \ddp and \dn+
- psql skeleton skeleton_admin -1 -f sql/perms.sql
+ psql -U ${PGUSER_DBA} -1f sql/maintenance/100_perms.sql
+
+ # Neuter the DBA user and use your own personal DBA account.
+ psql -U ${PGUSER_ROOT} -1f sql/initialize/900_cleanup_users.sql
# Other misc PostgreSQL tips:
#
# 1) Look in to using the contrib'ed auto-explain module:
# http://www.postgresql.org/docs/current/static/auto-explain.html
#
# 2) A few suggested development tunables for postgresql.conf:
- # shared_preload_libraries = 'pgcrypto,uuid-ossp'
- # synchronous_commit = off
- # log_connections = on
- # log_disconnections = on
- # log_duration = on
- # log_statement = 'all'
- # timezone = 'UTC'
+ # shared_preload_libraries = 'pgcrypto,uuid-ossp'
+ # synchronous_commit = off
+ # log_connections = on
+ # log_disconnections = on
+ # log_duration = on
+ # log_statement = 'all'
+ # timezone = 'UTC'
#
# 3) Learn how to use dblink (and get creative with its use in functions
# and views):
@@ -220,7 +253,13 @@
# production, but it's really convenient to test in an SSL environment
# from the beginning.
+ a) Register:
+
+ https://127.0.0.1:5000/register
+ Then update shadow.aaa_email_confirmation_log:
+
+ skeleton=> UPDATE shadow.aaa_email_confirmation_log SET confirmed = TRUE WHERE id = 1;
9) In production, you will need to combine all of your static assets
together and probably have them served by nginx. Something like:
@@ -234,7 +273,8 @@
# Be sure to change your CANONICAL_NAME and CANONICAL_PORT settings once
# you move in to production.
-10) Feel free to re-run the sql/perms.sql script as many times as you'd
+
+10) Feel free to re-run the sql/maintenance/100_perms.sql script as many times as you'd
like. \dp, \ddp and \dn+ are your friends.
View
93 TODO
@@ -1,53 +1,84 @@
-See below for a list of completed items. Things still in progress:
+See below for a list of completed items. Things still in progress (roughly in
+order of priority):
-*) Add per-user timezone support
*) Cache a user-object upon login in memcache
-*) Beef up the @logged_in decorator so that it:
- 1) checks memcache for a matching session
- 2) Logs a user out if their session has expired (or updates it accordingly)
- 3) Populates memcache with a user object upon cache miss
-*) Add authorization decorators (maybe)
+*) Begin using the g request global where possible
+*) Scripting via Flask-Script (both shell setup and cron-like jobs)
+*) Change the User model so that specific attributes call the appropriate SQL
+ to update the parameters in the database. Most of User is populated via a
+ VIEW.
+*) Support gettext()
+*) Add a file that handles all of the neuances of integrating Babel and accounts
*) Unit testing framework
*) Migrate to using setup.py instead of providing a fixed requirements.txt
-*) Scripting via Flask-Script (both shell setup and cron-like jobs)
-*) Localization
*) Services API example (XML, maybe protobuf)
-*) Add support for insecure and secure cookies
-*) Logout = kill session in memcache and the database
*) pgmemcache
+*) Example of sending mail
+*) Remaining session tedium:
+ *) Beef up the @logged_in decorator so that it:
+ a) checks memcache for a matching session
+ b) Logs a user out if their session has expired (or updates it accordingly)
+ c) Populates memcache with a user object upon cache miss
+ *) Loop detection for clients that have cookies disabled
+ *) Add no cookie page
+ *) Add support for insecure and secure cookies
+ *) Reissue cookie id's older than 24hrs
+ *) Cookies can have their freshness reset after 10min of life. A cookie
+ looses its fresh status after 20min of total life if not
+ refreshed. Each cookie has a timestamp that it was issued, a min
+ renewal time and a max freshness life.
+ *) Include the level of strength of the authenticated session (password,
+ old/renewed token, or 2FA auth'ed token)
+ *) Integrate/use Flask-Login where possible?
+ *) Add authorization decorators. Each session id needs to be given an
+ authorization token that gets refreshed every 300sec. User
+ automatically gets redirected from the decorator with the missing or
+ expired token and requests an authorization token that lasts for a
+ given app for 300sec.
+ *) Logout = kill session in memcache
-Things that are demonstrated well enough (alpha sorted list):
+Alpha sorted list of demonstrated components (some better than others):
AAA (Access, Authentication, Authorization):
- *) Login (via pl functions)
- *) Logout
- *) Registration
+ *) Login (via pl functions)
+ *) Logout
+ *) Registration
Application:
- *) Integration with other WSGI Middleware's
- *) Modularized development (filesystem layout)
- *) Session management (secure cookie handling)
- *) Static assets management
+ *) Integration with other WSGI Middleware's
+ *) Modularized development (filesystem layout)
+ *) Basic profile management
+ *) Session management (secure cookie handling)
+ *) Static assets management
Database (PostgreSQL):
- *) Database ROLEs and permissions
- *) ORM Layer and examples
- *) PostgreSQL pl functions
- *) PostgreSQL schema
+ *) Give each "application class" different database users to connect as
+ *) Ordered list of .sql files to execute in order to recreate (and
+ maintain) the database.
+ *) ORM Layer and examples
+ *) PostgreSQL pl functions
+ *) Use schemas as a management tool for setting correct permissions
+ *) Use a "DBA" role for owning objects and a DBA user for per-user
+ connections
+ *) Support two DBA roles per user, a read-only acount that lets a DBA see
+ the entire database (but not make changes), and a read-write account
+ that gives the user write privileges. Think of it like being an "admin"
+ and then having to "sudo to the root UID" to complete any real work.
Caching (memcached):
- *) Objects
- *) Views
- *) memoized functions
+ *) memoized functions
+ *) Objects
+ *) Views
Development:
- *) Application profiling
- *) Debugging toolbar
+ *) Application profiling
+ *) Debugging toolbar
ORM (SQLAlchemy):
- *) Use of PostgreSQL functions with SQLAlchemy
+ *) Declarative table use
+ *) Use of PostgreSQL functions with SQLAlchemy
Templating (Jinja2):
- *) Template filters
- *) Template layout
+ *) Template filters
+ *) Template layout
View
2 default_settings.py
@@ -22,7 +22,7 @@
DB_PASS = ''
DB_PORT = '5432'
DB_SCHEMA = 'skeleton_schema'
-DB_ADMIN = 'skeleton_admin'
+DB_ADMIN = 'skeleton_dba'
DB_USER = 'skeleton_www'
DEBUG = False
DEBUG_TOOLBAR = False
View
7 doc/COOKIES.txt
@@ -1,8 +1,9 @@
An explanation of every cookie:
b
- Randomly generated Browser ID
+ Randomly generated Browser ID
skeleton_session
- i = Session ID
- li = Logged In
+ dsturl = URL to be redirected to after a page completes handling a reque
+ i = Session ID
+ li = Logged In
View
7 requirements.txt
@@ -1,3 +1,5 @@
+-e git://github.com/mitsuhiko/flask-babel.git@dd55cda99e5b08717250673fe9bb5a08c09a81fc#egg=Flask_Babel-dev
+Babel==0.9.6
Flask-Cache==0.3.3
Flask-DebugToolbar==0.5
Flask-SQLAlchemy==0.11
@@ -10,17 +12,20 @@ M2Crypto==0.21.1
MarkupSafe==0.12
Paste==1.7.5.1
Pyrex==0.9.8.6
-SQLAlchemy==0.7.0
+SQLAlchemy==0.7.1
Tempita==0.5dev
WTForms==0.6.3
Werkzeug==0.6.2
argparse==1.2.1
blinker==1.1
decorator==3.3.1
+gaepytz==2011c
psycopg2==2.4.1
pyOpenSSL==0.12
python-libmemcached==0.17.0
+pytz==2011g
repoze.browserid==0.3
simplejson==2.1.6
+speaklater==1.2
twill==0.9
wsgiref==0.1.2
View
54 skeleton/__init__.py
@@ -1,16 +1,15 @@
-import os
-import sys
-
-import json
+import json, os, re, sys
from flask import Flask
+from pytz.gae import pytz # NOTE: Import gae.pytz before Babel!!!
+from flaskext.babel import Babel
from flaskext.cache import Cache
from flaskext.debugtoolbar import DebugToolbarExtension
from flaskext.sqlalchemy import SQLAlchemy
from repoze.browserid.middleware import BrowserIdMiddleware
from werkzeug.contrib.securecookie import SecureCookie
-import filters
+from . import filters
__all__ = ['create_app','db']
@@ -30,6 +29,7 @@
def create_app(name = __name__):
app = Flask(__name__, static_path='/static')
load_config(app)
+ babel.init_app(app)
cache.init_app(app)
db.init_app(app)
filters.init_app(app)
@@ -60,19 +60,20 @@ def load_config(app):
# Load the local modules
def load_module_models(app, module):
- if module.has_key('models') and module['models'] == False:
+ if 'models' in module and module['models'] == False:
return
- model_name = module['name']
+ name = module['name']
if app.config['DEBUG']:
- print '[MODEL] Loading db model %s' % (model_name)
- model_name = '%s.models' % (model_name)
+ print '[MODEL] Loading db model %s' % (name)
+ model_name = '%s.models' % (name)
try:
- mod = __import__(model_name)
+ mod = __import__(model_name, globals(), locals(), [], -1)
except ImportError as e:
- import re
if re.match(r'No module named ', e.message) == None:
print '[MODEL] Unable to load the model for %s: %s' % (model_name, e.message)
+ else:
+ print '[MODEL] Other(%s): %s' % (model_name, e.message)
return False
return True
@@ -82,20 +83,24 @@ def register_local_modules(app):
sys.path.append(os.path.dirname(cur) + '/modules')
for m in MODULES:
mod_name = '%s.views' % m['name']
- views = __import__(mod_name)
- url_prefix = None
- if m.has_key('url_prefix'):
- url_prefix = m['url_prefix']
+ try:
+ views = __import__(mod_name, globals(), locals(), [], -1)
+ except ImportError:
+ load_module_models(app, m)
+ else:
+ url_prefix = None
+ if 'url_prefix' in m:
+ url_prefix = m['url_prefix']
- if app.config['DEBUG']:
- print '[VIEW ] Mapping views in %s to prefix: %s' % (mod_name, url_prefix)
+ if app.config['DEBUG']:
+ print '[VIEW ] Mapping views in %s to prefix: %s' % (mod_name, url_prefix)
- # Automatically map '/' to None to prevent modules from stepping on
- # one another.
- if url_prefix == '/':
- url_prefix = None
- load_module_models(app, m)
- app.register_module(views.module, url_prefix=url_prefix)
+ # Automatically map '/' to None to prevent modules from
+ # stepping on one another.
+ if url_prefix == '/':
+ url_prefix = None
+ load_module_models(app, m)
+ app.register_module(views.module, url_prefix=url_prefix)
# Seeing 127.0.0.1 is almost never correct, promise. We're proxied 99.9% of
@@ -116,7 +121,8 @@ def __call__(self, environ, start_response):
environ['REMOTE_ADDR'] = host
return self.app(environ, start_response)
-# Cache
+# Flask Extensions
+babel = Babel()
cache = Cache()
# SQL ORM Missive:
View
1 skeleton/models/__init__.py
@@ -0,0 +1 @@
+from .timezone import Timezone
View
8 skeleton/models/timezone.py
@@ -0,0 +1,8 @@
+from skeleton import db
+
+class Timezone(db.Model):
+ __table__ = db.Table(
+ 'timezone', db.metadata,
+ db.Column('id', db.Integer, primary_key=True),
+ db.Column('name', db.String),
+ schema='public')
View
10 skeleton/modules/aaa/__init__.py
@@ -29,3 +29,13 @@ def decorated_function(*args, **kwargs):
return f(*args, **kwargs)
return decorated_function
+
+def fresh_login_required(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ if 'li' not in session:
+ session['dsturl'] = request.path
+ flash('Fresh login required for previous page')
+ return redirect(url_for('aaa.login'))
+ return f(*args, **kwargs)
+ return decorated_function
View
27 skeleton/modules/aaa/forms.py
@@ -1,5 +1,6 @@
-from flaskext.wtf import BooleanField, Email, EqualTo, Form, Length, \
- Required, PasswordField, RadioField, SubmitField, TextField, ValidationError
+from flaskext.wtf import BooleanField, Email, EqualTo, Form, IntegerField, \
+ Length, NumberRange, Optional, Required, PasswordField, QuerySelectField, \
+ RadioField, SubmitField, TextField, ValidationError
class LoginForm(Form):
email = TextField('Email', validators=[Required(), Email()])
@@ -15,13 +16,33 @@ class LoginForm(Form):
# in a totally broken corporate environment.
+class ProfileForm(Form):
+ password = PasswordField('New Password', validators=[
+ Optional(),
+ Length(min=8, max=80),
+ EqualTo('confirm', message='Passwords must match')
+ ])
+ confirm = PasswordField('Repeat Password')
+ default_ipv4_mask = IntegerField(label='IPv4 Mask', validators=[
+ Optional(),
+ NumberRange(min=0, max=32, message='IPv4 Mask must between %(min)s and %(max)s'),
+ ])
+ default_ipv6_mask = IntegerField(label='IPv6 Mask', validators=[
+ Optional(),
+ NumberRange(min=0, max=128, message='IPv6 Mask must between %(min)s and %(max)s'),
+ ])
+ timezone = QuerySelectField(get_label='name', allow_blank=True)
+ submit = SubmitField('Update Profile')
+
+
class RegisterForm(Form):
email = TextField('Email Address', validators = [Email()])
- password = PasswordField('New Password', validators = [
+ password = PasswordField('New Password', validators=[
Required(),
Length(min=8, max=80),
EqualTo('confirm', message='Passwords must match')
])
confirm = PasswordField('Repeat Password')
accept_tos = BooleanField('I accept the TOS', validators = [Required()])
+ timezone = QuerySelectField(get_label='name', allow_blank=True)
submit = SubmitField('Register')
View
7 skeleton/modules/aaa/models/__init__.py
@@ -1,3 +1,4 @@
-from user import User
-from email import Email
-from user_emails import UserEmails
+from .email import Email
+from .user import User
+from .user_emails import UserEmails
+from .user_info import UserInfo
View
16 skeleton/modules/aaa/models/user.py
@@ -1,8 +1,14 @@
from skeleton import db
class User(db.Model):
- __table__ = db.Table(
- 'user', db.metadata,
- db.Column('id', db.Integer, primary_key=True),
- db.Column('primary_email_id', db.Integer),
- schema='email')
+ active = db.Column(db.Boolean)
+ default_ipv4_mask = db.Column(db.Integer)
+ default_ipv6_mask = db.Column(db.Integer)
+ max_concurrent_sessions = db.Column(db.Integer)
+ registration_utc = db.Column(db.DateTime(timezone=True))
+ timezone_id = db.Column(db.Integer, db.ForeignKey('public.timezone.id'))
+ timezone = db.relationship('Timezone', primaryjoin='Timezone.id==User.timezone_id')
+ user_id = db.Column(db.Integer, primary_key=True)
+
+ __tablename__ = 'user'
+ __table_args__ = {'schema':'aaa'}
View
10 skeleton/modules/aaa/models/user_info.py
@@ -0,0 +1,10 @@
+from skeleton import db
+
+class UserInfo(db.Model):
+ timezone_id = db.Column(db.Integer, db.ForeignKey('public.timezone.id'))
+ timezone = db.relationship('Timezone', primaryjoin='Timezone.id==UserInfo.timezone_id')
+
+ user_id = db.Column(db.Integer, primary_key=True)
+
+ __tablename__ = 'user_info'
+ __table_args__ = {'schema':'aaa'}
View
22 skeleton/modules/aaa/templates/profile.html
@@ -0,0 +1,22 @@
+{% extends "layout/base.html" %}
+{% block title %}aaa/profile{% endblock %}
+{% block content %}
+<h1>Profile</h1>
+
+{% if form.errors %}<p class="error"><strong>Unable to update profile. See error messages below.</strong>{% endif %}
+{% from "_formhelpers.html" import render_field %}
+<form method="POST" action="">
+ <dl>
+ {{ render_field(form.password) }}
+ {{ render_field(form.confirm) }}
+ {{ render_field(form.default_ipv4_mask) }}
+ {{ render_field(form.default_ipv6_mask) }}
+ {{ render_field(form.timezone) }}
+ <dd>{{ form.submit }}</dd>
+ </dl>
+ {{ form.hidden_tag() }}
+</form>
+</p>
+{% endblock %}
+
+
View
1 skeleton/modules/aaa/templates/register.html
@@ -10,6 +10,7 @@
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm) }}
+ {{ render_field(form.timezone) }}
{{ render_field(form.accept_tos) }}
<dd>{{ form.submit }}</dd>
</dl>
View
36 skeleton/modules/aaa/user.py
@@ -0,0 +1,36 @@
+from flask import request
+from sqlalchemy.sql.expression import bindparam, text
+
+from skeleton import cache, db
+from .models.user_info import UserInfo
+
+@cache.memoize()
+def get_user_id(email = None, session_id = None):
+ """ Helper function that returns the user_id for a given email address """
+ if email is not None:
+ result = db.session.execute(
+ text("SELECT aaa.get_user_id_by_email(:email)",
+ bindparams=[bindparam('email', email)]))
+ return result.first()[0]
+
+ if session_id is not None:
+ result = db.session.execute(
+ text("SELECT aaa.get_user_id_by_session_id(:session)",
+ bindparams=[bindparam('session', session_id)]))
+ return result.first()[0]
+ return None
+
+
+@cache.memoize()
+def get_user_timezone(user_id = None, email = None, session_id = None):
+ """ Helper function that returns the user's timezone """
+ if session_id is not None:
+ user_id = get_user_id(session_id=session_id)
+
+ if email is not None:
+ user_id = get_user_id(email)
+
+ if user_id is not None:
+ return UserInfo.query.filter_by(user_id=user_id).first().timezone.name
+
+ return None
View
74 skeleton/modules/aaa/views.py
@@ -1,19 +1,24 @@
import hashlib
-from flask import current_app, flash, g, redirect, render_template, request, session, url_for
+from flask import current_app, flash, g, redirect, render_template, \
+ request, session, url_for
from sqlalchemy.sql.expression import bindparam, text
from sqlalchemy.types import LargeBinary
from skeleton import db
from skeleton.lib import fixup_destination_url, local_request
-from aaa.forms import LoginForm, RegisterForm
-from aaa import gen_session_id, module
+from .forms import LoginForm, ProfileForm, RegisterForm
+from . import fresh_login_required, gen_session_id, module
+from skeleton.models import Timezone
+from aaa.models.user import User
+from .user import get_user_id
+
@module.route('/login', methods=('GET','POST'))
def login():
form = LoginForm()
# Generate a session ID for them if they don't have one
- if not session.has_key('i'):
+ if 'i' not in session:
session['i'] = gen_session_id()
fixup_destination_url('dsturl','post_login_url')
@@ -48,14 +53,14 @@ def login():
ses = db.session
result = ses.execute(
- # SELECT result, "column", message FROM aaa.login(email := 'user@example.com', password := '\xbd\x18\xee\x85\x9f\x19Bl\x1e\x9dE\\xdc\x10\xe2NH\x1b\x94\xe5n\x01C\x98\xe5AQ\x05\xb2\xa7,\x1co', ip_address := '11.22.33.44', session_id := 'user session id from flask', renewal_interval := '60 minutes'::INTERVAL) AS (result BOOL, "column" TEXT, message TEXT);
- text("SELECT ret, col, msg FROM aaa.login(:email, :pw, :ip, :sid, :idle) AS (ret BOOL, col TEXT, msg TEXT)",
+ text("SELECT ret, col, msg FROM aaa.login(:email, :pw, :ip, :sid, :idle, :secure) AS (ret BOOL, col TEXT, msg TEXT)",
bindparams=[
bindparam('email', form.email.data),
bindparam('pw', shapass, type_=LargeBinary),
bindparam('ip', remote_addr),
bindparam('sid', new_sess_id),
- bindparam('idle',idle)]))
+ bindparam('idle',idle),
+ bindparam('secure', request.is_secure)]))
# Explicitly commit regardless of the remaining logic. The database
# did the right thing behind the closed doors of aaa.login() and we
@@ -78,8 +83,7 @@ def login():
try:
# If the database says be vague, we'll be vague in our error
# messages. When the database commands it we obey, got it?
- field = form.__getattribute__(row[1])
- if field.name == 'vague':
+ if row[1] == 'vague':
# Set bogus data so that 'form.errors == True'. If brute
# force weren't such an issue, we'd just append a field
# error like below. If you want to get the specifics of
@@ -90,6 +94,7 @@ def login():
form.errors['EPERM'] = 'There is no intro(2) error code for web errors'
pass
else:
+ field = form.__getattribute__(row[1])
field.errors.append(row[2])
except AttributeError as e:
pass
@@ -98,18 +103,34 @@ def login():
@module.route('/logout')
def logout():
+ # Is there a destination post-logout?
dsturl = None
if request.referrer and local_request(request.referrer):
dsturl = request.referrer
else:
dsturl = None
- already_logged_out = True if 'li' not in session else False
+ # End the session in the database
+ already_logged_out = False
+ if 'li' in session:
+ ses = db.session
+ result = ses.execute(
+ text("SELECT ret, col, msg FROM aaa.logout(:sid) AS (ret BOOL, col TEXT, msg TEXT)",
+ bindparams=[bindparam('sid', session['i'])]))
+ ses.commit()
+ # For now, don't test the result of the logout call. Regardless of
+ # whether or not a user provides us with a valid session ID from the
+ # wrong IP address, terminate the session. Shoot first, ask questions
+ # later (i.e. why was a BadUser in posession of GoodUser's session
+ # ID?!)
+ else:
+ already_logged_out = True
# Nuke every key in the session
for k in session.keys():
session.pop(k)
+ # Set a flash message after we nuke the keys in session
if already_logged_out:
flash('Session cleared for logged out user')
else:
@@ -118,12 +139,37 @@ def logout():
return render_template('aaa/logout.html', dsturl=dsturl)
+@module.route('/profile', methods=('GET','POST'))
+@fresh_login_required
+def profile():
+ user_id = get_user_id(session_id = session['i'])
+ user = User.query.filter_by(user_id=user_id).first_or_404()
+ form = ProfileForm(obj=user)
+ form.timezone.query = Timezone.query.order_by(Timezone.name)
+
+ if form.validate_on_submit():
+ shapass = None
+ if form.password:
+ # Hash the password once here:
+ h = hashlib.new('sha256')
+ h.update(current_app.config['PASSWORD_HASH'])
+ h.update(form.password.data)
+ shapass = h.digest()
+
+ form.populate_obj(user)
+ user.password = shapass
+ db.session.add(user)
+ db.session.commit()
+ return render_template('aaa/profile.html', form=form)
+
+
@module.route('/register', methods=('GET','POST'))
def register():
form = RegisterForm()
- if not session.has_key('i'):
+ if 'i' not in session:
session['i'] = gen_session_id()
+ form.timezone.query = Timezone.query.order_by(Timezone.name)
if form.validate_on_submit():
# Form validates, execute the registration pl function
@@ -144,6 +190,12 @@ def register():
bindparam('ip', remote_addr)]))
row = result.first()
if row[0] == True:
+ # Update the user's timezone if they submitted a timezone
+ if form.timezone.data:
+ res = ses.execute(
+ text("INSERT INTO aaa.user_info (user_id, timezone_id) VALUES (get_user_id_by_email(:email), :tz)",
+ bindparams=[bindparam('email', form.email.data),
+ bindparam('tz', form.timezone.data.id),]))
ses.commit()
flash('Thanks for registering! Please check your %s email account to confirm your email address.' % (form.email.data))
return redirect(url_for('aaa.login'))
View
4 skeleton/modules/home/models/__init__.py
@@ -1,2 +1,2 @@
-from h1 import H1
-from h3 import H3
+from .h1 import H1
+from .h3 import H3
View
1 skeleton/modules/home/views.py
@@ -1,4 +1,5 @@
from flask import render_template, request
+
from home import module
@module.route('/')
View
2 skeleton/modules/mod1/models/__init__.py
@@ -1 +1 @@
-from h2 import H2
+from .h2 import H2
View
5 skeleton/modules/mod1/views.py
@@ -1,11 +1,12 @@
import random
from flask import render_template, request
-from mod1 import module
-from mod1.models import H2
from skeleton import cache
+from . import module
+from .models import H2
+
@cache.memoize(timeout=10)
def random_func(useless_parameter):
""" Cache a random number for 10 seconds """
View
3 skeleton/modules/mod2/views.py
@@ -2,9 +2,10 @@
from flask import render_template, redirect, session, url_for
-from mod2 import module
from skeleton import cache
+from . import module
+
def user_logged_in():
""" Returns true if the user is logged in """
return True if 'li' in session else False
View
1 skeleton/modules/mod3/forms.py
@@ -1,4 +1,5 @@
import re
+
from flaskext.wtf import Form, Regexp, Required, SubmitField, TextField, URL
class PageAddTagForm(Form):
View
6 skeleton/modules/mod3/models/__init__.py
@@ -1,3 +1,3 @@
-from page_tags import PageTags
-from page import Page
-from tag import Tag
+from .page_tags import PageTags
+from .page import Page
+from .tag import Tag
View
3 skeleton/modules/mod3/models/page.py
@@ -1,5 +1,6 @@
from skeleton import db
-from page_tags import PageTags
+
+from .page_tags import PageTags
class Page(db.Model):
id = db.Column(db.Integer, primary_key=True)
View
2 skeleton/modules/mod3/templates/root.html
@@ -10,6 +10,8 @@
{% endif %}</li>
<li><a href="{{ url_for('tag_list') }}">Tag List</a></li>
</ul>
+<hr/>
+<p>According to the timezone information stored in the database, the local time for you is: <code>{{time}}</code></p>
{% endblock %}
{% block footer %}
{{ super() }}
View
25 skeleton/modules/mod3/views.py
@@ -1,15 +1,28 @@
-from flask import flash, redirect, render_template, url_for
+from datetime import datetime
-from mod3 import module
-from mod3.forms import PageAddTagForm, PageSubmitForm
-from mod3.models import Page, PageTags, Tag
-from skeleton import db
+from flask import current_app, flash, redirect, render_template, \
+ session, url_for
+from flaskext.babel import to_user_timezone
+
+from skeleton import babel, db
from aaa import login_required
+from aaa.user import get_user_timezone
+
+from . import module
+from .forms import PageAddTagForm, PageSubmitForm
+from .models import Page, PageTags, Tag
+
+
+@babel.timezoneselector
+def get_timezone():
+ if 'li' in session:
+ return get_user_timezone(session_id=session['i'])
@module.route('/')
def some_random_view():
- return render_template('mod3/root.html')
+ t = datetime.utcnow()
+ return render_template('mod3/root.html', time=to_user_timezone(t))
@module.route('/pages')
View
BIN skeleton/static/flask-powered.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
BIN skeleton/static/powered-by-flask-s.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
2 skeleton/templates/_formhelpers.html
@@ -1,6 +1,6 @@
{% macro render_field(field) %}
<dt>{{ field.label }}</dt>
- <dd>{{ field(**kwargs)|safe }}
+ <dd>{{ field(**kwargs) }}
{% if field.errors %}
<ul class="errors">
{% for error in field.errors %}<li>{{ error }}{% endfor %}
View
5 skeleton/templates/layout/base.html
@@ -21,6 +21,7 @@
<li><a href="{{ url_for('aaa.login', dsturl=request.url) }}">Login</a></li>
<li><a href="{{ url_for('aaa.register') }}">Register</a></li>
{% else %}
+ <li><a href="{{ url_for('aaa.profile') }}">Profile</a></li>
<li><a href="{{ url_for('aaa.logout') }}">Logout</a></li>
{% endif %}
</ul>
@@ -38,9 +39,9 @@
By copying from this example, I promise to make the world to be a better place and suck just a little less than it did before I woke up this morning.
{% endblock %}
{% if [0,1]|random == 0 %}
- <br/><a href="http://flask.pocoo.org/"><img src="http://flask.pocoo.org/static/badges/flask-powered.png" border="0" alt="Flask powered" title="Flask powered"></a>
+ <br/><a href="http://flask.pocoo.org/"><img src="/static/flask-powered.png" border="0" alt="Flask powered" title="Flask powered"></a>
{% else %}
- <br/><a href="http://flask.pocoo.org/"><img src="http://flask.pocoo.org/static/badges/powered-by-flask-s.png" border="0" alt="powered by Flask" title="powered by Flask"></a>
+ <br/><a href="http://flask.pocoo.org/"><img src="/static/powered-by-flask-s.png" border="0" alt="powered by Flask" title="powered by Flask"></a>
{% endif %}
</div>
</body>
View
1 sql/.gitignore
@@ -1,4 +1,3 @@
-funcs.sql
# Don't loose db_password.hash! If you loose the contents of that file, you loose the ability to log users in!!!! Mass-password resets suck.
#db_password.hash
# Don't loose db_email.uuid. Not the end of the world if you do, but you loose the ability to regenerate the email confirmation code UUIDs.
View
14 sql/create_roles.sql
@@ -1,14 +0,0 @@
--- Lots of www processes (use pgbouncer!!!)
-CREATE ROLE skeleton_www CONNECTION LIMIT 200 LOGIN;
-
--- Only a few admins
-CREATE ROLE skeleton_admin CONNECTION LIMIT 2 LOGIN;
-
--- There should only ever be one connection as the email user. This limits
--- the possibility of accidentally sending out duplicate emails.
-CREATE ROLE skeleton_email CONNECTION LIMIT 1 LOGIN;
-
--- Note that the skeleton_shadow user can't log in. This is very much
--- intended. The skeleton_shadow user is the user that various functions
--- execute with (think "setuid" privs for certain pl/pgsql functions).
-CREATE ROLE skeleton_shadow;
View
7 sql/fixup_func_owner.sql
@@ -1,7 +0,0 @@
--- Change the owner of these functions to skeleton_shadow because SECURITY
--- DEFINER is a set attribute for these functions. This must be run as the
--- pgsql user (can't be skeleton_admin because skeleton_admin isn't in the
--- role 'skeleton_shadow').
-ALTER FUNCTION aaa.expire_session(TEXT) OWNER TO skeleton_shadow;
-ALTER FUNCTION aaa.login(TEXT, TEXT, INET, TEXT, INTERVAL) OWNER TO skeleton_shadow;
-ALTER FUNCTION aaa.register(TEXT, TEXT, INET) OWNER TO skeleton_shadow;
View
279 sql/funcs.sql.in
@@ -1,279 +0,0 @@
--- Create a convenience function to SHA256 hash some data. Putting this
--- function in the public schema since it doesn't actually change the state
--- of any tables.
-CREATE OR REPLACE FUNCTION public.sha256(BYTEA) RETURNS TEXT AS $$
- SELECT encode(public.digest($1, 'sha256'), 'hex')
-$$ LANGUAGE SQL STRICT IMMUTABLE;
-CREATE OR REPLACE FUNCTION public.sha256(TEXT) RETURNS TEXT AS $$
- SELECT encode(public.digest($1, 'sha256'), 'hex')
-$$ LANGUAGE SQL STRICT IMMUTABLE;
-
-
--- Create a function to log a user in to the site. SECURITY DEFINER is set on
--- the function so that the skeleton_www user can execute the FUNCTION
--- without giving the skeleton_www user access to the shadow schema. Think of
--- SECURITY DEFINER like the "setuid" bit for *NIX programs. The return value
--- can be split in to three columns:
---
--- The return result (BOOL), the field the message applies to (TEXT), and
--- optional relevant information (TEXT). Example:
---
--- SELECT result, "column", message FROM aaa.login(email := 'user@example.com', password := '\xbd\x18\xee\x85\x9f\x19Bl\x1e\x9dE\xdc\x10\xe2NH\x1b\x94\xe5n\x01C\x98\xe5AQ\x05\xb2\xa7,\x1co', ip_address := '11.22.33.44', session_id := 'user session id from flask', renewal_interval := '60 minutes'::INTERVAL) AS (result BOOL, "column" TEXT, message TEXT);
-CREATE OR REPLACE FUNCTION aaa.login(email TEXT, password BYTEA, ip_address INET, session_id TEXT, renewal_interval INTERVAL) RETURNS RECORD AS $aaa_login$
-DECLARE
- ret RECORD;
- a_email ALIAS FOR email;
- a_password ALIAS FOR password;
- a_ip_address ALIAS FOR ip_address;
- a_session_id ALIAS FOR session_id;
- a_renewal_interval ALIAS FOR renewal_interval;
- v_email_id shadow.aaa_email.id%TYPE;
- v_email_confirmed shadow.aaa_email_confirmation_log.confirmed%TYPE;
- v_user_id shadow.aaa_user.id%TYPE;
- v_user_active shadow.aaa_user.active%TYPE;
- v_pw_from_user shadow.aaa_user.hashpass%TYPE;
- v_pw_from_db shadow.aaa_user.hashpass%TYPE;
- v_login_attempts INT NOT NULL DEFAULT 99;
- MAX_LOGIN_ATTEMPTS INT NOT NULL DEFAULT 3;
- LOGIN_INTERVAL INTERVAL NOT NULL DEFAULT '20 minutes'::INTERVAL;
-BEGIN
- -- Check that the email is valid.
- SELECT e.id, e.user_id INTO v_email_id, v_user_id FROM shadow.aaa_email AS e WHERE e.email = a_email;
- IF NOT found THEN
- -- Prevent someone from dictionary attacking the database
- -- with possibly known email addresses. No point in logging
- -- a failed login attempt with an unknown email address.
- ret := (FALSE, 'vague'::TEXT, 'Unknown email address'::TEXT);
- RETURN ret;
- END IF;
-
- -- Check that the email address is confirmed. Since login is a
- -- crucial part of any application, perform this check independently
- -- of the above email check that way a user knows that they need to
- -- have their email address confirmed. "Login failed" is less than
- -- descriptive and very unhelpful or friendly.
-
- SELECT ecl.confirmed INTO v_email_confirmed FROM shadow.aaa_email_confirmation_log AS ecl
- WHERE ecl.email_id = v_email_id
- ORDER BY ecl.timestamp_sent DESC LIMIT 1;
- IF NOT found THEN
- -- Raising an exception here because this means that
- -- something was INSERTed in to the database
- -- improperly. Normally I'd just RETURN FALSE, but given all
- -- registration activities are handled via a highly
- -- controlled FUNCTIONs (e.g. aaa.register()), RAISEing an
- -- EXCEPTION is more appropriate.
- RAISE EXCEPTION 'No email confirmation information for ecl.email_id %', v_email_id;
- END IF;
-
- -- Hash the user's password using a constant hash. Password inputs in
- -- to the database should be hashed by the web process before we
- -- attempt to hash the user's password here. Reason: If the hash'ed
- -- password in the database is just sha256('mypass') then the
- -- resulting hash is bruteforce-able or can be resolved via static
- -- dictionaries. If, however, the input to this function was already
- -- hashed on the webserver by: sha256('%s%s' % (webserver_login_key,
- -- user_pass)), the stored contents is not vulnerable to a host of
- -- attacks. This is not perfect, but it is significantly better than
- -- having a password hashed a single time. i.e. sha256('mypass') ==
- -- bad. sha256(sha256('mypass'), webserver_key) == infinitely better
- v_pw_from_user := public.sha256(a_password || 'DATABASE_PASSWORD_HASH');
- SELECT u.hashpass, u.active INTO v_pw_from_db, v_user_active
- FROM shadow.aaa_user AS u WHERE u.id = v_user_id;
- IF NOT FOUND THEN
- -- This check is required, despite having FOREIGN KEYS in
- -- place (some FOREIGN KEY constraints aren't executed until
- -- COMMIT time, which means aaa.login() could possibly return
- -- inconsistant information to the user).
- RAISE EXCEPTION 'No user record for user_id %', v_user_id;
- END IF;
-
- -- Access checks first: Is the user account active?
- IF NOT v_user_active THEN
- -- RETURN FALSE instead of being vague so that someone knows
- -- their account has been deactivated (for whatever reason).
- INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Account not active');
- ret := (FALSE, 'email'::TEXT, 'Account not active'::TEXT);
- RETURN ret;
- END IF;
-
- -- Access check: Throttle the number of login attempts in the
- -- configured interval *before* actually checking to see if the
- -- password matches.
- SELECT COUNT(*) INTO v_login_attempts
- FROM shadow.aaa_login_attempts AS ala
- WHERE
- TRUE = TRUE
- AND ala.user_id = v_user_id
- AND ala.login_utc >= (NOW() - LOGIN_INTERVAL)
- AND ala.success = FALSE;
- IF v_login_attempts > MAX_LOGIN_ATTEMPTS THEN
- INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Too many login attempts');
- ret := (FALSE, 'email'::TEXT, 'Too many failed login attempts. Account locked.'::TEXT);
- RETURN ret;
- END IF;
-
- -- Authentication checks second: Do passwords match?
- IF v_pw_from_db != v_pw_from_user THEN
- -- Passwords don't match? Oof. Suck on a vague message you
- -- hacker douche bag you ... or innocent person who forgot or
- -- typo'ed their password.
- INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Password incorrect');
- ret := (FALSE, 'vague'::TEXT, 'Password incorrect'::TEXT);
- RETURN ret;
- END IF;
-
- -- Check to make sure they've confirmed their email address.
- IF NOT v_email_confirmed THEN
- -- Successfully log a valid login attempt for users who have
- -- entered the right password, but haven't confirmed their
- -- email address yet. The above throttling check relies on
- -- failed login attempts so frustrated and impatient users
- -- who can't read will still get in once they calm down and
- -- RTFS (Read The Fucking Screen) and check their email.
- INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, TRUE, 'Email Address Unconfirmed');
- ret := (FALSE, 'email'::TEXT, 'Email Address Unconfirmed'::TEXT);
- RETURN ret;
- END IF;
-
- -- At this point, the user should be granted access and a session
- -- should be created. The INSERT triggers on shadow.aaa_session will
- -- invalidate other sessions automatically.
- INSERT INTO shadow.aaa_session (user_id, session_id, ip_addr, valid, start_utc, end_utc, renewal_interval) VALUES (v_user_id, a_session_id, a_ip_address, TRUE, NOW(), NOW() + a_renewal_interval, a_renewal_interval);
-
- -- Record the success
- INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success) VALUES (v_user_id, NOW(), a_ip_address, TRUE);
-
- ret := (TRUE, NULL::TEXT, NULL::TEXT);
- RETURN ret;
-END;
-$aaa_login$
- LANGUAGE plpgsql
- RETURNS NULL ON NULL INPUT
- SECURITY DEFINER;
-
-
--- Create a function to register a new user with the site. SECURITY DEFINER
--- is set on the function so that the skeleton_www user can execute the
--- FUNCTION without giving the skeleton_www user access to the shadow
--- schema. Think of SECURITY DEFINER like the "setuid" bit for *NIX
--- programs. The return value can be split in to three columns:
---
--- The return result (BOOL), the field the message applies to (TEXT), and
--- optional relevant information (TEXT). Example:
---
--- SELECT result, "column", message FROM aaa.register(email := 'user@example.com', password := 'myfailpw', ip_address := '11.22.33.44') AS (result BOOL, "column" TEXT, message TEXT);
-
-CREATE OR REPLACE FUNCTION aaa.register(email TEXT, password BYTEA, ip_address INET) RETURNS RECORD AS $$
-DECLARE
- ret RECORD;
- a_email ALIAS FOR email;
- a_password ALIAS FOR password;
- a_ip_address ALIAS FOR ip_address;
- v_email_conf_code shadow.aaa_email_confirmation_log.confirmation_code%TYPE;
- v_email_id shadow.aaa_email.id%TYPE;
- v_emailconflog_id shadow.aaa_email_confirmation_log.id%TYPE;
- v_user_id shadow.aaa_user.id%TYPE;
- v_hashed_password shadow.aaa_user.hashpass%TYPE;
-BEGIN
- -- Check that the email isn't already in use
- SELECT e.id INTO v_email_id FROM shadow.aaa_email AS e WHERE e.email = a_email LIMIT 1;
- IF found THEN
- -- Should log that this IP address attempted a failed registration
- ret := (FALSE, 'email'::TEXT, 'Email address already in use'::TEXT);
- RETURN ret;
- END IF;
-
- -- Hash the user's password.
- IF LENGTH(a_password) != 32 THEN
- RAISE EXCEPTION 'Password was not previously hashed by the webserver';
- END IF;
- -- Hash the password a second time using a database hash
- v_hashed_password := public.sha256(a_password || 'DATABASE_PASSWORD_HASH');
-
- -- Create a new user and email. Create the aaa_email record because
- -- it is smaller and we need to perform an UPDATE to resolve the
- -- circular FOREIGN KEYS.
- INSERT INTO shadow.aaa_email (email, user_id) VALUES (a_email, 0) RETURNING id INTO STRICT v_email_id;
- INSERT INTO shadow.aaa_user (hashpass, primary_email_id, registration_utc, registration_ip, active) VALUES (v_hashed_password, v_email_id, NOW(), a_ip_address, TRUE) RETURNING id INTO STRICT v_user_id;
-
- -- Use DATABASE_EMAIL_UUID to secure email confirmation codes. As
- -- long as DATABASE_EMAIL_UUID is not leaked, every email address
- -- should have a unique confirmation code that can be recreated based
- -- on the row id. This could have been done via a TRIGGER but I
- -- wanted to show case a different method of pre-fetching the value
- -- out of a SEQUENCE.
- v_emailconflog_id := NEXTVAL('shadow.aaa_email_confirmation_log_id_seq'::regclass);
- INSERT INTO shadow.aaa_email_confirmation_log (id, email_id, timestamp_sent, confirmation_code, ip_address) VALUES (v_emailconflog_id, v_email_id, NOW() AT TIME ZONE 'UTC', public.uuid_generate_v5('DATABASE_EMAIL_UUID'::UUID, v_emailconflog_id::TEXT), a_ip_address) RETURNING confirmation_code INTO STRICT v_email_conf_code;
- UPDATE shadow.aaa_email SET user_id = v_user_id WHERE id = v_email_id;
- ret := (TRUE, 'confirmation code'::TEXT, v_email_conf_code::TEXT);
- RETURN ret;
-END;
-$$
- LANGUAGE plpgsql
- RETURNS NULL ON NULL INPUT
- SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION aaa.expire_session(session_id TEXT) RETURNS BOOL AS $ses_expire$
-DECLARE
- a_session_id ALIAS FOR session_id;
-BEGIN
- UPDATE shadow.aaa_session SET valid = FALSE WHERE session_id = a_session_id;
- RETURN FOUND;
-END;
-$ses_expire$
- LANGUAGE plpgsql
- RETURNS NULL ON NULL INPUT
- SECURITY DEFINER;
-
-
-CREATE OR REPLACE FUNCTION shadow.aaa_session_expire_excess_trg() RETURNS TRIGGER AS $ses_expire$
-DECLARE
- ses RECORD;
- v_max_sessions shadow.aaa_user.max_concurrent_sessions%TYPE;
- v_current_sessions INT;
-BEGIN
- -- Mark old sessions invalid if the user's number of concurrent
- -- sessions has been reached.
- SELECT u.max_concurrent_sessions INTO STRICT v_max_sessions
- FROM shadow.aaa_user AS u
- WHERE u.id = NEW.user_id;
- IF NOT FOUND THEN
- RAISE EXCEPTION 'user_id not found %', NEW.user_id;
- END IF;
-
- SELECT COUNT(*) INTO STRICT v_current_sessions
- FROM shadow.aaa_sessions AS s
- WHERE s.user_id = NEW.user_id AND s.valid = TRUE;
- IF v_current_sessions >= v_max_sessions THEN
- -- Need to loop through every session older than the user's
- -- max allowed number of sessions and expire them.
- FOR ses IN
- SELECT * FROM shadow.aaa_sessions
- WHERE s.user_id = NEW.user_id AND s.valid = TRUE
- ORDER BY s.end_utc DESC OFFSET v_max_sessions - 1
- LOOP
- EXECUTE aaa.expire_session(s.session_id);
- END LOOP;
- END IF;
-
- -- Ok, can continue now that any excess sessions have been expired
- RETURN NEW;
-END;
-$ses_expire$ LANGUAGE plpgsql;
-
-
-
--- Upon expiring a session, make sure the session's end isn't at some point
--- in the future.
-CREATE OR REPLACE FUNCTION shadow.aaa_session_fixup_end_utc_trg() RETURNS TRIGGER AS $ses_fixup_end_utc$
-BEGIN
- IF OLD.end_utc > NOW() THEN
- NEW.end_utc := NOW();
- END IF;
-
- RETURN NEW;
-END;
-$ses_fixup_end_utc$
- LANGUAGE plpgsql;
View
1 sql/initialize/.gitignore
@@ -0,0 +1 @@
+300_funcs.sql
View
27 sql/initialize/100_create_roles.sql
@@ -0,0 +1,27 @@
+-- env PGDATABASE=template1 PGUSER=pgsql
+
+-- The skeleton_shadow user can't log in. This is very much intended. The
+-- skeleton_shadow user is the user that various functions execute with
+-- (think "setuid" privs for certain pl/pgsql functions).
+CREATE ROLE skeleton_shadow NOLOGIN;
+
+-- Lots of www processes (use pgbouncer!!!)
+CREATE ROLE skeleton_www CONNECTION LIMIT 200 LOGIN;
+
+-- Allow only one connection from skeleton_root user for now. Once the app
+-- goes in to maintenance mode, only use your per-user login. At the end of
+-- this procedure the skeleton_root ROLE is prevented from logging in. The
+-- skeleton_dba ROLE is a read-only GROUP. If a user needs read/write access,
+-- they need to use their respective read-write account. Once setup, it's
+-- easy to maintain the permissions since you grant permissions to the
+-- GROUPs, not to individuals. Think of it like sudo for dba's.
+--
+-- "Only you can prevent forrest fires."
+CREATE ROLE skeleton_dba NOLOGIN;
+CREATE ROLE skeleton_root CONNECTION LIMIT 1 LOGIN IN GROUP skeleton_shadow;
+
+-- There should only ever be one connection as the email user. This limits
+-- the possibility of accidentally sending out duplicate emails. The paranoid
+-- can use this as a way of preventing duplicate jobs from running and
+-- accidentally emailing out duplicates.
+CREATE ROLE skeleton_email CONNECTION LIMIT 1 LOGIN;
View
3 sql/initialize/130_create_database.sql
@@ -0,0 +1,3 @@
+-- env PGDATABASE=template1 PGUSER=pgsql
+CREATE DATABASE skeleton OWNER skeleton_root
+ ENCODING='UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8';
View
2 sql/initialize/140_alter_public_schema.sql
@@ -0,0 +1,2 @@
+-- env PGDATABASE=skeleton PGUSER=pgsql
+ALTER SCHEMA public OWNER TO skeleton_root;
View
7 sql/initialize/200_schema.sql
@@ -0,0 +1,7 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+CREATE SCHEMA mod1;
+
+CREATE SCHEMA aaa;
+CREATE SCHEMA email;
+CREATE SCHEMA shadow;
View
50 sql/initialize/205_integrated_definitions.sql
@@ -0,0 +1,50 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+-- Create a "normal" schema that inlines all of the normal SQL features in a
+-- single file. For more complex schemas, see the remaining .sql files that
+-- break apart these steps.
+
+-- module home's models
+CREATE TABLE public.h1 (
+ id SERIAL,
+ val TEXT,
+ PRIMARY KEY(id)
+);
+
+CREATE TABLE public.h2 (
+ id SERIAL,
+ val TEXT,
+ val2 TEXT,
+ PRIMARY KEY(id)
+);
+
+-- module mod1's models
+CREATE TABLE mod1.h1 (
+ id SERIAL,
+ val2 TEXT,
+ PRIMARY KEY(id)
+);
+
+-- module mod3's models
+CREATE TABLE public.page (
+ id SERIAL,
+ url TEXT,
+ PRIMARY KEY(id)
+);
+CREATE UNIQUE INDEX page_url_udx ON public.page(LOWER(url));
+
+CREATE TABLE public.tag (
+ id SERIAL,
+ name TEXT,
+ PRIMARY KEY(id)
+);
+CREATE UNIQUE INDEX tag_name_udx ON public.tag(LOWER(name));
+
+CREATE TABLE public.page_tags (
+ page_id INT NOT NULL,
+ tag_id INT NOT NULL,
+ PRIMARY KEY(page_id, tag_id),
+ FOREIGN KEY(page_id) REFERENCES public.page(id),
+ FOREIGN KEY(tag_id) REFERENCES public.tag(id)
+);
+CREATE UNIQUE INDEX page_tags_tag_page_id_udx ON public.page_tags (tag_id, page_id);
View
122 sql/initialize/210_tables.sql
@@ -0,0 +1,122 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+-- Centralize the list of all timezones
+CREATE TABLE public.timezone (
+ id SERIAL,
+ name TEXT NOT NULL,
+ PRIMARY KEY(id)
+);
+
+
+-- BEGIN: aaa's schema
+-- A table housing user-related information where there is no-harm if "a
+-- SELECT *'s worth of information" is accessed or updated by a web user.
+CREATE TABLE aaa.user_info (
+ user_id INT NOT NULL,
+ timezone_id INT
+);
+
+
+CREATE TABLE shadow.aaa_email (
+ id SERIAL,
+ email TEXT NOT NULL,
+ user_id INT NOT NULL,
+ PRIMARY KEY(id)
+);
+
+
+CREATE TABLE shadow.aaa_email_confirmation_log (
+ id SERIAL NOT NULL,
+ email_id INT NOT NULL,
+ timestamp_sent TIMESTAMP WITH TIME ZONE NOT NULL,
+ ttl INTERVAL NOT NULL DEFAULT '8 hours'::INTERVAL,
+ confirmation_code UUID NOT NULL,
+ confirmed BOOL NOT NULL DEFAULT FALSE,
+ ip_address INET,
+ timestamp_confirmed TIMESTAMP WITH TIME ZONE,
+ CHECK(confirmed = FALSE OR (confirmed = TRUE AND ip_address IS NOT NULL AND timestamp_confirmed IS NOT NULL)),
+ CHECK(EXTRACT(TIMEZONE FROM timestamp_sent) = 0.0),
+ -- If timestamp_confirmed IS NULL, the CHECK should pass, otherwise make
+ -- sure that we stored the data in UTC.
+ CHECK(timestamp_confirmed IS NULL OR EXTRACT(TIMEZONE FROM timestamp_confirmed) = 0.0),
+ PRIMARY KEY(id)
+);
+
+
+CREATE TABLE shadow.aaa_user (
+ id SERIAL,
+ hashpass TEXT NOT NULL,
+ active BOOL NOT NULL,
+ primary_email_id INT NOT NULL,
+ registration_utc TIMESTAMP WITH TIME ZONE NOT NULL,
+ registration_ip INET NOT NULL,
+ max_concurrent_sessions INT NOT NULL DEFAULT 1,
+ default_ipv4_mask INT NOT NULL DEFAULT 32,
+ default_ipv6_mask INT NOT NULL DEFAULT 128,
+ PRIMARY KEY(id),
+ CHECK(max_concurrent_sessions >= 0),
+ CHECK(EXTRACT(TIMEZONE FROM registration_utc) = 0.0)
+);
+
+
+-- The login attempts table needs a bit of explanation compared to the
+-- shadow.aaa_session table. This table is meant to be a pseudo-transient
+-- record of user activity (the person). Compare that with aaa_session, which
+-- is geared toward the actual session and behavior of sessions.
+CREATE TABLE shadow.aaa_login_attempts (
+ user_id INT NOT NULL,
+ login_utc TIMESTAMP WITH TIME ZONE NOT NULL,
+ ip_address INET NOT NULL,
+ success BOOL NOT NULL,
+ notes TEXT,
+ CHECK(success OR (NOT success AND notes IS NOT NULL)),
+ CHECK(EXTRACT(TIMEZONE FROM login_utc) = 0.0)
+);
+
+
+-- Backing storage mechanism for session information. Column notes:
+
+-- ip_mask is used to constrain which IP addresses are allowed to use this
+-- session id. In IPv4 land, this defaults to 32 (the specific IP address),
+-- and in IPv6 land, we default to a 128 bit mask. This is configurable on a
+-- per-user basis in the shadow.aaa_user table. If someone requests to rekey
+-- a session from an IP address outside of their session_ip_mask, the session
+-- will be marked invalid. This is set on a per-user basis for now, however
+-- it would be preferrable if there were "network profiles" setup for each
+-- user and this setting could be adjusted on a per-profile basis (work vs
+-- mobile). The value of moving this setting to a per-network profile basis
+-- is that mobile devices have their IP address change, corporate networks,
+-- etc. Using sane defaults for each profile would be preferred. For example,
+-- a home IP profile is a /32, a corporate profile should also be a /32
+-- unless the company has a screwed up network in which case they could use a
+-- /24, and nearly all mobile devices connect from the rediculously huge /9
+-- network from WDSPCo (i.e. NETBLK-CDPD-B) that can probably be assumed to
+-- be only /24's without much harm. A larger problem that I'd rather not
+-- re-tackle at this point in time.
+
+-- secure: the session ID is to be transmitted via HTTPS only.
+
+-- valid: marked FALSE when the session expires
+
+-- start_utc is when the session was created and end_utc is the planned
+-- expiration date of the session. A particular session_id can not be renewed
+-- or have its expiration time extended, however an expired session (within
+-- reason), can be used as an authentication source and renew a
+-- session_id.
+
+-- end_utc: When a user logs out, end_utc is reset to NOW() and valid is set
+-- to FALSE. If end_utc is moved backwards (e.g. to NOW()), the session ID
+-- must be invalidated from cache.
+
+-- session_id is generated by skeleton/aaa/__init__.py:gen_session_id()
+CREATE TABLE shadow.aaa_session (
+ user_id INT NOT NULL,
+ ip_mask CIDR NOT NULL,
+ secure BOOL NOT NULL DEFAULT TRUE,
+ valid BOOL NOT NULL,
+ start_utc TIMESTAMP WITH TIME ZONE NOT NULL,
+ end_utc TIMESTAMP WITH TIME ZONE NOT NULL,
+ session_id TEXT NOT NULL,
+ CHECK(start_utc < end_utc),
+ CHECK(EXTRACT(TIMEZONE FROM start_utc) = 0.0 AND EXTRACT(TIMEZONE FROM end_utc) = 0.0)
+);
View
47 sql/initialize/280_views.sql
@@ -0,0 +1,47 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+-- A view to SELECT user data. Explicitly don't include the primary_email_id
+-- or registration_ip. Users must always present their own email address and
+-- have their email address used to lookup their email_id.
+CREATE OR REPLACE VIEW
+aaa.user (user_id, active, registration_utc, max_concurrent_sessions,
+ default_ipv4_mask, default_ipv6_mask, timezone_id) AS
+ SELECT
+ u.id AS user_id, u.active, u.registration_utc, u.max_concurrent_sessions,
+ u.default_ipv4_mask, u.default_ipv6_mask, ui.timezone_id
+ FROM
+ shadow.aaa_user AS u LEFT OUTER JOIN aaa.user_info AS ui ON (u.id = ui.user_id);
+
+
+-- Enable this view in the email schema since access to email data should
+-- only be done via functions. Under no circumstances should a web process be
+-- able to do a 'SELECT *' on email or user information. email batch jobs
+-- have access to this data via the email schema (the web user does not have
+-- access to this schema, just like the email role does not have access to
+-- the rest of the schemas).
+CREATE OR REPLACE VIEW
+email.email (id, email, user_id) AS
+ SELECT
+ e.id, e.email, e.user_id
+ FROM
+ shadow.aaa_email AS e, shadow.aaa_email_confirmation_log AS ecl
+ WHERE
+ e.id = ecl.email_id AND ecl.confirmed = TRUE;
+
+
+-- Only show active users. Easy low hanging fruit to prevent accidents.
+CREATE OR REPLACE VIEW
+email."user" (id, primary_email_id) AS
+ SELECT id, primary_email_id
+ FROM shadow.aaa_user
+ WHERE active = TRUE;
+
+
+-- Note this view is created from a JOIN of two VIEWs. Again, in the email
+-- schema because no one web process should have access to see every email
+-- address in the system.
+CREATE OR REPLACE VIEW
+email.user_emails (user_id, email_id, email, user_primary_email_id) AS
+ SELECT u.id, e.id, e.email, u.primary_email_id
+ FROM email.email AS e, email."user" AS u
+ WHERE e.user_id = u.id;
View
430 sql/initialize/300_funcs.sql.in
@@ -0,0 +1,430 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+-- Create a convenience function to SHA256 hash some data. Putting this
+-- function in the public schema since it doesn't actually change the state
+-- of any tables.
+CREATE OR REPLACE FUNCTION
+public.sha256(BYTEA) RETURNS TEXT
+ AS $$SELECT encode(public.digest($1, 'sha256'), 'hex')$$
+ LANGUAGE SQL STRICT IMMUTABLE;
+
+CREATE OR REPLACE FUNCTION
+public.sha256(TEXT) RETURNS TEXT
+ AS $$SELECT encode(public.digest($1, 'sha256'), 'hex')$$
+ LANGUAGE SQL STRICT IMMUTABLE;
+
+
+-- Returns the user_id for a given email address.
+CREATE OR REPLACE FUNCTION
+aaa.get_email_id_by_email(email TEXT) RETURNS INT
+ AS $email_id_get$
+DECLARE
+ v_email_id shadow.aaa_email.id%TYPE;
+BEGIN
+ SELECT e.id INTO STRICT v_email_id
+ FROM shadow.aaa_email AS e
+ WHERE e.email = email;
+ RETURN v_email_id;
+END;$email_id_get$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Returns the user_id for a given email address.
+CREATE OR REPLACE FUNCTION
+aaa.get_user_id_by_email(email TEXT) RETURNS INT
+ AS $user_id_get$
+DECLARE
+ a_email ALIAS FOR $1;
+ v_user_id shadow.aaa_email.id%TYPE;
+BEGIN
+ SELECT e.id INTO STRICT v_user_id
+ FROM shadow.aaa_email AS e
+ WHERE e.email = a_email;
+ RETURN v_user_id;
+END;$user_id_get$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Returns the user_id for a given email address.
+CREATE OR REPLACE FUNCTION
+aaa.get_user_id_by_session_id(session_id TEXT) RETURNS INT
+ AS $user_id_by_session_id$
+DECLARE
+ a_session_id ALIAS FOR $1;
+ v_user_id shadow.aaa_email.id%TYPE;
+BEGIN
+ SELECT s.user_id INTO STRICT v_user_id
+ FROM shadow.aaa_session AS s
+ WHERE TRUE = TRUE
+ AND s.session_id = a_session_id
+ AND s.valid = TRUE
+ AND NOW() < s.end_utc;
+ RETURN v_user_id;
+END;$user_id_by_session_id$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Create a function to log a user in to the site. SECURITY DEFINER is set on
+-- the function so that the skeleton_www user can execute the FUNCTION
+-- without giving the skeleton_www user access to the shadow schema. Think of
+-- SECURITY DEFINER like the "setuid" bit for *NIX programs. The return value
+-- can be split in to three columns:
+--
+-- The return result (BOOL), the field the message applies to (TEXT), and
+-- optional relevant information (TEXT). Example:
+--
+-- SELECT result, "column", message FROM aaa.login(email :=
+-- 'user@example.com', password :=
+-- '\xbd\x18\xee\x85\x9f\x19Bl\x1e\x9dE\xdc\x10\xe2NH\x1b\x94\xe5n\x01C\x98\xe5AQ\x05\xb2\xa7,\x1co',
+-- ip_address := '11.22.33.44', session_id := 'user session id from flask',
+-- session_length := '60 minutes'::INTERVAL, secure := TRUE) AS (result BOOL, "column"
+-- TEXT, message TEXT);
+CREATE OR REPLACE FUNCTION
+aaa.login(email TEXT, password BYTEA, ip_address INET, session_id TEXT,
+ session_length INTERVAL, secure BOOL) RETURNS RECORD
+ AS $aaa_login$
+DECLARE
+ ret RECORD;
+ a_email ALIAS FOR email;
+ a_password ALIAS FOR password;
+ a_ip_address ALIAS FOR ip_address;
+ a_session_id ALIAS FOR session_id;
+ a_session_length ALIAS FOR session_length;
+ a_secure ALIAS FOR secure;
+ v_email_id shadow.aaa_email.id%TYPE;
+ v_email_confirmed shadow.aaa_email_confirmation_log.confirmed%TYPE;
+ v_ip_mask shadow.aaa_session.ip_mask%TYPE;
+ v_user_id shadow.aaa_user.id%TYPE;
+ v_user_active shadow.aaa_user.active%TYPE;
+ v_pw_from_user shadow.aaa_user.hashpass%TYPE;
+ v_pw_from_db shadow.aaa_user.hashpass%TYPE;
+ v_ipv4_mask shadow.aaa_user.default_ipv4_mask%TYPE;
+ v_ipv6_mask shadow.aaa_user.default_ipv6_mask%TYPE;
+ v_login_attempts INT NOT NULL DEFAULT 99;
+ MAX_LOGIN_ATTEMPTS INT NOT NULL DEFAULT 3;
+ LOGIN_INTERVAL INTERVAL NOT NULL DEFAULT '20 minutes'::INTERVAL;
+BEGIN
+ -- Check that the email is valid.
+ SELECT e.id, e.user_id INTO v_email_id, v_user_id FROM shadow.aaa_email AS e WHERE e.email = a_email;
+ IF NOT found THEN
+ -- Prevent someone from dictionary attacking the database with possibly
+ -- known email addresses. No point in logging a failed login attempt with
+ -- an unknown email address.
+ ret := (FALSE, 'vague'::TEXT, 'Unknown email address'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Check that the email address is confirmed. Since login is a crucial part
+ -- of any application, perform this check independently of the above email
+ -- check that way a user knows that they need to have their email address
+ -- confirmed. "Login failed" is less than descriptive and very unhelpful or
+ -- friendly.
+
+ SELECT ecl.confirmed INTO v_email_confirmed FROM shadow.aaa_email_confirmation_log AS ecl
+ WHERE ecl.email_id = v_email_id
+ ORDER BY ecl.timestamp_sent DESC LIMIT 1;
+ IF NOT found THEN
+ -- Raising an exception here because this means that something was
+ -- INSERTed in to the database improperly. Normally I'd just RETURN
+ -- FALSE, but given all registration activities are handled via a highly
+ -- controlled FUNCTIONs (e.g. aaa.register()), RAISEing an EXCEPTION is
+ -- more appropriate.
+ RAISE EXCEPTION 'No email confirmation information for ecl.email_id %', v_email_id;
+ END IF;
+
+ -- Hash the user's password using a constant hash. Password inputs in to
+ -- the database should be hashed by the web process before we attempt to
+ -- hash the user's password here. Reason: If the hash'ed password in the
+ -- database is just sha256('mypass') then the resulting hash is
+ -- bruteforce-able or can be resolved via static dictionaries. If, however,
+ -- the input to this function was already hashed on the webserver by:
+ -- sha256('%s%s' % (webserver_login_key, user_pass)), the stored contents
+ -- is not vulnerable to a host of attacks. This is not perfect, but it is
+ -- significantly better than having a password hashed a single
+ -- time. i.e. sha256('mypass') == bad. sha256(sha256('mypass'),
+ -- webserver_key) == infinitely better
+ v_pw_from_user := public.sha256(a_password || 'DATABASE_PASSWORD_HASH');
+ SELECT u.hashpass, u.active, u.default_ipv4_mask, u.default_ipv6_mask
+ INTO v_pw_from_db, v_user_active, v_ipv4_mask, v_ipv6_mask
+ FROM shadow.aaa_user AS u WHERE u.id = v_user_id;
+ IF NOT FOUND THEN
+ -- This check is required, despite having FOREIGN KEYS in place (some
+ -- FOREIGN KEY constraints aren't executed until COMMIT time, which means
+ -- aaa.login() could possibly return inconsistant information to the
+ -- user).
+ RAISE EXCEPTION 'No user record for user_id %', v_user_id;
+ END IF;
+
+ -- Access checks first: Is the user account active?
+ IF NOT v_user_active THEN
+ -- RETURN FALSE instead of being vague so that someone knows their
+ -- account has been deactivated (for whatever reason).
+ INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Account not active');
+ ret := (FALSE, 'email'::TEXT, 'Account not active'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Access check: Throttle the number of login attempts in the configured
+ -- interval *before* actually checking to see if the password matches.
+ SELECT COUNT(*) INTO v_login_attempts
+ FROM shadow.aaa_login_attempts AS ala
+ WHERE
+ TRUE = TRUE
+ AND ala.user_id = v_user_id
+ AND ala.login_utc >= (NOW() - LOGIN_INTERVAL)
+ AND ala.success = FALSE;
+ IF v_login_attempts > MAX_LOGIN_ATTEMPTS THEN
+ INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Too many login attempts');
+ ret := (FALSE, 'email'::TEXT, 'Too many failed login attempts. Account locked.'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Authentication checks second: Do passwords match?
+ IF v_pw_from_db != v_pw_from_user THEN
+ -- Passwords don't match? Oof. Suck on a vague message you hacker douche
+ -- bag you ... or innocent person who forgot or typo'ed their password.
+ INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, FALSE, 'Password incorrect');
+ ret := (FALSE, 'vague'::TEXT, 'Password incorrect'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Check to make sure they've confirmed their email address.
+ IF NOT v_email_confirmed THEN
+ -- Successfully log a valid login attempt for users who have entered the
+ -- right password, but haven't confirmed their email address yet. The
+ -- above throttling check relies on failed login attempts so frustrated
+ -- and impatient users who can't read will still get in once they calm
+ -- down and RTFS (Read The Fucking Screen) and check their email.
+ INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success, notes) VALUES (v_user_id, NOW(), a_ip_address, TRUE, 'Email Address Unconfirmed');
+ ret := (FALSE, 'email'::TEXT, 'Email Address Unconfirmed'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Before we grant the user access, let's setup the right IP mask for their
+ -- session.
+ IF family(a_ip_address) = 4 THEN
+ v_ip_mask := set_masklen(a_ip_address, v_ipv4_mask);
+ ELSIF family(a_ip_address) = 6 THEN
+ v_ip_mask := set_masklen(a_ip_address, v_ipv6_mask);
+ ELSE
+ RAISE EXCEPTION 'Unsupported IP family for IP %', a_ip_address;
+ END IF;
+
+ -- At this point, the user should be granted access and a session should be
+ -- created. The INSERT triggers on shadow.aaa_session will invalidate other
+ -- sessions automatically.
+ INSERT INTO shadow.aaa_session
+ (user_id, ip_mask, secure, valid, start_utc, end_utc, session_id)
+ VALUES
+ (v_user_id, v_ip_mask, a_secure, TRUE, NOW(), NOW() + a_session_length, a_session_id);
+
+ -- Record the success
+ INSERT INTO shadow.aaa_login_attempts (user_id, login_utc, ip_address, success) VALUES (v_user_id, NOW(), a_ip_address, TRUE);
+
+ ret := (TRUE, NULL::TEXT, NULL::TEXT);
+ RETURN ret;
+END;
+$aaa_login$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Create a function to log a user out of the site and kill their
+-- sessions. SECURITY DEFINER is set on the function so that the skeleton_www
+-- user can execute the FUNCTION without giving the skeleton_www user access
+-- to the shadow schema. Think of SECURITY DEFINER like the "setuid" bit for
+-- *NIX programs. The return value can be split in to three columns:
+--
+-- The return result (BOOL), the field the message applies to (TEXT), and
+-- optional relevant information (TEXT). Example:
+--
+-- SELECT result, "column", message FROM aaa.logout(session_id := 'user
+-- session id from flask') AS (result BOOL, "column" TEXT, message TEXT);
+CREATE OR REPLACE FUNCTION
+aaa.logout(session_id TEXT) RETURNS RECORD
+ AS $aaa_logout$
+DECLARE
+ ret RECORD;
+ a_session_id ALIAS FOR session_id;
+BEGIN
+ -- Close the session by settinv valid to false and letting the UPDATE
+ -- trigger do the rest.
+ IF aaa.expire_session(a_session_id) THEN
+ ret := (TRUE, NULL::TEXT, NULL::TEXT);
+ ELSE
+ ret := (FALSE, NULL::TEXT, NULL::TEXT);
+ END IF;
+
+ RETURN ret;
+END;
+$aaa_logout$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Create a function to register a new user with the site. SECURITY DEFINER
+-- is set on the function so that the skeleton_www user can execute the
+-- FUNCTION without giving the skeleton_www user access to the shadow
+-- schema. Think of SECURITY DEFINER like the "setuid" bit for *NIX
+-- programs. The return value can be split in to three columns:
+--
+-- The return result (BOOL), the field the message applies to (TEXT), and
+-- optional relevant information (TEXT). Example:
+--
+-- SELECT result, "column", message FROM aaa.register(email :=
+-- 'user@example.com', password := 'myfailpw', ip_address := '11.22.33.44')
+-- AS (result BOOL, "column" TEXT, message TEXT);
+CREATE OR REPLACE FUNCTION
+aaa.register(email TEXT, password BYTEA, ip_address INET) RETURNS RECORD
+ AS $$
+DECLARE
+ ret RECORD;
+ a_email ALIAS FOR email;
+ a_password ALIAS FOR password;
+ a_ip_address ALIAS FOR ip_address;
+ v_email_conf_code shadow.aaa_email_confirmation_log.confirmation_code%TYPE;
+ v_email_id shadow.aaa_email.id%TYPE;
+ v_emailconflog_id shadow.aaa_email_confirmation_log.id%TYPE;
+ v_user_id shadow.aaa_user.id%TYPE;
+ v_hashed_password shadow.aaa_user.hashpass%TYPE;
+BEGIN
+ -- Check that the email isn't already in use
+ SELECT e.id INTO v_email_id FROM shadow.aaa_email AS e WHERE e.email = a_email LIMIT 1;
+ IF found THEN
+ -- Should log that this IP address attempted a failed registration
+ ret := (FALSE, 'email'::TEXT, 'Email address already in use'::TEXT);
+ RETURN ret;
+ END IF;
+
+ -- Hash the user's password.
+ IF LENGTH(a_password) != 32 THEN
+ RAISE EXCEPTION 'Password was not previously hashed by the webserver';
+ END IF;
+ -- Hash the password a second time using a database hash
+ v_hashed_password := public.sha256(a_password || 'DATABASE_PASSWORD_HASH');
+
+ -- Create a new user and email. Create the aaa_email record because it is
+ -- smaller and we need to perform an UPDATE to resolve the circular FOREIGN
+ -- KEYS.
+ INSERT INTO shadow.aaa_email (email, user_id) VALUES (a_email, 0) RETURNING id INTO STRICT v_email_id;
+ INSERT INTO shadow.aaa_user (hashpass, primary_email_id, registration_utc, registration_ip, active) VALUES (v_hashed_password, v_email_id, NOW(), a_ip_address, TRUE) RETURNING id INTO STRICT v_user_id;
+
+ -- Use DATABASE_EMAIL_UUID to secure email confirmation codes. As long as
+ -- DATABASE_EMAIL_UUID is not leaked, every email address should have a
+ -- unique confirmation code that can be recreated based on the row id. This
+ -- could have been done via a TRIGGER but I wanted to show case a different
+ -- method of pre-fetching the value out of a SEQUENCE.
+ v_emailconflog_id := NEXTVAL('shadow.aaa_email_confirmation_log_id_seq'::regclass);
+ INSERT INTO shadow.aaa_email_confirmation_log (id, email_id, timestamp_sent, confirmation_code, ip_address) VALUES (v_emailconflog_id, v_email_id, NOW() AT TIME ZONE 'UTC', public.uuid_generate_v5('DATABASE_EMAIL_UUID'::UUID, v_emailconflog_id::TEXT), a_ip_address) RETURNING confirmation_code INTO STRICT v_email_conf_code;
+ UPDATE shadow.aaa_email SET user_id = v_user_id WHERE id = v_email_id;
+ ret := (TRUE, 'confirmation code'::TEXT, v_email_conf_code::TEXT);
+ RETURN ret;
+END;
+$$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION
+aaa.expire_session(session_id TEXT) RETURNS BOOL
+ AS $ses_expire$
+DECLARE
+ a_session_id ALIAS FOR session_id;
+BEGIN
+ UPDATE shadow.aaa_session AS s SET valid = FALSE WHERE s.session_id = a_session_id;
+ RETURN FOUND;
+END;
+$ses_expire$
+ LANGUAGE plpgsql
+ RETURNS NULL ON NULL INPUT
+ SECURITY DEFINER;
+
+
+-- Upon setting an email address to the confirmed status, update the
+-- timestamp_confirmed to NOW().
+CREATE OR REPLACE FUNCTION
+ shadow.aaa_email_confirmation_log_upd_trg() RETURNS TRIGGER
+ AS $fixup_confirmed$
+BEGIN
+ IF OLD.timestamp_confirmed IS NULL AND NEW.confirmed = TRUE THEN
+ NEW.timestamp_confirmed := NOW();
+ END IF;
+
+ -- Can't change when we confirmed an email address
+ IF OLD.confirmed = TRUE THEN
+ NEW.timestamp_confirmed := OLD.timestamp_confirmed;
+ END IF;
+
+ RETURN NEW;
+END;
+$fixup_confirmed$
+ LANGUAGE plpgsql;
+
+
+CREATE OR REPLACE FUNCTION
+shadow.aaa_session_expire_excess_trg() RETURNS TRIGGER
+ AS $ses_expire$
+DECLARE
+ ses RECORD;
+ v_max_sessions shadow.aaa_user.max_concurrent_sessions%TYPE;
+ v_current_sessions INT;
+BEGIN
+ -- Mark old sessions invalid if the user's number of concurrent sessions
+ -- has been reached.
+ SELECT u.max_concurrent_sessions INTO STRICT v_max_sessions
+ FROM shadow.aaa_user AS u
+ WHERE u.id = NEW.user_id;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'user_id not found %', NEW.user_id;
+ END IF;
+
+ SELECT COUNT(*) INTO STRICT v_current_sessions
+ FROM shadow.aaa_session AS s
+ WHERE s.user_id = NEW.user_id AND s.valid = TRUE;
+ IF v_current_sessions >= v_max_sessions THEN
+ -- Need to loop through every session older than the user's max allowed
+ -- number of sessions and expire them.
+ FOR ses IN
+ SELECT * FROM shadow.aaa_session AS s
+ WHERE s.user_id = NEW.user_id AND s.valid = TRUE
+ ORDER BY s.end_utc DESC OFFSET v_max_sessions - 1
+ LOOP
+ PERFORM aaa.expire_session(ses.session_id);
+ END LOOP;
+ END IF;
+
+ -- Ok, can continue now that any excess sessions have been expired
+ RETURN NEW;
+END;
+$ses_expire$ LANGUAGE plpgsql;
+
+
+
+-- Upon expiring a session, make sure the session's end isn't at some point
+-- in the future. By the same token, setting valid to FALSE automatically
+-- sets end_utc to NOW().
+CREATE OR REPLACE FUNCTION
+shadow.aaa_session_fixup_end_utc_trg() RETURNS TRIGGER
+ AS $ses_fixup_end_utc$
+BEGIN
+ IF NEW.end_utc > OLD.end_utc THEN
+ RAISE EXCEPTION 'Unable to push end_utc in to the future, it can only go backwards';
+ END IF;
+
+ IF OLD.valid = TRUE AND NEW.valid = FALSE THEN
+ NEW.end_utc := NOW();
+ END IF;
+
+ RETURN NEW;
+END;
+$ses_fixup_end_utc$
+ LANGUAGE plpgsql;
View
9 sql/initialize/400_triggers.sql
@@ -0,0 +1,9 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+CREATE TRIGGER aaa_email_confirmation_log_upd_trg BEFORE UPDATE ON shadow.aaa_email_confirmation_log
+ FOR EACH ROW EXECUTE PROCEDURE shadow.aaa_email_confirmation_log_upd_trg();
+
+CREATE TRIGGER aaa_session_expire_excess_trg BEFORE INSERT ON shadow.aaa_session
+ FOR EACH ROW EXECUTE PROCEDURE shadow.aaa_session_expire_excess_trg();
+
+CREATE TRIGGER aaa_session_fixup_end_utc_trg BEFORE UPDATE ON shadow.aaa_session
+ FOR EACH ROW EXECUTE PROCEDURE shadow.aaa_session_fixup_end_utc_trg();
View
9 sql/initialize/500_fixup_func_owner.sql
@@ -0,0 +1,9 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+-- Change the owner of these functions to skeleton_shadow because SECURITY
+-- DEFINER is a set attribute for these functions. This must be run as the
+-- pgsql user. This can't be run as skeleton_root because skeleton_root isn't
+-- a member of the role 'skeleton_shadow' (and shouldn't be!!!).
+ALTER FUNCTION aaa.expire_session(TEXT) OWNER TO skeleton_shadow;
+ALTER FUNCTION aaa.login(TEXT, BYTEA, INET, TEXT, INTERVAL, BOOL) OWNER TO skeleton_shadow;
+ALTER FUNCTION aaa.register(TEXT, BYTEA, INET) OWNER TO skeleton_shadow;
View
38 sql/initialize/600_create_indexes.sql
@@ -0,0 +1,38 @@
+-- env PGDATABASES=skeleton PGUSER=skeleton_root
+
+CREATE UNIQUE INDEX user_id_udx
+ ON aaa.user(id);
+
+CREATE UNIQUE INDEX timezone_name_lower_udx
+ ON public.timezone(LOWER(name));
+
+CREATE UNIQUE INDEX email_email_lower_udx
+ ON shadow.aaa_email(LOWER(email));
+
+CREATE INDEX aaa_email_confirmation_log_email_idx
+ ON shadow.aaa_email_confirmation_log(email_id);
+CREATE INDEX aaa_email_confirmation_log_confirmation_code_idx
+ ON shadow.aaa_email_confirmation_log(confirmation_code);
+
+CREATE INDEX aaa_login_attempts_user_login_idx
+ ON shadow.aaa_login_attempts(user_id, login_utc);
+
+-- Use a UNIQUE INDEX for valid sessions and a non-UNIQUE INDEX for expired
+-- sessions.
+CREATE UNIQUE INDEX aaa_session_id_valid_true_udx
+ ON shadow.aaa_session(session_id) WHERE valid = TRUE;
+CREATE INDEX aaa_session_id_valid_false_idx
+ ON shadow.aaa_session(session_id) WHERE valid = FALSE;
+
+-- A periodic cronjob should mark session invalid based on 'end_utc < NOW()'
+CREATE INDEX aaa_session_end_utc_idx
+ ON shadow.aaa_session(end_utc) WHERE valid = TRUE;
+
+-- Use an index scan for a particular user_id to make sure that the number of
+-- concurrent session for a user is less than the user's configured max
+-- number of concurrent sessions.
+CREATE INDEX aaa_session_user_id_idx
+ ON shadow.aaa_session(user_id) WHERE valid = TRUE;
+
+CREATE UNIQUE INDEX aaa_user_primary_email_udx
+ ON shadow.aaa_user(primary_email_id);
View
22 sql/initialize/700_foreign_keys.sql
@@ -0,0 +1,22 @@
+-- env PGDATABASE=skeleton PGUSER=skeleton_root
+
+ALTER TABLE aaa.user ADD CONSTRAINT id_fk
+ FOREIGN KEY(id) REFERENCES shadow.aaa_user(id);
+ALTER TABLE aaa.user ADD CONSTRAINT timezone_id_fk
+ FOREIGN KEY(timezone_id) REFERENCES public.timezone(id);
+
+ALTER TABLE shadow.aaa_email ADD CONSTRAINT email_user_fk
+ FOREIGN KEY(user_id) REFERENCES shadow.aaa_user(id) INITIALLY DEFERRED;
+
+ALTER TABLE shadow.aaa_email_confirmation_log ADD CONSTRAINT email_id_fk
+ FOREIGN KEY(email_id) REFERENCES shadow.aaa_email(id);
+
+ALTER TABLE shadow.aaa_login_attempts ADD CONSTRAINT user_id_fk
+ FOREIGN KEY(user_id) REFERENCES shadow.aaa_user(id);
+