authentic/src/authentic2/custom_user/models.py

420 lines
15 KiB
Python

# coding: utf-8
# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 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/>.
from __future__ import unicode_literals
import base64
import datetime
import os
from django.core.exceptions import MultipleObjectsReturned, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.utils import six, timezone
from django.utils.translation import ugettext_lazy as _
try:
from django.contrib.contenttypes.fields import GenericRelation
except ImportError:
from django.contrib.contenttypes.generic import GenericRelation
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import JSONField
from authentic2 import app_settings, utils
from authentic2.decorators import RequestCache, errorcollector
from authentic2.models import Attribute, AttributeValue, Service
from authentic2.validators import email_validator
from django_rbac.models import PermissionMixin
from django_rbac.utils import get_role_parenting_model
from .managers import UserManager, UserQuerySet
@RequestCache
def get_attributes_map():
mapping = {}
for at in Attribute.objects.all():
mapping[at.id] = at
mapping[at.name] = at
return mapping
def iter_attributes():
for key, value in get_attributes_map().items():
if isinstance(key, str):
yield value
class Attributes(object):
def __init__(self, owner, verified=None):
self.__dict__['owner'] = owner
self.__dict__['verified'] = verified
if not hasattr(self.owner, '_a2_attributes_cache'):
values = {}
setattr(self.owner, '_a2_attributes_cache', values)
for atv in self.owner.attribute_values.filter(attribute__disabled=False):
attribute = get_attributes_map()[atv.attribute_id]
atv.attribute = attribute
if attribute.multiple:
values.setdefault(attribute.name, []).append(atv)
else:
values[attribute.name] = atv
self.__dict__['values'] = owner._a2_attributes_cache
def __setattr__(self, name, value):
attribute = get_attributes_map().get(name)
if not attribute:
raise AttributeError(name)
with transaction.atomic():
if attribute.multiple:
attribute.set_value(self.owner, value, verified=bool(self.verified))
else:
atv = self.values.get(name)
self.values[name] = attribute.set_value(
self.owner, value, verified=bool(self.verified), attribute_value=atv
)
update_fields = ['modified']
if name in ['first_name', 'last_name']:
if getattr(self.owner, name) != value:
setattr(self.owner, name, value)
update_fields.append(name)
self.owner.save(update_fields=update_fields)
def __getattr__(self, name):
if name not in get_attributes_map():
raise AttributeError(name)
atv = self.values.get(name)
if self.verified and (not atv or not atv.verified):
return None
if atv:
if not isinstance(atv, (list, tuple)):
return atv.to_python()
else:
# multiple
return [x.to_python() for x in atv]
return None
class AttributesDescriptor(object):
def __init__(self, verified=None):
self.verified = verified
def __get__(self, obj, objtype):
return Attributes(obj, verified=self.verified)
class IsVerified(object):
def __init__(self, user):
self.user = user
def __getattr__(self, name):
v = getattr(self.user.attributes, name, None)
return v is not None and v == getattr(self.user.verified_attributes, name, None)
class IsVerifiedDescriptor(object):
def __get__(self, obj, objtype):
return IsVerified(obj)
class User(AbstractBaseUser, PermissionMixin):
"""
An abstract base class implementing a fully featured User model with
admin-compliant permissions.
Username, password and email are required. Other fields are optional.
"""
uuid = models.CharField(_('uuid'), max_length=32, default=utils.get_hex_uuid, editable=False, unique=True)
username = models.CharField(_('username'), max_length=256, null=True, blank=True)
first_name = models.CharField(_('first name'), max_length=128, blank=True)
last_name = models.CharField(_('last name'), max_length=128, blank=True)
email = models.EmailField(_('email address'), blank=True, max_length=254, validators=[email_validator])
email_verified = models.BooleanField(default=False, verbose_name=_('email verified'))
is_staff = models.BooleanField(
_('staff status'),
default=False,
help_text=_('Designates whether the user can log into this admin ' 'site.'),
)
is_active = models.BooleanField(
_('active'),
default=True,
help_text=_(
'Designates whether this user should be treated as '
'active. Unselect this instead of deleting accounts.'
),
)
ou = models.ForeignKey(
verbose_name=_('organizational unit'),
to='a2_rbac.OrganizationalUnit',
blank=True,
null=True,
swappable=False,
on_delete=models.CASCADE,
)
# events dates
date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
modified = models.DateTimeField(verbose_name=_('Last modification time'), db_index=True, auto_now=True)
last_account_deletion_alert = models.DateTimeField(
verbose_name=_('Last account deletion alert'), null=True, blank=True
)
deactivation = models.DateTimeField(verbose_name=_('Deactivation datetime'), null=True, blank=True)
objects = UserManager.from_queryset(UserQuerySet)()
attributes = AttributesDescriptor()
verified_attributes = AttributesDescriptor(verified=True)
is_verified = IsVerifiedDescriptor()
attribute_values = GenericRelation('authentic2.AttributeValue')
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']
USER_PROFILE = ('first_name', 'last_name', 'email')
class Meta:
verbose_name = _('user')
verbose_name_plural = _('users')
ordering = ('last_name', 'first_name', 'email', 'username')
def get_full_name(self):
"""
Returns the first_name plus the last_name, with a space in between.
"""
full_name = '%s %s' % (self.first_name, self.last_name)
return full_name.strip() or self.username or self.email
def get_short_name(self):
"Returns the short name for the user."
return self.first_name or self.username or self.email or self.uuid[:6]
def email_user(self, subject, message, from_email=None):
"""
Sends an email to this User.
"""
send_mail(subject, message, from_email, [self.email])
def get_username(self):
"Return the identifying username for this User"
return self.username or self.email or self.get_full_name() or self.uuid
def roles_and_parents(self):
qs1 = self.roles.all()
qs2 = qs1.model.objects.filter(child_relation__child__in=qs1)
qs = (qs1 | qs2).order_by('name').distinct()
RoleParenting = get_role_parenting_model()
rp_qs = RoleParenting.objects.filter(child__in=qs1)
qs = qs.prefetch_related(models.Prefetch('child_relation', queryset=rp_qs), 'child_relation__parent')
qs = qs.prefetch_related(
models.Prefetch('members', queryset=self.__class__.objects.filter(pk=self.pk), to_attr='member')
)
return qs
def __str__(self):
human_name = self.username or self.email or self.get_full_name()
short_id = self.uuid[:6]
return '%s (%s)' % (human_name, short_id)
def __repr__(self):
return '<User: %r>' % six.text_type(self)
def clean(self):
if not (self.username or self.email or (self.first_name and self.last_name)):
raise ValidationError(
_(
'An account needs at least one identifier: '
'username, email or a full name (first and last name).'
)
)
def validate_unique(self, exclude=None):
errors = {}
with errorcollector(errors):
super(User, self).validate_unique(exclude=exclude)
exclude = exclude or []
model = self.__class__
qs = model.objects
if self.pk:
qs = qs.exclude(pk=self.pk)
if (
'username' not in exclude
and self.username
and (app_settings.A2_USERNAME_IS_UNIQUE or (self.ou and self.ou.username_is_unique))
):
username_qs = qs
if not app_settings.A2_USERNAME_IS_UNIQUE:
username_qs = qs.filter(ou=self.ou)
try:
try:
username_qs.get(username=self.username)
except MultipleObjectsReturned:
pass
except model.DoesNotExist:
pass
else:
errors.setdefault('username', []).append(
_('This username is already in use. Please supply a different username.')
)
if (
'email' not in exclude
and self.email
and (app_settings.A2_EMAIL_IS_UNIQUE or (self.ou and self.ou.email_is_unique))
):
email_qs = qs
if not app_settings.A2_EMAIL_IS_UNIQUE:
email_qs = qs.filter(ou=self.ou)
try:
try:
email_qs.get(email__iexact=self.email)
except MultipleObjectsReturned:
pass
except model.DoesNotExist:
pass
else:
errors.setdefault('email', []).append(
_('This email address is already in use. Please supply a different email ' 'address.')
)
if errors:
raise ValidationError(errors)
def natural_key(self):
return (self.uuid,)
def has_verified_attributes(self):
return AttributeValue.objects.with_owner(self).filter(verified=True).exists()
def to_json(self):
d = {}
attributes_map = get_attributes_map()
for av in AttributeValue.objects.with_owner(self):
attribute = attributes_map[av.attribute_id]
drf_field = attribute.get_drf_field()
d[str(attribute.name)] = drf_field.to_representation(av.to_python())
d.update(
{
'uuid': self.uuid,
'username': self.username,
'email': self.email,
'ou': self.ou.name if self.ou else None,
'ou__uuid': self.ou.uuid if self.ou else None,
'ou__slug': self.ou.slug if self.ou else None,
'ou__name': self.ou.name if self.ou else None,
'first_name': self.first_name,
'last_name': self.last_name,
'is_superuser': self.is_superuser,
'roles': [role.to_json() for role in self.roles_and_parents()],
'services': [
service.to_json(roles=self.roles_and_parents()) for service in Service.objects.all()
],
}
)
return d
def save(self, *args, **kwargs):
update_fields = kwargs.get('update_fields')
rc = super(User, self).save(*args, **kwargs)
if not update_fields or not set(update_fields).isdisjoint(set(['first_name', 'last_name'])):
try:
self.attributes.first_name
except AttributeError:
pass
else:
if self.attributes and self.attributes.first_name != self.first_name:
self.attributes.first_name = self.first_name
try:
self.attributes.last_name
except AttributeError:
pass
else:
if self.attributes.last_name != self.last_name:
self.attributes.last_name = self.last_name
return rc
def can_change_password(self):
return True
def refresh_from_db(self, *args, **kwargs):
if hasattr(self, '_a2_attributes_cache'):
del self._a2_attributes_cache
return super(User, self).refresh_from_db(*args, **kwargs)
def mark_as_inactive(self, timestamp=None):
self.is_active = False
self.deactivation = timestamp or timezone.now()
self.save(update_fields=['is_active', 'deactivation'])
def set_random_password(self):
self.set_password(base64.b64encode(os.urandom(32)).decode('ascii'))
@transaction.atomic
def delete(self, **kwargs):
deleted_user = DeletedUser(old_user_id=self.id)
if 'email' in app_settings.A2_USER_DELETED_KEEP_DATA:
deleted_user.old_email = self.email.rsplit('#', 1)[0]
if 'uuid' in app_settings.A2_USER_DELETED_KEEP_DATA:
deleted_user.old_uuid = self.uuid
# save LDAP account references
external_ids = self.userexternalid_set.order_by('id')
if external_ids.exists():
deleted_user.old_data = {'external_ids': []}
for external_id in external_ids:
deleted_user.old_data['external_ids'].append(
{
'source': external_id.source,
'external_id': external_id.external_id,
}
)
external_ids.delete()
deleted_user.save()
return super().delete(**kwargs)
class DeletedUser(models.Model):
deleted = models.DateTimeField(verbose_name=_('Deletion date'), auto_now_add=True)
old_uuid = models.TextField(verbose_name=_('Old UUID'), null=True, blank=True)
old_user_id = models.PositiveIntegerField(verbose_name=_('Old user id'), null=True, blank=True)
old_email = models.EmailField(verbose_name=_('Old email adress'), null=True, blank=True)
old_data = JSONField(verbose_name=_('Old data'), null=True, blank=True)
@classmethod
def cleanup(cls, threshold=None, timestamp=None):
threshold = threshold or (
timezone.now() - datetime.timedelta(days=app_settings.A2_USER_DELETED_KEEP_DATA_DAYS)
)
cls.objects.filter(deleted__lt=threshold).delete()
def __str__(self):
return 'DeletedUser(old_id=%s, old_uuid=%s…, old_email=%s)' % (
self.old_user_id or '-',
(self.old_uuid or '')[:6],
self.old_email or '-',
)
class Meta:
verbose_name = _('deleted user')
verbose_name_plural = _('deleted users')
ordering = ('deleted', 'id')