mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +00:00
sso: Merge into users module, drop pubtkt related code
Tests: - 'make install' removes enabled sso module - Already logged in users stay logged in after update - Apps need to re-authenticate of update (but this is transparent) - Login and logout work as expected - Failed login attempts lead to CAPTCHA form - CAPTCHA form can't be skipped - Answering CAPTCHA form will lead back to login page - Users functional tests work Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
6fd85e3e46
commit
a7584b465d
3
Makefile
3
Makefile
@ -21,7 +21,8 @@ DISABLED_APPS_TO_REMOVE := \
|
|||||||
tahoe \
|
tahoe \
|
||||||
mldonkey \
|
mldonkey \
|
||||||
i2p \
|
i2p \
|
||||||
ttrss
|
ttrss \
|
||||||
|
sso
|
||||||
|
|
||||||
APP_FILES_TO_REMOVE := $(foreach app,$(DISABLED_APPS_TO_REMOVE),$(ENABLED_APPS_PATH)/$(app))
|
APP_FILES_TO_REMOVE := $(foreach app,$(DISABLED_APPS_TO_REMOVE),$(ENABLED_APPS_PATH)/$(app))
|
||||||
|
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""FreedomBox app to configure Single Sign On services."""
|
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from plinth import app as app_module
|
|
||||||
from plinth.config import DropinConfigs
|
|
||||||
from plinth.package import Packages
|
|
||||||
|
|
||||||
from . import privileged
|
|
||||||
|
|
||||||
|
|
||||||
class SSOApp(app_module.App):
|
|
||||||
"""FreedomBox app for single sign on."""
|
|
||||||
|
|
||||||
app_id = 'sso'
|
|
||||||
|
|
||||||
_version = 3
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Create components for the app."""
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
info = app_module.Info(app_id=self.app_id, version=self._version,
|
|
||||||
is_essential=True,
|
|
||||||
depends=['security',
|
|
||||||
'apache'], name=_('Single Sign On'))
|
|
||||||
self.add(info)
|
|
||||||
|
|
||||||
packages = Packages(
|
|
||||||
'packages-sso',
|
|
||||||
['libapache2-mod-auth-pubtkt', 'python3-cryptography', 'flite'])
|
|
||||||
self.add(packages)
|
|
||||||
|
|
||||||
dropin_configs = DropinConfigs('dropin-configs-sso', [
|
|
||||||
'/etc/apache2/includes/freedombox-single-sign-on.conf',
|
|
||||||
])
|
|
||||||
self.add(dropin_configs)
|
|
||||||
|
|
||||||
def setup(self, old_version):
|
|
||||||
"""Install and configure the app."""
|
|
||||||
super().setup(old_version)
|
|
||||||
privileged.create_key_pair()
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<RequireALL>
|
|
||||||
<IfModule mod_auth_pubtkt.c>
|
|
||||||
TKTAuthPublicKey /etc/apache2/auth-pubtkt-keys/pubkey.pem
|
|
||||||
TKTAuthLoginURL /freedombox/accounts/sso/login/
|
|
||||||
TKTAuthBackArgName next
|
|
||||||
TKTAuthDigest SHA512
|
|
||||||
TKTAuthRefreshURL /freedombox/accounts/sso/refresh/
|
|
||||||
TKTAuthUnauthURL /freedombox
|
|
||||||
AuthType mod_auth_pubtkt
|
|
||||||
AuthName "FreedomBox Single Sign On"
|
|
||||||
Require valid-user
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule !mod_auth_pubtkt.c>
|
|
||||||
Require all denied
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
# Require that LDAP account is not locked
|
|
||||||
AuthLDAPUrl "ldap:///ou=users,dc=thisbox?uid"
|
|
||||||
Require not ldap-attribute pwdAccountLockedTime="000001010000Z"
|
|
||||||
</RequireAll>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
plinth.modules.sso
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Forms for the Single Sign On app of FreedomBox.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from captcha.fields import CaptchaField
|
|
||||||
from django import forms
|
|
||||||
from django.contrib.auth.forms import \
|
|
||||||
AuthenticationForm as DjangoAuthenticationForm
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationForm(DjangoAuthenticationForm):
|
|
||||||
"""Authentication form with an additional username field attributes."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields['username'].widget.attrs.update({
|
|
||||||
'autofocus': 'autofocus',
|
|
||||||
'autocapitalize': 'none',
|
|
||||||
'autocomplete': 'username'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class CaptchaForm(forms.Form):
|
|
||||||
"""Form with a CAPTCHA field to use after 3 invalid login attempts."""
|
|
||||||
captcha = CaptchaField(
|
|
||||||
label=_('Enter the letters in the image to proceed to the login page'))
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""Generate a auth_pubtkt ticket.
|
|
||||||
|
|
||||||
Sign tickets with the FreedomBox server's private key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import hashes, serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
||||||
|
|
||||||
from plinth.actions import privileged
|
|
||||||
|
|
||||||
KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys'
|
|
||||||
|
|
||||||
|
|
||||||
@privileged
|
|
||||||
def create_key_pair():
|
|
||||||
"""Create public/private key pair for signing the tickets."""
|
|
||||||
keys_directory = pathlib.Path(KEYS_DIRECTORY)
|
|
||||||
private_key_file = keys_directory / 'privkey.pem'
|
|
||||||
public_key_file = keys_directory / 'pubkey.pem'
|
|
||||||
|
|
||||||
keys_directory.mkdir(exist_ok=True)
|
|
||||||
# Set explicitly in case permissions are incorrect
|
|
||||||
keys_directory.chmod(0o750)
|
|
||||||
if private_key_file.exists() and public_key_file.exists():
|
|
||||||
# Set explicitly in case permissions are incorrect
|
|
||||||
public_key_file.chmod(0o440)
|
|
||||||
private_key_file.chmod(0o440)
|
|
||||||
return
|
|
||||||
|
|
||||||
private_key = rsa.generate_private_key(public_exponent=65537,
|
|
||||||
key_size=4096)
|
|
||||||
|
|
||||||
def opener(path, flags):
|
|
||||||
return os.open(path, flags, mode=0o440)
|
|
||||||
|
|
||||||
with open(private_key_file, 'wb', opener=opener) as file_handle:
|
|
||||||
pem = private_key.private_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PrivateFormat.PKCS8,
|
|
||||||
encryption_algorithm=serialization.NoEncryption())
|
|
||||||
file_handle.write(pem)
|
|
||||||
|
|
||||||
with open(public_key_file, 'wb', opener=opener) as file_handle:
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
pem = public_key.public_bytes(
|
|
||||||
encoding=serialization.Encoding.PEM,
|
|
||||||
format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
||||||
file_handle.write(pem)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_ticket(private_key, uid, validuntil, ip=None, tokens=None,
|
|
||||||
udata=None, graceperiod=None, extra_fields=None):
|
|
||||||
"""Create and return a signed mod_auth_pubtkt ticket."""
|
|
||||||
tokens = ','.join(tokens)
|
|
||||||
fields = [
|
|
||||||
f'uid={uid}',
|
|
||||||
f'validuntil={int(validuntil)}',
|
|
||||||
ip and f'cip={ip}',
|
|
||||||
tokens and f'tokens={tokens}',
|
|
||||||
graceperiod and f'graceperiod={int(graceperiod)}',
|
|
||||||
udata and f'udata={udata}',
|
|
||||||
extra_fields
|
|
||||||
and ';'.join(['{}={}'.format(k, v) for k, v in extra_fields]),
|
|
||||||
]
|
|
||||||
data = ';'.join(filter(None, fields))
|
|
||||||
signature = 'sig={}'.format(_sign(private_key, data))
|
|
||||||
return ';'.join([data, signature])
|
|
||||||
|
|
||||||
|
|
||||||
def _sign(private_key, data):
|
|
||||||
"""Calculate and return ticket's signature."""
|
|
||||||
signature = private_key.sign(data.encode(), padding.PKCS1v15(),
|
|
||||||
hashes.SHA512())
|
|
||||||
return base64.b64encode(signature).decode()
|
|
||||||
|
|
||||||
|
|
||||||
@privileged
|
|
||||||
def generate_ticket(uid: str, private_key_file: str, tokens: list[str]) -> str:
|
|
||||||
"""Generate a mod_auth_pubtkt ticket using login credentials."""
|
|
||||||
with open(private_key_file, 'rb') as fil:
|
|
||||||
private_key = serialization.load_pem_private_key(
|
|
||||||
fil.read(), password=None)
|
|
||||||
|
|
||||||
valid_until = _minutes_from_now(12 * 60)
|
|
||||||
grace_period = _minutes_from_now(11 * 60)
|
|
||||||
return _create_ticket(private_key, uid, valid_until, tokens=tokens,
|
|
||||||
graceperiod=grace_period)
|
|
||||||
|
|
||||||
|
|
||||||
def _minutes_from_now(minutes):
|
|
||||||
"""Return a timestamp at the given number of minutes from now."""
|
|
||||||
return _seconds_from_now(minutes * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def _seconds_from_now(seconds):
|
|
||||||
"""Return a timestamp at the given number of seconds from now."""
|
|
||||||
return (datetime.datetime.now() +
|
|
||||||
datetime.timedelta(0, seconds)).timestamp()
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Functional, browser based tests for sso app.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from plinth.tests import functional
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.system, pytest.mark.essential, pytest.mark.sso]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='module', autouse=True)
|
|
||||||
def fixture_background(session_browser):
|
|
||||||
"""Login and install the app."""
|
|
||||||
functional.login(session_browser)
|
|
||||||
functional.install(session_browser, 'syncthing')
|
|
||||||
functional.app_enable(session_browser, 'syncthing')
|
|
||||||
yield
|
|
||||||
functional.app_disable(session_browser, 'syncthing')
|
|
||||||
|
|
||||||
|
|
||||||
def test_app_access(session_browser):
|
|
||||||
"""Test that only logged-in users can access Syncthing web interface."""
|
|
||||||
functional.logout(session_browser)
|
|
||||||
functional.access_url(session_browser, 'syncthing')
|
|
||||||
assert functional.is_login_prompt(session_browser)
|
|
||||||
|
|
||||||
functional.login(session_browser)
|
|
||||||
functional.access_url(session_browser, 'syncthing')
|
|
||||||
assert functional.is_available(session_browser, 'syncthing')
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
Test module for sso module operations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from plinth.modules.sso import privileged
|
|
||||||
from plinth.modules.sso.views import PRIVATE_KEY_FILE_NAME
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.usefixtures('mock_privileged')
|
|
||||||
privileged_modules_to_mock = ['plinth.modules.sso.privileged']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def fixture_keys_directory(tmpdir):
|
|
||||||
"""Set keys directory in the actions module."""
|
|
||||||
privileged.KEYS_DIRECTORY = str(tmpdir)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name='existing_key_pair')
|
|
||||||
def fixture_existing_key_pair():
|
|
||||||
"""A fixture to create key pair if needed."""
|
|
||||||
privileged.create_key_pair()
|
|
||||||
keys_directory = pathlib.Path(privileged.KEYS_DIRECTORY)
|
|
||||||
assert keys_directory.stat().st_mode == 0o40750
|
|
||||||
assert (keys_directory / 'privkey.pem').stat().st_mode == 0o100440
|
|
||||||
assert (keys_directory / 'pubkey.pem').stat().st_mode == 0o100440
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_ticket(existing_key_pair):
|
|
||||||
"""Test generating a ticket."""
|
|
||||||
username = 'tester'
|
|
||||||
groups = ['freedombox-share', 'syncthing', 'web-search']
|
|
||||||
|
|
||||||
private_key_file = os.path.join(privileged.KEYS_DIRECTORY,
|
|
||||||
PRIVATE_KEY_FILE_NAME)
|
|
||||||
ticket = privileged.generate_ticket(username, private_key_file, groups)
|
|
||||||
|
|
||||||
fields = {}
|
|
||||||
for item in ticket.split(';'):
|
|
||||||
try:
|
|
||||||
key, value = item.split('=')
|
|
||||||
fields[key] = value
|
|
||||||
except ValueError:
|
|
||||||
# The 'sig' field can also contain '='.
|
|
||||||
continue
|
|
||||||
|
|
||||||
assert fields['uid'] == username
|
|
||||||
assert int(fields['validuntil']) > 0
|
|
||||||
assert fields['tokens'] == ','.join(groups)
|
|
||||||
assert int(fields['graceperiod']) > 0
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""
|
|
||||||
URLs for the Single Sign On module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.urls import re_path
|
|
||||||
from stronghold.decorators import public
|
|
||||||
|
|
||||||
from plinth.utils import non_admin_view
|
|
||||||
|
|
||||||
from .views import CaptchaView, SSOLoginView, refresh
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
re_path(r'^accounts/sso/login/$', public(SSOLoginView.as_view()),
|
|
||||||
name='sso-login'),
|
|
||||||
re_path(r'^accounts/sso/refresh/$', non_admin_view(refresh),
|
|
||||||
name='sso-refresh'),
|
|
||||||
|
|
||||||
# Locked URL from django-axes
|
|
||||||
re_path(r'accounts/sso/login/locked/$', public(CaptchaView.as_view()),
|
|
||||||
name='locked_out'),
|
|
||||||
]
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
"""Views for the Single Sign On app of FreedomBox."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
import axes.utils
|
|
||||||
from django import shortcuts
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth import REDIRECT_FIELD_NAME
|
|
||||||
from django.contrib.auth import logout as auth_logout
|
|
||||||
from django.contrib.auth.views import LoginView
|
|
||||||
from django.http import HttpResponseRedirect
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from django.views.decorators.http import require_POST
|
|
||||||
from django.views.generic.edit import FormView
|
|
||||||
|
|
||||||
from plinth import translation
|
|
||||||
|
|
||||||
from . import privileged
|
|
||||||
from .forms import AuthenticationForm, CaptchaForm
|
|
||||||
|
|
||||||
PRIVATE_KEY_FILE_NAME = 'privkey.pem'
|
|
||||||
SSO_COOKIE_NAME = 'auth_pubtkt'
|
|
||||||
KEYS_DIRECTORY = '/etc/apache2/auth-pubtkt-keys'
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def set_ticket_cookie(user, response):
|
|
||||||
"""Generate and set a mod_auth_pubtkt as a cookie in the response."""
|
|
||||||
tokens = list(map(lambda g: g.name, user.groups.all()))
|
|
||||||
private_key_file = os.path.join(KEYS_DIRECTORY, PRIVATE_KEY_FILE_NAME)
|
|
||||||
ticket = privileged.generate_ticket(user.username, private_key_file,
|
|
||||||
tokens)
|
|
||||||
response.set_cookie(SSO_COOKIE_NAME, urllib.parse.quote(ticket))
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class SSOLoginView(LoginView):
|
|
||||||
"""View to login to FreedomBox and set a auth_pubtkt cookie.
|
|
||||||
|
|
||||||
Cookie will be used to provide Single Sign On for some other applications.
|
|
||||||
"""
|
|
||||||
|
|
||||||
redirect_authenticated_user = True
|
|
||||||
template_name = 'login.html'
|
|
||||||
form_class = AuthenticationForm
|
|
||||||
|
|
||||||
def dispatch(self, request, *args, **kwargs):
|
|
||||||
"""Handle a request and return a HTTP response."""
|
|
||||||
response = super().dispatch(request, *args, **kwargs)
|
|
||||||
if request.user.is_authenticated:
|
|
||||||
translation.set_language(request, response,
|
|
||||||
request.user.userprofile.language)
|
|
||||||
return set_ticket_cookie(request.user, response)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
class CaptchaView(FormView):
|
|
||||||
"""A simple form view with a CAPTCHA image.
|
|
||||||
|
|
||||||
When a user performs too many login attempts, they will no longer be able
|
|
||||||
to login with the typical login view. They will be redirected to this view.
|
|
||||||
On successfully solving the CAPTCHA in this form, their ability to use the
|
|
||||||
login form will be reset.
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_name = 'captcha.html'
|
|
||||||
form_class = CaptchaForm
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
|
||||||
"""Reset login attempts and redirect to login page."""
|
|
||||||
axes.utils.reset_request(self.request)
|
|
||||||
return shortcuts.redirect('users:login')
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
|
||||||
def logout(request):
|
|
||||||
"""Logout an authenticated user, remove SSO cookie and redirect to home."""
|
|
||||||
auth_logout(request)
|
|
||||||
response = shortcuts.redirect('index')
|
|
||||||
response.delete_cookie(SSO_COOKIE_NAME)
|
|
||||||
messages.success(request, _('Logged out successfully.'))
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def refresh(request):
|
|
||||||
"""Simulate cookie refresh - redirect logged in user with a new cookie."""
|
|
||||||
redirect_url = request.GET.get(REDIRECT_FIELD_NAME, '')
|
|
||||||
response = HttpResponseRedirect(redirect_url)
|
|
||||||
response.delete_cookie(SSO_COOKIE_NAME)
|
|
||||||
# Redirect with cookie doesn't work with 300 series
|
|
||||||
response.status_code = 200
|
|
||||||
return set_ticket_cookie(request.user, response)
|
|
||||||
@ -73,9 +73,13 @@ class UsersApp(app_module.App):
|
|||||||
])
|
])
|
||||||
self.add(packages)
|
self.add(packages)
|
||||||
|
|
||||||
dropin_configs = DropinConfigs('dropin-configs-users', [
|
dropin_configs = DropinConfigs(
|
||||||
'/etc/apache2/includes/freedombox-auth-ldap.conf',
|
'dropin-configs-users',
|
||||||
])
|
[
|
||||||
|
'/etc/apache2/includes/freedombox-auth-ldap.conf',
|
||||||
|
# Empty file kept for easier upgrade
|
||||||
|
'/etc/apache2/includes/freedombox-single-sign-on.conf',
|
||||||
|
])
|
||||||
self.add(dropin_configs)
|
self.add(dropin_configs)
|
||||||
|
|
||||||
daemon = Daemon('daemon-users', 'slapd', listen_ports=[(389, 'tcp4'),
|
daemon = Daemon('daemon-users', 'slapd', listen_ports=[(389, 'tcp4'),
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
# Empty file to easier upgrade process
|
||||||
@ -4,8 +4,11 @@
|
|||||||
import pwd
|
import pwd
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from captcha.fields import CaptchaField
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import auth, messages
|
from django.contrib import auth, messages
|
||||||
|
from django.contrib.auth.forms import \
|
||||||
|
AuthenticationForm as DjangoAuthenticationForm
|
||||||
from django.contrib.auth.forms import SetPasswordForm, UserCreationForm
|
from django.contrib.auth.forms import SetPasswordForm, UserCreationForm
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
@ -25,6 +28,24 @@ from . import get_last_admin_user, privileged
|
|||||||
from .components import UsersAndGroups
|
from .components import UsersAndGroups
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationForm(DjangoAuthenticationForm):
|
||||||
|
"""Authentication form with an additional username field attributes."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['username'].widget.attrs.update({
|
||||||
|
'autofocus': 'autofocus',
|
||||||
|
'autocapitalize': 'none',
|
||||||
|
'autocomplete': 'username'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaForm(forms.Form):
|
||||||
|
"""Form with a CAPTCHA field to use after 3 invalid login attempts."""
|
||||||
|
captcha = CaptchaField(
|
||||||
|
label=_('Enter the letters in the image to proceed to the login page'))
|
||||||
|
|
||||||
|
|
||||||
class ValidNewUsernameCheckMixin:
|
class ValidNewUsernameCheckMixin:
|
||||||
"""Mixin to check if a username is valid for created new user."""
|
"""Mixin to check if a username is valid for created new user."""
|
||||||
|
|
||||||
|
|||||||
@ -66,6 +66,27 @@ def fixture_login(session_browser):
|
|||||||
functional.login(session_browser)
|
functional.login(session_browser)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name='syncthing_installed')
|
||||||
|
def fixture_syncthing_installed(session_browser):
|
||||||
|
"""Login and install the app."""
|
||||||
|
functional.login(session_browser)
|
||||||
|
functional.install(session_browser, 'syncthing')
|
||||||
|
functional.app_enable(session_browser, 'syncthing')
|
||||||
|
yield
|
||||||
|
functional.app_disable(session_browser, 'syncthing')
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_access(session_browser, syncthing_installed):
|
||||||
|
"""Test that only logged-in users can access Syncthing web interface."""
|
||||||
|
functional.logout(session_browser)
|
||||||
|
functional.access_url(session_browser, 'syncthing')
|
||||||
|
assert functional.is_login_prompt(session_browser)
|
||||||
|
|
||||||
|
functional.login(session_browser)
|
||||||
|
functional.access_url(session_browser, 'syncthing')
|
||||||
|
assert functional.is_available(session_browser, 'syncthing')
|
||||||
|
|
||||||
|
|
||||||
def test_create_user(session_browser):
|
def test_create_user(session_browser):
|
||||||
"""Test creating a user."""
|
"""Test creating a user."""
|
||||||
_delete_user(session_browser, 'alice')
|
_delete_user(session_browser, 'alice')
|
||||||
|
|||||||
@ -6,7 +6,6 @@ URLs for the Users module
|
|||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
from stronghold.decorators import public
|
from stronghold.decorators import public
|
||||||
|
|
||||||
from plinth.modules.sso.views import CaptchaView, SSOLoginView, logout
|
|
||||||
from plinth.utils import non_admin_view
|
from plinth.utils import non_admin_view
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
@ -19,13 +18,11 @@ urlpatterns = [
|
|||||||
re_path(r'^sys/users/(?P<slug>[\w.@+-]+)/change_password/$',
|
re_path(r'^sys/users/(?P<slug>[\w.@+-]+)/change_password/$',
|
||||||
non_admin_view(views.UserChangePassword.as_view()),
|
non_admin_view(views.UserChangePassword.as_view()),
|
||||||
name='change_password'),
|
name='change_password'),
|
||||||
|
re_path(r'^accounts/login/$', public(views.LoginView.as_view()),
|
||||||
# Authnz is handled by SSO
|
|
||||||
re_path(r'^accounts/login/$', public(SSOLoginView.as_view()),
|
|
||||||
name='login'),
|
name='login'),
|
||||||
re_path(r'^accounts/logout/$', public(logout), name='logout'),
|
re_path(r'^accounts/logout/$', public(views.logout), name='logout'),
|
||||||
re_path(r'^users/firstboot/$', public(views.FirstBootView.as_view()),
|
re_path(r'^users/firstboot/$', public(views.FirstBootView.as_view()),
|
||||||
name='firstboot'),
|
name='firstboot'),
|
||||||
re_path(r'accounts/login/locked/$', public(CaptchaView.as_view()),
|
re_path(r'accounts/login/locked/$', public(views.CaptchaView.as_view()),
|
||||||
name='locked_out'),
|
name='locked_out'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
"""Django views for user app."""
|
"""Django views for user app."""
|
||||||
|
|
||||||
|
import axes.utils
|
||||||
import django.views.generic
|
import django.views.generic
|
||||||
|
from django import shortcuts
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import logout as auth_logout
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.auth.views import LoginView as DjangoLoginView
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
from django.utils.translation import gettext_lazy
|
from django.utils.translation import gettext_lazy
|
||||||
from django.views.generic.edit import (CreateView, FormView, UpdateView)
|
from django.views.decorators.http import require_POST
|
||||||
|
from django.views.generic.edit import CreateView, FormView, UpdateView
|
||||||
|
|
||||||
import plinth.modules.ssh.privileged as ssh_privileged
|
import plinth.modules.ssh.privileged as ssh_privileged
|
||||||
from plinth import translation
|
from plinth import translation
|
||||||
@ -18,8 +25,67 @@ from plinth.utils import is_user_admin
|
|||||||
from plinth.views import AppView
|
from plinth.views import AppView
|
||||||
|
|
||||||
from . import privileged
|
from . import privileged
|
||||||
from .forms import (CreateUserForm, FirstBootForm, UserChangePasswordForm,
|
from .forms import (AuthenticationForm, CaptchaForm, CreateUserForm,
|
||||||
UserUpdateForm)
|
FirstBootForm, UserChangePasswordForm, UserUpdateForm)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginView(DjangoLoginView):
|
||||||
|
"""View to login to FreedomBox and set language preference."""
|
||||||
|
|
||||||
|
redirect_authenticated_user = True
|
||||||
|
template_name = 'users_login.html'
|
||||||
|
form_class = AuthenticationForm
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Handle a request and return a HTTP response."""
|
||||||
|
response = super().dispatch(request, *args, **kwargs)
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
translation.set_language(request, response,
|
||||||
|
request.user.userprofile.language)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaView(FormView):
|
||||||
|
"""A simple form view with a CAPTCHA image.
|
||||||
|
|
||||||
|
When a user performs too many login attempts, they will no longer be able
|
||||||
|
to login with the typical login view. They will be redirected to this view.
|
||||||
|
On successfully solving the CAPTCHA in this form, their ability to use the
|
||||||
|
login form will be reset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = 'users_captcha.html'
|
||||||
|
form_class = CaptchaForm
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
"""Reset login attempts and redirect to login page."""
|
||||||
|
axes.utils.reset_request(self.request)
|
||||||
|
return shortcuts.redirect('users:login')
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def logout(request):
|
||||||
|
"""Logout an authenticated user, remove SSO cookie and redirect to home."""
|
||||||
|
auth_logout(request)
|
||||||
|
response = shortcuts.redirect('index')
|
||||||
|
|
||||||
|
# HACK: Remove Apache OpenID Connect module's session. This will logout all
|
||||||
|
# the apps using mod_auth_openidc for their authentication and
|
||||||
|
# authorization. A better way to do this is to implement OpenID Connect's
|
||||||
|
# Back-Channel Logout[1] or using OpenID Connect Session Management[2].
|
||||||
|
# With this scheme, each application will register a logout URL during
|
||||||
|
# client registration. The OpenID Provider (FreedomBox service) will call
|
||||||
|
# this URL with appropriate parameters to perform logout on all the apps.
|
||||||
|
# Support for OpenID Connect Back-Channel Logout is currently under
|
||||||
|
# review[3].
|
||||||
|
# 1. https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||||
|
# 2. https://openid.net/specs/openid-connect-session-1_0.html
|
||||||
|
# 3. https://github.com/django-oauth/django-oauth-toolkit/pull/1573
|
||||||
|
response.delete_cookie('mod_auth_openidc_session')
|
||||||
|
|
||||||
|
messages.success(request, _('Logged out successfully.'))
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ContextMixin:
|
class ContextMixin:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user