auth.py 41.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
# ⁻*- mode: python; coding: utf-8 -*-
"""
Backend Python pour FreeRADIUS.

Inspirés du code source du module rlm_python et d'autres exemples trouvés ici :
https://github.com/FreeRADIUS/freeradius-server/blob/v3.0.x/src/modules/rlm_python/
"""

from __future__ import print_function, unicode_literals

import os
12
import time
13 14
import logging
import traceback
15
import threading
16 17

import radiusd
18 19
import psycopg2
from psycopg2 import extras
20 21 22 23
from ldap import SERVER_DOWN, SCOPE_BASE

from lc_ldap import shortcuts, crans_utils, objets
from gestion.config import config
24
from gestion.annuaires_pg import reverse, lieux_public
25 26 27 28 29 30 31 32

###############################################################################
##                              Environnement                                ##
###############################################################################

#: Serveur radius de test
TEST_SERVER = bool(os.getenv('DBG_FREERADIUS', False))

33 34 35
#: Sur certains serveurs, on veut désactiver l'accounting
NO_ACCOUNTING = bool(os.getenv('NO_ACCOUNTING', False))

36 37 38
#: Nombre maximal de tentatives lors de la connexion à un annuaire LDAP
LDAP_CONN_MAX_TRY = 2

39
## Filtres LDAP
Hamza Dely's avatar
Hamza Dely committed
40 41 42
#: Filtre LDAP pour la recherche de machines
LDAP_FILTER = '(&(objectClass=%(objectClass)s)(%(af)s=%(address)s))'

43
## Paramètres SQL
44 45
#: Configuration de la connexion à la base de données SQL
SQL_PARAMS = {
46
    'user' : 'freerad',
47
    'dbname' : 'radius',
48 49 50 51
    'host' : 'pgsql.adm.crans.org' if not TEST_SERVER else 'vo.adm.crans.org',
    'cursor_factory' : extras.DictCursor,
}

52 53 54
#: Temps (en s) pendant lequel les résultats (propriétaire, machine) sont mis en cache
CACHE_TIMEOUT = 5.0

55 56 57 58 59 60 61
## Tagging dynamique des VLANs
#: Activation du tagging dynamique de VLANs en filaire
DYN_VLAN_WIRED = True

#: Activation du tagging dynamique de VLANs en Wi-Fi
DYN_VLAN_WIRELESS = False

62 63 64 65 66 67 68 69 70 71
## Change-Of-Authorization/Disconnect Messages (RFC 5176)
#: Activation des CoA/DMs
USE_COA = True

#: Envoi de CoA-Requests plutôt que des Disconnect-Requests en filaire
COA_REQUESTS_WIRED = True

#: Envoi de CoA-Requests plutôt que des Disconnect-Requests en sans fil
COA_REQUESTS_WIRELESS = False

72 73
## Blacklistes
#: Blacklistes impliquant un rejet immédiat
Hamza Dely's avatar
Hamza Dely committed
74
BL_REJECT = {'bloq'}
75 76

#: Blacklistes impliquant l'isolement de la machine
Hamza Dely's avatar
Hamza Dely committed
77
BL_ISOLEMENT = {'virus', 'autodisc_virus', 'autodisc_p2p', 'ipv6_ra'}
78 79

#: Blacklistes impliquant le traitement de la machine comme inconnue
Hamza Dely's avatar
Hamza Dely committed
80
BL_ACCUEIL = {'paiement', 'chambre_invalide'}
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128

# TODO: mettre ça dans config.py en explicitant un peu comment ça marche
# et en trouvant moyen de refresh en fonction de la période de l'année
# (bl soft/hard parefeu ou pas)

## Valeurs par défaut de certains attributs LDAP
#: Adresse MAC de substitution pour les nouvelles machines
MAC_PLACEHOLDER = '<automatique>'

## Types d'adhérents
#: Personnes physiques
OWNER_ADHERENT = objets.adherent

#: Personnes morales
OWNER_CLUB = objets.club

#: Le Crans (Pseudo-Adhérent)
OWNER_CRANS = objets.AssociationCrans

## Types de machines
#: NAS
TYPE_NAS = 'NAS'

#: Machine filaire
TYPE_WIRED = 'Wired'

#: Machine sans fil
TYPE_WIRELESS = 'Wireless'

## VLANs
#: VLAN pour machines filaires des adhérents
VLAN_ADHERENTS = unicode(config.vlans['adherent'])

#: VLAN pour les machines sans fil des adhérents
VLAN_WIFI = unicode(config.vlans['wifi'])

#: VLAN d'accueil
VLAN_ACCUEIL = unicode(config.vlans['accueil'])

#: VLAN d'isolement
VLAN_ISOLEMENT = unicode(config.vlans['isolement'])

#: VLAN des personnels de l'ENS
VLAN_APPARTEMENTS = unicode(config.vlans['appts'])

#: VLAN pour les machines des adhérents d'associations partenaires
VLAN_FEDEREZ = unicode(config.vlans['federez'])

129 130 131 132 133 134 135 136
## Noms de domaines
#: Nom de domaine associé à chaque type de machine
DOMAINS = {
    TYPE_WIRED : 'crans.org',
    TYPE_WIRELESS : 'wifi.crans.org',
    None : 'nodomain',
}

137
## Paramètres liés à des durées
138
#: Période (en secondes) utilisée par le NAS pour notifier le serveur RADIUS
139
#  des statistiques concernant une session donnée
140 141 142
#  Cette valeur NE DEVRAIT PAS être inférieure à 600 secondes et NE DOIT PAS
#  être inférieure à 60 secondes (RFC 2869)
ACCT_INTERIM_INTERVAL = '14400'  # 4 heures
143

144 145 146 147 148 149 150 151 152 153 154 155 156 157
###############################################################################
##                            Attributs RADIUS                               ##
###############################################################################

## Types de réseaux (NAS-Port-Type)
#: NAS-Port-Type filaires
NAS_PORT_TYPE_WIRED = ['Ethernet']

#: NAS-Port-Type sans fil
NAS_PORT_TYPE_WIRELESS = [
    'Wireless-802.11', 'Wireless-802.16', 'Wireless-802.20', 'Wireless-802.22',
    'Wireless-Other',
]

158 159 160 161 162 163 164 165 166
## Types de paquets de comptabilisation (Acct-Status-Type)
#: Début
ACCT_STATUS_TYPE_START = 'Start'

#: Mise à jour des informations concernant une machine
ACCT_STATUS_TYPE_INTERIM_UPDATE = 'Interim-Update'

#: Fin
ACCT_STATUS_TYPE_STOP = 'Stop'
167

168 169 170 171 172 173
#: Reboot du NAS, perte de lien avec le client, ...
ACCT_STATUS_TYPE_ACCOUNTING_OFF = 'Accounting-Off'

#: Remise en route du NAS
ACCT_STATUS_TYPE_ACCOUNTING_ON = 'Accounting-On'

174 175 176 177 178 179 180 181 182 183 184
################################################################################
##                                  Logging                                   ##
################################################################################

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.ERROR:
            rad_sig = radiusd.L_ERR
185
        elif record.levelno >= logging.WARNING:
186 187 188 189 190
            rad_sig = radiusd.L_WARN
        elif record.levelno >= logging.INFO:
            rad_sig = radiusd.L_INFO
        else:
            rad_sig = radiusd.L_DBG
191 192
        logmsg = self.format(record)
        radiusd.radlog(rad_sig, logmsg.encode('utf-8', 'replace'))
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217

logger = logging.getLogger('auth.py')
logger.setLevel(logging.DEBUG if TEST_SERVER else logging.INFO)
formatter = logging.Formatter('%(name)s: [%(levelname)s] %(message)s')
handler = RadiusdHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)

###############################################################################
##                          Fonctions utilitaires                            ##
###############################################################################

def __clean_pairs(pairs, bytestring=False):
    """Prend en entrée une liste de paires de chaînes de caractères et
    renvoie cette liste en garantissant qu'elle ne contienne que des
    bytestrings"""
    if bytestring:
        # On effectue le filtrage des guillemets et espace AVANT
        # la transformation en bytestring, faute de quoi une
        # UnicodeDecodeError est levée car les caractères à chercher
        # sont donnés en Unicode
        return tuple(
            (
                key.strip(' "').encode('utf-8', 'ignore'),
                value.strip(' "').encode('utf-8', 'ignore'),
218 219 220 221
            ) if not isinstance(value, bytearray) else (
                key.strip(' "').encode('utf-8', 'ignore'),
                bytes(value),
            ) for key, value in pairs
222 223 224 225 226 227 228 229 230 231
        )
    else:
        return tuple(
            (
                key.decode('utf-8', 'ignore').strip(' "'),
                value.decode('utf-8', 'ignore').strip(' "'),
            )
            for key, value in pairs
        )

Hamza Dely's avatar
Hamza Dely committed
232
def cleaned_request(request):
233 234 235
    """Prend en entrée une liste de paires de chaînes de caractères et renvoie
    un dictionnaire ne contenant que des bytestrings, après avoir retiré les
    guillemets et espaces surnuméraires autour des valeurs renvoyées"""
Hamza Dely's avatar
Hamza Dely committed
236 237
    if isinstance(request, dict):
        request = request.items()
238 239 240
    else:
        pass

Hamza Dely's avatar
Hamza Dely committed
241
    return dict(__clean_pairs(request))
242 243 244 245 246 247 248 249 250

def cleaned_response(response):
    """Prend en entrée une réponse à un évenement RADIUS et s'assure que les
    chaînes de caractères présentes à l'intérieur ne sont pas des unicodes"""
    if not isinstance(response, (list, tuple)):
        return response
    else:
        retcode = response[0]
        reply = response[1]
251
        radius_config = response[2]
252 253 254
        return (
            retcode,
            __clean_pairs(reply, bytestring=True),
255
            __clean_pairs(radius_config, bytestring=True),
256 257 258 259 260 261 262 263 264 265 266 267
        )

def ldap_unpack(ldap_list):
    """Récupère la valeur du premier élément de la liste `ldap_list` renvoyée
    par lc_ldap"""
    try:
        return ldap_list[0].value
    except IndexError:
        return None
    except AttributeError:
        raise

Hamza Dely's avatar
Hamza Dely committed
268
def type_of_machine(request):
269
    """Renvoie le type de la machine à authentifier"""
Hamza Dely's avatar
Hamza Dely committed
270
    nas_port_type = request.get('NAS-Port-Type', None)
271 272 273 274 275 276 277
    if nas_port_type in NAS_PORT_TYPE_WIRED:
        return TYPE_WIRED
    elif nas_port_type in NAS_PORT_TYPE_WIRELESS:
        return TYPE_WIRELESS
    else:
        return None

278 279 280 281 282 283 284 285 286 287
def is_registered(machine):
    """Indique si la machine est déjà enregistrée dans la base ou non"""
    if (machine is None
            or (ldap_unpack(machine['macAddress']) == MAC_PLACEHOLDER)
            or 'rid' not in machine.keys()
            or not machine['rid']):
        return False
    else:
        return True

288 289 290 291 292 293 294 295 296 297
def dynamic_vlan_enabled_for(m_type):
    """Renvoie True si l'assignation de VLAN est faite de manière dynamique
    pour le type de machine `m_type`"""
    if m_type == TYPE_WIRED and DYN_VLAN_WIRED:
        return True
    elif m_type == TYPE_WIRELESS and DYN_VLAN_WIRELESS:
        return True
    else:
        return False

298 299 300 301 302 303 304 305 306 307
def prefer_coa(m_type):
    """Renvoie True si le type de machine donné supporte les CoA-Requests, False
    dans le cas contraire"""
    if m_type == TYPE_WIRED and COA_REQUESTS_WIRED:
        return True
    elif m_type == TYPE_WIRELESS and COA_REQUESTS_WIRELESS:
        return True
    else:
        return False

308 309 310
def is_ieee8021X(request):
    """Indique si la requête reçue a été faite en utilisant IEEE 802.1X"""
    return 'EAP-Message' in request.keys()
311 312 313 314 315 316 317 318 319

def ieee8021X_password(machine):
    """Renvoie le mot de passe associé à `machine` utilisé pour
    l'authentification IEEE 802.1X"""
    try:
        return ldap_unpack(machine['ipsec'])
    except KeyError:
        return None

320 321 322 323
def blacklists_of(owner, machine):
    """Renvoie l'ensemble des types de blacklistes actives sur une machine
    et son propriétaire."""
    return set(bl['type'] for bl in owner.blacklist_actif() + machine.blacklist_actif())
324

325 326
def assign_vlan(machine, owner, m_type):
    """Décide quel VLAN assigner à la machine `machine`"""
327
    assignment = (VLAN_ACCUEIL, "Unknown machine")
328
    if machine is None:
329 330
        return assignment

331
    # Assignation forcée de VLAN à des fins de test
332 333 334 335 336 337 338
    if TEST_SERVER:
        infos = machine.get('info', [])
        vlan_id, reason = None, None
        while len(infos) > 0:
            if ldap_unpack(infos).startswith('force_vlan:'):
                vlan_id, reason = ldap_unpack(infos)[11:].split(',')
                vlan_id = vlan_id.strip()
339
                assignment = (vlan_id or None, reason)
340 341 342 343 344
                infos = []
            else:
                infos = infos[1:]
        if vlan_id is not None:
            return assignment
345 346

    rid = ldap_unpack(machine['rid'])
347
    blacklists = blacklists_of(owner, machine)
348 349 350
    bl_reject = blacklists & BL_REJECT
    bl_isolement = blacklists & BL_ISOLEMENT
    bl_accueil = blacklists & BL_ACCUEIL
351
    if any(begin <= rid <= end for (begin, end) in config.rid['personnel-ens']):
352
        # Les machines des personnels de l'ENS sont traitées à part
353
        assignment = (VLAN_APPARTEMENTS, "OK")
354
    elif bl_reject:
355
        assignment = (None, "blacklisted (%s)" % bl_reject.pop())
356
    elif bl_isolement:
357
        assignment = (VLAN_ISOLEMENT, "blacklisted (%s)" % bl_isolement.pop())
358
    elif bl_accueil:
359
        assignment = (VLAN_ACCUEIL, "blacklisted (%s)" % bl_accueil.pop())
360
    elif m_type == TYPE_WIRED:
361
        assignment = (VLAN_ADHERENTS, "OK")
362
    elif m_type == TYPE_WIRELESS:
363
        assignment = (VLAN_WIFI, "OK")
364 365
    else:
        # À ce stade, on a une machine d'un type inconnu, on rejette
366 367 368
        assignment = (None, "Unknown machine type %s" % m_type)

    return assignment
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386

################################################################################
##                                  Décorateurs                               ##
################################################################################

def radius_response(func):
    """Décorateur pour les fonctions d'interfaces avec FreeRADIUS.
    Une telle fonction vérifie les conditions suivantes :
    A)  elle prend un unique argument : un dictionnaire contenant les attributs
        de la requête RADIUS
    B)  elle renvoie soit None, soit un code de retour seul soit un triplet
        dont les composantes sont :
        1) le code de retour (voir radiusd.RLM_MODULE_*)
        2) un tuple de paires (clé, valeur) pour les valeurs de réponse (accès ok
           et autres trucs du genre)
        3) un tuple de paires (clé, valeur) pour les valeurs internes à mettre à
           jour (mot de passe par exemple)"""

Hamza Dely's avatar
Hamza Dely committed
387
    def new_f(self, request, *args, **kwargs):
388 389 390 391 392 393 394
        # On vérifie dans un premier temps que la connexion à la base LDAP
        # est bien initialisée
        #if (self.ldap is None) or (self.ldap_rw is None):
        #    logger.error("LDAP server is unreachable.")
        #    return radiusd.RLM_MODULE_FAIL

        try:
Hamza Dely's avatar
Hamza Dely committed
395
            request = cleaned_request(request or [])
396
            logger.debug("Processing RADIUS Request with data %s", request)
Hamza Dely's avatar
Hamza Dely committed
397
            response = func(self, request, *args, **kwargs)
398
            logger.debug('RADIUS Request processed. Answered: %s', (response,))
399 400
            return cleaned_response(response)
        except Exception:
Daniel STAN's avatar
Daniel STAN committed
401
            logger.error(traceback.format_exc())
402
            logger.error('RADIUS Request data: %s', request)
403 404 405 406 407 408 409 410 411 412 413
            return radiusd.RLM_MODULE_FAIL

    return new_f

################################################################################
##                      Gestionnaire d'évènements RADIUS                      ##
################################################################################

class RadiusEventHandler(object):
    """Gestionnaire d'évenements RADIUS"""

414 415
    #: Connexion aux annuaires LDAP local et en écriture
    __ldap, __ldap_rw = None, None
416

417 418 419
    #: Connexion à la base SQL pour la comptabilisation
    __sql_conn, __sql_cursor = None, None

420 421 422
    #: Cache pour les résultats des requêtes LDAP
    __cache = {}

423 424 425 426 427 428
    #: Thread de nettoyage des caches
    __cache_cleaner = None

    #: Variable de contrôle pour les boucles des threads
    __is_running = False

429 430 431 432 433
    ## Gestionnaires d'évènements RADIUS

    @radius_response
    def instantiate(self, *_):
        """Lance le module d'authentification"""
434
        logger.info('[instantiate] Loading Python RADIUS auth backend')
435
        if TEST_SERVER:
436
            logger.info('[instantiate] DBG_FREERADIUS is enabled')
437

438 439 440
        if NO_ACCOUNTING:
            logger.info('[instantiate] Accounting is disabled')

441 442 443 444 445
        self.__is_running = True
        self.__cache_cleaner = threading.Thread(target=self.__cleaner)
        logger.info("[instantiate] Starting cleaner thread")
        self.__cache_cleaner.start()
        logger.info("[instantiate] Cleaner thread started")
446
        return radiusd.RLM_MODULE_OK
447 448

    @radius_response
Hamza Dely's avatar
Hamza Dely committed
449
    def authorize(self, request):
450 451
        """Récupère les informations de la machine concernée en vue de
        préparer la phase d'authentification"""
Hamza Dely's avatar
Hamza Dely committed
452
        m_type = type_of_machine(request)
453 454 455
        username = request.get('User-Name', None)
        mac = request.get('Calling-Station-Id', None)
        if m_type not in [TYPE_WIRED, TYPE_WIRELESS]:
456
            logger.error('[pre-auth] Invalid machine type %s.', m_type)
457
            return radiusd.RLM_MODULE_INVALID
458

459
        logger.debug('[pre-auth] Supplicant treated as a %s machine', m_type)
460
        owner, machine, _, _, _ = self.find_match(request, m_type)
461
        if (owner is None) or (machine is None):
462
            logger.info('[pre-auth] No match found for %s/%s', mac, username)
463 464 465 466
            return radiusd.RLM_MODULE_NOTFOUND

        mid = ldap_unpack(machine['mid'])
        if isinstance(owner, OWNER_CRANS):
467
            logger.error('[pre-auth] Our machine %d tries to authenticate', mid)
468 469 470 471 472 473 474 475 476 477 478
            return radiusd.RLM_MODULE_REJECT

        if is_ieee8021X(request):
            # Dans le cas d'une authentification IEEE 802.1X, chaque machine
            # a un mot de passe qui lui est propre
            logger.debug('[pre-auth] Preparing IEEE 802.1X authentication')
            ieee8021X_pwd = ieee8021X_password(machine)
            cleartext_password = (('Cleartext-Password', ieee8021X_pwd),)
            if ieee8021X_pwd is None:
                logger.error(
                    '[pre-auth] Machine %d has no password set. '
479 480
                    'Unable to continue with IEEE 802.1X',
                    mid
481 482
                )
                return radiusd.RLM_MODULE_REJECT
483
        else:
484 485
            # Dans le cas d'un authentification MAC Auth, le switch utilise
            # l'adresse MAC du client comme mot de passe
486
            logger.debug('[pre-auth] Preparing CHAP authentication')
487
            cleartext_password = (('Cleartext-Password', username),)
488 489 490 491

        return (
            radiusd.RLM_MODULE_UPDATED,
            (),
492
            cleartext_password,
493
        )
494 495

    @radius_response
Hamza Dely's avatar
Hamza Dely committed
496
    def authenticate(self, request):
497
        """Authentification de l'utilisateur et de sa machine"""
Hamza Dely's avatar
Hamza Dely committed
498
        m_type = type_of_machine(request)
499
        _, machine, _, _, _ = self.find_match(request, m_type)
500
        remote_mac = request.get('Calling-Station-Id', None)
501 502 503
        if machine is None:
            # Il s'agit d'une machine inconnue, on la mettra sur le VLAN accueil
            # en phase de post-authentification
504
            logger.debug('[auth] Unknown machine %s is trying to connect', remote_mac)
505 506 507
            return radiusd.RLM_MODULE_NOTFOUND

        registered_mac = ldap_unpack(machine['macAddress'])
508
        if registered_mac == MAC_PLACEHOLDER:
509
            logger.debug(
510 511
                '[auth] Machine %s has no registered MAC address yet.',
                ldap_unpack(machine['mid'])
512 513 514 515 516 517 518
            )
            return radiusd.RLM_MODULE_NOOP

        if remote_mac is None:
            logger.error('[auth] No remote MAC address provided.')
            return radiusd.RLM_MODULE_INVALID

519
        if registered_mac.lower() == remote_mac.lower():
520
            logger.debug('[auth] Registered and remote MAC match (%s)', remote_mac)
521 522 523
            return radiusd.RLM_MODULE_OK
        else:
            logger.error(
524 525
                '[auth] Registered and remote MAC mismatch (%s != %s)',
                registered_mac, remote_mac
526 527 528 529 530 531 532 533 534
            )
            return radiusd.RLM_MODULE_REJECT

    @radius_response
    def preacct(self, *_):
        """Pré-traitement des requêtes de comptabilisation"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
535
    def accounting(self, request):
536
        """Traitement des requêtes de comptabilisation"""
537 538 539 540
        # Certains switches HP semblent ne pas donner de NAS-Port-Type dans les
        # Accounting-Request, on suppose donc en l'absence de cet attribut qu'il
        # s'agit d'une machine filaire
        m_type = type_of_machine(request) or TYPE_WIRED
541 542
        acct_status_type = request.get('Acct-Status-Type', None)
        acct_session_id = request.get('Acct-Session-Id', None)
543
        nas = request.get('NAS-IP-Address', request.get('NAS-IPv6-Address', None))
544 545
        mac = request.get('Calling-Station-Id', None)

546 547 548 549
        if NO_ACCOUNTING:
            # On ne traite pas les requêtes d'accounting
            return radiusd.RLM_MODULE_OK

550 551 552
        if acct_status_type == ACCT_STATUS_TYPE_START:
            # Le NAS indique juste après avoir reçu un Access-Accept le début
            # de la session : on garde une trace du couple MAC/Acct-Session-Id
553
            logger.debug("[accounting] Recording session ID %s for %s", acct_session_id, mac)
554
            self.register_session(request)
555 556 557 558 559
            return radiusd.RLM_MODULE_OK

        elif acct_status_type == ACCT_STATUS_TYPE_STOP:
            # Le NAS indique que la session est terminée.
            # On peut supprimer les données associées à cette MAC
560 561 562
            logger.debug(
                "[accounting] Removing record for %s (Session ID %s)", mac, acct_session_id
            )
563 564 565 566 567 568 569
            self.unregister_session(mac=mac)
            return radiusd.RLM_MODULE_OK

        elif acct_status_type in [ACCT_STATUS_TYPE_ACCOUNTING_ON, ACCT_STATUS_TYPE_ACCOUNTING_OFF]:
            # Le NAS a planté/rebooté/perdu le lien avec le client/(autre) et signale
            # que la session est suspendue ou a repris : on supprime toutes
            # les sessions enregistrées liées à ce NAS
570 571 572
            nas_mac = self._mac_of_nas(nas, m_type)
            logger.debug('[accounting] Deleting all sessions managed by NAS %s', nas_mac)
            self.unregister_session(nas=nas_mac)
573 574
            return radiusd.RLM_MODULE_OK

575
        elif not acct_status_type == ACCT_STATUS_TYPE_INTERIM_UPDATE:
576 577 578
            # On accuse récéption des autres paquets, pour que le NAS ne les renvoie pas
            # à intervalles réguilers (RFC 2866)
            logger.debug('[accounting] Received %s packet: ignoring', acct_status_type)
579 580
            return radiusd.RLM_MODULE_OK

581 582 583 584 585 586 587
        elif not USE_COA:
            # La gestion des changements d'autorisation et déconnexions dynamiques
            # est désactivée. On met juste à jour la base de données.
            logger.debug("[accounting] Updating SQL accounting database for MAC %s", mac)
            self.update_session(mac)
            return radiusd.RLM_MODULE_OK

588 589 590 591 592 593 594
        else:
            # Le NAS nous informe régulièrement sur l'état de la session en cours
            # Ces paquets sont l'occasion de chercher si de nouvelles blacklistes ont
            # été posées, et d'agir en conséquence
            pass

        session_info = self.retrieve_session(mac)
595
        vlan_id = session_info['vlan'] if session_info else None
596
        owner, machine, _, _, _ = self.find_match(request, m_type)
597
        new_vlan_id, why = assign_vlan(machine, owner, m_type)
598 599 600 601 602 603 604
        if vlan_id is None:
            # Le VLAN n'a pas été renvoyé par le NAS, on a donc
            # dû le recalculer. On met à jour l'enregistrement correspondant
            self.update_session(mac, vlan_id=new_vlan_id)
            return radiusd.RLM_MODULE_OK

        elif unicode(vlan_id) == new_vlan_id:
605
            # Aucun changement de VLAN n'est nécéssaire
606
            logger.debug("[accounting] No change for %s", mac)
607 608
            logger.debug("[accounting] Updating SQL accounting database")
            self.update_session(mac)
609 610
            return radiusd.RLM_MODULE_OK

611
        elif (new_vlan_id is None
612 613
              or not dynamic_vlan_enabled_for(m_type)
              or not prefer_coa(m_type)):
614 615 616
            # Aucun VLAN ne peut être affecté à la machine
            # On demande sa déconnexion
            logger.debug(
617
                "[accounting] Sending Disconnect-Request for machine %s (%s)", mac, why
618 619 620 621 622 623 624 625 626 627 628 629 630 631 632
            )
            return (
                radiusd.RLM_MODULE_OK,
                (),
                (
                    ("Send-Disconnect-Request", 'Yes'),
                ),
            )

        else:
            # Un changement de VLAN est nécéssaire pour la machine
            # On envoie un CoA avec l'ID du nouveau VLAN
            # XXX: Un changement brutal de VLAN déconnecte les machines,
            #      celles-ci fonctionnant encore avec leur ancienne IP
            logger.debug(
633
                "[accounting] Sending CoA-Request for machine %s (%s)", mac, why
634 635 636 637 638
            )
            return (
                radiusd.RLM_MODULE_OK,
                (),
                (
639
                    ("Tunnel-Private-Group-ID", new_vlan_id),
640 641 642
                    ("Send-CoA-Request", 'Yes'),
                ),
            )
643 644 645 646 647 648 649 650 651 652 653 654

    @radius_response
    def pre_proxy(self, *_):
        """Pré-traitement des requêtes avant proxification"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
    def post_proxy(self, *_):
        """Traitement des requêtes après retour de la proxification"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
Hamza Dely's avatar
Hamza Dely committed
655
    def post_auth(self, request):
656
        """Traitement de la requête après l'authentification"""
Hamza Dely's avatar
Hamza Dely committed
657
        m_type = type_of_machine(request)
658
        nas_id = request.get('NAS-IP-Address', request.get('NAS-IPv6-Address', 'Unknown'))
659 660 661 662
        owner, machine, hosts, plug, room = self.find_match(request, m_type)
        if room and plug:
            mac = request.get('Calling-Station-Id', request.get('User-Name', None))
            logger.info("[post-auth] Mac-address %s auth on room %s/%s" % (mac, room, plug))
663
        room = ldap_unpack(hosts[0]['chbre']) if hosts else None
664 665 666 667 668 669 670 671
        if machine is None:
            # La machine est inconnue : si on fait de l'assignation dynamique,
            # on la place sur le VLAN d'accueil, sinon on la rejette.
            if dynamic_vlan_enabled_for(m_type):
                return (
                    radiusd.RLM_MODULE_OK,
                    (
                        ('Tunnel-Type', 'VLAN'),
Hamza Dely's avatar
Hamza Dely committed
672
                        ('Tunnel-Medium-Type', 'IEEE-802'),
673
                        ('Tunnel-Private-Group-ID', VLAN_ACCUEIL),
674
                        ('Class', VLAN_ACCUEIL),
675
                        ('Acct-Interim-Interval', ACCT_INTERIM_INTERVAL),
676
                    ),
677
                    (),
678 679 680 681
                )
            else:
                return radiusd.RLM_MODULE_REJECT

682 683 684 685
        # Protection anti-squattage
        # Une machine utilisant une prise autre que celle de son propriétaire
        # doit utiliser celle d'un adhérent à jour de cotisation
        if (any(h.blacklist_actif() and not h.dn == owner.dn for h in hosts)
686
                and not room in lieux_public()):
687 688 689 690 691
            logger.warning(
                "[auth] Anti-squatting protection triggered for room %s and MAC %s. Rejecting.",
                room,
                request.get('Calling-Station-Id', None)
            )
692 693 694 695 696 697 698 699 700 701 702
            return (
                radiusd.RLM_MODULE_OK,
                (
                    ('Tunnel-Type', 'VLAN'),
                    ('Tunnel-Medium-Type', 'IEEE-802'),
                    ('Tunnel-Private-Group-ID', VLAN_ACCUEIL),
                    ('Class', VLAN_ACCUEIL),
                    ('Acct-Interim-Interval', ACCT_INTERIM_INTERVAL),
                ),
                (),
            )
703

704 705 706 707
        if not is_registered(machine):
            # La machine vient d'être enregistrée, on lui associe la
            # MAC que l'on vient de trouver
            mid = ldap_unpack(machine['mid'])
Hamza Dely's avatar
Hamza Dely committed
708
            mac = request.get('Calling-Station-Id', request.get('User-Name', None))
709
            logger.info('[post-auth] mid=%s <- %s', mid, mac)
710
            machine = self.register_new_machine(mac, machine)
711 712
        else:
            mid = ldap_unpack(machine['mid'])
713
            mac = ldap_unpack(machine['macAddress'])
714 715 716

        # On assigne maintenant un réseau à la machine en fonction
        # de ses blacklistes
717
        logger.debug("[post-auth] Trying to find a VLAN for %s", mac)
718 719 720
        vlan_id, why = assign_vlan(machine, owner, m_type)
        if vlan_id is None:
            # Impossible de savoir où placer l'adhérent, on rejette
721
            logger.info("[post-auth] %s rejected: %s", mac, why)
722 723
            return radiusd.RLM_MODULE_REJECT

724 725 726 727
        # Les RFC 2865 et 2866 spécifient que l'attribut Class PEUT être
        # renvoyé dans un Access-Accept, auquel cas il DOIT être renvoyé par le NAS
        # dans l'Accounting-Request qui suit. On peut s'en servir pour garder
        # une trace du VLAN affecté à la machine et voir s'il a changé.
728
        if dynamic_vlan_enabled_for(m_type):
729 730 731
            logger.info(
                "[post-auth] [%s/Dynamic] VLAN %s assigned to %s: %s", nas_id, vlan_id, mac, why
            )
732 733 734 735
            return (
                radiusd.RLM_MODULE_OK,
                (
                    ('Tunnel-Type', 'VLAN'),
Hamza Dely's avatar
Hamza Dely committed
736
                    ('Tunnel-Medium-Type', 'IEEE-802'),
737
                    ('Tunnel-Private-Group-ID', vlan_id),
738
                    ('Class', vlan_id),
739
                    ('Acct-Interim-Interval', ACCT_INTERIM_INTERVAL),
740 741 742 743 744 745 746
                ),
                (),
            )
        elif vlan_id not in [VLAN_ADHERENTS, VLAN_WIFI, VLAN_APPARTEMENTS]:
            # Dans le cas de VLANs statique, on ne peut pas placer les adhérents
            # sur des VLANs isolés en cas de blackliste, on est donc contraints
            # de rejeter l'authentification
747
            logger.info("[post-auth] [%s/Static] Rejected %s: %s", nas_id, mac, why)
748 749
            return radiusd.RLM_MODULE_REJECT
        else:
750
            logger.info("[post-auth] [%s/Static] Accepted %s: %s", nas_id, mac, why)
751 752 753
            return (
                radiusd.RLM_MODULE_OK,
                (
754
                    ('Class', vlan_id),
755
                    ('Acct-Interim-Interval', ACCT_INTERIM_INTERVAL),
756 757 758
                ),
                (),
            )
759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777

    @radius_response
    def recv_coa(self, *_):
        """Traitement des réponses aux changements d'autorisation"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
    def send_coa(self, *_):
        """Traitement des requêtes de changement d'autorisation envoyées aux NAS"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
    def checksimul(self, *_):
        """Traitement de la session associée à la requête en cours"""
        return radiusd.RLM_MODULE_NOOP

    @radius_response
    def detach(self, *_):
        """Décharge le module Python"""
778
        logger.info('[detach] Unloading Python RADIUS auth backend')
779 780 781
        logger.info('[detach] Terminating cleaner thread')
        self.__is_running = False
        self.__cache_cleaner.join(1.5*CACHE_TIMEOUT)
782

783 784
    # Méthodes internes

785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804
    def __connect(self, constructor):
        """Essaie d'établir une connexion avec une base LDAP"""
        count = LDAP_CONN_MAX_TRY
        while count > 0:
            try:
                connection = constructor()
                break
            except SERVER_DOWN:
                count -= 1
        else:
            logger.error('LDAP server did not respond')
            connection = None

        return connection

    @property
    def ldap(self):
        """Renvoie une connexion active à la base LDAP locale"""
        if not self.__ldap:
            logger.debug('Connecting to Local LDAP Server')
805
            self.__ldap = self.__connect(shortcuts.lc_ldap_local)
806
            self.__ldap.mode = 'ro'
807 808 809 810 811 812 813 814
        return self.__ldap

    @property
    def ldap_rw(self):
        """Renvoie une connexion à la base LDAP maîtresse"""
        if not self.__ldap_rw:
            logger.debug('Connecting to Master LDAP Server')
            self.__ldap_rw = self.__connect(shortcuts.lc_ldap_admin)
815
            self.__ldap_rw.mode = 'rw'
816 817
        return self.__ldap_rw

818 819 820 821 822 823 824 825 826 827 828 829 830 831
    @property
    def sql(self):
        """Renvoie un curseur sur la base de données SQL"""
        if not self.__sql_conn or self.__sql_conn.closed:
            logger.debug("Connecting to SQL database")
            self.__sql_conn = psycopg2.connect(**SQL_PARAMS)
            self.__sql_conn.set_session(autocommit=True)
        if not self.__sql_cursor or self.__sql_cursor.closed:
            self.__sql_cursor = self.__sql_conn.cursor()
        return self.__sql_cursor

    def register_session(self, request):
        """Enregistre une nouvelle session dans la base SQL"""
        p_mac = request.get('Calling-Station-Id', None)
832
        p_type = type_of_machine(request) or TYPE_WIRED
833
        p_session_id = request.get('Acct-Session-Id', None)
Hamza Dely's avatar
Hamza Dely committed
834
        p_nas = request.get('NAS-IP-Address', request.get('NAS-IPv6-Address', None))
835 836
        p_port = request.get('NAS-Port', None)
        p_ssid = request.get('Called-Station-SSID', None)
837 838
        p_bss = request.get('Called-Station-Id', None)
        p_vlan = request.get('Class', '')
839 840 841

        params = {
            'mac' : p_mac,
842
            'type' : p_type,
843
            'session_id' : p_session_id,
844
            'nas' : self._mac_of_nas(p_nas, p_type),
845 846
            'port' : p_port,
            'ssid' : p_ssid,
847 848
            'bss' : p_bss,
            'vlan' : p_vlan.replace('0x', '').decode('hex').decode('utf-8') or None,
849 850 851 852
        }

        logger.debug("Registering new session in SQL database with parameters %s", params)
        self.sql.execute(
853 854 855 856
            "INSERT INTO accounting (mac, type, session_id, nas, port, ssid, bss, vlan) VALUES "
            "(%(mac)s, %(type)s, %(session_id)s, %(nas)s, %(port)s, %(ssid)s, %(bss)s, %(vlan)s) "
            "ON CONFLICT (mac) "
            "DO UPDATE SET (type, session_id, nas, port, ssid, bss, last_update) = "
857
            "(%(type)s, %(session_id)s, %(nas)s, %(port)s, %(ssid)s, %(bss)s, now());",
858 859 860
            params
        )

861 862 863 864 865 866 867 868 869 870 871 872 873 874 875
    def update_session(self, mac, vlan_id=None):
        """Met à jour les données de la session associée à la MAC donnée.
        Si vlan_id est donné, mets à jour le champ 'vlan' en même temps"""
        logger.debug("Updating session for MAC %s", mac)
        if vlan_id is None:
            self.sql.execute(
                "UPDATE accounting SET last_update = DEFAULT WHERE mac = %s;",
                (mac,)
            )
        else:
            self.sql.execute(
                "UPDATE accounting SET (last_update, vlan) = (DEFAULT, %s) WHERE mac = %s;",
                (vlan_id, mac,)
            )

876 877 878 879 880 881 882 883 884 885 886 887
    def unregister_session(self, mac=None, nas=None):
        """Supprime des sessions enregistrées. Si `mac` est spécifié, supprime la
        session associée à cette MAC. Si `nas` est spécifié, supprime toutes les
        sessions gérées par ce NAS. `nas` prévaut sur `mac`"""
        if nas:
            logger.debug('Deleting all sessions managed by NAS %s in SQL database', nas)
            self.sql.execute("DELETE FROM accounting WHERE nas = %s;", (nas,))
        elif mac:
            logger.debug("Deleting old session for MAC address %s in SQL database", mac)
            self.sql.execute("DELETE FROM accounting WHERE mac = %s;", (mac,))
        else:
            logger.error("Unable to delete session without MAC or NAS address.")
888 889 890 891 892 893 894 895

    def retrieve_session(self, mac):
        """Renvoie les données de la session associée à la MAC donnée.
        Si aucune donnée n'est disponible, renvoie None"""
        logger.debug("SQL lookup for MAC %s", mac)
        self.sql.execute("SELECT * FROM accounting WHERE mac = %s;", (mac,))
        return self.sql.fetchone()

896
    def register_new_machine(self, mac, machine):
897 898 899 900 901 902 903 904
        """Enregistre la MAC d'une nouvelle machine"""
        if self.ldap_rw is None:
            logger.error("Unable to reach master LDAP server")
            return None

        machine_rw = self.ldap_rw.search(dn=machine.dn, scope=SCOPE_BASE, mode='rw')[0]
        with machine_rw:
            machine_rw['macAddress'] = mac
905
            machine_rw.renew_rid()
906 907 908 909 910 911
            machine_rw.validate_changes()
            machine_rw.history_gen()
            machine_rw.save()

        return machine_rw

912
    def find_match(self, request, m_type):
913 914 915 916 917
        """Renvoie un couple (propriétaire, machine) à partir des
        données fournies, en utilisant le cache du module pour
        économiser les requêtes à la base LDAP"""
        mac = request.get('Calling-Station-Id', None)
        if (mac, m_type) in self.__cache:
918
            logger.debug("%s found in module cache", mac)
919 920
            owner, machine, hosts, plug, room, _ = self.__cache[(mac, m_type)]
            return (owner, machine, hosts, plug, room)
921
        else:
922
            logger.debug("%s not found in module cache", mac)
923
            result = self.__find_match(request, m_type)
924
            self.__cache.update({(mac, m_type) : result + (time.time(),)})
925 926
            return result

927 928 929 930 931
    def _mac_of_nas(self, nas_ip, m_type):
        """Renvoie l'adresse MAC assoiciée à l'IP du NAS donné et du type donné"""
        if nas_ip is None:
            return None
        else:
Hamza Dely's avatar
Hamza Dely committed
932 933 934
            params = {
                'objectClass' : 'switchCrans' if m_type == TYPE_WIRED else 'borneWifi',
                'af' : 'ip6HostNumber' if ':' in nas_ip else 'ipHostNumber',
Hamza Dely's avatar
Hamza Dely committed
935
                'address' : crans_utils.escape(nas_ip),
Hamza Dely's avatar
Hamza Dely committed
936 937
            }
            nas = self.ldap.search(LDAP_FILTER % params)
938 939
            return ldap_unpack(nas[0]['macAddress']) if len(nas) > 0 else None

940 941 942
    def _plug_info(self, nas_id, port):
        """Renvoie la chambre associé à un NAS et une prise donnée d'une part
        et l'ensemble des adhérents y résidant d'autre part"""
943 944
        building = nas_id[3]
        plug = "%d%02d" % (int(nas_id[5:]), int(port))
945
        logger.debug('Trying to find a match by room (plug %s)', plug)
946
        room = reverse(building, plug)
947 948 949
        if room:
            room = room[0]
            logger.debug('Found room %s%s', building.upper(), crans_utils.escape(room))
950
            return building.lower() + plug, building.upper()+room, self.ldap.search('(&(chbre=%s%s)(!(chbre=EXT)))' % (building, room))
951
        else:
952
            logger.debug('No room for plug %s%s', building, plug)
953
            return None, None, []
954

955
    def __find_match(self, request, m_type):
956
        """Renvoie un triplet (propriétaire, machine, hébergeurs) à partir
957 958 959 960 961 962
        des données fournies.
        Peut renvoyer None pour n'importe laquelle des deux composantes"""
        mac = request.get('Calling-Station-Id', None)
        username = request.get('User-Name', None)
        nas_id = request.get('NAS-Identifier', None)
        port = request.get('NAS-Port', None)
Hamza Dely's avatar
Hamza Dely committed
963 964
        params = {}

965 966
        if self.ldap is None:
            logger.error('Unable to reach local LDAP server')
Gabriel Detraz's avatar
Gabriel Detraz committed
967
            return (None, None, [], None, None)
968

969 970
        if mac is None or username is None:
            logger.error('No User-Name or Calling-Station-Id attribute')
Gabriel Detraz's avatar
Gabriel Detraz committed
971
            return (None, None, [], None, None)
972 973

        if m_type == TYPE_WIRED:
Hamza Dely's avatar
Hamza Dely committed
974
            params.update({'objectClass' : 'machineFixe'})
975
        elif m_type == TYPE_WIRELESS:
Hamza Dely's avatar
Hamza Dely committed
976
            params.update({'objectClass' : 'machineWifi'})
977 978
        else:
            # Type de machine non géré
Gabriel Detraz's avatar
Gabriel Detraz committed
979
            return (None, None, [], None, None)
980

981
        if is_ieee8021X(request):
982
            # Si la requête a été effectuée en utilisant IEEE 802.1X, on cherche
983 984 985 986 987 988 989 990
            # la machine grâce à son User-Name.
            # Dans ce cas, la liste des hébergeurs n'est pas nécéssaire, car
            # il n'y a pas de spoofing a priori
            logger.debug('Trying to find a match by hostname (%s)', username)
            params.update({
                'af' : 'host',
                'address' : "%s.%s" % (crans_utils.escape(username), DOMAINS[m_type]),
            })
Hamza Dely's avatar
Hamza Dely committed
991
            m_list = self.ldap.search(LDAP_FILTER % params)
992
            hosts = []
993
            room, plug = None, None
994

995 996 997 998 999 1000 1001 1002
        else:
            # Si la requête n'utilise pas IEEE 802.1X, on cherche la machine via sa MAC.
            # Dans ce cas, s'il s'agit d'une machine filaire, on renvoie la liste
            # des hébergeurs de la chambre en question afin d'effectuer une
            # vérification anti-spoofing
            logger.debug('Trying to find a match by MAC (%s)', mac)
            params.update({'af' : 'macAddress', 'address' : crans_utils.escape(mac)})
            m_list = self.ldap.search(LDAP_FILTER % params)
Daniel STAN's avatar
Daniel STAN committed
1003
            plug, room, hosts = self._plug_info(nas_id, port) if m_type == TYPE_WIRED else (None, None, [])
1004 1005 1006

        if not m_list and not is_ieee8021X(request) and m_type == TYPE_WIRED and nas_id and port:
            # La recherche n'a rien donné, IEEE 802.1X n'est pas utilisé
1007 1008
            # mais on est en présence d'une machine filaire : on essaie de voir si
            # le locataire de la chambre a une nouvelle machine
1009
            params.update({'af' : 'macAddress', 'address' : MAC_PLACEHOLDER})
1010
            plug, room, hosts = self._plug_info(nas_id, port)
1011 1012 1013 1014 1015 1016 1017 1018
            owner = hosts[0] if hosts else None
            m_list = self.ldap.search(LDAP_FILTER % params, dn=owner.dn, scope=1) if owner else []

        if len(m_list) > 1:
            logger.warning(
                'Multiple wired machines registered with address %s : Using first match.',
                mac
            )
1019 1020

        if m_list:
1021
            # On a une machine, on peut en déduire le propriétaire présumé
1022
            machine = m_list[0]
1023
            logger.debug('Match found (machine %s)', ldap_unpack(machine['mid']))
1024
            owner = machine.proprio()
1025
            return (owner, machine, hosts, plug, room)
1026
        else: