Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# -*- coding: utf-8 -*-
2# This program is distributed in the hope that it will be useful, but WITHOUT
3# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
4# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
5# more details.
6#
7# You should have received a copy of the GNU General Public License version 3
8# along with this program; if not, write to the Free Software Foundation, Inc., 51
9# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
10#
11# (c) 2015-2016 Valentin Samir
12"""models for the app"""
13from .default_settings import settings, SessionStore
15from django.db import models
16from django.db.models import Q
17from django.contrib import messages
18from django.utils import timezone
19try:
20 from django.utils.encoding import python_2_unicode_compatible
21 from django.utils.translation import ugettext_lazy as _
22except ImportError:
23 def python_2_unicode_compatible(func):
24 """
25 We use Django >= 3.0 with Python >= 3.4, we don't need Python 2 compatibility.
26 """
27 return func
28 from django.utils.translation import gettext_lazy as _
29from django.core.mail import send_mail
31import re
32import sys
33import smtplib
34import logging
35from datetime import timedelta
36from concurrent.futures import ThreadPoolExecutor
37from requests_futures.sessions import FuturesSession
39from cas_server import utils
40from . import VERSION
42#: logger facility
43logger = logging.getLogger(__name__)
46class JsonAttributes(models.Model):
47 """
48 Bases: :class:`django.db.models.Model`
50 A base class for models storing attributes as a json
51 """
53 class Meta:
54 abstract = True
56 #: The attributes json encoded
57 _attributs = models.TextField(default=None, null=True, blank=True)
59 @property
60 def attributs(self):
61 """The attributes"""
62 if self._attributs is not None:
63 return utils.json.loads(self._attributs)
65 @attributs.setter
66 def attributs(self, value):
67 """attributs property setter"""
68 self._attributs = utils.json_encode(value)
71@python_2_unicode_compatible
72class FederatedIendityProvider(models.Model):
73 """
74 Bases: :class:`django.db.models.Model`
76 An identity provider for the federated mode
77 """
78 class Meta:
79 verbose_name = _(u"identity provider")
80 verbose_name_plural = _(u"identity providers")
81 #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.
82 #: it must be unique.
83 suffix = models.CharField(
84 max_length=30,
85 unique=True,
86 verbose_name=_(u"suffix"),
87 help_text=_(
88 u"Suffix append to backend CAS returned "
89 u"username: ``returned_username`` @ ``suffix``."
90 )
91 )
92 #: URL to the root of the CAS server application. If login page is
93 #: https://cas.example.net/cas/login then :attr:`server_url` should be
94 #: https://cas.example.net/cas/
95 server_url = models.CharField(max_length=255, verbose_name=_(u"server url"))
96 #: Version of the CAS protocol to use when sending requests the the backend CAS.
97 cas_protocol_version = models.CharField(
98 max_length=30,
99 choices=[
100 ("1", "CAS 1.0"),
101 ("2", "CAS 2.0"),
102 ("3", "CAS 3.0"),
103 ("CAS_2_SAML_1_0", "SAML 1.1")
104 ],
105 verbose_name=_(u"CAS protocol version"),
106 help_text=_(
107 u"Version of the CAS protocol to use when sending requests the the backend CAS."
108 ),
109 default="3"
110 )
111 #: Name for this identity provider displayed on the login page.
112 verbose_name = models.CharField(
113 max_length=255,
114 verbose_name=_(u"verbose name"),
115 help_text=_(u"Name for this identity provider displayed on the login page.")
116 )
117 #: Position of the identity provider on the login page. Identity provider are sorted using the
118 #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes.
119 pos = models.IntegerField(
120 default=100,
121 verbose_name=_(u"position"),
122 help_text=_(
123 (
124 u"Position of the identity provider on the login page. "
125 u"Identity provider are sorted using the "
126 u"(position, verbose name, suffix) attributes."
127 )
128 )
129 )
130 #: Display the provider on the login page. Beware that this do not disable the identity
131 #: provider, it just hide it on the login page. User will always be able to log in using this
132 #: provider by fetching ``/federate/suffix``.
133 display = models.BooleanField(
134 default=True,
135 verbose_name=_(u"display"),
136 help_text=_("Display the provider on the login page.")
137 )
139 def __str__(self):
140 return self.verbose_name
142 @staticmethod
143 def build_username_from_suffix(username, suffix):
144 """
145 Transform backend username into federated username using ``suffix``
147 :param unicode username: A CAS backend returned username
148 :param unicode suffix: A suffix identifying the CAS backend
149 :return: The federated username: ``username`` @ ``suffix``.
150 :rtype: unicode
151 """
152 return u'%s@%s' % (username, suffix)
154 def build_username(self, username):
155 """
156 Transform backend username into federated username
158 :param unicode username: A CAS backend returned username
159 :return: The federated username: ``username`` @ :attr:`suffix`.
160 :rtype: unicode
161 """
162 return u'%s@%s' % (username, self.suffix)
165@python_2_unicode_compatible
166class FederatedUser(JsonAttributes):
167 """
168 Bases: :class:`JsonAttributes`
170 A federated user as returner by a CAS provider (username and attributes)
171 """
172 class Meta:
173 unique_together = ("username", "provider")
174 verbose_name = _("Federated user")
175 verbose_name_plural = _("Federated users")
176 #: The user username returned by the CAS backend on successful ticket validation
177 username = models.CharField(max_length=124)
178 #: A foreign key to :class:`FederatedIendityProvider`
179 provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
180 #: The last ticket used to authenticate :attr:`username` against :attr:`provider`
181 ticket = models.CharField(max_length=255)
182 #: Last update timespampt. Usually, the last time :attr:`ticket` has been set.
183 last_update = models.DateTimeField(default=timezone.now)
185 def __str__(self):
186 return self.federated_username
188 @property
189 def federated_username(self):
190 """The federated username with a suffix for the current :class:`FederatedUser`."""
191 return self.provider.build_username(self.username)
193 @classmethod
194 def get_from_federated_username(cls, username):
195 """
196 :return: A :class:`FederatedUser` object from a federated ``username``
197 :rtype: :class:`FederatedUser`
198 """
199 if username is None:
200 raise cls.DoesNotExist()
201 else:
202 component = username.split('@')
203 username = '@'.join(component[:-1])
204 suffix = component[-1]
205 try:
206 provider = FederatedIendityProvider.objects.get(suffix=suffix)
207 return cls.objects.get(username=username, provider=provider)
208 except FederatedIendityProvider.DoesNotExist:
209 raise cls.DoesNotExist()
211 @classmethod
212 def clean_old_entries(cls):
213 """remove old unused :class:`FederatedUser`"""
214 federated_users = cls.objects.filter(
215 last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))
216 )
217 known_users = {user.username for user in User.objects.all()}
218 for user in federated_users:
219 if user.federated_username not in known_users:
220 user.delete()
223class FederateSLO(models.Model):
224 """
225 Bases: :class:`django.db.models.Model`
227 An association between a CAS provider ticket and a (username, session) for processing SLO
228 """
229 class Meta:
230 unique_together = ("username", "session_key", "ticket")
231 #: the federated username with the ``@``component
232 username = models.CharField(max_length=30)
233 #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket`
234 session_key = models.CharField(max_length=40, blank=True, null=True)
235 #: The ticket used to authenticate :attr:`username`
236 ticket = models.CharField(max_length=255, db_index=True)
238 @classmethod
239 def clean_deleted_sessions(cls):
240 """remove old :class:`FederateSLO` object for which the session do not exists anymore"""
241 for federate_slo in cls.objects.all():
242 if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
243 federate_slo.delete()
246@python_2_unicode_compatible
247class UserAttributes(JsonAttributes):
248 """
249 Bases: :class:`JsonAttributes`
251 Local cache of the user attributes, used then needed
252 """
253 class Meta:
254 verbose_name = _("User attributes cache")
255 verbose_name_plural = _("User attributes caches")
256 #: The username of the user for which we cache attributes
257 username = models.CharField(max_length=155, unique=True)
259 def __str__(self):
260 return self.username
262 @classmethod
263 def clean_old_entries(cls):
264 """Remove :class:`UserAttributes` for which no more :class:`User` exists."""
265 for user in cls.objects.all():
266 if User.objects.filter(username=user.username).count() == 0:
267 user.delete()
270@python_2_unicode_compatible
271class User(models.Model):
272 """
273 Bases: :class:`django.db.models.Model`
275 A user logged into the CAS
276 """
277 class Meta:
278 unique_together = ("username", "session_key")
279 verbose_name = _("User")
280 verbose_name_plural = _("Users")
281 #: The session key of the current authenticated user
282 session_key = models.CharField(max_length=40, blank=True, null=True)
283 #: The username of the current authenticated user
284 username = models.CharField(max_length=250)
285 #: Last time the authenticated user has do something (auth, fetch ticket, etc…)
286 date = models.DateTimeField(auto_now=True)
287 #: last time the user logged
288 last_login = models.DateTimeField(auto_now_add=True)
290 def delete(self, *args, **kwargs):
291 """
292 Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete
293 the corresponding :class:`FederateSLO` object.
294 """
295 if settings.CAS_FEDERATE:
296 FederateSLO.objects.filter(
297 username=self.username,
298 session_key=self.session_key
299 ).delete()
300 super(User, self).delete(*args, **kwargs)
302 @classmethod
303 def clean_old_entries(cls):
304 """
305 Remove :class:`User` objects inactive since more that
306 :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests.
307 """
308 filter = Q(date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)))
309 if settings.CAS_TGT_VALIDITY is not None:
310 filter |= Q(
311 last_login__lt=(timezone.now() - timedelta(seconds=settings.CAS_TGT_VALIDITY))
312 )
313 users = cls.objects.filter(filter)
314 for user in users:
315 user.logout()
316 users.delete()
318 @classmethod
319 def clean_deleted_sessions(cls):
320 """Remove :class:`User` objects where the corresponding session do not exists anymore."""
321 for user in cls.objects.all():
322 if not SessionStore(session_key=user.session_key).get('authenticated'):
323 user.logout()
324 user.delete()
326 @property
327 def attributs(self):
328 """
329 Property.
330 A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` if
331 possible, and if not, try to fallback to cached attributes (actually only used for ldap
332 auth class with bind password check mthode).
333 """
334 try:
335 return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()
336 except NotImplementedError:
337 try:
338 user = UserAttributes.objects.get(username=self.username)
339 attributes = user.attributs
340 if attributes is not None:
341 return attributes
342 else:
343 return {}
344 except UserAttributes.DoesNotExist:
345 return {}
347 def __str__(self):
348 return u"%s - %s" % (self.username, self.session_key)
350 def logout(self, request=None):
351 """
352 Send SLO requests to all services the user is logged in.
354 :param request: The current django HttpRequest to display possible failure to the user.
355 :type request: :class:`django.http.HttpRequest` or :obj:`NoneType<types.NoneType>`
356 """
357 ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
358 for error in Ticket.send_slos(
359 [ticket_class.objects.filter(user=self) for ticket_class in ticket_classes]
360 ):
361 logger.warning(
362 "Error during SLO for user %s: %s" % (
363 self.username,
364 error
365 )
366 )
367 if request is not None:
368 error = utils.unpack_nested_exception(error)
369 messages.add_message(
370 request,
371 messages.WARNING,
372 _(u'Error during service logout %s') % error
373 )
375 def get_ticket(self, ticket_class, service, service_pattern, renew):
376 """
377 Generate a ticket using ``ticket_class`` for the service
378 ``service`` matching ``service_pattern`` and asking or not for
379 authentication renewal with ``renew``
381 :param type ticket_class: :class:`ServiceTicket` or :class:`ProxyTicket` or
382 :class:`ProxyGrantingTicket`.
383 :param unicode service: The service url for which we want a ticket.
384 :param ServicePattern service_pattern: The service pattern matching ``service``.
385 Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current
386 :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done
387 here and you must perform them before calling this method.
388 :param bool renew: Should be ``True`` if authentication has been renewed. Must be
389 ``False`` otherwise.
390 :return: A :class:`Ticket` object.
391 :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or
392 :class:`ProxyGrantingTicket`.
393 """
394 attributs = dict(
395 (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all()
396 )
397 replacements = dict(
398 (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
399 )
400 service_attributs = {}
401 for (key, value) in self.attributs.items():
402 if key in attributs or '*' in attributs:
403 if key in replacements:
404 if isinstance(value, list):
405 for index, subval in enumerate(value):
406 value[index] = re.sub(
407 replacements[key][0],
408 replacements[key][1],
409 subval
410 )
411 else:
412 value = re.sub(replacements[key][0], replacements[key][1], value)
413 service_attributs[attributs.get(key, key)] = value
414 ticket = ticket_class.objects.create(
415 user=self,
416 attributs=service_attributs,
417 service=service,
418 renew=renew,
419 service_pattern=service_pattern,
420 single_log_out=service_pattern.single_log_out
421 )
422 ticket.save()
423 self.save()
424 return ticket
426 def get_service_url(self, service, service_pattern, renew):
427 """
428 Return the url to which the user must be redirected to
429 after a Service Ticket has been generated
431 :param unicode service: The service url for which we want a ticket.
432 :param ServicePattern service_pattern: The service pattern matching ``service``.
433 Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current
434 :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done
435 here and you must perform them before calling this method.
436 :param bool renew: Should be ``True`` if authentication has been renewed. Must be
437 ``False`` otherwise.
438 :return unicode: The service url with the ticket GET param added.
439 :rtype: unicode
440 """
441 ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
442 url = utils.update_url(service, {'ticket': ticket.value})
443 logger.info("Service ticket created for service %s by user %s." % (service, self.username))
444 return url
447class ServicePatternException(Exception):
448 """
449 Bases: :class:`exceptions.Exception`
451 Base exception of exceptions raised in the ServicePattern model"""
452 pass
455class BadUsername(ServicePatternException):
456 """
457 Bases: :class:`ServicePatternException`
459 Exception raised then an non allowed username try to get a ticket for a service
460 """
461 pass
464class BadFilter(ServicePatternException):
465 """
466 Bases: :class:`ServicePatternException`
468 Exception raised then a user try to get a ticket for a service and do not reach a condition
469 """
470 pass
473class UserFieldNotDefined(ServicePatternException):
474 """
475 Bases: :class:`ServicePatternException`
477 Exception raised then a user try to get a ticket for a service using as username
478 an attribut not present on this user
479 """
480 pass
483@python_2_unicode_compatible
484class ServicePattern(models.Model):
485 """
486 Bases: :class:`django.db.models.Model`
488 Allowed services pattern against services are tested to
489 """
490 class Meta:
491 ordering = ("pos", )
492 verbose_name = _("Service pattern")
493 verbose_name_plural = _("Services patterns")
495 #: service patterns are sorted using the :attr:`pos` attribute
496 pos = models.IntegerField(
497 default=100,
498 verbose_name=_(u"position"),
499 help_text=_(u"service patterns are sorted using the position attribute")
500 )
501 #: A name for the service (this can bedisplayed to the user on the login page)
502 name = models.CharField(
503 max_length=255,
504 unique=True,
505 blank=True,
506 null=True,
507 verbose_name=_(u"name"),
508 help_text=_(u"A name for the service")
509 )
510 #: A regular expression matching services. "Will usually looks like
511 #: '^https://some\\.server\\.com/path/.*$'. As it is a regular expression, special character
512 #: must be escaped with a '\\'.
513 pattern = models.CharField(
514 max_length=255,
515 unique=True,
516 verbose_name=_(u"pattern"),
517 help_text=_(
518 "A regular expression matching services. "
519 "Will usually looks like '^https://some\\.server\\.com/path/.*$'."
520 "As it is a regular expression, special character must be escaped with a '\\'."
521 ),
522 validators=[utils.regexpr_validator]
523 )
524 #: Name of the attribute to transmit as username, if empty the user login is used
525 user_field = models.CharField(
526 max_length=255,
527 default="",
528 blank=True,
529 verbose_name=_(u"user field"),
530 help_text=_("Name of the attribute to transmit as username, empty = login")
531 )
532 #: A boolean allowing to limit username allowed to connect to :attr:`usernames`.
533 restrict_users = models.BooleanField(
534 default=False,
535 verbose_name=_(u"restrict username"),
536 help_text=_("Limit username allowed to connect to the list provided bellow")
537 )
538 #: A boolean allowing to deliver :class:`ProxyTicket` to the service.
539 proxy = models.BooleanField(
540 default=False,
541 verbose_name=_(u"proxy"),
542 help_text=_("Proxy tickets can be delivered to the service")
543 )
544 #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param)
545 #: to deliver :class:`ProxyGrantingTicket`.
546 proxy_callback = models.BooleanField(
547 default=False,
548 verbose_name=_(u"proxy callback"),
549 help_text=_("can be used as a proxy callback to deliver PGT")
550 )
551 #: Enable SingleLogOut for the service. Old validaed tickets for the service will be kept
552 #: until ``settings.CAS_TICKET_TIMEOUT`` after what a SLO request is send to the service and
553 #: the ticket is purged from database. A SLO can be send earlier if the user log-out.
554 single_log_out = models.BooleanField(
555 default=False,
556 verbose_name=_(u"single log out"),
557 help_text=_("Enable SLO for the service")
558 )
559 #: An URL where the SLO request will be POST. If empty the service url will be used.
560 #: This is usefull for non HTTP proxied services like smtp or imap.
561 single_log_out_callback = models.CharField(
562 max_length=255,
563 default="",
564 blank=True,
565 verbose_name=_(u"single log out callback"),
566 help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
567 u"This is usefull for non HTTP proxied services.")
568 )
570 def __str__(self):
571 return u"%s: %s" % (self.pos, self.pattern)
573 def check_user(self, user):
574 """
575 Check if ``user`` if allowed to use theses services. If ``user`` is not allowed,
576 raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername`
578 :param User user: a :class:`User` object
579 :raises BadUsername: if :attr:`restrict_users` if ``True`` and :attr:`User.username`
580 is not within :attr:`usernames`.
581 :raises BadFilter: if a :class:`FilterAttributValue` condition of :attr:`filters`
582 connot be verified.
583 :raises UserFieldNotDefined: if :attr:`user_field` is defined and its value is not
584 within :attr:`User.attributs`.
585 :return: ``True``
586 :rtype: bool
587 """
588 if self.restrict_users and not self.usernames.filter(value=user.username):
589 logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
590 raise BadUsername()
591 for filtre in self.filters.all():
592 if isinstance(user.attributs.get(filtre.attribut, []), list):
593 attrs = user.attributs.get(filtre.attribut, [])
594 else:
595 attrs = [user.attributs[filtre.attribut]]
596 for value in attrs:
597 if re.match(filtre.pattern, str(value)):
598 break
599 else:
600 bad_filter = (filtre.pattern, filtre.attribut, user.attributs.get(filtre.attribut))
601 logger.warning(
602 "User constraint failed for %s, service %s: %s do not match %s %s." % (
603 (user.username, self.name) + bad_filter
604 )
605 )
606 raise BadFilter('%s do not match %s %s' % bad_filter)
607 if self.user_field and not user.attributs.get(self.user_field):
608 logger.warning(
609 "Cannot use %s a loggin for user %s on service %s because it is absent" % (
610 self.user_field,
611 user.username,
612 self.name
613 )
614 )
615 raise UserFieldNotDefined()
616 return True
618 @classmethod
619 def validate(cls, service):
620 """
621 Get a :class:`ServicePattern` intance from a service url.
623 :param unicode service: A service url
624 :return: A :class:`ServicePattern` instance matching ``service``.
625 :rtype: :class:`ServicePattern`
626 :raises ServicePattern.DoesNotExist: if no :class:`ServicePattern` is matching
627 ``service``.
628 """
629 for service_pattern in cls.objects.all().order_by('pos'):
630 if re.match(service_pattern.pattern, service):
631 return service_pattern
632 logger.warning("Service %s not allowed." % service)
633 raise cls.DoesNotExist()
636@python_2_unicode_compatible
637class Username(models.Model):
638 """
639 Bases: :class:`django.db.models.Model`
641 A list of allowed usernames on a :class:`ServicePattern`
642 """
643 #: username allowed to connect to the service
644 value = models.CharField(
645 max_length=255,
646 verbose_name=_(u"username"),
647 help_text=_(u"username allowed to connect to the service")
648 )
649 #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a
650 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames`
651 #: attribute.
652 service_pattern = models.ForeignKey(
653 ServicePattern,
654 related_name="usernames",
655 on_delete=models.CASCADE
656 )
658 def __str__(self):
659 return self.value
662@python_2_unicode_compatible
663class ReplaceAttributName(models.Model):
664 """
665 Bases: :class:`django.db.models.Model`
667 A replacement of an attribute name for a :class:`ServicePattern`. It also tell to transmit
668 an attribute of :attr:`User.attributs` to the service. An empty :attr:`replace` mean
669 to use the original attribute name.
670 """
671 class Meta:
672 unique_together = ('name', 'replace', 'service_pattern')
673 #: Name the attribute: a key of :attr:`User.attributs`
674 name = models.CharField(
675 max_length=255,
676 verbose_name=_(u"name"),
677 help_text=_(u"name of an attribute to send to the service, use * for all attributes")
678 )
679 #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name`
680 #: is used.
681 replace = models.CharField(
682 max_length=255,
683 blank=True,
684 verbose_name=_(u"replace"),
685 help_text=_(u"name under which the attribute will be show "
686 u"to the service. empty = default name of the attribut")
687 )
688 #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a
689 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs`
690 #: attribute.
691 service_pattern = models.ForeignKey(
692 ServicePattern,
693 related_name="attributs",
694 on_delete=models.CASCADE
695 )
697 def __str__(self):
698 if not self.replace:
699 return self.name
700 else:
701 return u"%s → %s" % (self.name, self.replace)
704@python_2_unicode_compatible
705class FilterAttributValue(models.Model):
706 """
707 Bases: :class:`django.db.models.Model`
709 A filter on :attr:`User.attributs` for a :class:`ServicePattern`. If a :class:`User` do not
710 have an attribute :attr:`attribut` or its value do not match :attr:`pattern`, then
711 :meth:`ServicePattern.check_user` will raises :class:`BadFilter` if called with that user.
712 """
713 #: The name of a user attribute
714 attribut = models.CharField(
715 max_length=255,
716 verbose_name=_(u"attribute"),
717 help_text=_(u"Name of the attribute which must verify pattern")
718 )
719 #: A regular expression the attribute :attr:`attribut` value must verify. If :attr:`attribut`
720 #: if a list, only one of the list values needs to match.
721 pattern = models.CharField(
722 max_length=255,
723 verbose_name=_(u"pattern"),
724 help_text=_(u"a regular expression"),
725 validators=[utils.regexpr_validator]
726 )
727 #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a
728 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters`
729 #: attribute.
730 service_pattern = models.ForeignKey(
731 ServicePattern,
732 related_name="filters",
733 on_delete=models.CASCADE
734 )
736 def __str__(self):
737 return u"%s %s" % (self.attribut, self.pattern)
740@python_2_unicode_compatible
741class ReplaceAttributValue(models.Model):
742 """
743 Bases: :class:`django.db.models.Model`
745 A replacement (using a regular expression) of an attribute value for a
746 :class:`ServicePattern`.
747 """
748 #: Name the attribute: a key of :attr:`User.attributs`
749 attribut = models.CharField(
750 max_length=255,
751 verbose_name=_(u"attribute"),
752 help_text=_(u"Name of the attribute for which the value must be replace")
753 )
754 #: A regular expression matching the part of the attribute value that need to be changed
755 pattern = models.CharField(
756 max_length=255,
757 verbose_name=_(u"pattern"),
758 help_text=_(u"An regular expression maching whats need to be replaced"),
759 validators=[utils.regexpr_validator]
760 )
761 #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 …
762 replace = models.CharField(
763 max_length=255,
764 blank=True,
765 verbose_name=_(u"replace"),
766 help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
767 )
768 #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a
769 #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements`
770 #: attribute.
771 service_pattern = models.ForeignKey(
772 ServicePattern,
773 related_name="replacements",
774 on_delete=models.CASCADE
775 )
777 def __str__(self):
778 return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
781@python_2_unicode_compatible
782class Ticket(JsonAttributes):
783 """
784 Bases: :class:`JsonAttributes`
786 Generic class for a Ticket
787 """
788 class Meta:
789 abstract = True
790 #: ForeignKey to a :class:`User`.
791 user = models.ForeignKey(User, related_name="%(class)s", on_delete=models.CASCADE)
792 #: A boolean. ``True`` if the ticket has been validated
793 validate = models.BooleanField(default=False)
794 #: The service url for the ticket
795 service = models.TextField()
796 #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to
797 #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it.
798 service_pattern = models.ForeignKey(
799 ServicePattern,
800 related_name="%(class)s",
801 on_delete=models.CASCADE
802 )
803 #: Date of the ticket creation
804 creation = models.DateTimeField(auto_now_add=True)
805 #: A boolean. ``True`` if the user has just renew his authentication
806 renew = models.BooleanField(default=False)
807 #: A boolean. Set to :attr:`service_pattern` attribute
808 #: :attr:`ServicePattern.single_log_out` value.
809 single_log_out = models.BooleanField(default=False)
811 #: Max duration between ticket creation and its validation. Any validation attempt for the
812 #: ticket after :attr:`creation` + VALIDITY will fail as if the ticket do not exists.
813 VALIDITY = settings.CAS_TICKET_VALIDITY
814 #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut
815 #: requests.
816 TIMEOUT = settings.CAS_TICKET_TIMEOUT
818 class DoesNotExist(Exception):
819 """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch"""
820 pass
822 def __str__(self):
823 return u"Ticket-%s" % self.pk
825 @staticmethod
826 def send_slos(queryset_list):
827 """
828 Send SLO requests to each ticket of each queryset of ``queryset_list``
830 :param list queryset_list: A list a :class:`Ticket` queryset
831 :return: A list of possibly encoutered :class:`Exception`
832 :rtype: list
833 """
834 # sending SLO to timed-out validated tickets
835 async_list = []
836 session = FuturesSession(
837 executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
838 )
839 errors = []
840 for queryset in queryset_list:
841 for ticket in queryset:
842 ticket.logout(session, async_list)
843 queryset.delete()
844 for future in async_list:
845 if future: # pragma: no branch (should always be true)
846 try:
847 future.result()
848 except Exception as error:
849 errors.append(error)
850 return errors
852 @classmethod
853 def clean_old_entries(cls):
854 """Remove old ticket and send SLO to timed-out services"""
855 # removing old validated ticket and non validated expired tickets
856 cls.objects.filter(
857 (
858 Q(single_log_out=False) & Q(validate=True)
859 ) | (
860 Q(validate=False) &
861 Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
862 )
863 ).delete()
864 queryset = cls.objects.filter(
865 creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
866 )
867 for error in cls.send_slos([queryset]):
868 logger.warning("Error durring SLO %s" % error)
869 sys.stderr.write("%r\n" % error)
871 def logout(self, session, async_list=None):
872 """Send a SLO request to the ticket service"""
873 # On logout invalidate the Ticket
874 self.validate = True
875 self.save()
876 if self.validate and self.single_log_out: # pragma: no branch (should always be true)
877 logger.info(
878 "Sending SLO requests to service %s for user %s" % (
879 self.service,
880 self.user.username
881 )
882 )
883 xml = utils.logout_request(self.value)
884 if self.service_pattern.single_log_out_callback:
885 url = self.service_pattern.single_log_out_callback
886 else:
887 url = self.service
888 async_list.append(
889 session.post(
890 url.encode('utf-8'),
891 data={'logoutRequest': xml.encode('utf-8')},
892 timeout=settings.CAS_SLO_TIMEOUT
893 )
894 )
896 @staticmethod
897 def get_class(ticket, classes=None):
898 """
899 Return the ticket class of ``ticket``
901 :param unicode ticket: A ticket
902 :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses
903 :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or
904 :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes,
905 ``None`` otherwise.
906 :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>`
907 """
908 if classes is None: # pragma: no cover (not used)
909 classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
910 for ticket_class in classes:
911 if ticket.startswith(ticket_class.PREFIX):
912 return ticket_class
914 def username(self):
915 """
916 The username to send on ticket validation
918 :return: The value of the corresponding user attribute if
919 :attr:`service_pattern`.user_field is set, the user username otherwise.
920 """
921 if self.service_pattern.user_field and self.user.attributs.get(
922 self.service_pattern.user_field
923 ):
924 username = self.user.attributs[self.service_pattern.user_field]
925 if isinstance(username, list):
926 # the list is not empty because we wont generate a ticket with a user_field
927 # that evaluate to False
928 username = username[0]
929 else:
930 username = self.user.username
931 return username
933 def attributs_flat(self):
934 """
935 generate attributes list for template rendering
937 :return: An list of (attribute name, attribute value) of all user attributes flatened
938 (no nested list)
939 :rtype: :obj:`list` of :obj:`tuple` of :obj:`unicode`
940 """
941 attributes = []
942 for key, value in self.attributs.items():
943 if isinstance(value, list):
944 for elt in value:
945 attributes.append((key, elt))
946 else:
947 attributes.append((key, value))
948 return attributes
950 @classmethod
951 def get(cls, ticket, renew=False, service=None):
952 """
953 Search the database for a valid ticket with provided arguments
955 :param unicode ticket: A ticket value
956 :param bool renew: Is authentication renewal needed
957 :param unicode service: Optional argument. The ticket service
958 :raises Ticket.DoesNotExist: if no class is found for the ticket prefix
959 :raises cls.DoesNotExist: if ``ticket`` value is not found in th database
960 :return: a :class:`Ticket` instance
961 :rtype: Ticket
962 """
963 # If the method class is the ticket abstract class, search for the submited ticket
964 # class using its prefix. Assuming ticket is a ProxyTicket or a ServiceTicket
965 if cls == Ticket:
966 ticket_class = cls.get_class(ticket, classes=[ServiceTicket, ProxyTicket])
967 # else use the method class
968 else:
969 ticket_class = cls
970 # If ticket prefix is wrong, raise DoesNotExist
971 if cls != Ticket and not ticket.startswith(cls.PREFIX):
972 raise Ticket.DoesNotExist()
973 if ticket_class:
974 # search for the ticket that is not yet validated and is still valid
975 ticket_queryset = ticket_class.objects.filter(
976 value=ticket,
977 validate=False,
978 creation__gt=(timezone.now() - timedelta(seconds=ticket_class.VALIDITY))
979 )
980 # if service is specified, add it the the queryset
981 if service is not None:
982 ticket_queryset = ticket_queryset.filter(service=service)
983 # only require renew if renew is True, otherwise it do not matter if renew is True
984 # or False.
985 if renew:
986 ticket_queryset = ticket_queryset.filter(renew=True)
987 # fetch the ticket ``MultipleObjectsReturned`` is never raised as the ticket value
988 # is unique across the database
989 ticket = ticket_queryset.get()
990 # For ServiceTicket and Proxyticket, mark it as validated before returning
991 if ticket_class != ProxyGrantingTicket:
992 ticket.validate = True
993 ticket.save()
994 return ticket
995 # If no class found for the ticket, raise DoesNotExist
996 else:
997 raise Ticket.DoesNotExist()
1000@python_2_unicode_compatible
1001class ServiceTicket(Ticket):
1002 """
1003 Bases: :class:`Ticket`
1005 A Service Ticket
1006 """
1007 #: The ticket prefix used to differentiate it from other tickets types
1008 PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
1009 #: The ticket value
1010 value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
1012 def __str__(self):
1013 return u"ServiceTicket-%s" % self.pk
1016@python_2_unicode_compatible
1017class ProxyTicket(Ticket):
1018 """
1019 Bases: :class:`Ticket`
1021 A Proxy Ticket
1022 """
1023 #: The ticket prefix used to differentiate it from other tickets types
1024 PREFIX = settings.CAS_PROXY_TICKET_PREFIX
1025 #: The ticket value
1026 value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
1028 def __str__(self):
1029 return u"ProxyTicket-%s" % self.pk
1032@python_2_unicode_compatible
1033class ProxyGrantingTicket(Ticket):
1034 """
1035 Bases: :class:`Ticket`
1037 A Proxy Granting Ticket
1038 """
1039 #: The ticket prefix used to differentiate it from other tickets types
1040 PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
1041 #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY`
1042 #: to get :class:`ProxyTicket` for :attr:`user`
1043 VALIDITY = settings.CAS_PGT_VALIDITY
1044 #: The ticket value
1045 value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
1047 def __str__(self):
1048 return u"ProxyGrantingTicket-%s" % self.pk
1051@python_2_unicode_compatible
1052class Proxy(models.Model):
1053 """
1054 Bases: :class:`django.db.models.Model`
1056 A list of proxies on :class:`ProxyTicket`
1057 """
1058 class Meta:
1059 ordering = ("-pk", )
1060 #: Service url of the PGT used for getting the associated :class:`ProxyTicket`
1061 url = models.CharField(max_length=255)
1062 #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a
1063 #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies`
1064 #: attribute.
1065 proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies", on_delete=models.CASCADE)
1067 def __str__(self):
1068 return self.url
1071class NewVersionWarning(models.Model):
1072 """
1073 Bases: :class:`django.db.models.Model`
1075 The last new version available version sent
1076 """
1077 version = models.CharField(max_length=255)
1079 @classmethod
1080 def send_mails(cls):
1081 """
1082 For each new django-cas-server version, if the current instance is not up to date
1083 send one mail to ``settings.ADMINS``.
1084 """
1085 if settings.CAS_NEW_VERSION_EMAIL_WARNING and settings.ADMINS:
1086 try:
1087 obj = cls.objects.get()
1088 except cls.DoesNotExist:
1089 obj = NewVersionWarning.objects.create(version=VERSION)
1090 LAST_VERSION = utils.last_version()
1091 if LAST_VERSION is not None and LAST_VERSION != obj.version:
1092 if utils.decode_version(VERSION) < utils.decode_version(LAST_VERSION):
1093 try:
1094 send_mail(
1095 (
1096 '%sA new version of django-cas-server is available'
1097 ) % settings.EMAIL_SUBJECT_PREFIX,
1098 u'''
1099A new version of the django-cas-server is available.
1101Your version: %s
1102New version: %s
1104Upgrade using:
1105 * pip install -U django-cas-server
1106 * fetching the last release on
1107 https://github.com/nitmir/django-cas-server/ or on
1108 https://pypi.org/project/django-cas-server/
1110After upgrade, do not forget to run:
1111 * ./manage.py migrate
1112 * ./manage.py collectstatic
1113and to reload your wsgi server (apache2, uwsgi, gunicord, etc…)
1115--\u0020
1116django-cas-server
1117'''.strip() % (VERSION, LAST_VERSION),
1118 settings.SERVER_EMAIL,
1119 ["%s <%s>" % admin for admin in settings.ADMINS],
1120 fail_silently=False,
1121 )
1122 obj.version = LAST_VERSION
1123 obj.save()
1124 except smtplib.SMTPException as error: # pragma: no cover (should not happen)
1125 logger.error("Unable to send new version mail: %s" % error)