feat: add auth views, for login/logout
This commit is contained in:
parent
e296b50aa4
commit
a505ef27fb
|
@ -19,6 +19,7 @@
|
||||||
subscribers
|
subscribers
|
||||||
util
|
util
|
||||||
views
|
views
|
||||||
|
views.auth
|
||||||
views.base
|
views.base
|
||||||
views.common
|
views.common
|
||||||
views.essential
|
views.essential
|
||||||
|
|
6
docs/api/wuttaweb/views.auth.rst
Normal file
6
docs/api/wuttaweb/views.auth.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.views.auth``
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.views.auth
|
||||||
|
:members:
|
|
@ -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')
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
46
src/wuttaweb/templates/login.mako
Normal file
46
src/wuttaweb/templates/login.mako
Normal 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
168
src/wuttaweb/views/auth.py
Normal 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)
|
|
@ -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'))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
72
tests/views/test_auth.py
Normal 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)
|
Loading…
Reference in a new issue