Skip to content

Commit

Permalink
Merge pull request #1958 from digitalfabrik/develop
Browse files Browse the repository at this point in the history
Release `2022.12.2`
  • Loading branch information
timobrembeck authored Dec 9, 2022
2 parents bd84534 + 721b4f1 commit fcc3b38
Show file tree
Hide file tree
Showing 32 changed files with 1,544 additions and 409 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
UNRELEASED
----------

* [ [#686](https://github.com/digitalfabrik/integreat-cms/issues/686) ] Improve page filter
* [ [#1132](https://github.com/digitalfabrik/integreat-cms/issues/1132) ] Add TOTP 2-factor authentication
* [ [#1884](https://github.com/digitalfabrik/integreat-cms/issues/1884) ] Add support for passwordless authentication


2022.12.1
---------
Expand Down
4 changes: 2 additions & 2 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ verify_ssl = true
black = "*"
bumpver = "*"
django-polymorphic = "*"
djlint = "*"
ipython = "*"
packaging = "*"
pre-commit = "*"
Expand All @@ -19,14 +20,13 @@ pytest-cov = "*"
pytest-django = "*"
pytest-testmon = "*"
pytest-xdist = "*"
requests-mock = "*"
shellcheck-py = "*"
sphinx = "*"
sphinx-last-updated-by-git = "*"
sphinx-rtd-theme = "*"
sphinxcontrib-django2 = "*"
twine = "*"
djlint = "*"
requests-mock = "*"

[packages]
integreat-cms = {editable = true, path = "."}
Expand Down
658 changes: 344 additions & 314 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion integreat_cms/cms/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def user_logged_in_callback(sender, request, user, **kwargs):
:type \**kwargs: dict
"""
ip = request.META.get("REMOTE_ADDR")
authlog.info("login user=%s, ip=%s", user, ip)
authlog.info("login user=%r, ip=%s", user, ip)


# pylint: disable=unused-argument
Expand Down
96 changes: 95 additions & 1 deletion integreat_cms/cms/forms/pages/page_filter_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.utils.translation import gettext_lazy as _

from ..custom_filter_form import CustomFilterForm
from ...constants import translation_status
from ...constants import translation_status, status

logger = logging.getLogger(__name__)

Expand All @@ -14,6 +14,30 @@ class PageFilterForm(CustomFilterForm):
Form for filtering page objects
"""

status = forms.ChoiceField(
label=_("Publication status"),
choices=(("", _("All")),) + status.CHOICES,
required=False,
)

date_from = forms.DateField(
label=_("From"),
widget=forms.DateInput(
format="%Y-%m-%d",
attrs={"type": "date", "class": "default-value", "data-default-value": ""},
),
required=False,
)

date_to = forms.DateField(
label=_("To"),
widget=forms.DateInput(
format="%Y-%m-%d",
attrs={"type": "date", "class": "default-value", "data-default-value": ""},
),
required=False,
)

translation_status = forms.MultipleChoiceField(
label=_("Translation status"),
widget=forms.CheckboxSelectMultiple(attrs={"class": "default-checked"}),
Expand Down Expand Up @@ -53,6 +77,12 @@ def apply(self, pages, language_slug):
pages = self.filter_by_query(pages, language_slug)
if "translation_status" in self.changed_data:
pages = self.filter_by_translation_status(pages, language_slug)
if "status" in self.changed_data:
pages = self.filter_by_publication_status(pages, language_slug)
if "date_from" in self.changed_data:
pages = self.filter_by_start_date(pages, language_slug)
if "date_to" in self.changed_data:
pages = self.filter_by_end_date(pages, language_slug)
return pages

def filter_by_query(self, pages, language_slug):
Expand Down Expand Up @@ -100,3 +130,67 @@ def filter_by_translation_status(self, pages, language_slug):
if translation_state in selected_status:
filtered_pages.append(page)
return filtered_pages

def filter_by_publication_status(self, pages, language_slug):
"""
Filter the pages list by publication status
:param pages: The list of pages
:type pages: list
:param language_slug: The slug of the current language
:type language_slug: str
:return: The filtered page list
:rtype: list
"""
selected_status = self.cleaned_data["status"]
# Buffer variable because the pages list should not be modified during iteration
filtered_pages = []
for page in pages:
translation = page.get_translation(language_slug)
if translation and translation.status == selected_status:
filtered_pages.append(page)
return filtered_pages

def filter_by_start_date(self, pages, language_slug):
"""
Filter the pages list by start date
:param pages: The list of pages
:type pages: list
:param language_slug: The slug of the current language
:type language_slug: str
:return: The filtered page list
:rtype: list
"""
selected_start_date = self.cleaned_data["date_from"]
filtered_pages = []
for page in pages:
translation = page.get_translation(language_slug)
if translation and translation.last_updated.date() >= selected_start_date:
filtered_pages.append(page)
return filtered_pages

def filter_by_end_date(self, pages, language_slug):
"""
Filter the pages list by end date
:param pages: The list of pages
:type pages: list
:param language_slug: The slug of the current language
:type language_slug: str
:return: The filtered page list
:rtype: list
"""
selected_end_date = self.cleaned_data["date_to"]
filtered_pages = []
for page in pages:
translation = page.get_translation(language_slug)
if translation and translation.last_updated.date() <= selected_end_date:
filtered_pages.append(page)
return filtered_pages
108 changes: 108 additions & 0 deletions integreat_cms/cms/forms/users/passwordless_authentication_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UsernameField
from django.db.models import Q
from django.forms import ValidationError
from django.utils.translation import gettext_lazy as _

from ...utils.translation_utils import gettext_many_lazy as __


class PasswordlessAuthenticationForm(forms.Form):
"""
Form class for authenticating users without using passwords but other authentication methods like FIDO2 or TOTP.
"""

username = UsernameField(widget=forms.TextInput(attrs={"autofocus": True}))

#: The user who tries to login without password
user = None

#: The different reasons why passwordless authentication might not be possible
error_messages = {
"invalid_login": _("The username or email address is incorrect."),
"inactive": _("This account is inactive."),
"disabled": __(
_("Your account is not activated for passwordless authentication."),
_("Please use the default login."),
),
"not_available": _(
"In order to use passwordless authentication, you have to configure at least one 2-factor authentication method."
),
}

def __init__(
self,
*args,
request=None,
**kwargs,
):
r"""
Render passwordless authentication form for HTTP GET requests
:param request: Object representing the user call
:type request: ~django.http.HttpRequest
:param \*args: The supplied arguments
:type \*args: list
:param \**kwargs: The supplied keyword arguments
:type \**kwargs: dict
"""
self.request = request
super().__init__(*args, **kwargs)

# Set the max length and label for the "username" field.
self.username_field = get_user_model()._meta.get_field(
get_user_model().USERNAME_FIELD
)
username_max_length = self.username_field.max_length or 254
self.fields["username"].max_length = username_max_length
self.fields["username"].widget.attrs["maxlength"] = username_max_length

def clean_username(self):
"""
Checks the input of the user to enable authentication
:raises ~django.core.exceptions.ValidationError: If the given username or email is invalid
:return: The cleaned username
:rtype: str
"""
username = self.cleaned_data.get("username")

self.user = (
get_user_model()
.objects.filter(Q(username=username) | Q(email=username))
.first()
)

# Check whether valid username was given
if not self.user:
raise ValidationError(
self.error_messages["invalid_login"],
code="invalid",
)

# Check whether the user is allowed to login
if not self.user.is_active:
raise ValidationError(
self.error_messages["inactive"],
code="inactive",
)

# Check whether the user is enabled for passwordless authentication
if not self.user.passwordless_authentication_enabled:
raise ValidationError(
self.error_messages["disabled"],
code="disabled",
)

# Check whether a 2-factor authentication method is available
if not self.user.mfa_keys.exists() and not self.user.totp_key:
raise ValidationError(
self.error_messages["not_available"],
code="not_available",
)

return username
9 changes: 9 additions & 0 deletions integreat_cms/cms/forms/users/user_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class Meta:
"regions",
"role",
"send_activation_link",
"passwordless_authentication_enabled",
]
field_classes = {"organization": OrganizationField}

Expand Down Expand Up @@ -111,6 +112,14 @@ def __init__(self, **kwargs):
self.fields["is_staff"].label = _("Integreat team member")
self.fields["email"].required = True

# Check if passwordless authentication is possible for the user
if (
"passwordless_authentication_enabled" in self.fields
and self.instance.totp_key is None
and not self.instance.mfa_keys.exists()
):
self.fields["passwordless_authentication_enabled"].disabled = True

def save(self, commit=True):
"""
This method extends the default ``save()``-method of the base :class:`~django.forms.ModelForm` to set attributes
Expand Down
36 changes: 36 additions & 0 deletions integreat_cms/cms/migrations/0054_user_totp_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 3.2.16 on 2022-11-06 20:59
from django.db import migrations, models


class Migration(migrations.Migration):
"""
Add a TOTP_key field to the user model to generate time-based on-time passwords for authentication.
"""

dependencies = [
("cms", "0053_alter_role_name"),
]

operations = [
migrations.AddField(
model_name="user",
name="totp_key",
field=models.CharField(
blank=True,
default=None,
help_text="Will be used to generate TOTP codes",
max_length=128,
null=True,
verbose_name="TOTP key",
),
),
migrations.AddField(
model_name="user",
name="passwordless_authentication_enabled",
field=models.BooleanField(
default=False,
help_text="Enable this option to activate the passwordless login routine for this account",
verbose_name="Enable passwordless authentication",
),
),
]
15 changes: 15 additions & 0 deletions integreat_cms/cms/models/users/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ class User(AbstractUser, AbstractBaseModel):
"Will be set to true once the user dismissed the page tree tutorial"
),
)
totp_key = models.CharField(
default=None,
null=True,
blank=True,
max_length=128,
verbose_name=_("TOTP key"),
help_text=_("Will be used to generate TOTP codes"),
)
passwordless_authentication_enabled = models.BooleanField(
default=False,
verbose_name=_("Enable passwordless authentication"),
help_text=_(
"Enable this option to activate the passwordless login routine for this account"
),
)

#: Custom model manager for user objects
objects = CustomUserManager()
Expand Down
24 changes: 24 additions & 0 deletions integreat_cms/cms/templates/authentication/passwordless_login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{% extends "authentication/_base.html" %}
{% load i18n %}
{% load static %}
{% load widget_tweaks %}
{% block heading %}
{% translate "Login" %}
{% endblock heading %}
{% block content %}
<form method="post" class="flex flex-col">
{% csrf_token %}
{% if form.errors %}
<div class="bg-red-100 border-l-4 border-red-500 text-red-500 px-4 py-3 mb-4"
role="alert">
<p>{{ form.errors.username }}</p>
</div>
{% endif %}
<div class="mb-4">
<label for="{{ form.username.id_for_label }}">{% translate "Username or email address" %}</label>
{% blocktrans asvar username_placeholder %}Enter your username or email address here{% endblocktrans %}
{% render_field form.username|add_error_class:"border-red-500" placeholder=username_placeholder %}
</div>
<button class="btn">{% translate "Log in" %}</button>
</form>
{% endblock content %}
Loading

0 comments on commit fcc3b38

Please sign in to comment.