From a505ef27fb908459242c56e232958a976dee4b65 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 4 Aug 2024 23:09:29 -0500 Subject: [PATCH] feat: add auth views, for login/logout --- docs/api/wuttaweb/index.rst | 1 + docs/api/wuttaweb/views.auth.rst | 6 ++ src/wuttaweb/subscribers.py | 44 +++++++- src/wuttaweb/templates/base.mako | 13 ++- src/wuttaweb/templates/login.mako | 46 ++++++++ src/wuttaweb/views/auth.py | 168 ++++++++++++++++++++++++++++++ src/wuttaweb/views/essential.py | 2 + tests/test_subscribers.py | 42 ++++++++ tests/views/test_auth.py | 72 +++++++++++++ 9 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 docs/api/wuttaweb/views.auth.rst create mode 100644 src/wuttaweb/templates/login.mako create mode 100644 src/wuttaweb/views/auth.py create mode 100644 tests/views/test_auth.py 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 name="render_user_menu()"> +<%def name="render_user_menu()"> + % if request.user: + + % else: + ${h.link_to("Login", url('login'), class_='navbar-item')} + % endif + <%def name="render_instance_header_title_extras()"> 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 name="render_this_page()"> + ${self.page_content()} + + +<%def name="page_content()"> +
+
+
+ ${form.render_vue_tag()} +
+
+
+ + +<%def name="modify_this_page_vars()"> + + + + +${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)