models.py 40.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
    #: The user username returned by the CAS backend on successful ticket validation
Valentin Samir's avatar
Valentin Samir committed
167
    username = models.CharField(max_length=124)
168
    #: A foreign key to :class:`FederatedIendityProvider`
169
    provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
170
    #: The last ticket used to authenticate :attr:`username` against :attr:`provider`
Valentin Samir's avatar
Valentin Samir committed
171
    ticket = models.CharField(max_length=255)
172
    #: Last update timespampt. Usually, the last time :attr:`ticket` has been set.
Valentin Samir's avatar
Valentin Samir committed
173 174
    last_update = models.DateTimeField(auto_now=True)

175 176 177 178 179
    def __str__(self):
        return self.federated_username

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

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

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

Valentin Samir's avatar
Valentin Samir committed
212

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

        An association between a CAS provider ticket and a (username, session) for processing SLO
    """
219
    class Meta:
220
        unique_together = ("username", "session_key", "ticket")
221
    #: the federated username with the ``@``component
222
    username = models.CharField(max_length=30)
223
    #: the session key for the session :attr:`username` has been authenticated using :attr:`ticket`
224
    session_key = models.CharField(max_length=40, blank=True, null=True)
225
    #: The ticket used to authenticate :attr:`username`
226
    ticket = models.CharField(max_length=255, db_index=True)
227 228 229

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


236
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
237
class User(models.Model):
238 239 240 241 242
    """
        Bases: :class:`django.db.models.Model`

        A user logged into the CAS
    """
Valentin Samir's avatar
Valentin Samir committed
243
    class Meta:
244
        unique_together = ("username", "session_key")
245 246
        verbose_name = _("User")
        verbose_name_plural = _("Users")
247
    #: The session key of the current authenticated user
248
    session_key = models.CharField(max_length=40, blank=True, null=True)
249
    #: The username of the current authenticated user
Valentin Samir's avatar
Valentin Samir committed
250
    username = models.CharField(max_length=30)
251
    #: Last time the authenticated user has do something (auth, fetch ticket, etc…)
252
    date = models.DateTimeField(auto_now=True)
Valentin Samir's avatar
Valentin Samir committed
253

254
    def delete(self, *args, **kwargs):
255 256 257 258
        """
            Remove the current :class:`User`. If ``settings.CAS_FEDERATE`` is ``True``, also delete
            the corresponding :class:`FederateSLO` object.
        """
259 260 261 262 263 264 265
        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
266 267
    @classmethod
    def clean_old_entries(cls):
268 269 270 271
        """
            Remove :class:`User` objects inactive since more that
            :django:setting:`SESSION_COOKIE_AGE` and send corresponding SingleLogOut requests.
        """
272 273 274
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
275 276 277 278
        for user in users:
            user.logout()
        users.delete()

279 280
    @classmethod
    def clean_deleted_sessions(cls):
281
        """Remove :class:`User` objects where the corresponding session do not exists anymore."""
282 283 284 285 286
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

287 288
    @property
    def attributs(self):
289 290 291 292
        """
            Property.
            A fresh :class:`dict` for the user attributes, using ``settings.CAS_AUTH_CLASS``
        """
293 294
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

295
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
296
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
297

Valentin Samir's avatar
Valentin Samir committed
298
    def logout(self, request=None):
299 300 301 302 303 304
        """
            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>`
        """
305
        ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321
        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
322 323 324

    def get_ticket(self, ticket_class, service, service_pattern, renew):
        """
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
            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
341 342 343 344 345
        """
        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
346
            (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
Valentin Samir's avatar
Valentin Samir committed
347
        )
Valentin Samir's avatar
Valentin Samir committed
348
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
349
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
350
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
351
                if key in replacements:
Valentin Samir's avatar
Valentin Samir committed
352 353 354 355 356 357 358 359 360
                    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
361
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
362 363 364 365 366
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
367 368
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
369
        )
Valentin Samir's avatar
Valentin Samir committed
370
        ticket.save()
371
        self.save()
Valentin Samir's avatar
Valentin Samir committed
372 373 374
        return ticket

    def get_service_url(self, service, service_pattern, renew):
375 376 377 378 379 380 381 382 383 384 385 386 387 388
        """
            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
389
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
Valentin Samir committed
390
        url = utils.update_url(service, {'ticket': ticket.value})
391
        logger.info("Service ticket created for service %s by user %s." % (service, self.username))
Valentin Samir's avatar
Valentin Samir committed
392 393
        return url

Valentin Samir's avatar
Valentin Samir committed
394

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

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


403
class BadUsername(ServicePatternException):
404 405 406 407 408
    """
        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
409
    pass
Valentin Samir's avatar
Valentin Samir committed
410 411


412
class BadFilter(ServicePatternException):
413 414 415 416 417
    """
        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
418
    pass
Valentin Samir's avatar
Valentin Samir committed
419

Valentin Samir's avatar
Valentin Samir committed
420

421
class UserFieldNotDefined(ServicePatternException):
422 423 424 425 426 427
    """
        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
428
    pass
Valentin Samir's avatar
Valentin Samir committed
429 430


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

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

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

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

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

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

    @classmethod
    def validate(cls, service):
568 569 570 571 572 573 574 575 576
        """
            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
577 578 579
        for service_pattern in cls.objects.all().order_by('pos'):
            if re.match(service_pattern.pattern, service):
                return service_pattern
580
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
581 582
        raise cls.DoesNotExist()

Valentin Samir's avatar
Valentin Samir committed
583

584
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
585
class Username(models.Model):
586 587 588 589 590 591
    """
        Bases: :class:`django.db.models.Model`

        A list of allowed usernames on a :class:`ServicePattern`
    """
    #: username allowed to connect to the service
592 593 594 595 596
    value = models.CharField(
        max_length=255,
        verbose_name=_(u"username"),
        help_text=_(u"username allowed to connect to the service")
    )
597 598 599
    #: 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
600
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
601

602
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
603 604
        return self.value

Valentin Samir's avatar
Valentin Samir committed
605

606
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
607
class ReplaceAttributName(models.Model):
608 609 610 611 612 613 614
    """
        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
615
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
616
        unique_together = ('name', 'replace', 'service_pattern')
617
    #: Name the attribute: a key of :attr:`User.attributs`
Valentin Samir's avatar
Valentin Samir committed
618 619
    name = models.CharField(
        max_length=255,
620
        verbose_name=_(u"name"),
621
        help_text=_(u"name of an attribute to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
622
    )
623 624
    #: 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
625 626 627
    replace = models.CharField(
        max_length=255,
        blank=True,
628
        verbose_name=_(u"replace"),
629
        help_text=_(u"name under which the attribute will be show "
Valentin Samir's avatar
Valentin Samir committed
630
                    u"to the service. empty = default name of the attribut")
Valentin Samir's avatar
Valentin Samir committed
631
    )
632 633 634
    #: 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
635 636
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

637
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
638 639 640 641 642
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
Valentin Samir committed
643

644
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
645
class FilterAttributValue(models.Model):
646 647 648 649 650 651 652 653
    """
        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
654 655
    attribut = models.CharField(
        max_length=255,
656 657
        verbose_name=_(u"attribute"),
        help_text=_(u"Name of the attribute which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
658
    )
659 660
    #: 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
661 662
    pattern = models.CharField(
        max_length=255,
663
        verbose_name=_(u"pattern"),
664 665
        help_text=_(u"a regular expression"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
666
    )
667 668 669
    #: 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
670 671
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

672
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
673 674
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
Valentin Samir committed
675

676
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
677
class ReplaceAttributValue(models.Model):
678 679 680 681 682 683 684
    """
        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
685 686
    attribut = models.CharField(
        max_length=255,
687 688
        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
689
    )
690
    #: A regular expression matching the part of the attribute value that need to be changed
Valentin Samir's avatar
Valentin Samir committed
691 692
    pattern = models.CharField(
        max_length=255,
693
        verbose_name=_(u"pattern"),
694 695
        help_text=_(u"An regular expression maching whats need to be replaced"),
        validators=[utils.regexpr_validator]
Valentin Samir's avatar
Valentin Samir committed
696
    )
697
    #: The replacement to what is mached by :attr:`pattern`. groups are capture by \\1, \\2 …
Valentin Samir's avatar
Valentin Samir committed
698 699 700
    replace = models.CharField(
        max_length=255,
        blank=True,
701 702
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
703
    )
704 705 706
    #: 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
707 708
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

709
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
710
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
711 712


713
@python_2_unicode_compatible
714
class Ticket(JsonAttributes):
715
    """
716
        Bases: :class:`JsonAttributes`
717 718 719

        Generic class for a Ticket
    """
Valentin Samir's avatar
Valentin Samir committed
720 721
    class Meta:
        abstract = True
722
    #: ForeignKey to a :class:`User`.
Valentin Samir's avatar
Valentin Samir committed
723
    user = models.ForeignKey(User, related_name="%(class)s")
724
    #: A boolean. ``True`` if the ticket has been validated
Valentin Samir's avatar
Valentin Samir committed
725
    validate = models.BooleanField(default=False)
726
    #: The service url for the ticket
Valentin Samir's avatar
Valentin Samir committed
727
    service = models.TextField()
728 729
    #: 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
730
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
731
    #: Date of the ticket creation
Valentin Samir's avatar
Valentin Samir committed
732
    creation = models.DateTimeField(auto_now_add=True)
733
    #: A boolean. ``True`` if the user has just renew his authentication
Valentin Samir's avatar
Valentin Samir committed
734
    renew = models.BooleanField(default=False)
735 736
    #: A boolean. Set to :attr:`service_pattern` attribute
    #: :attr:`ServicePattern.single_log_out` value.
737
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
738

739 740
    #: 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.
741
    VALIDITY = settings.CAS_TICKET_VALIDITY
742 743
    #: Time we keep ticket with :attr:`single_log_out` set to ``True`` before sending SingleLogOut
    #: requests.
744 745
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

Valentin Samir's avatar
Valentin Samir committed
746
    class DoesNotExist(Exception):
Valentin Samir's avatar
Valentin Samir committed
747
        """raised in :meth:`Ticket.get` then ticket prefix and ticket classes mismatch"""
Valentin Samir's avatar
Valentin Samir committed
748 749
        pass

750
    def __str__(self):
751
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
752

753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779
    @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

780
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
781
    def clean_old_entries(cls):
782 783 784 785
        """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
786 787
                Q(single_log_out=False) & Q(validate=True)
            ) | (
788 789
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
790 791
            )
        ).delete()
Valentin Samir's avatar
Valentin Samir committed
792 793 794
        queryset = cls.objects.filter(
            creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
        )
795 796 797
        for error in cls.send_slos([queryset]):
            logger.warning("Error durring SLO %s" % error)
            sys.stderr.write("%r\n" % error)
798

Valentin Samir's avatar
Valentin Samir committed
799
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
800
        """Send a SLO request to the ticket service"""
801 802 803
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
804
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
805 806 807 808 809 810
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
811
            xml = utils.logout_request(self.value)
Valentin Samir's avatar
Valentin Samir committed
812 813 814 815 816 817 818 819 820
            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
821
                )
Valentin Samir's avatar
Valentin Samir committed
822
            )
Valentin Samir's avatar
Valentin Samir committed
823

824
    @staticmethod
Valentin Samir's avatar
Valentin Samir committed
825
    def get_class(ticket, classes=None):
826 827 828 829
        """
            Return the ticket class of ``ticket``

            :param unicode ticket: A ticket
Valentin Samir's avatar
Valentin Samir committed
830
            :param list classes: Optinal arguement. A list of possible :class:`Ticket` subclasses
831
            :return: The class corresponding to ``ticket`` (:class:`ServiceTicket` or
Valentin Samir's avatar
Valentin Samir committed
832 833
                :class:`ProxyTicket` or :class:`ProxyGrantingTicket`) if found among ``classes,
                ``None`` otherwise.
834 835
            :rtype: :obj:`type` or :obj:`NoneType<types.NoneType>`
        """
Valentin Samir's avatar
Valentin Samir committed
836 837 838
        if classes is None:  # pragma: no cover (not used)
            classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
        for ticket_class in classes:
839 840 841
            if ticket.startswith(ticket_class.PREFIX):
                return ticket_class

Valentin Samir's avatar
Valentin Samir committed
842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926
    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
927

928
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
929
class ServiceTicket(Ticket):
930 931 932 933 934 935
    """
        Bases: :class:`Ticket`

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

940
    def __str__(self):
941
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
942 943


944
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
945
class ProxyTicket(Ticket):
946 947 948 949 950 951
    """
        Bases: :class:`Ticket`

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

956
    def __str__(self):
957
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
958 959


960
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
961
class ProxyGrantingTicket(Ticket):
962 963 964 965 966 967
    """
        Bases: :class:`Ticket`

        A Proxy Granting Ticket
    """
    #: The ticket prefix used to differentiate it from other tickets types
968
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
969 970
    #: ProxyGranting ticket are never validated. However, they can be used during :attr:`VALIDITY`
    #: to get :class:`ProxyTicket` for :attr:`user`
971
    VALIDITY = settings.CAS_PGT_VALIDITY
972
    #: The ticket value
973
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
974

975
    def __str__(self):
976
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
977

Valentin Samir's avatar
Valentin Samir committed
978

979
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
980
class Proxy(models.Model):
981 982 983 984 985
    """
        Bases: :class:`django.db.models.Model`

        A list of proxies on :class:`ProxyTicket`
    """
Valentin Samir's avatar
Valentin Samir committed
986 987
    class Meta:
        ordering = ("-pk", )
988
    #: Service url of the PGT used for getting the associated :class:`ProxyTicket`
Valentin Samir's avatar
Valentin Samir committed
989
    url = models.CharField(max_length=255)
990 991 992
    #: ForeignKey to a :class:`ProxyTicket`. :class:`Proxy` instances for a
    #: :class:`ProxyTicket` are accessible thought its :attr:`ProxyTicket.proxies`
    #: attribute.
Valentin Samir's avatar
Valentin Samir committed
993 994
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

995
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
996
        return self.url
997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053


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)