diff --git a/plinth/__main__.py b/plinth/__main__.py index a140c43b4..0177aa05b 100644 --- a/plinth/__main__.py +++ b/plinth/__main__.py @@ -17,10 +17,6 @@ # import argparse -import django.conf -from django.contrib.messages import constants as message_constants -import django.core.management -import django.core.wsgi import importlib import logging import os @@ -28,13 +24,16 @@ import stat import sys import warnings -import cherrypy +import django.conf +import django.core.management +import django.core.wsgi +from django.contrib.messages import constants as message_constants -from plinth import cfg -from plinth import menu -from plinth import module_loader -from plinth import service -from plinth import setup +import axes +import cherrypy +from plinth import cfg, menu, module_loader, service, setup + +axes.default_app_config = "plinth.axes_app_config.AppConfig" logger = logging.getLogger(__name__) @@ -47,27 +46,22 @@ def parse_arguments(): description='Plinth web interface for FreedomBox', formatter_class=argparse.ArgumentDefaultsHelpFormatter) # TODO: server_dir is actually a url prefix; use a better variable name - parser.add_argument( - '--server_dir', default=cfg.server_dir, - help='web server path under which to serve') - parser.add_argument( - '--debug', action='store_true', default=cfg.debug, - help='enable debugging and run server *insecurely*') + parser.add_argument('--server_dir', default=cfg.server_dir, + help='web server path under which to serve') + parser.add_argument('--debug', action='store_true', default=cfg.debug, + help='enable debugging and run server *insecurely*') parser.add_argument( '--setup', default=False, nargs='*', help='run setup tasks on all essential modules and exit') parser.add_argument( '--setup-no-install', default=False, nargs='*', help='run setup tasks without installing packages and exit') - parser.add_argument( - '--diagnose', action='store_true', default=False, - help='run diagnostic tests and exit') - parser.add_argument( - '--list-dependencies', default=False, nargs='*', - help='list package dependencies for essential modules') - parser.add_argument( - '--list-modules', default=False, nargs='*', - help='list modules') + parser.add_argument('--diagnose', action='store_true', default=False, + help='run diagnostic tests and exit') + parser.add_argument('--list-dependencies', default=False, nargs='*', + help='list package dependencies for essential modules') + parser.add_argument('--list-modules', default=False, nargs='*', + help='list modules') global arguments arguments = parser.parse_args() @@ -111,9 +105,12 @@ def setup_server(): static_dir = os.path.join(cfg.file_root, 'static') config = { - '/': {'tools.staticdir.root': static_dir, - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '.'}} + '/': { + 'tools.staticdir.root': static_dir, + 'tools.staticdir.on': True, + 'tools.staticdir.dir': '.' + } + } cherrypy.tree.mount(None, django.conf.settings.STATIC_URL, config) logger.debug('Serving static directory %s on %s', static_dir, django.conf.settings.STATIC_URL) @@ -121,9 +118,12 @@ def setup_server(): js_dir = '/usr/share/javascript' js_url = '/javascript' config = { - '/': {'tools.staticdir.root': js_dir, - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '.'}} + '/': { + 'tools.staticdir.root': js_dir, + 'tools.staticdir.on': True, + 'tools.staticdir.dir': '.' + } + } cherrypy.tree.mount(None, js_url, config) logger.debug('Serving javascript directory %s on %s', js_dir, js_url) @@ -131,9 +131,12 @@ def setup_server(): manual_url = '/'.join([cfg.server_dir, 'help/manual/images']) \ .replace('//', '/') config = { - '/': {'tools.staticdir.root': manual_dir, - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '.'}} + '/': { + 'tools.staticdir.root': manual_dir, + 'tools.staticdir.on': True, + 'tools.staticdir.dir': '.' + } + } cherrypy.tree.mount(None, manual_url, config) logger.debug('Serving manual images %s on %s', manual_dir, manual_url) @@ -144,9 +147,12 @@ def setup_server(): continue config = { - '/': {'tools.staticdir.root': static_dir, - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '.'}} + '/': { + 'tools.staticdir.root': static_dir, + 'tools.staticdir.on': True, + 'tools.staticdir.dir': '.' + } + } urlprefix = "%s%s" % (django.conf.settings.STATIC_URL, module_name) cherrypy.tree.mount(None, urlprefix, config) logger.debug('Serving static directory %s on %s', static_dir, @@ -168,25 +174,25 @@ def configure_django(): 'formatters': { 'default': { 'format': - '[%(asctime)s] %(name)-14s %(levelname)-8s %(message)s', - } - }, + '[%(asctime)s] %(name)-14s %(levelname)-8s %(message)s', + } + }, 'handlers': { 'file': { 'class': 'logging.FileHandler', 'filename': cfg.status_log_file, 'formatter': 'default' - }, + }, 'console': { 'class': 'logging.StreamHandler', 'formatter': 'default' - } - }, + } + }, 'root': { 'handlers': ['console', 'file'], 'level': 'DEBUG' if cfg.debug else 'INFO' - } } + } templates = [ { @@ -225,35 +231,45 @@ def configure_django(): if cfg.secure_proxy_ssl_header: secure_proxy_ssl_header = (cfg.secure_proxy_ssl_header, 'https') + pwd = 'django.contrib.auth.password_validation' + django.conf.settings.configure( ALLOWED_HOSTS=['*'], AUTH_PASSWORD_VALIDATORS=[ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + 'NAME': '{}.UserAttributeSimilarityValidator'.format(pwd), }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'NAME': '{}.MinimumLengthValidator'.format(pwd), 'OPTIONS': { 'min_length': 8, } }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + 'NAME': '{}.CommonPasswordValidator'.format(pwd), }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + 'NAME': '{}.NumericPasswordValidator'.format(pwd), }, ], - AXES_LOCKOUT_URL='locked', + AXES_LOCKOUT_URL='locked/', AXES_BEHIND_REVERSE_PROXY=True, - CACHES={'default': - {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}}, - CAPTCHA_FONT_PATH=['/usr/share/fonts/truetype/ttf-bitstream-vera/Vera.ttf'], + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache' + } + }, + CAPTCHA_FONT_PATH=[ + '/usr/share/fonts/truetype/ttf-bitstream-vera/Vera.ttf' + ], CAPTCHA_LENGTH=6, CAPTCHA_FLITE_PATH='/usr/bin/flite', - DATABASES={'default': - {'ENGINE': 'django.db.backends.sqlite3', - 'NAME': cfg.store_file}}, + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': cfg.store_file + } + }, DEBUG=cfg.debug, FORCE_SCRIPT_NAME=cfg.server_dir, INSTALLED_APPS=applications, @@ -274,8 +290,7 @@ def configure_django(): 'plinth.middleware.AdminRequiredMiddleware', 'plinth.middleware.FirstSetupMiddleware', 'plinth.modules.first_boot.middleware.FirstBootMiddleware', - 'plinth.middleware.SetupMiddleware', - ), + 'plinth.middleware.SetupMiddleware', ), ROOT_URLCONF='plinth.urls', SECURE_BROWSER_XSS_FILTER=True, SECURE_CONTENT_TYPE_NOSNIFF=True, @@ -284,8 +299,11 @@ def configure_django(): SESSION_FILE_PATH=sessions_directory, STATIC_URL='/'.join([cfg.server_dir, 'static/']).replace('//', '/'), # STRONGHOLD_PUBLIC_URLS=(r'^captcha/', ), - STRONGHOLD_PUBLIC_NAMED_URLS=('captcha-image', 'captcha-image-2x', - 'captcha-audio', 'captcha-refresh', ), + STRONGHOLD_PUBLIC_NAMED_URLS=( + 'captcha-image', + 'captcha-image-2x', + 'captcha-audio', + 'captcha-refresh', ), TEMPLATES=templates, USE_L10N=True, USE_X_FORWARDED_HOST=cfg.use_x_forwarded_host) diff --git a/plinth/axes_app_config.py b/plinth/axes_app_config.py new file mode 100644 index 000000000..ad04d0283 --- /dev/null +++ b/plinth/axes_app_config.py @@ -0,0 +1,29 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Overridden AppConfig from django-axes to avoid monkey-patched LoginView +""" + +from django import apps + + +class AppConfig(apps.AppConfig): + name = 'axes' + + def ready(self): + # Signals must be loaded for axes to get the login_failed signals + from axes import signals # isort:skip diff --git a/plinth/modules/sso/views.py b/plinth/modules/sso/views.py index 200086bfb..f90351262 100644 --- a/plinth/modules/sso/views.py +++ b/plinth/modules/sso/views.py @@ -18,22 +18,20 @@ Views for the Single Sign On module of Plinth """ +import logging import os import urllib -import logging -from .forms import AuthenticationForm - -from plinth import actions - -from django.http import HttpResponseRedirect from django.contrib.auth import REDIRECT_FIELD_NAME from django.contrib.auth.decorators import login_required from django.contrib.auth.views import LoginView, LogoutView +from django.http import HttpResponseRedirect -from django.shortcuts import render_to_response - +from axes.decorators import axes_form_invalid from axes.utils import reset +from plinth import actions + +from .forms import AuthenticationForm PRIVATE_KEY_FILE_NAME = 'privkey.pem' SSO_COOKIE_NAME = 'auth_pubtkt' @@ -70,6 +68,10 @@ class SSOLoginView(LoginView): else: return response + @axes_form_invalid + def form_invalid(self, *args, **kwargs): + return super(SSOLoginView, self).form_invalid(*args, **kwargs) + class CaptchaLoginView(LoginView): redirect_authenticated_user = True diff --git a/plinth/modules/users/urls.py b/plinth/modules/users/urls.py index b51cc10be..b734f4c3a 100644 --- a/plinth/modules/users/urls.py +++ b/plinth/modules/users/urls.py @@ -21,35 +21,30 @@ URLs for the Users module from django.conf.urls import url from django.urls import reverse_lazy -from stronghold.decorators import public -from plinth.utils import non_admin_view +from axes.decorators import axes_dispatch from plinth.modules.sso.views import SSOLoginView, SSOLogoutView -from . import views +from plinth.utils import non_admin_view +from stronghold.decorators import public -from axes.decorators import watch_login +from . import views urlpatterns = [ url(r'^sys/users/$', views.UserList.as_view(), name='index'), url(r'^sys/users/create/$', views.UserCreate.as_view(), name='create'), url(r'^sys/users/(?P[\w.@+-]+)/edit/$', - non_admin_view(views.UserUpdate.as_view()), - name='edit'), + non_admin_view(views.UserUpdate.as_view()), name='edit'), url(r'^sys/users/(?P[\w.@+-]+)/delete/$', - views.UserDelete.as_view(), - name='delete'), + views.UserDelete.as_view(), name='delete'), url(r'^sys/users/(?P[\w.@+-]+)/change_password/$', non_admin_view(views.UserChangePassword.as_view()), name='change_password'), # Authnz is handled by SSO url(r'^accounts/login/$', - public(watch_login(SSOLoginView.as_view())), - name='login'), + public(axes_dispatch(SSOLoginView.as_view())), name='login'), url(r'^accounts/logout/$', non_admin_view(SSOLogoutView.as_view()), - {'next_page': reverse_lazy('index')}, - name='logout'), + {'next_page': reverse_lazy('index')}, name='logout'), url(r'^users/firstboot/$', - public(views.FirstBootView.as_view()), - name='firstboot'), + public(views.FirstBootView.as_view()), name='firstboot'), ] diff --git a/plinth/urls.py b/plinth/urls.py index e9b76dcde..044d08fd2 100644 --- a/plinth/urls.py +++ b/plinth/urls.py @@ -17,10 +17,10 @@ """ Django URLconf file containing all urls """ -from captcha import views as cviews from django.conf.urls import url from django.views.generic import TemplateView +from captcha import views as cviews from plinth.modules.sso.views import CaptchaLoginView from stronghold.decorators import public @@ -29,20 +29,18 @@ from . import views urlpatterns = [ url(r'^$', views.index, name='index'), url(r'^apps/$', - TemplateView.as_view(template_name='apps.html'), - name='apps'), + TemplateView.as_view(template_name='apps.html'), name='apps'), url(r'^sys/$', views.system_index, name='system'), # captcha urls are public url(r'image/(?P\w+)/$', - public(cviews.captcha_image), - name='captcha-image', + public(cviews.captcha_image), name='captcha-image', kwargs={'scale': 1}), url(r'image/(?P\w+)@2/$', - public(cviews.captcha_image), - name='captcha-image-2x', + public(cviews.captcha_image), name='captcha-image-2x', kwargs={'scale': 2}), - url(r'audio/(?P\w+)/$', public(cviews.captcha_audio), name='captcha-audio'), + url(r'audio/(?P\w+)/$', + public(cviews.captcha_audio), name='captcha-audio'), url(r'refresh/$', public(cviews.captcha_refresh), name='captcha-refresh'), url(r'locked/$', public(CaptchaLoginView.as_view()), name='locked_out'), ]