Как стать автором
Обновить

Разбираемся с концепцией аутентификации в HTTP

Время на прочтение20 мин
Количество просмотров42K

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

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

В данном случае под бэкенд разработкой я подразумеваю создание разного рода API с использованием протокола HTTP. Мне, как разработчику, часто приходится писать очередной REST API для мобильных приложений или веб страниц. Например, берем Flask и быстро пишем микросервис с API для конкретной части приложения заказчика. Или берем django и django-rest-framework и пишем REST API для стартапа. Во всех таких проектах когда-нибудь, да приходится иметь дело с аутентификацей. Аутентификация через JWT, Oauth. И всегда реализация этой части сводилась к гуглению статей, копированию кода и подходов.

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

Сервис для создания заметок

Давайте представим, что вы делаете веб-сервис API для заметок. Новый стартап кремниевой долины. В качестве фреймворка вы выбрали Flask, БД – sqlite, для доступа к БД через ORM – SQLAlchemy. 

Сервис должен уметь сохранять заметки с заголовком и текстом. Начальная модель данных достаточно простая – заголовок(varchar 256) и содержание(text).

модель заметки
модель заметки
# app.py 
from flask import Flask

app = Flask(__name__)
# db.py
from flask_sqlalchemy import SQLAlchemy

from app import app

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///./notes.db'
db = SQLAlchemy(app)


def init_db():
    from models import User, Note  # noqa
    db.create_all()
# models.py
class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    body = db.Column(db.Text)

    def __repr__(self):
        return '<Note %r>' % self.title

Вы написали эндпоинт, который принимает данные для заметки, вызывает сервисную функцию create_note и возвращает HTTP ответ с кодом 201 CREATED и json, дублирующий информацию о заметке. Почему такой статус? Потому что это стандартный ответ для методов создания сущностей в  REST API. 

Этот эндпоинт может быть использован любой персоной, у кого есть доступ к сервису. Например, наш сервис тестирует  ваш друг Вован.  Вован садится за компьютер, включает свой любимый HTTP клиент и шлет запросы. Заметка Вована сохраняется на сервере.

Процесс  создания  заметки
Процесс создания заметки
# services.py
from flask import Request

from db import db
from models import Note


def create_note(request: Request) -> Note:
    note = Note(
        title=request.json['title'],
        body=request.json['body'],
    )
    db.session.add(note)
    db.session.commit()

    return note
# main.py
from flask import request, jsonify

import services as notes_service
from app import app


@app.route("/notes", methods=['POST'])
def create_note():
    note = notes_service.create_note(request=request)
    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response

Вован решил позвать еще одного друга – Димона. Димон садится за компьютер, включает свой любимый HTTP клиент и тоже шлет запросы. Затем вы вместе любуетесь своими заметками в базе данных.

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

Разные люди тестируют  сервис
Разные люди тестируют сервис

Идентификация

Вы обсуждаете, что делать дальше. Как понять, кто написал заметку? Нужно узнать человека по какому-то признаку. Например, по имени, но имя должно быть уникальным. Можно использовать псевдоним. Пусть Димон будет сообщать, что он dimon.

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

Новая  сущность. Навешаем уникальные идентификаторы пользователям
Новая сущность. Навешаем уникальные идентификаторы пользователям

Таким образом, username позволит нам уникально обозначить пользователя.

from db import db


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(120), unique=True, nullable=False)

    def __repr__(self):
        return '<User %r>' % self.username


class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    body = db.Column(db.Text)

    def __repr__(self):
        return '<Note %r>' % self.title

Теперь вы создаете пару записей в базе данных и заносите туда 2 пользователя – dimon_01 и vovan.  Наша база данных теперь знает об этих пользователях.

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

Новые модели и связи
Новые модели и связи
# models.py
from db import db


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(120), unique=True, nullable=False)

    def __repr__(self):
        return '<User %r>' % self.username


class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    body = db.Column(db.Text)
    user_id = db.Column(
        db.Integer, db.ForeignKey('user.id'),
        nullable=False
    )
    user = db.relationship(
        'User',
        backref=db.backref('notes', lazy=True)
    )

    def __repr__(self):
        return '<Note %r>' % self.title

После того, как мы создали записи пользователей в БД, мы хотим как-то использовать свои учетные данные(username) для создания заметки. Для этого в json помимо добавления данных о заметке(ее название и тело) мы будем передавать еще и учетные данные в поле credentials. В коде эндпоинта мы можем добавить вызов функции, которая считывает учетные данные и ищет пользователя в БД по его псевдониму. Если пользователь найден – возвращаем экземпляр класса модели юзера и передаем в сервисную функцию create_note, в которой создаем заметку с указанием автора.

Функция, считывающая данные пользователя и находящая его в БД, это есть идентификация пользователя.

Идентификация Вована
Идентификация Вована
# main.py
from typing import Optional

from flask import request, jsonify

import services as notes_service
from app import app
from models import User


def identify_user(credentials: Optional[dict]) -> Optional[User]:
    if credentials is None:
        return None

    username = credentials.get('username')
    user = User.query.filter_by(username=username).first()

    return user


@app.route("/notes", methods=['POST'])
def create_note_api():
    credentials = request.json().get('credentials')
    user = identify_user(credentials=credentials)

    note = notes_service.create_note(request=request, user=user)
    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response
# services.py
from flask import Request

from db import db
from models import Note, User


def create_note(request: Request, user: User) -> Note:
    note = Note(
        title=request.json['title'],
        body=request.json['body'],
        user=user,
    )
    db.session.add(note)
    db.session.commit()

    return note

Более формальное определение идентификации можно получить из википедии и викисловаря

Викисловарь:

комп. процесс определения какой-либо сущности (устройства, объекта или данных) путём присвоения уникального идентификатора либо сравнения идентификатора с перечнем присвоенных идентификаторов

 
Википедия:

Идентифика́ция в информационных системах — процедура, в результате выполнения которой для субъекта идентификации выявляется его идентификатор, однозначно идентифицирующий этого субъекта в информационной системе. Для выполнения процедуры идентификации в информационной системе субъекту предварительно должен быть назначен соответствующий идентификатор (то есть проведена регистрация субъекта в информационной системе).

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

Аутентификация

В нашем решении есть одна проблема. Что будет, если кто-то другой представит себя пользователем, которым он не является? Например, какой-нибудь злоумышленник в credentials отошлет тот же самый username, который использует Вован.

Кто-то представляется Вованом
Кто-то представляется Вованом

Ответ прост – злоумышленник будет действовать от имени Вована. Это дыра в безопасности. Чтобы защититься от этого пользователю нужно доказать, что он является Вованом(prove user’s identity).

Вы собираете команду и размышляете, как Вован может доказать, что он и есть Вован.

Сказать, что Вован это Вован, уже недостаточно. Он должен предоставить только то, что есть только у него.  Что может охарактеризовать и выделить Вована от остальных людей и что знает только он? Набор атомов? Уникальный ДНК? Вована отличают от остальных его темные волосы. Пусть вместе со своим псевдонимом сообщит и то, что у него темные волосы. А еще у него есть собака по имени Тузик. Еще его любимый цвет – красный. А ведь по сути это все факты о нем. Его характеристика Пусть Вован сам выбирает, какой факт о нем можно сообщить, чтобы система поверила ему. Нам хватит и одного факта Это, наверное, будет секретная фраза. Один фактор для аутентификации.

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

Модель юзера с паролем
Модель юзера с паролем
from db import db


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(256))

    def __repr__(self):
        return '<User %r>' % self.username


class Note(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(256))
    body = db.Column(db.Text)
    user_id = db.Column(
        db.Integer, db.ForeignKey('user.id'),
        nullable=False
    )
    user = db.relationship(
        'User',
        backref=db.backref('notes', lazy=True)
    )

    def __repr__(self):
        return '<Note %r>' % self.title

Таким образом, мы дополняем БД паролями, которые знают только админ. Админ затем передает лично пароли Вовану и Димон. Те, в свою очередь, используют их при взаимодействии с эндпоинтом для создания заметок. 

Внутри эндпоинта при успешном сценарии происходит следующее:

  1. Мы идентифицируем пользователя по псевдониму(username);

  2. Сравниваете пароли;

  3. Сервисная функция выполняется и создается новая заметка, привязанная к пользователю.

После идентификации пользователя вы сравниваете пароли. Если они совпадают, то пользователь подтверждает свою личность(prove user’s identity. 1 и 2 шаги в совокупности называются аутентификацией.

Процесс  аутентификации
Процесс аутентификации
# main.py
from typing import Optional

from flask import request, jsonify, make_response

import services as notes_service
from app import app
from models import User
from utils import hash_password


def authenticate_user(user: Optional[User], password: str) -> bool:
    if user is None:
        return False

    return user.password == hash_password(password)


def identify_user(credentials: Optional[dict]) -> Optional[User]:
    if credentials is None:
        return None

    username = credentials.get('username')
    user = User.query.filter_by(username=username).first()

    return user


@app.route("/notes", methods=['POST'])
def create_note_api():
    credentials = request.json().get('credentials')
    user = identify_user(credentials=credentials)

    password = credentials.get('password')
    if not authenticate_user(user=user, password=password):
        # Can't authenticate user, permission denied
        response = make_response(
            jsonify(
                {
                    'msg': "Not authenticated"
                }
            ), 403
        )
        response.headers["Content-Type"] = "application/json"
        return response

    note = notes_service.create_note(request=request, user=user)
    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response

Более формальное определение аутентификации можно получить из википедии:

Аутентифика́ция (англ. authentication < греч. αὐθεντικός [authentikos] «реальный, подлинный» < αὐτός [autos] «сам; он самый») — процедура проверки подлинности, например:

- проверка подлинности пользователя путём сравнения введённого им пароля (для указанного логина) с паролем, сохранённым в базе данных пользовательских логинов;

- подтверждение подлинности электронного письма путём проверки цифровой подписи письма по открытому ключу отправителя;

- проверка контрольной суммы файла на соответствие сумме, заявленной автором этого файла.

  Резюмируя:

  • Аутентификация это процесс доказательства личности пользователя;

  • Мы аутентифицируем пользователя с помощью одного факта/характеристики пользователя – его пароля. Однофакторная аутентификация;

  • Перед аутентификацией мы можем идентифицировать юзера;

  • Пример аутентификации – предоставление никнейма/имейла и сверка пароля(пароль доказывает, что этот человек тот, кем себя выдает).

  • Аутентификация может происходить при процедуре логина, а так же при каждом запросе к ресурсу.

Авторизация

Что будет, если пользователь предоставил учетные данные, которых нет в базе данных? Или может пароль от пользователя не совпадает с тем, что есть в БД?

Отказ в аутентификации из-за неправильного пароля
Отказ в аутентификации из-за неправильного пароля
Отказ в аутентификации из-за  невозможности  идентифицировать пользователя
Отказ в аутентификации из-за невозможности идентифицировать пользователя

Вы подумали, что заметки могут оставлять только пользователи, которых мы можем идентифицировать и которые предоставили правильный пароль. Если процедуры идентификации и сравнения не пройдены, то пользователь не имеет доступа к функционалу, то есть он не авторизован выполнять такие действия.

Какой HTTP код мы можем вернуть пользователю в таком случае? Порыскав в интернете список HTTP кодов, вы обнаружили говорящий за себя ответ 401 Unauthroized. То есть говорим пользователю, что он не авторизован.

401 Unauthorized
401 Unauthorized
# main.py

...

@app.route("/notes", methods=['POST'])
def create_note():
    data = request.get_json()
    credentials = data.get('credentials')
    user = identify_user(credentials=credentials)

    if not is_authorized(user=user):
        response = make_response(
            jsonify(
                {
                    'msg': 'Credentials not valid',
                }
            ), 401  # <----------- Unauthorized status
        )
        response.headers["Content-Type"] = "application/json"
        return response


    ...

Вы всей командой вновь тестируете сервис. Каждый из вас обнаруживает, что неудобно как-то писать каждый раз свои учетные данные в json. Вован вспоминает, что в протоколе HTTP сообщения могут содержать заголовки, куда можно будет спрятать данные для аутентификации и последующей авторизации.

 

Какой заголовок нам нужен? Если данные для аутентификации неправильные, то мы не авторизуем юзера и возвращаем код ответа 401 Unathorized. Пусть пользователь введет правильные данные и повторит запрос с заголовком Authorization Мы не можем его авторизовать, потому что у него неправильные данные для аутентификации. Если они станут валидными, то мы его авторизуем. Пользователь посылает заголовок Authorization и говорит как бы контекст "я с повторной авторизцией”. При этом данные для аутентиикации пусть будут представлены в виде одной строки, но разделены двоеточием username:passsword

Таким образом, мы пришли к следующему алгоритму:

  1. Пользователь посылает данные для создания заметки, но без каких-либо данных для аутентификации, либо с неверными данными аутентификации.

  2. Наш эндпоинт видит, что данных для аутентификации нет, либо они неправильные и говорит “ты не авторизован”.

  3. HTTP клиент пользователя просит пользователя ввести правильные данные для аутентификации.

  4. Пользователь посылает данные для создания заметки с заголовком Authorization, содержащим данные для аутентификации перед авторизацией.

  5. Пользователь получает успешный статус о создании заметки.

from typing import Optional

from flask import request, jsonify, make_response

import services as notes_service
from app import app
from models import User
from utils import hash_password


def authenticate_user(user: Optional[User], password: str) -> bool:
    if user is None:
        return False

    return user.password == hash_password(password)


def identify_user() -> Optional[User]:
    credentials = request.headers.get('Authorization')  # <--- new header
    if not credentials:
        return None

    username, _ = credentials.split(':')

    user = User.query.filter_by(username=username).first()

    return user


def is_authorized(user: Optional[User]) -> bool:
    credentials = request.headers.get('Authorization')
    username, password = credentials.split(':')

    if not authenticate_user(user=user, password=password):
        # Can't authenticate user, permission denied
        return False

    return True


@app.route("/notes", methods=['POST'])
def create_note():
    user = identify_user()

    if not is_authorized(user=user):
        response = make_response(
            jsonify(
                {
                    'msg': 'Credentials not valid',
                }
            ), 401
        )
        response.headers["Content-Type"] = "application/json"
        return response

    note = notes_service.create_note(request=request, user=user)

    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response

Масштабирование алгоритма аутентификации

Димон всегда думал наперед и предложил подумать о том, как поступать, если мы придумаем дополнительный способ аутентификации. Тот, который мы сейчас придумали, давайте назовем базовый(basic). Когда пользователь предоставит данные для другого способа аутентификации(например, будет использовать какой-то одноразовый код), мы сообщим ему, что в данном контексте нужно использовать базовую аутентификацию, а не одноразовый код. Будем возвращать в ответе заголовок WWW-Authenticate, который укажет схему аутентификации. Например, WWW-Authenticate: Basic

Еще я не хочу, чтобы учетные данные передавались в виде китайских или кириллических символов. Придется возиться с кодировками, это боль. Пусть лучше будут закодированы с помощью base64. Эта кодировка переводит все в подмножество ASCII символов без возни с другими символами. То, что нужно. Будет выглядеть так: вован:мой_пароль превратится в 0LLQvtCy0LDQvTrQvNC+0Llf0L/QsNGA0L7Qu9GM , а vovan:1234test превратится в dm92YW46MTIzNHRlc3Q=

Вы еще забыли, что если мы добавим в сервис заметок сервис секретных файлов, для аутентификации в котором нужен будет другой хост, то их стоило бы разграничить, чтобы не перепутали окружения. Давайте назовем это realm. Будет выглядеть так: WWW-Authenticate: Basic realm="dev_api"

В итоге вы пришли к следующей схеме, учитывающей масштабирование способов аутентификации:

  1. Пользователь посылает данные для создания заметки, но без каких-либо данных для аутентификации, либо с неверными данными аутентификации

  2. Наш эндпоинт видит, что данных для аутентификации нет, либо они неправильные и говорит “ты не авторизован, предоставь данные для базовой схемы аутентификации”. В добавок мы возвращаем заголовок WWW-Authenticate со значением нужной схемы аутентификации и указанием окружения(realm) API заметок.

  3. HTTP клиент пользователя смотрит, что нужно взять учетные данные для базовой аутентификации и просит пользователя ввести псевдоним и пароль. Смотрит, что нужно использовать тот же самый сервис notes_api.

  4. Пользователь посылает закодированные в base64 данные для создания заметки с заголовком Authorization, содержащим данные для базовой аутентификации перед авторизацией

  5. Пользователь получает успешный статус

# main.py
from typing import Optional

from flask import request, jsonify, make_response
import base64

import services as notes_service
from app import app
from models import User
from utils import hash_password


def authenticate_user(user: Optional[User], password: str) -> bool:
    if user is None:
        return False

    return user.password == hash_password(password)


def identify_user() -> Optional[User]:
    scheme, credentials = request.headers.get('Authorization').split()
    if not credentials:
        return None

    decoded_credentials = base64.b64decode(credentials).decode()
    username, _ = decoded_credentials.split(':')

    user = User.query.filter_by(username=username).first()

    return user


def is_authorized(user: Optional[User]) -> bool:
    scheme, credentials = request.headers.get('Authorization').split()
    decoded_credentials = base64.b64decode(credentials).decode()
    username, password = decoded_credentials.split(':')

    if not authenticate_user(user=user, password=password):
        # Can't authenticate user, permission denied
        return False

    return True


@app.route("/notes", methods=['POST'])
def create_note():
    user = identify_user()

    if not is_authorized(user=user):
        response = make_response(
            jsonify(
                {
                    'msg': 'Credentials not valid',
                }
            ), 401
        )
        response.headers["Content-Type"] = "application/json"
        # Add authentication scheme in header
        response.headers["WWW-Authenticate"] = "Basic realm=notes_api"

        return response

    note = notes_service.create_note(request=request, user=user)

    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response

Middleware

Чтобы не проводить аутентификацию прямо в коде эндпоинта, мы можем переместить этот код в место, которое аутентифицирует юзера до попадания запроса в эндпоинт. Обычно для этого в различных фреймворках используют Middleware.  Этот термин обозначает ряд функций или классов, которые по цепочке вызываются друг за другом перед попаданием запроса в эндпоинт. Они могут менять какие-то данные, что-то проверять и т.д Чтобы избежать дублирования кода, мы можем написать свой кастомный authentication middleware и использовать его повсюду в приложении, где нужна аутентификация. Мы можем использовать просто декораторы. Декораторы оборачивают какую-то функцию и выполняют какой-то код до обработки самим эндпоинтом. Это и будет своего рода middleware в нашем случае

Концепция Middleware
Концепция Middleware
# main.py
import base64
from functools import wraps
from typing import Optional

from flask import request, jsonify, make_response

import services as notes_service
from app import app
from models import User
from utils import hash_password


def authenticate_user(user: Optional[User], password: str) -> bool:
    if user is None:
        return False

    return user.password == hash_password(password)


def identify_user() -> Optional[User]:
    scheme, credentials = request.headers.get('Authorization').split()
    if not credentials:
        return None

    decoded_credentials = base64.b64decode(credentials).decode()
    username, _ = decoded_credentials.split(':')

    user = User.query.filter_by(username=username).first()

    return user


def authenticated_only(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        user = identify_user()
        
        scheme, credentials = request.headers.get('Authorization').split()
    		decoded_credentials = base64.b64decode(credentials).decode()
    		username, password = decoded_credentials.split(':')

    		is_authenticated = authenticate_user(user=user, password=password)
				
        # Not authenticated users not authorized to do this action
        if not is_authenticated:
            response = make_response(
                jsonify(
                    {
                        'msg': 'Credentials not valid',
                    }
                ), 401
            )
            response.headers["Content-Type"] = "application/json"
            response.headers["WWW-Authenticate"] = "Basic realm=notes_api"

            return response

        request.user = user

        return f(*args, **kwargs)
    return wrapper


@app.route("/notes", methods=['POST'])
@authenticated_only
def create_note():
    note = notes_service.create_note(request=request, user=request.user)

    response = make_response(
        jsonify(
            {
                'title': note.title,
                'body': note.body,
            }
        ), 201
    )
    response.headers["Content-Type"] = "application/json"
    return response

Выводы и зачем это все

Что мы узнали из этой статьи:

  • идентификация это процесс выявления какой-то сущности с идентификатором путем сравнения с уже присвоенными идентификаторами. Мы хотим узнать, кто есть кто в системе, поэтому ввели новую сущность юзера, которая имеет уникальный идентификатор username. Он и позволяет нам идентифицировать юзера. Например, под идентификацией можно понимать поиск в БД пользователя с указанным username/email.

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

  • авторизация это проверка прав доступа. Обычно перед авторизацией в HTTP проверяется аутентификация. Например, если доступ к ресурсу ограничен только аутентифицированными юзерами.

Почему эта статья может помочь? Система аутентификации в таких фреймворках, как django, django-rest-framework, flask, fastAPI использует похожие понятия и процессы, хотя детали реализации разные - где-то нужно наследовать специальный класс, где-то реализовать вызываемый объект и т.д.

Кусок внутреннего кода django-rest-framework:
class BasicAuthentication(BaseAuthentication):
    """
    HTTP Basic authentication against username/password.
    """
    www_authenticate_realm = 'api'

    def authenticate(self, request):
        """
        Returns a `User` if a correct username and password have been supplied
        using HTTP Basic authentication.  Otherwise returns `None`.
        """
        auth = get_authorization_header(request).split()

        if not auth or auth[0].lower() != b'basic':
            return None

        if len(auth) == 1:
            msg = _('Invalid basic header. No credentials provided.')
            raise exceptions.AuthenticationFailed(msg)
        elif len(auth) > 2:
            msg = _('Invalid basic header. Credentials string should not contain spaces.')
            raise exceptions.AuthenticationFailed(msg)

        try:
            try:
                auth_decoded = base64.b64decode(auth[1]).decode('utf-8')
            except UnicodeDecodeError:
                auth_decoded = base64.b64decode(auth[1]).decode('latin-1')
            auth_parts = auth_decoded.partition(':')
        except (TypeError, UnicodeDecodeError, binascii.Error):
            msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
            raise exceptions.AuthenticationFailed(msg)

        userid, password = auth_parts[0], auth_parts[2]
        return self.authenticate_credentials(userid, password, request)

    def authenticate_credentials(self, userid, password, request=None):
        """
        Authenticate the userid and password against username and password
        with optional request for context.
        """
        credentials = {
            get_user_model().USERNAME_FIELD: userid,
            'password': password
        }
        user = authenticate(request=request, **credentials)

        if user is None:
            raise exceptions.AuthenticationFailed(_('Invalid username/password.'))

        if not user.is_active:
            raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))

        return (user, None)

    def authenticate_header(self, request):
        return 'Basic realm="%s"' % self.www_authenticate_realm

кусок кода FastAPI
class HTTPBasic(HTTPBase):
    def __init__(
        self,
        *,
        scheme_name: Optional[str] = None,
        realm: Optional[str] = None,
        description: Optional[str] = None,
        auto_error: bool = True,
    ):
        self.model = HTTPBaseModel(scheme="basic", description=description)
        self.scheme_name = scheme_name or self.__class__.__name__
        self.realm = realm
        self.auto_error = auto_error

    async def __call__(  # type: ignore
        self, request: Request
    ) -> Optional[HTTPBasicCredentials]:
        authorization: str = request.headers.get("Authorization")
        scheme, param = get_authorization_scheme_param(authorization)
        if self.realm:
            unauthorized_headers = {"WWW-Authenticate": f'Basic realm="{self.realm}"'}
        else:
            unauthorized_headers = {"WWW-Authenticate": "Basic"}
        invalid_user_credentials_exc = HTTPException(
            status_code=HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers=unauthorized_headers,
        )
        if not authorization or scheme.lower() != "basic":
            if self.auto_error:
                raise HTTPException(
                    status_code=HTTP_401_UNAUTHORIZED,
                    detail="Not authenticated",
                    headers=unauthorized_headers,
                )
            else:
                return None
        try:
            data = b64decode(param).decode("ascii")
        except (ValueError, UnicodeDecodeError, binascii.Error):
            raise invalid_user_credentials_exc
        username, separator, password = data.partition(":")
        if not separator:
            raise invalid_user_credentials_exc
        return HTTPBasicCredentials(username=username, password=password)

кусок кода flask_httpauth
class HTTPBasicAuth(HTTPAuth):
    def __init__(self, scheme=None, realm=None):
        super(HTTPBasicAuth, self).__init__(scheme or 'Basic', realm)

        self.hash_password_callback = None
        self.verify_password_callback = None

    def hash_password(self, f):
        self.hash_password_callback = f
        return f

    def verify_password(self, f):
        self.verify_password_callback = f
        return f

    def get_auth(self):
        # this version of the Authorization header parser is more flexible
        # than Werkzeug's, as it also accepts other schemes besides "Basic"
        header = self.header or 'Authorization'
        if header not in request.headers:
            return None
        value = request.headers[header].encode('utf-8')
        try:
            scheme, credentials = value.split(b' ', 1)
            username, password = b64decode(credentials).split(b':', 1)
        except (ValueError, TypeError):
            return None
        try:
            username = username.decode('utf-8')
            password = password.decode('utf-8')
        except UnicodeDecodeError:
            username = None
            password = None
        return Authorization(
            scheme, {'username': username, 'password': password})

    def authenticate(self, auth, stored_password):
        if auth:
            username = auth.username
            client_password = auth.password
        else:
            username = ""
            client_password = ""
        if self.verify_password_callback:
            return self.ensure_sync(self.verify_password_callback)(
                username, client_password)
        if not auth:
            return
        if self.hash_password_callback:
            try:
                client_password = self.ensure_sync(
                    self.hash_password_callback)(client_password)
            except TypeError:
                client_password = self.ensure_sync(
                    self.hash_password_callback)(username, client_password)
        return auth.username if client_password is not None and \
            stored_password is not None and \
            hmac.compare_digest(client_password, stored_password) else None

 В кусках кода сверху можно заметить, что в деталях реализации они отличаются, но есть и одинаковые элементы. Например, краем глаза можно зацепиться за методы и переменные authenticate , заголовокAuthorization , заголовок WWW-Authenticate , scheme, credentials , HTTP_401_UNAUTHORIZED
Хотя идентификацию юзера тут явно не выделяют, но этот процесс присутствует.

Итого - вы можете примерно понять о чем речь. Поменять эту аутентификацию и реализовать свою. Например, для JWT аутентификации нужно будет точно так же читать заголовок Authorization , парсить scheme (Bearer) и credentials (сам токен), идентифицировать юзера(по айди в токене) и аутентифицировать(проверять валидность токена и существование юзера). И фреймворки как раз прячут это все где-то в части, связанной с middleware.

В следующей статье мы реализуем что-то более практичное. Например, кастомную JWT аутентификацию в django-rest-framework .

Ссылки для более глубокого изучения

Теги:
Хабы:
Всего голосов 21: ↑19 и ↓2+19
Комментарии4

Публикации

Истории

Работа

Python разработчик
120 вакансий
Data Scientist
75 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань