10: Security With Authentication and Authorization

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.

Objectives

  • Introduce the Pyramid concepts of authentication, authorization, permissions, and access control lists (ACLs)
  • Create login/logout views

Steps

  1. Copy the results from the previous step:

    (env33)$ cd ..; cp -r step09 step10; cd step10
    (env33)$ python3.3 setup.py develop
    
  2. 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
    
  3. 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()
    
  4. 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, [])
    
  5. 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)
    
  6. 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>
    
  7. 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>
    
  8. 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;
    }
    
  9. 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)
    
  10. Run the tests in your package using nose:

    (env33)$ nosetests .
    ..
    -----------------------------------------------------------------
    Ran 2 tests in 1.971s
    
    OK
    
  11. Run the WSGI application:

    (env33)$ pserve development.ini --reload
    
  12. Open http://127.0.0.1:6547/ in your browser.

Analysis

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:

  • A view can require a permission (edit)
  • The context for our view (the Root) has an access control list (ACL)
  • This ACL says that the edit permission is available on Root to the group:editors principal
  • The registered groupfinder answers whether a particular user (editor) has a particular group (group:editors)

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.

Extra Credit

  1. Can I use a database behind my groupfinder to look up principals?
  2. Do I have to put a renderer in my @forbidden_view_config decorator?
  3. Once I am logged in, does any user-centric information get jammed onto each request? Use import pdb; pdb.set_trace() to answer this.

Table Of Contents