Implémentation d'un Serveur FTP Multi-Utilisateurs en Python

Cet article présente une implémentation complète d'un serveur FTP capable de gérer plusieurs utilisateurs simultanément. Le système offre une sécurité renforcée avec une authentification cryptée, des espaces personnels isolés pour chaque utilisateur, et des fonctionnalités avancées de transfert de fichiers.

Exigences Fonctionnelles

  • Authentification sécurisée des utilisateurs avec cryptage
  • Connexion simultanée de plusieurs utilisateurs
  • Espace personnel dédié pour chaque utilisateur avec isolation des espaces
  • Quotas de stockage personnalisables par utilisateur
  • Naviguation libre dans les répertoires du serveur
  • Visualisation des fichiers dans le répertoire courant
  • Transferts de fichiers montants et descendants avec intégrité garantie
  • Affichage d'une barre de progression pendant les transferts
  • Fonction supplémentaire: reprise des transferts interrompus

Fonctionnalités Implémentées

  • Authentification sécurisée des utilisatuers
  • Connexion simultanée de plusieurs utilisateurs
  • Espace personnel isolé pour chaque utilisateur
  • Transferts de fichiers avec intégrité vérifiée
  • Affichage d'une barre de progression pendant les transferts

Architecture du Programme

Composant Serveur FTP


ServeurFTP
├── bin
│   └── demarrer_serveur.py
├── conf
│   ├── comptes.cfg
│   └── configuration.py
├── core
│   ├── gestion_ftp.py
│   └── principal.py
├── home
│   ├── utilisateur001
│   └── utilisateur002
└── logs

Composant Client FTP
└── client_ftp.py

Code du Serveur

Script de Démarrage (bin/demarrer_serveur.py)


#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys

CHEIN_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(CHEIN_BASE)

from core import principal

if __name__ == '__main__':
    principal.GestionArguments()

Fichier de Configuration des Comptes (conf/comptes.cfg)


[PAR_DEFAUT]

[utilisateur001]
MotDePasse = 123
Quota = 100

[utilisateur002]
MotDePasse = 123
Quota = 100

Paramètres Système (conf/configuration.py)


#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
CHEIN_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

REPERTOIRE_UTILISATEURS = "%s/home" % CHEIN_BASE
REPERTOIRE_JOURNAL = "%s/log" % CHEIN_BASE
NIVEAU_JOURNAL = "DEBUG"

FICHIER_COMPTE = "%s/conf/comptes.cfg" % CHEIN_BASE

ADRESSE_HOTE = "127.0.0.1"
PORT = 9999

Logique du Serveur FTP (core/gestion_ftp.py)


#!/usr/bin/env python
# -*- coding: utf-8 -*-

import socketserver
import json
import configparser
import os
import hashlib
from conf import configuration

CODES_STATUT = {
    250:"Format de commande invalide, ex: {'action':'recevoir','nomfichier':'test.py','taille':344}",
    251:"Commande invalide",
    252:"Données d'authentification invalides",
    253:"Nom d'utilisateur ou mot de passe incorrect",
    254:"Authentification réussie",
    255:"Nom de fichier non fourni",
    256:"Le fichier n'existe pas sur le serveur",
    257:"Prêt à envoyer le fichier",
    258:"Vérification md5",
}

class GestionnaireFTP(socketserver.BaseRequestHandler):

    def handle(self):
        '''Traite les messages entrants du client'''
        while True:
            self.donnees = self.request.recv(1024).strip()
            print(self.client_address[0])
            print(self.donnees)
            
            if not self.donnees:
                print("client déconnecté...")
                break
                
            paquet = json.loads(self.donnees.decode())
            if paquet.get('action') is not None:
                print("---->", hasattr(self, "_%s" % paquet.get('action')))
                if hasattr(self, "_%s" % paquet.get('action')):
                    methode = getattr(self, "_%s" % paquet.get('action'))
                    methode(paquet)
                else:
                    print("commande invalide")
                    self.envoyer_reponse(251)
            else:
                print("format de commande invalide")
                self.envoyer_reponse(250)

    def envoyer_reponse(self,code_statut,donnees=None):
        '''Envoie une réponse au client'''
        reponse = {'code_statut':code_statut,'message_statut':CODES_STATUT[code_statut]}
        if donnees:
            reponse.update(donnees)
        self.request.send(json.dumps(reponse).encode())

    def _authentifier(self,*args,**kwargs):
        '''Valide les informations d'authentification du client'''
        donnees = args[0]
        if donnees.get("nomutilisateur") is None or donnees.get("motdepasse") is None:
            self.envoyer_reponse(252)

        utilisateur = self.valider_identite(donnees.get("nomutilisateur"),donnees.get("motdepasse"))
        if utilisateur is None:
            self.envoyer_reponse(253)
        else:
            print("authentification réussie",utilisateur)
            self.utilisateur = utilisateur
            self.envoyer_reponse(254)

    def valider_identite(self,nomutilisateur,motdepasse):
        '''Vérifie la validité des credentials utilisateur'''
        config = configparser.ConfigParser()
        config.read(configuration.FICHIER_COMPTE)
        if nomutilisateur in config.sections():
            _motdepasse = config[nomutilisateur]["MotDePasse"]
            if _motdepasse == motdepasse:
                print("authentification réussie pour",nomutilisateur)
                config[nomutilisateur]["NomUtilisateur"] = nomutilisateur
                return config[nomutilisateur]

    def _envoyer(self,*args,**kwargs):
        "Le client envoie un fichier au serveur"
        donnees = args[0]
        nom_fichier_base = donnees.get('nomfichier')
        fichier_obj = open(nom_fichier_base, 'wb')
        donnees = self.request.recv(4096)
        fichier_obj.write(donnees)
        fichier_obj.close()

    def _recevoir(self,*args,**kwargs):
        '''Méthode de téléchargement de fichiers'''
        donnees = args[0]
        if donnees.get('nomfichier') is None:
            self.envoyer_reponse(255)
        repertoire_home = "%s/%s" %(configuration.REPERTOIRE_UTILISATEURS,self.utilisateur["NomUtilisateur"])
        chemin_complet_fichier = "%s/%s" %(repertoire_home,donnees.get('nomfichier'))
        
        print("chemin complet du fichier",chemin_complet_fichier)

        if os.path.isfile(chemin_complet_fichier):
            fichier_obj = open(chemin_complet_fichier,'rb')
            taille_fichier = os.path.getsize(chemin_complet_fichier)
            self.envoyer_reponse(257,{'taille_fichier':taille_fichier})

            self.request.recv(1)

            if donnees.get('md5'):
                objet_md5 = hashlib.md5()
                for ligne in fichier_obj:
                    self.request.send(ligne)
                    objet_md5.update(ligne)
                else:
                    fichier_obj.close()
                    valeur_md5 = objet_md5.hexdigest()
                    self.envoyer_reponse(258,{'md5':valeur_md5})
                    print("transmission du fichier terminée....")
            else:
                for ligne in fichier_obj:
                    self.request.send(ligne)
                else:
                    fichier_obj.close()
                    print("transmission du fichier terminée....")
        else:
            self.envoyer_reponse(256)

    def _lister(self,*args,**kwargs):
        pass

    def _changer_repertoire(self,*args,**kwargs):
        pass
    

if __name__ == '__main__':
    ADRESSE_HOTE, PORT = "127.0.0.1", 9999

Module Principal (core/principal.py)


#!/usr/bin/env python
# -*- coding: utf-8 -*-

import optparse
from core.gestion_ftp import GestionnaireFTP
import socketserver
from conf import configuration

class GestionArguments(object):
    def __init__(self):
        self.analyseur = optparse.OptionParser()
        (options, args) = self.analyseur.parse_args()
        self.valider_arguments(options, args)

    def valider_arguments(self,options,args):
        '''Valide et appelle la fonctionnalité correspondante'''
        if hasattr(self,args[0]):
            fonction = getattr(self,args[0])
            fonction()
        else:
            self.analyseur.print_help()

    def demarrer(self):
        print('---démarrage du serveur---')

        serveur = socketserver.ThreadingTCPServer((configuration.ADRESSE_HOTE, configuration.PORT), GestionnaireFTP)

        serveur.serve_forever()

Client FTP (client_ftp.py)


#!/usr/bin/env python
# -*- coding: utf-8 -*-

import socket
import os
import sys
import optparse
import json
import hashlib

CODES_STATUT = {
    250:"Format de commande invalide, ex: {'action':'recevoir','nomfichier':'test.py','taille':344}",
    251:"Commande invalide",
    252:"Données d'authentification invalides",
    253:"Nom d'utilisateur ou mot de passe incorrect",
    254:"Authentification réussie",
    255:"Nom de fichier non fourni",
    256:"Le fichier n'existe pas sur le serveur",
    257:"Prêt à envoyer le fichier",
}

class ClientFTP(object):
    def __init__(self):
        analyseur = optparse.OptionParser()
        analyseur.add_option("-s","--serveur",dest="serveur",help="adresse IP du serveur FTP")
        analyseur.add_option("-P","--port",type="int",dest="port",help="port du serveur FTP")
        analyseur.add_option("-u","--utilisateur",dest="utilisateur",help="nom d'utilisateur")
        analyseur.add_option("-p","--motdepasse",dest="motdepasse",help="mot de passe")

        self.options,self.arguments = analyseur.parse_args()
        self.valider_arguments(self.options,self.arguments)
        self.etablir_connexion()

    def etablir_connexion(self):
        '''Établit une connexion distante'''
        self.socket = socket.socket()
        self.socket.connect((self.options.serveur,self.options.port))

    def valider_arguments(self,options,arguments):
        '''Valide la validité des paramètres'''
        if options.utilisateur is not None and options.motdepasse is not None:
            pass
        elif options.utilisateur is None and options.motdepasse is None:
            pass
        else:
            exit("Erreur: nom d'utilisateur et mot de passe doivent être fournis ensemble...")

        if options.serveur and options.port:
            if options.port >0 and options.port <65535:
                return True
            else:
                exit("Erreur: le port doit être compris entre 0 et 65535")

    def authentifier(self):
        '''Authentifie l'utilisateur et récupère les informations saisies par le client'''
        if self.options.utilisateur:
            print(self.options.utilisateur,self.options.motdepasse)
            return self.obtenir_resultat_auth(self.options.utilisateur,self.options.motdepasse)
        else:
            tentative_restante = 0
            while tentative_restante <3:
                nom_utilisateur = input("nom d'utilisateur: ").strip()
                mot_de_passe = input("mot de passe: ").strip()
                return self.obtenir_resultat_auth(nom_utilisateur,mot_de_passe)

    def obtenir_resultat_auth(self,utilisateur,motdepasse):
        '''Le serveur distant évalue les informations utilisateur, mot de passe et action'''
        donnees = {'action':'authentifier',
                'utilisateur':utilisateur,
                'motdepasse':motdepasse,}

        self.socket.send(json.dumps(donnees).encode())
        reponse = self.obtenir_reponse()
        if reponse.get('code_statut') == 254:
            print("Authentification réussie!")
            self.utilisateur = utilisateur
            return True
        else:
            print(reponse.get("message_statut"))

    def obtenir_reponse(self):
        '''Récupère le résultat de la réponse du serveur, méthode commune'''
        donnees = self.socket.recv(1024)
        donnees = json.loads(donnees.decode())
        return donnees

    def interactif(self):
        '''Programme interactif'''
        if self.authentifier():
            print("--début de l'interaction...")
            while True:
                choix = input("[%s]:"%self.utilisateur).strip()
                if len(choix) == 0:continue
                liste_commandes = choix.split()
                if hasattr(self,"_%s"%liste_commandes[0]):
                    methode = getattr(self,"_%s"%liste_commandes[0])
                    methode(liste_commandes)
                else:
                    print("Commande invalide.")

    def verification_md5_requise(self,liste_commandes):
        '''Vérifie si la commande nécessite une vérification MD5'''
        if '--md5' in liste_commandes:
            return True

    def afficher_progression(self,total):
        '''Barre de progression'''
        taille_recue = 0
        pourcentage_actuel = 0
        while taille_recue < total:
            if int((taille_recue / total) * 100) > pourcentage_actuel :
                print("#",end="",flush=True)
                pourcentage_actuel = (taille_recue / total) * 100
            nouvelle_taille = yield
            taille_recue += nouvelle_taille

    def _recevoir(self,liste_commandes):
        '''Méthode de téléchargement de fichiers'''
        print("recevoir--",liste_commandes)
        if len(liste_commandes) == 1:
            print("aucun nom de fichier spécifié...")
            return
        
        en_tete_client = {
            'action':'recevoir',
            'nomfichier':liste_commandes[1],
        }

        if self.verification_md5_requise(liste_commandes):
            en_tete_client['md5'] = True

        self.socket.send(json.dumps(en_tete_client).encode())
        reponse = self.obtenir_reponse()
        print(reponse)

        if reponse["code_statut"] ==257:
            self.socket.send(b'1')
            nom_fichier_base = liste_commandes[1].split('/')[-1]
            taille_recue = 0
            fichier_obj = open(nom_fichier_base,'wb')

            if self.verification_md5_requise(liste_commandes):
                objet_md5 = hashlib.md5()

                progression = self.afficher_progression(reponse['taille_fichier'])
                progression.__next__()

                while taille_recue < reponse['taille_fichier']:
                    donnees = self.socket.recv(4096)
                    taille_recue += len(donnees)

                    try:
                        progression.send(len(donnees))
                    except StopIteration as e:
                        print("100%")

                    fichier_obj.write(donnees)
                    objet_md5.update(donnees)
                else:
                    print("--->réception du fichier terminée<---")
                    fichier_obj.close()
                    valeur_md5 = objet_md5.hexdigest()
                    md5_serveur = self.obtenir_reponse()
                    if md5_serveur['code_statut'] ==258:
                        if md5_serveur['md5'] == valeur_md5:
                            print("Vérification d'intégrité réussie pour %s!" %nom_fichier_base)
            else:
                progression = self.afficher_progression(reponse['taille_fichier'])
                progression.__next__()

                while taille_recue < reponse['taille_fichier']:
                    donnees = self.socket.recv(4096)
                    taille_recue += len(donnees)
                    fichier_obj.write(donnees)
                    try:
                        progression.send(len(donnees))
                    except StopIteration as e:
                        print("100%")
                else:
                    print("--->réception du fichier terminée<---")
                    fichier_obj.close()

    def _envoyer(self,liste_commandes):
        '''Méthode d'envoi de fichiers'''
        print("envoyer--", liste_commandes)
        if len(liste_commandes) == 1:
            print("aucun nom de fichier spécifié...")
            return
        
        en_tete_client = {
            'action':'envoyer',
            'nomfichier':liste_commandes[1],
        }
        self.socket.send(json.dumps(en_tete_client).encode())
        self.socket.recv(1)
        fichier_obj = open(liste_commandes[1],'rb')
        for ligne in fichier_obj:
            self.socket.send(ligne)


if __name__ == '__main__':
    ftp = ClientFTP()
    ftp.interactif()

Étiquettes: Python FTP serveur réseau Authentification

Publié le 1 juin à 20h57