Michael Merickel
2017-06-18 75c30dfe18b26ca04efae2acbe35052fa0d93ed6
commit | author | age
659a25 1 .. _wiki2_adding_authentication:
MM 2
3 =====================
4 Adding authentication
5 =====================
6
7 :app:`Pyramid` provides facilities for :term:`authentication` and
79054f 8 :term:`authorization`. In this section we'll focus solely on the authentication
SP 9 APIs to add login and logout functionality to our wiki.
659a25 10
MM 11 We will implement authentication with the following steps:
12
79054f 13 * Add an :term:`authentication policy` and a ``request.user`` computed property
SP 14   (``security.py``).
15 * Add routes for ``/login`` and ``/logout`` (``routes.py``).
659a25 16 * Add login and logout views (``views/auth.py``).
MM 17 * Add a login template (``login.jinja2``).
18 * Add "Login" and "Logout" links to every page based on the user's
19   authenticated state (``layout.jinja2``).
20 * Make the existing views verify user state (``views/default.py``).
79054f 21 * Redirect to ``/login`` when a user is denied access to any of the views that
SP 22   require permission, instead of a default "403 Forbidden" page
659a25 23   (``views/auth.py``).
79054f 24
659a25 25
MM 26 Authenticating requests
27 -----------------------
28
79054f 29 The core of :app:`Pyramid` authentication is an :term:`authentication policy`
659a25 30 which is used to identify authentication information from a ``request``,
79054f 31 as well as handling the low-level login and logout operations required to
SP 32 track users across requests (via cookies, headers, or whatever else you can
659a25 33 imagine).
79054f 34
659a25 35
MM 36 Add the authentication policy
37 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
38
79054f 39 Create a new file ``tutorial/security.py`` with the following content:
659a25 40
MM 41 .. literalinclude:: src/authentication/tutorial/security.py
42    :linenos:
43    :language: python
44
45 Here we've defined:
46
79054f 47 * A new authentication policy named ``MyAuthenticationPolicy``, which is
SP 48   subclassed from Pyramid's
49   :class:`pyramid.authentication.AuthTktAuthenticationPolicy`, which tracks the
50   :term:`userid` using a signed cookie (lines 7-11).
51 * A ``get_user`` function, which can convert the ``unauthenticated_userid``
52   from the policy into a ``User`` object from our database (lines 13-17).
53 * The ``get_user`` is registered on the request as ``request.user`` to be used
54   throughout our application as the authenticated ``User`` object for the
55   logged-in user (line 27).
659a25 56
79054f 57 The logic in this file is a little bit interesting, so we'll go into detail
SP 58 about what's happening here:
659a25 59
MM 60 First, the default authentication policies all provide a method named
61 ``unauthenticated_userid`` which is responsible for the low-level parsing
79054f 62 of the information in the request (cookies, headers, etc.). If a ``userid``
SP 63 is found, then it is returned from this method. This is named
64 ``unauthenticated_userid`` because, at the lowest level, it knows the value of
65 the userid in the cookie, but it doesn't know if it's actually a user in our
659a25 66 system (remember, anything the user sends to our app is untrusted).
MM 67
68 Second, our application should only care about ``authenticated_userid`` and
79054f 69 ``request.user``, which have gone through our application-specific process of
SP 70 validating that the user is logged in.
659a25 71
MM 72 In order to provide an ``authenticated_userid`` we need a verification step.
73 That can happen anywhere, so we've elected to do it inside of the cached
74 ``request.user`` computed property. This is a convenience that makes
75 ``request.user`` the source of truth in our system. It is either ``None`` or
76 a ``User`` object from our database. This is why the ``get_user`` function
79054f 77 uses the ``unauthenticated_userid`` to check the database.
SP 78
659a25 79
MM 80 Configure the app
81 ~~~~~~~~~~~~~~~~~
82
79054f 83 Since we've added a new ``tutorial/security.py`` module, we need to include it.
659a25 84 Open the file ``tutorial/__init__.py`` and edit the following lines:
MM 85
86 .. literalinclude:: src/authentication/tutorial/__init__.py
87    :linenos:
88    :emphasize-lines: 11
89    :language: python
90
91 Our authentication policy is expecting a new setting, ``auth.secret``. Open
92 the file ``development.ini`` and add the highlighted line below:
93
94 .. literalinclude:: src/authentication/development.ini
2c0e3e 95    :lines: 19-21
659a25 96    :emphasize-lines: 3
MM 97    :lineno-match:
98    :language: ini
99
79054f 100 Finally, best practices tell us to use a different secret for production, so
659a25 101 open ``production.ini`` and add a different secret:
MM 102
103 .. literalinclude:: src/authentication/production.ini
2c0e3e 104    :lines: 17-19
659a25 105    :emphasize-lines: 3
MM 106    :lineno-match:
107    :language: ini
108
79054f 109
659a25 110 Add permission checks
MM 111 ~~~~~~~~~~~~~~~~~~~~~
112
79054f 113 :app:`Pyramid` has full support for declarative authorization, which we'll
SP 114 cover in the next chapter. However, many people looking to get their feet wet
115 are just interested in authentication with some basic form of home-grown
116 authorization. We'll show below how to accomplish the simple security goals of
117 our wiki, now that we can track the logged-in state of users.
659a25 118
MM 119 Remember our goals:
120
121 * Allow only ``editor`` and ``basic`` logged-in users to create new pages.
122 * Only allow ``editor`` users and the page creator (possibly a ``basic`` user)
123   to edit pages.
124
125 Open the file ``tutorial/views/default.py`` and fix the following imports:
126
127 .. literalinclude:: src/authentication/tutorial/views/default.py
128    :lines: 5-13
129    :lineno-match:
130    :emphasize-lines: 2,9
131    :language: python
132
79054f 133 Change the two highlighted lines.
659a25 134
3e3004 135 In the same file, now edit the ``edit_page`` view function:
659a25 136
MM 137 .. literalinclude:: src/authentication/tutorial/views/default.py
138    :lines: 45-60
139    :lineno-match:
140    :emphasize-lines: 5-7
141    :language: python
142
143 Only the highlighted lines need to be changed.
144
79054f 145 If the user either is not logged in or the user is not the page's creator
SP 146 *and* not an ``editor``, then we raise ``HTTPForbidden``.
3e3004 147
SP 148 In the same file, now edit the ``add_page`` view function:
149
150 .. literalinclude:: src/authentication/tutorial/views/default.py
151    :lines: 62-76
152    :lineno-match:
153    :emphasize-lines: 3-5,13
154    :language: python
155
156 Only the highlighted lines need to be changed.
157
158 If the user either is not logged in or is not in the ``basic`` or ``editor``
159 roles, then we raise ``HTTPForbidden``, which will return a "403 Forbidden"
160 response to the user. However, we will hook this later to redirect to the login
161 page. Also, now that we have ``request.user``, we no longer have to hard-code
162 the creator as the ``editor`` user, so we can finally drop that hack.
659a25 163
MM 164 These simple checks should protect our views.
79054f 165
659a25 166
MM 167 Login, logout
168 -------------
169
79054f 170 Now that we've got the ability to detect logged-in users, we need to add the
SP 171 ``/login`` and ``/logout`` views so that they can actually login and logout!
659a25 172
79054f 173
SP 174 Add routes for ``/login`` and ``/logout``
175 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
659a25 176
MM 177 Go back to ``tutorial/routes.py`` and add these two routes as highlighted:
178
179 .. literalinclude:: src/authentication/tutorial/routes.py
180    :lines: 3-6
181    :lineno-match:
182    :emphasize-lines: 2-3
183    :language: python
184
185 .. note:: The preceding lines must be added *before* the following
186    ``view_page`` route definition:
187
188    .. literalinclude:: src/authentication/tutorial/routes.py
189       :lines: 6
79054f 190       :lineno-match:
659a25 191       :language: python
MM 192
193    This is because ``view_page``'s route definition uses a catch-all
79054f 194    "replacement marker" ``/{pagename}`` (see :ref:`route_pattern_syntax`),
659a25 195    which will catch any route that was not already caught by any route
MM 196    registered before it. Hence, for ``login`` and ``logout`` views to
197    have the opportunity of being matched (or "caught"), they must be above
198    ``/{pagename}``.
199
200
79054f 201 Add login, logout, and forbidden views
SP 202 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
203
204 Create a new file ``tutorial/views/auth.py``, and add the following code to it:
659a25 205
MM 206 .. literalinclude:: src/authentication/tutorial/views/auth.py
207    :linenos:
208    :language: python
209
44c087 210 This code adds three new views to the application:
659a25 211
MM 212 - The ``login`` view renders a login form and processes the post from the
213   login form, checking credentials against our ``users`` table in the database.
214
79054f 215   The check is done by first finding a ``User`` record in the database, then
SP 216   using our ``user.check_password`` method to compare the hashed passwords.
659a25 217
79054f 218   If the credentials are valid, then we use our authentication policy to store
SP 219   the user's id in the response using :meth:`pyramid.security.remember`.
659a25 220
79054f 221   Finally, the user is redirected back to either the page which they were
SP 222   trying to access (``next``) or the front page as a fallback. This parameter
223   is used by our forbidden view, as explained below, to finish the login
224   workflow.
659a25 225
79054f 226 - The ``logout`` view handles requests to ``/logout`` by clearing the
SP 227   credentials using :meth:`pyramid.security.forget`, then redirecting them to
228   the front page.
659a25 229
MM 230 - The ``forbidden_view`` is registered using the
231   :class:`pyramid.view.forbidden_view_config` decorator. This is a special
79054f 232   :term:`exception view`, which is invoked when a
659a25 233   :class:`pyramid.httpexceptions.HTTPForbidden` exception is raised.
MM 234
79054f 235   This view will handle a forbidden error by redirecting the user to
SP 236   ``/login``. As a convenience, it also sets the ``next=`` query string to the
237   current URL (the one that is forbidding access). This way, if the user
238   successfully logs in, they will be sent back to the page which they had been
239   trying to access.
240
659a25 241
MM 242 Add the ``login.jinja2`` template
243 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
244
245 Create ``tutorial/templates/login.jinja2`` with the following content:
246
247 .. literalinclude:: src/authentication/tutorial/templates/login.jinja2
248    :language: html
249
250 The above template is referenced in the login view that we just added in
251 ``tutorial/views/auth.py``.
252
79054f 253
SP 254 Add "Login" and "Logout" links
255 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
659a25 256
MM 257 Open ``tutorial/templates/layout.jinja2`` and add the following code as
258 indicated by the highlighted lines.
259
260 .. literalinclude:: src/authentication/tutorial/templates/layout.jinja2
261    :lines: 35-46
262    :lineno-match:
263    :emphasize-lines: 2-10
264    :language: html
265
266 The ``request.user`` will be ``None`` if the user is not authenticated, or a
79054f 267 ``tutorial.models.User`` object if the user is authenticated. This check will
SP 268 make the logout link shown only when the user is logged in, and conversely the
269 login link is only shown when the user is logged out.
270
659a25 271
MM 272 Viewing the application in a browser
273 ------------------------------------
274
275 We can finally examine our application in a browser (See
276 :ref:`wiki2-start-the-application`).  Launch a browser and visit each of the
277 following URLs, checking that the result is as expected:
278
279 - http://localhost:6543/ invokes the ``view_wiki`` view.  This always
280   redirects to the ``view_page`` view of the ``FrontPage`` page object.  It
281   is executable by any user.
282
283 - http://localhost:6543/FrontPage invokes the ``view_page`` view of the
79054f 284   ``FrontPage`` page object. There is a "Login" link in the upper right corner
SP 285   while the user is not authenticated, else it is a "Logout" link when the user
286   is authenticated.
659a25 287
3e3004 288 - http://localhost:6543/FrontPage/edit_page invokes the ``edit_page`` view for
SP 289   the ``FrontPage`` page object.  It is executable by only the ``editor`` user.
290   If a different user (or the anonymous user) invokes it, then a login form
291   will be displayed. Supplying the credentials with the username ``editor`` and
f99052 292   password ``editor`` will display the edit page form.
659a25 293
3e3004 294 - http://localhost:6543/add_page/SomePageName invokes the ``add_page`` view for
SP 295   a page. If the page already exists, then it redirects the user to the
296   ``edit_page`` view for the page object. It is executable by either the
297   ``editor`` or ``basic`` user.  If a different user (or the anonymous user)
298   invokes it, then a login form will be displayed. Supplying the credentials
299   with either the username ``editor`` and password ``editor``, or username
300   ``basic`` and password ``basic``, will display the edit page form.
659a25 301
3e3004 302 - http://localhost:6543/SomePageName/edit_page invokes the ``edit_page`` view
SP 303   for an existing page, or generates an error if the page does not exist. It is
304   editable by the ``basic`` user if the page was created by that user in the
305   previous step. If, instead, the page was created by the ``editor`` user, then
306   the login page should be shown for the ``basic`` user.
659a25 307
MM 308 - After logging in (as a result of hitting an edit or add page and submitting
79054f 309   the login form with the ``editor`` credentials), we'll see a "Logout" link in
3e3004 310   the upper right hand corner.  When we click it, we're logged out, redirected
SP 311   back to the front page, and a "Login" link is shown in the upper right hand
312   corner.