models.py 23.4 KB
Newer Older
Valentin Samir's avatar
Valentin Samir committed
1
# -*- coding: utf-8 -*-
Valentin Samir's avatar
Valentin Samir committed
2 3 4 5 6 7 8 9 10
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
# more details.
#
# You should have received a copy of the GNU General Public License version 3
# along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
Valentin Samir's avatar
Valentin Samir committed
11
# (c) 2015-2016 Valentin Samir
Valentin Samir's avatar
Valentin Samir committed
12
"""models for the app"""
13
from .default_settings import settings
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 picklefield.fields import PickledObjectField
Valentin Samir's avatar
Valentin Samir committed
22 23

import re
24
import sys
Valentin Samir's avatar
Valentin Samir committed
25
import logging
26
from importlib import import_module
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

Valentin Samir's avatar
Valentin Samir committed
31
import cas_server.utils as utils
Valentin Samir's avatar
Valentin Samir committed
32

33 34
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore

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

Valentin Samir's avatar
PEP8  
Valentin Samir committed
37

38 39
@python_2_unicode_compatible
class FederatedIendityProvider(models.Model):
40 41 42
    """
        An identity provider for the federated mode
    """
43
    class Meta:
44 45
        verbose_name = _(u"identity provider")
        verbose_name_plural = _(u"identity providers")
46 47 48 49
    suffix = models.CharField(
        max_length=30,
        unique=True,
        verbose_name=_(u"suffix"),
50 51 52 53
        help_text=_(
            u"Suffix append to backend CAS returner "
            u"username: ``returned_username`` @ ``suffix``."
        )
54 55 56 57 58 59 60 61 62 63 64
    )
    server_url = models.CharField(max_length=255, verbose_name=_(u"server url"))
    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"),
65 66 67
        help_text=_(
            u"Version of the CAS protocol to use when sending requests the the backend CAS."
        ),
68 69 70 71 72
        default="3"
    )
    verbose_name = models.CharField(
        max_length=255,
        verbose_name=_(u"verbose name"),
73
        help_text=_(u"Name for this identity provider displayed on the login page.")
74 75 76 77 78 79
    )
    pos = models.IntegerField(
        default=100,
        verbose_name=_(u"position"),
        help_text=_(
            (
80
                u"Position of the identity provider on the login page. "
81
                u"Identity provider are sorted using the "
82
                u"(position, verbose name, suffix) attributes."
83 84 85
            )
        )
    )
86 87 88
    display = models.BooleanField(
        default=True,
        verbose_name=_(u"display"),
89
        help_text=_("Display the provider on the login page.")
90
    )
91 92 93 94 95 96

    def __str__(self):
        return self.verbose_name

    @staticmethod
    def build_username_from_suffix(username, suffix):
97 98 99 100 101 102
        """
            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
        """
103 104 105 106 107 108 109 110
        return u'%s@%s' % (username, suffix)

    def build_username(self, username):
        """Transform backend username into federated username"""
        return u'%s@%s' % (username, self.suffix)


@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
111
class FederatedUser(models.Model):
Valentin Samir's avatar
Valentin Samir committed
112
    """A federated user as returner by a CAS provider (username and attributes)"""
Valentin Samir's avatar
Valentin Samir committed
113 114 115
    class Meta:
        unique_together = ("username", "provider")
    username = models.CharField(max_length=124)
116
    provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
117 118 119 120
    attributs = PickledObjectField()
    ticket = models.CharField(max_length=255)
    last_update = models.DateTimeField(auto_now=True)

121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
    def __str__(self):
        return self.federated_username

    @property
    def federated_username(self):
        """return the federated username with a suffix"""
        return self.provider.build_username(self.username)

    @classmethod
    def get_from_federated_username(cls, username):
        """return a FederatedUser object from a federated username"""
        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()
143

144 145
    @classmethod
    def clean_old_entries(cls):
Valentin Samir's avatar
Valentin Samir committed
146
        """remove old unused federated users"""
147 148 149 150 151
        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:
152
            if user.federated_username not in known_users:
153 154
                user.delete()

Valentin Samir's avatar
Valentin Samir committed
155

156
class FederateSLO(models.Model):
Valentin Samir's avatar
Valentin Samir committed
157
    """An association between a CAS provider ticket and a (username, session) for processing SLO"""
158
    class Meta:
159
        unique_together = ("username", "session_key", "ticket")
160 161
    username = models.CharField(max_length=30)
    session_key = models.CharField(max_length=40, blank=True, null=True)
162
    ticket = models.CharField(max_length=255, db_index=True)
163 164 165

    @classmethod
    def clean_deleted_sessions(cls):
Valentin Samir's avatar
Valentin Samir committed
166
        """remove old object for which the session do not exists anymore"""
167 168 169 170 171
        for federate_slo in cls.objects.all():
            if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
                federate_slo.delete()


172
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
173
class User(models.Model):
Valentin Samir's avatar
Valentin Samir committed
174
    """A user logged into the CAS"""
Valentin Samir's avatar
Valentin Samir committed
175
    class Meta:
176
        unique_together = ("username", "session_key")
177 178
        verbose_name = _("User")
        verbose_name_plural = _("Users")
179
    session_key = models.CharField(max_length=40, blank=True, null=True)
Valentin Samir's avatar
Valentin Samir committed
180
    username = models.CharField(max_length=30)
Valentin Samir's avatar
Valentin Samir committed
181
    date = models.DateTimeField(auto_now=True)
Valentin Samir's avatar
Valentin Samir committed
182

183
    def delete(self, *args, **kwargs):
Valentin Samir's avatar
Valentin Samir committed
184
        """remove the User"""
185 186 187 188 189 190 191
        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
192 193
    @classmethod
    def clean_old_entries(cls):
194
        """Remove users inactive since more that SESSION_COOKIE_AGE"""
195 196 197
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
198 199 200 201
        for user in users:
            user.logout()
        users.delete()

202 203
    @classmethod
    def clean_deleted_sessions(cls):
204
        """Remove user where the session do not exists anymore"""
205 206 207 208 209
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

210 211 212 213 214
    @property
    def attributs(self):
        """return a fresh dict for the user attributs"""
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

215
    def __str__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
216
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
217

Valentin Samir's avatar
Valentin Samir committed
218
    def logout(self, request=None):
Valentin Samir's avatar
Valentin Samir committed
219
        """Sending SLO request to all services the user logged in"""
Valentin Samir's avatar
Valentin Samir committed
220
        async_list = []
221 222 223
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
224 225
        # first invalidate all Tickets
        ticket_classes = [ProxyGrantingTicket, ServiceTicket, ProxyTicket]
226
        for ticket_class in ticket_classes:
227 228
            queryset = ticket_class.objects.filter(user=self)
            for ticket in queryset:
Valentin Samir's avatar
Valentin Samir committed
229
                ticket.logout(session, async_list)
230
            queryset.delete()
Valentin Samir's avatar
Valentin Samir committed
231
        for future in async_list:
Valentin Samir's avatar
Valentin Samir committed
232
            if future:  # pragma: no branch (should always be true)
233 234 235
                try:
                    future.result()
                except Exception as error:
Valentin Samir's avatar
Valentin Samir committed
236 237 238 239 240 241
                    logger.warning(
                        "Error during SLO for user %s: %s" % (
                            self.username,
                            error
                        )
                    )
Valentin Samir's avatar
Valentin Samir committed
242 243 244 245 246 247 248
                    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
249 250 251 252 253

    def get_ticket(self, ticket_class, service, service_pattern, renew):
        """
           Generate a ticket using `ticket_class` for the service
           `service` matching `service_pattern` and asking or not for
254
           authentication renewal with `renew`
Valentin Samir's avatar
Valentin Samir committed
255 256 257 258 259
        """
        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
260
            (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
Valentin Samir's avatar
Valentin Samir committed
261
        )
Valentin Samir's avatar
Valentin Samir committed
262
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
263
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
264
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
265
                if key in replacements:
Valentin Samir's avatar
Valentin Samir committed
266 267 268 269 270 271 272 273 274
                    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
275
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
276 277 278 279 280
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
281 282
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
283
        )
Valentin Samir's avatar
Valentin Samir committed
284
        ticket.save()
285
        self.save()
Valentin Samir's avatar
Valentin Samir committed
286 287 288
        return ticket

    def get_service_url(self, service, service_pattern, renew):
Valentin Samir's avatar
Valentin Samir committed
289 290
        """Return the url to which the user must be redirected to
        after a Service Ticket has been generated"""
Valentin Samir's avatar
Valentin Samir committed
291
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
292
        url = utils.update_url(service, {'ticket': ticket.value})
Valentin Samir's avatar
Valentin Samir committed
293
        logger.info("Service ticket created for service %s by user %s." % (service, self.username))
Valentin Samir's avatar
Valentin Samir committed
294 295
        return url

Valentin Samir's avatar
PEP8  
Valentin Samir committed
296

297
class ServicePatternException(Exception):
298
    """Base exception of exceptions raised in the ServicePattern model"""
299
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
300 301


302
class BadUsername(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
303 304
    """Exception raised then an non allowed username
    try to get a ticket for a service"""
Valentin Samir's avatar
Valentin Samir committed
305
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
306 307


308
class BadFilter(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
309 310
    """"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
311
    pass
Valentin Samir's avatar
Valentin Samir committed
312

Valentin Samir's avatar
PEP8  
Valentin Samir committed
313

314
class UserFieldNotDefined(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
315 316
    """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
317
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
318 319


320
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
321
class ServicePattern(models.Model):
Valentin Samir's avatar
Valentin Samir committed
322
    """Allowed services pattern agains services are tested to"""
Valentin Samir's avatar
Valentin Samir committed
323 324
    class Meta:
        ordering = ("pos", )
325 326
        verbose_name = _("Service pattern")
        verbose_name_plural = _("Services patterns")
Valentin Samir's avatar
Valentin Samir committed
327

328 329
    pos = models.IntegerField(
        default=100,
330 331
        verbose_name=_(u"position"),
        help_text=_(u"service patterns are sorted using the position attribute")
332
    )
Valentin Samir's avatar
Valentin Samir committed
333 334 335 336 337
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
338 339 340 341 342 343
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
    pattern = models.CharField(
        max_length=255,
        unique=True,
344 345 346 347 348 349
        verbose_name=_(u"pattern"),
        help_text=_(
            "A regular expression matching services. "
            "Will usually looks like '^https://some\\.server\\.com/path/.*$'."
            "As it is a regular expression, special character must be escaped with a '\\'."
        )
Valentin Samir's avatar
Valentin Samir committed
350 351 352 353 354
    )
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
355 356
        verbose_name=_(u"user field"),
        help_text=_("Name of the attribut to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
357 358 359
    )
    restrict_users = models.BooleanField(
        default=False,
360 361
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
362 363 364
    )
    proxy = models.BooleanField(
        default=False,
365
        verbose_name=_(u"proxy"),
366 367 368 369 370 371
        help_text=_("Proxy tickets can be delivered to the service")
    )
    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
372
    )
Valentin Samir's avatar
Valentin Samir committed
373
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
374
        default=False,
Valentin Samir's avatar
Valentin Samir committed
375 376
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
377
    )
Valentin Samir's avatar
Valentin Samir committed
378

379 380 381 382 383
    single_log_out_callback = models.CharField(
        max_length=255,
        default="",
        blank=True,
        verbose_name=_(u"single log out callback"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
384
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
385 386 387
                    u"This is usefull for non HTTP proxied services.")
    )

388
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
389 390 391
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
392 393 394 395 396 397
        """
            Check if ``user`` if allowed to use theses services. If ``user`` is not allowed,
            raises one of :class:`BadFilter`, :class:`UserFieldNotDefined`, :class:`BadUsername`

            :param user: a :class:`User` object
        """
Valentin Samir's avatar
Valentin Samir committed
398
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
399
            logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
Valentin Samir's avatar
Valentin Samir committed
400
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
401
        for filtre in self.filters.all():
402 403
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
404
            else:
Valentin Samir's avatar
Valentin Samir committed
405 406 407
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
408 409
                    break
            else:
Valentin Samir's avatar
Valentin Samir committed
410 411 412 413 414 415 416 417 418
                logger.warning(
                    "User constraint failed for %s, service %s: %s do not match %s %s." % (
                        user.username,
                        self.name,
                        filtre.pattern,
                        filtre.attribut,
                        user.attributs.get(filtre.attribut)
                    )
                )
Valentin Samir's avatar
Valentin Samir committed
419 420 421
                raise BadFilter('%s do not match %s %s' % (
                    filtre.pattern,
                    filtre.attribut,
Valentin Samir's avatar
Valentin Samir committed
422
                    user.attributs.get(filtre.attribut)
Valentin Samir's avatar
Valentin Samir committed
423
                ))
Valentin Samir's avatar
Valentin Samir committed
424
        if self.user_field and not user.attributs.get(self.user_field):
Valentin Samir's avatar
Valentin Samir committed
425 426 427 428 429 430 431
            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
432 433 434 435 436
            raise UserFieldNotDefined()
        return True

    @classmethod
    def validate(cls, service):
Valentin Samir's avatar
Valentin Samir committed
437 438 439 440 441
        """Check if a Service Patern match `service` and
        return it, else raise `ServicePattern.DoesNotExist`"""
        for service_pattern in cls.objects.all().order_by('pos'):
            if re.match(service_pattern.pattern, service):
                return service_pattern
Valentin Samir's avatar
Valentin Samir committed
442
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
443 444
        raise cls.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
445

446
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
447 448
class Username(models.Model):
    """A list of allowed usernames on a service pattern"""
449 450 451 452 453
    value = models.CharField(
        max_length=255,
        verbose_name=_(u"username"),
        help_text=_(u"username allowed to connect to the service")
    )
Valentin Samir's avatar
Valentin Samir committed
454
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
455

456
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
457 458
        return self.value

Valentin Samir's avatar
PEP8  
Valentin Samir committed
459

460
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
461
class ReplaceAttributName(models.Model):
Valentin Samir's avatar
Valentin Samir committed
462
    """A list of replacement of attributs name for a service pattern"""
Valentin Samir's avatar
Valentin Samir committed
463
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
464
        unique_together = ('name', 'replace', 'service_pattern')
Valentin Samir's avatar
Valentin Samir committed
465 466
    name = models.CharField(
        max_length=255,
467
        verbose_name=_(u"name"),
Valentin Samir's avatar
Valentin Samir committed
468
        help_text=_(u"name of an attribut to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
469 470 471 472
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
473
        verbose_name=_(u"replace"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
474 475
        help_text=_(u"name under which the attribut will be show"
                    u"to the service. empty = default name of the attribut")
Valentin Samir's avatar
Valentin Samir committed
476
    )
Valentin Samir's avatar
Valentin Samir committed
477 478
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

479
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
480 481 482 483 484
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
485

486
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
487
class FilterAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
488 489 490
    """A list of filter on attributs for a service pattern"""
    attribut = models.CharField(
        max_length=255,
491 492
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
493 494 495
    )
    pattern = models.CharField(
        max_length=255,
496 497
        verbose_name=_(u"pattern"),
        help_text=_(u"a regular expression")
Valentin Samir's avatar
Valentin Samir committed
498
    )
Valentin Samir's avatar
Valentin Samir committed
499 500
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

501
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
502 503
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
504

505
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
506
class ReplaceAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
507 508 509
    """Replacement to apply on attributs values for a service pattern"""
    attribut = models.CharField(
        max_length=255,
510 511
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut for which the value must be replace")
Valentin Samir's avatar
Valentin Samir committed
512 513 514
    )
    pattern = models.CharField(
        max_length=255,
515 516
        verbose_name=_(u"pattern"),
        help_text=_(u"An regular expression maching whats need to be replaced")
Valentin Samir's avatar
Valentin Samir committed
517 518 519 520
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
521 522
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
523
    )
Valentin Samir's avatar
Valentin Samir committed
524 525
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

526
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
527
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
528 529


530
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
531
class Ticket(models.Model):
Valentin Samir's avatar
Valentin Samir committed
532
    """Generic class for a Ticket"""
Valentin Samir's avatar
Valentin Samir committed
533 534 535 536 537 538
    class Meta:
        abstract = True
    user = models.ForeignKey(User, related_name="%(class)s")
    attributs = PickledObjectField()
    validate = models.BooleanField(default=False)
    service = models.TextField()
Valentin Samir's avatar
Valentin Samir committed
539
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
Valentin Samir's avatar
Valentin Samir committed
540 541
    creation = models.DateTimeField(auto_now_add=True)
    renew = models.BooleanField(default=False)
542
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
543

Valentin Samir's avatar
Valentin Samir committed
544 545 546
    VALIDITY = settings.CAS_TICKET_VALIDITY
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

547
    def __str__(self):
548
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
549

550
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
551
    def clean_old_entries(cls):
552 553 554 555
        """Remove old ticket and send SLO to timed-out services"""
        # removing old validated ticket and non validated expired tickets
        cls.objects.filter(
            (
Valentin Samir's avatar
PEP8  
Valentin Samir committed
556 557
                Q(single_log_out=False) & Q(validate=True)
            ) | (
558 559
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
560 561 562 563
            )
        ).delete()

        # sending SLO to timed-out validated tickets
Valentin Samir's avatar
Valentin Samir committed
564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580
        async_list = []
        session = FuturesSession(
            executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
        )
        queryset = cls.objects.filter(
            creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
        )
        for ticket in queryset:
            ticket.logout(session, async_list)
        queryset.delete()
        for future in async_list:
            if future:  # pragma: no branch (should always be true)
                try:
                    future.result()
                except Exception as error:
                    logger.warning("Error durring SLO %s" % error)
                    sys.stderr.write("%r\n" % error)
581

Valentin Samir's avatar
Valentin Samir committed
582
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
583
        """Send a SLO request to the ticket service"""
584 585 586
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
587
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
Valentin Samir's avatar
Valentin Samir committed
588 589 590 591 592 593
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
Valentin Samir's avatar
Valentin Samir committed
594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612
            xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
 ID="%(id)s" Version="2.0" IssueInstant="%(datetime)s">
<saml:NameID xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"></saml:NameID>
<samlp:SessionIndex>%(ticket)s</samlp:SessionIndex>
</samlp:LogoutRequest>""" % \
                {
                    'id': utils.gen_saml_id(),
                    'datetime': timezone.now().isoformat(),
                    'ticket':  self.value
                }
            if self.service_pattern.single_log_out_callback:
                url = self.service_pattern.single_log_out_callback
            else:
                url = self.service
            async_list.append(
                session.post(
                    url.encode('utf-8'),
                    data={'logoutRequest': xml.encode('utf-8')},
                    timeout=settings.CAS_SLO_TIMEOUT
Valentin Samir's avatar
Valentin Samir committed
613
                )
Valentin Samir's avatar
Valentin Samir committed
614
            )
Valentin Samir's avatar
Valentin Samir committed
615

616 617 618 619 620 621
    @staticmethod
    def get_class(ticket):
        for ticket_class in [ServiceTicket, ProxyTicket, ProxyGrantingTicket]:
            if ticket.startswith(ticket_class.PREFIX):
                return ticket_class

Valentin Samir's avatar
PEP8  
Valentin Samir committed
622

623
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
624
class ServiceTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
625
    """A Service Ticket"""
626
    PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
627
    value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
628

629
    def __str__(self):
630
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
631 632


633
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
634
class ProxyTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
635
    """A Proxy Ticket"""
636
    PREFIX = settings.CAS_PROXY_TICKET_PREFIX
637
    value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
638

639
    def __str__(self):
640
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
641 642


643
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
644
class ProxyGrantingTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
645
    """A Proxy Granting Ticket"""
646
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
Valentin Samir's avatar
Valentin Samir committed
647
    VALIDITY = settings.CAS_PGT_VALIDITY
648
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
649

650
    def __str__(self):
651
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
652

Valentin Samir's avatar
PEP8  
Valentin Samir committed
653

654
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
655
class Proxy(models.Model):
Valentin Samir's avatar
Valentin Samir committed
656
    """A list of proxies on `ProxyTicket`"""
Valentin Samir's avatar
Valentin Samir committed
657 658 659 660 661
    class Meta:
        ordering = ("-pk", )
    url = models.CharField(max_length=255)
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

662
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
663
        return self.url