460 lines
16 KiB
Python
460 lines
16 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2022 Entr'ouvert
|
|
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
import datetime
|
|
import logging
|
|
import uuid
|
|
|
|
from django.apps import apps
|
|
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import Max
|
|
from django.shortcuts import render, reverse
|
|
from django.utils.formats import date_format
|
|
from django.utils.html import format_html
|
|
from django.utils.text import capfirst
|
|
from django.utils.translation import gettext_lazy as _
|
|
from django.utils.translation import pgettext_lazy
|
|
|
|
from authentic2 import views
|
|
from authentic2.a2_rbac.models import Role
|
|
from authentic2.data_transfer import search_ou, search_role
|
|
from authentic2.manager.utils import label_from_role
|
|
from authentic2.models import Attribute
|
|
from authentic2.utils.evaluate import condition_validator, evaluate_condition
|
|
from authentic2.utils.template import validate_condition_template
|
|
|
|
from .query import AuthenticatorManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthenticatorImportError(Exception):
|
|
pass
|
|
|
|
|
|
class BaseAuthenticator(models.Model):
|
|
uuid = models.CharField(max_length=255, unique=True, default=uuid.uuid4, editable=False)
|
|
name = models.CharField(_('Name'), blank=True, max_length=128)
|
|
slug = models.SlugField(unique=True)
|
|
ou = models.ForeignKey(
|
|
verbose_name=_('organizational unit'),
|
|
to='a2_rbac.OrganizationalUnit',
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.CASCADE,
|
|
)
|
|
order = models.IntegerField(_('Order'), default=0, editable=False)
|
|
enabled = models.BooleanField(default=False, editable=False)
|
|
show_condition = models.CharField(
|
|
_('Show condition'),
|
|
max_length=1024,
|
|
blank=True,
|
|
default='',
|
|
help_text=_(
|
|
'Condition controlling authenticator display. For example, "\'backoffice\' in '
|
|
'login_hint or remote_addr == \'1.2.3.4\'" would hide the authenticator from normal users '
|
|
'except if they come from the specified IP address. Available variables include '
|
|
'service_ou_slug, service_slug, remote_addr, login_hint and headers.'
|
|
),
|
|
validators=[condition_validator],
|
|
)
|
|
button_description = models.CharField(
|
|
_('Login block description'),
|
|
max_length=256,
|
|
blank=True,
|
|
help_text=_('Description will be shown at the top of login block (unless already set by theme).'),
|
|
)
|
|
button_label = models.CharField(_('Login button label'), max_length=256, default=_('Login'))
|
|
|
|
objects = models.Manager()
|
|
authenticators = AuthenticatorManager()
|
|
|
|
type = ''
|
|
related_models = {}
|
|
related_object_form_class = None
|
|
manager_view_template_name = 'authentic2/authenticators/authenticator_detail.html'
|
|
unique = False
|
|
protected = False
|
|
description_fields = ['show_condition']
|
|
empty_field_labels = {'show_condition': pgettext_lazy('show condition', 'None')}
|
|
|
|
class Meta:
|
|
ordering = ('-enabled', 'order', 'name', 'slug', 'ou')
|
|
|
|
def __str__(self):
|
|
if not self.unique:
|
|
return '%s - %s' % (self._meta.verbose_name, self.name or self.slug)
|
|
return str(self._meta.verbose_name)
|
|
|
|
@property
|
|
def manager_form_classes(self):
|
|
return [(_('General'), self.manager_form_class)]
|
|
|
|
def get_identifier(self):
|
|
return self.type if self.unique else '%s_%s' % (self.type, self.slug)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse('a2-manager-authenticator-detail', kwargs={'pk': self.pk})
|
|
|
|
def get_short_description(self):
|
|
return ''
|
|
|
|
def get_full_description(self):
|
|
for field in self.description_fields:
|
|
if hasattr(self, 'get_%s_display' % field):
|
|
value = getattr(self, 'get_%s_display' % field)()
|
|
else:
|
|
value = getattr(self, field)
|
|
|
|
value = value or self.empty_field_labels.get(field)
|
|
if not value:
|
|
continue
|
|
|
|
if isinstance(value, datetime.datetime):
|
|
value = date_format(value, 'DATETIME_FORMAT')
|
|
elif isinstance(value, bool):
|
|
value = _('Yes') if value else _('No')
|
|
|
|
yield format_html(
|
|
_('{field}: {value}'),
|
|
field=capfirst(self._meta.get_field(field).verbose_name),
|
|
value=value,
|
|
)
|
|
|
|
def is_for_office(self, office_keyword, ctx):
|
|
try:
|
|
return evaluate_condition(
|
|
settings.AUTHENTICATOR_SHOW_CONDITIONS[office_keyword], ctx, on_raise=False
|
|
)
|
|
except Exception as e:
|
|
logger.error(e)
|
|
return False
|
|
|
|
def shown(self, ctx=()):
|
|
if not self.show_condition:
|
|
return True
|
|
|
|
def is_for_backoffice():
|
|
return self.is_for_office('is_for_backoffice', ctx)
|
|
|
|
def is_for_frontoffice():
|
|
return self.is_for_office('is_for_frontoffice', ctx)
|
|
|
|
ctx = dict(
|
|
ctx, id=self.slug, is_for_backoffice=is_for_backoffice, is_for_frontoffice=is_for_frontoffice
|
|
)
|
|
try:
|
|
return evaluate_condition(self.show_condition, ctx, on_raise=True)
|
|
except Exception as e:
|
|
logger.error(e)
|
|
return False
|
|
|
|
def has_valid_configuration(self):
|
|
for _, form_class in self.manager_form_classes:
|
|
try:
|
|
self.full_clean(exclude=getattr(form_class._meta, 'exclude', None))
|
|
except ValidationError:
|
|
return False
|
|
return True
|
|
|
|
def export_json(self):
|
|
data = {
|
|
'authenticator_type': '%s.%s' % (self._meta.app_label, self._meta.model_name),
|
|
}
|
|
|
|
fields = [
|
|
f for f in self._meta.get_fields() if not f.is_relation and not f.auto_created and f.editable
|
|
]
|
|
data.update({field.name: getattr(self, field.attname) for field in fields})
|
|
|
|
data['ou'] = self.ou and self.ou.natural_key_json()
|
|
data['related_objects'] = [obj.export_json() for qs in self.related_models.values() for obj in qs]
|
|
|
|
return data
|
|
|
|
@staticmethod
|
|
def import_json(data):
|
|
def get_model_from_dict(data, key):
|
|
try:
|
|
model_name = data.pop(key)
|
|
except KeyError:
|
|
raise AuthenticatorImportError(_('Missing "%s" key.') % key)
|
|
|
|
try:
|
|
return apps.get_model(model_name)
|
|
except LookupError:
|
|
raise AuthenticatorImportError(
|
|
_('Unknown %(key)s: %(value)s.') % {'key': key, 'value': model_name}
|
|
)
|
|
except ValueError:
|
|
raise AuthenticatorImportError(
|
|
_('Invalid %(key)s: %(value)s.') % {'key': key, 'value': model_name}
|
|
)
|
|
|
|
related_objects = data.pop('related_objects', [])
|
|
|
|
ou = data.pop('ou', None)
|
|
if ou:
|
|
data['ou'] = search_ou(ou)
|
|
if not data['ou']:
|
|
raise AuthenticatorImportError(_('Organization unit not found: %s.') % ou)
|
|
|
|
model = get_model_from_dict(data, 'authenticator_type')
|
|
try:
|
|
slug = data.pop('slug')
|
|
except KeyError:
|
|
raise AuthenticatorImportError(_('Missing slug.'))
|
|
authenticator, created = model.objects.update_or_create(slug=slug, defaults=data)
|
|
|
|
for obj in related_objects:
|
|
model = get_model_from_dict(obj, 'object_type')
|
|
model.import_json(obj, authenticator)
|
|
|
|
if created:
|
|
max_order = BaseAuthenticator.objects.aggregate(max=Max('order'))['max'] or 0
|
|
authenticator.order = max_order + 1
|
|
authenticator.save()
|
|
|
|
return authenticator, created
|
|
|
|
|
|
class AuthenticatorRelatedObjectBase(models.Model):
|
|
authenticator = models.ForeignKey(BaseAuthenticator, on_delete=models.CASCADE)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def get_journal_text(self):
|
|
return '%s (%s)' % (self._meta.verbose_name, self.pk)
|
|
|
|
@property
|
|
def model_name(self):
|
|
return self._meta.model_name
|
|
|
|
@property
|
|
def verbose_name_plural(self):
|
|
return self._meta.verbose_name_plural
|
|
|
|
def export_json(self):
|
|
data = {
|
|
'object_type': '%s.%s' % (self._meta.app_label, self._meta.model_name),
|
|
}
|
|
|
|
fields = [
|
|
f for f in self._meta.get_fields() if not f.is_relation and not f.auto_created and f.editable
|
|
]
|
|
data.update({field.name: getattr(self, field.attname) for field in fields})
|
|
return data
|
|
|
|
@classmethod
|
|
def import_json(cls, data, authenticator):
|
|
cls.objects.update_or_create(authenticator=authenticator, **data)
|
|
|
|
|
|
class AddRoleAction(AuthenticatorRelatedObjectBase):
|
|
role = models.ForeignKey(Role, verbose_name=_('Role'), on_delete=models.CASCADE)
|
|
mandatory = models.BooleanField(_('Mandatory (unused)'), editable=False, default=False)
|
|
|
|
condition = models.CharField(
|
|
_('Condition'),
|
|
max_length=1024,
|
|
blank=True,
|
|
default='',
|
|
help_text=_(
|
|
'Django condition controlling role attribution. For example, "\'Admin\' in attributes.groups"'
|
|
' will attribute the role if attributes has "groups" attribute containing the value'
|
|
' "Admin". Variable "attributes" contains the attributes received from the identity provider. '
|
|
'If condition is not satisfied the role will be removed.'
|
|
),
|
|
validators=[validate_condition_template],
|
|
)
|
|
|
|
description = _('Add roles to users on successful login.')
|
|
|
|
class Meta:
|
|
default_related_name = 'add_role_actions'
|
|
verbose_name = _('Add a role')
|
|
verbose_name_plural = _('Add roles')
|
|
|
|
def __str__(self):
|
|
if self.condition:
|
|
return _('%s (depending on condition)') % (label_from_role(self.role))
|
|
return label_from_role(self.role)
|
|
|
|
def export_json(self):
|
|
data = super().export_json()
|
|
data['role'] = self.role.natural_key_json()
|
|
return data
|
|
|
|
@classmethod
|
|
def import_json(cls, data, authenticator):
|
|
try:
|
|
role = data.pop('role')
|
|
except KeyError:
|
|
raise AuthenticatorImportError(_('Missing "role" key in add role action.'))
|
|
|
|
data['role'] = search_role(role)
|
|
if not data['role']:
|
|
raise AuthenticatorImportError(_('Role not found: %s.') % role)
|
|
|
|
super().import_json(data, authenticator)
|
|
|
|
|
|
class LoginPasswordAuthenticator(BaseAuthenticator):
|
|
MIN_PASSWORD_STRENGTH_CHOICES = (
|
|
(None, _('Follow static checks')),
|
|
(0, _('Very Weak')),
|
|
(1, _('Weak')),
|
|
(2, _('Fair')),
|
|
(3, _('Good')),
|
|
(4, _('Strong')),
|
|
)
|
|
|
|
registration_open = models.BooleanField(
|
|
_('Registration open'), default=True, help_text=_('Allow users to create accounts.')
|
|
)
|
|
remember_me = models.PositiveIntegerField(
|
|
_('Remember me duration'),
|
|
blank=True,
|
|
null=True,
|
|
help_text=_(
|
|
'Session duration as seconds when using the remember me checkbox. Leave blank to hide the checkbox.'
|
|
),
|
|
)
|
|
include_ou_selector = models.BooleanField(_('Include OU selector in login form'), default=False)
|
|
accept_email_authentication = models.BooleanField(
|
|
_('Let the users identify with their email address'), default=True
|
|
)
|
|
accept_phone_authentication = models.BooleanField(
|
|
_('Let the users identify with their phone number'), default=False
|
|
)
|
|
phone_identifier_field = models.ForeignKey(
|
|
Attribute,
|
|
verbose_name=_('Phone field used as user identifier'),
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
|
|
min_password_strength = models.IntegerField(
|
|
verbose_name=_('Minimum password strength'),
|
|
choices=MIN_PASSWORD_STRENGTH_CHOICES,
|
|
default=3,
|
|
blank=True,
|
|
null=True,
|
|
help_text=_(
|
|
'Password strength, using dynamic indicators such as common names, dates and other '
|
|
'popular patterns. Selecting "static checks" will instead validate that a password '
|
|
'contains enough different kind of caracters. Password indicator on registration '
|
|
'form will reflect the chosen policy.'
|
|
),
|
|
)
|
|
password_min_length = models.PositiveIntegerField(_('Password minimum length'), default=8, null=True)
|
|
password_regex = models.CharField(
|
|
_('Regular expression for validating passwords'), max_length=512, blank=True, default=''
|
|
)
|
|
password_regex_error_msg = models.CharField(
|
|
_('Error message to show when the password do not validate the regular expression'),
|
|
max_length=1024,
|
|
blank=True,
|
|
default='',
|
|
)
|
|
|
|
login_exponential_retry_timeout_duration = models.FloatField(
|
|
_('Retry timeout duration'),
|
|
default=1,
|
|
help_text=_(
|
|
'Exponential backoff base factor duration as seconds until next try after a login failure.'
|
|
),
|
|
)
|
|
login_exponential_retry_timeout_factor = models.FloatField(
|
|
_('Retry timeout factor'),
|
|
default=1.8,
|
|
help_text=_('Exponential backoff factor duration as seconds until next try after a login failure.'),
|
|
)
|
|
login_exponential_retry_timeout_max_duration = models.PositiveIntegerField(
|
|
_('Retry timeout max duration'),
|
|
default=3600,
|
|
help_text=_(
|
|
'Maximum exponential backoff maximum duration as seconds until next try after a login failure.'
|
|
),
|
|
)
|
|
login_exponential_retry_timeout_min_duration = models.PositiveIntegerField(
|
|
_('Retry timeout min duration'),
|
|
default=10,
|
|
help_text=_(
|
|
'Minimum exponential backoff maximum duration as seconds until next try after a login failure.'
|
|
),
|
|
)
|
|
|
|
emails_ip_ratelimit = models.CharField(
|
|
_('Emails IP ratelimit'),
|
|
default='10/h',
|
|
max_length=32,
|
|
help_text=_('Maximum rate of email sendings triggered by the same IP address.'),
|
|
)
|
|
sms_ip_ratelimit = models.CharField(
|
|
_('SMS IP ratelimit'),
|
|
default='10/h',
|
|
max_length=32,
|
|
help_text=_('Maximum rate of SMSs triggered by the same IP address.'),
|
|
)
|
|
emails_address_ratelimit = models.CharField(
|
|
_('Emails address ratelimit'),
|
|
default='3/d',
|
|
max_length=32,
|
|
help_text=_('Maximum rate of emails sent to the same email address.'),
|
|
)
|
|
sms_number_ratelimit = models.CharField(
|
|
_('SMS number ratelimit'),
|
|
default='10/h',
|
|
max_length=32,
|
|
help_text=_('Maximum rate of SMSs sent to the same phone number.'),
|
|
)
|
|
|
|
type = 'password'
|
|
how = ['password', 'password-on-https']
|
|
unique = True
|
|
protected = True
|
|
|
|
class Meta:
|
|
verbose_name = _('Password')
|
|
|
|
@property
|
|
def is_phone_authn_active(self):
|
|
return bool(self.accept_phone_authentication and self.phone_identifier_field)
|
|
|
|
@property
|
|
def manager_form_classes(self):
|
|
from .forms import LoginPasswordAuthenticatorAdvancedForm, LoginPasswordAuthenticatorEditForm
|
|
|
|
return [
|
|
(_('General'), LoginPasswordAuthenticatorEditForm),
|
|
(_('Advanced'), LoginPasswordAuthenticatorAdvancedForm),
|
|
]
|
|
|
|
def login(self, request, *args, **kwargs):
|
|
return views.login_password_login(request, self, *args, **kwargs)
|
|
|
|
def profile(self, request, *args, **kwargs):
|
|
return views.login_password_profile(request, *args, **kwargs)
|
|
|
|
def registration(self, request, *args, **kwargs):
|
|
context = kwargs.get('context', {})
|
|
return render(request, 'authentic2/login_password_registration_form.html', context)
|