Repository: zestedesavoir/zds-site

Issue:
Staff : page d'utilisateurs avec la même IP → avec le même réseau en IPv6
En IPv6, une IP (routable) est unique par marche dans l'univers. Par contre, les FAI n'attribuent pas une IP par box, mais N sous-réseaux (N variant de 1 si le FAI est un rapiat à 256 s'il respecte les recommandations).

Donc, une meilleure chance de trouver les doublons serait de lister, dans le cas d'une IPv6, non pas les correspondance d'IP exacte, mais de sous-réseaux.

Ça se fait « facilement » (en excluant quelques cas pathologiques) : les sous-réseaux IPv6 font 64 bits (sur les 128 de l'adresse), il suffit donc de sélectionner toutes les adresses dont les 64 bits sont identiques, ce qui est très facile avec des IPv6 au format canonique (c'est la partie `2001:0db8:0000:85a3` de l'adresse `2001:0db8:0000:85a3:0000:0000:ac1f:8001`, donc les 19 premiers caractères).


Discussion:
Donc finalement, il faut vérifier que l'on a affaire à une IPv6 et, si c'est le cas, rechercher les membres dont l'adresse IP commence par les 19 premiers caractères de l'IP ? Si ce n'est que ça, je devrais pouvoir m'en occuper. :)
À vérifier si les IPv6 qu'on récupère sont au format canonique (on trouve
des :0000: dedans) ou compactées (c'est remplacé par ::). Dans le 1er cas
c'est un test direct, dans le second cas il faut trouver un moyen de les
rendre canoniques d'abord.

Le 29 décembre 2016 à 10:51, Guillaume <notifications@github.com> a écrit :

> Donc finalement, il faut vérifier que l'on a affaire à une IPv6 et, si
> c'est le cas, rechercher les membres dont l'adresse IP commence par les 19
> premiers caractères de l'IP ? Si ce n'est que ça, je devrais pouvoir m'en
> occuper. :)
>
> —
> You are receiving this because you authored the thread.
> Reply to this email directly, view it on GitHub
> <https://github.com/zestedesavoir/zds-site/issues/4103#issuecomment-269606220>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AFhKnLepCQf_7e9Ij3qANy8S8VOK-ofgks5rM4KbgaJpZM4LXRMi>
> .
>

**Indications pour résoudre ce ticket**

Il va falloir modifier la fonction `member_from_ip` du fichier Python `zds/member/views.py` ainsi que le fichier HTML `templates/member/settings/memberip.html`.

https://github.com/zestedesavoir/zds-site/blob/c4d3dd39c6a780054113d5185fb83bc82f6753be/zds/member/views.py#L1406-L1413

Il faut séparer les deux cas : 

- en IPv4, on souhaite rechercher les membres qui ont *exactement* la même adresse ;
- en IPv6, on souhaite rechercher les membres qui ont *exactement* la même adresse et les membres qui viennent du même réseau (c'est-à-dire pour l'adresse `aaaa:bbbb:cccc:dddd:eeee:ffff:aabb:ccdd` les membres dont l'adresse commence par `aaaa:bbbb:cccc:dddd:`). Les IPv6 sont stockées en base de données sous une forme compactée (par exemple `aaaa:bbbb:0000:0000:cccc:dddd:eeee:ffff` devient `aaaa:bbbb::cccc:dddd:eeee:ffff`) ce qui nous complique la tâche. Le plus simple est probablement d'utiliser la bibliothèque `ipaddress` (présente par défaut dans Python) et sa fonction `ip_network` ([voir la documentation](https://docs.python.org/3.5/library/ipaddress.html#ipaddress.ip_network)) pour trouver l'adresse IP du réseau de l'adresse IP du membre (par exemple pour `aaaa:bbbb:cccc:dddd:eeee:ffff:aabb:ccdd` il s'agirait de `aaaa:bbbb:cccc:dddd::`) et ensuite retirer le dernier ":".

Cela donne approximativement ce code : 

```py
# On recherche les membres qui ont exactement la même adresse IP
members = Profile.objects.filter(last_ip_address=ip_address).order_by('-last_visit')

if ':' in ip_address: # Si c'est une adresse IPv6
    # On récupère l'adresse réseau correspondant à cette adresse IP
    network_ip = ipaddress.ip_network(ip_address + '/64', strict=False).network_ip
    # L'adresse réseau se termine par "::", on enlève le dernier ":" pour récupérer les adresses IP de ce réseau
    network_ip = str(network_ip)[:-1]
    # On recherche les membres qui ont une adresse IP sur le même réseau
    network_members = Profile.objects.filter(last_ip_address__starts_with=network_ip).order_by('-last_visit')
```

Il faudra modifier le gabarit (fichier HTML) pour afficher la liste des membres qui ont exactement la même adresse IP (`members`) et ceux qui appartiennent au même réseau (`network_members`). Il serait bien d'écrire un paragraphe pour expliquer la différence entre les deux listes (car tous les membres du Staff ne connaissent pas forcément cette différence).

Ne pas hésiter à jouer avec le module `ipaddress` pour bien comprendre comme ça fonctionne et poser des questions si besoin !
Si personne n'est dessus, je vais me pencher dessus, mais je ne promets rien ^^

Resolving patch:
diff --git a/zds/member/views.py b/zds/member/views.py
--- a/zds/member/views.py
+++ b/zds/member/views.py
@@ -1,3 +1,4 @@
+import ipaddress
 import uuid
 from datetime import datetime, timedelta
 from urllib.parse import unquote
@@ -1457,10 +1458,20 @@ def settings_promote(request, user_pk):
 @login_required
 @permission_required("member.change_profile", raise_exception=True)
 def member_from_ip(request, ip_address):
-    """List users connected from a particular IP."""
+    """List users connected from a particular IP, and an IPV6 subnetwork."""
 
     members = Profile.objects.filter(last_ip_address=ip_address).order_by("-last_visit")
-    return render(request, "member/admin/memberip.html", {"members": members, "ip": ip_address})
+    members_and_ip = {"members": members, "ip": ip_address}
+
+    if ":" in ip_address:  # Check if it's an IPV6
+        network_ip = ipaddress.ip_network(ip_address + "/64", strict=False).network_address  # Get the network / block
+        # Remove the additional ":" at the end of the network adresse, so we can filter the IP adresses on this network
+        network_ip = str(network_ip)[:-1]
+        network_members = Profile.objects.filter(last_ip_address__startswith=network_ip).order_by("-last_visit")
+        members_and_ip["network_members"] = network_members
+        members_and_ip["network_ip"] = network_ip
+
+    return render(request, "member/admin/memberip.html", members_and_ip)
 
 
 @login_required


Test patch:
diff --git a/zds/member/tests/tests_views.py b/zds/member/tests/tests_views.py
--- a/zds/member/tests/tests_views.py
+++ b/zds/member/tests/tests_views.py
@@ -14,6 +14,7 @@
 from django.utils.html import escape
 from django.utils.translation import gettext_lazy as _
 
+from zds.member.views import member_from_ip
 from zds.notification.models import TopicAnswerSubscription
 from zds.member.factories import (
     ProfileFactory,
@@ -1723,3 +1724,97 @@ def send_messages(messages):
         )
         self.assertEqual(result.status_code, 200)
         self.assertIn(escape("Impossible d'envoyer l'email."), result.content.decode("utf-8"))
+
+
+class IpListingsTests(TestCase):
+    """Test the member_from_ip function : listing users from a same IPV4/IPV6 address or same IPV6 network."""
+
+    def setUp(self) -> None:
+        self.staff = StaffProfileFactory().user
+        self.regular_user = ProfileFactory()
+
+        self.user_ipv4_same_ip_1 = ProfileFactory(last_ip_address="155.128.92.54")
+        self.user_ipv4_same_ip_1.user.username = "user_ipv4_same_ip_1"
+        self.user_ipv4_same_ip_1.user.save()
+
+        self.user_ipv4_same_ip_2 = ProfileFactory(last_ip_address="155.128.92.54")
+        self.user_ipv4_same_ip_2.user.username = "user_ipv4_same_ip_2"
+        self.user_ipv4_same_ip_2.user.save()
+
+        self.user_ipv4_different_ip = ProfileFactory(last_ip_address="155.128.92.55")
+        self.user_ipv4_different_ip.user.username = "user_ipv4_different_ip"
+        self.user_ipv4_different_ip.user.save()
+
+        self.user_ipv6_same_ip_1 = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:7981:9852:1493:3721")
+        self.user_ipv6_same_ip_1.user.username = "user_ipv6_same_ip_1"
+        self.user_ipv6_same_ip_1.user.save()
+
+        self.user_ipv6_same_ip_2 = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:7981:9852:1493:3721")
+        self.user_ipv6_same_ip_2.user.username = "user_ipv6_same_ip_2"
+        self.user_ipv6_same_ip_2.user.save()
+
+        self.user_ipv6_same_network = ProfileFactory(last_ip_address="2001:8f8:1425:60a0:9852:7981:3721:1493")
+        self.user_ipv6_same_network.user.username = "user_ipv6_same_network"
+        self.user_ipv6_same_network.user.save()
+
+        self.user_ipv6_different_network = ProfileFactory(last_ip_address="8f8:60a0:3721:1425:7981:1493:2001:9852")
+        self.user_ipv6_different_network.user.username = "user_ipv6_different_network"
+        self.user_ipv6_different_network.user.save()
+
+    def test_same_ipv4(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=[self.user_ipv4_same_ip_1.last_ip_address]))
+        self.assertContains(response, self.user_ipv4_same_ip_1.user.username)
+        self.assertContains(response, self.user_ipv4_same_ip_2.user.username)
+        self.assertContains(response, self.user_ipv4_same_ip_1.last_ip_address)
+        self.assertNotContains(response, self.user_ipv4_different_ip.user.username)
+
+    def test_different_ipv4(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=[self.user_ipv4_different_ip.last_ip_address]))
+        self.assertContains(response, self.user_ipv4_different_ip.user.username)
+        self.assertContains(response, self.user_ipv4_different_ip.last_ip_address)
+        self.assertNotContains(response, self.user_ipv6_same_ip_1.user.username)
+
+    def test_same_ipv6_and_same_ipv6_network(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_same_ip_1.last_ip_address]))
+        self.assertContains(response, self.user_ipv6_same_ip_1.user.username)
+        self.assertContains(response, self.user_ipv6_same_ip_2.user.username)
+        self.assertContains(response, self.user_ipv6_same_network.user.username)
+        self.assertNotContains(response, self.user_ipv6_different_network.user.username)
+
+    def test_same_ipv6_network_but_different_ip(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_same_network.last_ip_address]))
+        self.assertContains(response, self.user_ipv6_same_network.user.username)
+        self.assertContains(response, self.user_ipv6_same_ip_1.user.username)
+        self.assertContains(response, self.user_ipv6_same_ip_2.user.username)
+        self.assertNotContains(response, self.user_ipv6_different_network.user.username)
+
+    def test_different_ipv6_network(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=[self.user_ipv6_different_network.last_ip_address]))
+        self.assertContains(response, self.user_ipv6_different_network.user.username)
+        self.assertNotContains(response, self.user_ipv6_same_ip_1.user.username)
+        self.assertNotContains(response, self.user_ipv6_same_ip_2.user.username)
+        self.assertNotContains(response, self.user_ipv6_same_network.user.username)
+
+    def test_access_rights_to_ip_page_as_regular_user(self) -> None:
+        self.client.force_login(self.regular_user.user)
+        response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"]))
+        self.assertEqual(response.status_code, 403)
+
+    def test_access_rights_to_ip_page_as_anonymous(self) -> None:
+        response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"]))
+        self.assertEqual(response.status_code, 302)
+
+    def test_access_rights_to_ip_page_as_staff(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"]))
+        self.assertEqual(response.status_code, 200)
+
+    def test_template_used_by_ip_page(self) -> None:
+        self.client.force_login(self.staff)
+        response = self.client.get(reverse(member_from_ip, args=["0.0.0.0"]))
+        self.assertTemplateUsed(response, "member/admin/memberip.html")