diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 8ee6ff4..204864e 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -19,6 +19,7 @@
subscribers
util
views
+ views.auth
views.base
views.common
views.essential
diff --git a/docs/api/wuttaweb/views.auth.rst b/docs/api/wuttaweb/views.auth.rst
new file mode 100644
index 0000000..9a03e3e
--- /dev/null
+++ b/docs/api/wuttaweb/views.auth.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.auth``
+=======================
+
+.. automodule:: wuttaweb.views.auth
+ :members:
diff --git a/src/wuttaweb/subscribers.py b/src/wuttaweb/subscribers.py
index eea26d2..eebefb4 100644
--- a/src/wuttaweb/subscribers.py
+++ b/src/wuttaweb/subscribers.py
@@ -41,6 +41,7 @@ import logging
from pyramid import threadlocal
from wuttaweb import helpers
+from wuttaweb.db import Session
log = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ log = logging.getLogger(__name__)
def new_request(event):
"""
- Event hook called when processing a new request.
+ Event hook called when processing a new :term:`request`.
The hook is auto-registered if this module is "included" by
Pyramid config object. Or you can explicitly register it::
@@ -56,7 +57,7 @@ def new_request(event):
pyramid_config.add_subscriber('wuttaweb.subscribers.new_request',
'pyramid.events.NewRequest')
- This will add some things to the request object:
+ This will add to the request object:
.. attribute:: request.wutta_config
@@ -66,7 +67,7 @@ def new_request(event):
Flag indicating whether the frontend should be displayed using
Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if
- ``False``).
+ ``False``). This flag is ``False`` by default.
"""
request = event.request
config = request.registry.settings['wutta_config']
@@ -84,6 +85,42 @@ def new_request(event):
request.set_property(use_oruga, reify=True)
+def new_request_set_user(event, db_session=None):
+ """
+ Event hook called when processing a new :term:`request`, for sake
+ of setting the ``request.user`` property.
+
+ The hook is auto-registered if this module is "included" by
+ Pyramid config object. Or you can explicitly register it::
+
+ pyramid_config.add_subscriber('wuttaweb.subscribers.new_request_set_user',
+ 'pyramid.events.NewRequest')
+
+ This will add to the request object:
+
+ .. attribute:: request.user
+
+ Reference to the authenticated
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` instance
+ (if logged in), or ``None``.
+
+ :param db_session: Optional :term:`db session` to use, instead of
+ :class:`wuttaweb.db.Session`. Probably only useful for tests.
+ """
+ request = event.request
+ config = request.registry.settings['wutta_config']
+ app = config.get_app()
+ model = app.model
+
+ def user(request):
+ uuid = request.authenticated_userid
+ if uuid:
+ session = db_session or Session()
+ return session.get(model.User, uuid)
+
+ request.set_property(user, reify=True)
+
+
def before_render(event):
"""
Event hook called just before rendering a template.
@@ -151,4 +188,5 @@ def before_render(event):
def includeme(config):
config.add_subscriber(new_request, 'pyramid.events.NewRequest')
+ config.add_subscriber(new_request_set_user, 'pyramid.events.NewRequest')
config.add_subscriber(before_render, 'pyramid.events.BeforeRender')
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index 5511195..f6b4206 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -309,7 +309,18 @@
%def>
-<%def name="render_user_menu()">%def>
+<%def name="render_user_menu()">
+ % if request.user:
+
+
${request.user}
+
+ ${h.link_to("Logout", url('logout'), class_='navbar-item')}
+
+
+ % else:
+ ${h.link_to("Login", url('login'), class_='navbar-item')}
+ % endif
+%def>
<%def name="render_instance_header_title_extras()">%def>
diff --git a/src/wuttaweb/templates/login.mako b/src/wuttaweb/templates/login.mako
new file mode 100644
index 0000000..b50a863
--- /dev/null
+++ b/src/wuttaweb/templates/login.mako
@@ -0,0 +1,46 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/form.mako" />
+
+<%def name="title()">Login%def>
+
+<%def name="render_this_page()">
+ ${self.page_content()}
+%def>
+
+<%def name="page_content()">
+
+
+
+ ${form.render_vue_tag()}
+
+
+
+%def>
+
+<%def name="modify_this_page_vars()">
+
+%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py
new file mode 100644
index 0000000..981afbd
--- /dev/null
+++ b/src/wuttaweb/views/auth.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Auth Views
+"""
+
+import colander
+from deform.widget import TextInputWidget, PasswordWidget
+
+from wuttaweb.views import View
+from wuttaweb.db import Session
+from wuttaweb.auth import login_user, logout_user
+
+
+class AuthView(View):
+ """
+ Auth views shared by all apps.
+ """
+
+ def login(self, session=None):
+ """
+ View for user login.
+
+ This view shows the login form, and handles its submission.
+ Upon successful login, user is redirected to home page.
+
+ * route: ``login``
+ * template: ``/login.mako``
+ """
+ auth = self.app.get_auth_handler()
+
+ # TODO: should call request.get_referrer()
+ referrer = self.request.route_url('home')
+
+ # redirect if already logged in
+ if self.request.user:
+ self.request.session.flash(f"{self.request.user} is already logged in", 'error')
+ return self.redirect(referrer)
+
+ form = self.make_form(schema=self.login_make_schema(),
+ align_buttons_right=True,
+ show_button_reset=True,
+ button_label_submit="Login",
+ button_icon_submit='user')
+
+ # TODO
+ # form.show_cancel = False
+
+ # validate basic form data (sanity check)
+ data = form.validate()
+ if data:
+
+ # truly validate user credentials
+ session = session or Session()
+ user = auth.authenticate_user(session, data['username'], data['password'])
+ if user:
+
+ # okay now they're truly logged in
+ headers = login_user(self.request, user)
+ return self.redirect(referrer, headers=headers)
+
+ else:
+ self.request.session.flash("Invalid user credentials", 'error')
+
+ return {
+ 'index_title': self.app.get_title(),
+ 'form': form,
+ # TODO
+ # 'referrer': referrer,
+ }
+
+ def login_make_schema(self):
+ schema = colander.Schema()
+
+ # nb. we must explicitly declare the widgets in order to also
+ # specify the ref attribute. this is needed for autofocus and
+ # keydown behavior for login form.
+
+ schema.add(colander.SchemaNode(
+ colander.String(),
+ name='username',
+ widget=TextInputWidget(attributes={
+ 'ref': 'username',
+ })))
+
+ schema.add(colander.SchemaNode(
+ colander.String(),
+ name='password',
+ widget=PasswordWidget(attributes={
+ 'ref': 'password',
+ })))
+
+ return schema
+
+ def logout(self):
+ """
+ View for user logout.
+
+ This deletes/invalidates the current user session and then
+ redirects to the login page.
+
+ Note that a simple GET is sufficient; POST is not required.
+
+ * route: ``logout``
+ * template: n/a
+ """
+ # truly logout the user
+ headers = logout_user(self.request)
+
+ # TODO
+ # # redirect to home page after logout, if so configured
+ # if self.config.get_bool('wuttaweb.home_after_logout', default=False):
+ # return self.redirect(self.request.route_url('home'), headers=headers)
+
+ # otherwise redirect to referrer, with 'login' page as fallback
+ # TODO: should call request.get_referrer()
+ # referrer = self.request.get_referrer(default=self.request.route_url('login'))
+ referrer = self.request.route_url('login')
+ return self.redirect(referrer, headers=headers)
+
+ @classmethod
+ def defaults(cls, config):
+ cls._auth_defaults(config)
+
+ @classmethod
+ def _auth_defaults(cls, config):
+
+ # login
+ config.add_route('login', '/login')
+ config.add_view(cls, attr='login',
+ route_name='login',
+ renderer='/login.mako')
+
+ # logout
+ config.add_route('logout', '/logout')
+ config.add_view(cls, attr='logout',
+ route_name='logout')
+
+
+def defaults(config, **kwargs):
+ base = globals()
+
+ AuthView = kwargs.get('AuthView', base['AuthView'])
+ AuthView.defaults(config)
+
+
+def includeme(config):
+ defaults(config)
diff --git a/src/wuttaweb/views/essential.py b/src/wuttaweb/views/essential.py
index a9272f4..93c8149 100644
--- a/src/wuttaweb/views/essential.py
+++ b/src/wuttaweb/views/essential.py
@@ -29,6 +29,7 @@ Most apps should include this module::
That will in turn include the following modules:
+* :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common`
"""
@@ -36,6 +37,7 @@ That will in turn include the following modules:
def defaults(config, **kwargs):
mod = lambda spec: kwargs.get(spec, spec)
+ config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common'))
diff --git a/tests/test_subscribers.py b/tests/test_subscribers.py
index 804eb1a..63b6640 100644
--- a/tests/test_subscribers.py
+++ b/tests/test_subscribers.py
@@ -7,9 +7,11 @@ from unittest.mock import MagicMock
from wuttjamaican.conf import WuttaConfig
from pyramid import testing
+from pyramid.security import remember
from wuttaweb import subscribers
from wuttaweb import helpers
+from wuttaweb.auth import WuttaSecurityPolicy
class TestNewRequest(TestCase):
@@ -56,6 +58,46 @@ def custom_oruga_detector(request):
return True
+class TestNewRequestSetUser(TestCase):
+
+ def setUp(self):
+ self.config = WuttaConfig(defaults={
+ 'wutta.db.default.url': 'sqlite://',
+ })
+
+ self.request = testing.DummyRequest()
+ self.pyramid_config = testing.setUp(request=self.request, settings={
+ 'wutta_config': self.config,
+ })
+
+ self.app = self.config.get_app()
+ model = self.app.model
+ model.Base.metadata.create_all(bind=self.config.appdb_engine)
+ self.session = self.app.make_session()
+ self.user = model.User(username='barney')
+ self.session.add(self.user)
+ self.session.commit()
+
+ self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
+
+ def tearDown(self):
+ testing.tearDown()
+
+ def test_anonymous(self):
+ self.assertFalse(hasattr(self.request, 'user'))
+ event = MagicMock(request=self.request)
+ subscribers.new_request_set_user(event)
+ self.assertIsNone(self.request.user)
+
+ def test_authenticated(self):
+ uuid = self.user.uuid
+ self.assertIsNotNone(uuid)
+ remember(self.request, uuid)
+ event = MagicMock(request=self.request)
+ subscribers.new_request_set_user(event, db_session=self.session)
+ self.assertIs(self.request.user, self.user)
+
+
class TestBeforeRender(TestCase):
def setUp(self):
diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py
new file mode 100644
index 0000000..495dac1
--- /dev/null
+++ b/tests/views/test_auth.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import MagicMock
+
+from pyramid import testing
+from pyramid.httpexceptions import HTTPFound
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb.views import auth as mod
+from wuttaweb.auth import WuttaSecurityPolicy
+
+
+class TestAuthView(TestCase):
+
+ def setUp(self):
+ self.config = WuttaConfig(defaults={
+ 'wutta.db.default.url': 'sqlite://',
+ })
+
+ self.request = testing.DummyRequest(wutta_config=self.config, user=None)
+ self.pyramid_config = testing.setUp(request=self.request)
+
+ self.app = self.config.get_app()
+ auth = self.app.get_auth_handler()
+ model = self.app.model
+ model.Base.metadata.create_all(bind=self.config.appdb_engine)
+ self.session = self.app.make_session()
+ self.user = model.User(username='barney')
+ self.session.add(self.user)
+ auth.set_user_password(self.user, 'testpass')
+ self.session.commit()
+
+ self.pyramid_config.set_security_policy(WuttaSecurityPolicy(db_session=self.session))
+ self.pyramid_config.include('wuttaweb.views.auth')
+ self.pyramid_config.include('wuttaweb.views.common')
+
+ def tearDown(self):
+ testing.tearDown()
+
+ def test_login(self):
+ view = mod.AuthView(self.request)
+ context = view.login()
+ self.assertIn('form', context)
+
+ # redirect if user already logged in
+ self.request.user = self.user
+ view = mod.AuthView(self.request)
+ redirect = view.login(session=self.session)
+ self.assertIsInstance(redirect, HTTPFound)
+
+ # login fails w/ wrong password
+ self.request.user = None
+ self.request.method = 'POST'
+ self.request.POST = {'username': 'barney', 'password': 'WRONG'}
+ view = mod.AuthView(self.request)
+ context = view.login(session=self.session)
+ self.assertIn('form', context)
+
+ # redirect if login succeeds
+ self.request.method = 'POST'
+ self.request.POST = {'username': 'barney', 'password': 'testpass'}
+ view = mod.AuthView(self.request)
+ redirect = view.login(session=self.session)
+ self.assertIsInstance(redirect, HTTPFound)
+
+ def test_logout(self):
+ view = mod.AuthView(self.request)
+ self.request.session.delete = MagicMock()
+ redirect = view.logout()
+ self.request.session.delete.assert_called_once_with()
+ self.assertIsInstance(redirect, HTTPFound)