Commit 99cbd1bf authored by Baptiste Jonglez's avatar Baptiste Jonglez Committed by jocelyn

Implement a Radius/LDAP-based DSL module

This implements the model, admin, and LDAP export.  Still no user
interface.
parent 0227df9a
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from polymorphic.admin import PolymorphicChildModelAdmin
from .models import RadiusGroup, DSLConfiguration
from coin.configuration.admin import ConfigurationAdminFormMixin
from coin.utils import delete_selected
class DSLConfigurationInline(admin.StackedInline):
model = DSLConfiguration
fields = ('offersubscription', 'phone_number', 'activated', 'radius_group', 'login', 'password')
readonly_fields = ['configuration_ptr', 'login']
class DSLConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAdmin):
base_model = DSLConfiguration
list_display = ('offersubscription', 'activated', 'full_login',
'radius_group')
list_filter = ('activated', 'radius_group')
search_fields = ('login',
# TODO: searching on member directly doesn't work
'offersubscription__member__first_name',
'offersubscription__member__last_name',
'offersubscription__member__email')
actions = (delete_selected, "activate", "deactivate")
fields = ('offersubscription', 'phone_number', 'activated', 'radius_group',
'login', 'password')
inline = DSLConfigurationInline
def get_readonly_fields(self, request, obj=None):
if obj and obj.login != "":
return ['login',]
else:
return []
def set_activation(self, request, queryset, value):
count = 0
# We must update each object individually, because we want to run
# the save() method to update the backend.
for vpn in queryset:
if vpn.activated != value:
vpn.activated = value
vpn.full_clean()
vpn.save()
count += 1
action = "activated" if value else "deactivated"
msg = "{} DSL line(s) {}.".format(count, action)
self.message_user(request, msg)
def activate(self, request, queryset):
self.set_activation(request, queryset, True)
activate.short_description = "Activate selected DSL lines"
def deactivate(self, request, queryset):
self.set_activation(request, queryset, False)
deactivate.short_description = "Deactivate selected DSL lines"
admin.site.register(RadiusGroup,)
admin.site.register(DSLConfiguration, DSLConfigurationAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
import ldapdb.models
from ldapdb.models.fields import CharField, ListField
from coin.mixins import CoinLdapSyncMixin
from coin.configuration.models import Configuration
from coin import validation, utils
# Implementationo of a simple Radius backend for DSL, stored in LDAP.
# Settings
# - LDAP branch
class RadiusGroup(models.Model):
"""This implements a notion of "Radius group", which is mostly useful to
support multiple backhaul operators (i.e. each object provides
settings for a specific backhaul operator).
For now, it only changes the realm of the login stored in LDAP, but it
might also be used in the future to implement different Radius profile
for each group (different L2TP server, etc). One possible usage could
be to load-balance users (possibly on the same backhaul
infrastracture) to different L2TP servers by placing them in different
Radius groups.
"""
name = models.CharField(max_length=256, verbose_name=_("group name"))
realm = models.CharField(max_length=50,
verbose_name=_("radius realm"),
help_text=_('Example: "fdn.nerim"'))
suffix = models.CharField(max_length=50, blank=True,
verbose_name=_("suffix"),
help_text=_('Optional suffix added to the login, as a kind of "sub-realm". Example: "%gnd"'))
comment = models.CharField(max_length=256, blank=True,
verbose_name=_("comment"))
def __unicode__(self):
return "{} ({}@{})".format(self.name, self.suffix, self.realm)
class Meta:
verbose_name = _("radius group")
verbose_name_plural = _("radius groups")
class DSLConfiguration(CoinLdapSyncMixin, Configuration):
"""TODO: make sure that the (full) login never changes, because it's a
LDAP primary key. Or at least delete the old object and create a new
one when the login changes.
"""
url_namespace = "dsl_ldap"
phone_number = models.CharField(max_length=20,
verbose_name=_('phone number'),
help_text=_("Phone number associated to the DSL line"))
activated = models.BooleanField(default=False, verbose_name=_('activated'))
radius_group = models.ForeignKey(RadiusGroup, verbose_name=_("radius group"),
help_text=_("Group (i.e. backhaul) to use"))
# TODO: introduce forbidden characters (and read RFC2865)
login = models.CharField(max_length=50, unique=True, blank=True,
verbose_name=_("login"),
help_text=_("Leave empty for automatic generation"))
password = models.CharField(max_length=256, blank=True,
verbose_name=_("password"),
help_text=_("Will be stored in cleartext! Automatically generated if empty"))
def full_login(self):
"""Login with realm"""
return "{}{}@{}".format(self.login, self.radius_group.suffix,
self.radius_group.realm)
def clean(self):
# Generate DSL login, of the form "login-dslX".
if not self.login:
username = self.offersubscription.member.username
dsl_lines = DSLConfiguration.objects.filter(offersubscription__member__username=username)
# This is the list of existing DSL logins for this user.
logins = [dsl.login for dsl in dsl_lines]
# 100 DSL lines ought to be enough for anybody.
for login in ["{}-dsl{}".format(username, k) for k in range(1, 101)]:
if login not in logins:
self.login = login
break
# We may have failed.
if not self.login:
ValidationError("Impossible de générer un login DSL")
# Generate password: 8 lowercase letters. We don't really care
# about security, since 1/ we store it in cleartext 2/ to
# bruteforce it, you must have a DSL line on the same backhaul
# infrastructure. On the other hand, it must be easy to copy it
# by hand to configure a modem.
if not self.password:
self.password = utils.generate_weak_password(8)
# This method is part of the general configuration interface.
def subnet_event(self):
self.sync_to_ldap(False)
def get_subnets(self, version):
subnets = self.ip_subnet.all()
return [subnet for subnet in subnets if subnet.inet.version == version]
def sync_to_ldap(self, creation, *args, **kwargs):
if creation:
config = LdapDSLConfig()
else:
config = LdapDSLConfig.objects.get(pk=self.full_login())
config.login = self.full_login()
config.cleartext_password = self.password
config.password = utils.ldap_hash(self.password)
config.active = 'yes' if self.activated else 'no'
v4_subnets = self.get_subnets(4)
v6_subnets = self.get_subnets(6)
# Instead of having an explicit IPv4 endpoint in the model, we
# simply take the first IPv4 subnet (which we assume is a /32)
if len(v4_subnets) > 0:
config.ipv4_endpoint = str(v4_subnets[0].inet.ip)
else:
config.ipv4_endpoint = None
config.ranges_v4 = [str(s) for s in v4_subnets]
if len(v6_subnets) > 0:
config.ranges_v6 = [str(s) for s in v6_subnets]
else:
# This field is multi-valued, but mandatory... Hack hack hack.
config.ranges_v6 = ["fd20:fd79:5eb6:5cc5::/64"]
config.save()
def delete_from_ldap(self):
LdapDSLConfig.objects.get(pk=self.full_login()).delete()
def __unicode__(self):
return self.full_login()
class Meta:
verbose_name = _("DSL line")
verbose_name_plural = _("DSL lines")
class LdapDSLConfig(ldapdb.models.Model):
base_dn = settings.DSL_CONF_BASE_DN # "ou=radius,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
object_classes = [b'top', b'radiusObjectProfile', b'radiusprofile', b'ipHost']
login = CharField(db_column=b'cn', primary_key=True, max_length=255)
password = CharField(db_column=b'userPassword', max_length=255)
cleartext_password = CharField(db_column=b'description', max_length=255)
active = CharField(db_column=b'dialupAccess', max_length=3)
ipv4_endpoint = CharField(db_column=b'radiusFramedIPAddress', max_length=16)
ranges_v4 = ListField(db_column=b'radiusFramedRoute')
# This field is multi-valued, but mandatory...
ranges_v6 = ListField(db_column=b'ipHostNumber')
def __unicode__(self):
return self.login
class Meta:
managed = False # Indique à South de ne pas gérer le model LdapUser
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase
# Create your tests here.
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import render
# Create your views here.
......@@ -269,6 +269,7 @@ LDAP_ACTIVATE = False
# Not setting them results in NameError
LDAP_USER_BASE_DN = None
VPN_CONF_BASE_DN = None
DSL_CONF_BASE_DN = None
# Allow member to edit their vpn
MEMBER_CAN_EDIT_VPN_CONF = True
......
......@@ -2,6 +2,7 @@
from __future__ import unicode_literals
import os
import random
import hashlib
import binascii
import base64
......@@ -188,6 +189,12 @@ def postgresql_regexp(regexp):
return re.sub(
r'\?P<.*?>', '', original_pattern)
def generate_weak_password(length):
"""Generates a weak password of the given length. It only contains
lowercase letters in the [a-z] range. Don't use this if you have
serious security requirements...
"""
return "".join(["%c" % random.randrange(0x61, 0x7B) for i in range(length)])
if __name__ == '__main__':
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment