From c4626765913de97fb6410f0fdb50a4c93a38bd5b Mon Sep 17 00:00:00 2001
From: Michael Merickel
Date: Mon, 6 Jan 2020 22:31:40 -0600
Subject: update authentication docs with security policy
---
docs/tutorials/wiki2/authentication.rst | 136 ++++++++++-----------
docs/tutorials/wiki2/src/authentication/.gitignore | 1 +
docs/tutorials/wiki2/src/authentication/setup.py | 2 +-
.../tutorials/wiki2/src/authentication/testing.ini | 81 ++++++++++++
.../wiki2/src/authentication/tests/conftest.py | 125 +++++++++++++++++++
.../src/authentication/tests/test_functional.py | 13 ++
.../wiki2/src/authentication/tests/test_it.py | 66 ----------
.../wiki2/src/authentication/tests/test_views.py | 23 ++++
.../wiki2/src/authentication/tutorial/__init__.py | 4 +-
.../src/authentication/tutorial/models/__init__.py | 22 ++--
.../tutorial/scripts/initialize_db.py | 4 +
.../wiki2/src/authentication/tutorial/security.py | 46 ++++---
.../authentication/tutorial/templates/403.jinja2 | 6 +
.../authentication/tutorial/templates/404.jinja2 | 6 +-
.../authentication/tutorial/templates/edit.jinja2 | 3 +-
.../tutorial/templates/layout.jinja2 | 19 ++-
.../authentication/tutorial/templates/login.jinja2 | 3 +-
.../src/authentication/tutorial/views/auth.py | 41 +++++--
.../src/authentication/tutorial/views/default.py | 23 ++--
19 files changed, 428 insertions(+), 196 deletions(-)
create mode 100644 docs/tutorials/wiki2/src/authentication/testing.ini
create mode 100644 docs/tutorials/wiki2/src/authentication/tests/conftest.py
create mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_functional.py
delete mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_it.py
create mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_views.py
create mode 100644 docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2
(limited to 'docs')
diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst
index 3f2fcec83..580e4ba75 100644
--- a/docs/tutorials/wiki2/authentication.rst
+++ b/docs/tutorials/wiki2/authentication.rst
@@ -10,7 +10,7 @@ APIs to add login and logout functionality to our wiki.
We will implement authentication with the following steps:
-* Add an :term:`authentication policy` and a ``request.user`` computed property
+* Add a :term:`security 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``).
@@ -18,25 +18,24 @@ We will implement authentication with the following steps:
* 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``).
+* Redirect to ``/login`` when a user is not logged in and is denied access to any of the views that require permission (``views/auth.py``)..
+* Show a custom "403 Forbidden" page if a logged in user is denied access to any views that require permission (``views/auth.py``).
Authenticating requests
-----------------------
-The core of :app:`Pyramid` authentication is an :term:`authentication policy`
+The core of :app:`Pyramid` authentication is a :term:`security policy`
which is used to identify authentication information from a ``request``,
as well as handling the low-level login and logout operations required to
track users across requests (via cookies, headers, or whatever else you can
imagine).
-Add the authentication policy
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Add the security policy
+~~~~~~~~~~~~~~~~~~~~~~~
-Create a new file ``tutorial/security.py`` with the following content:
+Update ``tutorial/security.py`` with the following content:
.. literalinclude:: src/authentication/tutorial/security.py
:linenos:
@@ -44,49 +43,26 @@ Create a new file ``tutorial/security.py`` with the following content:
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 (lines 7-11).
-* A ``get_user`` function, which can convert the ``unauthenticated_userid``
- from the policy into a ``User`` object from our database (lines 13-17).
-* 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 (line 27).
-
-The logic in this file is a little bit interesting, 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.
+* A new security policy named ``MySecurityPolicy``, which is implementing most of the :class:`pyramid.interfaces.ISecurityPolicy` interface by tracking a :term:`identity` using a signed cookie implemented by :class:`pyramid.authentication.AuthTktCookieHelper` (lines 7-29).
+* The ``request.user`` computed property is registered for use throughout our application as the authenticated ``tutorial.models.User`` object for the logged-in user (line 38-39).
+Our new :term:`security policy` defines how our application will remember, forget, and identify users.
+It also handles authorization, which we'll cover in the next chapter (if you're wondering why we didn't implement the ``permits`` method yet).
-Configure the app
-~~~~~~~~~~~~~~~~~
+Identifying the current user is done in a couple steps:
-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:
+1. The ``MySecurityPolicy.authenticated_identity`` method asks the cookie helper to pull the identity from the request.
+ This value is ``None`` if the cookie is missing or the content cannot be verified.
+2. We then translate the identity into a ``tutorial.models.User`` object by looking for a record in the database.
+
+This is a good spot to confirm that the user is actually allowed to access our application.
+For example, maybe they were marked deleted or banned and we should return ``None`` instead of the ``user`` object.
+
+Finally, :attr:`pyramid.request.Request.authenticated_identity` contains either ``None`` or a ``tutorial.models.User`` instance and that value is aliased to ``request.user`` for convenience in our application.
-.. literalinclude:: src/authentication/tutorial/__init__.py
- :linenos:
- :emphasize-lines: 11
- :language: python
+
+Configure the app
+~~~~~~~~~~~~~~~~~
Our authentication policy is expecting a new setting, ``auth.secret``. Open
the file ``development.ini`` and add the highlighted line below:
@@ -97,7 +73,7 @@ the file ``development.ini`` and add the highlighted line below:
:lineno-match:
:language: ini
-Finally, best practices tell us to use a different secret for production, so
+Finally, best practices tell us to use a different secret in each environment, so
open ``production.ini`` and add a different secret:
.. literalinclude:: src/authentication/production.ini
@@ -106,6 +82,14 @@ open ``production.ini`` and add a different secret:
:lineno-match:
:language: ini
+And ``testing.ini``:
+
+.. literalinclude:: src/authentication/testing.ini
+ :lines: 17-19
+ :emphasize-lines: 3
+ :lineno-match:
+ :language: ini
+
Add permission checks
~~~~~~~~~~~~~~~~~~~~~
@@ -125,7 +109,7 @@ Remember our goals:
Open the file ``tutorial/views/default.py`` and fix the following import:
.. literalinclude:: src/authentication/tutorial/views/default.py
- :lines: 5-9
+ :lines: 3-7
:lineno-match:
:emphasize-lines: 2
:language: python
@@ -135,7 +119,7 @@ Change the highlighted line.
In the same file, now edit the ``edit_page`` view function:
.. literalinclude:: src/authentication/tutorial/views/default.py
- :lines: 45-60
+ :lines: 44-59
:lineno-match:
:emphasize-lines: 5-7
:language: python
@@ -148,18 +132,16 @@ If the user either is not logged in or the user is not the page's creator
In the same file, now edit the ``add_page`` view function:
.. literalinclude:: src/authentication/tutorial/views/default.py
- :lines: 62-76
+ :lines: 61-
:lineno-match:
:emphasize-lines: 3-5,13
:language: python
Only the highlighted lines need to be changed.
-If the user either 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 will 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.
+If the user either is not logged in or is not in the ``basic`` or ``editor`` roles, then we raise ``HTTPForbidden``, which will trigger our forbidden view to compute a response.
+However, we will 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.
These simple checks should protect our views.
@@ -215,6 +197,9 @@ This code adds three new views to the application:
The check is done by first finding a ``User`` record in the database, then
using our ``user.check_password`` method to compare the hashed passwords.
+ At a privilege boundary we are sure to reset the CSRF token using :meth:`pyramid.csrf.new_csrf_token`.
+ If we were using sessions we would want to invalidate that as well.
+
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`.
@@ -227,16 +212,19 @@ This code adds three new views to the application:
credentials using :meth:`pyramid.security.forget`, then redirecting them to
the front page.
+ At a privilege boundary we are sure to reset the CSRF token using :meth:`pyramid.csrf.new_csrf_token`.
+ If we were using sessions we would want to invalidate that as well.
+
- 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 which they had been
- trying to access.
+ By default, the view will return a "403 Forbidden" response and display our ``403.jinja2`` template (added below).
+
+ However, if the user is not logged in, 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 which they had been trying to access.
Add the ``login.jinja2`` template
@@ -258,9 +246,9 @@ 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
+ :lines: 35-48
:lineno-match:
- :emphasize-lines: 2-10
+ :emphasize-lines: 2-12
:language: html
The ``request.user`` will be ``None`` if the user is not authenticated, or a
@@ -269,6 +257,17 @@ make the logout link shown only when the user is logged in, and conversely the
login link is only shown when the user is logged out.
+Add the ``403.jinja2`` template
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Create ``tutorial/templates/403.jinja2`` with the following content:
+
+.. literalinclude:: src/authentication/tutorial/templates/403.jinja2
+ :language: html
+
+The above template is referenced in the forbidden view that we just added in ``tutorial/views/auth.py``.
+
+
Viewing the application in a browser
------------------------------------
@@ -287,15 +286,16 @@ following URLs, checking that the result is as expected:
- http://localhost:6543/FrontPage/edit_page invokes the ``edit_page`` view for
the ``FrontPage`` page object. It is executable by only the ``editor`` user.
- If a different user (or the anonymous user) invokes it, then a login form
- will be displayed. Supplying the credentials with the username ``editor`` and
- password ``editor`` will display the edit page form.
+ If a different user invokes it, then the "403 Forbidden" page will be displayed.
+ If an anonymous user invokes it, then a login form will be displayed.
+ Supplying the credentials with the username ``editor`` and password ``editor`` will display the edit page form.
- http://localhost:6543/add_page/SomePageName invokes the ``add_page`` view for
a page. If the page already exists, then it redirects the user to the
``edit_page`` view for the page object. It is executable by either the
- ``editor`` or ``basic`` user. If a different user (or the anonymous user)
- invokes it, then a login form will be displayed. Supplying the credentials
+ ``editor`` or ``basic`` user.
+ If an anonymous user invokes it, then a login form will be displayed.
+ Supplying the credentials
with either the username ``editor`` and password ``editor``, or username
``basic`` and password ``basic``, will display the edit page form.
diff --git a/docs/tutorials/wiki2/src/authentication/.gitignore b/docs/tutorials/wiki2/src/authentication/.gitignore
index 1853d983c..c612e59f2 100644
--- a/docs/tutorials/wiki2/src/authentication/.gitignore
+++ b/docs/tutorials/wiki2/src/authentication/.gitignore
@@ -19,3 +19,4 @@ Data.fs*
.DS_Store
coverage
test
+*.sqlite
diff --git a/docs/tutorials/wiki2/src/authentication/setup.py b/docs/tutorials/wiki2/src/authentication/setup.py
index 500c5e599..12eabaff2 100644
--- a/docs/tutorials/wiki2/src/authentication/setup.py
+++ b/docs/tutorials/wiki2/src/authentication/setup.py
@@ -20,8 +20,8 @@ requires = [
'pyramid_tm',
'SQLAlchemy',
'transaction',
- 'zope.sqlalchemy',
'waitress',
+ 'zope.sqlalchemy',
]
tests_require = [
diff --git a/docs/tutorials/wiki2/src/authentication/testing.ini b/docs/tutorials/wiki2/src/authentication/testing.ini
new file mode 100644
index 000000000..07ec6550e
--- /dev/null
+++ b/docs/tutorials/wiki2/src/authentication/testing.ini
@@ -0,0 +1,81 @@
+###
+# app configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html
+###
+
+[app:main]
+use = egg:tutorial
+
+pyramid.reload_templates = false
+pyramid.debug_authorization = false
+pyramid.debug_notfound = false
+pyramid.debug_routematch = false
+pyramid.default_locale_name = en
+
+sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+
+retry.attempts = 3
+
+auth.secret = test-seekrit
+
+[pshell]
+setup = tutorial.pshell.setup
+
+###
+# wsgi server configuration
+###
+
+[alembic]
+# path to migration scripts
+script_location = tutorial/alembic
+file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s
+# file_template = %%(rev)s_%%(slug)s
+
+[server:main]
+use = egg:waitress#main
+listen = *:6543
+
+###
+# logging configuration
+# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html
+###
+
+[loggers]
+keys = root, tutorial, sqlalchemy, alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_tutorial]
+level = WARN
+handlers =
+qualname = tutorial
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+# "level = INFO" logs SQL queries.
+# "level = DEBUG" logs SQL queries and results.
+# "level = WARN" logs neither. (Recommended for production systems.)
+
+[logger_alembic]
+level = WARN
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s
diff --git a/docs/tutorials/wiki2/src/authentication/tests/conftest.py b/docs/tutorials/wiki2/src/authentication/tests/conftest.py
new file mode 100644
index 000000000..2db65f887
--- /dev/null
+++ b/docs/tutorials/wiki2/src/authentication/tests/conftest.py
@@ -0,0 +1,125 @@
+import alembic
+import alembic.config
+import alembic.command
+import os
+from pyramid.paster import get_appsettings
+from pyramid.scripting import prepare
+from pyramid.testing import DummyRequest
+import pytest
+import transaction
+from webob.cookies import Cookie
+import webtest
+
+from tutorial import main
+from tutorial import models
+from tutorial.models.meta import Base
+
+
+def pytest_addoption(parser):
+ parser.addoption('--ini', action='store', metavar='INI_FILE')
+
+@pytest.fixture(scope='session')
+def ini_file(request):
+ # potentially grab this path from a pytest option
+ return os.path.abspath(request.config.option.ini or 'testing.ini')
+
+@pytest.fixture(scope='session')
+def app_settings(ini_file):
+ return get_appsettings(ini_file)
+
+@pytest.fixture(scope='session')
+def dbengine(app_settings, ini_file):
+ engine = models.get_engine(app_settings)
+
+ alembic_cfg = alembic.config.Config(ini_file)
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+ # run migrations to initialize the database
+ # depending on how we want to initialize the database from scratch
+ # we could alternatively call:
+ # Base.metadata.create_all(bind=engine)
+ # alembic.command.stamp(alembic_cfg, "head")
+ alembic.command.upgrade(alembic_cfg, "head")
+
+ yield engine
+
+ Base.metadata.drop_all(bind=engine)
+ alembic.command.stamp(alembic_cfg, None, purge=True)
+
+@pytest.fixture(scope='session')
+def app(app_settings, dbengine):
+ return main({}, dbengine=dbengine, **app_settings)
+
+@pytest.fixture
+def tm():
+ tm = transaction.TransactionManager(explicit=True)
+ tm.begin()
+ tm.doom()
+
+ yield tm
+
+ tm.abort()
+
+@pytest.fixture
+def dbsession(app, tm):
+ session_factory = app.registry['dbsession_factory']
+ return models.get_tm_session(session_factory, tm)
+
+@pytest.fixture
+def testapp(app, tm, dbsession):
+ # override request.dbsession and request.tm with our own
+ # externally-controlled values that are shared across requests but aborted
+ # at the end
+ testapp = webtest.TestApp(app, extra_environ={
+ 'HTTP_HOST': 'example.com',
+ 'tm.active': True,
+ 'tm.manager': tm,
+ 'app.dbsession': dbsession,
+ })
+
+ return testapp
+
+@pytest.fixture
+def app_request(app, tm, dbsession):
+ """
+ A real request.
+
+ This request is almost identical to a real request but it has some
+ drawbacks in tests as it's harder to mock data and is heavier.
+
+ """
+ env = prepare(registry=app.registry)
+ request = env['request']
+ request.host = 'example.com'
+
+ # without this, request.dbsession will be joined to the same transaction
+ # manager but it will be using a different sqlalchemy.orm.Session using
+ # a separate database transaction
+ request.dbsession = dbsession
+ request.tm = tm
+
+ yield request
+ env['closer']()
+
+@pytest.fixture
+def dummy_request(app, tm, dbsession):
+ """
+ A lightweight dummy request.
+
+ This request is ultra-lightweight and should be used only when the
+ request itself is not a large focus in the call-stack.
+
+ It is way easier to mock and control side-effects using this object.
+
+ - It does not have request extensions applied.
+ - Threadlocals are not properly pushed.
+
+ """
+ request = DummyRequest()
+ request.registry = app.registry
+ request.host = 'example.com'
+ request.dbsession = dbsession
+ request.tm = tm
+
+ return request
diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_functional.py b/docs/tutorials/wiki2/src/authentication/tests/test_functional.py
new file mode 100644
index 000000000..dbcd8aec7
--- /dev/null
+++ b/docs/tutorials/wiki2/src/authentication/tests/test_functional.py
@@ -0,0 +1,13 @@
+from tutorial import models
+
+def test_my_view_success(testapp, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ res = testapp.get('/', status=200)
+ assert res.body
+
+def test_notfound(testapp):
+ res = testapp.get('/badurl', status=404)
+ assert res.status_code == 404
diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_it.py b/docs/tutorials/wiki2/src/authentication/tests/test_it.py
deleted file mode 100644
index ea16534fc..000000000
--- a/docs/tutorials/wiki2/src/authentication/tests/test_it.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import unittest
-
-from pyramid import testing
-
-import transaction
-
-
-def dummy_request(dbsession):
- return testing.DummyRequest(dbsession=dbsession)
-
-
-class BaseTest(unittest.TestCase):
- def setUp(self):
- self.config = testing.setUp(settings={
- 'sqlalchemy.url': 'sqlite:///:memory:'
- })
- self.config.include('tutorial.models')
- settings = self.config.get_settings()
-
- from tutorial.models import (
- get_engine,
- get_session_factory,
- get_tm_session,
- )
-
- self.engine = get_engine(settings)
- session_factory = get_session_factory(self.engine)
-
- self.session = get_tm_session(session_factory, transaction.manager)
-
- def init_database(self):
- from tutorial.models.meta import Base
- Base.metadata.create_all(self.engine)
-
- def tearDown(self):
- from tutorial.models.meta import Base
-
- testing.tearDown()
- transaction.abort()
- Base.metadata.drop_all(self.engine)
-
-
-class TestMyViewSuccessCondition(BaseTest):
-
- def setUp(self):
- super(TestMyViewSuccessCondition, self).setUp()
- self.init_database()
-
- from tutorial.models import MyModel
-
- model = MyModel(name='one', value=55)
- self.session.add(model)
-
- def test_passing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info['one'].name, 'one')
- self.assertEqual(info['project'], 'myproj')
-
-
-class TestMyViewFailureCondition(BaseTest):
-
- def test_failing_view(self):
- from tutorial.views.default import my_view
- info = my_view(dummy_request(self.session))
- self.assertEqual(info.status_int, 500)
diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_views.py b/docs/tutorials/wiki2/src/authentication/tests/test_views.py
new file mode 100644
index 000000000..8ae464d03
--- /dev/null
+++ b/docs/tutorials/wiki2/src/authentication/tests/test_views.py
@@ -0,0 +1,23 @@
+from tutorial import models
+from tutorial.views.default import my_view
+from tutorial.views.notfound import notfound_view
+
+
+def test_my_view_failure(app_request):
+ info = my_view(app_request)
+ assert info.status_int == 500
+
+def test_my_view_success(app_request, dbsession):
+ model = models.MyModel(name='one', value=55)
+ dbsession.add(model)
+ dbsession.flush()
+
+ info = my_view(app_request)
+ assert app_request.response.status_int == 200
+ assert info['one'].name == 'one'
+ assert info['project'] == 'myproj'
+
+def test_notfound_view(app_request):
+ info = notfound_view(app_request)
+ assert app_request.response.status_int == 404
+ assert info == {}
diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py
index ce2e9f12a..81a22c68c 100644
--- a/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py
+++ b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py
@@ -5,9 +5,9 @@ def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
with Configurator(settings=settings) as config:
- config.include('.models')
config.include('pyramid_jinja2')
- config.include('.routes')
config.include('.security')
+ config.include('.routes')
+ config.include('.models')
config.scan()
return config.make_wsgi_app()
diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py
index a4209a6e9..47d77ef01 100644
--- a/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py
+++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py
@@ -66,13 +66,21 @@ def includeme(config):
# use pyramid_retry to retry a request when transient exceptions occur
config.include('pyramid_retry')
- session_factory = get_session_factory(get_engine(settings))
+ # hook to share the dbengine fixture in testing
+ dbengine = settings.get('dbengine')
+ if not dbengine:
+ dbengine = get_engine(settings)
+
+ session_factory = get_session_factory(dbengine)
config.registry['dbsession_factory'] = session_factory
# make request.dbsession available for use in Pyramid
- config.add_request_method(
- # r.tm is the transaction manager used by pyramid_tm
- lambda r: get_tm_session(session_factory, r.tm),
- 'dbsession',
- reify=True
- )
+ def dbsession(request):
+ # hook to share the dbsession fixture in testing
+ dbsession = request.environ.get('app.dbsession')
+ if dbsession is None:
+ # request.tm is the transaction manager used by pyramid_tm
+ dbsession = get_tm_session(session_factory, request.tm)
+ return dbsession
+
+ config.add_request_method(dbsession, reify=True)
diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py
index e6350fb36..c8034e5a5 100644
--- a/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py
+++ b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py
@@ -8,6 +8,10 @@ from .. import models
def setup_models(dbsession):
+ """
+ Add or update models / fixtures in the database.
+
+ """
editor = models.User(name='editor', role='editor')
editor.set_password('editor')
dbsession.add(editor)
diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/security.py b/docs/tutorials/wiki2/src/authentication/tutorial/security.py
index 8ea3858d2..48149d6e5 100644
--- a/docs/tutorials/wiki2/src/authentication/tutorial/security.py
+++ b/docs/tutorials/wiki2/src/authentication/tutorial/security.py
@@ -1,27 +1,39 @@
-from pyramid.authentication import AuthTktAuthenticationPolicy
-from pyramid.authorization import ACLAuthorizationPolicy
+from pyramid.authentication import AuthTktCookieHelper
+from pyramid.csrf import CookieCSRFStoragePolicy
-from .models import User
+from . import models
-class MyAuthenticationPolicy(AuthTktAuthenticationPolicy):
+class MySecurityPolicy:
+ def __init__(self, secret):
+ self.authtkt = AuthTktCookieHelper(secret)
+
+ def authenticated_identity(self, request):
+ identity = self.authtkt.identify(request)
+ if identity is None:
+ return None
+
+ userid = identity['userid']
+ user = request.dbsession.query(models.User).get(userid)
+ return user
+
def authenticated_userid(self, request):
- user = request.user
+ user = self.authenticated_identity(request)
if user is not None:
return user.id
-def get_user(request):
- user_id = request.unauthenticated_userid
- if user_id is not None:
- user = request.dbsession.query(User).get(user_id)
- return user
+ def remember(self, request, userid, **kw):
+ return self.authtkt.remember(request, userid, **kw)
+
+ def forget(self, request, **kw):
+ return self.authtkt.forget(request, **kw)
def includeme(config):
settings = config.get_settings()
- authn_policy = MyAuthenticationPolicy(
- settings['auth.secret'],
- hashalg='sha512',
- )
- config.set_authentication_policy(authn_policy)
- config.set_authorization_policy(ACLAuthorizationPolicy())
- config.add_request_method(get_user, 'user', reify=True)
+
+ config.set_csrf_storage_policy(CookieCSRFStoragePolicy())
+ config.set_default_csrf_options(require_csrf=True)
+
+ config.set_security_policy(MySecurityPolicy(settings['auth.secret']))
+ config.add_request_method(
+ lambda request: request.authenticated_identity, 'user', property=True)
diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2
new file mode 100644
index 000000000..7a6f523bc
--- /dev/null
+++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2
@@ -0,0 +1,6 @@
+{% extends "layout.jinja2" %}
+
+{% block content %}
+