From 89729f9a6ba8a780e550f4c307b568cc191050cf Mon Sep 17 00:00:00 2001 From: Gabriel Detraz Date: Thu, 8 Dec 2016 06:16:29 +0100 Subject: [PATCH 1/3] Script d'auth freeradius --- freeradius_utils/authenticate_filaire.py | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 freeradius_utils/authenticate_filaire.py diff --git a/freeradius_utils/authenticate_filaire.py b/freeradius_utils/authenticate_filaire.py new file mode 100755 index 00000000..b0cc4fb5 --- /dev/null +++ b/freeradius_utils/authenticate_filaire.py @@ -0,0 +1,75 @@ +import os, sys + +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. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +import argparse + +from machines.models import Interface +from topologie.models import Room, Port, Switch +from users.models import User + +from re2o.settings import RADIUS_VLAN_DECISION + +VLAN_NOK = RADIUS_VLAN_DECISION['VLAN_NOK'] +VLAN_OK = RADIUS_VLAN_DECISION['VLAN_OK'] + +def decide_vlan(switch_name, port_number, mac_address): + # Get port from switch and port number + switch = Switch.objects.filter(switch_interface=Interface.objects.filter(dns=switch_name)) + if switch: + port = Port.objects.filter(switch=switch[0], port=port_number) + if port: + port = port[0] + if port.radius == 'NO': + # Aucune authentification sur ce port + decision = ("Pas d'authentification sur ce port", VLAN_OK) + elif port.radius == 'BLOQ': + # Prise désactivée + decision = ('Port desactive', VLAN_NOK) + elif port.radius == 'COMMON' or port.radius == 'STRICT': + # Authentification par mac + interface = Interface.objects.filter(mac_address=mac_address) + if not interface: + decision = ('Mac not found', VLAN_NOK) + elif interface[0].is_active(): + # Verification de la prise + if port.radius == 'STRICT': + if port.room: + user = User.objects.filter(room=Room.objects.filter(name=port.room)) + if not user: + decision = ('Chambre non cotisante', VLAN_NOK) + elif user[0].has_access(): + decision = ('Machine OK, Proprio OK', VLAN_OK) + else: + decision = ('Chambre inconnue', VLAN_NOK) + else: + # Mode COMMON + decision = ('Machine OK', VLAN_OK) + else: + decision = ('Machine non active / adherent non cotisant', VLAN_NOK) + else: + decision = ('VLAN forced', int(port.radius)) + else: + decision = ('port not found!', VLAN_OK) + else: + decision = ('switch not found!', VLAN_OK) + return decision + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Decide radius vlan attribution') + parser.add_argument('switch_name', action="store") + parser.add_argument('port_number', action="store", type=int) + parser.add_argument('mac_address', action="store") + args = parser.parse_args() + print(decide_vlan(args.switch_name, args.port_number, args.mac_address)) + From 8b0114391aa052a1c41b2ce433820ef1ed672da0 Mon Sep 17 00:00:00 2001 From: Gabriel Detraz Date: Thu, 8 Dec 2016 15:13:41 +0100 Subject: [PATCH 2/3] Add auth.py et module --- freeradius_utils/auth.py | 262 ++++++++++++++++++ freeradius_utils/modules/rlm_python_re2o.conf | 1 + 2 files changed, 263 insertions(+) create mode 100644 freeradius_utils/auth.py create mode 120000 freeradius_utils/modules/rlm_python_re2o.conf diff --git a/freeradius_utils/auth.py b/freeradius_utils/auth.py new file mode 100644 index 00000000..e6e612d3 --- /dev/null +++ b/freeradius_utils/auth.py @@ -0,0 +1,262 @@ +# ⁻*- mode: python; coding: utf-8 -*- +""" +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/ +""" + +import logging +import netaddr +import radiusd # Module magique freeradius (radiusd.py is dummy) +import os +import binascii +import hashlib +import subprocess + +import os, sys +from ast import literal_eval as make_tuple + +#: Serveur radius de test (pas la prod) +TEST_SERVER = bool(os.getenv('DBG_FREERADIUS', False)) + + +## -*- 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.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) + +# 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) + +def radius_event(fun): + """Décorateur pour les fonctions d'interfaces avec radius. + Une telle fonction prend un uniquement argument, qui est une liste de tuples + (clé, valeur) et renvoie un triplet dont les composantes sont : + * 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): + if type(auth_data) == dict: + 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: + # TODO s'assurer ici que les tuples renvoyés sont bien des (str,str) + # rlm_python ne digère PAS les unicodes + return fun(data) + except Exception as err: + logger.error('Failed %r on data %r' % (err, auth_data)) + raise + + return new_f + + + +@radius_event +def instantiate(*_): + """Utile pour initialiser les connexions ldap une première fois (otherwise, + do nothing)""" + logger.info('Instantiation') + if TEST_SERVER: + logger.info('DBG_FREERADIUS is enabled') + +@radius_event +def authorize(data): + """Fonction qui aiguille entre nas, wifi et filaire pour authorize + On se contecte de faire une verification basique de ce que contien la requète + pour déterminer la fonction à utiliser""" + if data.get('NAS-Port-Type', '')==u'Ethernet': + return authorize_fil(data) + elif u"Wireless" in data.get('NAS-Port-Type', ''): + return authorize_wifi(data) + +@radius_event +def authorize_wifi(data): + """Section authorize pour le wifi + (NB: le filaire est en accept pour tout le monde) + Éxécuté avant l'authentification proprement dite. On peut ainsi remplir les + champs login et mot de passe qui serviront ensuite à l'authentification + (MschapV2/PEAP ou MschapV2/TTLS)""" + + items = get_machines(data) + + if not items: + logger.error('Nothing found') + return radiusd.RLM_MODULE_NOTFOUND + + if len(items) > 1: + logger.warn('lc_ldap: Too many results (taking first)') + + machine = items[0] + + proprio = machine.proprio() + if isinstance(proprio, lc_ldap.objets.AssociationCrans): + logger.error('Crans machine trying to authenticate !') + return radiusd.RLM_MODULE_INVALID + + for bl in machine.blacklist_actif(): + if bl.value['type'] in BL_REJECT: + return radiusd.RLM_MODULE_REJECT + # Kludge : vlan isolement pas possible, donc reject quand-même + if not WIFI_DYN_VLAN and bl.value['type'] in BL_ISOLEMENT: + return radiusd.RLM_MODULE_REJECT + + + if not machine.get('ipsec', False): + logger.error('WiFi auth but machine has no password') + return radiusd.RLM_MODULE_REJECT + + password = machine['ipsec'][0].value.encode('ascii', 'ignore') + + # TODO: feed cert here + return (radiusd.RLM_MODULE_UPDATED, + (), + ( + ("Cleartext-Password", password), + ), + ) + +@radius_event +def authorize_fil(data): + """ + Check le challenge chap, et accepte. + """ + + chap_ok = False + # Teste l'authentification chap fournie + # password et challenge doivent être données + # en hexa (avec ou sans le 0x devant) + # le User-Name est en réalité la mac ( xx:xx:xx:xx:xx ) + password = data.get('CHAP-Password', '') + challenge = data.get('CHAP-Challenge', '') + mac = data.get('User-Name', '') + + logger.debug('(fil) authorize(%r)' % ((password, challenge, mac),)) + + try: + challenge = binascii.a2b_hex(challenge.replace('0x','')) + password = binascii.a2b_hex(password.replace('0x','')) + if hashlib.md5(password[0] + mac + challenge).digest() == password[1:]: + logger.info("(fil) Chap ok") + chap_ok = True + else: + logger.info("(fil) Chap wrong") + except Exception as err: + logger.info("(fil) Chap challenge check failed with %r" % err) + + if not chap_ok: + if TEST_SERVER: + logger.debug('(fil) Continue auth (debug)') + else: + return radiusd.RLM_MODULE_REJECT + + return (radiusd.RLM_MODULE_UPDATED, + (), + ( + ("Auth-Type", "Accept"), + ), + ) + +@radius_event +def post_auth(data): + # On cherche quel est le type de machine, et quel sites lui appliquer + if data.get('NAS-Port-Type', '')==u'Ethernet': + return post_auth_fil(data) + elif u"Wireless" in data.get('NAS-Port-Type', ''): + return post_auth_wifi(data) + +@radius_event +def post_auth_wifi(data): + """Appelé une fois que l'authentification est ok. + On peut rajouter quelques éléments dans la réponse radius ici. + Comme par exemple le vlan sur lequel placer le client""" + + port, vlan_name, reason = decide_vlan(data, True) + mac = data.get('Calling-Station-Id', None) + + log_message = '(wifi) %s -> %s [%s%s]' % \ + (port, mac, vlan_name, (reason and u': ' + reason).encode('utf-8')) + logger.info(log_message) + + # Si NAS ayant des mapping particuliers, à signaler ici + vlan_id = config.vlans[vlan_name] + + # WiFi : Pour l'instant, on ne met pas d'infos de vlans dans la réponse + # les bornes wifi ont du mal avec cela + if WIFI_DYN_VLAN: + return (radiusd.RLM_MODULE_UPDATED, + ( + ("Tunnel-Type", "VLAN"), + ("Tunnel-Medium-Type", "IEEE-802"), + ("Tunnel-Private-Group-Id", '%d' % vlan_id), + ), + () + ) + + return radiusd.RLM_MODULE_OK + +@radius_event +def post_auth_fil(data): + """Idem, mais en filaire. + """ + + nas = data.get('NAS-Identifier', None) + port = data.get('NAS-Port', None) + mac = data.get('Calling-Station-Id', None) + out = subprocess.check_output(['/usr/bin/python3', '/var/www/re2o/freeradius_utils/authenticate_filaire.py', nas, port, mac]) + reason, vlan_id = make_tuple(out) + + log_message = '(fil) %s -> %s [%s%s]' % \ + (nas + u":" + port, mac, vlan_id, (reason and u': ' + reason).encode('utf-8')) + logger.info(log_message) + + # Filaire + return (radiusd.RLM_MODULE_UPDATED, + ( + ("Tunnel-Type", "VLAN"), + ("Tunnel-Medium-Type", "IEEE-802"), + ("Tunnel-Private-Group-Id", '%d' % int(vlan_id)), + ), + () + ) + +@radius_event +def dummy_fun(_): + """Do nothing, successfully. (C'est pour avoir un truc à mettre)""" + return radiusd.RLM_MODULE_OK + +def detach(_=None): + """Appelé lors du déchargement du module (enfin, normalement)""" + print "*** goodbye from auth.py ***" + return radiusd.RLM_MODULE_OK + diff --git a/freeradius_utils/modules/rlm_python_re2o.conf b/freeradius_utils/modules/rlm_python_re2o.conf new file mode 120000 index 00000000..5bd5d2ad --- /dev/null +++ b/freeradius_utils/modules/rlm_python_re2o.conf @@ -0,0 +1 @@ +../rlm_python_re2o.conf \ No newline at end of file From e266a854624dfe779e6383aabed86573fd22513e Mon Sep 17 00:00:00 2001 From: Gabriel Detraz Date: Sun, 11 Dec 2016 01:28:34 +0100 Subject: [PATCH 3/3] Corrige auth.py pour la partie authorize filaire et user --- freeradius_utils/auth.py | 109 +++++++---------------- freeradius_utils/authenticate_filaire.py | 63 +++++++------ freeradius_utils/authenticate_user.py | 37 ++++++++ 3 files changed, 104 insertions(+), 105 deletions(-) create mode 100755 freeradius_utils/authenticate_user.py diff --git a/freeradius_utils/auth.py b/freeradius_utils/auth.py index e6e612d3..0c441e40 100644 --- a/freeradius_utils/auth.py +++ b/freeradius_utils/auth.py @@ -95,56 +95,37 @@ def authorize(data): """Fonction qui aiguille entre nas, wifi et filaire pour authorize On se contecte de faire une verification basique de ce que contien la requète pour déterminer la fonction à utiliser""" - if data.get('NAS-Port-Type', '')==u'Ethernet': + if data.get('Service-Type', '')==u'NAS-Prompt-User' or data.get('Service-Type', '')==u'Administrative-User': + return authorize_user(data) + else: return authorize_fil(data) - elif u"Wireless" in data.get('NAS-Port-Type', ''): - return authorize_wifi(data) @radius_event -def authorize_wifi(data): - """Section authorize pour le wifi - (NB: le filaire est en accept pour tout le monde) - Éxécuté avant l'authentification proprement dite. On peut ainsi remplir les - champs login et mot de passe qui serviront ensuite à l'authentification - (MschapV2/PEAP ou MschapV2/TTLS)""" +def authorize_user(data): + nas = data.get('NAS-IP-Address', None) + nas_id = data.get('NAS-Identifier', None) + user = data.get('User-Name', None) + password = data.get('User-Password', None) + out = subprocess.check_output(['/usr/bin/python3', '/var/www/re2o/freeradius_utils/authenticate_user.py', user, password]) + if out[:-1] == u"TRUE": + if data.get('Service-Type', '')==u'NAS-Prompt-User': + logger.info(u"Access of user %s on %s (%s)" % (user, nas, nas_id)) + elif data.get('Service-Type', '')==u'Administrative-User': + logger.info(u"Enable manager for %s on %s (%s)" % (user, nas, nas_id)) + return (radiusd.RLM_MODULE_UPDATED, + (), + ( + ("Auth-Type", "Accept"), + ), + ) + else: + return (radiusd.RLM_MODULE_UPDATED, + (), + ( + ("Auth-Type", "Reject"), + ), + ) - items = get_machines(data) - - if not items: - logger.error('Nothing found') - return radiusd.RLM_MODULE_NOTFOUND - - if len(items) > 1: - logger.warn('lc_ldap: Too many results (taking first)') - - machine = items[0] - - proprio = machine.proprio() - if isinstance(proprio, lc_ldap.objets.AssociationCrans): - logger.error('Crans machine trying to authenticate !') - return radiusd.RLM_MODULE_INVALID - - for bl in machine.blacklist_actif(): - if bl.value['type'] in BL_REJECT: - return radiusd.RLM_MODULE_REJECT - # Kludge : vlan isolement pas possible, donc reject quand-même - if not WIFI_DYN_VLAN and bl.value['type'] in BL_ISOLEMENT: - return radiusd.RLM_MODULE_REJECT - - - if not machine.get('ipsec', False): - logger.error('WiFi auth but machine has no password') - return radiusd.RLM_MODULE_REJECT - - password = machine['ipsec'][0].value.encode('ascii', 'ignore') - - # TODO: feed cert here - return (radiusd.RLM_MODULE_UPDATED, - (), - ( - ("Cleartext-Password", password), - ), - ) @radius_event def authorize_fil(data): @@ -152,34 +133,6 @@ def authorize_fil(data): Check le challenge chap, et accepte. """ - chap_ok = False - # Teste l'authentification chap fournie - # password et challenge doivent être données - # en hexa (avec ou sans le 0x devant) - # le User-Name est en réalité la mac ( xx:xx:xx:xx:xx ) - password = data.get('CHAP-Password', '') - challenge = data.get('CHAP-Challenge', '') - mac = data.get('User-Name', '') - - logger.debug('(fil) authorize(%r)' % ((password, challenge, mac),)) - - try: - challenge = binascii.a2b_hex(challenge.replace('0x','')) - password = binascii.a2b_hex(password.replace('0x','')) - if hashlib.md5(password[0] + mac + challenge).digest() == password[1:]: - logger.info("(fil) Chap ok") - chap_ok = True - else: - logger.info("(fil) Chap wrong") - except Exception as err: - logger.info("(fil) Chap challenge check failed with %r" % err) - - if not chap_ok: - if TEST_SERVER: - logger.debug('(fil) Continue auth (debug)') - else: - return radiusd.RLM_MODULE_REJECT - return (radiusd.RLM_MODULE_UPDATED, (), ( @@ -230,14 +183,16 @@ def post_auth_fil(data): """Idem, mais en filaire. """ - nas = data.get('NAS-Identifier', None) + nas = data.get('NAS-IP-Address', None) port = data.get('NAS-Port', None) mac = data.get('Calling-Station-Id', None) + # Hack, à cause d'une numérotation cisco baroque + port = port[-2:] out = subprocess.check_output(['/usr/bin/python3', '/var/www/re2o/freeradius_utils/authenticate_filaire.py', nas, port, mac]) - reason, vlan_id = make_tuple(out) + sw_name, reason, vlan_id = make_tuple(out) log_message = '(fil) %s -> %s [%s%s]' % \ - (nas + u":" + port, mac, vlan_id, (reason and u': ' + reason).encode('utf-8')) + (sw_name + u":" + port, mac, vlan_id, (reason and u': ' + reason).encode('utf-8')) logger.info(log_message) # Filaire diff --git a/freeradius_utils/authenticate_filaire.py b/freeradius_utils/authenticate_filaire.py index b0cc4fb5..3640240a 100755 --- a/freeradius_utils/authenticate_filaire.py +++ b/freeradius_utils/authenticate_filaire.py @@ -14,7 +14,7 @@ application = get_wsgi_application() import argparse -from machines.models import Interface +from machines.models import Interface, IpList from topologie.models import Room, Port, Switch from users.models import User @@ -23,53 +23,60 @@ from re2o.settings import RADIUS_VLAN_DECISION VLAN_NOK = RADIUS_VLAN_DECISION['VLAN_NOK'] VLAN_OK = RADIUS_VLAN_DECISION['VLAN_OK'] -def decide_vlan(switch_name, port_number, mac_address): +def decide_vlan(switch_ip, port_number, mac_address): # Get port from switch and port number - switch = Switch.objects.filter(switch_interface=Interface.objects.filter(dns=switch_name)) + switch = Switch.objects.filter(switch_interface=Interface.objects.filter(ipv4=IpList.objects.filter(ipv4=switch_ip))) if switch: + sw_name = str(switch[0].switch_interface) port = Port.objects.filter(switch=switch[0], port=port_number) if port: port = port[0] if port.radius == 'NO': # Aucune authentification sur ce port - decision = ("Pas d'authentification sur ce port", VLAN_OK) + decision = (sw_name, "Pas d'authentification sur ce port", VLAN_OK) elif port.radius == 'BLOQ': - # Prise désactivée - decision = ('Port desactive', VLAN_NOK) - elif port.radius == 'COMMON' or port.radius == 'STRICT': + # Prise désactivée + decision = (sw_name, 'Port desactive', VLAN_NOK) + elif port.radius == 'COMMON': # Authentification par mac interface = Interface.objects.filter(mac_address=mac_address) if not interface: - decision = ('Mac not found', VLAN_NOK) - elif interface[0].is_active(): - # Verification de la prise - if port.radius == 'STRICT': - if port.room: - user = User.objects.filter(room=Room.objects.filter(name=port.room)) - if not user: - decision = ('Chambre non cotisante', VLAN_NOK) - elif user[0].has_access(): - decision = ('Machine OK, Proprio OK', VLAN_OK) - else: - decision = ('Chambre inconnue', VLAN_NOK) - else: - # Mode COMMON - decision = ('Machine OK', VLAN_OK) + decision = (sw_name, 'Mac not found', VLAN_NOK) + elif not interface[0].is_active(): + decision = (sw_name, 'Machine non active / adherent non cotisant', VLAN_NOK) else: - decision = ('Machine non active / adherent non cotisant', VLAN_NOK) + decision = (sw_name, 'Machine OK', VLAN_OK) + elif port.radius == 'STRICT': + if port.room: + user = User.objects.filter(room=Room.objects.filter(name=port.room)) + if not user: + decision = (sw_name, 'Chambre non cotisante', VLAN_NOK) + elif not user[0].has_access(): + decision = (sw_name, 'Resident desactive', VLAN_NOK) + else: + # Verification de la mac + interface = Interface.objects.filter(mac_address=mac_address) + if not interface: + decision = (sw_name, 'Chambre Ok, but mac not found', VLAN_NOK) + elif not interface[0].is_active(): + decision = (sw_name, 'Chambre Ok, but machine non active / adherent non cotisant', VLAN_NOK) + else: + decision = (sw_name, 'Machine OK, Proprio OK', VLAN_OK) + else: + decision = (sw_name, 'Chambre inconnue', VLAN_NOK) else: - decision = ('VLAN forced', int(port.radius)) + decision = (sw_name, 'VLAN forced', int(port.radius)) else: - decision = ('port not found!', VLAN_OK) + decision = (sw_name, 'port not found!', VLAN_OK) else: - decision = ('switch not found!', VLAN_OK) + decision = ('?', 'switch not found!', VLAN_OK) return decision if __name__ == '__main__': parser = argparse.ArgumentParser(description='Decide radius vlan attribution') - parser.add_argument('switch_name', action="store") + parser.add_argument('switch_ip', action="store") parser.add_argument('port_number', action="store", type=int) parser.add_argument('mac_address', action="store") args = parser.parse_args() - print(decide_vlan(args.switch_name, args.port_number, args.mac_address)) + print(decide_vlan(args.switch_ip, args.port_number, args.mac_address)) diff --git a/freeradius_utils/authenticate_user.py b/freeradius_utils/authenticate_user.py new file mode 100755 index 00000000..933ba9a3 --- /dev/null +++ b/freeradius_utils/authenticate_user.py @@ -0,0 +1,37 @@ +import os, sys + +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. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + +import argparse + +from django.contrib.auth import authenticate +from users.models import User + + +def authorize_user(user, password): + user = authenticate(username=user, password=password) + if user: + if User.objects.get(pseudo=user): + return "TRUE" + else: + return "FALSE" + else: + return "FALSE" + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Authorize user') + parser.add_argument('user', action="store") + parser.add_argument('password', action="store") + args = parser.parse_args() + print(authorize_user(args.user, args.password)) +