models.py 41.1 KB
Newer Older
Valentin Samir's avatar
Valentin Samir committed
1
# -*- coding: utf-8 -*-
Valentin Samir's avatar
Valentin Samir committed
2 3 4 5 6 7 8 9 10
# 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.
#
Valentin Samir's avatar
Valentin Samir committed
11
# (c) 2015-2016 Valentin Samir
Valentin Samir's avatar
Valentin Samir committed
12
"""models for the app"""
13
from .default_settings import settings, SessionStore
14

Valentin Samir's avatar
Valentin Samir committed
15
from django.db import models
16
from django.db.models import Q
Valentin Samir's avatar
Valentin Samir committed
17
from django.contrib import messages
18
from django.utils.translation import ugettext_lazy as _
19
from django.utils import timezone
20
from django.utils.encoding import python_2_unicode_compatible
21 22
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
Valentin Samir's avatar
Valentin Samir committed
23 24

import re
25
import sys
26
import smtplib
Valentin Samir's avatar
Valentin Samir committed
27
import logging
28
from datetime import timedelta
Valentin Samir's avatar
Valentin Samir committed
29 30
from concurrent.futures import ThreadPoolExecutor
from requests_futures.sessions import FuturesSession
Valentin Samir's avatar
Valentin Samir committed
31

Valentin Samir's avatar
Valentin Samir committed
32
import cas_server.utils as utils
33
from . import VERSION
Valentin Samir's avatar
Valentin Samir committed
34

35
#: logger facility
Valentin Samir's avatar
Valentin Samir committed
36 37
logger = logging.getLogger(__name__)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
38

39 40
@python_2_unicode_compatible
class FederatedIendityProvider(models.Model):
41
    """
42 43
        Bases: :class:`django.db.models.Model`

44 45
        An identity provider for the federated mode
    """
46
    class Meta:
47 48
        verbose_name = _(u"identity provider")
        verbose_name_plural = _(u"identity providers")
49 50
    #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.
    #: it must be unique.
51 52 53 54
    suffix = models.CharField(
        max_length=30,
        unique=True,
        verbose_name=_(u"suffix"),
55
        help_text=_(
56
            u"Suffix append to backend CAS returned "
57 58
            u"username: ``returned_username`` @ ``suffix``."
        )
59
    )
60 61 62
    #: URL to the root of the CAS server application. If login page is
    #: https://cas.example.net/cas/login then :attr:`server_url` should be
    #: https://cas.example.net/cas/
63
    server_url = models.CharField(max_length=255, verbose_name=_(u"server url"))
64
    #: Version of the CAS protocol to use when sending requests the the backend CAS.
65 66 67 68 69 70 71 72 73
    cas_protocol_version = models.CharField(
        max_length=30,
        choices=[
            ("1", "CAS 1.0"),
            ("2", "CAS 2.0"),
            ("3", "CAS 3.0"),
            ("CAS_2_SAML_1_0", "SAML 1.1")
        ],
        verbose_name=_(u"CAS protocol version"),
74 75 76
        help_text=_(
            u"Version of the CAS protocol to use when sending requests the the backend CAS."
        ),
77 78
        default="3"
    )
79
    #: Name for this identity provider displayed on the login page.
80 81 82
    verbose_name = models.CharField(
        max_length=255,
        verbose_name=_(u"verbose name"),
83
        help_text=_(u"Name for this identity provider displayed on the login page.")
84
    )
85 86
    #: Position of the identity provider on the login page. Identity provider are sorted using the
    #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes.
87 88 89 90 91
    pos = models.IntegerField(
        default=100,
        verbose_name=_(u"position"),
        help_text=_(
            (
92
                u"Position of the identity provider on the login page. "
93
                u"Identity provider are sorted using the "
94
                u"(position, verbose name, suffix) attributes."
95 96 97
            )
        )
    )
98 99 100
    #: Display the provider on the login page. Beware that this do not disable the identity
    #: provider, it just hide it on the login page. User will always be able to log in using this
    #: provider by fetching ``/federate/suffix``.
101 102 103
    display = models.BooleanField(
        default=True,
        verbose_name=_(u"display"),
104
        help_text=_("Display the provider on the login page.")
105
    )
106 107 108 109 110 111

    def __str__(self):
        return self.verbose_name

    @staticmethod
    def build_username_from_suffix(username, suffix):
112 113 114 115 116
        """
            Transform backend username into federated username using ``suffix``

            :param unicode username: A CAS backend returned username
            :param unicode suffix: A suffix identifying the CAS backend
117 118
            :return: The federated username: ``username`` @ ``suffix``.
            :rtype: unicode
119
        """
120 121 122
        return u'%s@%s' % (username, suffix)

    def build_username(self, username):
123 124 125 126 127 128 129
        """
            Transform backend username into federated username

            :param unicode username: A CAS backend returned username
            :return: The federated username: ``username`` @ :attr:`suffix`.
            :rtype: unicode
        """
130 131 132 133
        return u'%s@%s' % (username, self.suffix)


@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
134
class FederatedUser(models.Model):
135 136 137 138 139
    """
        Bases: :class:`django.db.models.Model`

        A federated user as returner by a CAS provider (username and attributes)
    """
Valentin Samir's avatar
Valentin Samir committed
140 141
    class Meta:
        unique_together = ("username", "provider")
142
    #: The user username returned by the CAS backend on successful ticket validation
Valentin Samir's avatar
Valentin Samir committed
143
    username = models.CharField(max_length=124)
144
    #: A foreign key to :class:`FederatedIendityProvider`
145
    provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
146 147
    #: The user attributes json encoded
    _attributs = models.TextField(default=None, null=True, blank=True)
148
    #: The last ticket used to authenticate :attr:`username` against :attr:`provider`
Valentin Samir's avatar
Valentin Samir committed
149
    ticket = models.CharField(max_length=255)
150
    #: Last update timespampt. Usually, the last time :attr:`ticket` has been set.
Valentin Samir's avatar
Valentin Samir committed
151 152
    last_update = models.DateTimeField(auto_now=True)

153 154 155
    def __str__(self):
        return self.federated_username

156 157 158 159 160 161 162 163 164 165 166
    @property
    def attributs(self):
        """The user attributes returned by the CAS backend on successful ticket validation"""
        if self._attributs is not None:
            return utils.json.loads(self._attributs)

    @attributs.setter
    def attributs(self, value):
        """attributs property setter"""
        self._attributs = utils.json_encode(value)

167 168
    @property
    def federated_username(self):
169
        """The federated username with a suffix for the current :class:`FederatedUser`."""
170 171 172 173
        return self.provider.build_username(self.username)

    @classmethod
    def get_from_federated_username(cls, username):
174 175 176 177
        """
            :return: A :class:`FederatedUser` object from a federated ``username``
            :rtype: :class:`FederatedUser`
        """
178 179 180 181 182 183 184 185 186 187 188
        if username is None:
            raise cls.DoesNotExist()
        else:
            component = username.split('@')
            username = '@'.join(component[:-1])
            suffix = component[-1]
            try:
                provider = FederatedIendityProvider.objects.get(suffix=suffix)
                return cls.objects.get(username=username, provider=provider)
            except FederatedIendityProvider.DoesNotExist:
                raise cls.DoesNotExist()
189

190 191
    @classmethod
    def clean_old_entries(cls):
192
        """remove old unused :class:`FederatedUser`"""
193 194 195 196 197
        federated_users = cls.objects.filter(
            last_update__lt=(timezone.now() - timedelta(seconds=settings.CAS_TICKET_TIMEOUT))
        )
        known_users = {user.username for user in User.objects.all()}
        for user in federated_users:
198
            if user.federated_username not in known_users:
199 200
                user.delete()

Valentin Samir's avatar
Valentin Samir committed
201

202
class FederateSLO(models.Model):
203 204 205 206 207
    """
        Bases: :class:`django.db.models.Model`

        An association between a CAS provider ticket and a (username, session) for processing SLO
    """
208
    class Meta:
209
        unique_together = ("username", "session_key", "ticket")
210
    #: the federated username with the ``@``component
211
    username = models.CharField(max_length=30)
212
    #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket`
213
    session_key = models.CharField(max_length=40, blank=True, null=True)
214
    #: The ticket used to authenticate :attr:`username`
215
    ticket = models.CharField(max_length=255, db_index=True)
216 217 218

    @classmethod
    def clean_deleted_sessions(cls):
219
        """remove old :class:`FederateSLO` object for which the session do not exists anymore"""
220 221 222 223 224
        for federate_slo in cls.objects.all():
            if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
                federate_slo.delete()


225
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
226
class User(models.Model):
227 228 229 230 231
    """
        Bases: :class:`django.db.models.Model`

        A user logged into the CAS
    """
Valentin Samir's avatar
Valentin Samir committed
232
    class Meta:
233
        unique_together = ("username", "session_key")
234 235
        verbose_name = _("User")
        verbose_name_plural = _("Users")
236
    #: The session key of the current authenticated user
237
    session_key = models.CharField(max_length=40, blank=True, null=True)
238
    #: The username of the current authenticated user
Valentin Samir's avatar
Valentin Samir committed
239
    username = models.CharField(max_length=30)
240
    #: Last time the authenticated user has do something (auth, fetch ticket, etc…)
Valentin Samir's avatar
Valentin Samir committed
241
    date = models.DateTimeField(auto_now=True)
Valentin Samir's avatar
Valentin Samir committed
242

243
    def delete(self, *args, **kwargs):
244 245 246 247
        """
            Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete
            the corresponding :class:`FederateSLO` object.
        """
248 249 250 251 252 253 254
        if settings.CAS_FEDERATE:
            FederateSLO.objects.filter(
                username=self.username,
                session_key=self.session_key
            ).delete()
        super(User, self).delete(*args, **kwargs)

Valentin Samir's avatar
Valentin Samir committed
255 256
    @classmethod
    def clean_old_entries(cls):
257 258 259 260
        """
            Remove :class:`User` objects inactive since more that
            :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests.
        """
261 262 263
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
264 265 266 267
        for user in users:
            user.logout()
        users.delete()

268 269
    @classmethod
    def clean_deleted_sessions(cls):
270
        """Remove :class:`User` objects where the corresponding session do not exists anymore."""
271 272 273 274 275
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

276 277
    @property
    def attributs(self):
278 279 280 281
        """
            Property.
            A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS``
        """
282 283
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

284
    def __str__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
285
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
286

Valentin Samir's avatar
Valentin Samir committed
287
    def logout(self, request=None):
288 289 290 291 292 293
        """
            Send SLO requests to all services the user is logged in.

            :param request: The current django HttpRequest to display possible failure to the user.
            :type request: :class:`django.http.HttpRequest` or :obj:`NoneType<types.NoneType>`
        """
Valentin Samir's avatar
Valentin Samir committed
294
        async_list = []
295 296 297
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
298 299
        # first invalidate all Tickets
        ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
300
        for ticket_class in ticket_classes:
301 302
            queryset = ticket_class.objects.filter(user=self)
            for ticket in queryset:
Valentin Samir's avatar
Valentin Samir committed
303
                ticket.logout(session, async_list)
304
            queryset.delete()
Valentin Samir's avatar
Valentin Samir committed
305
        for future in async_list:
Valentin Samir's avatar
Valentin Samir committed
306
            if future:  # pragma: no branch (should always be true)
307 308 309
                try:
                    future.result()
                except Exception as error:
Valentin Samir's avatar
Valentin Samir committed
310 311 312 313 314 315
                    logger.warning(
                        "Error during SLO for user %s: %s" % (
                            self.username,
                            error
                        )
                    )
Valentin Samir's avatar
Valentin Samir committed
316 317 318 319 320 321 322
                    if request is not None:
                        error = utils.unpack_nested_exception(error)
                        messages.add_message(
                            request,
                            messages.WARNING,
                            _(u'Error during service logout %s') % error
                        )
Valentin Samir's avatar
Valentin Samir committed
323 324 325

    def get_ticket(self, ticket_class, service, service_pattern, renew):
        """
326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341
            Generate a ticket using ``ticket_class`` for the service
            ``service`` matching ``service_pattern`` and asking or not for
            authentication renewal with ``renew``

            :param type ticket_class: :class:`ServiceTicket` or :class:`ProxyTicket` or
               :class:`ProxyGrantingTicket`.
            :param unicode service: The service url for which we want a ticket.
            :param ServicePattern service_pattern: The service pattern matching ``service``.
               Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current
               :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done
               here and you must perform them before calling this method.
            :param bool renew: Should be ``True`` if authentication has been renewed. Must be
                ``False`` otherwise.
            :return: A :class:`Ticket` object.
            :rtype: :class:`ServiceTicket` or :class:`ProxyTicket` or
               :class:`ProxyGrantingTicket`.
Valentin Samir's avatar
Valentin Samir committed
342 343 344 345 346
        """
        attributs = dict(
            (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all()
        )
        replacements = dict(
Valentin Samir's avatar
Valentin Samir committed
347
            (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
Valentin Samir's avatar
Valentin Samir committed
348
        )
Valentin Samir's avatar
Valentin Samir committed
349
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
350
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
351
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
352
                if key in replacements:
Valentin Samir's avatar
Valentin Samir committed
353 354 355 356 357 358 359 360 361
                    if isinstance(value, list):
                        for index, subval in enumerate(value):
                            value[index] = re.sub(
                                replacements[key][0],
                                replacements[key][1],
                                subval
                            )
                    else:
                        value = re.sub(replacements[key][0], replacements[key][1], value)
Valentin Samir's avatar
Valentin Samir committed
362
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
363 364 365 366 367
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
368 369
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
370
        )
Valentin Samir's avatar
Valentin Samir committed
371
        ticket.save()
372
        self.save()
Valentin Samir's avatar
Valentin Samir committed
373 374 375
        return ticket

    def get_service_url(self, service, service_pattern, renew):
376 377 378 379 380 381 382 383 384 385 386 387 388 389
        """
            Return the url to which the user must be redirected to
            after a Service Ticket has been generated

            :param unicode service: The service url for which we want a ticket.
            :param ServicePattern service_pattern: The service pattern matching ``service``.
               Beware that ``service`` must match :attr:`ServicePattern.pattern` and the current
               :class:`User` must pass :meth:`ServicePattern.check_user`. These checks are not done
               here and you must perform them before calling this method.
            :param bool renew: Should be ``True`` if authentication has been renewed. Must be
                ``False`` otherwise.
            :return unicode: The service url with the ticket GET param added.
            :rtype: unicode
        """
Valentin Samir's avatar
Valentin Samir committed
390
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
391
        url = utils.update_url(service, {'ticket': ticket.value})
Valentin Samir's avatar
Valentin Samir committed
392
        logger.info("Service ticket created for service %s by user %s." % (service, self.username))
Valentin Samir's avatar
Valentin Samir committed
393 394
        return url

Valentin Samir's avatar
PEP8  
Valentin Samir committed
395

396
class ServicePatternException(Exception):
397 398 399 400
    """
        Bases: :class:`exceptions.Exception`

        Base exception of exceptions raised in the ServicePattern model"""
401
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
402 403


404
class BadUsername(ServicePatternException):
405 406 407 408 409
    """
        Bases: :class:`ServicePatternException`

        Exception raised then an non allowed username try to get a ticket for a service
    """
Valentin Samir's avatar
Valentin Samir committed
410
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
411 412


413
class BadFilter(ServicePatternException):
414 415 416 417 418
    """
        Bases: :class:`ServicePatternException`

        Exception raised then a user try to get a ticket for a service and do not reach a condition
    """
Valentin Samir's avatar
Valentin Samir committed
419
    pass
Valentin Samir's avatar
Valentin Samir committed
420

Valentin Samir's avatar
PEP8  
Valentin Samir committed
421

422
class UserFieldNotDefined(ServicePatternException):
423 424 425 426 427 428
    """
        Bases: :class:`ServicePatternException`

        Exception raised then a user try to get a ticket for a service using as username
        an attribut not present on this user
    """
Valentin Samir's avatar
Valentin Samir committed
429
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
430 431


432
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
433
class ServicePattern(models.Model):
434 435 436 437 438
    """
        Bases: :class:`django.db.models.Model`

        Allowed services pattern agains services are tested to
    """
Valentin Samir's avatar
Valentin Samir committed
439 440
    class Meta:
        ordering = ("pos", )
441 442
        verbose_name = _("Service pattern")
        verbose_name_plural = _("Services patterns")
Valentin Samir's avatar
Valentin Samir committed
443

444
    #: service patterns are sorted using the :attr:`pos` attribute
445 446
    pos = models.IntegerField(
        default=100,
447 448
        verbose_name=_(u"position"),
        help_text=_(u"service patterns are sorted using the position attribute")
449
    )
450
    #: A name for the service (this can bedisplayed to the user on the login page)
Valentin Samir's avatar
Valentin Samir committed
451 452 453 454 455
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
456 457 458
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
459 460 461
    #: 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 '\\'.
462 463 464
    pattern = models.CharField(
        max_length=255,
        unique=True,
465 466 467 468 469 470
        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 '\\'."
        )
Valentin Samir's avatar
Valentin Samir committed
471
    )
472
    #: Name of the attribut to transmit as username, if empty the user login is used
Valentin Samir's avatar
Valentin Samir committed
473 474 475 476
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
477 478
        verbose_name=_(u"user field"),
        help_text=_("Name of the attribut to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
479
    )
480
    #: A boolean allowing to limit username allowed to connect to :attr:`usernames`.
Valentin Samir's avatar
Valentin Samir committed
481 482
    restrict_users = models.BooleanField(
        default=False,
483 484
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
485
    )
486
    #: A boolean allowing to deliver :class:`ProxyTicket` to the service.
Valentin Samir's avatar
Valentin Samir committed
487 488
    proxy = models.BooleanField(
        default=False,
489
        verbose_name=_(u"proxy"),
490 491
        help_text=_("Proxy tickets can be delivered to the service")
    )
492 493
    #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param)
    #: to deliver :class:`ProxyGrantingTicket`.
494 495 496 497
    proxy_callback = models.BooleanField(
        default=False,
        verbose_name=_(u"proxy callback"),
        help_text=_("can be used as a proxy callback to deliver PGT")
Valentin Samir's avatar
Valentin Samir committed
498
    )
499 500 501
    #: Enable SingleLogOut for the service. Old validaed tickets for the service will be kept
    #: until ``settings.CAS_TICKET_TIMEOUT`` after what a SLO request is send to the service and
    #: the ticket is purged from database. A SLO can be send earlier if the user log-out.
Valentin Samir's avatar
Valentin Samir committed
502
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
503
        default=False,
Valentin Samir's avatar
Valentin Samir committed
504 505
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
506
    )
507 508
    #: An URL where the SLO request will be POST. If empty the service url will be used.
    #: This is usefull for non HTTP proxied services like smtp or imap.
509 510 511 512 513
    single_log_out_callback = models.CharField(
        max_length=255,
        default="",
        blank=True,
        verbose_name=_(u"single log out callback"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
514
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
515 516 517
                    u"This is usefull for non HTTP proxied services.")
    )

518
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
519 520 521
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
522 523 524 525
        """
            Check if ``user`` if allowed to use theses services. If ``user`` is not allowed,
            raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername`

526 527 528 529 530 531 532 533 534
            :param User user: a :class:`User` object
            :raises BadUsername: if :attr:`restrict_users` if ``True`` and :attr:`User.username`
                is not within :attr:`usernames`.
            :raises BadFilter: if a :class:`FilterAttributValue` condition of :attr:`filters`
                connot be verified.
            :raises UserFieldNotDefined: if :attr:`user_field` is defined and its value is not
                within :attr:`User.attributs`.
            :return: ``True``
            :rtype: bool
535
        """
Valentin Samir's avatar
Valentin Samir committed
536
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
537
            logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
Valentin Samir's avatar
Valentin Samir committed
538
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
539
        for filtre in self.filters.all():
540 541
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
542
            else:
Valentin Samir's avatar
Valentin Samir committed
543 544 545
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
546 547
                    break
            else:
Valentin Samir's avatar
Valentin Samir committed
548 549 550 551 552 553 554 555 556
                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)
                    )
                )
Valentin Samir's avatar
Valentin Samir committed
557 558 559
                raise BadFilter('%s do not match %s %s' % (
                    filtre.pattern,
                    filtre.attribut,
Valentin Samir's avatar
Valentin Samir committed
560
                    user.attributs.get(filtre.attribut)
Valentin Samir's avatar
Valentin Samir committed
561
                ))
Valentin Samir's avatar
Valentin Samir committed
562
        if self.user_field and not user.attributs.get(self.user_field):
Valentin Samir's avatar
Valentin Samir committed
563 564 565 566 567 568 569
            logger.warning(
                "Cannot use %s a loggin for user %s on service %s because it is absent" % (
                    self.user_field,
                    user.username,
                    self.name
                )
            )
Valentin Samir's avatar
Valentin Samir committed
570 571 572 573 574
            raise UserFieldNotDefined()
        return True

    @classmethod
    def validate(cls, service):
575 576 577 578 579 580 581 582 583
        """
            Get a :class:`ServicePattern` intance from a service url.

            :param unicode service: A service url
            :return: A :class:`ServicePattern` instance matching ``service``.
            :rtype: :class:`ServicePattern`
            :raises ServicePattern.DoesNotExist: if no :class:`ServicePattern` is matching
                ``service``.
        """
Valentin Samir's avatar
Valentin Samir committed
584 585 586
        for service_pattern in cls.objects.all().order_by('pos'):
            if re.match(service_pattern.pattern, service):
                return service_pattern
Valentin Samir's avatar
Valentin Samir committed
587
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
588 589
        raise cls.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
590

591
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
592
class Username(models.Model):
593 594 595 596 597 598
    """
        Bases: :class:`django.db.models.Model`

        A list of allowed usernames on a :class:`ServicePattern`
    """
    #: username allowed to connect to the service
599 600 601 602 603
    value = models.CharField(
        max_length=255,
        verbose_name=_(u"username"),
        help_text=_(u"username allowed to connect to the service")
    )
604 605 606
    #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
607
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
608

609
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
610 611
        return self.value

Valentin Samir's avatar
PEP8  
Valentin Samir committed
612

613
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
614
class ReplaceAttributName(models.Model):
615 616 617 618 619 620 621
    """
        Bases: :class:`django.db.models.Model`

        A replacement of an attribute name for a :class:`ServicePattern`. It also tell to transmit
        an attribute of :attr:`User.attributs` to the service. An empty :attr:`replace` mean
        to use the original attribute name.
    """
Valentin Samir's avatar
Valentin Samir committed
622
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
623
        unique_together = ('name', 'replace', 'service_pattern')
624
    #: Name the attribute: a key of :attr:`User.attributs`
Valentin Samir's avatar
Valentin Samir committed
625 626
    name = models.CharField(
        max_length=255,
627
        verbose_name=_(u"name"),
Valentin Samir's avatar
Valentin Samir committed
628
        help_text=_(u"name of an attribut to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
629
    )
630 631
    #: The name of the attribute to transmit to the service. If empty, the value of :attr:`name`
    #: is used.
Valentin Samir's avatar
Valentin Samir committed
632 633 634
    replace = models.CharField(
        max_length=255,
        blank=True,
635
        verbose_name=_(u"replace"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
636 637
        help_text=_(u"name under which the attribut will be show"
                    u"to the service. empty = default name of the attribut")
Valentin Samir's avatar
Valentin Samir committed
638
    )
639 640 641
    #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
642 643
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

644
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
645 646 647 648 649
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
650

651
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
652
class FilterAttributValue(models.Model):
653 654 655 656 657 658 659 660
    """
        Bases: :class:`django.db.models.Model`

        A filter on :attr:`User.attributs` for a :class:`ServicePattern`. If a :class:`User` do not
        have an attribute :attr:`attribut` or its value do not match :attr:`pattern`, then
        :meth:`ServicePattern.check_user` will raises :class:`BadFilter` if called with that user.
    """
    #: The name of a user attribute
Valentin Samir's avatar
Valentin Samir committed
661 662
    attribut = models.CharField(
        max_length=255,
663 664
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
665
    )
666 667
    #: A regular expression the attribute :attr:`attribut` value must verify. If :attr:`attribut`
    #: if a list, only one of the list values needs to match.
Valentin Samir's avatar
Valentin Samir committed
668 669
    pattern = models.CharField(
        max_length=255,
670 671
        verbose_name=_(u"pattern"),
        help_text=_(u"a regular expression")
Valentin Samir's avatar
Valentin Samir committed
672
    )
673 674 675
    #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
676 677
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

678
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
679 680
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
681

682
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
683
class ReplaceAttributValue(models.Model):
684 685 686 687 688 689 690
    """
        Bases: :class:`django.db.models.Model`

        A replacement (using a regular expression) of an attribute value for a
        :class:`ServicePattern`.
    """
    #: Name the attribute: a key of :attr:`User.attributs`
Valentin Samir's avatar
Valentin Samir committed
691 692
    attribut = models.CharField(
        max_length=255,
693 694
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut for which the value must be replace")
Valentin Samir's avatar
Valentin Samir committed
695
    )
696
    #: A regular expression matching the part of the attribute value that need to be changed
Valentin Samir's avatar
Valentin Samir committed
697 698
    pattern = models.CharField(
        max_length=255,
699 700
        verbose_name=_(u"pattern"),
        help_text=_(u"An regular expression maching whats need to be replaced")
Valentin Samir's avatar
Valentin Samir committed
701
    )
702
    #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 …
Valentin Samir's avatar
Valentin Samir committed
703 704 705
    replace = models.CharField(
        max_length=255,
        blank=True,
706 707
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
708
    )
709 710 711
    #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
712 713
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

714
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
715
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
716 717


718
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
719
class Ticket(models.Model):
720 721 722 723 724
    """
        Bases: :class:`django.db.models.Model`

        Generic class for a Ticket
    """
Valentin Samir's avatar
Valentin Samir committed
725 726
    class Meta:
        abstract = True
727
    #: ForeignKey to a :class:`User`.
Valentin Samir's avatar
Valentin Samir committed
728
    user = models.ForeignKey(User, related_name="%(class)s")
729 730
    #: The user attributes to transmit to the service json encoded
    _attributs = models.TextField(default=None, null=True, blank=True)
731
    #: A boolean. ``True`` if the ticket has been validated
Valentin Samir's avatar
Valentin Samir committed
732
    validate = models.BooleanField(default=False)
733
    #: The service url for the ticket
Valentin Samir's avatar
Valentin Samir committed
734
    service = models.TextField()
735 736
    #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to
    #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it.
Valentin Samir's avatar
Valentin Samir committed
737
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
738
    #: Date of the ticket creation
Valentin Samir's avatar
Valentin Samir committed
739
    creation = models.DateTimeField(auto_now_add=True)
740
    #: A boolean. ``True`` if the user has just renew his authentication
Valentin Samir's avatar
Valentin Samir committed
741
    renew = models.BooleanField(default=False)
742 743
    #: A boolean. Set to :attr:`service_pattern` attribute
    #: :attr:`ServicePattern.single_log_out` value.
744
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
745

746 747
    #: Max duration between ticket creation and its validation. Any validation attempt for the
    #: ticket after :attr:`creation` + VALIDITY will fail as if the ticket do not exists.
Valentin Samir's avatar
Valentin Samir committed
748
    VALIDITY = settings.CAS_TICKET_VALIDITY
749 750
    #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut
    #: requests.
Valentin Samir's avatar
Valentin Samir committed
751 752
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

753 754 755 756 757 758 759 760 761 762 763
    @property
    def attributs(self):
        """The user attributes to be transmited to the service on successful validation"""
        if self._attributs is not None:
            return utils.json.loads(self._attributs)

    @attributs.setter
    def attributs(self, value):
        """attributs property setter"""
        self._attributs = utils.json_encode(value)

Valentin Samir's avatar
Valentin Samir committed
764
    class DoesNotExist(Exception):
Valentin Samir's avatar
Valentin Samir committed
765
        """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch"""
Valentin Samir's avatar
Valentin Samir committed
766 767
        pass

768
    def __str__(self):
769
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
770

771
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
772
    def clean_old_entries(cls):
773 774 775 776
        """Remove old ticket and send SLO to timed-out services"""
        # removing old validated ticket and non validated expired tickets
        cls.objects.filter(
            (
Valentin Samir's avatar
PEP8  
Valentin Samir committed
777 778
                Q(single_log_out=False) & Q(validate=True)
            ) | (
779 780
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
781 782 783 784
            )
        ).delete()

        # sending SLO to timed-out validated tickets
Valentin Samir's avatar
Valentin Samir committed
785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801
        async_list = []
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
        queryset = cls.objects.filter(
            creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
        )
        for ticket in queryset:
            ticket.logout(session, async_list)
        queryset.delete()
        for future in async_list:
            if future:  # pragma: no branch (should always be true)
                try:
                    future.result()
                except Exception as error:
                    logger.warning("Error durring SLO %s" % error)
                    sys.stderr.write("%r\n" % error)
802

Valentin Samir's avatar
Valentin Samir committed
803
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
804
        """Send a SLO request to the ticket service"""
805 806 807
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
808
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
Valentin Samir's avatar
Valentin Samir committed
809 810 811 812 813 814
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
Valentin Samir's avatar
Valentin Samir committed
815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833
            xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
 ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
</samlp:LogoutRequest>""" % \
                {
                    'id': utils.gen_saml_id(),
                    'datetime': timezone.now().isoformat(),
                    'ticket':  self.value
                }
            if self.service_pattern.single_log_out_callback:
                url = self.service_pattern.single_log_out_callback
            else:
                url = self.service
            async_list.append(
                session.post(
                    url.encode('utf-8'),
                    data={'logoutRequest': xml.encode('utf-8')},
                    timeout=settings.CAS_SLO_TIMEOUT
Valentin Samir's avatar
Valentin Samir committed
834
                )
Valentin Samir's avatar
Valentin Samir committed
835
            )
Valentin Samir's avatar
Valentin Samir committed
836

837
    @staticmethod
Valentin Samir's avatar
Valentin Samir committed
838
    def get_class(ticket, classes=None):
839 840 841 842
        """
            Return the ticket class of ``ticket``

            :param unicode ticket: A ticket
Valentin Samir's avatar
Valentin Samir committed
843
            :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses
844
            :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or
Valentin Samir's avatar
Valentin Samir committed
845 846
                :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes,
                ``None`` otherwise.
847 848
            :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>`
        """
Valentin Samir's avatar
Valentin Samir committed
849 850 851
        if classes is None:  # pragma: no cover (not used)
            classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
        for ticket_class in classes:
852 853 854
            if ticket.startswith(ticket_class.PREFIX):
                return ticket_class

Valentin Samir's avatar
Valentin Samir committed
855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939
    def username(self):
        """
            The username to send on ticket validation

            :return: The value of the corresponding user attribute if
                :attr:`service_pattern`.user_field is set, the user username otherwise.
        """
        if self.service_pattern.user_field and self.user.attributs.get(
            self.service_pattern.user_field
        ):
            username = self.user.attributs[self.service_pattern.user_field]
            if isinstance(username, list):
                # the list is not empty because we wont generate a ticket with a user_field
                # that evaluate to False
                username = username[0]
        else:
            username = self.user.username
        return username

    def attributs_flat(self):
        """
            generate attributes list for template rendering

            :return: An list of (attribute name, attribute value) of all user attributes flatened
                (no nested list)
            :rtype: :obj:`list` of :obj:`tuple` of :obj:`unicode`
        """
        attributes = []
        for key, value in self.attributs.items():
            if isinstance(value, list):
                for elt in value:
                    attributes.append((key, elt))
            else:
                attributes.append((key, value))
        return attributes

    @classmethod
    def get(cls, ticket, renew=False, service=None):
        """
            Search the database for a valid ticket with provided arguments

           :param unicode ticket: A ticket value
           :param bool renew: Is authentication renewal needed
           :param unicode service: Optional argument. The ticket service
           :raises Ticket.DoesNotExist: if no class is found for the ticket prefix
           :raises cls.DoesNotExist: if ``ticket`` value is not found in th database
           :return: a :class:`Ticket` instance
           :rtype: Ticket
        """
        # If the method class is the ticket abstract class, search for the submited ticket
        # class using its prefix. Assuming ticket is a ProxyTicket or a ServiceTicket
        if cls == Ticket:
            ticket_class = cls.get_class(ticket, classes=[ServiceTicket, ProxyTicket])
        # else use the method class
        else:
            ticket_class = cls
        # If ticket prefix is wrong, raise DoesNotExist
        if cls != Ticket and not ticket.startswith(cls.PREFIX):
            raise Ticket.DoesNotExist()
        if ticket_class:
            # search for the ticket that is not yet validated and is still valid
            ticket_queryset = ticket_class.objects.filter(
                value=ticket,
                validate=False,
                creation__gt=(timezone.now() - timedelta(seconds=ticket_class.VALIDITY))
            )
            # if service is specified, add it the the queryset
            if service is not None:
                ticket_queryset = ticket_queryset.filter(service=service)
            # only require renew if renew is True, otherwise it do not matter if renew is True
            # or False.
            if renew:
                ticket_queryset = ticket_queryset.filter(renew=True)
            # fetch the ticket ``MultipleObjectsReturned`` is never raised as the ticket value
            # is unique across the database
            ticket = ticket_queryset.get()
            # For ServiceTicket and Proxyticket, mark it as validated before returning
            if ticket_class != ProxyGrantingTicket:
                ticket.validate = True
                ticket.save()
            return ticket
        # If no class found for the ticket, raise DoesNotExist
        else:
            raise Ticket.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
940

941
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
942
class ServiceTicket(Ticket):
943 944 945 946 947 948
    """
        Bases: :class:`Ticket`

        A Service Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
949
    PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
950
    #: The ticket value
951
    value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
952

953
    def __str__(self):
954
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
955 956


957
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
958
class ProxyTicket(Ticket):
959 960 961 962 963 964
    """
        Bases: :class:`Ticket`

        A Proxy Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
965
    PREFIX = settings.CAS_PROXY_TICKET_PREFIX
966
    #: The ticket value
967
    value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
968

969
    def __str__(self):
970
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
971 972


973
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
974
class ProxyGrantingTicket(Ticket):
975 976 977 978 979 980
    """
        Bases: :class:`Ticket`

        A Proxy Granting Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
981
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
982 983
    #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY`
    #: to get :class:`ProxyTicket` for :attr:`user`
Valentin Samir's avatar
Valentin Samir committed
984
    VALIDITY = settings.CAS_PGT_VALIDITY
985
    #: The ticket value
986
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
987

988
    def __str__(self):
989
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
990

Valentin Samir's avatar
PEP8  
Valentin Samir committed
991

992
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
993
class Proxy(models.Model):
994 995 996 997 998
    """
        Bases: :class:`django.db.models.Model`

        A list of proxies on :class:`ProxyTicket`
    """
Valentin Samir's avatar
Valentin Samir committed
999 1000
    class Meta:
        ordering = ("-pk", )
1001
    #: Service url of the PGT used for getting the associated :class:`ProxyTicket`
Valentin Samir's avatar
Valentin Samir committed
1002
    url = models.CharField(max_length=255)
1003 1004 1005
    #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a
    #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
1006 1007
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

1008
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
1009
        return self.url
1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066


class NewVersionWarning(models.Model):
    """
        Bases: :class:`django.db.models.Model`

        The last new version available version sent
    """
    version = models.CharField(max_length=255)

    @classmethod
    def send_mails(cls):
        """
            For each new django-cas-server version, if the current instance is not up to date
            send one mail to ``settings.ADMINS``.
        """
        if settings.CAS_NEW_VERSION_EMAIL_WARNING and settings.ADMINS:
            try:
                obj = cls.objects.get()
            except cls.DoesNotExist:
                obj = NewVersionWarning.objects.create(version=VERSION)
            LAST_VERSION = utils.last_version()
            if LAST_VERSION is not None and LAST_VERSION != obj.version:
                if utils.decode_version(VERSION) < utils.decode_version(LAST_VERSION):
                    try:
                        send_mail(
                            (
                                '%sA new version of django-cas-server is available'
                            ) % settings.EMAIL_SUBJECT_PREFIX,
                            u'''
A new version of the django-cas-server is available.

Your version: %s
New version: %s

Upgrade using:
    * pip install -U django-cas-server
    * fetching the last release on
      https://github.com/nitmir/django-cas-server/ or on
      https://pypi.python.org/pypi/django-cas-server

After upgrade, do not forget to run:
    * ./manage.py migrate
    * ./manage.py collectstatic
and to reload your wsgi server (apache2, uwsgi, gunicord, etc…)

--\u0020
django-cas-server
'''.strip() % (VERSION, LAST_VERSION),
                            settings.SERVER_EMAIL,
                            ["%s <%s>" % admin for admin in settings.ADMINS],
                            fail_silently=False,
                        )
                        obj.version = LAST_VERSION
                        obj.save()
                    except smtplib.SMTPException as error:  # pragma: no cover (should not happen)
                        logger.error("Unable to send new version mail: %s" % error)