Пишем web на Python3, делим пользователей на группы

newbie

Опубликован:  2019-03-19T08:11:49.041531Z
Отредактирован:  2019-03-21T07:04:43.095196Z

Продолжаем работу над проектом selfish. В этой демонстрации я продолжу разработку системы авторизации пользователей, у selfish появится администратор и пара новых юзеров, всех пользователей приложения я разделю на группы в соответствии с начальным замыслом, изложенным в предыдущем выпуске блога.

Поскольку в начальном замысле нашлось место администратору приложения, необходимо предусмотреть инструменты удобные для создания такого пользователя прямо в терминале - это позволит некоторым образом облегчить процедуру развёртывания приложения на сервере в дальнейшем. Забегая немного вперёд, следует раскрыть ещё одну особенность приложения, дело в том, что в перспективе система авторизации selfish будет предусматривать смену пользователем своего почтового ящика, зарегистрированного в аккаунте. Эта возможность будет реализована в два шага:

  1. Запрос на смену адреса;
  2. Подтверждение смены адреса вводом пароля.

Для хранения нового адреса в промежутке между этими действиями мне понадобится ещё одно свойство в модели аккаунта, и поскольку при регистрации суперпользователя (администратора приложения) это свойство аккаунта будет опрашиваться, модифицировать аккаунт лучше уже сейчас. Открываю файл selfish/models/auth.py и дописываю новое свойство классу Account.

class Account(db.Model):
    __tablename__ = 'accounts'

    ...
    swap = db.Column(db.String(128), default=None)
    ...

Иду в терминал с активированным виртуальным окружением, находясь в базовом каталоге selfish делаю очередную миграцию и обновляю базу данных.

python manage.py db migrate -m"Modify Account"
python manage.py db upgrade

XWhIeSY5Zz.png

Можно подключиться к базе данных в psql и проверить результат выполненных операций.

OQpHiXnBal.png

В таблице accounts появилось поле swap, которое может оставаться пустым, можно двигаться дальше.

Деление пользователей сервиса на группы в соответствии с начальным замыслом - вещь достаточно условная в данном случае, так как доступ к функциональным возможностям сервиса определяется на базе имеющихся у пользователя разрешений, и этого достаточно. Тем не менее, в профиле пользователя намного удобней указать принадлежность к той или иной группе, чем перечислять все его разрешения. Определять группы я буду аналогично разрешениям в файле auth_units.py, открываю его в редакторе и дописываю два новых объекта.

Group = namedtuple(
    'Group', ['pariah',
              'taciturn',
              'commentator',
              'blogger',
              'curator',
              'root'])

groups = Group(
    pariah='Изгнанные',
    taciturn='Читатели',
    commentator='Комментаторы',
    blogger='Блогеры',
    curator='Модераторы',
    root='Администраторы')

Объект groups является целевым и даст мне возможность в будущем легко поправить названия групп в одном месте, если такая необходимость возникнет. При помощи этого целевого объекта можно довольно легко определить принадлежность пользователя к той или иной группе на основе имеющихся у этого пользователя разрешений. Возвращаюсь к файлу с моделями auth.py, импортирую в нём groups и дописываю в класс User ещё одно особое свойство.

...
from .auth_units import defaults, groups, permissions
...


class User(UserMixin, db.Model):
    ...

    @property
    def group(self):
        if self.can(permissions.ADMINISTER):
            return groups.root
        if self.can(permissions.POLICE):
            return groups.curator
        if self.can(permissions.WRITE_JOURNAL):
            return groups.blogger
        if self.can(permissions.COMMENT) or self.can(permissions.SEND_PM):
            return groups.commentator
        if self.can(permissions.READ_JOURNAL):
            return groups.taciturn
        if self.can(permissions.CANNOT_LOG_IN):
            return groups.pariah

    @group.setter
    def group(self, group):
        raise AttributeError('group cannot be set directly')

Чуть-чуть позже, когда я создам пользователей некоторых групп, будет продемонстрирована работа этого кода.

Процесс развёртывания web-приложения на сервер предполагает регистрацию хотя бы одного пользователя, который неограничен в правах и будет иметь доступ абсолютно ко всем функциональным возможностям приложения. Сейчас я разработаю инструмент, с помощью которого такой пользователь может быть зарегистрирован одной командой в терминале.

Для создания администратора необходимо получить из пользовательского ввода в терминале три параметра:

  • желаемый псевдоним пользователя;
  • адрес электронной почты;
  • желаемый пароль пользователя.

При этом, мне необходимо предотвратить или отсеять ввод заведомо неверных данных. Начнём с псевдонима. Псевдоним пользователя должен содержать не более 16 символов из определённого набора и должен быть уникальным. Создаю новый файл.

touch selfish/manage_tools.py

Открываю этот файл в редакторе и даю ему следующий код.

import re

from .models.auth import User

USERNAME = 'Enter your username: '
MESSAGE = """Username must be from 3 to 16 symbols (Latin letters, numbers,
dots, hyphens, underscores) and begin with any Latin letter."""


def get_username():
    pattern = re.compile(r'^[A-Za-z][A-Za-z0-9\-_.]{2,15}$')
    username = input(USERNAME)
    while True:
        if not pattern.match(username):
            print(MESSAGE)
            username = input(USERNAME)
            continue
        if User.query.filter_by(username=username).first():
            print('This name is already registered. Sorry.\n')
            username = input(USERNAME)
            continue
        return username

Функция get_username запрашивает у пользователя его желаемый псевдоним в бесконечном цикле, проверяет пользовательский ввод по двум условиям: соответствие регулярному выражению и отсутствие в базе данных записи пользователя с введённым псевдонимом. Если пользовательский ввод отвечает обоим условиям, то функция вернёт введённую пользователем строку.

Мне нужна ещё одна аналогичная функция для получения от пользователя адреса электронной почты, пишу в этот же файл.

...
from validate_email import validate_email

from .models.auth import Account, User
...

EMAIL = 'Enter your email address: '


def get_email():
    address = input(EMAIL)
    while True:
        if not validate_email(address):
            print('This is not a valid email address.\n')
            address = input(EMAIL)
            continue
        account = Account.query.filter_by(address=address).first()
        swapped = Account.query.filter_by(swap=address).first()
        if (account and account.user) or swapped:
            print('This email address cannot be registered. Sorry.\n')
            address = input(EMAIL)
            continue
        return address

Функция get_email запрашивает у пользователя его желаемый адрес электронной почты в бесконечном цикле, проверяет пользовательский ввод по двум условиям: введён адрес электронной почты, введённый адрес отсутствует в базе данных в аккаунтах зарегистрированных пользователей. Если условия выполняются, то функция возвращает введённую строку.

Третья вспомогательная функция будет запрашивать у пользователя пароль. Ввод пароля осуществляется дважды и проверяется, что оба раза введена одна и та же фраза. Дописываю в этот же файл.

...
from getpass import getpass
...

PASSWORD = 'Enter your password: '


def get_password():
    phrase = getpass(PASSWORD)
    while True:
        confirm = getpass('Confirm the password: ')
        if confirm != phrase:
            print('Passwords must match\n')
            phrase = getpass(PASSWORD)
            continue
        return phrase

Функция getpass из одноимённого модуля стандартной библиотеки позволяет во время ввода не отображать в консоли вводимые символы. Разработанная функция get_password дважды запрашивает ввод пароля в бесконечном цикле, проверяет, что оба раза введён один и тот же пароль, и если так, то возвращает введённую строку.

Теперь необходимо каким-то образом определить набор разрешений администратора, дописываю в файл auth_units.py ещё один целевой объект.

roots = [permission for permission in permissions
         if permission != permissions.CANNOT_LOG_IN]

Таким образом в наборе администратора будут все возможные разрешения, кроме блокировки. Все необходимые инструменты в моём распоряжении есть, и теперь я могу определить новую команду для менеджера проекта. Модифицирую в редакторе файл manage.py, дописываю следующее.

...
from sqlalchemy.exc import OperationalError, ProgrammingError
...
from selfish.manage_tools import get_email, get_password, get_username
...

from selfish.models.auth_units import permissions, roots

ERROR_MESSAGE = """Make sure your database and its tables exist.
Check your 'config.py' then use the 'db upgrade' command and then try again."""

...


def make_shell_context():
    return {'app': app, 'db': db, 'Account': Account, 'User': User,
            'Permission': Permission, 'permissions': permissions,
            'roots': roots}

...


@manager.command
def create_root():
    try:
        Permission.insert_permissions()
    except (OperationalError, ProgrammingError):
        print(ERROR_MESSAGE)
        return None
    username, address, password = get_username(), get_email(), get_password()
    account = Account.query.filter_by(address=address).first()
    if account is None:
        account = Account(address=address)
    root = User(username=username, password=password)
    root.account = account
    root.permissions = roots
    db.session.add(root)
    db.session.commit()

...

Здесь я определил новую команду менеджера, с помощью которой можно зарегистрировать администратора web-приложения. В этой команде менеджера сначала производится попытка заполнить таблицу permissions в базе данных при помощи соответствующего метода класса Permission, если это удаётся, то при помощи соответствующих инструментов запрашиваются необходимые данные, и на их основе регистрируется новый пользователь, который получает все установленные для администратора разрешения. Пробую...

c8LpC46xXc.png

На следующем этапе разработки мне понадобится дополнительно два пользователя:

  • один пользователь в группе "Изгнанные";
  • один пользователь в группе "Читатели".

На их примере я разработаю и покажу в действии ограничения в сервисе selfish. Пока я могу создать новых пользователей указанных групп только в служебной консоли, воспользуюсь ей.

>>> scain_acc = Account(address='scain@example.com')
>>> scain = User(username='scain', password='aa')
>>> scain.permissions = [permissions.CANNOT_LOG_IN]
>>> db.session.add(scain)
>>> db.session.commit()
>>> 

Пользователь scain - представитель группы "Изгнанные". К нему в компанию создам ещё одного пользователя - представителя группы "Читатели".

>>> scain.account = scain_acc
>>> fossman_acc = Account(address='fossman@example.com')
>>> fossman = User(username='fossman', password='aa')
>>> fossman.permissions = [permissions.READ_JOURNAL]
>>> fossman.account = fossman_acc
>>> db.session.add(scain)
>>> db.session.add(fossman)
>>> db.session.commit()
>>> 

Отфильтрую двух созданных ранее пользователей: newbie и webmaster.

>>> newbie = User.query.filter_by(username='newbie').first()
>>> webmaster = User.query.filter_by(username='webmaster').first()
>>> 

Теперь я имею возможность посмотреть принадлежность группе для каждого существующего пользователя.

>>> scain.group
'Изгнанные'
>>> fossman.group
'Читатели'
>>> newbie.group
'Блогеры'
>>> webmaster.group
'Администраторы'
>>> 

rABPSZrYob.png

Отлично, на базе этих простых инструментов в перспективе я разработаю логику web-интерфейса, а пока следует заглянуть в базу данных при помощи psql и посмотреть, как проделанные действия отразились на данных.

$ psql -h 192.168.56.101 -d selfish_d -U newbie
Пароль пользователя newbie: 
psql (11.2 (Debian 11.2-2), сервер 11.1 (Debian 11.1-2))
SSL-соединение (протокол: TLSv1.3, шифр: TLS_AES_256_GCM_SHA384, бит: 256, сжатие: выкл.)
Введите "help", чтобы получить справку.

selfish_d=> SELECT id, permission, initial FROM permissions;
id |      permission       | initial 
----+-----------------------+---------
1  | заблокирован          | f
2  | читать журнал         | t
3  | создавать ленту       | t
4  | писать в приват       | t
5  | комментировать журнал | t
6  | вести журнал          | t
7  | следить за порядком   | f
8  | без ограничений       | f
(8 строк)

selfish_d=> SELECT id, username, permissions FROM users;
id | username  |                                                             permissions                                                              
----+-----------+--------------------------------------------------------------------------------------------------------------------------------------
1 | newbie    | {"читать журнал","создавать ленту","писать в приват","комментировать журнал","вести журнал"}
2 | webmaster | {"читать журнал","создавать ленту","писать в приват","комментировать журнал","вести журнал","следить за порядком","без ограничений"}
3 | scain     | {заблокирован}
4 | fossman   | {"читать журнал"}
(4 строки)

selfish_d=> SELECT id, address, swap FROM accounts;
id |       address       | swap 
----+---------------------+------
1  | newbie@auriz.ru     | 
2  | webmaster@auriz.ru  | 
3  | scain@example.com   | 
4  | fossman@example.com | 
(4 строки)

selfish_d=> SELECT users.id, users.username, accounts.address FROM users, accounts WHERE users.id = accounts.user_id;
id | username  |       address       
----+-----------+---------------------
1  | newbie    | newbie@auriz.ru
2  | webmaster | webmaster@auriz.ru
3  | scain     | scain@example.com
4  | fossman   | fossman@example.com
(4 строки)

selfish_d=> 

Все таблицы в норме, аномалий в базе данных нет, пользователи зарегистрированы, разрешения назначены, группы определены. Текущее состояние кода приложения можно увидеть в моём аккаунте на gitlab.com. Все поставленные на этот этап разработки цели достигнуты, продолжение следует, и в следующем выпуске блога я покажу, как на основе разработанных инструментов можно ограничивать доступ к сайту авторизованным пользователям сервиса, будет интересно. Уважаемые друзья, свои вопросы мне вы можете задать в комментариях ниже, для этого достаточно получить пароль и войти в сервис.

Комментарии: