mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-03-11 09:04:54 +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 \
|
||||
mldonkey \
|
||||
i2p \
|
||||
ttrss
|
||||
ttrss \
|
||||
sso
|
||||
|
||||
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)
|
||||
|
||||
dropin_configs = DropinConfigs('dropin-configs-users', [
|
||||
'/etc/apache2/includes/freedombox-auth-ldap.conf',
|
||||
])
|
||||
dropin_configs = DropinConfigs(
|
||||
'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)
|
||||
|
||||
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 re
|
||||
|
||||
from captcha.fields import CaptchaField
|
||||
from django import forms
|
||||
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.hashers import check_password
|
||||
from django.contrib.auth.models import Group, User
|
||||
@ -25,6 +28,24 @@ from . import get_last_admin_user, privileged
|
||||
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:
|
||||
"""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)
|
||||
|
||||
|
||||
@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):
|
||||
"""Test creating a user."""
|
||||
_delete_user(session_browser, 'alice')
|
||||
|
||||
@ -6,7 +6,6 @@ URLs for the Users module
|
||||
from django.urls import re_path
|
||||
from stronghold.decorators import public
|
||||
|
||||
from plinth.modules.sso.views import CaptchaView, SSOLoginView, logout
|
||||
from plinth.utils import non_admin_view
|
||||
|
||||
from . import views
|
||||
@ -19,13 +18,11 @@ urlpatterns = [
|
||||
re_path(r'^sys/users/(?P<slug>[\w.@+-]+)/change_password/$',
|
||||
non_admin_view(views.UserChangePassword.as_view()),
|
||||
name='change_password'),
|
||||
|
||||
# Authnz is handled by SSO
|
||||
re_path(r'^accounts/login/$', public(SSOLoginView.as_view()),
|
||||
re_path(r'^accounts/login/$', public(views.LoginView.as_view()),
|
||||
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()),
|
||||
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'),
|
||||
]
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Django views for user app."""
|
||||
|
||||
import axes.utils
|
||||
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.models import User
|
||||
from django.contrib.auth.views import LoginView as DjangoLoginView
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
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
|
||||
from plinth import translation
|
||||
@ -18,8 +25,67 @@ from plinth.utils import is_user_admin
|
||||
from plinth.views import AppView
|
||||
|
||||
from . import privileged
|
||||
from .forms import (CreateUserForm, FirstBootForm, UserChangePasswordForm,
|
||||
UserUpdateForm)
|
||||
from .forms import (AuthenticationForm, CaptchaForm, CreateUserForm,
|
||||
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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user