feat: add auth views, for login/logout
This commit is contained in:
parent
e296b50aa4
commit
a505ef27fb
|
@ -19,6 +19,7 @@
|
|||
subscribers
|
||||
util
|
||||
views
|
||||
views.auth
|
||||
views.base
|
||||
views.common
|
||||
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 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')
|
||||
|
|
|
@ -309,7 +309,18 @@
|
|||
</div>
|
||||
</%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>
|
||||
|
||||
|
|
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:
|
||||
|
||||
* :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'))
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
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