# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
import logging
import pytz
import uuid
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.db import models
from django.db import transaction
from django.db.models import F
from django.db.models.query_utils import Q
from django.template.loader import render_to_string
from django.utils import translation
from django.utils.deconstruct import deconstructible
from django.utils.translation import ugettext_lazy as _
from lizard_auth_server.utils import gen_secret_key
BILLING_ROLE = 'billing'
THREEDI_PORTAL = '3Di'
logger = logging.getLogger(__name__)
@deconstructible
class GenKey(object):
"""
Helper function to give a unique default value to the selected
field in a model.
Note: Field.default needs to be serializable (a change since Django 1.7).
Here we use the deconstruct method described in:
https://code.djangoproject.com/ticket/22999
https://docs.djangoproject.com/en/1.9/topics/migrations/#adding-a-deconstruct-method
"""
def __init__(self, model, field):
self.model = model
self.field = field
def __call__(self):
if isinstance(self.model, str):
ModelClass = apps.get_app_config('lizard_auth_server').get_model(
self.model)
if not ModelClass:
raise Exception('Unknown model {}'.format(self.model))
else:
ModelClass = self.model
key = gen_secret_key(64)
while ModelClass.objects.filter(**{self.field: key}).exists():
key = gen_secret_key(64)
return key
def __eq__(self, other):
return all([self.model == other.model,
self.field == other.field])
[docs]class Portal(models.Model):
"""
A portal. If secret/key change, the portal website has to be updated too!
"""
name = models.CharField(
verbose_name=_('name'),
max_length=255,
null=False,
blank=False,
help_text=_('Name used to refer to this portal.'))
sso_secret = models.CharField(
verbose_name=_('shared secret'),
max_length=64,
unique=True,
default=GenKey('Portal', 'sso_secret'),
help_text=_('Secret shared between SSO client and '
'server to sign/encrypt communication.'))
sso_key = models.CharField(
verbose_name=_('identifying key'),
max_length=64,
unique=True,
default=GenKey('Portal', 'sso_key'),
help_text=_('String used to identify the SSO client.'))
allowed_domain = models.CharField(
verbose_name=_('allowed domain(s)'),
max_length=255,
default='unused in v2 api',
help_text=_(
'Allowed domain suffix for redirects using the next parameter. '
'Multiple, whitespace-separated suffixes may be specified.'))
redirect_url = models.CharField(
verbose_name=_('redirect url'),
max_length=255,
default='http://unused.in/v2/api',
help_text=_('URL used in the SSO redirection.'))
visit_url = models.CharField(
verbose_name=_('visit url'),
max_length=255,
help_text=_('URL used in the UI to refer to this portal.'))
def __str__(self):
return self.name
def rotate_keys(self):
self.sso_secret = GenKey(Portal, 'sso_secret')()
self.sso_key = GenKey(Portal, 'sso_key')()
self.save()
class Meta:
ordering = ('name',)
verbose_name = _('portal')
verbose_name_plural = _('portals')
class TokenManager(models.Manager):
def create_for_portal(self, portal):
"""
Create a new token for a portal object.
"""
request_token = gen_secret_key(64)
auth_token = gen_secret_key(64)
# check unique constraints
while self.filter(
Q(request_token=request_token) |
Q(auth_token=auth_token)).exists():
request_token = gen_secret_key(64)
auth_token = gen_secret_key(64)
return self.create(
portal=portal,
request_token=request_token,
auth_token=auth_token,
)
def token_creation_date():
return datetime.datetime.now(tz=pytz.UTC)
class Token(models.Model):
"""
An auth token used to authenticate a user.
"""
portal = models.ForeignKey(
Portal,
verbose_name=_('portal'))
request_token = models.CharField(
verbose_name=_('request token'),
max_length=64,
unique=True)
auth_token = models.CharField(
verbose_name=_('auth token'),
max_length=64,
unique=True)
user = models.ForeignKey(
User,
verbose_name=_('user'),
related_name='user_tokens',
blank=True,
null=True)
created = models.DateTimeField(
verbose_name=_('created at'),
default=token_creation_date
)
objects = TokenManager()
class Meta:
verbose_name = _('(authentication token)')
verbose_name_plural = _('(authentication tokens)')
ordering = ('-created',)
class UserProfileManager(models.Manager):
def fetch_for_user(self, user):
if not user:
raise AttributeError("Can't get UserProfile without user")
return self.get(user=user)
[docs]class UserProfile(models.Model):
user = models.OneToOneField(
User,
verbose_name=_('user'),
related_name='user_profile')
portals = models.ManyToManyField(
Portal,
verbose_name=_('portals'),
help_text="only used in the old v1 api",
related_name='user_profiles',
blank=True)
organisations = models.ManyToManyField(
"Organisation",
verbose_name=_('organisations'),
help_text="only used in the old v1 api",
related_name='user_profiles',
blank=True)
roles = models.ManyToManyField(
"OrganisationRole",
related_name='user_profiles',
help_text="only used in the old v1 api",
verbose_name=_('roles (via organisation)'),
blank=True)
created_at = models.DateTimeField(
verbose_name=_('created at'),
auto_now_add=True)
updated_at = models.DateTimeField(
verbose_name=_('updated on'),
auto_now=True)
objects = UserProfileManager()
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profiles')
ordering = ['user__username']
def __str__(self):
if self.user:
return '{}'.format(self.user)
else:
return 'UserProfile {}'.format(self.pk)
def update_all(self, data):
self.user.email = data['email']
self.user.first_name = data['first_name']
self.user.last_name = data['last_name']
self.user.save()
@property
def username(self):
return self.user.username
@property
def full_name(self):
return self.user.get_full_name()
@property
def first_name(self):
return self.user.first_name
@property
def last_name(self):
return self.user.last_name
@property
def email(self):
return self.user.email
@property
def organisation(self):
"""Return the name of one of this user's organisations, or None.
For backward compatibility. Instead of many Organisation objects, a
user used to have a single organisation string."""
try:
return self.organisations.all().order_by('id')[0:1].get().name
except Organisation.DoesNotExist:
return None
@property
def is_active(self):
"""
Returns True when the account is active, meaning the User has not been
deactivated by an admin.
Note: unrelated to account activation.
"""
return self.user.is_active
[docs] def has_access(self, portal):
"""
Returns True when this user has access to this portal.
"""
if not portal:
raise AttributeError('Need a valid Portal instance')
if self.user.is_staff:
# staff can access any site
return True
return self.portals.filter(pk=portal.pk).exists()
[docs] def all_organisation_roles(self, portal, return_explanation=False):
"""Return a queryset of OrganisationRoles that apply to this profile.
If ``return_explanation`` is True, return a dict with explanatory
results, instead.
"""
# First grab all applicable roles.
relevant_roles_tied_to_the_portal = Role.objects.filter(portal=portal)
# Two Q objects for filtering organisation roles I have access
# to. Either directly via my profile or via for_all_users.
tied_to_my_organisation_for_all_users = models.Q(
for_all_users=True,
organisation__user_profiles=self)
tied_to_my_user_profile = models.Q(user_profiles=self)
# All organisation roles I have access to. This does not yet take into
# account the organisation roles I get via the role inheritance
organisation_roles_i_can_access = OrganisationRole.objects.filter(
tied_to_my_organisation_for_all_users | tied_to_my_user_profile)
# Two criteria for filtering organisation roles.
# The simple case is that an organisation role is both in our access
# list AND it points at a relevant role. Bingo.
relevant_role_and_direct_access = models.Q(
id__in=organisation_roles_i_can_access,
role__in=relevant_roles_tied_to_the_portal)
# The elaborate case is that a role must of course be a relevant
# role. Then that same role must have a base role with an organisation
# role that I can access. That same organisation role must also have
# the same organisation as the organisation role I'm looking
# from. Django ensures those "the same" items are really the
# same.
relevant_role_and_indirect_access_with_matching_org = models.Q(
role__in=relevant_roles_tied_to_the_portal,
role__base_roles__organisation_roles__in=organisation_roles_i_can_access,
role__base_roles__organisation_roles__organisation=F('organisation'))
results = OrganisationRole.objects.filter(
relevant_role_and_direct_access |
relevant_role_and_indirect_access_with_matching_org).distinct()
if return_explanation:
organisation_roles_directly = OrganisationRole.objects.filter(
tied_to_my_user_profile)
organisation_roles_via_organisation = OrganisationRole.objects.filter(
tied_to_my_organisation_for_all_users).distinct()
direct_results = OrganisationRole.objects.filter(
relevant_role_and_direct_access).distinct()
indirect_results = OrganisationRole.objects.filter(
relevant_role_and_indirect_access_with_matching_org).distinct()
return {
'relevant_roles_tied_to_the_portal':
relevant_roles_tied_to_the_portal,
'organisation_roles_directly': organisation_roles_directly,
'organisation_roles_via_organisation':
organisation_roles_via_organisation,
'direct_results': direct_results,
'indirect_results': indirect_results,
'results': results}
return results
class Invitation(models.Model):
name = models.CharField(
verbose_name=_('name'),
max_length=255,
null=False,
blank=False)
email = models.EmailField(
verbose_name=_('e-mail'),
null=False,
blank=False)
organisation = models.CharField(
verbose_name=_('organisation'),
max_length=255,
null=False,
blank=False)
language = models.CharField(
verbose_name=_('language'),
max_length=16,
null=False,
blank=False)
portals = models.ManyToManyField(
Portal,
verbose_name=_('portals'),
blank=True)
created_at = models.DateTimeField(
verbose_name=_('created at'),
auto_now_add=True)
activation_key = models.CharField(
verbose_name=_('activation key'),
max_length=64,
null=True,
blank=True,
unique=True)
activation_key_date = models.DateTimeField(
verbose_name=_('activation key date'),
null=True,
blank=True,
help_text=_(
'Date on which the activation key was generated. '
'Used for expiration.')
)
is_activated = models.BooleanField(
verbose_name=_('is activated'),
default=False)
activated_on = models.DateTimeField(
verbose_name=_('activated on'),
null=True,
blank=True)
user = models.ForeignKey(
User,
verbose_name=_('user'),
null=True,
blank=True)
class Meta:
verbose_name = _('(invitation)')
verbose_name_plural = _('(invitation)')
ordering = ['is_activated', '-created_at', 'email']
def __str__(self):
return "invitation for %s" % self.email
def clean(self):
if self.is_activated:
if self.user is None:
raise ValidationError(
'Invitation is marked as activated, but its '
'user is not set.')
if self.activated_on is None:
raise ValidationError(
'Invitation is marked as activated, but its '
'field "activated_on" is not set.')
def _rotate_activation_key(self):
if self.is_activated:
raise Exception('user is already activated')
# generate a new activation key
self.activation_key = GenKey(Invitation, 'activation_key')()
# update key date so we can check for expiration
self.activation_key_date = datetime.datetime.now(tz=pytz.UTC)
self.save()
def get_context(self, **extra):
"""Create the context for rendering the invitation email."""
username = self.user.username if self.user else self.name
context = {
'name': username,
'activation_key': self.activation_key,
'expiration_days': settings.ACCOUNT_ACTIVATION_DAYS,
'site_name': settings.SITE_NAME,
'site_public_url_prefix': settings.SITE_PUBLIC_URL_PREFIX,
'invitation': self,
}
context.update(extra)
return context
def send_new_activation_email(self):
if self.is_activated:
raise Exception('user is already activated')
# generate a fresh key
self._rotate_activation_key()
# send this user an email containing the key
# build a render context for the email template
expiration_date = (
datetime.datetime.now(tz=pytz.UTC) +
datetime.timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS))
ctx_dict = self.get_context(expiration_date=expiration_date)
# switch to the users language
old_lang = translation.get_language()
translation.activate(self.language)
# render the email subject and message using Django's templating
subject = render_to_string(
'lizard_auth_server/invitation_email_subject.txt', ctx_dict)
# ensure email subject doesn't contain newlines
subject = ''.join(subject.splitlines())
message = render_to_string(
'lizard_auth_server/invitation_email.html', ctx_dict)
# switch language back
translation.activate(old_lang)
# send the actual email
send_mail(subject, message, None, [self.email])
def create_user(self, data):
with transaction.atomic():
if self.user is None:
# create the Django auth user
user = User.objects.create_user(
username=data['username'],
password=data['new_password1']
)
# immediately deactivate this user, no way to do this directly
user.is_active = False
user.save()
# link the new user to the invitation
self.user = user
self.save()
else:
logger.warn(
'This invitation already has a user linked to it: %s',
self.user)
def activate(self, data):
with transaction.atomic():
user = self.user
# create and fill the profile
# this sets the additional attributes on the User model as well
# get_profile deprecated in Django >= 1.7
# profile = user.get_profile()
profile = user.user_profile
profile.update_all(data)
# many-to-many, so save these after profile has been assigned an ID
organisation, created = Organisation.objects.get_or_create(
name=self.organisation)
profile.organisations.add(organisation)
profile.portals = self.portals.all()
profile.save()
# and mark the User as active
user.is_active = True
user.save()
# set the activation flag on the Invitation
self.activated_on = datetime.datetime.now(tz=pytz.UTC)
self.is_activated = True
self.save()
def create_new_uuid():
return uuid.uuid4().hex
class RoleManager(models.Manager):
def get_queryset(self):
return super(RoleManager, self).get_queryset().select_related('portal')
class Role(models.Model):
portal = models.ForeignKey(
Portal,
related_name='roles',
verbose_name=_('portal'))
unique_id = models.CharField(
verbose_name=_('unique id'),
max_length=32,
editable=False,
unique=True,
default=create_new_uuid)
code = models.CharField(
verbose_name=_('code'),
max_length=255,
help_text=_('name used internally by the portal to identify the role'),
null=False,
blank=False)
name = models.CharField(
verbose_name=_('name'),
help_text=_('human-readable name'),
max_length=255,
null=False,
blank=False)
inheriting_roles = models.ManyToManyField(
"self",
verbose_name=_('inheriting roles'),
symmetrical=False,
related_name='base_roles',
help_text=_('roles that are automatically inherited from us for '
'organisations that have organisation roles pointing at '
'both base and inheriting role.'),
blank=True)
external_description = models.TextField(
verbose_name=_('external description'),
blank=True)
internal_description = models.TextField(
verbose_name=_('internal description'),
blank=True)
objects = RoleManager()
class Meta:
ordering = ['portal', 'name']
unique_together = (('name', 'portal'), )
verbose_name = _('(role)')
verbose_name_plural = _('(roles)')
def __str__(self):
return _('{name} on {portal}').format(name=self.name,
portal=self.portal.name)
def as_dict(self):
return {
'unique_id': self.unique_id,
'code': self.code,
'name': self.name,
'external_description': self.external_description,
'internal_description': self.internal_description
}
[docs]class Organisation(models.Model):
name = models.CharField(
verbose_name=_('name'),
max_length=255,
null=False,
blank=False,
unique=True)
unique_id = models.CharField(
verbose_name=_('unique id'),
max_length=32,
unique=True,
default=create_new_uuid)
roles = models.ManyToManyField(
Role,
through='OrganisationRole',
verbose_name=_('roles'),
blank=True)
class Meta:
ordering = ['name']
verbose_name = _('organisation')
verbose_name_plural = _('organisations')
def __str__(self):
return self.name
def as_dict(self):
return {
'name': self.name,
'unique_id': self.unique_id
}
class OrganisationRoleManager(models.Manager):
def get_queryset(self):
# Always use select_related on role and organisation, otherwise we
# have to specify it in a *lot* of places.
return super(OrganisationRoleManager, self).get_queryset(
).select_related(
'role', 'organisation', 'role__portal')
class OrganisationRole(models.Model):
organisation = models.ForeignKey(
Organisation,
related_name='organisation_roles',
verbose_name=_('organisation'))
role = models.ForeignKey(
Role,
related_name='organisation_roles',
verbose_name=_('role'))
for_all_users = models.BooleanField(
verbose_name=_('for all users'),
default=False)
objects = OrganisationRoleManager()
class Meta:
unique_together = (('organisation', 'role'), )
verbose_name = _('(organisation-role-mapping)')
verbose_name_plural = _('(organisation-role-mappings)')
def __str__(self):
if self.for_all_users:
return _("{role} for everybody in {org}").format(
role=self.role, org=self.organisation)
else:
return "{role} in {org}".format(
role=self.role, org=self.organisation)
def clean(self):
if self.role.code != BILLING_ROLE:
return
if self.role.portal.name != THREEDI_PORTAL:
return
# Hardcoded: we point at the 3di 'billing' role.
if self.for_all_users:
raise ValidationError(
{'for_all_users': [
_('The special 3di billing role is not allowed '
'"for all users"')]})
def as_dict(self):
return {
"organisation": self.organisation.as_dict(),
"role": self.role.as_dict()
}