auth.py 19.5 KB
Newer Older
1
# -*- mode: python; coding: utf-8 -*-
2 3 4 5
# Re2o est un logiciel d'administration développé initiallement au rezometz. Il
# se veut agnostique au réseau considéré, de manière à être installable en
# quelques clics.
#
6
# Copyirght © 2017  Daniel Stan
7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
# Copyright © 2017  Gabriel Détraz
# Copyright © 2017  Goulven Kermarec
# Copyright © 2017  Augustin Lemesle
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# 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 for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

Gabriel Detraz's avatar
Gabriel Detraz committed
25 26 27 28 29 30 31 32 33
"""
Backend python pour freeradius.

Ce fichier contient la définition de plusieurs fonctions d'interface à
freeradius qui peuvent être appelées (suivant les configurations) à certains
moment de l'authentification, en WiFi, filaire, ou par les NAS eux-mêmes.

Inspirés d'autres exemples trouvés ici :
https://github.com/FreeRADIUS/freeradius-server/blob/master/src/modules/rlm_python/
34 35

Inspiré du travail de Daniel Stan au Crans
Gabriel Detraz's avatar
Gabriel Detraz committed
36 37
"""

38 39
import os
import sys
Maël Kervella's avatar
Maël Kervella committed
40 41
import logging
import radiusd  # Module magique freeradius (radiusd.py is dummy)
42

Maël Kervella's avatar
Maël Kervella committed
43
from django.core.wsgi import get_wsgi_application
44
from django.db.models import Q
Maël Kervella's avatar
Maël Kervella committed
45

46 47 48 49 50 51 52 53 54 55 56
proj_path = "/var/www/re2o/"
# This is so Django knows where to find stuff.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "re2o.settings")
sys.path.append(proj_path)

# This is so my local_settings.py gets loaded.
os.chdir(proj_path)

# This is so models get loaded.
application = get_wsgi_application()

57 58 59
from machines.models import Interface, IpList, Nas, Domain
from topologie.models import Port, Switch
from users.models import User
60
from preferences.models import RadiusOption
61 62


63 64 65
OPTIONS, created = RadiusOption.objects.get_or_create()
VLAN_OK = OPTIONS.vlan_decision_ok.vlan_id
RADIUS_POLICY = OPTIONS.radius_general_policy
66

Gabriel Detraz's avatar
Gabriel Detraz committed
67 68 69 70
#: Serveur radius de test (pas la prod)
TEST_SERVER = bool(os.getenv('DBG_FREERADIUS', False))


71
# Logging
Gabriel Detraz's avatar
Gabriel Detraz committed
72 73 74 75 76 77 78 79 80 81 82 83 84
class RadiusdHandler(logging.Handler):
    """Handler de logs pour freeradius"""

    def emit(self, record):
        """Process un message de log, en convertissant les niveaux"""
        if record.levelno >= logging.WARN:
            rad_sig = radiusd.L_ERR
        elif record.levelno >= logging.INFO:
            rad_sig = radiusd.L_INFO
        else:
            rad_sig = radiusd.L_DBG
        radiusd.radlog(rad_sig, record.msg)

85

Gabriel Detraz's avatar
Gabriel Detraz committed
86 87 88 89 90 91 92 93
# Initialisation d'un logger (pour logguer unifié)
logger = logging.getLogger('auth.py')
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(name)s: [%(levelname)s] %(message)s')
handler = RadiusdHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)

94

Gabriel Detraz's avatar
Gabriel Detraz committed
95 96
def radius_event(fun):
    """Décorateur pour les fonctions d'interfaces avec radius.
97 98
    Une telle fonction prend un uniquement argument, qui est une liste de
    tuples (clé, valeur) et renvoie un triplet dont les composantes sont :
Gabriel Detraz's avatar
Gabriel Detraz committed
99 100 101 102 103 104 105 106 107 108
     * le code de retour (voir radiusd.RLM_MODULE_* )
     * un tuple de couples (clé, valeur) pour les valeurs de réponse (accès ok
       et autres trucs du genre)
     * un tuple de couples (clé, valeur) pour les valeurs internes à mettre à
       jour (mot de passe par exemple)

    On se contente avec ce décorateur (pour l'instant) de convertir la liste de
    tuples en entrée en un dictionnaire."""

    def new_f(auth_data):
Maël Kervella's avatar
Maël Kervella committed
109 110
        """ The function transforming the tuples as dict """
        if isinstance(auth_data, dict):
Gabriel Detraz's avatar
Gabriel Detraz committed
111 112 113 114 115 116 117 118
            data = auth_data
        else:
            data = dict()
            for (key, value) in auth_data or []:
                # Beware: les valeurs scalaires sont entre guillemets
                # Ex: Calling-Station-Id: "une_adresse_mac"
                data[key] = value.replace('"', '')
        try:
119 120
            # TODO s'assurer ici que les tuples renvoyés sont bien des
            # (str,str) : rlm_python ne digère PAS les unicodes
Gabriel Detraz's avatar
Gabriel Detraz committed
121 122 123
            return fun(data)
        except Exception as err:
            logger.error('Failed %r on data %r' % (err, auth_data))
124
            return radiusd.RLM_MODULE_FAIL
Gabriel Detraz's avatar
Gabriel Detraz committed
125 126 127

    return new_f

128

Gabriel Detraz's avatar
Gabriel Detraz committed
129 130 131 132 133 134
@radius_event
def instantiate(*_):
    """Utile pour initialiser les connexions ldap une première fois (otherwise,
    do nothing)"""
    logger.info('Instantiation')
    if TEST_SERVER:
135
        logger.info(u'DBG_FREERADIUS is enabled')
Gabriel Detraz's avatar
Gabriel Detraz committed
136

137

Gabriel Detraz's avatar
Gabriel Detraz committed
138 139
@radius_event
def authorize(data):
140
    """On test si on connait le calling nas:
141 142
    - si le nas est inconnue, on suppose que c'est une requète 802.1X, on la
      traite
143
    - si le nas est connu, on applique 802.1X si le mode est activé
144 145
    - si le nas est connu et si il s'agit d'un nas auth par mac, on repond
      accept en authorize
146
    """
147
    # Pour les requetes proxifiees, on split
148 149 150
    nas = data.get('NAS-IP-Address', data.get('NAS-Identifier', None))
    nas_instance = find_nas_from_request(nas)
    # Toutes les reuquètes non proxifiées
Gabriel Detraz's avatar
Gabriel Detraz committed
151
    nas_type = None
152
    if nas_instance:
153 154
        nas_type = Nas.objects.filter(nas_type=nas_instance.type).first()
    if not nas_type or nas_type.port_access_mode == '802.1X':
155
        user = data.get('User-Name', '').decode('utf-8', errors='replace')
156
        user = user.split('@', 1)[0]
157
        mac = data.get('Calling-Station-Id', '')
158 159 160 161 162
        result, log, password = check_user_machine_and_register(
            nas_type,
            user,
            mac
        )
163
        logger.info(log.encode('utf-8'))
164 165
        logger.info(user.encode('utf-8'))

166 167 168
        if not result:
            return radiusd.RLM_MODULE_REJECT
        else:
169 170 171 172 173 174
            return (
                radiusd.RLM_MODULE_UPDATED,
                (),
                (
                    (str("NT-Password"), str(password)),
                ),
175
            )
Gabriel Detraz's avatar
Gabriel Detraz committed
176

177
    else:
178 179 180 181 182 183
        return (
            radiusd.RLM_MODULE_UPDATED,
            (),
            (
                ("Auth-Type", "Accept"),
            ),
184
        )
Gabriel Detraz's avatar
Gabriel Detraz committed
185

186

Gabriel Detraz's avatar
Gabriel Detraz committed
187 188
@radius_event
def post_auth(data):
Maël Kervella's avatar
Maël Kervella committed
189 190 191
    """ Function called after the user is authenticated
    """

192
    nas = data.get('NAS-IP-Address', data.get('NAS-Identifier', None))
root's avatar
root committed
193
    nas_instance = find_nas_from_request(nas)
194
    # Toutes les reuquètes non proxifiées
195 196
    if not nas_instance:
        logger.info(u"Requète proxifiée, nas inconnu".encode('utf-8'))
197 198 199
        return radiusd.RLM_MODULE_OK
    nas_type = Nas.objects.filter(nas_type=nas_instance.type).first()
    if not nas_type:
200 201 202
        logger.info(
            u"Type de nas non enregistré dans la bdd!".encode('utf-8')
        )
203
        return radiusd.RLM_MODULE_OK
204

Gabriel Detraz's avatar
Gabriel Detraz committed
205
    mac = data.get('Calling-Station-Id', None)
206

207 208
    # Switch et bornes héritent de machine et peuvent avoir plusieurs
    # interfaces filles
209
    nas_machine = nas_instance.machine
210
    # Si il s'agit d'un switch
211
    if hasattr(nas_machine, 'switch'):
212
        port = data.get('NAS-Port-Id', data.get('NAS-Port', None))
213 214
        # Pour les infrastructures possédant des switchs Juniper :
        # On vérifie si le switch fait partie d'un stack Juniper
215
        instance_stack = nas_machine.switch.stack
216 217 218
        if instance_stack:
            # Si c'est le cas, on resélectionne le bon switch dans la stack
            id_stack_member = port.split("-")[1].split('/')[0]
219 220 221 222 223
            nas_machine = (Switch.objects
                           .filter(stack=instance_stack)
                           .filter(stack_member_id=id_stack_member)
                           .prefetch_related(
                               'interface_set__domain__extension'
Maël Kervella's avatar
Maël Kervella committed
224
                           )
225 226 227
                           .first())
        # On récupère le numéro du port sur l'output de freeradius.
        # La ligne suivante fonctionne pour cisco, HP et Juniper
228
        port = port.split(".")[0].split('/')[-1][-2:]
229
        out = decide_vlan_switch(nas_machine, nas_type, port, mac)
230 231 232 233 234 235 236 237 238 239
        sw_name, room, reason, vlan_id, decision = out

        if decision:
            log_message = '(fil) %s -> %s [%s%s]' % (
                sw_name + u":" + port + u"/" + str(room),
                mac,
                vlan_id,
                (reason and u': ' + reason).encode('utf-8')
            )
            logger.info(log_message)
240

241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
            # Filaire
            return (
                radiusd.RLM_MODULE_UPDATED,
                (
                    ("Tunnel-Type", "VLAN"),
                    ("Tunnel-Medium-Type", "IEEE-802"),
                    ("Tunnel-Private-Group-Id", '%d' % int(vlan_id)),
                ),
                ()
            )
        else:
            log_message = '(fil) %s -> %s [Reject:%s]' % (
                sw_name + u":" + port + u"/" + str(room),
                mac,
                (reason and u': ' + reason).encode('utf-8')
            )
            logger.info(log_message)
258

259
            return radiusd.RLM_MODULE_REJECT
Gabriel Detraz's avatar
Gabriel Detraz committed
260

261 262
    else:
        return radiusd.RLM_MODULE_OK
Gabriel Detraz's avatar
Gabriel Detraz committed
263

264

Maël Kervella's avatar
Maël Kervella committed
265
# TODO : remove this function
Gabriel Detraz's avatar
Gabriel Detraz committed
266 267 268 269 270
@radius_event
def dummy_fun(_):
    """Do nothing, successfully. (C'est pour avoir un truc à mettre)"""
    return radiusd.RLM_MODULE_OK

271

Gabriel Detraz's avatar
Gabriel Detraz committed
272 273
def detach(_=None):
    """Appelé lors du déchargement du module (enfin, normalement)"""
Maël Kervella's avatar
Maël Kervella committed
274
    print("*** goodbye from auth.py ***")
Gabriel Detraz's avatar
Gabriel Detraz committed
275 276
    return radiusd.RLM_MODULE_OK

277

278
def find_nas_from_request(nas_id):
Maël Kervella's avatar
Maël Kervella committed
279
    """ Get the nas object from its ID """
280 281 282 283 284 285 286
    nas = (Interface.objects
           .filter(
               Q(domain=Domain.objects.filter(name=nas_id)) |
               Q(ipv4=IpList.objects.filter(ipv4=nas_id))
           )
           .select_related('type')
           .select_related('machine__switch__stack'))
root's avatar
root committed
287
    return nas.first()
288

289

290
def check_user_machine_and_register(nas_type, username, mac_address):
291 292
    """Verifie le username et la mac renseignee. L'enregistre si elle est
    inconnue.
293 294 295 296 297
    Renvoie le mot de passe ntlm de l'user si tout est ok
    Utilise pour les authentifications en 802.1X"""
    interface = Interface.objects.filter(mac_address=mac_address).first()
    user = User.objects.filter(pseudo=username).first()
    if not user:
298 299 300
        return (False, u"User inconnu", '')
    if not user.has_access():
        return (False, u"Adhérent non cotisant", '')
301 302
    if interface:
        if interface.machine.user != user:
303 304 305 306
            return (False,
                    u"Machine enregistrée sur le compte d'un autre "
                    "user...",
                    '')
307 308
        elif not interface.is_active:
            return (False, u"Machine desactivée", '')
309 310 311
        elif not interface.ipv4:
            interface.assign_ipv4()
            return (True, u"Ok, Reassignation de l'ipv4", user.pwd_ntlm)
312
        else:
313
            return (True, u"Access ok", user.pwd_ntlm)
314
    elif nas_type:
315
        if nas_type.autocapture_mac:
316 317
            result, reason = user.autoregister_machine(mac_address, nas_type)
            if result:
318 319 320
                return (True,
                        u'Access Ok, Capture de la mac...',
                        user.pwd_ntlm)
321
            else:
322
                return (False, u'Erreur dans le register mac %s' % reason, '')
323 324
        else:
            return (False, u'Machine inconnue', '')
325
    else:
326
        return (False, u"Machine inconnue", '')
327 328


329
def decide_vlan_switch(nas_machine, nas_type, port_number,
330
                       mac_address):
331 332
    """Fonction de placement vlan pour un switch en radius filaire auth par
    mac.
333
    Plusieurs modes :
334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
        - tous les modes:
            - nas inconnu: VLAN_OK
            - port inconnu: Politique définie dans RadiusOption
        - pas de radius sur le port: VLAN_OK
        - force: placement sur le vlan indiqué dans la bdd
        - mode strict:
            - pas de chambre associée: Politique définie
              dans RadiusOption
            - pas d'utilisateur dans la chambre : Rejet
              (redirection web si disponible)
            - utilisateur de la chambre banni ou désactivé : Rejet
              (redirection web si disponible)
            - utilisateur de la chambre non cotisant et non whiteslist:
              Politique définie dans RadiusOption

            - sinon passe à common (ci-dessous)
        - mode common :
            - interface connue (macaddress):
                - utilisateur proprio non cotisant / machine désactivée:
                    Politique définie dans RadiusOption
                - utilisateur proprio banni :
                    Politique définie dans RadiusOption
                - user à jour : VLAN_OK (réassignation de l'ipv4 au besoin)
            - interface inconnue :
                - register mac désactivé : Politique définie
                  dans RadiusOption
                - register mac activé: redirection vers webauth
    Returns:
        tuple avec :
            - Nom du switch (str)
            - chambre (str)
            - raison de la décision (str)
            - vlan_id (int)
            - decision (bool)
368
    """
369
    # Get port from switch and port number
370
    extra_log = ""
371
    # Si le NAS est inconnu, on place sur le vlan defaut
372
    if not nas_machine:
373
        return ('?', u'Chambre inconnue', u'Nas inconnu', VLAN_OK, True)
374

375
    sw_name = str(getattr(nas_machine, 'short_name', str(nas_machine)))
376

377 378 379 380 381 382
    port = (Port.objects
            .filter(
                switch=Switch.objects.filter(machine_ptr=nas_machine),
                port=port_number
            )
            .first())
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
383

384
    # Si le port est inconnu, on place sur le vlan defaut
chirac's avatar
chirac committed
385 386
    # Aucune information particulière ne permet de déterminer quelle
    # politique à appliquer sur ce port
387
    if not port:
388 389 390 391 392 393 394
        return (
            sw_name,
            "Chambre inconnue",
            u'Port inconnu',
            OPTIONS.unknown_port_vlan.vlan_id,
            OPTIONS.unknown_port != OPTIONS.REJECT
        )
395

396
    # On récupère le profil du port
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
397
    port_profile = port.get_port_profile
398

Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
399
    # Si un vlan a été précisé dans la config du port,
chirac's avatar
chirac committed
400
    # on l'utilise pour VLAN_OK
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
401 402
    if port_profile.vlan_untagged:
        DECISION_VLAN = int(port_profile.vlan_untagged.vlan_id)
403 404 405
        extra_log = u"Force sur vlan " + str(DECISION_VLAN)
    else:
        DECISION_VLAN = VLAN_OK
406

407
    # Si le port est désactivé, on rejette la connexion
408
    if not port.state:
409
        return (sw_name, port.room, u'Port desactivé', None, False)
410

chirac's avatar
chirac committed
411
    # Si radius est désactivé, on laisse passer
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
412
    if port_profile.radius_type == 'NO':
413 414 415
        return (sw_name,
                "",
                u"Pas d'authentification sur ce port" + extra_log,
416 417
                DECISION_VLAN,
                True)
418

419 420
    # Si le 802.1X est activé sur ce port, cela veut dire que la personne a
    # été accept précédemment
chirac's avatar
chirac committed
421
    # Par conséquent, on laisse passer sur le bon vlan
422
    if (nas_type.port_access_mode, port_profile.radius_type) == ('802.1X', '802.1X'):
chirac's avatar
chirac committed
423
        room = port.room or "Chambre/local inconnu"
424 425 426 427 428 429 430
        return (
            sw_name,
            room,
            u'Acceptation authentification 802.1X',
            DECISION_VLAN,
            True
        )
chirac's avatar
chirac committed
431 432 433

    # Sinon, cela veut dire qu'on fait de l'auth radius par mac
    # Si le port est en mode strict, on vérifie que tous les users
434 435 436 437
    # rattachés à ce port sont bien à jour de cotisation. Sinon on rejette
    # (anti squattage)
    # Il n'est pas possible de se connecter sur une prise strict sans adhérent
    # à jour de cotis dedans
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
438
    if port_profile.radius_mode == 'STRICT':
439 440
        room = port.room
        if not room:
441 442 443 444 445 446 447
            return (
                sw_name,
                "Inconnue",
                u'Chambre inconnue',
                OPTIONS.unknown_room_vlan.vlan_id,
                OPTIONS.unknown_room != OPTIONS.REJECT
            )
448

449 450 451
        room_user = User.objects.filter(
            Q(club__room=port.room) | Q(adherent__room=port.room)
        )
452
        if not room_user:
453 454 455 456 457 458 459
            return (
                sw_name,
                room,
                u'Chambre non cotisante -> Web redirect',
                None,
                False
            )
460
        for user in room_user:
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
461
            if user.is_ban() or user.state != User.STATE_ACTIVE:
462 463 464 465 466 467 468
                return (
                    sw_name,
                    room,
                    u'Utilisateur banni ou désactivé -> Web redirect',
                    None,
                    False
                )
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
469
            elif not (user.is_connected() or user.is_whitelisted()):
470 471 472 473 474 475 476
                return (
                    sw_name,
                    room,
                    u'Utilisateur non cotisant',
                    OPTIONS.non_member_vlan.vlan_id,
                    OPTIONS.non_member != OPTIONS.REJECT
                )
477 478
        # else: user OK, on passe à la verif MAC

479 480
    # Si on fait de l'auth par mac, on cherche l'interface
    # via sa mac dans la bdd
Hugo LEVY-FALK's avatar
Hugo LEVY-FALK committed
481
    if port_profile.radius_mode == 'COMMON' or port_profile.radius_mode == 'STRICT':
482
        # Authentification par mac
483 484 485 486 487
        interface = (Interface.objects
                     .filter(mac_address=mac_address)
                     .select_related('machine__user')
                     .select_related('ipv4')
                     .first())
488
        if not interface:
489
            room = port.room
490 491 492 493 494 495 496 497 498 499 500 501
            # On essaye de register la mac, si l'autocapture a été activée,
            # on rejette pour faire une redirection web si possible.
            if nas_type.autocapture_mac:
                return (
                    sw_name,
                    room,
                    u'Machine Inconnue -> Web redirect',
                    None,
                    False
                )
            # Sinon on bascule sur la politique définie dans les options
            # radius.
502
            else:
503 504 505 506 507 508 509 510 511 512
                return (
                    sw_name,
                    "",
                    u'Machine inconnue',
                    OPTIONS.unknown_machine_vlan.vlan_id,
                    OPTIONS.unknown_machine != OPTIONS.REJECT
                )

        # L'interface a été trouvée, on vérifie qu'elle est active,
        # sinon on reject
chirac's avatar
chirac committed
513 514
        # Si elle n'a pas d'ipv4, on lui en met une
        # Enfin on laisse passer sur le vlan pertinent
515
        else:
516
            room = port.room
517
            if interface.machine.user.is_ban():
518 519 520 521 522 523 524
                return (
                    sw_name,
                    room,
                    u'Adherent banni',
                    OPTIONS.banned_vlan.vlan_id,
                    OPTIONS.banned != OPTIONS.REJECT
                )
525
            if not interface.is_active:
526 527 528 529 530 531 532 533 534
                return (
                    sw_name,
                    room,
                    u'Machine non active / adherent non cotisant',
                    OPTIONS.non_member_vlan.vlan_id,
                    OPTIONS.non_member != OPTIONS.REJECT
                )
            # Si on choisi de placer les machines sur le vlan
            # correspondant à leur type :
535 536 537
            if RADIUS_POLICY == 'MACHINE':
                DECISION_VLAN = interface.type.ip_type.vlan.vlan_id
            if not interface.ipv4:
538
                interface.assign_ipv4()
539 540 541 542 543 544 545
                return (
                    sw_name,
                    room,
                    u"Ok, Reassignation de l'ipv4" + extra_log,
                    DECISION_VLAN,
                    True
                )
546
            else:
547 548 549 550 551 552 553
                return (
                    sw_name,
                    room,
                    u'Machine OK' + extra_log,
                    DECISION_VLAN,
                    True
                )