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