Commit 3b151b9b authored by jocelyn's avatar jocelyn

Merge branch 'illyse-return' into 'master'

Intégration de l'app dsl_ldap de illyse

Closes #207, #208, and #209

See merge request ffdn/coin!2
parents 0227df9a 72fad952
......@@ -344,9 +344,10 @@ Using optional apps
Some apps are not enabled by default :
- *vpn*: Management of OpenVPN subscription and credentials through LDAP
- *vpn*: Management of OpenVPN subscription and credentials through LDAP (used by [Illyse]( and [ARN](
- *simple_dsl*: Simple DSL subscriptions, without handling
any authentication backend or user configuration ("marque blanche")
- *dsl_ldap*: A DSL service which provisions LDAP for radius use (used by [Illyse](
- *hardware_provisioning* : Self-service app to manage hardware inventory,
hardware lent to members or in different stock sites.
- *maillists* : Self-service mailling-list (un)subscription for members:
......@@ -10,10 +10,11 @@ from coin.configuration.models import Configuration
class ConfigurationForm(ModelForm):
comment = forms.CharField(widget=forms.Textarea)
class Meta:
model = Configuration
widgets = {
'comment': forms.Textarea(),
fields = '__all__'
def __init__(self, *args, **kwargs):
......@@ -529,27 +529,27 @@ class MembershipFee(models.Model):
class LdapUser(ldapdb.models.Model):
# "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
base_dn = settings.LDAP_USER_BASE_DN
object_classes = [b'inetOrgPerson', b'organizationalPerson', b'person',
b'top', b'posixAccount', b'ldapPublicKey']
object_classes = ['inetOrgPerson', 'organizationalPerson', 'person',
'top', 'posixAccount', 'ldapPublicKey']
uid = CharField(db_column=b'uid', unique=True, max_length=255)
nick_name = CharField(db_column=b'cn', unique=True, primary_key=True,
uid = CharField(db_column='uid', unique=True, max_length=255)
nick_name = CharField(db_column='cn', unique=True, primary_key=True,
first_name = CharField(db_column=b'givenName', max_length=255)
last_name = CharField(db_column=b'sn', max_length=255)
display_name = CharField(db_column=b'displayName', max_length=255,
first_name = CharField(db_column='givenName', max_length=255)
last_name = CharField(db_column='sn', max_length=255)
display_name = CharField(db_column='displayName', max_length=255,
password = CharField(db_column=b'userPassword', max_length=255)
uidNumber = IntegerField(db_column=b'uidNumber', unique=True)
gidNumber = IntegerField(db_column=b'gidNumber', default=2000)
password = CharField(db_column='userPassword', max_length=255)
uidNumber = IntegerField(db_column='uidNumber', unique=True)
gidNumber = IntegerField(db_column='gidNumber', default=2000)
# Used by Sympa for logging in.
mail = CharField(db_column=b'mail', max_length=255, blank=True,
mail = CharField(db_column='mail', max_length=255, blank=True,
homeDirectory = CharField(db_column=b'homeDirectory', max_length=255,
homeDirectory = CharField(db_column='homeDirectory', max_length=255,
loginShell = CharField(db_column=b'loginShell', max_length=255,
loginShell = CharField(db_column='loginShell', max_length=255,
sshPublicKey = ListField(db_column=b'sshPublicKey', default=[])
sshPublicKey = ListField(db_column='sshPublicKey', default=[])
def __unicode__(self):
return self.display_name
......@@ -561,11 +561,11 @@ class LdapUser(ldapdb.models.Model):
# class LdapGroup(ldapdb.models.Model):
# "ou=groups,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
# base_dn = settings.LDAP_GROUP_BASE_DN
# object_classes = [b'posixGroup']
# object_classes = ['posixGroup']
# gid = IntegerField(db_column=b'gidNumber', unique=True)
# name = CharField(db_column=b'cn', max_length=200, primary_key=True)
# members = ListField(db_column=b'memberUid')
# gid = IntegerField(db_column='gidNumber', unique=True)
# name = CharField(db_column='cn', max_length=200, primary_key=True)
# members = ListField(db_column='memberUid')
# def __unicode__(self):
# return
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from import BaseCommand, CommandError
from django.db.models import Q
from coin.offers.models import OfferSubscription
class Command(BaseCommand):
help = 'Returns the email addresses of all subscribers, in a format suitable for bulk importing in Sympa'
def handle(self, *args, **options):
emails = [ for s in OfferSubscription.objects.filter(Q( | Q(resign_date__isnull=True))]
# Use a set to ensure uniqueness
for email in set(emails):
......@@ -269,6 +269,7 @@ LDAP_ACTIVATE = False
# Not setting them results in NameError
# Allow member to edit their vpn
......@@ -3,6 +3,7 @@ from __future__ import unicode_literals
......@@ -3,7 +3,8 @@
Generaly, this is redundant, with fields content */
form .inline-group .inline-related td.original p, /* TabularInline */
form .inline-group .inline-related h3 { /* TabularStacked */
form .inline-group .inline-related h3 b,
form .inline-group .inline-related h3 .inline_label { /* TabularStacked */
display: none;
.inline-group .tabular tr.has_original td {
......@@ -157,6 +157,16 @@ tfoot, tr {
background: transparent;
.faketable {
font-size: 0.9em;
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
padding: 0.25em;
.hint {
font-size: 0.9em;
margin-top: -0.5em;
/* Specific table: Member personnal info */
......@@ -37,7 +37,6 @@ urlpatterns = patterns(
url(r'^members/', include('coin.members.urls', namespace='members')),
url(r'^billing/', include('coin.billing.urls', namespace='billing')),
url(r'^subscription/', include('coin.offers.urls', namespace='subscription')),
url(r'^admin/', include(,
url(r'^hijack/', include('hijack.urls', namespace='hijack')),
......@@ -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__':
default_app_config = 'dsl_ldap.apps.DSLLDAPConfig'
# -*- 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', 'comment')
list_filter = ('activated', 'radius_group')
search_fields = ('login',
# TODO: searching on member directly doesn't work
actions = (delete_selected, "activate", "deactivate")
fields = ('offersubscription', 'phone_number', 'comment',
'activated', 'radius_group', 'login', 'password')
inline = DSLConfigurationInline
def get_readonly_fields(self, request, obj=None):
if obj and obj.login != "":
return ['login',]
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
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",), DSLConfigurationAdmin)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.apps import AppConfig
import coin.apps
from . import urls
class DSLLDAPConfig(AppConfig, coin.apps.AppURLs):
name = 'dsl_ldap'
verbose_name = "xDSL (LDAP)"
exported_urlpatterns = [('dsl_ldap', urls.urlpatterns)]
"fields": {
"comment": "via Grenode",
"realm": "fdn.nerim",
"name": "Collecte FDN",
"suffix": "!ils%gnd"
"model": "dsl_ldap.radiusgroup",
"pk": 1
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import coin.mixins
import ldapdb.models.fields
class Migration(migrations.Migration):
dependencies = [
('configuration', '0003_configuration_comment'),
operations = [
('dn', models.CharField(max_length=200)),
('login', ldapdb.models.fields.CharField(max_length=255, serialize=False, primary_key=True, db_column=b'cn')),
('password', ldapdb.models.fields.CharField(max_length=255, db_column=b'userPassword')),
('cleartext_password', ldapdb.models.fields.CharField(max_length=255, db_column=b'description')),
('active', ldapdb.models.fields.CharField(max_length=3, db_column=b'dialupAccess')),
('ipv4_endpoint', ldapdb.models.fields.CharField(max_length=16, db_column=b'radiusFramedIPAddress')),
('ranges_v4', ldapdb.models.fields.ListField(db_column=b'radiusFramedRoute')),
('ranges_v6', ldapdb.models.fields.ListField(db_column=b'ipHostNumber')),
'managed': False,
('configuration_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='configuration.Configuration')),
('phone_number', models.CharField(help_text='Phone number associated to the DSL line', max_length=20, verbose_name='phone number')),
('activated', models.BooleanField(default=False, verbose_name='activated')),
('login', models.CharField(help_text='Leave empty for automatic generation', unique=True, max_length=50, verbose_name='login')),
('password', models.CharField(help_text='Will be stored in cleartext!', max_length=256, verbose_name='password')),
'verbose_name': 'DSL configuration',
'verbose_name_plural': 'DSL configurations',
bases=(coin.mixins.CoinLdapSyncMixin, 'configuration.configuration'),
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=256, verbose_name='group name')),
('realm', models.CharField(help_text='Example: "fdn.nerim"', max_length=50, verbose_name='radius realm')),
('suffix', models.CharField(help_text='Optional suffix added to the login, as a kind of "sub-realm". Example: "%gnd"', max_length=50, verbose_name='suffix', blank=True)),
('comment', models.CharField(max_length=256, verbose_name='comment', blank=True)),
'verbose_name': 'radius group',
'verbose_name_plural': 'radius groups',
field=models.ForeignKey(verbose_name='radius group', to='dsl_ldap.RadiusGroup', help_text='Group (i.e. backhaul) to use'),
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('dsl_ldap', '0001_initial'),
operations = [
options={'verbose_name': 'DSL line', 'verbose_name_plural': 'DSL lines'},
field=models.CharField(help_text='Leave empty for automatic generation', unique=True, max_length=50, verbose_name='login', blank=True),
field=models.CharField(help_text='Will be stored in cleartext! Automatically generated if empty', max_length=256, verbose_name='password', blank=True),
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models
from django.conf import settings
from netfields import InetAddressField, NetManager
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="nom du groupe")
realm = models.CharField(max_length=50,
verbose_name="realm radius",
help_text='Exemple : « fdn.nerim »')
suffix = models.CharField(max_length=50, blank=True,
help_text='Suffixe optionnel, ajouté après le login, comme un « sous-realm ». Exemple : "%gnd"')
comment = models.CharField(max_length=256, blank=True,
def __unicode__(self):
return "{} ({}@{})".format(, self.suffix, self.realm)
class Meta:
verbose_name = "groupe radius"
verbose_name_plural = "groupes radius"
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="numéro de téléphone",
help_text="numéro de téléphone associé à la ligne xDSL")
activated = models.BooleanField(default=False, verbose_name='actif')
radius_group = models.ForeignKey(RadiusGroup, verbose_name="groupe radius",
help_text="Groupe (ex : backhaul) à utiliser")
# TODO: introduce forbidden characters (and read RFC2865)
login = models.CharField(max_length=50, unique=True, blank=True,
help_text="Laisser vide pour auto-générer.")
password = models.CharField(max_length=256, blank=True,
verbose_name="mot de passe",
help_text="Est stocké en clair ! Auto-généré si laissé vide.")
objects = NetManager()
def full_login(self):
"""Login with realm"""
return "{}{}@{}".format(self.login, self.radius_group.suffix,
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
# 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):
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()
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) = '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)
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]
# This field is multi-valued, but mandatory... Hack hack hack.
config.ranges_v6 = ["fd20:fd79:5eb6:5cc5::/64"]
def delete_from_ldap(self):
def __unicode__(self):
return self.full_login()
class Meta:
verbose_name = "Ligne xDSL"
verbose_name_plural = "Lignes xDSL"
class LdapDSLConfig(ldapdb.models.Model):
base_dn = settings.DSL_CONF_BASE_DN # "ou=radius,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
object_classes = ['top', 'radiusObjectProfile', 'radiusprofile', 'ipHost']
login = CharField(db_column='cn', primary_key=True, max_length=255)
password = CharField(db_column='userPassword', max_length=255)
cleartext_password = CharField(db_column='description', max_length=255)
active = CharField(db_column='dialupAccess', max_length=3)
ipv4_endpoint = CharField(db_column='radiusFramedIPAddress', max_length=16)
ranges_v4 = ListField(db_column='radiusFramedRoute')
# This field is multi-valued, but mandatory...
ranges_v6 = ListField(db_column='ipHostNumber')
def __unicode__(self):
return self.login
class Meta:
managed = False # Indique à South de ne pas gérer le model LdapUser
{% extends "base.html" %}
{% load subnets %}
{% block content %}
<div class="row">
<h2>Configuration de l'ADSL</h2>
<form class="flatform" action="" method="post">{% csrf_token %}
<p class="legend">Quand vous aurez terminé vos modifications, cliquez sur <input class="button" type="submit" value="Valider" /></p>
{% for message in messages %}
<div class="message{% if message.tags %} {{ message.tags }}{% endif %}">
{{ message }}
{% endfor %}
{% if form.non_field_errors %}
<div class="alert-box alert nogap">
{{ form.non_field_errors }}
</div>{% endif %}
<div class="large-7 columns">
<div class="panel">
<table class="full-width">
<td class="center"><span class="label">Numéro de téléphone :</span></td>
<td>{{ object.phone_number }}</td>
<td class="center"><span class="label">Identifiant ADSL :</span></td>
<td>{{ object.full_login }}</td>
<td class="center boolviewer" colspan="2">
<input type="checkbox" disabled="disabled"{% if object.activated %} checked="checked"{% endif %} />
<span>Cette ligne est {{ object.activated|yesno:"activée,désactivée" }}</span>
<tr class="flatfield">
<td class="center">{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
{{ form.password.errors }}
<p class="hint">Indiquez un mot de passe vide pour en générer un nouveau automatiquement.</p>
<div class="large-5 columns">
<div class="panel">
<h3>Adresses IP</h3>
<div class="faketable">
{% for subnet in object.ip_subnet.all %}
{{ subnet|prettify }}<br />
{% endfor %}
<p class="formcontrol"><input class="button" type="submit" value="Valider" /></p>
{% endblock %}
# -*- 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.conf.urls import patterns, url
from .views import DSLView
urlpatterns = patterns(
# This is part of the generic configuration interface (the "name" is
# the same as the "backend_name" of the model).
url(r'^(?P<pk>\d+)$', DSLView.as_view(template_name="dsl_ldap/dsl.html"),
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.views.generic.edit import UpdateView
from django.contrib.auth.decorators import login_required
from django.contrib.messages.views import SuccessMessageMixin
from django.utils.decorators import method_decorator
from .models import DSLConfiguration
class DSLView(SuccessMessageMixin, UpdateView):
model = DSLConfiguration
fields = ['password']
success_message = "Mot de passe mis à jour avec succès !"
def dispatch(self, *args, **kwargs):
return super(DSLView, self).dispatch(*args, **kwargs)
def get_object(self):
if self.request.user.is_superuser:
return get_object_or_404(DSLConfiguration, pk=self.kwargs.get("pk"))
# For normal users, ensure the VPN belongs to them.
return get_object_or_404(DSLConfiguration, pk=self.kwargs.get("pk"),
......@@ -3,7 +3,6 @@
from __future__ import unicode_literals
import re
from django.utils.translation import ugettext_lazy as _
from django.forms import fields
from django.db import models
......@@ -13,7 +12,7 @@ mac_re = re.compile(MAC_RE)
class MACAddressFormField(fields.RegexField):
default_error_messages = {
'invalid': _(u'Enter a valid MAC address.'),
'invalid': 'Saisir une adresse MAC valide',
def __init__(self, *args, **kwargs):
# To match django-ldapdb version:
......@@ -12,7 +13,8 @@ django-sendfile==0.3.10
# Latest with Django <= 1.8 support:
......@@ -164,17 +164,17 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
class LdapVPNConfig(ldapdb.models.Model):
base_dn = settings.VPN_CONF_BASE_DN
object_classes = [b'person', b'organizationalPerson', b'inetOrgPerson',
b'top', b'radiusprofile']
login = CharField(db_column=b'cn', primary_key=True, max_length=255)
sn = CharField(db_column=b'sn', max_length=255)
password = CharField(db_column=b'userPassword', max_length=255)
active = CharField(db_column=b'dialupAccess', max_length=3)
ipv4_endpoint = CharField(db_column=b'radiusFramedIPAddress', max_length=16)
ipv6_endpoint = CharField(db_column=b'postalAddress', max_length=40)
ranges_v4 = ListField(db_column=b'radiusFramedRoute')
ranges_v6 = ListField(db_column=b'registeredAddress')
object_classes = ['person', 'organizationalPerson', 'inetOrgPerson',
'top', 'radiusprofile']
login = CharField(db_column='cn', primary_key=True, max_length=255)
sn = CharField(db_column='sn', max_length=255)