From 675b51cac2219e1c890256f2e851e4ec9f87aaa6 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 14 Aug 2024 18:29:08 -0500 Subject: [PATCH] feat: add first-time setup page to create admin user --- src/wuttaweb/templates/setup.mako | 20 ++++++ src/wuttaweb/views/auth.py | 11 +++- src/wuttaweb/views/common.py | 90 +++++++++++++++++++++++++- src/wuttaweb/views/roles.py | 4 +- tests/views/test_auth.py | 101 ++++++++++++++---------------- tests/views/test_common.py | 94 +++++++++++++++++++++------ 6 files changed, 241 insertions(+), 79 deletions(-) create mode 100644 src/wuttaweb/templates/setup.mako diff --git a/src/wuttaweb/templates/setup.mako b/src/wuttaweb/templates/setup.mako new file mode 100644 index 0000000..9728d9f --- /dev/null +++ b/src/wuttaweb/templates/setup.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + +<%def name="title()">First-Time Setup + +<%def name="page_content()"> + +

+ The app is running okay! +

+

+ Please setup the first Administrator account below. +

+
+ + ${parent.page_content()} + + + +${parent.body()} diff --git a/src/wuttaweb/views/auth.py b/src/wuttaweb/views/auth.py index 5acd697..abb52ea 100644 --- a/src/wuttaweb/views/auth.py +++ b/src/wuttaweb/views/auth.py @@ -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: diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py index b5c108e..8b9aaf8 100644 --- a/src/wuttaweb/views/common.py +++ b/src/wuttaweb/views/common.py @@ -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() diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py index 327f4fa..b31b049 100644 --- a/src/wuttaweb/views/roles.py +++ b/src/wuttaweb/views/roles.py @@ -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 diff --git a/tests/views/test_auth.py b/tests/views/test_auth.py index d10e759..4fd5413 100644 --- a/tests/views/test_auth.py +++ b/tests/views/test_auth.py @@ -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 diff --git a/tests/views/test_common.py b/tests/views/test_common.py index 2b1607b..8038245 100644 --- a/tests/views/test_common.py +++ b/tests/views/test_common.py @@ -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")