Wrzasq.pl

Konfiguracja serwisu z kontami FTP

Monday, 02 March 2009, 01:14

Analiza przedwstępna

Dość ciekawym zagadnieniem, jakie ostatnio musiałem poruszyć przy tworzeniu pewnego projektu jest udostępnianie użytkownikom sewrisu dodatkowych usług. Nie mam tutaj na myśli jakichś usług do wybrania na stronie, ale usług sieciowych. Konkretnie problematyka była taka, aby każdy użytkownik serwisu webowego miał dostęp do serwera FTP z własnym kontem. Serwis oczywiście miał swoją własną bazę MySQL z kontami użytkowników.

Pierwsze dwa wyjścia jakie się nasuwają w takiej sytuacji to oczywiście skrajne albo obsłużenie serwera FTP (napisanie jakiegoś prostego daemona do tego celu), albo z kolei tworzenie automatycznie bliźniaczego konta w systemie (oczywiście obsługując wtedy rejestrację, usuwanie kont, zmianę hasła, zarządzanie kontem, …). Oczywiście obydwa te rozwiązania jako zbyt absurdalne nawet dla mnie ;) odłożyłem na później (jakby wszystko inne zawiodło). Zacząłem szukać możliwości jakiegoś sprzężenia istniejącej bazy danych strony internetowej z mechanizmem logowania usługi FTP. Na szczęście na serwerze stoi ProFTPd - jego slogan Highly configurable GPL-licensed FTP server software nie jest bezpodstawny.

ProFTPd daje naprawdę duże możliwości konfiguracji i to we wszelkim zakresie, ale skupmy się na problemie. Daemon ten umożliwia uwierzytelnianie nie tylko bazujące na użytkownikach systemowych, ale również pochodzących z innych źródeł. Ponieważ bez sensu było by na przykład tworzenie pliku z wirtualnymi użytkownikami, lub inne pośrednie obejścia, godne rozważenia pozostały dwie opcje autoryzacji: PAM i SQL.

Pierwsza z nich to uniwersalny, modułowy mechanizm logowania w systemach UNIXowych. Daje możliwość kontroli autoryzacji na wiele sposobów włączając w to możliwość tworzenia własnych modułów. I w sumie to by wystarczyło, gdyby nie fakt, że sam ProFTPd zawiera również moduł do pobierania danych logowania z bazy SQL (obsługiwane są MySQL i PostgreSQL) więc pośrednictwo PAM byłoby w tym wypadku zbędne i zapewne kłopotliwe.

Struktura tabeli SQL

Pierwszym problemem jaki napotkałem była struktura bazy danych. Moja była raczej dość normalna dla skryptów PHP:

CREATE TABLE `groups` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID grupy.',
    `name` VARCHAR(255) NOT NULL COMMENT 'Nazwa grupy.',
/* … */
    PRIMARY KEY (`id`),
    UNIQUE KEY (`name`)
) ENGINE = InnoDB CHARSET = utf8 COMMENT 'Grupy użytkowników.';

CREATE TABLE `users` (
    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID użytkownika.',
    `idGroup` BIGINT UNSIGNED NOT NULL COMMENT 'Grupa użytkownika.',
    `login` VARCHAR(255) NOT NULL COMMENT 'Login.',
    `password` VARCHAR(40) NOT NULL COMMENT 'Hasło z SHA1.',
/* … */
    PRIMARY KEY (`id`),
    CONSTRAINT `userIdGroup` FOREIGN KEY (`idGroup`) REFERENCES `groups` (`id`) ON UPDATE CASCADE,
    UNIQUE KEY (`login`)
) ENGINE = InnoDB CHARSET = utf8 COMMENT 'Użytkownicy systemu.';

Sęk w tym, że mod_sql dla ProFTPd wymagał z goła odmiennej struktury:

SQLUsersInfo:
|- login (string)
|- password (string)
|- id (integer)
|- idGroup (integer)
|- path (string)
`- shell (string)

SQLGroupsInfo:
|- name (string)
|- id (integer)
`- members (string)

Tak więc powstały następujące problemy:

  • Hasło (w bazie danych serwisu hasło jest prezchowywane jako hash SHA1),
  • Pola path i shell (o ile ich dodanie nie byłoby problematyczne, to potrzebne jest wyznaczanie ich wartości),
  • Pole members tabeli grup (musi zawierać listę loginów wszystkich członków tej grupy oddzieloną przecinkami),
  • Ogólna zmiana struktury tabel.

Sprzężenie kont FTP z tabelą SQL i konfiguracja ProFTPd

Tutaj pomocne, a wręcz niezbędne, stały się widoki oraz elementy języka PL/SQL. Poszczególne problemy omówię od końca, czyli od najkrótszego rozwiązania. Ostatni problem praktycznie zniknął, ponieważ taka jest idea widoków - udostępnić zestawy pól źródłowych jako pojedyncze encje wynikowe.

W przypadku widoku grup jedynym niezwykłym polem będzie pole members ponieważ musi ono zawierać sklejenie wszystkich loginów wszystkich członków danej grupy. Zrobi to za nas funkcja GROUP_CONCAT():

CREATE VIEW `ftpUsers`
AS SELECT
    `groups`.`id` AS `id`,
    `groups`.`name` AS `name`,
    GROUP_CONCAT(`users`.`login` SEPARATOR ',') AS `members`
FROM
    `groups`
LEFT JOIN
    `users`
ON
    `users`.`idGroup` = `groups`.`id`
GROUP BY
    `groups`.`id`;

Należy zwrócić uwagę, że sklejenie to sam przecinek! Bez żadnych spacji! W klauzuli WHERE zapytań wysyłanych przez ProFTPd jest %,name,% (oraz możliwośc znajdowania się na końcu, początku, oraz jako jedyna pozycja na liście), więc dodanie spacji spowoduje błąd.

Nieco bardziej problematyczny jest widok generujący dla ProFTPd listę użytkowników. Pole shell ustawimy na NULL - nie potrzebujemy powłoki dla użytkowników FTP. Musimy jednak ustalić ścieżki katalogów użytkowników. Niech to będzie dajmy na to: /var/www/upload/users/$ID/, gdzie $ID to ID użytkownika w bazie danych.

Jednak najbardziej problematyczne jest pole password. Otóż hasło można przekazywać na wiele różnych sposobów - "czyste" (czyli w formie niezmienionej), zaszyfrowane UNIXowym crypt'em, jako skrót wynikowy funkcji PASSWORD(), a także jako inny hasz obsługiwany przez OpenSSH. Ale tutaj jest problem. OpenSSH przyjmuje skróty w formie binarnej, zakodowanej Base64. W serwisie internetowym zazwyczaj hashe generuje się z poziomu PHP i zapisuje w formie heksadecymalnej, na przykład funkcją sha1() i taki skrót zapisuje się w bazie. Jak zatem z poziomu bazy danych przetworzyć hasło z formu szasnastkowej na binarne i jeszcze zakodować je w Base64? MySQL tego niestety nie obsługuje. Dlatego zostawmy to na koniec. Najpierw zakończmy już konfigurację ProFTPd. Pierwsze co jest nam potrzebne to widok na użytkowników:

CREATE VIEW `ftpUsers`
AS SELECT
    `users`.`id` AS `id`,
    `groups`.`id` AS `idGroup`,
    CONCAT(`users`.`login`, '@domena') AS `login`,
    CONCAT('{sha1}', BASE64_SHA1(`users`.`password`) ) AS `password`,
    CONCAT('/var/www/upload/users/', `users`.`id`) AS `path`,
    NULL AS `shell`
FROM
    `users`
LEFT JOIN
    `groups`
ON
    `users`.`idGroup` = `groups`.`id`;

Dzięki dodaniu do loginu części @domena konta zwykłych użytkowników systemu pozostaną bez konfliktu z kontami wirtualnych użytkowników serwisu WWW.

Pewnie zauważyłeś dziwną funkcję BASE64_SHA1(), której na dodatek nie ma w żadnym manualu do MySQLa, a nawet google niewiele o niej wie. Nic dziwnego, ponieważ to właśnie sedno całego mechanizmu. Ale tak jak powiedziałem zostawię to na koniec. Przyjrzyjmy się jak dla takiej bazy skonfigurować ProFTPd. Po pierwsze musimy załadować moduł mod_sql dla MySQLa:

LoadModule mod_sql_mysql.c

W zależności od dystrybucji możemy mieć różnie rozmieszczone pliki konfiguracyjne, jednak w 99,9% głównym plikiem będzie /etc/proftpd/proftpd.conf i ewentualne dodatkowe pliki będa w nim wymienione. Następnym krokiem będzie konfiguracja samego logowania z uzyciem danych z bazy danych SQL:

# ustawiamy silnik mod_sql
SQLBackend mysql

# te dwie linijki kolejno włączają mod_sql oraz ustawiają go jako źródło uwierzytelniania
SQLEngine on
SQLAuthenticate on

# ustawiamy format haseł
SQLAuthTypes OpenSSL

# logowanie do bazy danych MySQL
SQLConnectInfo dbname@dbhost dbuser

# tutaj wpisujemy kolejno nazwy tabel/widoków oraz pól w widzialej poniżej kolejności
SQLUserInfo ftpUsers login password id idGroup path shell
SQLGroupInfo ftpGroups name id members

# przydatne jeśli coś pójdzie nie tak
SQLLogFile /var/log/proftpd/sql.log

Teraz pozostaje już tylko pare szczegółów dotyczących działania samego daemona. Po pierwsze dobrze by było "zamknąć" wirtualnych użytkowników w ich katalogach domowych, a po drugie - jak pamiętamy ustawiliśmy na sztywno brak powłoki logowania dla użytkowników z systemu, a ProFTPd domyślnie to sprawdza. Poniższe dwie linijki załątwią te problemy:

# ustawia katalog główny widziany z poziomu FTP na katalog domowy w systemie (taki chroot)
DefaultRoot ~

# nie wymaga, aby użytkownik posiadał poprawną powłokę logowania
RequireValidShell off

Przesyłanie hasła

Ostatnim, ale za to największym problemem jest wspomniane kodowanie hasła. Po pierwsze należy je przekształcić z formy szesnastkowej na binarną, a po drugie zakodować w Base64, a MySQL nie udostępnia do tego funkcji. Udostępnia co prawda konwersję między poszczególnymi systemami liczbowymi, ale problemem jest wtedy kodowanie znaków - szczególnie w moim przypadku, gdzie jest to UTF-8 i reprezentacja binarna miała by się nijak do tego, co powinno się otrzymać.

Kodowanie Base64 w języku SQL dla MySQLa oparłem na pliku znalezionym w Internecie, autorstwa niejakiego Iana Gullivera: base64.sql (nam potrzebne jest jedynie kodowanie). Problemem pozostawało przekształcanie z systemu szesnastkowego na binarną reprezentację bez uwzględniania kodowania (gdyż Unicode mógłby tutaj nieźle napsuć zapisując niektóre znaki binarnie). Mając już kodowanie Base64 łatwo było zmodyfikować tą funkcję tak, aby zamiast znaków, pobierała z wejściowego łańcucha wartości liczbowe i przekształcała je na odpowiadający kod znaku. W efekcie powstała taka oto funkcja (nadal potrzebna jest ze wskazanego pliku tabela base64_data):

DROP FUNCTION IF EXISTS BASE64_SHA1;
CREATE FUNCTION BASE64_SHA1 (input VARCHAR(40) )
    RETURNS VARCHAR(28)
    CONTAINS SQL
    DETERMINISTIC
    SQL SECURITY INVOKER
BEGIN
    DECLARE ret VARCHAR(28) DEFAULT '';
    DECLARE done TINYINT DEFAULT 0;

    IFinput IS NULL THEN
        RETURN NULL;
    END IF;

each_block:
    WHILE NOT done DO
    BEGIN
        DECLARE accum_value BIGINT UNSIGNED DEFAULT 0;
        DECLARE in_count TINYINT DEFAULT 0;
        DECLARE out_count TINYINT;

each_input_char:
        WHILE in_count < 3 DO BEGIN
            DECLARE first_char INT;

            IF LENGTH(input) = 0 THEN
                SET done = 1;
                SET accum_value = accum_value << (8 * (3 - in_count));
                LEAVE each_input_char;
            END IF;

            SET first_char = CONV( SUBSTRING(input, 1, 2), 16, 10);
            SET input = SUBSTRING(input, 3);

            SET accum_value = (accum_value << 8) + first_char;

            SET in_count = in_count + 1;
        END; END WHILE;

        -- We've now accumulated 24 bits; deaccumulate into base64 characters

        -- We have to work from the left, so use the third byte position and shift left
        CASE
            WHEN in_count = 3 THEN SET out_count = 4;
            WHEN in_count = 2 THEN SET out_count = 3;
            WHEN in_count = 1 THEN SET out_count = 2;
            ELSE RETURN ret;
        END CASE;

        WHILE out_count > 0 DO BEGIN
            BEGIN
                DECLARE out_char CONV(1);
                DECLARE base64_getval CURSOR FOR SELECT c FROM base64_data WHERE val = (accum_value >> 18);

                OPEN base64_getval;
                FETCH base64_getval INTO out_char;
                CLOSE base64_getval;

                SET ret = CONCAT(ret,out_char);
                SET out_count = out_count - 1;
                SET accum_value = accum_value << 6 & 0xffffff;
            END;
        END; END WHILE;

        CASE
            WHEN in_count = 2 THEN SET ret = CONCAT(ret,'=');
            WHEN in_count = 1 THEN SET ret = CONCAT(ret,'==');
            ELSE BEGIN END;
        END CASE;

    END; END WHILE;

    RETURN ret;
END;

Viola! /etc/init.d/protfpd restart i możemy się na serwer FTP logować z użyciem danych z serwisu webowego napisanego w PHP i przechowującym dane użytkowników bazie MySQL na dodatek niekoniecznie fizycznie przystosowanej do współpracy z ProFTPd :).

Tags: Web, MySQL, ProFTPd, Tutorial