Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

object-level security and tons of other small improvements to the wiki2 tutorial #2334

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a6d08e1
improve the models api/usage and add a lot of comments
mmerickel Feb 4, 2016
f4e1ca8
rename dbmaker to session_factory
mmerickel Feb 5, 2016
0e21c2a
make models/__init__.py a template
mmerickel Feb 5, 2016
35dc507
update source for basiclayout
mmerickel Feb 5, 2016
9b83981
fix the Base import
mmerickel Feb 5, 2016
8ec0b01
unindent literalincludes
mmerickel Feb 5, 2016
7b89a7e
update basiclayout prose
mmerickel Feb 5, 2016
21d69fd
link to create_all sqla method
mmerickel Feb 5, 2016
ab68f1f
minor grammar and punctuation tweaks, break up run-on sentences.
stevepiercy Feb 5, 2016
d1cb346
assume the user is in the tutorial folder
mmerickel Feb 7, 2016
8d45715
reference addon links for pyramid_jinja2, pyramid_tm, zope.sqlalchemy…
mmerickel Feb 7, 2016
d6243ac
update definingmodels chapter of wiki2 tutorial
mmerickel Feb 7, 2016
4b23c9f
update definingviews chapter of wiki2 tutorial
mmerickel Feb 8, 2016
14cff75
update authorization chapter of wiki2 tutorial
mmerickel Feb 8, 2016
4337a25
minor fixes to wiki2 distributing chapter
mmerickel Feb 8, 2016
0b02e46
expose the session factory on the registry
mmerickel Feb 8, 2016
1c10801
[wip] update tests in wiki2 tutorial
mmerickel Feb 8, 2016
ed218d3
Merge pull request #2 from stevepiercy/mmerickel-feature/alchemy-scaf…
mmerickel Feb 9, 2016
d6a758e
fix tests to get the bind from dbsession_factory properly
mmerickel Feb 9, 2016
91ffcca
fix jinja2 none test
mmerickel Feb 9, 2016
62f0411
fix functional tests
mmerickel Feb 9, 2016
ff88c15
split login from forbidden
mmerickel Feb 11, 2016
07d38f5
several simple refactorings
mmerickel Feb 11, 2016
e01b270
explain the base layout.jinja2 template and notfound view
mmerickel Feb 11, 2016
9a7cfe3
update 404 templates
mmerickel Feb 11, 2016
f2e9c68
move security into one place
mmerickel Feb 11, 2016
cb5a848
copy layout and templates from views to authorization
mmerickel Feb 12, 2016
81e5989
create an actual user model to prepare for security
mmerickel Feb 12, 2016
e6e4f65
let's go ahead and bite off more than we can chew by adding object-se…
mmerickel Feb 12, 2016
574ba1a
update the models chapter with the new user model
mmerickel Feb 12, 2016
a115c6d
add the bcrypt dependency
mmerickel Feb 12, 2016
4872a1e
forward port changes to models / scripts to later chapters
mmerickel Feb 12, 2016
23c2d7b
update the views/models with setup.py develop
mmerickel Feb 12, 2016
60891b8
improve the views section by removing quirks and explaining transactions
mmerickel Feb 13, 2016
4c391c5
fix syntax
mmerickel Feb 14, 2016
bca6c99
highlight more appropriate lines in views
mmerickel Feb 14, 2016
42c9316
fix unicode issues with check_password
mmerickel Feb 14, 2016
da5ebc2
split routes into a separate module
mmerickel Feb 14, 2016
00b2c69
implement the authentication example code
mmerickel Feb 14, 2016
659a254
add a new authentication chapter
mmerickel Feb 16, 2016
38b4076
use page.name to prepare for context
mmerickel Feb 17, 2016
f2c4368
remove whitespace
mmerickel Feb 17, 2016
2fa9046
add first cut at source for authorization chapter
mmerickel Feb 17, 2016
9e85d2b
update the authorization chapter
mmerickel Feb 18, 2016
91f7ed4
add webtest and tests_require to setup.py
mmerickel Feb 18, 2016
50e08a7
add fallback for next_url
mmerickel Feb 18, 2016
66fabb4
update tests chapter
mmerickel Feb 18, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions docs/tutorials/wiki2/authentication.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
.. _wiki2_adding_authentication:

=====================
Adding authentication
=====================

:app:`Pyramid` provides facilities for :term:`authentication` and
:term:`authorization`. In this section we'll focus solely on the
authentication APIs to add login/logout functionality to our wiki.

We will implement authentication with the following steps:

* Add an :term:`authentication policy` and a ``request.user`` computed
property (``security.py``).
* Add routes for /login and /logout (``routes.py``).
* Add login and logout views (``views/auth.py``).
* Add a login template (``login.jinja2``).
* Add "Login" and "Logout" links to every page based on the user's
authenticated state (``layout.jinja2``).
* Make the existing views verify user state (``views/default.py``).
* Redirect to /login when a user is denied access to any of the views
that require permission, instead of a default "403 Forbidden" page
(``views/auth.py``).

Authenticating requests
-----------------------

The core of :app:`Pyramid` authentication is a :term:`authentication policy`
which is used to identify authentication information from a ``request``,
as well as handling the low-level login/logout operations required to
track users across requests (via cookies or headers or whatever else you can
imagine).

Add the authentication policy
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Create a new file ``tutorial/security.py``:

.. literalinclude:: src/authentication/tutorial/security.py
:linenos:
:emphasize-lines: 9,14,21-27
:language: python

Here we've defined:

* A new authentication policy named ``MyAuthenticationPolicy`` which is
subclassed from pyramid's
:class:`pyramid.authentication.AuthTktAuthenticationPolicy` which tracks
the :term:`userid` using a signed cookie.
* A ``get_user`` function which can convert the ``unauthenticated_userid``
from the policy into a ``User`` object from our database.
* The ``get_user`` is registered on the request as ``request.user``
to be used throughout our application as the authenticated ``User`` object
for the logged-in user.

The logic in this file is a little bit interesting and so we'll go into
detail about what's happening here:

First, the default authentication policies all provide a method named
``unauthenticated_userid`` which is responsible for the low-level parsing
of the information in the request (cookies, headers, etc). If a ``userid``
is found then it is returned from this method. This is named
``unauthenticated_userid`` because at the lowest level it knows the value of
the userid in the cookie but it doesn't know if it's actually a user in our
system (remember, anything the user sends to our app is untrusted).

Second, our application should only care about ``authenticated_userid`` and
``request.user`` which have gone through our application-specific process of
validating that the user is logged-in.

In order to provide an ``authenticated_userid`` we need a verification step.
That can happen anywhere, so we've elected to do it inside of the cached
``request.user`` computed property. This is a convenience that makes
``request.user`` the source of truth in our system. It is either ``None`` or
a ``User`` object from our database. This is why the ``get_user`` function
uses the ``unauthenticated_userid`` to check the database

Configure the app
~~~~~~~~~~~~~~~~~

Since we've added a new ``tutorial/security.py`` module we need to include it.

Open the file ``tutorial/__init__.py`` and edit the following lines:

.. literalinclude:: src/authentication/tutorial/__init__.py
:linenos:
:emphasize-lines: 11
:language: python

Our authentication policy is expecting a new setting, ``auth.secret``. Open
the file ``development.ini`` and add the highlighted line below:

.. literalinclude:: src/authentication/development.ini
:lines: 18-20
:emphasize-lines: 3
:lineno-match:
:language: ini

Finally best-practices tell us to use a different secret for production so
open ``production.ini`` and add a different secret:

.. literalinclude:: src/authentication/production.ini
:lines: 15-17
:emphasize-lines: 3
:lineno-match:
:language: ini

Add permission checks
~~~~~~~~~~~~~~~~~~~~~

:app:`Pyramid` has full support for declarative authorization which we'll
cover in the next chapter. However many people looking to get their feet
wet are just interested in authentication with some basic form of
home-grown authorization. We'll show below how to accomplish the simple
security goals of our wiki now that we can track the logged-in state of users.

Remember our goals:

* Allow only ``editor`` and ``basic`` logged-in users to create new pages.
* Only allow ``editor`` users and the page creator (possibly a ``basic`` user)
to edit pages.

Open the file ``tutorial/views/default.py`` and fix the following imports:

.. literalinclude:: src/authentication/tutorial/views/default.py
:lines: 5-13
:lineno-match:
:emphasize-lines: 2,9
:language: python

Only the highlighted lines need to be changed.

Now edit the ``add_page`` view function:

.. literalinclude:: src/authentication/tutorial/views/default.py
:lines: 62-76
:lineno-match:
:emphasize-lines: 3-5,10
:language: python

Only the highlighted lines need to be changed.

If the user is not logged in or is not in the ``basic`` or ``editor`` roles
then we raise ``HTTPForbidden`` which will return a "403 Forbidden" response
to the user. However we hook this later to redirect to the login page. Also,
now that we have ``request.user`` we no longer have to hard-code the creator
as the ``editor`` user so we can finally drop that hack.

Now edit the ``edit_page`` view function:

.. literalinclude:: src/authentication/tutorial/views/default.py
:lines: 45-60
:lineno-match:
:emphasize-lines: 5-7
:language: python

Only the highlighted lines need to be changed.

If the user is not logged in or the user is not the page's creator **and**
not an ``editor`` then we raise ``HTTPForbidden``.

These simple checks should protect our views.

Login, logout
-------------

Now that we've got the ability to detect logged-in users, we need to
add the /login and /logout views so that they can actually login!

Add routes for /login and /logout
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Go back to ``tutorial/routes.py`` and add these two routes as highlighted:

.. literalinclude:: src/authentication/tutorial/routes.py
:lines: 3-6
:lineno-match:
:emphasize-lines: 2-3
:language: python

.. note:: The preceding lines must be added *before* the following
``view_page`` route definition:

.. literalinclude:: src/authentication/tutorial/routes.py
:lines: 6
:language: python

This is because ``view_page``'s route definition uses a catch-all
"replacement marker" ``/{pagename}`` (see :ref:`route_pattern_syntax`)
which will catch any route that was not already caught by any route
registered before it. Hence, for ``login`` and ``logout`` views to
have the opportunity of being matched (or "caught"), they must be above
``/{pagename}``.

Add login, logout and forbidden views
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Create a new file ``tutorial/views/auth.py`` where we will add the following
code:

.. literalinclude:: src/authentication/tutorial/views/auth.py
:linenos:
:language: python

This code adds 3 new views to application:

- The ``login`` view renders a login form and processes the post from the
login form, checking credentials against our ``users`` table in the database.

The check is done by first finding a ``User`` record in the database and
then using our ``user.check_password`` method to compare the passwords.

If the credentials are valid then we use our authentication policy to
store the user's id in the response using :meth:`pyramid.security.remember`.

Finally, the user is redirected back to the page they were trying to access
(``next``) or the front page as a fallback. This parameter is used by
our forbidden view as explained below to finish the login workflow.

- The ``logout`` view handles requests to /logout by clearing the credentials
using :meth:`pyramid.security.forget` and then redirecting them to the front
page.

- The ``forbidden_view`` is registered using the
:class:`pyramid.view.forbidden_view_config` decorator. This is a special
:term:`exception view` which is invoked when a
:class:`pyramid.httpexceptions.HTTPForbidden` exception is raised.

This view will handle a forbidden error by redirecting the user to /login.
As a convenience it also sets the ``next=`` query string to the current url
(the one that is forbidding access). This way if the user successfully logs
in they will be sent back to the page they had been trying to access.

Add the ``login.jinja2`` template
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Create ``tutorial/templates/login.jinja2`` with the following content:

.. literalinclude:: src/authentication/tutorial/templates/login.jinja2
:language: html

The above template is referenced in the login view that we just added in
``tutorial/views/auth.py``.

Add a "Login" and "Logout" links
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Open ``tutorial/templates/layout.jinja2`` and add the following code as
indicated by the highlighted lines.

.. literalinclude:: src/authentication/tutorial/templates/layout.jinja2
:lines: 35-46
:lineno-match:
:emphasize-lines: 2-10
:language: html

The ``request.user`` will be ``None`` if the user is not authenticated, or a
``tutorial.models.User`` object if the user is authenticated. This
check will make the logout link active only when the user is logged in and
vice versa the login link is only active when the user is logged out.

Viewing the application in a browser
------------------------------------

We can finally examine our application in a browser (See
:ref:`wiki2-start-the-application`). Launch a browser and visit each of the
following URLs, checking that the result is as expected:

- http://localhost:6543/ invokes the ``view_wiki`` view. This always
redirects to the ``view_page`` view of the ``FrontPage`` page object. It
is executable by any user.

- http://localhost:6543/FrontPage invokes the ``view_page`` view of the
``FrontPage`` page object. There is a "Login" link in the upper right corner.

- http://localhost:6543/FrontPage/edit_page invokes the edit view for the
FrontPage object. It is executable by only the ``editor`` user. If a
different user (or the anonymous user) invokes it, a login form will be
displayed. Supplying the credentials with the username ``editor``, password
``editor`` will display the edit page form.

- http://localhost:6543/add_page/SomePageName invokes the add view for a page.
It is executable by the ``editor`` or ``basic`` user. If a different user
(or the anonymous user) invokes it, a login form will be displayed. Supplying
the credentials with the username ``basic``, password ``basic`` will display
the edit page form.

- http://localhost:6543/SomePageName/edit_page is editable by the ``basic``
if the page was created by that user in the previous step. If, instead, the
page was created by ``editor`` then the login page should be shown for the
``basic`` user.

- After logging in (as a result of hitting an edit or add page and submitting
the login form with the ``editor`` credentials), we'll see a Logout link in
the upper right hand corner. When we click it, we're logged out, and
redirected back to the front page.
Loading