diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-06 22:31:40 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2020-01-06 22:58:26 -0600 |
| commit | c4626765913de97fb6410f0fdb50a4c93a38bd5b (patch) | |
| tree | 7d61935e7cc115ed3a9b9b2ee47df009efeeceae /docs | |
| parent | cd666082fbbd8b11d5cefd4a2d72209ae4f847be (diff) | |
| download | pyramid-c4626765913de97fb6410f0fdb50a4c93a38bd5b.tar.gz pyramid-c4626765913de97fb6410f0fdb50a4c93a38bd5b.tar.bz2 pyramid-c4626765913de97fb6410f0fdb50a4c93a38bd5b.zip | |
update authentication docs with security policy
Diffstat (limited to 'docs')
19 files changed, 428 insertions, 196 deletions
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 %} +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">403</span> Forbidden</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 index 4016b26c9..64a1db0c5 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 @@ -35,18 +35,29 @@ <div class="content"> {% if request.user is none %} <p class="pull-right"> - <a href="{{ request.route_url('login') }}">Login</a> + <a href="{{ request.route_url('login') }}">Login</a> </p> {% else %} - <p class="pull-right"> - {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> - </p> + <form class="pull-right" action="{{ request.route_url('logout') }}" method="post"> + {{request.user.name}} + <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> + <button class="btn btn-link" type="submit">Logout</button> + </form> {% endif %} {% block content %}{% endblock %} </div> </div> </div> <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> + <div class="row"> <div class="copyright"> Copyright © Pylons Project </div> diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 index 1806de0ff..058b7254b 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 @@ -10,6 +10,7 @@ {{ message }} </p> <form action="{{ url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="next" value="{{ next_url }}"> <div class="form-group"> <label for="login">Username</label> @@ -20,7 +21,7 @@ <input type="password" name="password"> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> + <button type="submit" class="btn btn-default">Log In</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py index 16fa616e5..e1a564415 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py @@ -1,14 +1,15 @@ -from pyramid.httpexceptions import HTTPFound +from pyramid.csrf import new_csrf_token +from pyramid.httpexceptions import HTTPSeeOther from pyramid.security import ( remember, forget, - ) +) from pyramid.view import ( forbidden_view_config, view_config, ) -from ..models import User +from .. import models @view_config(route_name='login', renderer='tutorial:templates/login.jinja2') @@ -18,29 +19,43 @@ def login(request): next_url = request.route_url('view_wiki') message = '' login = '' - if 'form.submitted' in request.params: + if request.method == 'POST': login = request.params['login'] password = request.params['password'] - user = request.dbsession.query(User).filter_by(name=login).first() + user = ( + request.dbsession.query(models.User) + .filter_by(name=login) + .first() + ) if user is not None and user.check_password(password): + new_csrf_token(request) headers = remember(request, user.id) - return HTTPFound(location=next_url, headers=headers) + return HTTPSeeOther(location=next_url, headers=headers) message = 'Failed login' + request.response.status = 400 return dict( message=message, url=request.route_url('login'), next_url=next_url, login=login, - ) + ) @view_config(route_name='logout') def logout(request): - headers = forget(request) next_url = request.route_url('view_wiki') - return HTTPFound(location=next_url, headers=headers) + if request.method == 'POST': + new_csrf_token(request) + headers = forget(request) + return HTTPSeeOther(location=next_url, headers=headers) + + return HTTPSeeOther(location=next_url) + +@forbidden_view_config(renderer='tutorial:templates/403.jinja2') +def forbidden_view(exc, request): + if request.user is None: + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPSeeOther(location=next_url) -@forbidden_view_config() -def forbidden_view(request): - next_url = request.route_url('login', _query={'next': request.url}) - return HTTPFound(location=next_url) + request.response.status = 403 + return {} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py index d1c429950..ebb49ef49 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py @@ -1,24 +1,23 @@ from html import escape -import re from docutils.core import publish_parts - from pyramid.httpexceptions import ( HTTPForbidden, - HTTPFound, HTTPNotFound, - ) - + HTTPSeeOther, +) from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2') def view_page(request): @@ -49,15 +48,15 @@ def edit_page(request): user = request.user if user is None or (user.role != 'editor' and page.creator != user): raise HTTPForbidden - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2') def add_page(request): @@ -67,13 +66,13 @@ def add_page(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - return HTTPFound(location=next_url) - if 'form.submitted' in request.params: + return HTTPSeeOther(location=next_url) + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = request.user request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) |
