diff --git a/plinth/modules/first_boot/__init__.py b/plinth/modules/first_boot/__init__.py index 55d9941a0..b754507f8 100644 --- a/plinth/modules/first_boot/__init__.py +++ b/plinth/modules/first_boot/__init__.py @@ -22,3 +22,12 @@ Plinth module for first boot wizard version = 1 is_essential = True +first_boot_steps = [{'id': 'firstboot_state0', + 'url': 'first_boot:state0', + 'order': 0 + }, + {'id': 'firstboot_state10', + 'url': 'first_boot:state10', + 'order': 10 + } + ] diff --git a/plinth/modules/first_boot/middleware.py b/plinth/modules/first_boot/middleware.py index 069a3f728..c504fcd6a 100644 --- a/plinth/modules/first_boot/middleware.py +++ b/plinth/modules/first_boot/middleware.py @@ -23,9 +23,9 @@ yet. from django.http.response import HttpResponseRedirect from django.urls import reverse import logging - -from plinth import kvstore - +from operator import itemgetter +from plinth import kvstore, module_loader +from django.shortcuts import render LOGGER = logging.getLogger(__name__) @@ -37,19 +37,50 @@ class FirstBootMiddleware(object): def process_request(request): """Handle a request as Django middleware request handler.""" state = kvstore.get_default('firstboot_state', 0) - - firstboot_index_url = reverse('first_boot:index') - user_requests_firstboot = request.path.startswith(firstboot_index_url) - - help_index_url = reverse('help:index') - user_requests_help = request.path.startswith(help_index_url) - - # Setup is complete: Forbid accessing firstboot - if state >= 10 and user_requests_firstboot: + user_requests_firstboot = is_firstboot(request.path) + if state == 1 and user_requests_firstboot: return HttpResponseRedirect(reverse('index')) + elif state == 0 and not user_requests_firstboot: + url = next_step() + return HttpResponseRedirect(reverse(url)) - # Setup is not complete: Forbid accessing anything but - # firstboot or help - if state < 10 and not user_requests_firstboot and \ - not user_requests_help: - return HttpResponseRedirect(reverse('first_boot:state%d' % state)) + +def is_firstboot(path): + """ + Returns whether the path is a firstboot step url + :param path: path of current url + :return: true if its a first boot url false otherwise + """ + steps = get_firstboot_steps() + for step in steps: + if reverse(step.get('url')) == path: + return True + return False + + +def get_firstboot_steps(): + steps = [] + modules = module_loader.loaded_modules + for (module_name, module_object) in modules.items(): + if getattr(module_object, 'first_boot_steps', None): + for step in module_object.first_boot_steps: + steps.append(step) + steps = sorted(steps, key=itemgetter('order')) + return steps + + +def next_step(): + """ Returns the next first boot step required to run """ + steps = get_firstboot_steps() + for step in steps: + done = kvstore.get_default(step.get('id'), 0) + if done == 0: + return step.get('url') + + +def mark_step_done(id): + """ + Marks the status of a first boot step is done + :param id: id of the firstboot step + """ + kvstore.set(id, 1) diff --git a/plinth/modules/first_boot/urls.py b/plinth/modules/first_boot/urls.py index c4c87bc03..6c41de5ed 100644 --- a/plinth/modules/first_boot/urls.py +++ b/plinth/modules/first_boot/urls.py @@ -22,14 +22,12 @@ URLs for the First Boot module from django.conf.urls import url from stronghold.decorators import public -from .views import State0View, State1View, State5View, state10 +from .views import State0View, state10 urlpatterns = [ # Take care of the firstboot middleware when changing URLs url(r'^firstboot/$', public(State0View.as_view()), name='index'), url(r'^firstboot/state0/$', public(State0View.as_view()), name='state0'), - url(r'^firstboot/state1/$', public(State1View.as_view()), name='state1'), - url(r'^firstboot/state5/$', State5View.as_view(), name='state5'), url(r'^firstboot/state10/$', state10, name='state10'), ] diff --git a/plinth/modules/first_boot/views.py b/plinth/modules/first_boot/views.py index bff4ecc0c..801362dcb 100644 --- a/plinth/modules/first_boot/views.py +++ b/plinth/modules/first_boot/views.py @@ -28,33 +28,15 @@ from plinth import kvstore from plinth import network from plinth.errors import DomainRegistrationError from .forms import State1Form, State5Form +from .middleware import mark_step_done class State0View(TemplateView): """Show the welcome screen.""" + kvstore.set('firstboot_state0', 'done') template_name = 'firstboot_state0.html' -class State1View(CreateView): - """Create user account and log the user in.""" - template_name = 'firstboot_state1.html' - form_class = State1Form - success_url = reverse_lazy('first_boot:state10') - - def __init__(self, *args, **kwargs): - """Initialize the view object.""" - if cfg.danube_edition: - self.success_url = reverse_lazy('first_boot:state5') - - return super(State1View, self).__init__(*args, **kwargs) - - def get_form_kwargs(self): - """Make request available to the form (to insert messages)""" - kwargs = super(State1View, self).get_form_kwargs() - kwargs['request'] = self.request - return kwargs - - def state10(request): """State 10 is when all firstboot setup is done. @@ -62,36 +44,10 @@ def state10(request): """ # Make sure that a user exists before finishing firstboot if User.objects.all(): - kvstore.set('firstboot_state', 10) + mark_step_done('firstboot_state') connections = network.get_connection_list() return render(request, 'firstboot_state10.html', {'title': _('Setup Complete'), 'connections': connections}) - - -class State5View(FormView): - """State 5 is the (optional) setup of the Pagekite subdomain.""" - template_name = 'firstboot_state5.html' - form_class = State5Form - success_url = reverse_lazy('first_boot:state10') - - def get(self, *args, **kwargs): - """Respond to GET request.""" - kvstore.set('firstboot_state', 5) - return super(State5View, self).get(*args, **kwargs) - - def form_valid(self, form): - """Act on valid form submission.""" - try: - form.register_domain() - except DomainRegistrationError as error: - messages.error(self.request, error) - return HttpResponseRedirect(reverse_lazy('first_boot:state5')) - else: - form.setup_pagekite() - message = _('Pagekite setup finished. The HTTP and HTTPS services ' - 'are activated now.') - messages.success(self.request, message) - return super(State5View, self).form_valid(form) diff --git a/plinth/modules/pagekite/__init__.py b/plinth/modules/pagekite/__init__.py index fd868f631..048b0e5d8 100644 --- a/plinth/modules/pagekite/__init__.py +++ b/plinth/modules/pagekite/__init__.py @@ -31,6 +31,12 @@ depends = ['system', 'names'] managed_packages = ['pagekite'] +first_boot_steps = [{'id': 'pagekite_firstboot', + 'url': 'pagekite:firstboot', + 'order': 5, + }, + ] + title = _('Public Visibility (PageKite)') description = [ diff --git a/plinth/modules/pagekite/forms.py b/plinth/modules/pagekite/forms.py index 6e83bd45e..c2230ed5a 100644 --- a/plinth/modules/pagekite/forms.py +++ b/plinth/modules/pagekite/forms.py @@ -18,12 +18,19 @@ import copy from django import forms from django.contrib import messages +from django.contrib.sites import requests from django.core import validators +from django.core.exceptions import ValidationError from django.utils.translation import ugettext as _, ugettext_lazy import json import logging -from plinth.errors import ActionError +from plinth import cfg +from plinth.errors import ActionError, DomainRegistrationError +from plinth.modules.first_boot.forms import SubdomainWidget +from plinth.modules.pagekite.utils import PREDEFINED_SERVICES, run +from plinth.utils import format_lazy + from . import utils LOGGER = logging.getLogger(__name__) @@ -237,3 +244,94 @@ class AddCustomServiceForm(BaseCustomServiceForm): messages.error(request, _('This service already exists')) else: raise +class State5Form(forms.Form): + """Set up freedombox.me pagekite subdomain""" + DOMAIN_APPENDIX = '.freedombox.me' + # Webservice url for domain validation and registration + service_url = 'http://freedombox.me/cgi-bin/freedomkite.pl' + + code_help_text = format_lazy( + ugettext_lazy('The voucher you received with your {box_name} Danube ' + 'Edition'), box_name=ugettext_lazy(cfg.box_name)) + + code = forms.CharField(help_text=code_help_text) + + domain = forms.SlugField(label=_('Subdomain'), + widget=SubdomainWidget(domain=DOMAIN_APPENDIX), + help_text=_('The subdomain you want to register')) + + def clean_domain(self): + """Append the domain to the users' subdomain""" + return self.cleaned_data['domain'] + self.DOMAIN_APPENDIX + + def clean(self): + """Validate user input (subdomain and code)""" + cleaned_data = super().clean() + + # If the subdomain is wrong, don't look if the domain is + # available + if self.errors: + return cleaned_data + + self.domain_already_registered = False + code = cleaned_data.get('code') + domain = cleaned_data.get('domain') + + response = requests.get(self.service_url, params={'code': code}).json() + + # 1. Code is invalid: {} + if 'domain' not in response: + raise ValidationError(_('This code is not valid'), code='invalid') + # 2. Code is valid, domain registered: {'domain': 'xx.freedombox.me'} + elif response['domain']: + if response['domain'] == domain: + self.domain_already_registered = True + else: + message = _('This code is bound to the domain {domain}.') \ + .format(domain=response['domain']) + raise ValidationError(message, code='invalid') + # 3. Code is valid, no domain registered: {'domain': None} + elif response['domain'] is None: + # Make sure that the desired domain is available + data = {'domain': domain} + domain_response = requests.get(self.service_url, params=data) + registered_domain = domain_response.json()['domain'] + if registered_domain is not None: + message = _('The requested domain is already registered.') + raise ValidationError(message, code='invalid') + + return cleaned_data + + def register_domain(self): + """Register a domain (only if it's not already registered)""" + if self.domain_already_registered: + return + + data = {'domain': self.cleaned_data['domain'], + 'code': self.cleaned_data['code']} + response = requests.post(self.service_url, data) + if not response.ok: + message = _('Domain registration failed: {response}.').format( + response=response.text) + LOGGER.error(message) + raise DomainRegistrationError(message) + + def setup_pagekite(self): + """Configure and enable PageKite service.""" + # Set kite name and secret + run(['set-kite', '--kite-name', self.cleaned_data['domain']], + input=self.cleaned_data['code'].encode()) + + # Set frontend + run(['set-frontend', '%s:80' % self.cleaned_data['domain']]) + + # Enable PageKite HTTP + HTTPS service + for service_name in ['http', 'https']: + service = PREDEFINED_SERVICES[service_name]['params'] + try: + run(['add-service', '--service', json.dumps(service)]) + except ActionError as err: + if 'already exists' not in str(err): + raise + + run(['start-and-enable']) diff --git a/plinth/modules/first_boot/templates/firstboot_state5.html b/plinth/modules/pagekite/templates/firstboot_state5.html similarity index 100% rename from plinth/modules/first_boot/templates/firstboot_state5.html rename to plinth/modules/pagekite/templates/firstboot_state5.html diff --git a/plinth/modules/pagekite/urls.py b/plinth/modules/pagekite/urls.py index 4d23a5e20..bb1508193 100644 --- a/plinth/modules/pagekite/urls.py +++ b/plinth/modules/pagekite/urls.py @@ -22,8 +22,7 @@ URLs for the PageKite module from django.conf.urls import url from .views import StandardServiceView, CustomServiceView, ConfigurationView, \ - DeleteServiceView, index - + DeleteServiceView, index, State5View urlpatterns = [ url(r'^sys/pagekite/$', index, name='index'), @@ -35,4 +34,5 @@ urlpatterns = [ name='custom-services'), url(r'^sys/pagekite/services/custom/delete$', DeleteServiceView.as_view(), name='delete-custom-service'), + url(r'^sys/pagekite/firstboot/$', State5View.as_view(), name='firstboot'), ] diff --git a/plinth/modules/pagekite/views.py b/plinth/modules/pagekite/views.py index 46854e254..c81f69c61 100644 --- a/plinth/modules/pagekite/views.py +++ b/plinth/modules/pagekite/views.py @@ -14,19 +14,21 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - +from django.contrib import messages from django.http.response import HttpResponseRedirect from django.template.response import TemplateResponse from django.urls import reverse, reverse_lazy from django.utils.translation import ugettext_lazy as _ from django.views.generic import View, TemplateView from django.views.generic.edit import FormView +from plinth import kvstore +from plinth.errors import DomainRegistrationError from . import utils from .forms import ConfigurationForm, StandardServiceForm, \ - AddCustomServiceForm, DeleteCustomServiceForm + AddCustomServiceForm, DeleteCustomServiceForm, State5Form from plinth.modules import pagekite - +from plinth.modules.first_boot.middleware import mark_step_done subsubmenu = [{'url': reverse_lazy('pagekite:index'), 'text': _('About PageKite')}, @@ -51,6 +53,7 @@ class ContextMixin(object): Also adds the requirement of all necessary packages to be installed """ + def get_context_data(self, **kwargs): """Use self.title and the module-level subsubmenu""" context = super(ContextMixin, self).get_context_data(**kwargs) @@ -129,3 +132,28 @@ class ConfigurationView(ContextMixin, FormView): def form_valid(self, form): form.save(self.request) return super(ConfigurationView, self).form_valid(form) + + +class State5View(FormView): + """State 5 is the (optional) setup of the Pagekite subdomain.""" + template_name = 'firstboot_state5.html' + form_class = State5Form + + def get(self, *args, **kwargs): + """Respond to GET request.""" + mark_step_done('pagekite_firstboot') + return super(State5View, self).get(*args, **kwargs) + + def form_valid(self, form): + """Act on valid form submission.""" + try: + form.register_domain() + except DomainRegistrationError as error: + messages.error(self.request, error) + return HttpResponseRedirect(reverse_lazy('pagekite:firstboot')) + else: + form.setup_pagekite() + message = _('Pagekite setup finished. The HTTP and HTTPS services ' + 'are activated now.') + messages.success(self.request, message) + return super(State5View, self).form_valid(form) diff --git a/plinth/modules/users/__init__.py b/plinth/modules/users/__init__.py index e227175fe..cad4201b5 100644 --- a/plinth/modules/users/__init__.py +++ b/plinth/modules/users/__init__.py @@ -34,7 +34,11 @@ depends = ['system'] managed_packages = ['ldapscripts', 'ldap-utils', 'libnss-ldapd', 'libpam-ldapd', 'nslcd', 'slapd'] - +first_boot_steps = [{'id': 'users_firstboot', + 'url': 'users:firstboot', + 'order': 1 + }, + ] title = _('Users and Groups') @@ -76,4 +80,4 @@ def _diagnose_ldap_entry(search_item): pass return [_('Check LDAP entry "{search_item}"') - .format(search_item=search_item), result] + .format(search_item=search_item), result] diff --git a/plinth/modules/users/forms.py b/plinth/modules/users/forms.py index ce3277e79..f4b429c2c 100644 --- a/plinth/modules/users/forms.py +++ b/plinth/modules/users/forms.py @@ -18,6 +18,7 @@ import subprocess from django import forms +from django.contrib import auth from django.contrib import messages from django.contrib.auth.models import User, Group from django.contrib.auth.forms import UserCreationForm, SetPasswordForm @@ -28,6 +29,8 @@ from plinth import actions from plinth.errors import ActionError # Usernames used by optional services (that might not be installed yet). +from plinth.modules.security import set_restricted_access + RESERVED_USERNAMES = [ 'debian-deluged', 'Debian-minetest', @@ -240,3 +243,64 @@ class UserChangePasswordForm(SetPasswordForm): _('Changing LDAP user password failed.')) return user + +class State1Form(ValidNewUsernameCheckMixin, auth.forms.UserCreationForm): + """Firstboot state 1: create a new user.""" + def __init__(self, *args, **kwargs): + self.request = kwargs.pop('request') + super().__init__(*args, **kwargs) + + def save(self, commit=True): + """Create and log the user in.""" + user = super().save(commit=commit) + if commit: + try: + actions.superuser_run( + 'ldap', + ['create-user', user.get_username()], + input=self.cleaned_data['password1'].encode()) + except ActionError: + messages.error(self.request, + _('Creating LDAP user failed.')) + + try: + actions.superuser_run( + 'ldap', + ['add-user-to-group', user.get_username(), 'admin']) + except ActionError: + messages.error(self.request, + _('Failed to add new user to admin group.')) + + # Create initial Django groups + for group_choice in GROUP_CHOICES: + auth.models.Group.objects.get_or_create(name=group_choice[0]) + + admin_group = auth.models.Group.objects.get(name='admin') + admin_group.user_set.add(user) + + self.login_user(self.cleaned_data['username'], + self.cleaned_data['password1']) + + # Restrict console login to users in admin or sudo group + try: + set_restricted_access(True) + message = _('Console login access restricted to users in ' + '"admin" group. This can be configured in ' + 'security settings.') + messages.success(self.request, message) + except Exception: + messages.error(self.request, + _('Failed to restrict console access.')) + + return user + + def login_user(self, username, password): + """Try to login the user with the credentials provided""" + try: + user = auth.authenticate(username=username, password=password) + auth.login(self.request, user) + except Exception: + pass + else: + message = _('User account created, you are now logged in') + messages.success(self.request, message) diff --git a/plinth/modules/first_boot/templates/firstboot_state1.html b/plinth/modules/users/templates/firstboot_state1.html similarity index 100% rename from plinth/modules/first_boot/templates/firstboot_state1.html rename to plinth/modules/users/templates/firstboot_state1.html diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index bb5d53476..7cd438ae1 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -22,6 +22,7 @@ URLs for the Users module from django.conf.urls import url from django.contrib.auth import views as auth_views from django.urls import reverse_lazy +from stronghold.decorators import public from . import views @@ -40,4 +41,5 @@ urlpatterns = [ {'template_name': 'login.html'}, name='login'), url(r'^accounts/logout/$', auth_views.logout, {'next_page': reverse_lazy('index')}, name='logout'), + url(r'^users/firstboot/$', public(views.State1View.as_view()), name='firstboot'), ] diff --git a/plinth/modules/users/views.py b/plinth/modules/users/views.py index 253f759e6..350bf4976 100644 --- a/plinth/modules/users/views.py +++ b/plinth/modules/users/views.py @@ -22,13 +22,16 @@ from django.contrib.messages.views import SuccessMessageMixin from django.urls import reverse, reverse_lazy from django.views.generic.edit import (CreateView, DeleteView, UpdateView, FormView) -from django.views.generic import ListView +from django.views.generic import ListView, CreateView from django.utils.translation import ugettext as _, ugettext_lazy +from plinth import cfg +from plinth import kvstore + +from .forms import CreateUserForm, UserChangePasswordForm, UserUpdateForm, State1Form -from .forms import CreateUserForm, UserChangePasswordForm, UserUpdateForm from plinth import actions from plinth.errors import ActionError - +from plinth.modules.first_boot.middleware import mark_step_done subsubmenu = [{'url': reverse_lazy('users:index'), 'text': ugettext_lazy('Users')}, @@ -38,6 +41,7 @@ subsubmenu = [{'url': reverse_lazy('users:index'), class ContextMixin(object): """Mixin to add 'subsubmenu' and 'title' to the context.""" + def get_context_data(self, **kwargs): """Use self.title and the module-level subsubmenu""" context = super(ContextMixin, self).get_context_data(**kwargs) @@ -163,3 +167,22 @@ class UserChangePassword(ContextMixin, SuccessMessageMixin, FormView): form.save() update_session_auth_hash(self.request, form.user) return super(UserChangePassword, self).form_valid(form) + + +class State1View(CreateView): + """Create user account and log the user in.""" + template_name = 'firstboot_state1.html' + form_class = State1Form + + def __init__(self, *args, **kwargs): + """Initialize the view object.""" + if not cfg.danube_edition: + mark_step_done('pagekite_firstboot') + mark_step_done('users_firstboot') + return super(State1View, self).__init__(*args, **kwargs) + + def get_form_kwargs(self): + """Make request available to the form (to insert messages)""" + kwargs = super(State1View, self).get_form_kwargs() + kwargs['request'] = self.request + return kwargs