Pull to refresh

Token-Based Authetification в автономных системах посредством Qt6 с использованием Qr-кодов. Http-сервер

Level of difficultyMedium
Reading time8 min
Views2.2K

Содержание

Общая схема

Хранить и отдавать сервер будет изображения из какой-либо директории (сама директория будет параметром командной строки).

Запросы с localhost будут проходить без авторизации, а для всех остальных будет проверяться http-заголовок Authorization на предмет наличия и валидности токена.

Для картинок будут две ручки: одна возвращает json-массив с именами картинок, вторая — картинку по имени.

Для токенов будет crud, доступный только по localhost.

Репозиторий изображений

Простой read-only репозиторий на два метода: получить список имён, и получить картинку по имени. Ничего лишнего.

Реализация ImageRepository
ImageRepository.h
namespace storages {
class ImageRepository {
private:
    QDir m_root;
public:
    ImageRepository(const QString &root = QDir::rootPath());
public:
    QImage image(const QString& name);
    QStringList images() const;
};
}

ImageRepository.cpp
namespace storages {
ImageRepository::ImageRepository(const QString &root)
    :m_root{ root } { m_root.setNameFilters(QStringList{} << "*.png" << "*.jpg" << "*.jpeg"); }

QImage ImageRepository::image(const QString &name) {
    return QImage{ m_root.filePath(name) };
}
QStringList ImageRepository::images() const {
    return m_root.entryList(QDir::Filter::Files | QDir::Filter::Readable);
}
}

Репозиторий токенов

Тут всё малость сложнее: нам нужны возможности чтения и записи, а также проверки на валидность (existence и expiration). При этом нужно учитывать, что наш великий сервис может в любой момент упасть, поэтому нужно какое-то хранилище на диске.

В качестве токена будет выступать QUuid, время представим в виде QDateTime, а в качестве хранилища — старый добрый QSettings. Хранить будем в формате ключ-значение, где uuid — ключ, а expiration — значение.

Для этого определим 4 метода:

  • createToken — создаёт и возвращает новый токен доступа

  • removeToken — удаляет токен при его наличии

  • isValidToken — проверяет existence и expiration у токена

  • tokens — возвращает мапу token-expiration

Дополнительно при любом обращении должен вызываться метод removeExpiredTokens(), удаляющий все протухшие токены.

Реализация TokenRepository
TokenRepository.h
namespace storages {
class TokenRepository {
private:
    static inline constexpr const char* s_tokens = "tokens";
    QSettings m_storage;
public:
    TokenRepository(const QString& path = {});
public:
    bool isValidToken(const QUuid& bearer);
    QMap<QUuid, QDateTime> tokens();
public:
    QUuid createToken(const QDateTime& expiration);
    void removeToken(const QUuid& bearer);
private:
    void removeExpiredTokens();
};
}

TokenRepository.cpp
namespace storages {
TokenRepository::TokenRepository(const QString &path)
    :m_storage{ path, QSettings::Format::IniFormat } { 
        m_storage.beginGroup(s_tokens); 
    }


bool TokenRepository::isValidToken(const QUuid &token) {
    removeExpiredTokens();
    return m_storage.contains(token.toString());
}
QMap<QUuid, QDateTime> TokenRepository::tokens() {
    removeExpiredTokens();
    QMap<QUuid, QDateTime> result{};
    for(const auto& key: m_storage.allKeys())
        result[QUuid::fromString(key)] = m_storage.value(key).toDateTime();

    return result;
}

QUuid TokenRepository::createToken(const QDateTime& expiration) {
    removeExpiredTokens();
    const auto token = QUuid::createUuid();
    m_storage.setValue(token.toString(), expiration);
    return token;
}
void TokenRepository::removeToken(const QUuid &token) {
    removeExpiredTokens();
    m_storage.remove(token.toString());
}

void TokenRepository::removeExpiredTokens() {
    const auto current = QDateTime::currentDateTime();
    for(const auto& key: m_storage.allKeys())
        if(current > m_storage.value(key).toDateTime())
            m_storage.remove(key);
}
}

Тут стоит отметить, что по CoreGuidelines следовало сделать методы isValidToken и tokens константными, а m_storage сделать mutable, дабы подчеркнуть логическую неизменность и отделить её от бинарной, но тут я решил этого не делать.

Контроллеры

Эти объекты нужны просто чтобы трансформировать данные из json в представление, которым пользуются репозитории. Ничего сложного.

Тут мы уже увидим использование класса QHttpServerResponse. Это класс, способный вернуть массив байтов, строку, json, короче всё, что должен уметь возвращать хороший http-сервер.

Реализация ImageController
ImageController.h
namespace controllers {
class ImageController {
private:
    std::shared_ptr<storages::ImageRepository> m_images;
public:
    ImageController(const std::shared_ptr<storages::ImageRepository> &images);
public:
    QHttpServerResponse image(const QString& name) const;
    QHttpServerResponse imagesList() const;
};
}

ImageController.cpp
namespace controllers {
ImageController::ImageController(const std::shared_ptr<storages::ImageRepository> &images)
    :m_images{ std::move(images) } {}

QHttpServerResponse ImageController::image(const QString &name) const {
    QByteArray result{};
    //QBuffer - простой QIODevice для работы с QByteArray
    QBuffer buffer{ &result };
    m_images->image(name).save(&buffer, "PNG");
    return QHttpServerResponse{ result };
}
QHttpServerResponse ImageController::imagesList() const {
    return QHttpServerResponse{ QJsonArray::fromStringList(m_images->images()) };
}
}

Реализация TokenController
TokenController.h
namespace controllers {
class TokenController {
private:
    std::shared_ptr<storages::TokenRepository> m_tokens;
public:
    TokenController(const std::shared_ptr<storages::TokenRepository> &tokens);
public:
    QHttpServerResponse createToken(quint64 expirationSpan);
    QHttpServerResponse removeToken(const QByteArray& token);
    QHttpServerResponse getAllTokens();
};
}

TokenController.cpp
namespace controllers {
TokenController::TokenController(const std::shared_ptr<storages::TokenRepository> &tokens)
    :m_tokens{ std::move(tokens) } { }

QHttpServerResponse TokenController::createToken(quint64 expirationSpan) {
    return QHttpServerResponse{ m_tokens->createToken(QDateTime::currentDateTime().addSecs(expirationSpan)).toString() };
}
QHttpServerResponse TokenController::removeToken(const QByteArray &bearer) {
    m_tokens->removeToken(QUuid::fromString(bearer));
    return QHttpServerResponse{ QHttpServerResponse::StatusCode::Accepted };
}
QHttpServerResponse TokenController::getAllTokens() {
    QJsonObject result{};
    const auto elements = m_tokens->tokens();
    for(auto iter = elements.begin(); iter != elements.end(); ++iter)
        result[iter.key().toString(QUuid::StringFormat::WithoutBraces)] = iter.value().toSecsSinceEpoch();

    return result;
}
}

Д

Для простоты json-интерфейса, в метод createToken нужно передавать число секунд, которое токен будет жить: если нужен токен, живущий сутки, нужно передать 24 * 60 * 60 = 86400.

Как видно, эти классы действительно не делают ничего, кроме перевода json-ов.

Http-фильтрация

Ну и, наверное, самая сложная часть этого сервиса. Ограничение доступа.

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

Для начала нам нужен фильтр. Для него введём два класса:

  • AbstractHttpController — контроллер, принимающий N параметров различных типов и возвращающий QHttpServerResponse.

  • TokenAuthorizator — собственно, фильтр, принимающий N параметров и QHttpServerRequest на конце.

AbstractHttpController.h
namespace utils {
template<typename...Args>
struct AbstractHttpController {
    Q_DISABLE_COPY(AbstractHttpController);
    virtual QHttpServerResponse handle(const Args&...) = 0;
    virtual ~AbstractHttpController() = default;
    AbstractHttpController() = default;
};
}

Просто структура с дефолтными конструктором и деструктором и методом handle, принимающим variadic template.

TokenAuthorizator.h
namespace utils {
template<typename...Args>
class TokenAuthorizator: public AbstractHttpController<Args..., QHttpServerRequest> {
private:
    std::function<QHttpServerResponse(const Args&...)> m_next;
    std::shared_ptr<storages::TokenRepository> m_tokens;
public:
    TokenAuthorizator(std::shared_ptr<storages::TokenRepository> tokens, const std::function<QHttpServerResponse(const Args&...)>& next)
        :m_next{ std::move(next) }, m_tokens{ tokens } {}
public:
    virtual QHttpServerResponse handle(const Args&...parametes, const QHttpServerRequest& request) override {
        if(not request.remoteAddress().isLoopback())
            if(not m_tokens->isValidToken(QUuid::fromString(request.value("Authorization"))))
                return QHttpServerResponse::StatusCode::Unauthorized;

        return m_next(parametes...);
    }
};
}

Тут уже несколько интереснее:

  1. Класс принимает Args... и QHttpServerRequest. Args... он передаёт дальше, а по QHttpServerRequest делает фильтрацию. В реальном коде нужна ещё специализация шаблона на случай, если QHttpServerRequest тоже нужно передавать.

  2. В методе handle сначала идёт проверка на то, что запрос пришёл не с ::1 (с этого ПК), а откуда-то извне. И если пришедший извне запрос не имеет валидного токена, возвращается code 401 (Unauthorized).

  3. В реальном коде не стоит передавать tokens в TokenAuthorizator напрямую. Стоит сделать прокладку в виде предиката, а уже tokens закидывать в этот предикат. Это удалит зависимость между этими классами.

Собрать всё в кучу

Осталось лишь соединить все эти куски вместе. Удобного Dependency Injection, как в C#, мы из коробки не имеем, да и тут он по большей части излишен. Поэтому соединяем прямо в main.

main.cpp
QCoreApplication app{ argc, argv };
    if(app.arguments().size() == 2)
        qFatal("Use app: <app-name> <image-dir> <tokens-storage>");

auto images = std::make_shared<ImageRepository>(argv[1]);
auto tokens = std::make_shared<TokenRepository>(argv[2]);
auto server = std::make_shared<QHttpServer>();

auto imageController = std::make_shared<ImageController>(images);
auto tokenController = std::make_shared<TokenController>(tokens);

//можно воспользоваться std::bind или std::bind_from (since C++20)
auto getAllTokens = std::make_shared<TokenAuthorizator<>>(tokens,
    [tokenController]() { return tokenController->getAllTokens(); });
auto createToken = std::make_shared<TokenAuthorizator<quint64>>(tokens,
    [tokenController](quint64 expiration) { return tokenController->createToken(expiration); });
auto removeToken = std::make_shared<TokenAuthorizator<QByteArray>>(tokens,
    [tokenController](const QByteArray& token) { return tokenController->removeToken(token); });

auto getImagesList = std::make_shared<TokenAuthorizator<>>(tokens,
    [imageController]() { return imageController->imagesList(); });
auto getImage = std::make_shared<TokenAuthorizator<QString>>(tokens,
    [imageController](const QString& image) { return imageController->image(image); });

//Про api сервера и как пользоваться методом route можно
//почитать тут: https://doc.qt.io/qt-6/qhttpserver.html
server->route("/auth/token/all/", [getAllTokens](const QHttpServerRequest& request) {
    return getAllTokens->handle(request);
});
server->route("/auth/token/create/<arg>", [createToken](quint64 expirationSpan, const QHttpServerRequest& request) {
    return createToken->handle(expirationSpan, request);
});
server->route("/auth/token/remove/<arg>", [removeToken](const QByteArray& token, const QHttpServerRequest& request) {
    return removeToken->handle(token, request);
});

server->route("/data/images/list", [getImagesList](const QHttpServerRequest& request) {
    return getImagesList->handle(request);
});
server->route("/data/images/<arg>", [getImage](const QString& image, const QHttpServerRequest& request) {
    return getImage->handle(image, request);
});

//Отвечаем на запросы с любых адресов на порт 5555
server->listen(QHostAddress::SpecialAddress::Any, 5555);
return app.exec();

Тестируем

Для теста достаточно двух устройств: на одном запустим сервер (ноутбук), с другого нужно делать запросы (качаем любой API-tester на мобилу и радуемся жизни).

Для тестирования с ноута достаточно вбить запрос в строку браузера, и посмотреть, как всё это работает. Тут стоит отметить, что отдавать имена картинок — плохое решение, потому что в номерах будут пробелы. Но в реальном проекте все картинки вы, скорее всего, будете как-то индексировать, присваивать им имена и т.п.

Тест на ноуте
Список изображений
Список изображений
Список изображений
Запрос изображения

Чтобы связать ноутбук и телефон, достаточно раздать Wifi с одного устройства, и подключиться с другого.

Попробуем для начала сделать запрос без токена:

Как видим, не выходит. Теперь пробуем подключиться по токену:

Отправляем с доверенного устройства (ноутбука) запрос http://127.0.0.1:5555/auth/token/create/86400

В ответе придёт созданный токен
В ответе придёт созданный токен

Вставляем этот токен в хедер Authorization, и всё получается.

Авторизованный доступ
Авторизованный доступ

Заключение

В реальном проекте вы, скорее всего, будете использовать что-то типа OAuth. Для OAuth будет одно отличие: перед кодом будет слово Bearer.

Т.е.
Authorzation: 0d2c7f09-8a3a-4750-8d47-9a052bb1587f
превратится в
Authorzation: Bearer 0d2c7f09-8a3a-4750-8d47-9a052bb1587f

Но особой разницы в этом нет.

Tags:
Hubs:
0
Comments2

Articles