1
0
Fork 0

feat: add auth views, for login/logout

This commit is contained in:
Lance Edgar 2024-08-04 23:09:29 -05:00
parent e296b50aa4
commit a505ef27fb
9 changed files with 390 additions and 4 deletions

View file

@ -19,6 +19,7 @@
subscribers subscribers
util util
views views
views.auth
views.base views.base
views.common views.common
views.essential views.essential

View file

@ -0,0 +1,6 @@
``wuttaweb.views.auth``
=======================
.. automodule:: wuttaweb.views.auth
:members:

View file

@ -41,6 +41,7 @@ import logging
from pyramid import threadlocal from pyramid import threadlocal
from wuttaweb import helpers from wuttaweb import helpers
from wuttaweb.db import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -48,7 +49,7 @@ log = logging.getLogger(__name__)
def new_request(event): 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 The hook is auto-registered if this module is "included" by
Pyramid config object. Or you can explicitly register it:: 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_config.add_subscriber('wuttaweb.subscribers.new_request',
'pyramid.events.NewRequest') 'pyramid.events.NewRequest')
This will add some things to the request object: This will add to the request object:
.. attribute:: request.wutta_config .. attribute:: request.wutta_config
@ -66,7 +67,7 @@ def new_request(event):
Flag indicating whether the frontend should be displayed using Flag indicating whether the frontend should be displayed using
Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if Vue 3 + Oruga (if ``True``), or else Vue 2 + Buefy (if
``False``). ``False``). This flag is ``False`` by default.
""" """
request = event.request request = event.request
config = request.registry.settings['wutta_config'] config = request.registry.settings['wutta_config']
@ -84,6 +85,42 @@ def new_request(event):
request.set_property(use_oruga, reify=True) 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): def before_render(event):
""" """
Event hook called just before rendering a template. Event hook called just before rendering a template.
@ -151,4 +188,5 @@ def before_render(event):
def includeme(config): def includeme(config):
config.add_subscriber(new_request, 'pyramid.events.NewRequest') 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') config.add_subscriber(before_render, 'pyramid.events.BeforeRender')

View file

@ -309,7 +309,18 @@
</div> </div>
</%def> </%def>
<%def name="render_user_menu()"></%def> <%def name="render_user_menu()">
% if request.user:
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">${request.user}</a>
<div class="navbar-dropdown">
${h.link_to("Logout", url('logout'), class_='navbar-item')}
</div>
</div>
% else:
${h.link_to("Login", url('login'), class_='navbar-item')}
% endif
</%def>
<%def name="render_instance_header_title_extras()"></%def> <%def name="render_instance_header_title_extras()"></%def>

View file

@ -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()">
<div style="height: 100%; display: flex; align-items: center; justify-content: center;">
<div class="card">
<div class="card-content">
${form.render_vue_tag()}
</div>
</div>
</div>
</%def>
<%def name="modify_this_page_vars()">
<script>
${form.vue_component}Data.usernameInput = null
${form.vue_component}.mounted = function() {
this.$refs.username.focus()
this.usernameInput = this.$refs.username.$el.querySelector('input')
this.usernameInput.addEventListener('keydown', this.usernameKeydown)
}
${form.vue_component}.beforeDestroy = function() {
this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
}
${form.vue_component}.methods.usernameKeydown = function(event) {
if (event.which == 13) { // ENTER
event.preventDefault()
this.$refs.password.focus()
}
}
</script>
</%def>
${parent.body()}

168
src/wuttaweb/views/auth.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -29,6 +29,7 @@ Most apps should include this module::
That will in turn include the following modules: That will in turn include the following modules:
* :mod:`wuttaweb.views.auth`
* :mod:`wuttaweb.views.common` * :mod:`wuttaweb.views.common`
""" """
@ -36,6 +37,7 @@ That will in turn include the following modules:
def defaults(config, **kwargs): def defaults(config, **kwargs):
mod = lambda spec: kwargs.get(spec, spec) mod = lambda spec: kwargs.get(spec, spec)
config.include(mod('wuttaweb.views.auth'))
config.include(mod('wuttaweb.views.common')) config.include(mod('wuttaweb.views.common'))

View file

@ -7,9 +7,11 @@ from unittest.mock import MagicMock
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from pyramid import testing from pyramid import testing
from pyramid.security import remember
from wuttaweb import subscribers from wuttaweb import subscribers
from wuttaweb import helpers from wuttaweb import helpers
from wuttaweb.auth import WuttaSecurityPolicy
class TestNewRequest(TestCase): class TestNewRequest(TestCase):
@ -56,6 +58,46 @@ def custom_oruga_detector(request):
return True 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): class TestBeforeRender(TestCase):
def setUp(self): def setUp(self):

72
tests/views/test_auth.py Normal file
View file

@ -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)