models.py 42.1 KB
Newer Older
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
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
35 36
logger = logging.getLogger(__name__)

Valentin Samir's avatar
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…)
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
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
Valentin Samir committed
434
        url = utils.update_url(service, {'ticket': ticket.value})
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
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
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
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
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
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
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):
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))
593 594
                logger.warning(
                    "User constraint failed for %s, service %s: %s do not match %s %s." % (
595
                        (user.username, self.name) + bad_filter
596 597
                    )
                )
598
                raise BadFilter('%s do not match %s %s' % bad_filter)
Valentin Samir's avatar
Valentin Samir committed
599
        if self.user_field and not user.attributs.get(self.user_field):
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
624
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
625 626
        raise cls.DoesNotExist()

Valentin Samir's avatar
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.
Valentin Samir's avatar
Valentin Samir committed
644 645 646 647 648
    service_pattern = models.ForeignKey(
        ServicePattern,
        related_name="usernames",
        on_delete=models.CASCADE
    )
Valentin Samir's avatar
Valentin Samir committed
649

650
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
651 652
        return self.value

Valentin Samir's avatar
Valentin Samir committed
653

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

689
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
690 691 692 693 694
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
Valentin Samir committed
695

696
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
697
class FilterAttributValue(models.Model):
698 699 700 701 702 703 704 705
    """
        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
706 707
    attribut = models.CharField(
        max_length=255,
708 709
        verbose_name=_(u"attribute"),
        help_text=_(u"Name of the attribute which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
710
    )
711 712
    #: 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
713 714
    pattern = models.CharField(
        max_length=255,
715
        verbose_name=_(u"pattern"),
716 717
        help_text=_(u"a regular expression"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
718
    )
719 720 721
    #: 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
722 723 724 725 726
    service_pattern = models.ForeignKey(
        ServicePattern,
        related_name="filters",
        on_delete=models.CASCADE
    )
Valentin Samir's avatar
Valentin Samir committed
727

728
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
729 730
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
Valentin Samir committed
731

732
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
733
class ReplaceAttributValue(models.Model):
734 735 736 737 738 739 740
    """
        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
741 742
    attribut = models.CharField(
        max_length=255,
743 744
        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
745
    )
746
    #: A regular expression matching the part of the attribute value that need to be changed
Valentin Samir's avatar
Valentin Samir committed
747 748
    pattern = models.CharField(
        max_length=255,
749
        verbose_name=_(u"pattern"),
750 751
        help_text=_(u"An regular expression maching whats need to be replaced"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
752
    )
753
    #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 …
Valentin Samir's avatar
Valentin Samir committed
754 755 756
    replace = models.CharField(
        max_length=255,
        blank=True,
757 758
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
759
    )
760 761 762
    #: 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
763 764 765 766 767
    service_pattern = models.ForeignKey(
        ServicePattern,
        related_name="replacements",
        on_delete=models.CASCADE
    )
Valentin Samir's avatar
Valentin Samir committed
768

769
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
770
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
771 772


773
@python_2_unicode_compatible
774
class Ticket(JsonAttributes):
775
    """
776
        Bases: :class:`JsonAttributes`
777 778 779

        Generic class for a Ticket
    """
Valentin Samir's avatar
Valentin Samir committed
780 781
    class Meta:
        abstract = True
782
    #: ForeignKey to a :class:`User`.
783
    user = models.ForeignKey(User, related_name="%(class)s", on_delete=models.CASCADE)
784
    #: A boolean. ``True`` if the ticket has been validated
Valentin Samir's avatar
Valentin Samir committed
785
    validate = models.BooleanField(default=False)
786
    #: The service url for the ticket
Valentin Samir's avatar
Valentin Samir committed
787
    service = models.TextField()
788 789
    #: 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
790 791 792 793 794
    service_pattern = models.ForeignKey(
        ServicePattern,
        related_name="%(class)s",
        on_delete=models.CASCADE
    )
795
    #: Date of the ticket creation
Valentin Samir's avatar
Valentin Samir committed
796
    creation = models.DateTimeField(auto_now_add=True)
797
    #: A boolean. ``True`` if the user has just renew his authentication
Valentin Samir's avatar
Valentin Samir committed
798
    renew = models.BooleanField(default=False)
799 800
    #: A boolean. Set to :attr:`service_pattern` attribute
    #: :attr:`ServicePattern.single_log_out` value.
801
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
802

803 804
    #: 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.
805
    VALIDITY = settings.CAS_TICKET_VALIDITY
806 807
    #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut
    #: requests.
808 809
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

Valentin Samir's avatar
Valentin Samir committed
810
    class DoesNotExist(Exception):
Valentin Samir's avatar
Valentin Samir committed
811
        """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch"""
Valentin Samir's avatar
Valentin Samir committed
812 813
        pass

814
    def __str__(self):
815
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
816

817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843
    @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

844
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
845
    def clean_old_entries(cls):
846 847 848 849
        """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
Valentin Samir committed
850 851
                Q(single_log_out=False) & Q(validate=True)
            ) | (
852 853
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
854 855
            )
        ).delete()
Valentin Samir's avatar
Valentin Samir committed
856 857 858
        queryset = cls.objects.filter(
            creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
        )
859 860 861
        for error in cls.send_slos([queryset]):
            logger.warning("Error durring SLO %s" % error)
            sys.stderr.write("%r\n" % error)
862

Valentin Samir's avatar
Valentin Samir committed
863
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
864
        """Send a SLO request to the ticket service"""
865 866 867
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
868
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
869 870 871 872 873 874
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
875
            xml = utils.logout_request(self.value)
Valentin Samir's avatar
Valentin Samir committed
876 877 878 879 880 881 882 883 884
            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
885
                )
Valentin Samir's avatar
Valentin Samir committed
886
            )
Valentin Samir's avatar
Valentin Samir committed
887

888
    @staticmethod
Valentin Samir's avatar
Valentin Samir committed
889
    def get_class(ticket, classes=None):
890 891 892 893
        """
            Return the ticket class of ``ticket``

            :param unicode ticket: A ticket
Valentin Samir's avatar
Valentin Samir committed
894
            :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses
895
            :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or
Valentin Samir's avatar
Valentin Samir committed
896 897
                :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes,
                ``None`` otherwise.
898 899
            :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>`
        """
Valentin Samir's avatar
Valentin Samir committed
900 901 902
        if classes is None:  # pragma: no cover (not used)
            classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
        for ticket_class in classes:
903 904 905
            if ticket.startswith(ticket_class.PREFIX):
                return ticket_class

Valentin Samir's avatar
Valentin Samir committed
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 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990
    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
Valentin Samir committed
991

992
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
993
class ServiceTicket(Ticket):
994 995 996 997 998 999
    """
        Bases: :class:`Ticket`

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

1004
    def __str__(self):
1005
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
1006 1007


1008
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
1009
class ProxyTicket(Ticket):
1010 1011 1012 1013 1014 1015
    """
        Bases: :class:`Ticket`

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

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


1024
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
1025
class ProxyGrantingTicket(Ticket):
1026 1027 1028 1029 1030 1031
    """
        Bases: :class:`Ticket`

        A Proxy Granting Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
1032
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
1033 1034
    #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY`
    #: to get :class:`ProxyTicket` for :attr:`user`
1035
    VALIDITY = settings.CAS_PGT_VALIDITY
1036
    #: The ticket value
1037
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
1038

1039
    def __str__(self):
1040
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
1041

Valentin Samir's avatar
Valentin Samir committed
1042

1043
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
1044
class Proxy(models.Model):
1045 1046 1047 1048 1049
    """
        Bases: :class:`django.db.models.Model`

        A list of proxies on :class:`ProxyTicket`
    """
Valentin Samir's avatar
Valentin Samir committed
1050 1051
    class Meta:
        ordering = ("-pk", )
1052
    #: Service url of the PGT used for getting the associated :class:`ProxyTicket`
Valentin Samir's avatar
Valentin Samir committed
1053
    url = models.CharField(max_length=255)
1054 1055 1056
    #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a
    #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies`
    #: attribute.
1057
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies", on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
1058

1059
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
1060
        return self.url
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 1098 1099


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
1100
      https://pypi.org/project/django-cas-server/
1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117

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)