models.py 15.6 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 11
# 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.
#
# (c) 2015 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 picklefield.fields import PickledObjectField
Valentin Samir's avatar
Valentin Samir committed
21 22 23

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

30
import utils
Valentin Samir's avatar
Valentin Samir committed
31

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

Valentin Samir's avatar
Valentin Samir committed
34
class User(models.Model):
Valentin Samir's avatar
Valentin Samir committed
35
    """A user logged into the CAS"""
Valentin Samir's avatar
Valentin Samir committed
36
    class Meta:
37 38
        unique_together = ("username", "session_key")
    session_key = models.CharField(max_length=40, blank=True, null=True)
Valentin Samir's avatar
Valentin Samir committed
39
    username = models.CharField(max_length=30)
Valentin Samir's avatar
Valentin Samir committed
40 41
    date = models.DateTimeField(auto_now_add=True, auto_now=True)

Valentin Samir's avatar
Valentin Samir committed
42 43
    @classmethod
    def clean_old_entries(cls):
44 45 46
        users = cls.objects.filter(
            date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
        )
Valentin Samir's avatar
Valentin Samir committed
47 48 49 50
        for user in users:
            user.logout()
        users.delete()

51 52 53 54 55 56 57
    @classmethod
    def clean_deleted_sessions(cls):
        for user in cls.objects.all():
            if not SessionStore(session_key=user.session_key).get('authenticated'):
                user.logout()
                user.delete()

58 59 60 61 62
    @property
    def attributs(self):
        """return a fresh dict for the user attributs"""
        return utils.import_attr(settings.CAS_AUTH_CLASS)(self.username).attributs()

Valentin Samir's avatar
Valentin Samir committed
63
    def __unicode__(self):
Valentin Samir's avatar
oops  
Valentin Samir committed
64
        return u"%s - %s" % (self.username, self.session_key)
Valentin Samir's avatar
Valentin Samir committed
65

Valentin Samir's avatar
Valentin Samir committed
66
    def logout(self, request=None):
Valentin Samir's avatar
Valentin Samir committed
67
        """Sending SLO request to all services the user logged in"""
Valentin Samir's avatar
Valentin Samir committed
68 69
        async_list = []
        session = FuturesSession(executor=ThreadPoolExecutor(max_workers=10))
70 71 72 73
        ticket_classes = [ServiceTicket, ProxyTicket, ProxyGrantingTicket]
        for ticket_class in ticket_classes:
            for ticket in ticket_class.objects.filter(
                    user=self,
74
                    validate=True if ticket_class != ProxyGrantingTicket else False,
75 76 77 78
                    single_log_out=True
            ):
                async_list.append(ticket.logout(request, session))
                ticket.delete()
Valentin Samir's avatar
Valentin Samir committed
79
        for future in async_list:
80 81 82 83
            if future:
                try:
                    future.result()
                except Exception as error:
Valentin Samir's avatar
Valentin Samir committed
84 85 86 87 88 89 90
                    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
91 92 93 94 95 96 97 98 99 100 101 102 103

    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(
            (a.name, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
        )
Valentin Samir's avatar
Valentin Samir committed
104
        service_attributs = {}
Valentin Samir's avatar
Valentin Samir committed
105
        for (key, value) in self.attributs.items():
Valentin Samir's avatar
Valentin Samir committed
106
            if key in attributs or '*' in attributs:
Valentin Samir's avatar
Valentin Samir committed
107 108
                if key in replacements:
                    value = re.sub(replacements[key][0], replacements[key][1], value)
Valentin Samir's avatar
Valentin Samir committed
109
                service_attributs[attributs.get(key, key)] = value
Valentin Samir's avatar
Valentin Samir committed
110 111 112 113 114
        ticket = ticket_class.objects.create(
            user=self,
            attributs=service_attributs,
            service=service,
            renew=renew,
115 116
            service_pattern=service_pattern,
            single_log_out=service_pattern.single_log_out
Valentin Samir's avatar
Valentin Samir committed
117
        )
Valentin Samir's avatar
Valentin Samir committed
118
        ticket.save()
119
        self.save()
Valentin Samir's avatar
Valentin Samir committed
120 121 122
        return ticket

    def get_service_url(self, service, service_pattern, renew):
Valentin Samir's avatar
Valentin Samir committed
123 124
        """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
125
        ticket = self.get_ticket(ServiceTicket, service, service_pattern, renew)
Valentin Samir's avatar
Valentin Samir committed
126 127 128
        url = utils.update_url(service, {'ticket':ticket.value})
        return url

129 130 131
class ServicePatternException(Exception):
    pass
class BadUsername(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
132 133
    """Exception raised then an non allowed username
    try to get a ticket for a service"""
Valentin Samir's avatar
Valentin Samir committed
134
    pass
135
class BadFilter(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
136 137
    """"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
138
    pass
Valentin Samir's avatar
Valentin Samir committed
139

140
class UserFieldNotDefined(ServicePatternException):
Valentin Samir's avatar
Valentin Samir committed
141 142
    """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
143 144
    pass
class ServicePattern(models.Model):
Valentin Samir's avatar
Valentin Samir committed
145
    """Allowed services pattern agains services are tested to"""
Valentin Samir's avatar
Valentin Samir committed
146 147 148
    class Meta:
        ordering = ("pos", )

149 150 151 152
    pos = models.IntegerField(
        default=100,
        verbose_name=_(u"position")
    )
Valentin Samir's avatar
Valentin Samir committed
153 154 155 156 157
    name = models.CharField(
        max_length=255,
        unique=True,
        blank=True,
        null=True,
158 159 160 161 162 163 164
        verbose_name=_(u"name"),
        help_text=_(u"A name for the service")
    )
    pattern = models.CharField(
        max_length=255,
        unique=True,
        verbose_name=_(u"pattern")
Valentin Samir's avatar
Valentin Samir committed
165 166 167 168 169
    )
    user_field = models.CharField(
        max_length=255,
        default="",
        blank=True,
170 171
        verbose_name=_(u"user field"),
        help_text=_("Name of the attribut to transmit as username, empty = login")
Valentin Samir's avatar
Valentin Samir committed
172 173 174
    )
    restrict_users = models.BooleanField(
        default=False,
175 176
        verbose_name=_(u"restrict username"),
        help_text=_("Limit username allowed to connect to the list provided bellow")
Valentin Samir's avatar
Valentin Samir committed
177 178 179
    )
    proxy = models.BooleanField(
        default=False,
180
        verbose_name=_(u"proxy"),
181 182 183 184 185 186
        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
187
    )
Valentin Samir's avatar
Valentin Samir committed
188
    single_log_out = models.BooleanField(
Valentin Samir's avatar
Valentin Samir committed
189
        default=False,
Valentin Samir's avatar
Valentin Samir committed
190 191
        verbose_name=_(u"single log out"),
        help_text=_("Enable SLO for the service")
Valentin Samir's avatar
Valentin Samir committed
192
    )
Valentin Samir's avatar
Valentin Samir committed
193

194 195 196 197 198 199 200 201 202 203
    single_log_out_callback = models.CharField(
        max_length=255,
        default="",
        blank=True,
        verbose_name=_(u"single log out callback"),
        help_text=_(u"URL where the SLO request will be POST. empty = service url\n" \
                    u"This is usefull for non HTTP proxied services.")
    )


Valentin Samir's avatar
Valentin Samir committed
204 205 206 207
    def __unicode__(self):
        return u"%s: %s" % (self.pos, self.pattern)

    def check_user(self, user):
Valentin Samir's avatar
Valentin Samir committed
208
        """Check if `user` if allowed to use theses services"""
Valentin Samir's avatar
Valentin Samir committed
209
        if self.restrict_users and not self.usernames.filter(value=user.username):
Valentin Samir's avatar
Valentin Samir committed
210
            raise BadUsername()
Valentin Samir's avatar
Valentin Samir committed
211
        for filtre in self.filters.all():
212 213
            if isinstance(user.attributs.get(filtre.attribut, []), list):
                attrs = user.attributs.get(filtre.attribut, [])
Valentin Samir's avatar
Valentin Samir committed
214
            else:
Valentin Samir's avatar
Valentin Samir committed
215 216 217
                attrs = [user.attributs[filtre.attribut]]
            for value in attrs:
                if re.match(filtre.pattern, str(value)):
Valentin Samir's avatar
Valentin Samir committed
218 219
                    break
            else:
Valentin Samir's avatar
Valentin Samir committed
220 221 222
                raise BadFilter('%s do not match %s %s' % (
                    filtre.pattern,
                    filtre.attribut,
Valentin Samir's avatar
Valentin Samir committed
223
                    user.attributs.get(filtre.attribut)
Valentin Samir's avatar
Valentin Samir committed
224
                ))
Valentin Samir's avatar
Valentin Samir committed
225 226 227 228 229 230 231
        if self.user_field and not user.attributs.get(self.user_field):
            raise UserFieldNotDefined()
        return True


    @classmethod
    def validate(cls, service):
Valentin Samir's avatar
Valentin Samir committed
232 233 234 235 236
        """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
237 238
        raise cls.DoesNotExist()

Valentin Samir's avatar
Valentin Samir committed
239 240
class Username(models.Model):
    """A list of allowed usernames on a service pattern"""
241 242 243 244 245
    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
246
    service_pattern = models.ForeignKey(ServicePattern, related_name="usernames")
Valentin Samir's avatar
Valentin Samir committed
247

Valentin Samir's avatar
Valentin Samir committed
248 249 250
    def __unicode__(self):
        return self.value

Valentin Samir's avatar
Valentin Samir committed
251
class ReplaceAttributName(models.Model):
Valentin Samir's avatar
Valentin Samir committed
252
    """A list of replacement of attributs name for a service pattern"""
Valentin Samir's avatar
Valentin Samir committed
253
    class Meta:
Valentin Samir's avatar
Valentin Samir committed
254
        unique_together = ('name', 'replace', 'service_pattern')
Valentin Samir's avatar
Valentin Samir committed
255 256
    name = models.CharField(
        max_length=255,
257
        verbose_name=_(u"name"),
Valentin Samir's avatar
Valentin Samir committed
258
        help_text=_(u"name of an attribut to send to the service, use * for all attributes")
Valentin Samir's avatar
Valentin Samir committed
259 260 261 262
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
263 264 265
        verbose_name=_(u"replace"),
        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
266
    )
Valentin Samir's avatar
Valentin Samir committed
267 268 269 270 271 272 273 274 275
    service_pattern = models.ForeignKey(ServicePattern, related_name="attributs")

    def __unicode__(self):
        if not self.replace:
            return self.name
        else:
            return u"%s → %s" % (self.name, self.replace)

class FilterAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
276 277 278
    """A list of filter on attributs for a service pattern"""
    attribut = models.CharField(
        max_length=255,
279 280
        verbose_name=_(u"attribut"),
        help_text=_(u"Name of the attribut which must verify pattern")
Valentin Samir's avatar
Valentin Samir committed
281 282 283
    )
    pattern = models.CharField(
        max_length=255,
284 285
        verbose_name=_(u"pattern"),
        help_text=_(u"a regular expression")
Valentin Samir's avatar
Valentin Samir committed
286
    )
Valentin Samir's avatar
Valentin Samir committed
287 288 289 290 291 292
    service_pattern = models.ForeignKey(ServicePattern, related_name="filters")

    def __unicode__(self):
        return u"%s %s" % (self.attribut, self.pattern)

class ReplaceAttributValue(models.Model):
Valentin Samir's avatar
Valentin Samir committed
293 294 295
    """Replacement to apply on attributs values for a service pattern"""
    attribut = models.CharField(
        max_length=255,
296 297
        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
298 299 300
    )
    pattern = models.CharField(
        max_length=255,
301 302
        verbose_name=_(u"pattern"),
        help_text=_(u"An regular expression maching whats need to be replaced")
Valentin Samir's avatar
Valentin Samir committed
303 304 305 306
    )
    replace = models.CharField(
        max_length=255,
        blank=True,
307 308
        verbose_name=_(u"replace"),
        help_text=_(u"replace expression, groups are capture by \\1, \\2 …")
Valentin Samir's avatar
Valentin Samir committed
309
    )
Valentin Samir's avatar
Valentin Samir committed
310 311 312 313
    service_pattern = models.ForeignKey(ServicePattern, related_name="replacements")

    def __unicode__(self):
        return u"%s %s %s" % (self.attribut, self.pattern, self.replace)
Valentin Samir's avatar
Valentin Samir committed
314 315


Valentin Samir's avatar
Valentin Samir committed
316
class Ticket(models.Model):
Valentin Samir's avatar
Valentin Samir committed
317
    """Generic class for a Ticket"""
Valentin Samir's avatar
Valentin Samir committed
318 319 320 321 322 323
    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
324
    service_pattern = models.ForeignKey(ServicePattern, related_name="%(class)s")
Valentin Samir's avatar
Valentin Samir committed
325 326
    creation = models.DateTimeField(auto_now_add=True)
    renew = models.BooleanField(default=False)
327
    single_log_out = models.BooleanField(default=False)
Valentin Samir's avatar
Valentin Samir committed
328

Valentin Samir's avatar
Valentin Samir committed
329 330 331
    VALIDITY = settings.CAS_TICKET_VALIDITY
    TIMEOUT = settings.CAS_TICKET_TIMEOUT

Valentin Samir's avatar
Valentin Samir committed
332
    def __unicode__(self):
333
        return u"Ticket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
334

335
    @classmethod
Valentin Samir's avatar
Valentin Samir committed
336
    def clean_old_entries(cls):
337 338 339 340 341 342 343
        """Remove old ticket and send SLO to timed-out services"""
        # removing old validated ticket and non validated expired tickets
        cls.objects.filter(
            (
                Q(single_log_out=False)&Q(validate=True)
            )|(
                Q(validate=False)&\
Valentin Samir's avatar
Valentin Samir committed
344
                Q(creation__lt=(timezone.now() - timedelta(seconds=cls.VALIDITY)))
345 346 347 348
            )
        ).delete()

        # sending SLO to timed-out validated tickets
Valentin Samir's avatar
Valentin Samir committed
349
        if cls.TIMEOUT and cls.TIMEOUT > 0:
350 351 352 353 354
            async_list = []
            session = FuturesSession(executor=ThreadPoolExecutor(max_workers=10))
            queryset = cls.objects.filter(
                single_log_out=True,
                validate=True,
Valentin Samir's avatar
Valentin Samir committed
355
                creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
356 357 358 359 360 361 362 363 364 365 366
            )
            for ticket in queryset:
                async_list.append(ticket.logout(None, session))
            queryset.delete()
            for future in async_list:
                if future:
                    try:
                        future.result()
                    except Exception as error:
                        sys.stderr.write("%r\n" % error)

Valentin Samir's avatar
Valentin Samir committed
367
    def logout(self, request, session):
Valentin Samir's avatar
Valentin Samir committed
368
        """Send a SLO request to the ticket service"""
369
        if (self.validate or isinstance(self, ProxyGrantingTicket)) and self.single_log_out:
370 371
            try:
                xml = u"""<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
Valentin Samir's avatar
Valentin Samir committed
372 373 374
     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>
Valentin Samir's avatar
Valentin Samir committed
375 376 377
  </samlp:LogoutRequest>""" % \
            {
                'id' : os.urandom(20).encode("hex"),
378
                'datetime' : timezone.now().isoformat(),
Valentin Samir's avatar
Valentin Samir committed
379 380
                'ticket': self.value
            }
381 382 383 384
                if self.service_pattern.single_log_out_callback:
                    url = self.service_pattern.single_log_out_callback
                else:
                   url = self.service
Valentin Samir's avatar
Valentin Samir committed
385
                return session.post(
386
                    url.encode('utf-8'),
387
                    data={'logoutRequest':xml.encode('utf-8')},
Valentin Samir's avatar
Valentin Samir committed
388 389
                )
            except Exception as error:
390 391 392 393 394 395 396 397 398 399
                if request is not None:
                    error = utils.unpack_nested_exception(error)
                    messages.add_message(
                        request,
                        messages.WARNING,
                        _(u'Error during service logout %(service)s:\n%(error)s') %
                        {'service': self.service, 'error':error}
                    )
                else:
                    sys.stderr.write("%r\n" % error)
Valentin Samir's avatar
Valentin Samir committed
400 401

class ServiceTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
402
    """A Service Ticket"""
403
    PREFIX = settings.CAS_SERVICE_TICKET_PREFIX
404
    value = models.CharField(max_length=255, default=utils.gen_st, unique=True)
Valentin Samir's avatar
Valentin Samir committed
405
    def __unicode__(self):
406
        return u"ServiceTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
407
class ProxyTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
408
    """A Proxy Ticket"""
409
    PREFIX = settings.CAS_PROXY_TICKET_PREFIX
410
    value = models.CharField(max_length=255, default=utils.gen_pt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
411
    def __unicode__(self):
412
        return u"ProxyTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
413
class ProxyGrantingTicket(Ticket):
Valentin Samir's avatar
Valentin Samir committed
414
    """A Proxy Granting Ticket"""
415
    PREFIX = settings.CAS_PROXY_GRANTING_TICKET_PREFIX
Valentin Samir's avatar
Valentin Samir committed
416
    VALIDITY = settings.CAS_PGT_VALIDITY
417
    value = models.CharField(max_length=255, default=utils.gen_pgt, unique=True)
Valentin Samir's avatar
Valentin Samir committed
418 419


Valentin Samir's avatar
Valentin Samir committed
420
    def __unicode__(self):
421
        return u"ProxyGrantingTicket-%s" % self.pk
Valentin Samir's avatar
Valentin Samir committed
422 423

class Proxy(models.Model):
Valentin Samir's avatar
Valentin Samir committed
424
    """A list of proxies on `ProxyTicket`"""
Valentin Samir's avatar
Valentin Samir committed
425 426 427 428 429
    class Meta:
        ordering = ("-pk", )
    url = models.CharField(max_length=255)
    proxy_ticket = models.ForeignKey(ProxyTicket, related_name="proxies")

Valentin Samir's avatar
Valentin Samir committed
430 431 432
    def __unicode__(self):
        return self.url