547 lines
20 KiB
Python
547 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
# 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 General Public License version 3 for
|
|
# more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License version 3
|
|
# along with this program; if not, write to the Free Software Foundation, Inc., 51
|
|
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
#
|
|
# (c) 2015-2016 Valentin Samir
|
|
"""models for the app"""
|
|
from .default_settings import settings
|
|
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from django.contrib import messages
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from django.utils import timezone
|
|
from picklefield.fields import PickledObjectField
|
|
|
|
import re
|
|
import sys
|
|
import logging
|
|
from importlib import import_module
|
|
from datetime import timedelta
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from requests_futures.sessions import FuturesSession
|
|
|
|
import cas_server.utils as utils
|
|
|
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FederatedUser(models.Model):
|
|
"""A federated user as returner by a CAS provider (username and attributes)"""
|
|
class Meta:
|
|
unique_together = ("username", "provider")
|
|
username = models.CharField(max_length=124)
|
|
provider = models.CharField(max_length=124)
|
|
attributs = PickledObjectField()
|
|
ticket = models.CharField(max_length=255)
|
|
last_update = models.DateTimeField(auto_now=True)
|
|
|
|
def __unicode__(self):
|
|
return u"%s@%s" % (self.username, self.provider)
|
|
|
|
@classmethod
|
|
def clean_old_entries(cls):
|
|
"""remove old unused federated users"""
|
|
federated_users = cls.objects.filter(
|
|
last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))
|
|
)
|
|
known_users = {user.username for user in User.objects.all()}
|
|
for user in federated_users:
|
|
if not ('%s@%s' % (user.username, user.provider)) in known_users:
|
|
user.delete()
|
|
|
|
|
|
class FederateSLO(models.Model):
|
|
"""An association between a CAS provider ticket and a (username, session) for processing SLO"""
|
|
class Meta:
|
|
unique_together = ("username", "session_key")
|
|
username = models.CharField(max_length=30)
|
|
session_key = models.CharField(max_length=40, blank=True, null=True)
|
|
ticket = models.CharField(max_length=255)
|
|
|
|
@classmethod
|
|
def clean_deleted_sessions(cls):
|
|
"""remove old object for which the session do not exists anymore"""
|
|
for federate_slo in cls.objects.all():
|
|
if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
|
|
federate_slo.delete()
|
|
|
|
|
|
class User(models.Model):
|
|
"""A user logged into the CAS"""
|
|
class Meta:
|
|
unique_together = ("username", "session_key")
|
|
verbose_name = _("User")
|
|
verbose_name_plural = _("Users")
|
|
session_key = models.CharField(max_length=40, blank=True, null=True)
|
|
username = models.CharField(max_length=30)
|
|
date = models.DateTimeField(auto_now=True)
|
|
|
|
def delete(self, *args, **kwargs):
|
|
"""remove the User"""
|
|
if settings.CAS_FEDERATE:
|
|
FederateSLO.objects.filter(
|
|
username=self.username,
|
|
session_key=self.session_key
|
|
).delete()
|
|
super(User, self).delete(*args, **kwargs)
|
|
|
|
@classmethod
|
|
def clean_old_entries(cls):
|
|
"""Remove users inactive since more that SESSION_COOKIE_AGE"""
|
|
users = cls.objects.filter(
|
|
date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
|
|
)
|
|
for user in users:
|
|
user.logout()
|
|
users.delete()
|
|
|
|
@classmethod
|
|
def clean_deleted_sessions(cls):
|
|
"""Remove user where the session do not exists anymore"""
|
|
for user in cls.objects.all():
|
|
if not SessionStore(session_key=user.session_key).get('authenticated'):
|
|
user.logout()
|
|
user.delete()
|
|
|
|
@property
|
|
def attributs(self):
|
|
"""return a fresh dict for the user attributs"""
|
|
return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()
|
|
|
|
def __unicode__(self):
|
|
return u"%s - %s" % (self.username, self.session_key)
|
|
|
|
def logout(self, request=None):
|
|
"""Sending SLO request to all services the user logged in"""
|
|
async_list = []
|
|
session = FuturesSession(
|
|
executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
|
|
)
|
|
# first invalidate all Tickets
|
|
ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
|
|
for ticket_class in ticket_classes:
|
|
queryset = ticket_class.objects.filter(user=self)
|
|
for ticket in queryset:
|
|
ticket.logout(session, async_list)
|
|
queryset.delete()
|
|
for future in async_list:
|
|
if future: # pragma: no branch (should always be true)
|
|
try:
|
|
future.result()
|
|
except Exception as error:
|
|
logger.warning(
|
|
"Error during SLO for user %s: %s" % (
|
|
self.username,
|
|
error
|
|
)
|
|
)
|
|
if request is not None:
|
|
error = utils.unpack_nested_exception(error)
|
|
messages.add_message(
|
|
request,
|
|
messages.WARNING,
|
|
_(u'Error during service logout %s') % error
|
|
)
|
|
|
|
def get_ticket(self, ticket_class, service, service_pattern, renew):
|
|
"""
|
|
Generate a ticket using `ticket_class` for the service
|
|
`service` matching `service_pattern` and asking or not for
|
|
authentication renewal with `renew
|
|
"""
|
|
attributs = dict(
|
|
(a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all()
|
|
)
|
|
replacements = dict(
|
|
(a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
|
|
)
|
|
service_attributs = {}
|
|
for (key, value) in self.attributs.items():
|
|
if key in attributs or '*' in attributs:
|
|
if key in replacements:
|
|
if isinstance(value, list):
|
|
for index, subval in enumerate(value):
|
|
value[index] = re.sub(
|
|
replacements[key][0],
|
|
replacements[key][1],
|
|
subval
|
|
)
|
|
else:
|
|
value = re.sub(replacements[key][0], replacements[key][1], value)
|
|
service_attributs[attributs.get(key, key)] = value
|
|
ticket = ticket_class.objects.create(
|
|
user=self,
|
|
attributs=service_attributs,
|
|
service=service,
|
|
renew=renew,
|
|
service_pattern=service_pattern,
|
|
single_log_out=service_pattern.single_log_out
|
|
)
|
|
ticket.save()
|
|
self.save()
|
|
return ticket
|
|
|
|
def get_service_url(self, service, service_pattern, renew):
|
|
"""Return the url to which the user must be redirected to
|
|
after a Service Ticket has been generated"""
|
|
ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
|
|
url = utils.update_url(service, {'ticket': ticket.value})
|
|
logger.info("Service ticket created for service %s by user %s." % (service, self.username))
|
|
return url
|
|
|
|
|
|
class ServicePatternException(Exception):
|
|
"""Base exception of exceptions raised in the ServicePattern model"""
|
|
pass
|
|
|
|
|
|
class BadUsername(ServicePatternException):
|
|
"""Exception raised then an non allowed username
|
|
try to get a ticket for a service"""
|
|
pass
|
|
|
|
|
|
class BadFilter(ServicePatternException):
|
|
""""Exception raised then a user try
|
|
to get a ticket for a service and do not reach a condition"""
|
|
pass
|
|
|
|
|
|
class UserFieldNotDefined(ServicePatternException):
|
|
"""Exception raised then a user try to get a ticket for a service
|
|
using as username an attribut not present on this user"""
|
|
pass
|
|
|
|
|
|
class ServicePattern(models.Model):
|
|
"""Allowed services pattern agains services are tested to"""
|
|
class Meta:
|
|
ordering = ("pos", )
|
|
verbose_name = _("Service pattern")
|
|
verbose_name_plural = _("Services patterns")
|
|
|
|
pos = models.IntegerField(
|
|
default=100,
|
|
verbose_name=_(u"position")
|
|
)
|
|
name = models.CharField(
|
|
max_length=255,
|
|
unique=True,
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_(u"name"),
|
|
help_text=_(u"A name for the service")
|
|
)
|
|
pattern = models.CharField(
|
|
max_length=255,
|
|
unique=True,
|
|
verbose_name=_(u"pattern"),
|
|
help_text=_(
|
|
"A regular expression matching services. "
|
|
"Will usually looks like '^https://some\\.server\\.com/path/.*$'."
|
|
"As it is a regular expression, special character must be escaped with a '\\'."
|
|
)
|
|
)
|
|
user_field = models.CharField(
|
|
max_length=255,
|
|
default="",
|
|
blank=True,
|
|
verbose_name=_(u"user field"),
|
|
help_text=_("Name of the attribut to transmit as username, empty = login")
|
|
)
|
|
restrict_users = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_(u"restrict username"),
|
|
help_text=_("Limit username allowed to connect to the list provided bellow")
|
|
)
|
|
proxy = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_(u"proxy"),
|
|
help_text=_("Proxy tickets can be delivered to the service")
|
|
)
|
|
proxy_callback = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_(u"proxy callback"),
|
|
help_text=_("can be used as a proxy callback to deliver PGT")
|
|
)
|
|
single_log_out = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_(u"single log out"),
|
|
help_text=_("Enable SLO for the service")
|
|
)
|
|
|
|
single_log_out_callback = models.CharField(
|
|
max_length=255,
|
|
default="",
|
|
blank=True,
|
|
verbose_name=_(u"single log out callback"),
|
|
help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
|
|
u"This is usefull for non HTTP proxied services.")
|
|
)
|
|
|
|
def __unicode__(self):
|
|
return u"%s: %s" % (self.pos, self.pattern)
|
|
|
|
def check_user(self, user):
|
|
"""Check if `user` if allowed to use theses services"""
|
|
if self.restrict_users and not self.usernames.filter(value=user.username):
|
|
logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
|
|
raise BadUsername()
|
|
for filtre in self.filters.all():
|
|
if isinstance(user.attributs.get(filtre.attribut, []), list):
|
|
attrs = user.attributs.get(filtre.attribut, [])
|
|
else:
|
|
attrs = [user.attributs[filtre.attribut]]
|
|
for value in attrs:
|
|
if re.match(filtre.pattern, str(value)):
|
|
break
|
|
else:
|
|
logger.warning(
|
|
"User constraint failed for %s, service %s: %s do not match %s %s." % (
|
|
user.username,
|
|
self.name,
|
|
filtre.pattern,
|
|
filtre.attribut,
|
|
user.attributs.get(filtre.attribut)
|
|
)
|
|
)
|
|
raise BadFilter('%s do not match %s %s' % (
|
|
filtre.pattern,
|
|
filtre.attribut,
|
|
user.attributs.get(filtre.attribut)
|
|
))
|
|
if self.user_field and not user.attributs.get(self.user_field):
|
|
logger.warning(
|
|
"Cannot use %s a loggin for user %s on service %s because it is absent" % (
|
|
self.user_field,
|
|
user.username,
|
|
self.name
|
|
)
|
|
)
|
|
raise UserFieldNotDefined()
|
|
return True
|
|
|
|
@classmethod
|
|
def validate(cls, service):
|
|
"""Check if a Service Patern match `service` and
|
|
return it, else raise `ServicePattern.DoesNotExist`"""
|
|
for service_pattern in cls.objects.all().order_by('pos'):
|
|
if re.match(service_pattern.pattern, service):
|
|
return service_pattern
|
|
logger.warning("Service %s not allowed." % service)
|
|
raise cls.DoesNotExist()
|
|
|
|
|
|
class Username(models.Model):
|
|
"""A list of allowed usernames on a service pattern"""
|
|
value = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"username"),
|
|
help_text=_(u"username allowed to connect to the service")
|
|
)
|
|
service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
|
|
|
|
def __unicode__(self):
|
|
return self.value
|
|
|
|
|
|
class ReplaceAttributName(models.Model):
|
|
"""A list of replacement of attributs name for a service pattern"""
|
|
class Meta:
|
|
unique_together = ('name', 'replace', 'service_pattern')
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"name"),
|
|
help_text=_(u"name of an attribut to send to the service, use * for all attributes")
|
|
)
|
|
replace = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
verbose_name=_(u"replace"),
|
|
help_text=_(u"name under which the attribut will be show"
|
|
u"to the service. empty = default name of the attribut")
|
|
)
|
|
service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")
|
|
|
|
def __unicode__(self):
|
|
if not self.replace:
|
|
return self.name
|
|
else:
|
|
return u"%s → %s" % (self.name, self.replace)
|
|
|
|
|
|
class FilterAttributValue(models.Model):
|
|
"""A list of filter on attributs for a service pattern"""
|
|
attribut = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"attribut"),
|
|
help_text=_(u"Name of the attribut which must verify pattern")
|
|
)
|
|
pattern = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"pattern"),
|
|
help_text=_(u"a regular expression")
|
|
)
|
|
service_pattern = models.ForeignKey(ServicePattern, related_name="filters")
|
|
|
|
def __unicode__(self):
|
|
return u"%s %s" % (self.attribut, self.pattern)
|
|
|
|
|
|
class ReplaceAttributValue(models.Model):
|
|
"""Replacement to apply on attributs values for a service pattern"""
|
|
attribut = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"attribut"),
|
|
help_text=_(u"Name of the attribut for which the value must be replace")
|
|
)
|
|
pattern = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_(u"pattern"),
|
|
help_text=_(u"An regular expression maching whats need to be replaced")
|
|
)
|
|
replace = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
verbose_name=_(u"replace"),
|
|
help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
|
|
)
|
|
service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")
|
|
|
|
def __unicode__(self):
|
|
return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
|
|
|
|
|
|
class Ticket(models.Model):
|
|
"""Generic class for a Ticket"""
|
|
class Meta:
|
|
abstract = True
|
|
user = models.ForeignKey(User, related_name="%(class)s")
|
|
attributs = PickledObjectField()
|
|
validate = models.BooleanField(default=False)
|
|
service = models.TextField()
|
|
service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
|
|
creation = models.DateTimeField(auto_now_add=True)
|
|
renew = models.BooleanField(default=False)
|
|
single_log_out = models.BooleanField(default=False)
|
|
|
|
VALIDITY = settings.CAS_TICKET_VALIDITY
|
|
TIMEOUT = settings.CAS_TICKET_TIMEOUT
|
|
|
|
def __unicode__(self):
|
|
return u"Ticket-%s" % self.pk
|
|
|
|
@classmethod
|
|
def clean_old_entries(cls):
|
|
"""Remove old ticket and send SLO to timed-out services"""
|
|
# removing old validated ticket and non validated expired tickets
|
|
cls.objects.filter(
|
|
(
|
|
Q(single_log_out=False) & Q(validate=True)
|
|
) | (
|
|
Q(validate=False) &
|
|
Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
|
|
)
|
|
).delete()
|
|
|
|
# sending SLO to timed-out validated tickets
|
|
async_list = []
|
|
session = FuturesSession(
|
|
executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
|
|
)
|
|
queryset = cls.objects.filter(
|
|
creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
|
|
)
|
|
for ticket in queryset:
|
|
ticket.logout(session, async_list)
|
|
queryset.delete()
|
|
for future in async_list:
|
|
if future: # pragma: no branch (should always be true)
|
|
try:
|
|
future.result()
|
|
except Exception as error:
|
|
logger.warning("Error durring SLO %s" % error)
|
|
sys.stderr.write("%r\n" % error)
|
|
|
|
def logout(self, session, async_list=None):
|
|
"""Send a SLO request to the ticket service"""
|
|
# On logout invalidate the Ticket
|
|
self.validate = True
|
|
self.save()
|
|
if self.validate and self.single_log_out: # pragma: no branch (should always be true)
|
|
logger.info(
|
|
"Sending SLO requests to service %s for user %s" % (
|
|
self.service,
|
|
self.user.username
|
|
)
|
|
)
|
|
xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
|
|
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
|
|
<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
|
|
</samlp:LogoutRequest>""" % \
|
|
{
|
|
'id': utils.gen_saml_id(),
|
|
'datetime': timezone.now().isoformat(),
|
|
'ticket': self.value
|
|
}
|
|
if self.service_pattern.single_log_out_callback:
|
|
url = self.service_pattern.single_log_out_callback
|
|
else:
|
|
url = self.service
|
|
async_list.append(
|
|
session.post(
|
|
url.encode('utf-8'),
|
|
data={'logoutRequest': xml.encode('utf-8')},
|
|
timeout=settings.CAS_SLO_TIMEOUT
|
|
)
|
|
)
|
|
|
|
|
|
class ServiceTicket(Ticket):
|
|
"""A Service Ticket"""
|
|
PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
|
|
value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
|
|
|
|
def __unicode__(self):
|
|
return u"ServiceTicket-%s" % self.pk
|
|
|
|
|
|
class ProxyTicket(Ticket):
|
|
"""A Proxy Ticket"""
|
|
PREFIX = settings.CAS_PROXY_TICKET_PREFIX
|
|
value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
|
|
|
|
def __unicode__(self):
|
|
return u"ProxyTicket-%s" % self.pk
|
|
|
|
|
|
class ProxyGrantingTicket(Ticket):
|
|
"""A Proxy Granting Ticket"""
|
|
PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
|
|
VALIDITY = settings.CAS_PGT_VALIDITY
|
|
value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
|
|
|
|
def __unicode__(self):
|
|
return u"ProxyGrantingTicket-%s" % self.pk
|
|
|
|
|
|
class Proxy(models.Model):
|
|
"""A list of proxies on `ProxyTicket`"""
|
|
class Meta:
|
|
ordering = ("-pk", )
|
|
url = models.CharField(max_length=255)
|
|
proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")
|
|
|
|
def __unicode__(self):
|
|
return self.url
|