2021-12-12 / Bartłomiej Kurek
Django - virtual mail manager (#3 - models)

W poprzedniej części przygotowaliśmy wstępną konfigurację środowisk (development, testing, production) projektu oraz uruchomiliśmy panel administracyjny.

Repozytorium kodu dostępne jest na platformie GitHub.

W tej części zajmiemy się tworzeniem modeli bazy danych oraz dodaniem ich do panelu administracyjnego.
Modele obejmują 3 części: users, domains, aliases. Jest to mały projekt, jednak każdy z tych modułów będzie osobną "aplikacją" (nomenklatura Django) wewnątrz tego projektu. To pozwoli na przejrzystą organizację kodu, rozdzielenie testów i wygodne dodawanie ewentualnych funkcji w przyszłości.

Dodatkowo stworzę moduł "common", w którym umieszczę dodatkową klasę modelu bazowego. Dostarczy on pola ze znacznikami czasowymi, a pozostałe klasy modeli będą je dziedziczyć.

Tworzymy moduły aplikacji (startapp)

Moduły tworzę w katalogu vmapp - tym, w którym znajduje się skrypt zarządzający django (manage.py).

$ cd vmapp
$ django-admin startapp common
$ django-admin startapp domains
$ django-admin startapp users
$ django-admin startapp aliases
$ cd -

Otrzymujemy taką strukturę w katalogu aplikacji django (vmapp):

$ tree -L 1 vmapp/
vmapp/
├── aliases
├── common
├── domains
├── manage.py
├── users
└── vmapp

Od razu dodaję te aplikacje do listy INSTALLED_APPS w vmapp/settings/defaults.py.

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",

    "common",
    "domains",
    "users",
    "aliases",
]

App: common

BaseModel

Definicję modelu bazowego nazywam po prostu BaseModel.
Plik vmapp/common/models.py:

from django.db import models


class BaseModel(models.Model):
    class Meta:
        abstract = True

    ctime = models.DateTimeField(null=False, blank=True, auto_now_add=True)
    mtime = models.DateTimeField(null=False, blank=True, auto_now=True)

Dodanie abstract = True w BaseModel.Meta informuje django, że jest to jedynie klasa bazowego modelu
na potrzeby definicji wspólnych pól, oraz że klasa ta nie ma posiadać reprezentacji w postaci tabeli w bazie danych
(dokumentacja Django).
Pole ctime to automatycznie uzupełniany czas utworzenia obiektu, a mtime to automatycznie aktualizowana data jego ostatniej modyfikacji.

App: domains

Model: Domain

Plik vmapp/domains/models.py:

from django.db import models
from common.models import BaseModel


class Domain(BaseModel):
    class Meta:
        db_table = "domains"

    domain = models.CharField(null=False, unique=True,
                              max_length=255)  # RFC1035
    is_enabled = models.BooleanField(default=True)

    def __str__(self):
        return self.domain  

Model ten dziedziczy z common.BaseModel, a zatem tabela będzie posiadać również kolumny ctime, mtime.
W Meta.db_table jawnie podaję nazwę tabeli jaką życzę sobie widzieć w bazie danych.
Długość pola domain ograniczam do 255 znaków rfc1035.
Pole is_enabled będzie pozwalać na wyłączenie wszystkich kont w danej domenie.
Metoda __str__ zwraca po prostu nazwę domeny, dzięki czemu będziemy mogli łatwo rozróżnić obiekty domen w panelu administracyjnym.

Admin model: DomainAdmin

Plik vmapp/domains/admin.py:

from django.contrib import admin
from django.db.models import Q, Count
from .models import Domain


@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
    list_display = (
        "domain", "is_enabled", "total_users", "total_active_users",
        "ctime", "mtime"
    )

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        qs = qs.annotate(
            total_users=Count(
                "user",
            ),
            total_active_users=Count(
                "user",
                filter=Q(user__is_enabled=True)
            )

        )
        return qs

    def total_users(self, obj):
        return obj.total_users

    def total_active_users(self, obj):
        return obj.total_active_users

Rejestrujemy model Domain i dostarczamy definicję klasy, której do obsługi domen będzie używał panel administracyjny.
Do listy pól dodaję total_users oraz total_active_users. Te dwa pola pochodzą z agregatów dodanych w metodzie get_queryset().

App: users

Model

Plik vmapp/users/models.py:

import crypt
from django.db import models
from common.models import BaseModel
from domains.models import Domain


class User(BaseModel):
    class Meta:
        db_table = "users"
        unique_together = [
            ["username", "domain"]
        ]

    domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
    username = models.CharField(null=False, max_length=64)
    password = models.CharField(null=False, max_length=256)
    quota = models.PositiveIntegerField(default=134217728)
    is_enabled = models.BooleanField(default=True)

    @classmethod
    def from_db(cls, db, field_names, values):
        obj = super().from_db(db, field_names, values)
        obj.quota = obj.quota / 1024 / 1024
        return obj

    def save(self, *args, **kwargs):
        # QUOTA: megabytes to bytes
        self.quota = self.quota * 1024 * 1024

        # HASH password
        algo = "{SHA512-CRYPT}"
        if not self.password.startswith("%s$6$" % algo):
            salt = crypt.mksalt(method=crypt.METHOD_SHA512)
            self.password = "{algo}{crypted}".format(
                algo=algo,
                crypted=crypt.crypt(self.password, salt)
            )
        super().save(*args, **kwargs)

    def email(self):
        return "%s@%s" % (self.username, self.domain.domain)

    def __str__(self):
        return self.email()

Model User dziedziczy z common.BaseModel, tabela nazywać ma się users.
Meta.unique_together ma wytworzyć unikalny index dla pól username + domain, tak aby w ramach tej samej domeny nie mogły występować dwa rekordy o takim samym username.

Pole domain to klucz obcy do tabeli domain, a trigger on_delete ma spowodować kaskadowe usunięcie wszystkich kont w tej domenie jeśli odpowiadający rekord domeny z tabeli domains zostanie usunięty. Pamiętać należy, iż Django - niestety - w rzeczywistości nie tworzy żadnych triggerów na poziomie bazy danych. Triggery te są wywoływane jedynie przy akcjach wykonywanych z poziomu aplikacji Django.

Pole username ograniczam do długości 64 znaków. Po szczegóły techniczne czytelników odsyłam w tym miejscu do rfc822 oraz rfc2822. Przyjmuję tutaj po prostu, iż username może mieć długość 64 znaków, a domena 255 znaków. Według standardu cały adres powinien mieścić się w 255 znakach (tj. 255, a nie 63@255). Oczywiście można dodać walidację długości (i poprawności adresu email), ale na tym etapie pomijam tę kwestię.

Pole quota to liczba bajtów określająca pojemność skrzynki, domyślnie 128mb.

Pole is_enabled pozwala aktywować/dezaktywować daną skrzynkę.

Dodaję również metodę "email()" oraz __str__ do łatwej identyfikacji obiektów w panelu administratora.

Metoda klasy (@classmethod) from_db konwertuje pojemność skrzynki z bajtów na megabajty podczas odczytu obiektu.

Metoda save() modelu, hash hasła.

W metodzie save() /przy zapisie obiektu/ konwertuję najpierw wartość kwoty dyskowej z megabajtów do bajtów, a następnie hashuję hasło.

Tutaj kilka wyjaśnień, a dla zainteresowanych - również nieco głębsza analiza techniczna.
Tworząc aplikację Django jesteśmy w zasadzie ograniczeni do języka Python. W polu hasła zawsze chcemy mieć wyliczony hash, nie chcemy przechowywać haseł w postaci otwartego tekstu. Gdybyśmy tworzyli schemat bazy w jezyku SQL, to moglibyśmy stworzyć funkcję i podpiąć ją jako trigger (BEFORE INSERT OR UPDATE). Niektóre bazy danych (PostgreSQL) dostarczają nawet moduły kryptograficzne (pgcrypto), których moglibyśmy do tego użyć. Tutaj jednak wytworzymy w Pythonie hash kompatybilny z autentykacją serwera dovecot, którego docelowo użyjemy do obsługi skrzynek pocztowych/autentykacji/imaps. Kod umieścimy w implementacji metody save(), którą Django wywołuje przy każdorazowym zapisie obiektu modelu. Oczywiście hashujemy tylko wtedy, kiedy hasło jest ustawiane/zmieniane (wartość nie zaczyna się od "{SHA512-CRYPT}$6$"). W przeciwnym wypadku zahashowalibyśmy poprzedni hash.

Hash

W Dovecot możemy w celu hashowania hasła użyć różnych algorytmów:

# doveadm pw -l
SHA1 SSHA512 SCRAM-SHA-256 BLF-CRYPT PLAIN HMAC-MD5 OTP SHA512 SHA DES-CRYPT
CRYPT SSHA MD5-CRYPT PLAIN-MD4 PLAIN-MD5 SCRAM-SHA-1 SHA512-CRYPT
CLEAR CLEARTEXT ARGON2I ARGON2ID SSHA256 MD5 PBKDF2 SHA256 CRAM-MD5
PLAIN-TRUNC SHA256-CRYPT SMD5 DIGEST-MD5 LDAP-MD5

Ja dla obsługi haseł w aplikacji wybiorę SHA512-CRYPT.
Naszym zadaniem jest wytworzenie hasha hasła kompatybilego z dovecot. Przyjrzyjmy się zatem jak sam dovecot generuje hashe.

# doveadm pw -s SHA512-CRYPT
Enter new password: 
Retype new password: 
{SHA512-CRYPT}$6$bGrFbnBLQnxM0ilu$596aSFqve0uRogs.nPrqwUTFmwbSDQKWE1u4jf73wHJ.tC4PMhENR6Rjkk/3AlxOp5O9n36rZwK971.PdKgVz.

Widzimy, że dovecot produkuje string zawierający: nazwę algorytmu, oznaczenie soli ($6), salt, hash posolonego hasła. Musimy teraz odtworzyć całość (i sprawdzić) w Pythonie. Obsługę tego zapewni nam moduł ze standardowej biblioteki Python3 - crypt. Prześledźmy zatem poszczególne kroki:

Generowanie soli:

>>> import crypt
>>> crypt.mksalt(method=crypt.METHOD_SHA512)
'$6$VssQ2JMaY8lB1EhB'
>>> crypt.mksalt(method=crypt.METHOD_SHA512)
'$6$5F0jHTECfvpHxkuo'
>>> crypt.mksalt(method=crypt.METHOD_BLOWFISH)
'$2b$12$94dlyIBEW9GsRswy1mrol/'

Widzimy, że wygenerowana losowa sól dla METHOD_SHA512 zaczyna się od ciągu "$6$". Dla porównania - algorytm BLOWFISH daje sól z oznaczeniem $2b$.

Do hashowania hasła użyjemy funkcji crypt(word, salt=None), gdzie word to hasło, a salt będziemy generować używając mksalt(). Sól jest elementem losowym, a zatem - dla testu - wykonanie hashowania tego samego hasła z inną solą powinno dać nam różne rezultaty.

>>> crypt.crypt("test", crypt.mksalt(method=crypt.METHOD_SHA512))
'$6$e6qPw7l2c290ZtDn$WR029DH/p5avfLbWrrvCwF02awdkX3HZwQoPPaEie/9Cgoc3fCicjBUHGt/NQ9Lrlfd21UFRRkdln7pnC.yEa1'
>>> crypt.crypt("test", crypt.mksalt(method=crypt.METHOD_SHA512))
'$6$xag4jMlYlgMvgnZU$5BUwbDOXRNyDAgFTwFG00eINduATqMsIH.hvBfuKD9pFd2pK2tRLD63gwhXGpyg1tE/meO/7Xi90uq.YPPB7t0'

Do tak stworzonego hasha dodamy prefix algorytmu {SHA512-CRYPT} i taką wartość zapiszemy w polu password naszego modelu User. Taka właśnie wartość zostanie wpisana do bazy danych, skąd przy autentykacji będzie pobierał ją program dovecot.

Omówienie autentykacji

Dla pełnego obrazu przedstawię jeszcze na czym polega sprawdzenie hasła przy autentykacji. Generuję hasło przez "doveadm pw":

# doveadm pw -s SHA512-CRYPT
Enter new password:
Retype new password:
{SHA512-CRYPT}$6$.H8vXIXjbUaQqyl4$rMoABuPsJj81UpXcht6B.TwcM6FyCKr6yDlKlETtknjvYCMPgY6yUqjAdM95jHDVMYNk9Z4hMZeJrFYOeb/g21

Otrzymany hash podstawię do zmiennej w interpreterze Python i dokonam sprawdzenia hasła używając standardowego modułu hmac (Hash Based Message Authentication Codes).

>>> crypted = "{SHA512-CRYPT}$6$.H8vXIXjbUaQqyl4$rMoABuPsJj81UpXcht6B.TwcM6FyCKr6yDlKlETtknjvYCMPgY6yUqjAdM95jHDVMYNk9Z4hMZeJrFYOeb/g21"
>>> crypted = crypted.replace("{SHA512-CRYPT}", "")
>>> import crypt
>>> import hmac
>>> hmac.compare_digest(crypt.crypt("password123", crypted), crypted)
False
>>> hmac.compare_digest(crypt.crypt("vmapp123", crypted), crypted)
True
>>> crypt.crypt("vmapp123", crypted) == crypted
True

Całość tak naprawdę sprowadza się do zahashowania podanego hasła ponownie - przy użyciu ciągu "soli" zawartej w zapisanym hashu, a następnie porównaniu tych hashy. Oczywście, w przypadku naszej aplikacji wartość "crypted" będzie zapisana w bazie danych, a sprawdzenie haseł (autentykację) będzie realizował serwer dovecot. Niemniej, mechanizm właśnie na tym polega.

Admin model: UserAdmin

Plik vmapp/users/admin.py:

from django.contrib import admin
from .models import User


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
    list_display = (
        "email", "username", "domain", "is_enabled", "quota",
        "ctime", "mtime", "is_domain_enabled",
    )

    def is_domain_enabled(self, obj):
        return obj.domain.is_enabled

Tutaj do listy dodaję pola:
- email - pełny adrese email zwracany metodą email() obiektu (zdefiniowana w klasie modelu User)
- is_domain_enabled - informuje czy dana domena jest aktywna

App: aliases

Model

Plik vmapp/aliases/models.py:

from django.db import models
from common.models import BaseModel


class Alias(BaseModel):
    class Meta:
        db_table = "aliases"
        verbose_name_plural = "aliases"
        unique_together = [
            ["email", "alias"]
        ]

    email = models.EmailField(null=False, max_length=255)
    alias = models.EmailField(null=False, max_length=255)
    is_enabled = models.BooleanField(default=True)

Model Alias również dziedziczy z common.BaseModel, tabela nazywać będzie się "aliases", unikalny indeks zakładamy na pola email+alias, aby uniknąć powtórzeń, a is_enabled mówi nam czy alias jest aktywny. Nazwa klasy modelu kończy się na literę "s", a django domyślnie dodaje kolejne "s" przy wyświetlaniu nazw w liczbie mnogiej, zatem ustawiam verbose_name_plural na "aliases".

Admin model: AliasAdmin

Plik vmapp/aliases/admin.py:

from django.contrib import admin
from .models import Alias


@admin.register(Alias)
class AliasAdmin(admin.ModelAdmin):
    list_display = ("email", "alias", "is_enabled", "ctime", "mtime")

    def email(self, obj):
        return "%s@%s" % (obj.username, obj.domain.domain)

Migracja bazy danych

Tworzymy pliki migrujące bazę danych:

$ ./manage development makemigrations
Migrations for 'aliases':
  vmapp/aliases/migrations/0001_initial.py
    - Create model Alias
Migrations for 'domains':
  vmapp/domains/migrations/0001_initial.py
    - Create model Domain
Migrations for 'users':
  vmapp/users/migrations/0001_initial.py
    - Create model User

Wykonujemy migrację:

$ ./manage development migrate
Operations to perform:
  Apply all migrations: admin, aliases, auth, contenttypes, domains, sessions, users
Running migrations:
  Applying aliases.0001_initial... OK
  Applying domains.0001_initial... OK
  Applying users.0001_initial... OK

Uruchomienie panelu administracyjnego

Uruchamiamy deweloperski serwer aplikacji:

$ ./manage development runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
December 12, 2021 - 18:16:13
Django version 4.0, using settings 'vmapp.settings.development'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Otwieramy w przeglądarce panel administracyjny i logujemy się:

$ firefox --private-window http://127.0.0.1:8000/admin &

img-border

Wypełniamy danymi naszą bazę i sprawdzamy zachowanie panelu. Dla przykładu kilka zrzutów ekranu z mojej instalacji.

Screenshot: domains

Mój komputer ma nazwę "dev.lan", więc taką domenę dodałem w panelu.

img-border

Screenshot: users

Dodałem dwie skrzynki użytkowników w tej domenie (inbox, test). Skrzynkę "test" oznaczyłem jako nieaktywną.

img-border

Screenshot: aliases

Dodałem przekierowania:
- z adresu alias-1@dev.lan na inbox@dev.lan
- catch-all *@dev.lan również kierujący do inbox@dev.lan (wszystkie adresy w domenie kierowane do jednej skrzynki)

img-border

Podsumowanie

Kodu obecnie nie jest dużo, jednak kilka mechanizmów nie jest trywialnych, a szczególnie tych związanych z kryptografią (docelowa autentykacja dovecot). Podczas implementacji pojawia się wiele niuansów i pomysłów. Starałem się na tym etapie utrzymać minimum wymaganej funkcjonalności. Mam jednak na uwadze docelowe wdrożenie i integrację z przyszłymi zewnętrznymi komponentami.

W następnej części zajmiemy się testami jednostkowymi.