models.py 42 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
from django.core.mail import send_mail
Valentin Samir's avatar
Valentin Samir committed
22 23

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

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

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

Valentin Samir's avatar
PEP8  
Valentin Samir committed
37

38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
class JsonAttributes(models.Model):
    """
        Bases: :class:`django.db.models.Model`

        A base class for models storing attributes as a json
    """

    class Meta:
        abstract = True

    #: The attributes json encoded
    _attributs = models.TextField(default=None, null=True, blank=True)

    @property
    def attributs(self):
        """The attributes"""
        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)


63 64
@python_2_unicode_compatible
class FederatedIendityProvider(models.Model):
65
    """
66 67
        Bases: :class:`django.db.models.Model`

68 69
        An identity provider for the federated mode
    """
70
    class Meta:
71 72
        verbose_name = _(u"identity provider")
        verbose_name_plural = _(u"identity providers")
73 74
    #: Suffix append to backend CAS returned username: ``returned_username`` @ ``suffix``.
    #: it must be unique.
75 76 77 78
    suffix = models.CharField(
        max_length=30,
        unique=True,
        verbose_name=_(u"suffix"),
79
        help_text=_(
80
            u"Suffix append to backend CAS returned "
81 82
            u"username: ``returned_username`` @ ``suffix``."
        )
83
    )
84 85 86
    #: 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/
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 90 91 92 93 94 95 96 97
    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"),
98 99 100
        help_text=_(
            u"Version of the CAS protocol to use when sending requests the the backend CAS."
        ),
101 102
        default="3"
    )
103
    #: Name for this identity provider displayed on the login page.
104 105 106
    verbose_name = models.CharField(
        max_length=255,
        verbose_name=_(u"verbose name"),
107
        help_text=_(u"Name for this identity provider displayed on the login page.")
108
    )
109 110
    #: Position of the identity provider on the login page. Identity provider are sorted using the
    #: (:attr:`pos`, :attr:`verbose_name`, :attr:`suffix`) attributes.
111 112 113 114 115
    pos = models.IntegerField(
        default=100,
        verbose_name=_(u"position"),
        help_text=_(
            (
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 123 124
    #: 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``.
125 126 127
    display = models.BooleanField(
        default=True,
        verbose_name=_(u"display"),
128
        help_text=_("Display the provider on the login page.")
129
    )
130 131 132 133 134 135

    def __str__(self):
        return self.verbose_name

    @staticmethod
    def build_username_from_suffix(username, suffix):
136 137 138 139 140
        """
            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
141 142
            :return: The federated username: ``username`` @ ``suffix``.
            :rtype: unicode
143
        """
144 145 146
        return u'%s@%s' % (username, suffix)

    def build_username(self, username):
147 148 149 150 151 152 153
        """
            Transform backend username into federated username

            :param unicode username: A CAS backend returned username
            :return: The federated username: ``username`` @ :attr:`suffix`.
            :rtype: unicode
        """
154 155 156 157
        return u'%s@%s' % (username, self.suffix)


@python_2_unicode_compatible
158
class FederatedUser(JsonAttributes):
159
    """
160
        Bases: :class:`JsonAttributes`
161 162 163

        A federated user as returner by a CAS provider (username and attributes)
    """
Valentin Samir's avatar
Valentin Samir committed
164 165
    class Meta:
        unique_together = ("username", "provider")
166 167
        verbose_name = _("Federated user")
        verbose_name_plural = _("Federated users")
168
    #: The user username returned by the CAS backend on successful ticket validation
Valentin Samir's avatar
Valentin Samir committed
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`
Valentin Samir's avatar
Valentin Samir committed
173
    ticket = models.CharField(max_length=255)
174
    #: Last update timespampt. Usually, the last time :attr:`ticket` has been set.
Valentin Samir's avatar
Valentin Samir committed
175 176
    last_update = models.DateTimeField(auto_now=True)

177 178 179 180 181
    def __str__(self):
        return self.federated_username

    @property
    def federated_username(self):
182
        """The federated username with a suffix for the current :class:`FederatedUser`."""
183 184 185 186
        return self.provider.build_username(self.username)

    @classmethod
    def get_from_federated_username(cls, username):
187 188 189 190
        """
            :return: A :class:`FederatedUser` object from a federated ``username``
            :rtype: :class:`FederatedUser`
        """
191 192 193 194 195 196 197 198 199 200 201
        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()
202

203 204
    @classmethod
    def clean_old_entries(cls):
205
        """remove old unused :class:`FederatedUser`"""
206 207 208 209 210
        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:
211
            if user.federated_username not in known_users:
212 213
                user.delete()

Valentin Samir's avatar
Valentin Samir committed
214

215
class FederateSLO(models.Model):
216 217 218 219 220
    """
        Bases: :class:`django.db.models.Model`

        An association between a CAS provider ticket and a (username, session) for processing SLO
    """
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)
229 230 231

    @classmethod
    def clean_deleted_sessions(cls):
232
        """remove old :class:`FederateSLO` object for which the session do not exists anymore"""
233 234 235 236 237
        for federate_slo in cls.objects.all():
            if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
                federate_slo.delete()


238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
@python_2_unicode_compatible
class UserAttributes(JsonAttributes):
    """
        Bases: :class:`JsonAttributes`

        Local cache of the user attributes, used then needed
    """
    class Meta:
        verbose_name = _("User attributes cache")
        verbose_name_plural = _("User attributes caches")
    #: The username of the user for which we cache attributes
    username = models.CharField(max_length=155, unique=True)

    def __str__(self):
        return self.username

    @classmethod
    def clean_old_entries(cls):
        """Remove :class:`UserAttributes` for which no more :class:`User` exists."""
        for user in cls.objects.all():
            if User.objects.filter(username=user.username).count() == 0:
                user.delete()


262
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
263
class User(models.Model):
264 265 266 267 268
    """
        Bases: :class:`django.db.models.Model`

        A user logged into the CAS
    """
Valentin Samir's avatar
Valentin Samir committed
269
    class Meta:
270
        unique_together = ("username", "session_key")
271 272
        verbose_name = _("User")
        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…)
Valentin Samir's avatar
Valentin Samir committed
278
    date = models.DateTimeField(auto_now=True)
279 280
    #: last time the user logged
    last_login = models.DateTimeField(auto_now_add=True)
Valentin Samir's avatar
Valentin Samir committed
281

282
    def delete(self, *args, **kwargs):
283 284 285 286
        """
            Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete
            the corresponding :class:`FederateSLO` object.
        """
287 288 289 290 291 292 293
        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
294 295
    @classmethod
    def clean_old_entries(cls):
296 297 298 299
        """
            Remove :class:`User` objects inactive since more that
            :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests.
        """
300 301 302 303 304 305
        filter = Q(date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)))
        if settings.CAS_TGT_VALIDITY is not None:
            filter |= Q(
                last_login__lt=(timezone.now() - timedelta(seconds=settings.CAS_TGT_VALIDITY))
            )
        users = cls.objects.filter(filter)
Valentin Samir's avatar
Valentin Samir committed
306 307 308 309
        for user in users:
            user.logout()
        users.delete()

310 311
    @classmethod
    def clean_deleted_sessions(cls):
312
        """Remove :class:`User` objects where the corresponding session do not exists anymore."""
313 314 315 316 317
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

318 319
    @property
    def attributs(self):
320 321
        """
            Property.
322 323 324
            A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS`` if
            possible, and if not, try to fallback to cached attributes (actually only used for ldap
            auth class with bind password check mthode).
325
        """
326 327 328 329 330 331 332 333 334 335 336 337
        try:
            return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()
        except NotImplementedError:
            try:
                user = UserAttributes.objects.get(username=self.username)
                attributes = user.attributs
                if attributes is not None:
                    return attributes
                else:
                    return {}
            except UserAttributes.DoesNotExist:
                return {}
338

339
    def __str__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
340
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
341

Valentin Samir's avatar
Valentin Samir committed
342
    def logout(self, request=None):
343 344 345 346 347 348
        """
            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>`
        """
349
        ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365
        for error in Ticket.send_slos(
            [ticket_class.objects.filter(user=self) for ticket_class in ticket_classes]
        ):
            logger.warning(
                "Error during SLO for user %s: %s" % (
                    self.username,
                    error
                )
            )
            if request is not None:
                error = utils.unpack_nested_exception(error)
                messages.add_message(
                    request,
                    messages.WARNING,
                    _(u'Error during service logout %s') % error
                )
Valentin Samir's avatar
Valentin Samir committed
366 367 368

    def get_ticket(self, ticket_class, service, service_pattern, renew):
        """
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384
            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
385 386 387 388 389
        """
        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
390
            (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
Valentin Samir's avatar
Valentin Samir committed
391
        )
Valentin Samir's avatar
Valentin Samir committed
392
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
393
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
394
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
395
                if key in replacements:
Valentin Samir's avatar
Valentin Samir committed
396 397 398 399 400 401 402 403 404
                    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
405
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
406 407 408 409 410
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
411 412
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
413
        )
Valentin Samir's avatar
Valentin Samir committed
414
        ticket.save()
415
        self.save()
Valentin Samir's avatar
Valentin Samir committed
416 417 418
        return ticket

    def get_service_url(self, service, service_pattern, renew):
419 420 421 422 423 424 425 426 427 428 429 430 431 432
        """
            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
433
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
434
        url = utils.update_url(service, {'ticket': ticket.value})
Valentin Samir's avatar
Valentin Samir committed
435
        logger.info("Service ticket created for service %s by user %s." % (service, self.username))
Valentin Samir's avatar
Valentin Samir committed
436 437
        return url

Valentin Samir's avatar
PEP8  
Valentin Samir committed
438

439
class ServicePatternException(Exception):
440 441 442 443
    """
        Bases: :class:`exceptions.Exception`

        Base exception of exceptions raised in the ServicePattern model"""
444
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
445 446


447
class BadUsername(ServicePatternException):
448 449 450 451 452
    """
        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
453
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
454 455


456
class BadFilter(ServicePatternException):
457 458 459 460 461
    """
        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
462
    pass
Valentin Samir's avatar
Valentin Samir committed
463

Valentin Samir's avatar
PEP8  
Valentin Samir committed
464

465
class UserFieldNotDefined(ServicePatternException):
466 467 468 469 470 471
    """
        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
472
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
473 474


475
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
476
class ServicePattern(models.Model):
477 478 479
    """
        Bases: :class:`django.db.models.Model`

480
        Allowed services pattern against services are tested to
481
    """
Valentin Samir's avatar
Valentin Samir committed
482 483
    class Meta:
        ordering = ("pos", )
484 485
        verbose_name = _("Service pattern")
        verbose_name_plural = _("Services patterns")
Valentin Samir's avatar
Valentin Samir committed
486

487
    #: service patterns are sorted using the :attr:`pos` attribute
488 489
    pos = models.IntegerField(
        default=100,
490 491
        verbose_name=_(u"position"),
        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)
Valentin Samir's avatar
Valentin Samir committed
494 495 496 497 498
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
499 500 501
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
502 503 504
    #: 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 '\\'.
505 506 507
    pattern = models.CharField(
        max_length=255,
        unique=True,
508 509 510 511 512
        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 '\\'."
513 514
        ),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
515
    )
516
    #: Name of the attribute to transmit as username, if empty the user login is used
Valentin Samir's avatar
Valentin Samir committed
517 518 519 520
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
521
        verbose_name=_(u"user field"),
522
        help_text=_("Name of the attribute to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
523
    )
524
    #: A boolean allowing to limit username allowed to connect to :attr:`usernames`.
Valentin Samir's avatar
Valentin Samir committed
525 526
    restrict_users = models.BooleanField(
        default=False,
527 528
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
529
    )
530
    #: A boolean allowing to deliver :class:`ProxyTicket` to the service.
Valentin Samir's avatar
Valentin Samir committed
531 532
    proxy = models.BooleanField(
        default=False,
533
        verbose_name=_(u"proxy"),
534 535
        help_text=_("Proxy tickets can be delivered to the service")
    )
536 537
    #: A boolean allowing the service to be used as a proxy callback (via the pgtUrl GET param)
    #: to deliver :class:`ProxyGrantingTicket`.
538 539 540 541
    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
542
    )
543 544 545
    #: 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
546
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
547
        default=False,
Valentin Samir's avatar
Valentin Samir committed
548 549
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
550
    )
551 552
    #: 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.
553 554 555 556 557
    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
558
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
559 560 561
                    u"This is usefull for non HTTP proxied services.")
    )

562
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
563 564 565
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
566 567 568 569
        """
            Check if ``user`` if allowed to use theses services. If ``user`` is not allowed,
            raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername`

570 571 572 573 574 575 576 577 578
            :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
579
        """
Valentin Samir's avatar
Valentin Samir committed
580
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
581
            logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
Valentin Samir's avatar
Valentin Samir committed
582
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
583
        for filtre in self.filters.all():
584 585
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
586
            else:
Valentin Samir's avatar
Valentin Samir committed
587 588 589
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
590 591
                    break
            else:
592
                bad_filter = (filtre.pattern, filtre.attribut, user.attributs.get(filtre.attribut))
Valentin Samir's avatar
Valentin Samir committed
593 594
                logger.warning(
                    "User constraint failed for %s, service %s: %s do not match %s %s." % (
595
                        (user.username, self.name) + bad_filter
Valentin Samir's avatar
Valentin Samir committed
596 597
                    )
                )
598
                raise BadFilter('%s do not match %s %s' % bad_filter)
Valentin Samir's avatar
Valentin Samir committed
599
        if self.user_field and not user.attributs.get(self.user_field):
Valentin Samir's avatar
Valentin Samir committed
600 601 602 603 604 605 606
            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
607 608 609 610 611
            raise UserFieldNotDefined()
        return True

    @classmethod
    def validate(cls, service):
612 613 614 615 616 617 618 619 620
        """
            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
621 622 623
        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
624
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
625 626
        raise cls.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
627

628
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
629
class Username(models.Model):
630 631 632 633 634 635
    """
        Bases: :class:`django.db.models.Model`

        A list of allowed usernames on a :class:`ServicePattern`
    """
    #: username allowed to connect to the service
636 637 638 639 640
    value = models.CharField(
        max_length=255,
        verbose_name=_(u"username"),
        help_text=_(u"username allowed to connect to the service")
    )
641 642 643
    #: ForeignKey to a :class:`ServicePattern`. :class:`Username` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.usernames`
    #: attribute.
644
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
645

646
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
647 648
        return self.value

Valentin Samir's avatar
PEP8  
Valentin Samir committed
649

650
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
651
class ReplaceAttributName(models.Model):
652 653 654 655 656 657 658
    """
        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
659
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
660
        unique_together = ('name', 'replace', 'service_pattern')
661
    #: Name the attribute: a key of :attr:`User.attributs`
Valentin Samir's avatar
Valentin Samir committed
662 663
    name = models.CharField(
        max_length=255,
664
        verbose_name=_(u"name"),
665
        help_text=_(u"name of an attribute to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
666
    )
667 668
    #: 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
669 670 671
    replace = models.CharField(
        max_length=255,
        blank=True,
672
        verbose_name=_(u"replace"),
673
        help_text=_(u"name under which the attribute will be show "
Valentin Samir's avatar
PEP8  
Valentin Samir committed
674
                    u"to the service. empty = default name of the attribut")
Valentin Samir's avatar
Valentin Samir committed
675
    )
676 677 678
    #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributName` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.attributs`
    #: attribute.
679
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
680

681
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
682 683 684 685 686
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
687

688
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
689
class FilterAttributValue(models.Model):
690 691 692 693 694 695 696 697
    """
        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
698 699
    attribut = models.CharField(
        max_length=255,
700 701
        verbose_name=_(u"attribute"),
        help_text=_(u"Name of the attribute which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
702
    )
703 704
    #: 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
705 706
    pattern = models.CharField(
        max_length=255,
707
        verbose_name=_(u"pattern"),
708 709
        help_text=_(u"a regular expression"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
710
    )
711 712 713
    #: ForeignKey to a :class:`ServicePattern`. :class:`FilterAttributValue` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.filters`
    #: attribute.
714
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
715

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

Valentin Samir's avatar
PEP8  
Valentin Samir committed
719

720
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
721
class ReplaceAttributValue(models.Model):
722 723 724 725 726 727 728
    """
        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
729 730
    attribut = models.CharField(
        max_length=255,
731 732
        verbose_name=_(u"attribute"),
        help_text=_(u"Name of the attribute for which the value must be replace")
Valentin Samir's avatar
Valentin Samir committed
733
    )
734
    #: A regular expression matching the part of the attribute value that need to be changed
Valentin Samir's avatar
Valentin Samir committed
735 736
    pattern = models.CharField(
        max_length=255,
737
        verbose_name=_(u"pattern"),
738 739
        help_text=_(u"An regular expression maching whats need to be replaced"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
740
    )
741
    #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 …
Valentin Samir's avatar
Valentin Samir committed
742 743 744
    replace = models.CharField(
        max_length=255,
        blank=True,
745 746
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
747
    )
748 749 750
    #: ForeignKey to a :class:`ServicePattern`. :class:`ReplaceAttributValue` instances for a
    #: :class:`ServicePattern` are accessible thought its :attr:`ServicePattern.replacements`
    #: attribute.
751
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
752

753
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
754
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
755 756


757
@python_2_unicode_compatible
758
class Ticket(JsonAttributes):
759
    """
760
        Bases: :class:`JsonAttributes`
761 762 763

        Generic class for a Ticket
    """
Valentin Samir's avatar
Valentin Samir committed
764 765
    class Meta:
        abstract = True
766
    #: ForeignKey to a :class:`User`.
767
    user = models.ForeignKey(User, related_name="%(class)s", on_delete=models.CASCADE)
768
    #: A boolean. ``True`` if the ticket has been validated
Valentin Samir's avatar
Valentin Samir committed
769
    validate = models.BooleanField(default=False)
770
    #: The service url for the ticket
Valentin Samir's avatar
Valentin Samir committed
771
    service = models.TextField()
772 773
    #: ForeignKey to a :class:`ServicePattern`. The :class:`ServicePattern` corresponding to
    #: :attr:`service`. Use :meth:`ServicePattern.validate` to find it.
774
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s", on_delete=models.CASCADE)
775
    #: Date of the ticket creation
Valentin Samir's avatar
Valentin Samir committed
776
    creation = models.DateTimeField(auto_now_add=True)
777
    #: A boolean. ``True`` if the user has just renew his authentication
Valentin Samir's avatar
Valentin Samir committed
778
    renew = models.BooleanField(default=False)
779 780
    #: A boolean. Set to :attr:`service_pattern` attribute
    #: :attr:`ServicePattern.single_log_out` value.
781
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
782

783 784
    #: 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
785
    VALIDITY = settings.CAS_TICKET_VALIDITY
786 787
    #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut
    #: requests.
Valentin Samir's avatar
Valentin Samir committed
788 789
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

Valentin Samir's avatar
Valentin Samir committed
790
    class DoesNotExist(Exception):
Valentin Samir's avatar
Valentin Samir committed
791
        """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch"""
Valentin Samir's avatar
Valentin Samir committed
792 793
        pass

794
    def __str__(self):
795
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
796

797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823
    @staticmethod
    def send_slos(queryset_list):
        """
            Send SLO requests to each ticket of each queryset of ``queryset_list``

            :param list queryset_list: A list a :class:`Ticket` queryset
            :return: A list of possibly encoutered :class:`Exception`
            :rtype: list
        """
        # sending SLO to timed-out validated tickets
        async_list = []
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
        errors = []
        for queryset in queryset_list:
            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:
                    errors.append(error)
        return errors

824
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
825
    def clean_old_entries(cls):
826 827 828 829
        """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
830 831
                Q(single_log_out=False) & Q(validate=True)
            ) | (
832 833
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
834 835
            )
        ).delete()
Valentin Samir's avatar
Valentin Samir committed
836 837 838
        queryset = cls.objects.filter(
            creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
        )
839 840 841
        for error in cls.send_slos([queryset]):
            logger.warning("Error durring SLO %s" % error)
            sys.stderr.write("%r\n" % error)
842

Valentin Samir's avatar
Valentin Samir committed
843
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
844
        """Send a SLO request to the ticket service"""
845 846 847
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
848
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
Valentin Samir's avatar
Valentin Samir committed
849 850 851 852 853 854
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
855
            xml = utils.logout_request(self.value)
Valentin Samir's avatar
Valentin Samir committed
856 857 858 859 860 861 862 863 864
            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
865
                )
Valentin Samir's avatar
Valentin Samir committed
866
            )
Valentin Samir's avatar
Valentin Samir committed
867

868
    @staticmethod
Valentin Samir's avatar
Valentin Samir committed
869
    def get_class(ticket, classes=None):
870 871 872 873
        """
            Return the ticket class of ``ticket``

            :param unicode ticket: A ticket
Valentin Samir's avatar
Valentin Samir committed
874
            :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses
875
            :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or
Valentin Samir's avatar
Valentin Samir committed
876 877
                :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes,
                ``None`` otherwise.
878 879
            :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>`
        """
Valentin Samir's avatar
Valentin Samir committed
880 881 882
        if classes is None:  # pragma: no cover (not used)
            classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
        for ticket_class in classes:
883 884 885
            if ticket.startswith(ticket_class.PREFIX):
                return ticket_class

Valentin Samir's avatar
Valentin Samir committed
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 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970
    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
971

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

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

984
    def __str__(self):
985
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
986 987


988
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
989
class ProxyTicket(Ticket):
990 991 992 993 994 995
    """
        Bases: :class:`Ticket`

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

1000
    def __str__(self):
1001
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
1002 1003


1004
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
1005
class ProxyGrantingTicket(Ticket):
1006 1007 1008 1009 1010 1011
    """
        Bases: :class:`Ticket`

        A Proxy Granting Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
1012
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
1013 1014
    #: 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
1015
    VALIDITY = settings.CAS_PGT_VALIDITY
1016
    #: The ticket value
1017
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
1018

1019
    def __str__(self):
1020
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
1021

Valentin Samir's avatar
PEP8  
Valentin Samir committed
1022

1023
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
1024
class Proxy(models.Model):
1025 1026 1027 1028 1029
    """
        Bases: :class:`django.db.models.Model`

        A list of proxies on :class:`ProxyTicket`
    """
Valentin Samir's avatar
Valentin Samir committed
1030 1031
    class Meta:
        ordering = ("-pk", )
1032
    #: Service url of the PGT used for getting the associated :class:`ProxyTicket`
Valentin Samir's avatar
Valentin Samir committed
1033
    url = models.CharField(max_length=255)
1034 1035 1036
    #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a
    #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies`
    #: attribute.
1037
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
1038

1039
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
1040
        return self.url
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 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097


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)