feat: add first-time setup page to create admin user
This commit is contained in:
parent
bc49392140
commit
675b51cac2
20
src/wuttaweb/templates/setup.mako
Normal file
20
src/wuttaweb/templates/setup.mako
Normal 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()}
|
|
@ -47,10 +47,16 @@ class AuthView(View):
|
||||||
* route: ``login``
|
* route: ``login``
|
||||||
* template: ``/auth/login.mako``
|
* template: ``/auth/login.mako``
|
||||||
"""
|
"""
|
||||||
|
model = self.app.model
|
||||||
|
session = session or Session()
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
# TODO: should call request.get_referrer()
|
# nb. redirect to /setup if no users exist
|
||||||
referrer = self.request.route_url('home')
|
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
|
# redirect if already logged in
|
||||||
if self.request.user:
|
if self.request.user:
|
||||||
|
@ -69,7 +75,6 @@ class AuthView(View):
|
||||||
if data:
|
if data:
|
||||||
|
|
||||||
# truly validate user credentials
|
# truly validate user credentials
|
||||||
session = session or Session()
|
|
||||||
user = auth.authenticate_user(session, data['username'], data['password'])
|
user = auth.authenticate_user(session, data['username'], data['password'])
|
||||||
if user:
|
if user:
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,11 @@
|
||||||
Common Views
|
Common Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import colander
|
||||||
|
|
||||||
from wuttaweb.views import View
|
from wuttaweb.views import View
|
||||||
|
from wuttaweb.forms import widgets
|
||||||
|
from wuttaweb.db import Session
|
||||||
|
|
||||||
|
|
||||||
class CommonView(View):
|
class CommonView(View):
|
||||||
|
@ -32,7 +36,7 @@ class CommonView(View):
|
||||||
Common views shared by all apps.
|
Common views shared by all apps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def home(self):
|
def home(self, session=None):
|
||||||
"""
|
"""
|
||||||
Home page view.
|
Home page view.
|
||||||
|
|
||||||
|
@ -40,12 +44,88 @@ class CommonView(View):
|
||||||
|
|
||||||
This is normally the view shown when a user navigates to the
|
This is normally the view shown when a user navigates to the
|
||||||
root URL for the web app.
|
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 {
|
return {
|
||||||
'index_title': self.app.get_title(),
|
'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
|
@classmethod
|
||||||
def defaults(cls, config):
|
def defaults(cls, config):
|
||||||
cls._defaults(config)
|
cls._defaults(config)
|
||||||
|
@ -62,6 +142,12 @@ class CommonView(View):
|
||||||
route_name='home',
|
route_name='home',
|
||||||
renderer='/home.mako')
|
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):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -75,12 +75,12 @@ class RoleView(MasterView):
|
||||||
auth = self.app.get_auth_handler()
|
auth = self.app.get_auth_handler()
|
||||||
|
|
||||||
# prevent delete for built-in roles
|
# prevent delete for built-in roles
|
||||||
if role is auth.get_role_administrator(session):
|
|
||||||
return False
|
|
||||||
if role is auth.get_role_authenticated(session):
|
if role is auth.get_role_authenticated(session):
|
||||||
return False
|
return False
|
||||||
if role is auth.get_role_anonymous(session):
|
if role is auth.get_role_anonymous(session):
|
||||||
return False
|
return False
|
||||||
|
if role is auth.get_role_administrator(session):
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
@ -1,90 +1,88 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from unittest.mock import MagicMock, patch
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from pyramid import testing
|
|
||||||
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
|
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
|
||||||
from wuttaweb.views import auth as mod
|
from wuttaweb.views import auth as mod
|
||||||
from wuttaweb.auth import WuttaSecurityPolicy
|
from tests.util import WebTestCase
|
||||||
from wuttaweb.subscribers import new_request
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthView(TestCase):
|
class TestAuthView(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.config = WuttaConfig(defaults={
|
self.setup_web()
|
||||||
'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.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
|
||||||
def tearDown(self):
|
def make_view(self):
|
||||||
testing.tearDown()
|
return mod.AuthView(self.request)
|
||||||
|
|
||||||
|
def test_includeme(self):
|
||||||
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
|
||||||
def test_login(self):
|
def test_login(self):
|
||||||
view = mod.AuthView(self.request)
|
model = self.app.model
|
||||||
context = view.login()
|
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)
|
self.assertIn('form', context)
|
||||||
|
|
||||||
# redirect if user already logged in
|
# redirect if user already logged in
|
||||||
self.request.user = self.user
|
with patch.object(self.request, 'user', new=barney):
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
redirect = view.login(session=self.session)
|
response = view.login(session=self.session)
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
# login fails w/ wrong password
|
# login fails w/ wrong password
|
||||||
self.request.user = None
|
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {'username': 'barney', 'password': 'WRONG'}
|
self.request.POST = {'username': 'barney', 'password': 'WRONG'}
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
context = view.login(session=self.session)
|
context = view.login(session=self.session)
|
||||||
self.assertIn('form', context)
|
self.assertIn('form', context)
|
||||||
|
|
||||||
# redirect if login succeeds
|
# redirect if login succeeds
|
||||||
self.request.method = 'POST'
|
self.request.method = 'POST'
|
||||||
self.request.POST = {'username': 'barney', 'password': 'testpass'}
|
self.request.POST = {'username': 'barney', 'password': 'testpass'}
|
||||||
view = mod.AuthView(self.request)
|
view = self.make_view()
|
||||||
redirect = view.login(session=self.session)
|
response = view.login(session=self.session)
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_logout(self):
|
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()
|
self.request.session.delete = MagicMock()
|
||||||
redirect = view.logout()
|
response = view.logout()
|
||||||
self.request.session.delete.assert_called_once_with()
|
self.request.session.delete.assert_called_once_with()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_change_password(self):
|
def test_change_password(self):
|
||||||
view = mod.AuthView(self.request)
|
model = self.app.model
|
||||||
auth = self.app.get_auth_handler()
|
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
|
# unauthenticated user is redirected
|
||||||
redirect = view.change_password()
|
redirect = view.change_password()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
|
|
||||||
# now "login" the user, and set initial password
|
# now "login" the user, and set initial password
|
||||||
self.request.user = self.user
|
self.request.user = barney
|
||||||
auth.set_user_password(self.user, 'foo')
|
auth.set_user_password(barney, 'foo')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
|
||||||
# view should now return context w/ form
|
# view should now return context w/ form
|
||||||
|
@ -105,9 +103,8 @@ class TestAuthView(TestCase):
|
||||||
redirect = view.change_password()
|
redirect = view.change_password()
|
||||||
self.assertIsInstance(redirect, HTTPFound)
|
self.assertIsInstance(redirect, HTTPFound)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.session.refresh(self.user)
|
self.assertFalse(auth.check_user_password(barney, 'foo'))
|
||||||
self.assertFalse(auth.check_user_password(self.user, 'foo'))
|
self.assertTrue(auth.check_user_password(barney, 'bar'))
|
||||||
self.assertTrue(auth.check_user_password(self.user, 'bar'))
|
|
||||||
|
|
||||||
# at this point 'foo' is the password, now let's submit some
|
# at this point 'foo' is the password, now let's submit some
|
||||||
# invalid forms and make sure we get back a context w/ form
|
# 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.")
|
self.assertEqual(dform['new_password'].errormsg, "New password must be different from old password.")
|
||||||
|
|
||||||
def test_become_root(self):
|
def test_become_root(self):
|
||||||
event = MagicMock(request=self.request)
|
|
||||||
new_request(event) # add request.get_referrer()
|
|
||||||
view = mod.AuthView(self.request)
|
view = mod.AuthView(self.request)
|
||||||
|
|
||||||
# GET not allowed
|
# GET not allowed
|
||||||
|
@ -168,8 +163,6 @@ class TestAuthView(TestCase):
|
||||||
self.assertTrue(self.request.session['is_root'])
|
self.assertTrue(self.request.session['is_root'])
|
||||||
|
|
||||||
def test_stop_root(self):
|
def test_stop_root(self):
|
||||||
event = MagicMock(request=self.request)
|
|
||||||
new_request(event) # add request.get_referrer()
|
|
||||||
view = mod.AuthView(self.request)
|
view = mod.AuthView(self.request)
|
||||||
|
|
||||||
# GET not allowed
|
# GET not allowed
|
||||||
|
|
|
@ -1,27 +1,85 @@
|
||||||
# -*- coding: utf-8; -*-
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
from unittest import TestCase
|
from wuttaweb.views import common as mod
|
||||||
|
from tests.util import WebTestCase
|
||||||
from pyramid import testing
|
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
|
||||||
from wuttaweb.views import common
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommonView(TestCase):
|
class TestCommonView(WebTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def make_view(self):
|
||||||
self.config = WuttaConfig()
|
return mod.CommonView(self.request)
|
||||||
self.app = self.config.get_app()
|
|
||||||
self.request = testing.DummyRequest()
|
def test_includeme(self):
|
||||||
self.request.wutta_config = self.config
|
|
||||||
self.pyramid_config = testing.setUp(request=self.request)
|
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
testing.tearDown()
|
|
||||||
|
|
||||||
def test_home(self):
|
def test_home(self):
|
||||||
view = common.CommonView(self.request)
|
self.pyramid_config.add_route('setup', '/setup')
|
||||||
context = view.home()
|
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())
|
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")
|
||||||
|
|
Loading…
Reference in a new issue