First code commit

This commit is contained in:
Matt Wright
2012-03-08 16:03:53 -05:00
parent 6054e2fde4
commit f5db44d0a1
16 changed files with 811 additions and 0 deletions
+1
View File
@@ -1,3 +1,4 @@
*.pyc
.project
.pydevproject
.settings
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (C) 2012 by Matt Wright
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1
View File
@@ -0,0 +1 @@
include tests/*.py
+3
View File
@@ -0,0 +1,3 @@
Flask-Security
Simple security for Flask applications.
View File
+85
View File
@@ -0,0 +1,85 @@
# a little trick so you can run:
# $ python example/app.py
# from the root of the security project
import sys, os
sys.path.pop(0)
sys.path.insert(0, os.getcwd())
from flask import Flask, render_template
from flask.ext.sqlalchemy import SQLAlchemy
from flask.ext.security import (Security, LoginForm, user_datastore,
login_required, roles_required, roles_accepted)
from flask.ext.security.datastore.sqlalchemy import SQLAlchemyUserDatastore
def create_app(auth_config=None):
app = Flask(__name__)
app.debug = True
app.config['SECRET_KEY'] = 'secret'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
app.config['AUTH'] = auth_config or {}
db = SQLAlchemy(app)
Security(app, SQLAlchemyUserDatastore(db))
@app.route('/')
def index():
return render_template('index.html', content='Home Page')
@app.route('/login')
def login():
return render_template('login.html', content='Login Page', form=LoginForm())
@app.route('/custom_login')
def custom_login():
return render_template('login.html', content='Custom Login Page', form=LoginForm())
@app.route('/profile')
@login_required
def profile():
return render_template('index.html', content='Profile Page')
@app.route('/post_login')
@login_required
def post_login():
return render_template('index.html', content='Post Login')
@app.route('/post_logout')
def post_logout():
return render_template('index.html', content='Post Logout')
@app.route('/admin')
@roles_required('admin')
def admin():
return render_template('index.html', content='Admin Page')
@app.route('/admin_or_editor')
@roles_accepted('admin', 'editor')
def admin_or_editor():
return render_template('index.html', content='Admin or Editor Page')
@app.before_first_request
def before_first_request():
db.create_all()
user_datastore.create_user(username='matt', email='matt@lp.com',
password='password',
roles=['admin'])
user_datastore.create_user(username='joe', email='joe@lp.com',
password='password',
roles=['editor'])
user_datastore.create_user(username='jill', email='jill@lp.com',
password='password',
roles=['author'])
user_datastore.create_user(username='tiya', email='tiya@lp.com',
password='password', active=False)
return app
if __name__ == '__main__':
app = create_app()
app.run()
+9
View File
@@ -0,0 +1,9 @@
{%- with messages = get_flashed_messages(with_categories=true) -%}
{% if messages %}
<ul class=flashes>
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{%- endwith %}
+20
View File
@@ -0,0 +1,20 @@
{%- if current_user.is_authenticated() -%}
<p>Hello {{ current_user.email }}</p>
{%- endif %}
<ul>
<li><a href="{{ url_for('index') }}">Index</a></li>
<li><a href="{{ url_for('profile') }}">Profile</a></li>
{% if current_user.has_role('admin') -%}
<li><a href="{{ url_for('admin') }}">Admin</a></li>
{% endif -%}
{% if current_user.has_role('admin') or current_user.has_role('editor') -%}
<li><a href="{{ url_for('admin_or_editor') }}">Admin or Editor</a></li>
{% endif -%}
<li>
{%- if current_user.is_authenticated() -%}
<a href="{{ url_for('auth.logout') }}">Log out</a>
{%- else -%}
<a href="{{ url_for('login') }}">Log in</a>
{%- endif -%}
</li>
</ul>
+3
View File
@@ -0,0 +1,3 @@
{% include "_messages.html" %}
{% include "_nav.html" %}
<p>{{ content }}</p>
+10
View File
@@ -0,0 +1,10 @@
{% include "_messages.html" %}
{% include "_nav.html" %}
<form action="{{ url_for('auth.authenticate') }}" method="POST" name="login_form">
{{ form.hidden_tag() }}
{{ form.username.label }} {{ form.username }}<br/>
{{ form.password.label }} {{ form.password }}<br/>
{{ form.remember.label }} {{ form.remember }}<br/>
{{ form.submit }}
</form>
<p>{{ content }}</p>
+344
View File
@@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
"""
flask.ext.security
~~~~~~~~~~~~~~
Flask-Security is a Flask extension module that aims to add quick and
simple security via Flask-Login and Flask-Principal.
:copyright: (c) 2012 by Matt Wright.
:license: MIT, see LICENSE for more details.
"""
from __future__ import absolute_import
import sys
from datetime import datetime
from flask import (current_app, Blueprint, flash, redirect, request,
session, _request_ctx_stack, url_for, abort, g)
from flask.ext.login import (AnonymousUser as AnonymousUserBase, UserMixin,
LoginManager, login_required, login_user, logout_user,
current_user, user_logged_in, user_logged_out)
from flask.ext.principal import (Identity, Principal, RoleNeed, UserNeed,
Permission, AnonymousIdentity, identity_changed, identity_loaded)
from flask.ext.wtf import (Form, TextField, PasswordField, SubmitField,
HiddenField, Required, ValidationError, BooleanField, Email)
from functools import wraps
from passlib.context import CryptContext
from werkzeug.utils import import_string
from werkzeug.local import LocalProxy
User, Role = None, None
AUTH_CONFIG_KEY = 'AUTH'
URL_PREFIX_KEY = 'url_prefix'
USER_MODEL_ENGINE_KEY = 'user_model_engine'
AUTH_PROVIDER_KEY = 'auth_provider'
PASSWORD_HASH_KEY = 'password_hash'
USER_DATASTORE_NAME_KEY = 'user_datastore_name'
LOGIN_FORM_KEY = 'login_form'
AUTH_URL_KEY = 'auth_url'
LOGOUT_URL_KEY = 'logout_url'
LOGIN_VIEW_KEY = 'login_view'
POST_LOGIN_VIEW_KEY = 'post_login_view'
POST_LOGOUT_VIEW_KEY = 'post_logout_view'
default_config = {
URL_PREFIX_KEY: None,
PASSWORD_HASH_KEY: 'plaintext',
USER_DATASTORE_NAME_KEY: 'user_datastore',
AUTH_PROVIDER_KEY: 'flask.ext.security.AuthenticationProvider',
LOGIN_FORM_KEY: 'flask.ext.security.LoginForm',
AUTH_URL_KEY: '/auth',
LOGOUT_URL_KEY: '/logout',
LOGIN_VIEW_KEY: '/login',
POST_LOGIN_VIEW_KEY: '/',
POST_LOGOUT_VIEW_KEY: '/',
}
class BadCredentialsError(Exception):
"""Raised when an authentication attempt fails due to an error with the
provided credentials.
"""
class AuthenticationError(Exception):
"""Raised when an authentication attempt fails due to invalid configuration
or an unknown reason.
"""
class UserNotFoundError(Exception):
"""Raised by a user datastore when there is an attempt to find a user by
their identifier, often username or email, and the user is not found.
"""
class UserIdNotFoundError(Exception):
"""Raised by a user datastore when there is an attempt to find a user by
ID and the user is not found.
"""
class UserDatastoreError(Exception):
"""Raise when a user datastore experiences an unexpected error
"""
class UserCreationError(Exception):
"""Raise when an error occurs during user create
"""
#: App logger for convenience
logger = LocalProxy(lambda: current_app.logger)
#: Authentication provider
auth_provider = LocalProxy(lambda: current_app.auth_provider)
#: Login manager
login_manager = LocalProxy(lambda: current_app.login_manager)
#: Password encyption context
pwd_context = LocalProxy(lambda: current_app.pwd_context)
# User service
user_datastore = LocalProxy(lambda: getattr(current_app,
current_app.config[AUTH_CONFIG_KEY][USER_DATASTORE_NAME_KEY]))
def roles_required(*args):
roles = args
perm = Permission(*[RoleNeed(role) for role in roles])
def wrapper(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
if current_user.is_authenticated() and perm.can():
return fn(*args, **kwargs)
logger.debug('Identity does not provide all of the '
'following roles: %s' % [r for r in roles])
c = current_app.config[AUTH_CONFIG_KEY]
return redirect(c[LOGIN_VIEW_KEY])
return decorated_view
return wrapper
def roles_accepted(*args):
roles = args
perms = [Permission(RoleNeed(role)) for role in roles]
def wrapper(fn):
@wraps(fn)
def decorated_view(*args, **kwargs):
for perm in perms:
if current_user.is_authenticated() and perm.can():
return fn(*args, **kwargs)
logger.debug('Identity does not provide at least one of '
'the following roles: %s' % [r for r in roles])
c = current_app.config[AUTH_CONFIG_KEY]
return redirect(c[LOGIN_VIEW_KEY])
return decorated_view
return wrapper
class AnonymousUser(AnonymousUserBase):
def __init__(self):
super(AnonymousUser, self).__init__()
self.roles = [] # TODO: Make this immutable
def has_role(self, *args):
return False
class Security(object):
def __init__(self, app=None, datastore=None):
self.init_app(app, datastore)
def init_app(self, app, datastore):
"""Initialize the application
:param app: An instance of an application
:param datastore: An instance of a datastore for your users
"""
if app is None or datastore is None: return
blueprint = Blueprint(AUTH_CONFIG_KEY.lower(), __name__)
config = default_config.copy()
config.update(app.config.get(AUTH_CONFIG_KEY, {}))
app.config[AUTH_CONFIG_KEY] = config
# setup the login manager extension
login_manager = LoginManager()
login_manager.anonymous_user = AnonymousUser
login_manager.login_view = config[LOGIN_VIEW_KEY]
login_manager.setup_app(app)
app.login_manager = login_manager
Provider = get_class_from_config(AUTH_PROVIDER_KEY, config)
Form = get_class_from_config(LOGIN_FORM_KEY, config)
pw_hash = config[PASSWORD_HASH_KEY]
app.pwd_context = CryptContext(schemes=[pw_hash], default=pw_hash)
app.auth_provider = Provider(Form)
app.principal = Principal(app)
setattr(app, config[USER_DATASTORE_NAME_KEY], datastore)
@identity_loaded.connect_via(app)
def on_identity_loaded(sender, identity):
if hasattr(current_user, 'id'):
identity.provides.add(UserNeed(current_user.id))
for role in current_user.roles:
identity.provides.add(RoleNeed(role.name))
identity.user = current_user
DEBUG_LOGIN = 'User %s logged in. Redirecting to: %s'
ERROR_LOGIN = 'Unsuccessful authentication attempt: %s. ' \
'Redirecting to: %s'
DEBUG_LOGOUT = 'User logged out, redirecting to: %s'
FLASH_INACTIVE = 'Inactive user'
@login_manager.user_loader
def load_user(user_id):
try:
return datastore.with_id(user_id)
except Exception, e:
logger.error('Error getting user: %s' % e)
return None
auth_url = config[AUTH_URL_KEY]
@blueprint.route(auth_url, methods=['POST'], endpoint='authenticate')
def authenticate():
try:
form = Form()
user = auth_provider.authenticate(form)
if login_user(user, remember=form.remember.data):
redirect_url = get_post_login_redirect()
identity_changed.send(app, identity=Identity(user.id))
logger.debug(DEBUG_LOGIN % (user, redirect_url))
return redirect(redirect_url)
raise BadCredentialsError(FLASH_INACTIVE)
except BadCredentialsError, e:
message = '%s' % e
flash(message, 'error')
redirect_url = request.referrer or login_manager.login_view
logger.error(ERROR_LOGIN % (message, redirect_url))
return redirect(redirect_url)
@blueprint.route(config[LOGOUT_URL_KEY], endpoint='logout')
@login_required
def logout():
for value in ('identity.name', 'identity.auth_type'):
session.pop(value, None)
identity_changed.send(app, identity=AnonymousIdentity())
logout_user()
redirect_url = find_redirect(POST_LOGOUT_VIEW_KEY, config)
logger.debug(DEBUG_LOGOUT % redirect_url)
return redirect(redirect_url)
app.register_blueprint(blueprint, url_prefix=config[URL_PREFIX_KEY])
"""
Here are some forms, useing the WTForm extension for Flask because, well, its
nice to have a form library when building web apps
"""
class LoginForm(Form):
username = TextField("Username or Email",
validators=[Required(message="Username not provided")])
password = PasswordField("Password",
validators=[Required(message="Password not provided")])
remember = BooleanField("Remember Me")
next = HiddenField()
submit = SubmitField("Login")
def __init__(self, *args, **kwargs):
super(LoginForm, self).__init__(*args, **kwargs)
self.next.data = request.args.get('next', None)
"""
Here we have the default authentication provider. It requires a user service in
order to retrieve users and handle authentication.
"""
class AuthenticationProvider(object):
def __init__(self, login_form_class=None):
self.login_form_class = login_form_class or LoginForm
def login_form(self, formdata=None):
return self.login_form_class(formdata)
def authenticate(self, form):
# first some basic validation
if not form.validate():
if form.username.errors:
raise BadCredentialsError(form.username.errors[0])
if form.password.errors:
raise BadCredentialsError(form.password.errors[0])
return self.do_authenticate(form.username.data, form.password.data)
def do_authenticate(self, user_identifier, password):
try:
user = user_datastore.find(user_identifier)
except AttributeError, e:
self.auth_error("Could not find user service: %s" % e)
except UserNotFoundError, e:
raise BadCredentialsError("Specified user does not exist")
except AttributeError, e:
self.auth_error('Invalid user service: %s' % e)
except Exception, e:
self.auth_error('Unexpected authentication error: %s' % e)
# compare passwords
if pwd_context.verify(password, user.password):
return user
# bad match
raise BadCredentialsError("Password does not match")
def auth_error(self, msg):
logger.error(msg)
raise AuthenticationError(msg)
def get_class_by_name(clazz):
parts = clazz.split('.')
module = ".".join(parts[:-1])
m = __import__( module )
for comp in parts[1:]:
m = getattr(m, comp)
return m
def get_class_from_config(key, config):
try:
return get_class_by_name(config[key])
except Exception, e:
raise AttributeError(
"Could not get class '%s' for Auth setting '%s' >> %s" %
(config[key], key, e))
def get_url(value):
# try building the url or assume its a url already
try: return url_for(value)
except: return value
def get_post_login_redirect():
return (get_url(request.args.get('next')) or
get_url(request.form.get('next')) or
find_redirect(POST_LOGIN_VIEW_KEY,
current_app.config[AUTH_CONFIG_KEY]))
def find_redirect(key, config):
# Look in the session first, and if not there go to the config, and
# if its not there either just go to the root url
result = (get_url(session.get(key.lower(), None)) or
get_url(config[key.lower()] or None) or '/')
# Try and delete the session value if it was used
try: del session[key.lower()]
except: pass
return result
+38
View File
@@ -0,0 +1,38 @@
from datetime import datetime
from flask.ext.security import UserCreationError, pwd_context
class UserDatastore(object):
"""A sort of abstract user service"""
def with_id(self, id):
raise NotImplementedError(
"User datastore does not implement with_id method")
def find(self, user_identifier):
raise NotImplementedError(
"User datastore does not implement find_user method")
def create_role(self, **kwargs):
raise NotImplementedError(
"User datastore does not implement create_role method")
def create_user(self, **kwargs):
raise NotImplementedError(
"User datastore does not implement create_user method")
def _prepare_create_args(self, kwargs):
if not kwargs.has_key('username') and not kwargs.has_key('email'):
raise UserCreationError('Error creating user: username and/or '
'email arguments not provided')
if not kwargs.has_key('password'):
raise UserCreationError('Error creating user: password '
'argument not provided')
now = datetime.utcnow()
kwargs['created_at'], kwargs['modified_at'] = now, now
pw = kwargs['password']
if not pwd_context.identify(pw):
kwargs['password'] = pwd_context.encrypt(pw)
return kwargs
+1
View File
@@ -0,0 +1 @@
# TODO: Add mongoengine datastore
+113
View File
@@ -0,0 +1,113 @@
from types import StringType
from flask.ext import security
from flask.ext.login import UserMixin
from flask.ext.security.datastore import UserDatastore
class SQLAlchemyUserDatastore(UserDatastore):
"""SQLAlchemy user service"""
def __init__(self, db):
self.db = db
roles_users = db.Table('roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('role.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('user.id')))
class Role(db.Model):
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))
def __init__(self, name=None, description=None):
self.name = name
self.description = description
def __eq__(self, other):
return self.name == other.name
def __ne__(self, other):
return self.name != other.name
def __str__(self):
return '<Role name=%s, description=%s>' % (self.name, self.description)
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(255), unique=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(120))
active = db.Column(db.Boolean())
created_at = db.Column(db.DateTime())
modified_at = db.Column(db.DateTime())
roles= db.relationship('Role', secondary=roles_users,
backref=db.backref('users', lazy='dynamic'))
def __init__(self, username=None, email=None, password=None,
active=True, roles=None,
created_at=None, modified_at=None):
self.username = username
self.email = email
self.password = password
self.active = active
self.roles = roles or []
self.created_at = created_at
self.modified_at = modified_at
def is_active(self):
return self.active
def has_role(self, role):
if type(role) is StringType:
role = security.Role(name=role)
return role in self.roles
def __str__(self):
return '<User id=%(id)s, email=%(email)s>' % self.__dict__
security.User = User
security.Role = Role
db.create_all()
def with_id(self, id):
user = security.User.query.get(id)
if user: return user
raise security.UserIdNotFoundError()
def find(self, user_identifier):
user = security.User.query.filter_by(username=user_identifier).first()
if user: return user
user = security.User.query.filter_by(email=user_identifier).first()
if user: return user
raise security.UserNotFoundError()
def create_role(self, commit=True, **kwargs):
if not kwargs.has_key('name'):
raise TypeError("create_role() did not receive "
"keyword argument 'name'")
name = kwargs.get('name')
description = kwargs.get('description', None)
if security.Role.query.filter_by(name=name).first() is None:
role = security.Role(name=name, description=description)
self.db.session.add(role)
if commit: self.db.session.commit()
return role
def create_user(self, commit=True, **kwargs):
kwargs = self._prepare_create_args(kwargs)
roles = kwargs.get('roles', [])
user_roles = []
for role in roles:
user_roles.append(self.create_role(name=role, commit=False))
kwargs['roles'] = user_roles
user = security.User(**kwargs)
self.db.session.add(user)
if commit: self.db.session.commit()
return user
+51
View File
@@ -0,0 +1,51 @@
"""
Flask-Security
--------------
Simple security for Flask apps
Links
`````
* `development version
<https://github.com/mattupstate/flask-security/raw/master#egg=Flask-Security-dev>`_
"""
from setuptools import setup
setup(
name='Flask-Security',
version='1.0.0',
url='https://github.com/mattupstate/flask-security',
license='MIT',
author='Matthew Wright',
author_email='matt@nobien.net',
description='Simple security for Flask apps',
long_description=__doc__,
packages=['flask_security'],
zip_safe=False,
include_package_data=True,
platforms='any',
install_requires=[
'Flask',
'Flask-Login',
'Flask-Principal',
'Flask-WTF',
'passlib'
],
test_suite='nose.collector',
tests_require=[
'nose',
'py-bcrypt'
],
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Software Development :: Libraries :: Python Modules'
]
)
+123
View File
@@ -0,0 +1,123 @@
import unittest
from example import app
class SecurityTests(unittest.TestCase):
AUTH_CONFIG = None
def setUp(self):
super(SecurityTests, self).setUp()
self.app = app.create_app(self.AUTH_CONFIG or None)
self.app.debug = False
self.app.config['TESTING'] = True
self.client = self.app.test_client()
def _get(self, route, content_type=None, follow_redirects=None):
return self.client.get(route, follow_redirects=follow_redirects,
content_type=content_type or 'text/html')
def _post(self, route, data=None, content_type=None, follow_redirects=True):
return self.client.post(route, data=data,
follow_redirects=follow_redirects,
content_type=content_type or 'text/html')
def authenticate(self, username, password, endpoint=None):
data = dict(username=username, password=password)
return self._post(endpoint or '/auth', data=data,
content_type='application/x-www-form-urlencoded')
def logout(self, endpoint=None):
return self._get(endpoint or '/logout', follow_redirects=True)
class DefaultSecurityTests(SecurityTests):
def test_login_view(self):
r = self._get('/login')
assert 'Login Page' in r.data
def test_authenticate(self):
r = self.authenticate("matt", "password")
assert 'Home Page' in r.data
def test_unprovided_username(self):
r = self.authenticate("", "password")
assert "Username not provided" in r.data
def test_unprovided_password(self):
r = self.authenticate("matt", "")
assert "Password not provided" in r.data
def test_invalid_user(self):
r = self.authenticate("bogus", "password")
assert "Specified user does not exist" in r.data
def test_bad_password(self):
r = self.authenticate("matt", "bogus")
assert "Password does not match" in r.data
def test_inactive_user(self):
r = self.authenticate("tiya", "password")
assert "Inactive user" in r.data
def test_logout(self):
self.authenticate("matt", "password")
r = self.logout()
assert 'Home Page' in r.data
def test_unauthorized_access(self):
r = self._get('/profile', follow_redirects=True)
assert 'Please log in to access this page' in r.data
def test_authorized_access(self):
self.authenticate("matt", "password")
r = self._get("/profile")
assert 'profile' in r.data
def test_valid_admin_role(self):
self.authenticate("matt", "password")
r = self._get("/admin")
assert 'Admin Page' in r.data
def test_invalid_admin_role(self):
self.authenticate("joe", "password")
r = self._get("/admin", follow_redirects=True)
assert 'Login Page' in r.data
def test_roles_accepted(self):
for user in ("matt", "joe"):
self.authenticate(user, "password")
r = self._get("/admin_or_editor")
self.assertIn('Admin or Editor Page', r.data)
self.logout()
self.authenticate("jill", "password")
r = self._get("/admin_or_editor", follow_redirects=True)
self.assertIn('Login Page', r.data)
class ConfiguredSecurityTests(SecurityTests):
AUTH_CONFIG = {
'password_hash': 'bcrypt',
'user_datastore_name': 'custom_datastore_name',
'auth_url': '/custom_auth',
'logout_url': '/custom_logout',
'login_view': '/custom_login',
'post_login_view': '/post_login',
'post_logout_view': '/post_logout'
}
def test_login_view(self):
r = self._get('/custom_login')
assert "Custom Login Page" in r.data
def test_authenticate(self):
r = self.authenticate("matt", "password", endpoint="/custom_auth")
assert 'Post Login' in r.data
def test_logout(self):
self.authenticate("matt", "password", endpoint="/custom_auth")
r = self.logout(endpoint="/custom_logout")
assert 'Post Logout' in r.data