django-cas-server/cas_server/models.py

539 lines
19 KiB
Python
Raw Normal View History

2015-05-16 21:43:46 +00:00
# ⁻*- coding: utf-8 -*-
2015-05-27 20:10:06 +00:00
# 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 Valentin Samir
2015-05-27 19:56:39 +00:00
"""models for the app"""
from .default_settings import settings
2015-05-28 15:30:27 +00:00
2015-05-16 21:43:46 +00:00
from django.db import models
2015-05-28 15:30:27 +00:00
from django.db.models import Q
2015-05-16 21:43:46 +00:00
from django.contrib import messages
2015-05-27 20:56:20 +00:00
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from picklefield.fields import PickledObjectField
2015-05-16 21:43:46 +00:00
import re
import os
2015-05-28 15:30:27 +00:00
import sys
2015-12-13 12:50:29 +00:00
import logging
from importlib import import_module
2015-05-28 15:30:27 +00:00
from datetime import timedelta
2015-05-18 18:30:00 +00:00
from concurrent.futures import ThreadPoolExecutor
from requests_futures.sessions import FuturesSession
2015-05-16 21:43:46 +00:00
2015-06-21 16:56:16 +00:00
import cas_server.utils as utils
2015-05-27 19:56:39 +00:00
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
2015-12-13 12:50:29 +00:00
logger = logging.getLogger(__name__)
2015-06-12 16:10:52 +00:00
2016-06-17 17:28:49 +00:00
class FederatedUser(models.Model):
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)
2016-06-17 17:28:49 +00:00
2016-06-23 15:18:53 +00:00
class FederateSLO(models.Model):
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)
@property
def provider(self):
component = self.username.split("@")
return component[-1]
@classmethod
def clean_deleted_sessions(cls):
for federate_slo in cls.objects.all():
if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
federate_slo.delete()
2015-05-16 21:43:46 +00:00
class User(models.Model):
2015-05-27 19:56:39 +00:00
"""A user logged into the CAS"""
2015-06-09 20:04:05 +00:00
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)
2015-06-09 20:04:05 +00:00
username = models.CharField(max_length=30)
2015-12-12 12:51:59 +00:00
date = models.DateTimeField(auto_now=True)
2015-05-16 21:43:46 +00:00
2015-06-09 20:04:05 +00:00
@classmethod
def clean_old_entries(cls):
users = cls.objects.filter(
date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
)
2015-06-09 20:04:05 +00:00
for user in users:
user.logout()
users.delete()
@classmethod
def clean_deleted_sessions(cls):
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()
2015-05-16 21:43:46 +00:00
def __unicode__(self):
2015-06-11 21:34:25 +00:00
return u"%s - %s" % (self.username, self.session_key)
2015-05-16 21:43:46 +00:00
2015-06-09 20:04:05 +00:00
def logout(self, request=None):
2015-05-28 00:13:09 +00:00
"""Sending SLO request to all services the user logged in"""
2015-05-18 18:30:00 +00:00
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:
2015-11-13 23:17:31 +00:00
ticket.logout(request, session, async_list)
queryset.delete()
2015-05-18 18:30:00 +00:00
for future in async_list:
if future:
try:
future.result()
except Exception as error:
2015-12-13 12:50:29 +00:00
logger.warning(
"Error during SLO for user %s: %s" % (
self.username,
error
)
)
2015-06-09 20:04:05 +00:00
if request is not None:
error = utils.unpack_nested_exception(error)
messages.add_message(
request,
messages.WARNING,
_(u'Error during service logout %s') % error
)
2015-05-27 19:56:39 +00:00
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.name, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
)
2015-05-18 18:30:00 +00:00
service_attributs = {}
2015-05-27 19:56:39 +00:00
for (key, value) in self.attributs.items():
2015-06-07 12:39:12 +00:00
if key in attributs or '*' in attributs:
2015-05-27 19:56:39 +00:00
if key in replacements:
value = re.sub(replacements[key][0], replacements[key][1], value)
2015-06-07 12:39:12 +00:00
service_attributs[attributs.get(key, key)] = value
2015-05-27 19:56:39 +00:00
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
2015-05-27 19:56:39 +00:00
)
2015-05-16 21:43:46 +00:00
ticket.save()
self.save()
2015-05-18 18:30:00 +00:00
return ticket
def get_service_url(self, service, service_pattern, renew):
2015-05-27 19:56:39 +00:00
"""Return the url to which the user must be redirected to
after a Service Ticket has been generated"""
2015-05-18 18:30:00 +00:00
ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
2015-06-12 16:10:52 +00:00
url = utils.update_url(service, {'ticket': ticket.value})
2015-12-13 12:50:29 +00:00
logger.info("Service ticket created for service %s by user %s." % (service, self.username))
2015-05-16 21:43:46 +00:00
return url
2015-06-12 16:10:52 +00:00
class ServicePatternException(Exception):
pass
2015-06-12 16:10:52 +00:00
class BadUsername(ServicePatternException):
2015-05-27 19:56:39 +00:00
"""Exception raised then an non allowed username
try to get a ticket for a service"""
2015-05-17 21:24:41 +00:00
pass
2015-06-12 16:10:52 +00:00
class BadFilter(ServicePatternException):
2015-05-27 19:56:39 +00:00
""""Exception raised then a user try
to get a ticket for a service and do not reach a condition"""
2015-05-17 21:24:41 +00:00
pass
2015-05-27 19:56:39 +00:00
2015-06-12 16:10:52 +00:00
class UserFieldNotDefined(ServicePatternException):
2015-05-27 19:56:39 +00:00
"""Exception raised then a user try to get a ticket for a service
using as username an attribut not present on this user"""
2015-05-17 21:24:41 +00:00
pass
2015-06-12 16:10:52 +00:00
2015-05-17 21:24:41 +00:00
class ServicePattern(models.Model):
2015-05-27 19:56:39 +00:00
"""Allowed services pattern agains services are tested to"""
2015-05-17 21:24:41 +00:00
class Meta:
ordering = ("pos", )
verbose_name = _("Service pattern")
verbose_name_plural = _("Services patterns")
2015-05-17 21:24:41 +00:00
2015-05-27 20:56:20 +00:00
pos = models.IntegerField(
default=100,
verbose_name=_(u"position")
)
2015-05-27 19:56:39 +00:00
name = models.CharField(
max_length=255,
unique=True,
blank=True,
null=True,
2015-05-27 20:56:20 +00:00
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 '\\'."
)
2015-05-27 19:56:39 +00:00
)
user_field = models.CharField(
max_length=255,
default="",
blank=True,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"user field"),
help_text=_("Name of the attribut to transmit as username, empty = login")
2015-05-27 19:56:39 +00:00
)
restrict_users = models.BooleanField(
default=False,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"restrict username"),
help_text=_("Limit username allowed to connect to the list provided bellow")
2015-05-27 19:56:39 +00:00
)
proxy = models.BooleanField(
default=False,
2015-05-27 20:56:20 +00:00
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")
2015-05-27 19:56:39 +00:00
)
2015-05-28 00:13:09 +00:00
single_log_out = models.BooleanField(
2015-05-27 20:23:16 +00:00
default=False,
2015-05-28 00:13:09 +00:00
verbose_name=_(u"single log out"),
help_text=_("Enable SLO for the service")
2015-05-27 20:23:16 +00:00
)
2015-05-17 21:24:41 +00:00
single_log_out_callback = models.CharField(
max_length=255,
default="",
blank=True,
verbose_name=_(u"single log out callback"),
2015-06-12 16:10:52 +00:00
help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
u"This is usefull for non HTTP proxied services.")
)
2015-05-17 21:24:41 +00:00
def __unicode__(self):
return u"%s: %s" % (self.pos, self.pattern)
def check_user(self, user):
2015-05-27 19:56:39 +00:00
"""Check if `user` if allowed to use theses services"""
2015-05-18 18:30:00 +00:00
if self.restrict_users and not self.usernames.filter(value=user.username):
2015-12-13 12:50:29 +00:00
logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
2015-05-17 21:24:41 +00:00
raise BadUsername()
2015-05-27 19:56:39 +00:00
for filtre in self.filters.all():
if isinstance(user.attributs.get(filtre.attribut, []), list):
attrs = user.attributs.get(filtre.attribut, [])
2015-05-18 18:30:00 +00:00
else:
2015-05-27 19:56:39 +00:00
attrs = [user.attributs[filtre.attribut]]
for value in attrs:
if re.match(filtre.pattern, str(value)):
2015-05-18 18:30:00 +00:00
break
else:
2015-12-13 12:50:29 +00:00
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)
)
)
2015-05-27 19:56:39 +00:00
raise BadFilter('%s do not match %s %s' % (
filtre.pattern,
filtre.attribut,
2015-06-07 15:12:04 +00:00
user.attributs.get(filtre.attribut)
2015-05-27 19:56:39 +00:00
))
2015-05-17 21:24:41 +00:00
if self.user_field and not user.attributs.get(self.user_field):
2015-12-13 12:50:29 +00:00
logger.warning(
"Cannot use %s a loggin for user %s on service %s because it is absent" % (
self.user_field,
user.username,
self.name
)
)
2015-05-17 21:24:41 +00:00
raise UserFieldNotDefined()
return True
@classmethod
def validate(cls, service):
2015-05-27 19:56:39 +00:00
"""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
2015-12-13 12:50:29 +00:00
logger.warning("Service %s not allowed." % service)
2015-05-17 21:24:41 +00:00
raise cls.DoesNotExist()
2015-06-12 16:10:52 +00:00
2015-05-27 19:56:39 +00:00
class Username(models.Model):
"""A list of allowed usernames on a service pattern"""
2015-05-27 20:56:20 +00:00
value = models.CharField(
max_length=255,
verbose_name=_(u"username"),
help_text=_(u"username allowed to connect to the service")
)
2015-05-18 18:30:00 +00:00
service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
2015-05-18 21:38:28 +00:00
2015-05-27 19:56:39 +00:00
def __unicode__(self):
return self.value
2015-06-12 16:10:52 +00:00
2015-05-18 18:30:00 +00:00
class ReplaceAttributName(models.Model):
2015-05-27 19:56:39 +00:00
"""A list of replacement of attributs name for a service pattern"""
2015-05-18 21:38:28 +00:00
class Meta:
2015-05-23 17:32:02 +00:00
unique_together = ('name', 'replace', 'service_pattern')
2015-05-27 19:56:39 +00:00
name = models.CharField(
max_length=255,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"name"),
2015-06-07 12:39:12 +00:00
help_text=_(u"name of an attribut to send to the service, use * for all attributes")
2015-05-27 19:56:39 +00:00
)
replace = models.CharField(
max_length=255,
blank=True,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"replace"),
2015-06-12 16:10:52 +00:00
help_text=_(u"name under which the attribut will be show"
u"to the service. empty = default name of the attribut")
2015-05-27 19:56:39 +00:00
)
2015-05-18 18:30:00 +00:00
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)
2015-06-12 16:10:52 +00:00
2015-05-18 18:30:00 +00:00
class FilterAttributValue(models.Model):
2015-05-27 19:56:39 +00:00
"""A list of filter on attributs for a service pattern"""
attribut = models.CharField(
max_length=255,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"attribut"),
help_text=_(u"Name of the attribut which must verify pattern")
2015-05-27 19:56:39 +00:00
)
pattern = models.CharField(
max_length=255,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"pattern"),
help_text=_(u"a regular expression")
2015-05-27 19:56:39 +00:00
)
2015-05-18 18:30:00 +00:00
service_pattern = models.ForeignKey(ServicePattern, related_name="filters")
def __unicode__(self):
return u"%s %s" % (self.attribut, self.pattern)
2015-06-12 16:10:52 +00:00
2015-05-18 18:30:00 +00:00
class ReplaceAttributValue(models.Model):
2015-05-27 19:56:39 +00:00
"""Replacement to apply on attributs values for a service pattern"""
attribut = models.CharField(
max_length=255,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"attribut"),
help_text=_(u"Name of the attribut for which the value must be replace")
2015-05-27 19:56:39 +00:00
)
pattern = models.CharField(
max_length=255,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"pattern"),
help_text=_(u"An regular expression maching whats need to be replaced")
2015-05-27 19:56:39 +00:00
)
replace = models.CharField(
max_length=255,
blank=True,
2015-05-27 20:56:20 +00:00
verbose_name=_(u"replace"),
help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
2015-05-27 19:56:39 +00:00
)
2015-05-18 18:30:00 +00:00
service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")
def __unicode__(self):
return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
2015-05-17 21:24:41 +00:00
2015-05-16 21:43:46 +00:00
class Ticket(models.Model):
2015-05-27 19:56:39 +00:00
"""Generic class for a Ticket"""
2015-05-16 21:43:46 +00:00
class Meta:
abstract = True
user = models.ForeignKey(User, related_name="%(class)s")
attributs = PickledObjectField()
validate = models.BooleanField(default=False)
service = models.TextField()
2015-05-27 19:56:39 +00:00
service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
2015-05-16 21:43:46 +00:00
creation = models.DateTimeField(auto_now_add=True)
renew = models.BooleanField(default=False)
single_log_out = models.BooleanField(default=False)
2015-05-16 21:43:46 +00:00
2015-06-08 00:51:22 +00:00
VALIDITY = settings.CAS_TICKET_VALIDITY
TIMEOUT = settings.CAS_TICKET_TIMEOUT
2015-05-16 21:43:46 +00:00
def __unicode__(self):
return u"Ticket-%s" % self.pk
2015-05-16 21:43:46 +00:00
2015-05-28 15:30:27 +00:00
@classmethod
2015-06-09 20:04:05 +00:00
def clean_old_entries(cls):
2015-05-28 15:30:27 +00:00
"""Remove old ticket and send SLO to timed-out services"""
# removing old validated ticket and non validated expired tickets
cls.objects.filter(
(
2015-06-12 16:10:52 +00:00
Q(single_log_out=False) & Q(validate=True)
) | (
Q(validate=False) &
Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
2015-05-28 15:30:27 +00:00
)
).delete()
# sending SLO to timed-out validated tickets
2015-06-08 00:51:22 +00:00
if cls.TIMEOUT and cls.TIMEOUT > 0:
2015-05-28 15:30:27 +00:00
async_list = []
session = FuturesSession(
executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
)
2015-05-28 15:30:27 +00:00
queryset = cls.objects.filter(
2015-06-08 00:51:22 +00:00
creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
2015-05-28 15:30:27 +00:00
)
for ticket in queryset:
2015-11-13 23:17:31 +00:00
ticket.logout(None, session, async_list)
2015-05-28 15:30:27 +00:00
queryset.delete()
for future in async_list:
if future:
try:
future.result()
except Exception as error:
2015-12-13 12:50:29 +00:00
logger.warning("Error durring SLO %s" % error)
2015-05-28 15:30:27 +00:00
sys.stderr.write("%r\n" % error)
2015-11-13 23:17:31 +00:00
def logout(self, request, session, async_list=None):
2015-05-28 00:13:09 +00:00
"""Send a SLO request to the ticket service"""
# On logout invalidate the Ticket
self.validate = True
self.save()
2015-11-13 23:17:31 +00:00
if self.validate and self.single_log_out:
2015-12-13 12:50:29 +00:00
logger.info(
"Sending SLO requests to service %s for user %s" % (
self.service,
self.user.username
)
)
2015-06-12 14:37:50 +00:00
try:
xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
2015-05-16 21:43:46 +00:00
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>
2015-05-27 19:56:39 +00:00
</samlp:LogoutRequest>""" % \
2015-06-12 16:10:52 +00:00
{
'id': os.urandom(20).encode("hex"),
'datetime': timezone.now().isoformat(),
'ticket': self.value
}
if self.service_pattern.single_log_out_callback:
url = self.service_pattern.single_log_out_callback
else:
2015-06-12 16:10:52 +00:00
url = self.service
2015-11-13 23:17:31 +00:00
async_list.append(
session.post(
url.encode('utf-8'),
data={'logoutRequest': xml.encode('utf-8')},
2015-12-13 12:50:01 +00:00
timeout=settings.CAS_SLO_TIMEOUT
2015-11-13 23:17:31 +00:00
)
2015-05-27 19:56:39 +00:00
)
except Exception as error:
2015-12-13 12:50:29 +00:00
error = utils.unpack_nested_exception(error)
logger.warning(
"Error durring SLO for user %s on service %s: %s" % (
self.user.username,
self.service,
error
)
)
2015-05-28 15:30:27 +00:00
if request is not None:
messages.add_message(
request,
messages.WARNING,
_(u'Error during service logout %(service)s:\n%(error)s') %
2015-06-12 16:10:52 +00:00
{'service': self.service, 'error': error}
2015-05-28 15:30:27 +00:00
)
else:
sys.stderr.write("%r\n" % error)
2015-05-16 21:43:46 +00:00
2015-06-12 16:10:52 +00:00
2015-05-16 21:43:46 +00:00
class ServiceTicket(Ticket):
2015-05-27 19:56:39 +00:00
"""A Service Ticket"""
PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
2015-06-12 16:10:52 +00:00
2015-05-27 19:56:39 +00:00
def __unicode__(self):
return u"ServiceTicket-%s" % self.pk
2015-06-12 16:10:52 +00:00
2015-05-16 21:43:46 +00:00
class ProxyTicket(Ticket):
2015-05-27 19:56:39 +00:00
"""A Proxy Ticket"""
PREFIX = settings.CAS_PROXY_TICKET_PREFIX
value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
2015-06-12 16:10:52 +00:00
2015-05-27 19:56:39 +00:00
def __unicode__(self):
return u"ProxyTicket-%s" % self.pk
2015-06-12 16:10:52 +00:00
2015-05-16 21:43:46 +00:00
class ProxyGrantingTicket(Ticket):
2015-05-27 19:56:39 +00:00
"""A Proxy Granting Ticket"""
PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
2015-06-08 00:51:22 +00:00
VALIDITY = settings.CAS_PGT_VALIDITY
value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
2015-06-08 00:51:22 +00:00
2015-05-27 19:56:39 +00:00
def __unicode__(self):
return u"ProxyGrantingTicket-%s" % self.pk
2015-05-16 21:43:46 +00:00
2015-06-12 16:10:52 +00:00
2015-05-16 21:43:46 +00:00
class Proxy(models.Model):
2015-05-27 19:56:39 +00:00
"""A list of proxies on `ProxyTicket`"""
2015-05-16 21:43:46 +00:00
class Meta:
ordering = ("-pk", )
url = models.CharField(max_length=255)
proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")
2015-05-27 19:56:39 +00:00
def __unicode__(self):
return self.url