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. |