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

    def __str__(self):
        return self.verbose_name

    @staticmethod
    def build_username_from_suffix(username, suffix):
        """Transform backend username into federated username using `suffix`"""
        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
98
class FederatedUser(models.Model):
Valentin Samir's avatar
Valentin Samir committed
99
    """A federated user as returner by a CAS provider (username and attributes)"""
Valentin Samir's avatar
Valentin Samir committed
100 101 102
    class Meta:
        unique_together = ("username", "provider")
    username = models.CharField(max_length=124)
103
    provider = models.ForeignKey(FederatedIendityProvider, on_delete=models.CASCADE)
Valentin Samir's avatar
Valentin Samir committed
104 105 106 107
    attributs = PickledObjectField()
    ticket = models.CharField(max_length=255)
    last_update = models.DateTimeField(auto_now=True)

108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
    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()
130

131 132
    @classmethod
    def clean_old_entries(cls):
Valentin Samir's avatar
Valentin Samir committed
133
        """remove old unused federated users"""
134 135 136 137 138
        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:
139
            if user.federated_username not in known_users:
140 141
                user.delete()

Valentin Samir's avatar
Valentin Samir committed
142

143
class FederateSLO(models.Model):
Valentin Samir's avatar
Valentin Samir committed
144
    """An association between a CAS provider ticket and a (username, session) for processing SLO"""
145
    class Meta:
146
        unique_together = ("username", "session_key", "ticket")
147 148
    username = models.CharField(max_length=30)
    session_key = models.CharField(max_length=40, blank=True, null=True)
149
    ticket = models.CharField(max_length=255, db_index=True)
150 151 152

    @classmethod
    def clean_deleted_sessions(cls):
Valentin Samir's avatar
Valentin Samir committed
153
        """remove old object for which the session do not exists anymore"""
154 155 156 157 158
        for federate_slo in cls.objects.all():
            if not SessionStore(session_key=federate_slo.session_key).get('authenticated'):
                federate_slo.delete()


159
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
160
class User(models.Model):
Valentin Samir's avatar
Valentin Samir committed
161
    """A user logged into the CAS"""
Valentin Samir's avatar
Valentin Samir committed
162
    class Meta:
163
        unique_together = ("username", "session_key")
164 165
        verbose_name = _("User")
        verbose_name_plural = _("Users")
166
    session_key = models.CharField(max_length=40, blank=True, null=True)
Valentin Samir's avatar
Valentin Samir committed
167
    username = models.CharField(max_length=30)
Valentin Samir's avatar
Valentin Samir committed
168
    date = models.DateTimeField(auto_now=True)
Valentin Samir's avatar
Valentin Samir committed
169

170
    def delete(self, *args, **kwargs):
Valentin Samir's avatar
Valentin Samir committed
171
        """remove the User"""
172 173 174 175 176 177 178
        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
179 180
    @classmethod
    def clean_old_entries(cls):
181
        """Remove users inactive since more that SESSION_COOKIE_AGE"""
182 183 184
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
185 186 187 188
        for user in users:
            user.logout()
        users.delete()

189 190
    @classmethod
    def clean_deleted_sessions(cls):
191
        """Remove user where the session do not exists anymore"""
192 193 194 195 196
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

197 198 199 200 201
    @property
    def attributs(self):
        """return a fresh dict for the user attributs"""
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

202
    def __str__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
203
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
204

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

    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
           authentication renewal with `renew
        """
        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
247
            (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
Valentin Samir's avatar
Valentin Samir committed
248
        )
Valentin Samir's avatar
Valentin Samir committed
249
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
250
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
251
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
252
                if key in replacements:
Valentin Samir's avatar
Valentin Samir committed
253 254 255 256 257 258 259 260 261
                    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
262
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
263 264 265 266 267
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
268 269
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
270
        )
Valentin Samir's avatar
Valentin Samir committed
271
        ticket.save()
272
        self.save()
Valentin Samir's avatar
Valentin Samir committed
273 274 275
        return ticket

    def get_service_url(self, service, service_pattern, renew):
Valentin Samir's avatar
Valentin Samir committed
276 277
        """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
278
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
279
        url = utils.update_url(service, {'ticket': ticket.value})
Valentin Samir's avatar
Valentin Samir committed
280
        logger.info("Service ticket created for service %s by user %s." % (service, self.username))
Valentin Samir's avatar
Valentin Samir committed
281 282
        return url

Valentin Samir's avatar
PEP8  
Valentin Samir committed
283

284
class ServicePatternException(Exception):
285
    """Base exception of exceptions raised in the ServicePattern model"""
286
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
287 288


289
class BadUsername(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
290 291
    """Exception raised then an non allowed username
    try to get a ticket for a service"""
Valentin Samir's avatar
Valentin Samir committed
292
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
293 294


295
class BadFilter(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
296 297
    """"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
298
    pass
Valentin Samir's avatar
Valentin Samir committed
299

Valentin Samir's avatar
PEP8  
Valentin Samir committed
300

301
class UserFieldNotDefined(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
302 303
    """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
304
    pass
Valentin Samir's avatar
PEP8  
Valentin Samir committed
305 306


307
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
308
class ServicePattern(models.Model):
Valentin Samir's avatar
Valentin Samir committed
309
    """Allowed services pattern agains services are tested to"""
Valentin Samir's avatar
Valentin Samir committed
310 311
    class Meta:
        ordering = ("pos", )
312 313
        verbose_name = _("Service pattern")
        verbose_name_plural = _("Services patterns")
Valentin Samir's avatar
Valentin Samir committed
314

315 316
    pos = models.IntegerField(
        default=100,
317 318
        verbose_name=_(u"position"),
        help_text=_(u"service patterns are sorted using the position attribute")
319
    )
Valentin Samir's avatar
Valentin Samir committed
320 321 322 323 324
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
325 326 327 328 329 330
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
    pattern = models.CharField(
        max_length=255,
        unique=True,
331 332 333 334 335 336
        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
337 338 339 340 341
    )
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
342 343
        verbose_name=_(u"user field"),
        help_text=_("Name of the attribut to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
344 345 346
    )
    restrict_users = models.BooleanField(
        default=False,
347 348
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
349 350 351
    )
    proxy = models.BooleanField(
        default=False,
352
        verbose_name=_(u"proxy"),
353 354 355 356 357 358
        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
359
    )
Valentin Samir's avatar
Valentin Samir committed
360
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
361
        default=False,
Valentin Samir's avatar
Valentin Samir committed
362 363
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
364
    )
Valentin Samir's avatar
Valentin Samir committed
365

366 367 368 369 370
    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
371
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n"
372 373 374
                    u"This is usefull for non HTTP proxied services.")
    )

375
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
376 377 378
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
Valentin Samir's avatar
Valentin Samir committed
379
        """Check if `user` if allowed to use theses services"""
Valentin Samir's avatar
Valentin Samir committed
380
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
381
            logger.warning("Username %s not allowed on service %s" % (user.username, self.name))
Valentin Samir's avatar
Valentin Samir committed
382
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
383
        for filtre in self.filters.all():
384 385
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
386
            else:
Valentin Samir's avatar
Valentin Samir committed
387 388 389
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
390 391
                    break
            else:
Valentin Samir's avatar
Valentin Samir committed
392 393 394 395 396 397 398 399 400
                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
401 402 403
                raise BadFilter('%s do not match %s %s' % (
                    filtre.pattern,
                    filtre.attribut,
Valentin Samir's avatar
Valentin Samir committed
404
                    user.attributs.get(filtre.attribut)
Valentin Samir's avatar
Valentin Samir committed
405
                ))
Valentin Samir's avatar
Valentin Samir committed
406
        if self.user_field and not user.attributs.get(self.user_field):
Valentin Samir's avatar
Valentin Samir committed
407 408 409 410 411 412 413
            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
414 415 416 417 418
            raise UserFieldNotDefined()
        return True

    @classmethod
    def validate(cls, service):
Valentin Samir's avatar
Valentin Samir committed
419 420 421 422 423
        """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
424
        logger.warning("Service %s not allowed." % service)
Valentin Samir's avatar
Valentin Samir committed
425 426
        raise cls.DoesNotExist()

Valentin Samir's avatar
PEP8  
Valentin Samir committed
427

428
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
429 430
class Username(models.Model):
    """A list of allowed usernames on a service pattern"""
431 432 433 434 435
    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
436
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
437

438
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
439 440
        return self.value

Valentin Samir's avatar
PEP8  
Valentin Samir committed
441

442
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
443
class ReplaceAttributName(models.Model):
Valentin Samir's avatar
Valentin Samir committed
444
    """A list of replacement of attributs name for a service pattern"""
Valentin Samir's avatar
Valentin Samir committed
445
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
446
        unique_together = ('name', 'replace', 'service_pattern')
Valentin Samir's avatar
Valentin Samir committed
447 448
    name = models.CharField(
        max_length=255,
449
        verbose_name=_(u"name"),
Valentin Samir's avatar
Valentin Samir committed
450
        help_text=_(u"name of an attribut to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
451 452 453 454
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
455
        verbose_name=_(u"replace"),
Valentin Samir's avatar
PEP8  
Valentin Samir committed
456 457
        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
458
    )
Valentin Samir's avatar
Valentin Samir committed
459 460
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

461
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
462 463 464 465 466
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
467

468
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
469
class FilterAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
470 471 472
    """A list of filter on attributs for a service pattern"""
    attribut = models.CharField(
        max_length=255,
473 474
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
475 476 477
    )
    pattern = models.CharField(
        max_length=255,
478 479
        verbose_name=_(u"pattern"),
        help_text=_(u"a regular expression")
Valentin Samir's avatar
Valentin Samir committed
480
    )
Valentin Samir's avatar
Valentin Samir committed
481 482
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

483
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
484 485
        return u"%s %s" % (self.attribut, self.pattern)

Valentin Samir's avatar
PEP8  
Valentin Samir committed
486

487
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
488
class ReplaceAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
489 490 491
    """Replacement to apply on attributs values for a service pattern"""
    attribut = models.CharField(
        max_length=255,
492 493
        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
494 495 496
    )
    pattern = models.CharField(
        max_length=255,
497 498
        verbose_name=_(u"pattern"),
        help_text=_(u"An regular expression maching whats need to be replaced")
Valentin Samir's avatar
Valentin Samir committed
499 500 501 502
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
503 504
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
505
    )
Valentin Samir's avatar
Valentin Samir committed
506 507
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

508
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
509
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
510 511


512
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
513
class Ticket(models.Model):
Valentin Samir's avatar
Valentin Samir committed
514
    """Generic class for a Ticket"""
Valentin Samir's avatar
Valentin Samir committed
515 516 517 518 519 520
    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
521
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
Valentin Samir's avatar
Valentin Samir committed
522 523
    creation = models.DateTimeField(auto_now_add=True)
    renew = models.BooleanField(default=False)
524
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
525

Valentin Samir's avatar
Valentin Samir committed
526 527 528
    VALIDITY = settings.CAS_TICKET_VALIDITY
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

529
    def __str__(self):
530
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
531

532
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
533
    def clean_old_entries(cls):
534 535 536 537
        """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
538 539
                Q(single_log_out=False) & Q(validate=True)
            ) | (
540 541
                Q(validate=False) &
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
542 543 544 545
            )
        ).delete()

        # sending SLO to timed-out validated tickets
Valentin Samir's avatar
Valentin Samir committed
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562
        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)
563

Valentin Samir's avatar
Valentin Samir committed
564
    def logout(self, session, async_list=None):
Valentin Samir's avatar
Valentin Samir committed
565
        """Send a SLO request to the ticket service"""
566 567 568
        # On logout invalidate the Ticket
        self.validate = True
        self.save()
Valentin Samir's avatar
Valentin Samir committed
569
        if self.validate and self.single_log_out:  # pragma: no branch (should always be true)
Valentin Samir's avatar
Valentin Samir committed
570 571 572 573 574 575
            logger.info(
                "Sending SLO requests to service %s for user %s" % (
                    self.service,
                    self.user.username
                )
            )
Valentin Samir's avatar
Valentin Samir committed
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594
            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
595
                )
Valentin Samir's avatar
Valentin Samir committed
596
            )
Valentin Samir's avatar
Valentin Samir committed
597

Valentin Samir's avatar
PEP8  
Valentin Samir committed
598

599
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
600
class ServiceTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
601
    """A Service Ticket"""
602
    PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
603
    value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
604

605
    def __str__(self):
606
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
607 608


609
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
610
class ProxyTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
611
    """A Proxy Ticket"""
612
    PREFIX = settings.CAS_PROXY_TICKET_PREFIX
613
    value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
Valentin Samir's avatar
PEP8  
Valentin Samir committed
614

615
    def __str__(self):
616
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
PEP8  
Valentin Samir committed
617 618


619
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
620
class ProxyGrantingTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
621
    """A Proxy Granting Ticket"""
622
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
Valentin Samir's avatar
Valentin Samir committed
623
    VALIDITY = settings.CAS_PGT_VALIDITY
624
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
625

626
    def __str__(self):
627
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
628

Valentin Samir's avatar
PEP8  
Valentin Samir committed
629

630
@python_2_unicode_compatible
Valentin Samir's avatar
Valentin Samir committed
631
class Proxy(models.Model):
Valentin Samir's avatar
Valentin Samir committed
632
    """A list of proxies on `ProxyTicket`"""
Valentin Samir's avatar
Valentin Samir committed
633 634 635 636 637
    class Meta:
        ordering = ("-pk", )
    url = models.CharField(max_length=255)
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

638
    def __str__(self):
Valentin Samir's avatar
Valentin Samir committed
639
        return self.url