Our application has URLs that allow people to add/edit/delete content via a web browser. Time to add security to the application. Let’s protect our add/edit/delete views to require a login (username of editor and password of editor.) We will allow the other views to continue working without a password.
Copy the results from the previous step:
(env33)$ cd ..; cp -r step09 step10; cd step10
(env33)$ python3.3 setup.py develop
Update tutorial/models.py to include statements from Pyramid’s declarative security features:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | from pyramid.security import Allow, Everyone
pages = {
'100': dict(uid='100', title='Page 100', body='<em>100</em>'),
'101': dict(uid='101', title='Page 101', body='<em>101</em>'),
'102': dict(uid='102', title='Page 102', body='<em>102</em>')
}
class Root(object):
__acl__ = [(Allow, Everyone, 'view'),
(Allow, 'group:editors', 'edit')]
def __init__(self, request):
pass
|
Our __init__.py needs this root factory, our authentication/authorization policies, and routes to login/logout:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.config import Configurator
from .security import groupfinder
def main(global_config, **settings):
config = Configurator(settings=settings,
root_factory='tutorial.models.Root')
# Security policies
authn_policy = AuthTktAuthenticationPolicy(
'sosecret', callback=groupfinder, hashalg='sha512')
authz_policy = ACLAuthorizationPolicy()
config = Configurator(settings=settings,
root_factory='tutorial.models.Root')
config.set_authentication_policy(authn_policy)
config.set_authorization_policy(authz_policy)
config.add_route('wiki_view', '/')
config.add_route('login', '/login')
config.add_route('logout', '/logout')
config.add_route('wikipage_add', '/add')
config.add_route('wikipage_view', '/{uid}')
config.add_route('wikipage_edit', '/{uid}/edit')
config.add_route('wikipage_delete', '/{uid}/delete')
config.add_static_view(name='static', path='tutorial:static')
config.add_static_view('deform_static', 'deform:static/')
config.scan()
return config.make_wsgi_app()
|
Create a tutorial/security.py module that can find our user information by providing an authentication policy callback:
1 2 3 4 5 6 7 8 | USERS = {'editor': 'editor',
'viewer': 'viewer'}
GROUPS = {'editor': ['group:editors']}
def groupfinder(userid, request):
if userid in USERS:
return GROUPS.get(userid, [])
|
Our tutorial/views.py needs some changes: permissions on the add/edit/delete views, new views for login/logout, and a way to track whether a user is logged_in:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | import colander
import deform.widget
from pyramid.decorator import reify
from pyramid.httpexceptions import HTTPFound
from pyramid.renderers import get_renderer
from pyramid.security import remember, forget, authenticated_userid
from pyramid.view import view_config, forbidden_view_config
from .models import pages
from .security import USERS
class WikiPage(colander.MappingSchema):
title = colander.SchemaNode(colander.String())
body = colander.SchemaNode(
colander.String(),
widget=deform.widget.RichTextWidget()
)
class WikiViews(object):
def __init__(self, request):
self.request = request
renderer = get_renderer("templates/layout.pt")
self.layout = renderer.implementation().macros['layout']
self.logged_in = authenticated_userid(request)
@reify
def wiki_form(self):
schema = WikiPage()
return deform.Form(schema, buttons=('submit',))
@reify
def reqts(self):
return self.wiki_form.get_widget_resources()
@view_config(route_name='wiki_view',
renderer='templates/wiki_view.pt')
def wiki_view(self):
return dict(title='Welcome to the Wiki',
pages=pages.values())
@view_config(route_name='wikipage_add',
permission='edit',
renderer='templates/wikipage_addedit.pt')
def wikipage_add(self):
if 'submit' in self.request.params:
controls = self.request.POST.items()
try:
appstruct = self.wiki_form.validate(controls)
except deform.ValidationFailure as e:
# Form is NOT valid
return dict(title='Add Wiki Page', form=e.render())
# Form is valid, make a new identifier and add to list
last_uid = int(sorted(pages.keys())[-1])
new_uid = str(last_uid + 1)
pages[new_uid] = dict(
uid=new_uid, title=appstruct['title'],
body=appstruct['body']
)
# Now visit new page
url = self.request.route_url('wikipage_view', uid=new_uid)
return HTTPFound(url)
return dict(title='Add Wiki Page', form=self.wiki_form.render())
@view_config(route_name='wikipage_view',
renderer='templates/wikipage_view.pt')
def wikipage_view(self):
uid = self.request.matchdict['uid']
page = pages[uid]
return dict(page=page, title=page['title'])
@view_config(route_name='wikipage_edit',
permission='edit',
renderer='templates/wikipage_addedit.pt')
def wikipage_edit(self):
uid = self.request.matchdict['uid']
page = pages[uid]
title = 'Edit ' + page['title']
if 'submit' in self.request.params:
controls = self.request.POST.items()
try:
appstruct = self.wiki_form.validate(controls)
except deform.ValidationFailure as e:
return dict(title=title, page=page, form=e.render())
# Change the content and redirect to the view
page['title'] = appstruct['title']
page['body'] = appstruct['body']
url = self.request.route_url('wikipage_view',
uid=page['uid'])
return HTTPFound(url)
form = self.wiki_form.render(page)
return dict(page=page, title=title, form=form)
@view_config(route_name='wikipage_delete', permission='edit')
def wikipage_delete(self):
uid = self.request.matchdict['uid']
del pages[uid]
url = self.request.route_url('wiki_view')
return HTTPFound(url)
@view_config(route_name='login', renderer='templates/login.pt')
@forbidden_view_config(renderer='templates/login.pt')
def login(self):
request = self.request
login_url = request.route_url('login')
referrer = request.url
if referrer == login_url:
referrer = '/' # never use login form itself as came_from
came_from = request.params.get('came_from', referrer)
message = ''
login = ''
password = ''
if 'form.submitted' in request.params:
login = request.params['login']
password = request.params['password']
if USERS.get(login) == password:
headers = remember(request, login)
return HTTPFound(location=came_from,
headers=headers)
message = 'Failed login'
return dict(
title='Login',
message=message,
url=request.application_url + '/login',
came_from=came_from,
login=login,
password=password,
)
@view_config(route_name='logout')
def logout(self):
request = self.request
headers = forget(request)
url = request.route_url('wiki_view')
return HTTPFound(location=url,
headers=headers)
|
We have a login view that needs a template at tutorial/templates/login.pt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | <div metal:use-macro="view.layout">
<div metal:fill-slot="content">
<h4>Login</h4>
<span tal:replace="message"/>
<form action="${url}" method="post">
<input type="hidden" name="came_from"
value="${came_from}"/>
<label for="login">Username</label>
<input type="text" id="login"
name="login"
value="${login}"/><br/>
<label for="password">Password</label>
<input type="password" id="password"
name="password"
value="${password}"/><br/>
<input type="submit" name="form.submitted"
value="Log In"/>
</form>
</div>
</div>
|
tutorial/templats/layout.pt needs a conditional link to appear on all pages, for either logging in or logging out:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:metal="http://xml.zope.org/namespaces/metal"
xmlns:tal="http://xml.zope.org/namespaces/tal"
metal:define-macro="layout">
<head>
<title>Wiki - ${title}</title>
<more metal:define-slot="head-more"></more>
<link rel="stylesheet"
href="${request.static_url('tutorial:static/wiki.css')}"/>
</head>
<body>
<div id="main">
<div id="loginout">
<a tal:condition="view.logged_in is not None"
href="${request.application_url}/logout">Logout</a>
<a tal:condition="view.logged_in is None"
href="${request.application_url}/login">Log In</a>
</div>
<h1>
<a href="${request.route_url('wiki_view')}">
<img src="${request.static_url('tutorial:static/logo.png')}"
alt="Logo"/></a>
${title}</h1>
<div metal:define-slot="content">
</div>
</div>
</body>
</html>
|
Let’s style that link by adding a float to tutorial/static/wiki.css:
body {
font-family: sans-serif;
margin: 2em;
}
h1 a {
vertical-align: bottom;
}
#loginout {
float: right;
}
Finally, we need to change our functional tests, as requests to add/edit/delete get redirected to the login screen:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | import unittest
from pyramid import testing
class WikiViewTests(unittest.TestCase):
def setUp(self):
self.config = testing.setUp()
def tearDown(self):
testing.tearDown()
def test_wiki_view(self):
from tutorial.views import WikiViews
request = testing.DummyRequest()
inst = WikiViews(request)
response = inst.wiki_view()
self.assertEqual(response['title'], 'Welcome to the Wiki')
class WikiFunctionalTests(unittest.TestCase):
def setUp(self):
from tutorial import main
settings = {}
app = main(settings)
from webtest import TestApp
self.testapp = TestApp(app)
def test_it(self):
res = self.testapp.get('/', status=200)
self.assertIn(b'Welcome', res.body)
res = self.testapp.get('/add', status=200)
self.assertIn(b'Log', res.body)
res = self.testapp.get('/100', status=200)
self.assertIn(b'100', res.body)
res = self.testapp.get('/100/edit', status=200)
self.assertIn(b'Log', res.body)
res = self.testapp.get('/100/delete', status=200)
self.assertIn(b'Log', res.body)
|
Run the tests in your package using nose:
(env33)$ nosetests . .. ----------------------------------------------------------------- Ran 2 tests in 1.971s OK
Run the WSGI application:
(env33)$ pserve development.ini --reload
Open http://127.0.0.1:6547/ in your browser.
Unlike many web frameworks, Pyramid includes a built-in (but optional) security model for authentication and authorization. This security system is intended to be flexible and support many needs.
This simple tutorial step can be boiled down to the following:
In summary: wikipage_add wants edit permission, Root says group:editors has edit permission.
Of course, this only applies on Root. Some other part of the site (a.k.a. context) might have a different ACL.
If you are not logged in and click on Add WikiPage, you need to get sent to a login screen. How does Pyramid know what is the login page to use? We explicitly told Pyramid that the login view should be used by decorating the view with @forbidden_view_config.