diff --git a/api/permissions.py b/api/permissions.py index 8e3bd2d4..4061d7d7 100644 --- a/api/permissions.py +++ b/api/permissions.py @@ -63,7 +63,7 @@ def _get_param_in_view(view, param_name): "cannot apply {} on a view that does not set " "`.{}` or have a `.get_{}()` method." ).format( - self.__class__.__name__, param_name, param_name + view.__class__.__name__, param_name, param_name ) if hasattr(view, "get_" + param_name): diff --git a/freeradius_utils/auth.py b/freeradius_utils/auth.py index 00c3e774..9c1fd295 100644 --- a/freeradius_utils/auth.py +++ b/freeradius_utils/auth.py @@ -185,6 +185,11 @@ def post_auth(data): instance_stack = nas_machine.switch.stack if instance_stack: # If it is a stack, we select the correct switch in the stack + # + # For Juniper, the result looks something like this: NAS-Port-Id = "ge-0/0/6.0"" + # For other brands (e.g. HP or Mikrotik), the result usually looks like: NAS-Port-Id = "6.0" + # This "magic split" handles both cases + # Cisco can rot in Hell for all I care, so their format is not supported (it looks like NAS-Port-ID = atm 31/31/7:255.65535 guangzhou001/0/31/63/31/127) id_stack_member = port.split("-")[1].split("/")[0] nas_machine = ( Switch.objects.filter(stack=instance_stack) @@ -192,8 +197,8 @@ def post_auth(data): .prefetch_related("interface_set__domain__extension") .first() ) - # Find the port number from freeradius, works both with HP, Cisco - # and juniper output + # Find the port number from freeradius + # See above for details about this "magic split" port = port.split(".")[0].split("/")[-1][-2:] out = decide_vlan_switch(nas_machine, nas_type, port, mac) sw_name, room, reason, vlan_id, decision, attributes = out diff --git a/install_utils/apache2/re2o-tls.conf b/install_utils/apache2/re2o-tls.conf index eb8f2c42..b9ae3741 100644 --- a/install_utils/apache2/re2o-tls.conf +++ b/install_utils/apache2/re2o-tls.conf @@ -32,4 +32,6 @@ SSLCertificateKeyFile /etc/letsencrypt/live/LE_PATH/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf + AllowEncodedSlashes On + diff --git a/install_utils/apache2/re2o.conf b/install_utils/apache2/re2o.conf index 1b4e02b3..ed2bbf4c 100644 --- a/install_utils/apache2/re2o.conf +++ b/install_utils/apache2/re2o.conf @@ -21,4 +21,6 @@ WSGIDaemonProcess re2o processes=2 threads=16 maximum-requests=1000 display-name=re2o WSGIPassAuthorization On + AllowEncodedSlashes On + diff --git a/radius/__init__.py b/radius/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/radius/api/__init__.py b/radius/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/radius/api/serializers.py b/radius/api/serializers.py new file mode 100644 index 00000000..febca280 --- /dev/null +++ b/radius/api/serializers.py @@ -0,0 +1,130 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2020 Corentin Canebier +# +# 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. + +from rest_framework import serializers + +import machines.models as machines +import users.models as users +from api.serializers import NamespacedHMSerializer +from rest_framework.serializers import Serializer + + +class Ipv4Serializer(Serializer): + ipv4 = serializers.CharField() + + +class InterfaceSerializer(Serializer): + mac_address = serializers.CharField() + ipv4 = Ipv4Serializer() + active = serializers.BooleanField(source="is_active") + user_pk = serializers.CharField(source="machine.user.pk") + machine_short_name = serializers.CharField(source="machine.short_name") + is_ban = serializers.BooleanField(source="machine.user.is_ban") + vlan_id = serializers.IntegerField(source="machine_type.ip_type.vlan.vlan_id") + + +class NasSerializer(Serializer): + port_access_mode = serializers.CharField() + autocapture_mac = serializers.BooleanField() + + +class UserSerializer(Serializer): + access = serializers.BooleanField(source="has_access") + pk = serializers.CharField() + pwd_ntlm = serializers.CharField() + state = serializers.CharField() + email_state = serializers.IntegerField() + is_ban = serializers.BooleanField() + is_connected = serializers.BooleanField() + is_whitelisted = serializers.BooleanField() + + +class PortSerializer(Serializer): + state = serializers.BooleanField() + room = serializers.CharField() + + +class VlanSerializer(Serializer): + vlan_id = serializers.IntegerField() + + +class PortProfileSerializer(Serializer): + vlan_untagged = VlanSerializer() + radius_type = serializers.CharField() + radius_mode = serializers.CharField() + + +class SwitchSerializer(Serializer): + name = serializers.CharField(source="short_name") + ipv4 = serializers.CharField() + + +class RadiusAttributeSerializer(Serializer): + attribute = serializers.CharField() + value = serializers.CharField() + + +class RadiusOptionSerializer(Serializer): + radius_general_policy = serializers.CharField() + unknown_machine = serializers.CharField() + unknown_machine_vlan = VlanSerializer() + unknown_machine_attributes = RadiusAttributeSerializer(many=True) + unknown_port = serializers.CharField() + unknown_port_vlan = VlanSerializer() + unknown_port_attributes = RadiusAttributeSerializer(many=True) + unknown_room = serializers.CharField() + unknown_room_vlan = VlanSerializer() + unknown_room_attributes = RadiusAttributeSerializer(many=True) + non_member = serializers.CharField() + non_member_vlan = VlanSerializer() + non_member_attributes = RadiusAttributeSerializer(many=True) + banned = serializers.CharField() + banned_vlan = VlanSerializer() + banned_attributes = RadiusAttributeSerializer(many=True) + vlan_decision_ok = VlanSerializer() + ok_attributes = RadiusAttributeSerializer(many=True) + + +class AuthorizeResponseSerializer(Serializer): + """Serializer for AuthorizeResponse objects + See views.py for the declaration of AuthorizeResponse + """ + + nas = NasSerializer(read_only=True) + user = UserSerializer(read_only=True) + user_interface = InterfaceSerializer(read_only=True) + + +class PostAuthResponseSerializer(Serializer): + """Serializer for PostAuthResponse objects + See views.py for the declaration of PostAuthResponse + """ + + nas = NasSerializer(read_only=True) + room_users = UserSerializer(many=True) + port = PortSerializer() + port_profile = PortProfileSerializer(partial=True) + switch = SwitchSerializer() + user_interface = InterfaceSerializer() + radius_option = RadiusOptionSerializer() + EMAIL_STATE_UNVERIFIED = serializers.IntegerField() + RADIUS_OPTION_REJECT = serializers.CharField() + USER_STATE_ACTIVE = serializers.CharField() diff --git a/radius/api/urls.py b/radius/api/urls.py new file mode 100644 index 00000000..2ab8ec91 --- /dev/null +++ b/radius/api/urls.py @@ -0,0 +1,41 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2020 Corentin Canebier +# +# 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. + +from . import views + +urls_functional_view = [ + ( + r"radius/authorize/(?P[^/]+)/(?P.+)/(?P[^/]{17})$", + views.authorize, + None, + ), + ( + r"radius/post_auth/(?P[^/]+)/(?P.+)/(?P[^/]{17})$", + views.post_auth, + None, + ), + ( + r"radius/autoregister/(?P[^/]+)/(?P.+)/(?P[^/]{17})$", + views.autoregister_machine, + None, + ), + (r"radius/assign_ip/(?P[0-9a-fA-F\:\-]{17})$", views.assign_ip, None), +] diff --git a/radius/api/views.py b/radius/api/views.py new file mode 100644 index 00000000..ba490b04 --- /dev/null +++ b/radius/api/views.py @@ -0,0 +1,266 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2020 Corentin Canebier +# +# 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. + +from rest_framework.decorators import api_view +from rest_framework.response import Response +from django.db.models import Q +from django.http import HttpResponse +from django.forms import ValidationError +from django.contrib.auth.decorators import login_required + +from . import serializers +from machines.models import Domain, IpList, Interface, Nas, Machine +from users.models import User +from preferences.models import RadiusOption +from topologie.models import Port, Switch +from re2o.acl import can_view_all_api, can_edit_all_api, can_create_api + + +class AuthorizeResponse: + """Contains objects the radius needs for the Authorize step""" + + def __init__(self, nas, user, user_interface): + self.nas = nas + self.user = user + self.user_interface = user_interface + + def can_view(self, user): + """Method to bypass api permissions, because we are using ACL decorators""" + return (True, None, None) + + +@api_view(["GET"]) +@login_required +@can_view_all_api(Interface, Domain, IpList, Nas, User) +def authorize(request, nas_id, username, mac_address): + """Return objects the radius needs for the Authorize step + + Parameters: + nas_id (string): NAS name or ipv4 + username (string): username of the user who is trying to connect + mac_address (string): mac address of the device which is trying to connect + + Return: + AuthorizeResponse: contains all required informations + """ + + # get the Nas object which made the request (if exists) + nas_interface = Interface.objects.filter( + Q(domain__name=nas_id) | Q(ipv4__ipv4=nas_id) + ).first() + nas_type = None + if nas_interface: + nas_type = Nas.objects.filter(nas_type=nas_interface.machine_type).first() + + # get the User corresponding to the username in the URL + # If no username was provided (wired connection), username="None" + user = User.objects.filter(pseudo__iexact=username).first() + + # get the interface which is trying to connect (if already created) + user_interface = Interface.objects.filter(mac_address=mac_address).first() + + serialized = serializers.AuthorizeResponseSerializer( + AuthorizeResponse(nas_type, user, user_interface) + ) + + return Response(data=serialized.data) + + +class PostAuthResponse: + """Contains objects the radius needs for the Post-Auth step""" + + def __init__( + self, + nas, + room_users, + port, + port_profile, + switch, + user_interface, + radius_option, + EMAIL_STATE_UNVERIFIED, + RADIUS_OPTION_REJECT, + USER_STATE_ACTIVE, + ): + self.nas = nas + self.room_users = room_users + self.port = port + self.port_profile = port_profile + self.switch = switch + self.user_interface = user_interface + self.radius_option = radius_option + self.EMAIL_STATE_UNVERIFIED = EMAIL_STATE_UNVERIFIED + self.RADIUS_OPTION_REJECT = RADIUS_OPTION_REJECT + self.USER_STATE_ACTIVE = USER_STATE_ACTIVE + + def can_view(self, user): + """Method to bypass api permissions, because we are using ACL decorators""" + return (True, None, None) + + +@api_view(["GET"]) +@login_required +@can_view_all_api(Interface, Domain, IpList, Nas, Switch, Port, User) +def post_auth(request, nas_id, nas_port, user_mac): + """Return objects the radius needs for the Post-Auth step + + Parameters: + nas_id (string): NAS name or ipv4 + nas_port (string): NAS port from wich the request came. Work with Cisco, HP and Juniper convention + user_mac (string): mac address of the device which is trying to connect + + Return: + PostAuthResponse: contains all required informations + """ + + # get the Nas object which made the request (if exists) + nas_interface = ( + Interface.objects.prefetch_related("machine__switch__stack") + .filter(Q(domain__name=nas_id) | Q(ipv4__ipv4=nas_id)) + .first() + ) + nas_type = None + if nas_interface: + nas_type = Nas.objects.filter(nas_type=nas_interface.machine_type).first() + + # get the switch (if wired connection) + switch = None + if nas_interface: + switch = Switch.objects.filter(machine_ptr=nas_interface.machine).first() + + # If the switch is part of a stack, get the correct object + if hasattr(nas_interface.machine, "switch"): + stack = nas_interface.machine.switch.stack + if stack: + # For Juniper, the result looks something like this: NAS-Port-Id = "ge-0/0/6.0"" + # For other brands (e.g. HP or Mikrotik), the result usually looks like: NAS-Port-Id = "6.0" + # This "magic split" handles both cases + # Cisco can rot in Hell for all I care, so their format is not supported (it looks like NAS-Port-ID = atm 31/31/7:255.65535 guangzhou001/0/31/63/31/127) + id_stack_member = nas_port.split("-")[1].split("/")[0] + switch = ( + Switch.objects.filter(stack=stack) + .filter(stack_member_id=id_stack_member) + .first() + ) + + # get the switch port + port = None + if nas_port and nas_port != "None": + # magic split (see above) + port_number = nas_port.split(".")[0].split("/")[-1][-2:] + port = Port.objects.filter(switch=switch, port=port_number).first() + + port_profile = None + if port: + port_profile = port.get_port_profile + + # get the interface which is trying to connect (if already created) + user_interface = ( + Interface.objects.filter(mac_address=user_mac) + .select_related("machine__user") + .select_related("ipv4") + .first() + ) + + # get all users and clubs of the room + room_users = [] + if port: + room_users = User.objects.filter( + Q(club__room=port.room) | Q(adherent__room=port.room) + ) + + # get all radius options + radius_option = RadiusOption.objects.first() + + # get a few class constants the radius will need + EMAIL_STATE_UNVERIFIED = User.EMAIL_STATE_UNVERIFIED + RADIUS_OPTION_REJECT = RadiusOption.REJECT + USER_STATE_ACTIVE = User.STATE_ACTIVE + + serialized = serializers.PostAuthResponseSerializer( + PostAuthResponse( + nas_type, + room_users, + port, + port_profile, + switch, + user_interface, + radius_option, + EMAIL_STATE_UNVERIFIED, + RADIUS_OPTION_REJECT, + USER_STATE_ACTIVE, + ) + ) + + return Response(data=serialized.data) + + +@api_view(["GET"]) +@login_required +@can_view_all_api(Interface, Domain, IpList, Nas, User) +@can_edit_all_api(User, Domain, Machine, Interface) +def autoregister_machine(request, nas_id, username, mac_address): + """Autoregister machine in the Authorize step of the radius + + Parameters: + nas_id (string): NAS name or ipv4 + username (string): username of the user who is trying to connect + mac_address (string): mac address of the device which is trying to connect + + Return: + 200 if autoregistering worked + 400 if it failed, and the reason why + """ + nas_interface = Interface.objects.filter( + Q(domain__name=nas_id) | Q(ipv4__ipv4=nas_id) + ).first() + nas_type = None + if nas_interface: + nas_type = Nas.objects.filter(nas_type=nas_interface.machine_type).first() + + user = User.objects.filter(pseudo__iexact=username).first() + + result, reason = user.autoregister_machine(mac_address, nas_type) + if result: + return Response(reason) + return Response(reason, status=400) + + +@api_view(["GET"]) +@can_view_all_api(Interface) +@can_edit_all_api(Interface) +def assign_ip(request, mac_address): + """Autoassign ip in the Authorize and Post-Auth steps of the Radius + + Parameters: + mac_address (string): mac address of the device which is trying to connect + + Return: + 200 if it worked + 400 if it failed, and the reason why + """ + interface = Interface.objects.filter(mac_address=mac_address).first() + + try: + interface.assign_ipv4() + return Response() + except ValidationError as err: + return Response(err.message, status=400) diff --git a/radius/urls.py b/radius/urls.py new file mode 100644 index 00000000..c412a679 --- /dev/null +++ b/radius/urls.py @@ -0,0 +1,26 @@ +# -*- mode: python; coding: utf-8 -*- +# Re2o est un logiciel d'administration développé initiallement au Rézo Metz. Il +# se veut agnostique au réseau considéré, de manière à être installable en +# quelques clics. +# +# Copyright © 2021 Corentin Canebier +# +# 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. + +from . import views + +urlpatterns = [] + +app_name = "radius" diff --git a/radius/views.py b/radius/views.py new file mode 100644 index 00000000..e69de29b diff --git a/re2o/acl.py b/re2o/acl.py index d1d2c3a9..72633256 100644 --- a/re2o/acl.py +++ b/re2o/acl.py @@ -6,6 +6,7 @@ # Copyright © 2017 Gabriel Détraz # Copyright © 2017 Lara Kermarec # Copyright © 2017 Augustin Lemesle +# Copyright © 2020 Corentin Canebier # # 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 diff --git a/re2o/mixins.py b/re2o/mixins.py index ddc827b6..e0ce3310 100644 --- a/re2o/mixins.py +++ b/re2o/mixins.py @@ -5,6 +5,7 @@ # # Copyright © 2018 Gabriel Détraz # Copyright © 2017 Charlie Jacomme +# Copyright © 2020 Corentin Canebier # # 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