1
0
Fork 0

feat: add first-time setup page to create admin user

This commit is contained in:
Lance Edgar 2024-08-14 18:29:08 -05:00
parent bc49392140
commit 675b51cac2
6 changed files with 241 additions and 79 deletions

View file

@ -0,0 +1,20 @@
## -*- coding: utf-8; -*-
<%inherit file="/form.mako" />
<%def name="title()">First-Time Setup</%def>
<%def name="page_content()">
<b-notification type="is-success">
<p class="block">
The app is running okay!
</p>
<p class="block">
Please setup the first Administrator account below.
</p>
</b-notification>
${parent.page_content()}
</%def>
${parent.body()}

View file

@ -47,10 +47,16 @@ class AuthView(View):
* route: ``login``
* template: ``/auth/login.mako``
"""
model = self.app.model
session = session or Session()
auth = self.app.get_auth_handler()
# TODO: should call request.get_referrer()
referrer = self.request.route_url('home')
# nb. redirect to /setup if no users exist
user = session.query(model.User).first()
if not user:
return self.redirect(self.request.route_url('setup'))
referrer = self.request.get_referrer()
# redirect if already logged in
if self.request.user:
@ -69,7 +75,6 @@ class AuthView(View):
if data:
# truly validate user credentials
session = session or Session()
user = auth.authenticate_user(session, data['username'], data['password'])
if user:

View file

@ -24,7 +24,11 @@
Common Views
"""
import colander
from wuttaweb.views import View
from wuttaweb.forms import widgets
from wuttaweb.db import Session
class CommonView(View):
@ -32,7 +36,7 @@ class CommonView(View):
Common views shared by all apps.
"""
def home(self):
def home(self, session=None):
"""
Home page view.
@ -40,12 +44,88 @@ class CommonView(View):
This is normally the view shown when a user navigates to the
root URL for the web app.
"""
model = self.app.model
session = session or Session()
# nb. redirect to /setup if no users exist
user = session.query(model.User).first()
if not user:
return self.redirect(self.request.route_url('setup'))
return {
'index_title': self.app.get_title(),
}
def setup(self, session=None):
"""
View for first-time app setup, to create admin user.
Template: ``/setup.mako``
This page is only meant for one-time use. As such, if the app
DB contains any users, this page will always redirect to the
home page.
However if no users exist yet, this will show a form which may
be used to create the first admin user. When finished, user
will be redirected to the login page.
.. note::
As long as there are no users in the DB, both the home and
login pages will automatically redirect to this one.
"""
model = self.app.model
session = session or Session()
# nb. this view only available until first user is created
user = session.query(model.User).first()
if user:
return self.redirect(self.request.route_url('home'))
form = self.make_form(fields=['username', 'password', 'first_name', 'last_name'],
show_button_cancel=False,
show_button_reset=True)
form.set_widget('password', widgets.CheckedPasswordWidget())
form.set_required('first_name', False)
form.set_required('last_name', False)
if form.validate():
auth = self.app.get_auth_handler()
data = form.validated
# make user
user = auth.make_user(session=session, username=data['username'])
auth.set_user_password(user, data['password'])
# assign admin role
admin = auth.get_role_administrator(session)
user.roles.append(admin)
# ensure all built-in roles exist
auth.get_role_authenticated(session)
auth.get_role_anonymous(session)
# maybe make person
if data['first_name'] or data['last_name']:
first = data['first_name']
last = data['last_name']
person = model.Person(first_name=first,
last_name=last,
full_name=(f"{first} {last}").strip())
session.add(person)
user.person = person
# send user to /login
self.request.session.flash("Account created! Please login below.")
return self.redirect(self.request.route_url('login'))
return {
'index_title': self.app.get_title(),
'form': form,
}
@classmethod
def defaults(cls, config):
cls._defaults(config)
@ -62,6 +142,12 @@ class CommonView(View):
route_name='home',
renderer='/home.mako')
# setup
config.add_route('setup', '/setup')
config.add_view(cls, attr='setup',
route_name='setup',
renderer='/setup.mako')
def defaults(config, **kwargs):
base = globals()

View file

@ -75,12 +75,12 @@ class RoleView(MasterView):
auth = self.app.get_auth_handler()
# prevent delete for built-in roles
if role is auth.get_role_administrator(session):
return False
if role is auth.get_role_authenticated(session):
return False
if role is auth.get_role_anonymous(session):
return False
if role is auth.get_role_administrator(session):
return False
return True

View file

@ -1,90 +1,88 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from pyramid import testing
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import auth as mod
from wuttaweb.auth import WuttaSecurityPolicy
from wuttaweb.subscribers import new_request
from tests.util import WebTestCase
class TestAuthView(TestCase):
class TestAuthView(WebTestCase):
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, settings={
'wutta_config': self.config,
})
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.setup_web()
self.pyramid_config.include('wuttaweb.views.common')
def tearDown(self):
testing.tearDown()
def make_view(self):
return mod.AuthView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.auth')
def test_login(self):
view = mod.AuthView(self.request)
context = view.login()
model = self.app.model
auth = self.app.get_auth_handler()
view = self.make_view()
# until user exists, will redirect
self.assertEqual(self.session.query(model.User).count(), 0)
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
# make a user
barney = model.User(username='barney')
auth.set_user_password(barney, 'testpass')
self.session.add(barney)
self.session.commit()
# now since user exists, form will display
context = view.login(session=self.session)
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)
with patch.object(self.request, 'user', new=barney):
view = self.make_view()
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
# 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)
view = self.make_view()
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)
view = self.make_view()
response = view.login(session=self.session)
self.assertEqual(response.status_code, 302)
def test_logout(self):
view = mod.AuthView(self.request)
self.pyramid_config.add_route('login', '/login')
view = self.make_view()
self.request.session.delete = MagicMock()
redirect = view.logout()
response = view.logout()
self.request.session.delete.assert_called_once_with()
self.assertIsInstance(redirect, HTTPFound)
self.assertEqual(response.status_code, 302)
def test_change_password(self):
view = mod.AuthView(self.request)
model = self.app.model
auth = self.app.get_auth_handler()
barney = model.User(username='barney')
self.session.add(barney)
self.session.commit()
view = self.make_view()
# unauthenticated user is redirected
redirect = view.change_password()
self.assertIsInstance(redirect, HTTPFound)
# now "login" the user, and set initial password
self.request.user = self.user
auth.set_user_password(self.user, 'foo')
self.request.user = barney
auth.set_user_password(barney, 'foo')
self.session.commit()
# view should now return context w/ form
@ -105,9 +103,8 @@ class TestAuthView(TestCase):
redirect = view.change_password()
self.assertIsInstance(redirect, HTTPFound)
self.session.commit()
self.session.refresh(self.user)
self.assertFalse(auth.check_user_password(self.user, 'foo'))
self.assertTrue(auth.check_user_password(self.user, 'bar'))
self.assertFalse(auth.check_user_password(barney, 'foo'))
self.assertTrue(auth.check_user_password(barney, 'bar'))
# at this point 'foo' is the password, now let's submit some
# invalid forms and make sure we get back a context w/ form
@ -147,8 +144,6 @@ class TestAuthView(TestCase):
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
def test_become_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed
@ -168,8 +163,6 @@ class TestAuthView(TestCase):
self.assertTrue(self.request.session['is_root'])
def test_stop_root(self):
event = MagicMock(request=self.request)
new_request(event) # add request.get_referrer()
view = mod.AuthView(self.request)
# GET not allowed

View file

@ -1,27 +1,85 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import common
from wuttaweb.views import common as mod
from tests.util import WebTestCase
class TestCommonView(TestCase):
class TestCommonView(WebTestCase):
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
self.request = testing.DummyRequest()
self.request.wutta_config = self.config
self.pyramid_config = testing.setUp(request=self.request)
def make_view(self):
return mod.CommonView(self.request)
def test_includeme(self):
self.pyramid_config.include('wuttaweb.views.common')
def tearDown(self):
testing.tearDown()
def test_home(self):
view = common.CommonView(self.request)
context = view.home()
self.pyramid_config.add_route('setup', '/setup')
model = self.app.model
view = self.make_view()
# if no users then home page will redirect
response = view.home(session=self.session)
self.assertEqual(response.status_code, 302)
# so add a user
user = model.User(username='foo')
self.session.add(user)
self.session.commit()
# now we see the home page
context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
def test_setup(self):
self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_route('login', '/login')
model = self.app.model
auth = self.app.get_auth_handler()
view = self.make_view()
# at first, can see the setup page
self.assertEqual(self.session.query(model.User).count(), 0)
context = view.setup(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
# so add a user
user = model.User(username='foo')
self.session.add(user)
self.session.commit()
# once user exists it will always redirect
response = view.setup(session=self.session)
self.assertEqual(response.status_code, 302)
# delete that user
self.session.delete(user)
self.session.commit()
# so we can see the setup page again
context = view.setup(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title())
# and finally, post data to create admin user
self.request.method = 'POST'
self.request.POST = {
'username': 'barney',
'__start__': 'password:mapping',
'password': 'testpass',
'password-confirm': 'testpass',
'__end__': 'password:mapping',
'first_name': "Barney",
'last_name': "Rubble",
}
response = view.setup(session=self.session)
# nb. redirects on success
self.assertEqual(response.status_code, 302)
barney = self.session.query(model.User).one()
self.assertEqual(barney.username, 'barney')
self.assertTrue(auth.check_user_password(barney, 'testpass'))
admin = auth.get_role_administrator(self.session)
self.assertIn(admin, barney.roles)
self.assertIsNotNone(barney.person)
person = barney.person
self.assertEqual(person.first_name, "Barney")
self.assertEqual(person.last_name, "Rubble")
self.assertEqual(person.full_name, "Barney Rubble")