Русский
Русский
English
Статистика
Реклама

Jwt

Best practices для клиент-серверного проекта PoC

22.12.2020 12:11:10 | Автор: admin
image
Типичный проект PoC (Proof of Concept) состоит из клиента с GUI, сервера c бизнес логикой и API между ними. Также используется база данных, хранящая оперативную информацию и данные пользователей. Во многих случаях необходима связь с внешними системами со своим API.
Когда у меня возникла необходимость в создании проекта PoC, и я начал разбираться в деталях, то оказалось, что порог вхождения в веб-программирование весьма высок. В крупных проектах для каждого компонента есть выделенные специалисты: front-end, back-end разработчики, UX/UI дизайнеры, архитекторы баз данных, специалисты по API и информационной безопасности, системные администраторы. В небольшом PoC надо самому во всем разобраться, выбрать подходящее техническое решение, реализовать и развернуть. Ситуацию ухудшает тот факт, что из обучающих материалов не всегда понятно, почему предлагается сделать именно так, а не иначе, есть ли альтернативы, является ли решение best practice или это частное мнение автора. Поэтому я разработал заготовку под названием Common Test DB, отвечающую лучшим практикам. Ее можно использовать для начала любого проекта, остается только наполнить функциональным смыслом.
В статье я подробно опишу примененные best practices, расскажу про имеющиеся альтернативы и в конце размещу ссылки на исходники и работающий в сети пример.

Требования к проекту PoC


Начнем с минимальных требований к проекту PoC. В статье Блокчейн: что нам стоит PoC построить? я уже рассматривал из каких частей состоит универсальная информационная система. В этой статье подробно разберем состав типичного проекта PoC. Даже минимальный проект в клиент-серверной архитектуре требует значительной функциональности и технической проработки:

Клиент:
  • Должен осуществлять запросы к серверу, используя REST API
  • Регистрировать, авторизовать, аутентифицировать пользователей
  • Принимать, разбирать и визуализировать пришедшие данные

Сервер
  • Должен принимать HTTP/HTTPS запросы от клиента
  • Осуществлять взаимодействие с внешними системами
  • Взаимодействовать с базой данных (реализовать базовую функциональности CRUD)
  • Обеспечивать безопасность данных, вызовов API и конфигурационных параметров
  • Осуществлять логирование и мониторинг работоспособности

База данных
  • Необходимо реализовать структуру DB
  • Наполнить DB начальными значениями

Архитектура
  • Проект PoC должен быть реализован с возможностью расширения функциональности
  • Архитектура должна предусматривать масштабирование решения

Технологический стек


  • В настоящее время для большинства веб проектов используетсяязык JavaScript.Над JavaScript есть надстройкаTypeScript, которая обеспечивает типизацию переменных и реализацию шаблонов.
  • Для полнофункциональных веб проектов есть несколько стандартных стеков технологий. Один из самых популярных: MEVN (MongoDB + Express.js + Vue.js + Node.js), мне больше нравится PEVN (PostgreSQL + Express.js + Vue.js + Node.js), т.к. RDB базы мне технологически ближе, чем NoSQL.
  • Для GUI существует несколькофреймворков (Vue, React, Angular). Тут выбор, скорее, определяется традициями или личными предпочтениями, поэтому сложно говорить, что лучше, а что хуже. Сначала я выбрал Vue.js, да так на нем и остался. Этот фреймворк хорошо развивается, для него есть готовыевизуальныекомпоненты встилеMaterial Design (Vuetify), которые хорошо смотрятся даже при непрофессиональном применении. Считается, что для React порог вхождения выше, чем для Vue. В React сначала надо осознать специфичную объектную модель, которая отличается от классического веб программирования: компоненты как функции, компоненты как классы, цепочки вызовов.
  • Выбор базы данных уже сложнее, чем личные предпочтения. Но для PoC, зачастую, требуется не функциональность или производительность, а простота. Поэтому в примере будем использовать самую простую в мире базу SQLite. В промышленных PoC в качестве SQL базы я использую PostgreSQL, в качестве NoSQL ее же, т.к. запас прочности уPostgreSQL огромен. Прочность, конечно, не бесконечна и когда-нибудь настанет необходимость перехода на специализированную базу, но это отдельная тема для обсуждения.

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

Сервер


HTTP сервер


В качестве HTTP сервера япробовал два варианта:

Express более зрелый, Fastify, говорят, более быстрый. Есть еще варианты серверов, примеры перечислены в статье The Best 10 Node.js Frameworks. Я использовал самый популярный Express.js.

HTTPS запросы


Говоря про HTTP, я всегда подразумеваю HTTPS, т.к. без него крайне сложно построить безопасное взаимодействие между клиентом и сервером. Для поддержки HTTPS надо:
  • Получить SSL (TLS) сертификат
  • На сервере реализовать поддержку HTTPS протокола
  • На клиенте использовать запросы к клиенту с префиксом HTTPS

Получаем сертификат
Для организации работы HTTPS протокола нужен SSL сертификат. Существуют бесплатные сертификаты Lets Encrypt, но солидные сайты получают платные сертификаты от доверительных центров сертификации CA(Certificate Authority). Для наших тестовых целей на локальном хосте достаточно, так называемого, self-signedcertificate. Он генерируется с помощью утилиты openssl:
openssl req -nodes -new -x509 -keyout server.key -out server.crt

Далее отвечаем на несколько вопросов про страну, город, имя, email и т.п. В результате получаемдва файла:
  • server.key ключ
  • server.crt сертификат

Их нужно будет подложить нашему Express серверу.

HTTPS сервер
С Express сервером все достаточно просто, можно взять стандартный пакет HTTPS из Node.js, и использовать официальную инструкцию: How to create an https server?
После реализации HTTPS сервера используем полученные файлы server.key и server.crt и стандартный для HTTPS порт443.

Взаимодействие с внешними системами


Самая известная библиотека для реализации HTTP/HTTPS запросов: Axios. Она используется как в сервере для вызова API внешних систем, так и в клиенте для вызова API сервера. Есть еще варианты библиотек, которые можно использовать дляспецифичных целей: Обзор пяти HTTP-библиотек для веб-разработки.

Взаимодействие с базой данных


Для работы с базой данных я использовал самую популярную библиотеку для Node.js: Sequelize. В ней мне больше всего нравится то, что переключиться между различными типами баз данных можно всего лишь изменив несколько настроечных параметров. При условии, конечно, что в самом коде не используется специфика определенной базы.Например, чтобы переключиться с SQLight на PostgreSQL, надо в конфигурационном файле сервера заменить:
dialect: 'sqlite'

на
dialect: 'postgres'

И при необходимости изменить имя базы, пользователя и хост.
В PoC я использовал базу данныхSQLite, которая не требует установки.Для администрирования баз данных есть хорошо развитые GUI:

Безопасность доступа к данным


В примере реализована следующая простейшая логика доступа к данным, которую можно расширять и изменять в зависимости от бизнес-задач:
  1. При регистрации каждому пользователю присваивается уникальный ID.
  2. Когда пользователь создает новые данные, в них добавляется его ID.
  3. При любом запросе к данным проверяется, что пользователь имеет на это право:

  • Получить данные могут все зарегистрированные пользователи.
  • Изменить и удалить данные может только владелец этих данных.

Такая модель доступа к данным называетсяDiscretionary Access Control (DAC), по-русски: избирательное управление доступом. В этой модели владелец данных может делать с ними любые CRUD операции, а права остальных пользователей ограниченны.
Существуют несколько моделей контроля доступа к данным. ПомимоDiscretionary Access Control (DAC)OWASP рекомендует (Access Control Cheat Sheet) использовать следующие:
  • Role-Based Access Control (RBAC)
  • Mandatory Access Control (MAC)
  • Permission Based Access Control

Безопасность вызовов API


В моей предыдущей статье я рассматривал все возможные угрозы: Безопасность REST API от А до ПИ.В PoC реализованы следующие механизмы:
  • Cross-Site Request Forgery (CSRF) (Межсайтовая подмена запросов)

От данной угрозы реализован механизм CSRF токенов когда для каждой сессии пользователя генерируется новый токен (он же SessionId) и сервер проверяет его валидность при любых запросах с клиента. Алгоритм генерации и проверки SessionId подробно описан далее в разделе Авторизация, аутентификация пользователей.
  • Cross-origin resource sharing (CORS) (Кросс-доменное использование ресурсов)

Защита реализована с помощью пакета CORS. В config файле сервера указываются адрес (origin), с которого могут поступать API запросы и список методов (methods), которые может использовать клиент. В дальнейшем сервер будет автоматически ограничивать прием запросов в соответствии с этими настройками.
Для защиты от угроз, перечисленных далее, я использовал пакетhelmet, который позволяет задать значения для определенных HTTP заголовков:
  • Cross-site Scripting (XSS) (Межсайтовое выполнение скриптов)

Для защиты выставляется заголовок, ограничивающий выполнение скриптов:
X-XSS-Protection: 1; mode=block

  • Insecure HTTP Headers

Блокируется отправка сервером заголовка, дающего дополнительную информацию злоумышленнику:
X-Powered-By: Express

  • Insecure HTTP Headers: HTTP Strict Transport Security (HSTS)

Используется Strict-Transport-Security заголовок, который запрещает браузеру обращаться к ресурсам по HTTP протоколу, только HTTPS:
Strict-Transport-Security: max-age=15552000; includeSubDomains

  • Insecure HTTP Headers: X-Frame-Options (защита от Clickjacking)

Данный заголовок позволяет защититься от атаки Clickjacking. Он разрешает использовать фреймы только в нашем домене:
X-Frame-Options: SAMEORIGIN

  • Insecure HTTP Headers: X-Content-Type-Options

Установка заголовка X-Content-Type-Options запрещает браузеру самому интерпретировать тип присланных файлов и принуждает использовать только тот, что был прислан в заголовке Content-Type:
X-Content-Type-Options: nosniff

Защита от DoS


Необходимо защитить сервер и от отказа в обслуживании (DoS-атаки). Например, ограничить число запросов от одного пользователя или по одному ресурсу в течении определенного времени.
Для Node.js существуют средства, позволяющие автоматически реализовывать ограничения на число запросови сразу посылать ответ 429 Too Many Requests, не нагружая бизнес логику сервера.
В примере реализовано простейшее ограничение на число запросов от каждого пользователя в течении определенного времени по таблице Data. В промышленных системах надо защищать все запросы, т.к. даже один запрос, оставшийся без проверки может дать возможность провести атаку.

Безопасность конфигурационных параметров


Настройки для клиента и сервера хранятся в файлах configв JSON формате. Чувствительные настройки сервера нельзя хранить вconfig файлах, т.к. они могут стать доступны в системах версионного контроля. Для такой информации как: логины/пароли для базы данных, ключи доступа к API и т.д. надо использовать специализированные механизмы:
  • Задавать эти параметры в .env файле. Файл .env прописан в gitignore и не сохраняется в системах версионного контроля, поэтомупоэтому туда можно безопасно записывать чувствительную информацию.
  • Использовать механизм переменных окружения (environment variables), которые устанавливаются вручную или прописываютсяинсталляторами.

Логирование


Стандартные требования к логированию:
  • Обеспечить разный уровень логирования (Debug; Info; Warning; Error и т.д.)
  • Логировать HTTP/HTTPS запросы
  • Дублировать консольный вывод в файлы на диске
  • Обеспечить самоочистку файлов логирования по времени иобъёму

Варианты пакетов для логирования, которые я пробовал:

В проекте я использовал log4js. Пакет log4jsработает интуитивно понятно и достаточно развит, чтобы реализовать все требования.

Мониторинг


Мы не будем останавливаться на внешних система мониторинга: Обзор систем мониторинга серверов, т.к. они начинают играть важную роль не на этапе PoC, а в промышленной эксплуатации.
Обратимся к внутренним системам, которые позволяют наглядно посмотреть, что сейчас происходит с сервером, например пакетexpress-status-monitor. Если его установить, топо endpoint
/monitor

можно наглядно мониторить простейшие параметры работы сервера, такие как загрузка CPU, потребление памяти, http нагрузку и т.п. Приведу скриншот из нашего примера. На скриншоте горит красная плашка Failed и может показаться, что что-то не так. Но, на самом деле, все в порядке, т.к. вызов API сознательно делается на несуществующий endpoint:
/wrongRoutePath

image

База данных


Структурабазы данных


В нашем примере реализованы две таблицы с говорящими именами:
  • Users
  • Data

Лучшие практики говорят, что все проверки надо помаксимуму отдавать базе данных. Поэтому в схеме базы данных аккуратно определяем все типы данных, добавляем ограничения (constraints) и внешние ключи.Для пакета sequelize данная функциональность подробно описана в стандартной документации.
В нашем пример сделан один внешний ключ UserId в таблице Data для того, чтобы гарантировать, что у каждой записи в таблицеData будет определенный владелец User. Это позволит при изменении или удалении записи в Data реализовать проверку пользователя, т.к. по нашей логике данные может удалять только их владелец.
Еще одна особенность нашей схемы в том, что один столбец таблицы Data задан в формате JSON. Такой формат удобно использовать, если внешняя система возвращает данные в JSON. В этом случае можно просто записать полученные данные, не тратя усилия на парсинг,а потом делать SQL запросы, используя расширенный синтаксис.Описание работы со столбцами JSON можно найти в официальной документации баз данных. В качестве независимого описания мне понравилась статья на Хабре, в которой описаны все варианты запросов: "JSONB запросы в PostgreSQL.

Формальная схема DB
Cхема таблицыUsers:
  • id INTEGERPRIMARY KEY AUTOINCREMENT уникальный ID
  • uuid UUID NOT NULL UNIQUE уникальный UUID, по нему идет связь с данными этого пользователя в таблице Data
  • email VARCHAR(255) NOT NULL UNIQUE
  • password VARCHAR(64)
  • ddosFirstRequest DATETIME
  • ddosLastRequest DATETIME
  • ddosRequestsNumber DECIMAL
  • lastLogin DATETIME
  • loginState VARCHAR(255) NOT NULL
  • sessionId VARCHAR(1024)
  • commonToken VARCHAR(1024)
  • googleToken VARCHAR(1024)
  • googleAccessToken VARCHAR(1024)
  • googleRefreshToken VARCHAR(1024)
  • createdAt DATETIME NOT NULL
  • updatedAt DATETIME NOT NULL

Схема таблицыData:
  • id INTEGERPRIMARY KEY AUTOINCREMENT
  • uuid UUID NOT NULL UNIQUE
  • ownerUuid UUID NOT NULL
  • data JSON NOT NULL
  • UserId INTEGER REFERENCES Users (id) ON DELETE CASCADE ON UPDATE CASCADE,
  • createdAt DATETIME NOT NULL
  • updatedAt DATETIME NOT NULL


Начальные значения


  • В таблице Users задан один пользовательuser@example.comс паролем password.
  • В таблице Data записано несколько предопределенных значений, которые можно получить из GUI.

Далее перейдем к клиенту.

Клиент


HTTPS запросы


Для безопасной передачи информации клиент использует HTTPS протокол. Для запросов используется та же библиотека, что и на сервере для вызова API внешних систем: Axios.

RESTful API


REST API лучше реализовывать, используя стандартные средства, чтобы было проще тестировать и документировать, например SwaggerHub
Наше REST API сформировано по правилам Open API и использует стандартные HTTP методы для манипуляции объектами из таблиц Users и Data.В схеме нашего API не все сделано по чистым правилам REST, но главное соблюдена общая концепция.
Хорошей практикой являетсяограничение объёма возвращаемых данных. Для этого используются параметры в запросе: фильтр (filter), ограничение (limit) и смещение (offset). В примере этого нет, но в промышленных системах эта функциональность должна быть реализована.Но даже если реализованы ограничивающие механизмы на клиенте, должна осуществляться дополнительная проверка на сервере на максимальные значения. В нашем примере в конфигурации сервера реализован ограничитель на максимальное число возвращаемых строк из базы,который подставляется вSELECT запросы:limit: 1000

YAML файл с Open API описанием
---swagger: "2.0"info:  description: "This is a common-test-db API. You can find out more about common-test-db\    \ at \nhttps://github.com/AlexeySushkov/common-test-db\n"  version: "2.0.0"  title: "common-test-db"  contact:    email: "alexey.p.sushkov@gmail.com"  license:    name: "MIT License"    url: "https://github.com/AlexeySushkov/common-test-db/blob/main/LICENSE"host: "localhost:443"basePath: "/commontest/v1"tags:- name: "data"  description: "Everything about Data"  externalDocs:    description: "Find out more"    url: "https://github.com/AlexeySushkov/common-test-db/"- name: "users"  description: "Everything about Users"  externalDocs:    description: "Find out more"    url: "https://github.com/AlexeySushkov/common-test-db/"schemes:- "https"paths:  /data:    get:      tags:      - "data"      summary: "Gets all data"      description: "Gets all data"      operationId: "getData"      produces:      - "application/json"      parameters: []      responses:        "200":          description: "successful operation"          schema:            type: "array"            items:              $ref: "#/definitions/Data"        "400":          description: "Invalid status value"      x-swagger-router-controller: "Data"    post:      tags:      - "data"      summary: "Add a new data to the db"      operationId: "addData"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "Data object that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/Data"      responses:        "500":          description: "Create data error"      x-swagger-router-controller: "Data"    put:      tags:      - "data"      summary: "Update an existing data"      operationId: "updateData"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "Data information that needs to be changed"        required: true        schema:          $ref: "#/definitions/Data"      responses:        "400":          description: "Invalid uuid"        "404":          description: "User not found"        "500":          description: "Update data error"      x-swagger-router-controller: "Data"    delete:      tags:      - "data"      summary: "Delete an existing data"      operationId: "deleteData"      consumes:      - "application/x-www-form-urlencoded"      - "application/json"      produces:      - "application/json"      parameters:      - name: "uuid"        in: "formData"        description: "uuid"        required: true        type: "string"      responses:        "400":          description: "Invalid uuid"        "404":          description: "User not found"        "500":          description: "Delete data error"      x-swagger-router-controller: "Data"  /users:    post:      tags:      - "users"      summary: "Register new user"      operationId: "userRegister"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be deleted"        required: true        schema:          $ref: "#/definitions/User"      responses:        "500":          description: "Register user error"      x-swagger-router-controller: "Users"    put:      tags:      - "users"      summary: "Update existing user"      operationId: "userUpdate"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User information that needs to be changed"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Delete user error"      x-swagger-router-controller: "Users"    delete:      tags:      - "users"      summary: "Delete user"      operationId: "userDelete"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Delete user error"      x-swagger-router-controller: "Users"  /users/login:    post:      tags:      - "users"      summary: "Login"      operationId: "userLogin"      consumes:      - "application/json"      produces:      - "application/json"      parameters:      - in: "body"        name: "body"        description: "User that needs to be added to the db"        required: true        schema:          $ref: "#/definitions/User"      responses:        "404":          description: "User not found"        "500":          description: "Login user error"      x-swagger-router-controller: "Users"definitions:  Data:    type: "object"    required:    - "Counter1"    - "Counter2"    properties:      Counter1:        type: "integer"        format: "int64"      Counter2:        type: "integer"        format: "int64"    example:      Counter1: 10      Counter2: 20  User:    type: "object"    required:    - "email"    - "password"    properties:      email:        type: "string"      password:        type: "string"    example:      password: "password"      email: "email"  ApiResponse:    type: "object"    properties:      status:        type: "string"


Интересная возможность встроить Swagger прямо в сервер, далее подложить ему YAML, реализующий API и по endpoint:
/api-docs

получить стандартную Swagger панель управления и осуществлять тестирование и просмотр документации. Скриншот Swagger, встроенного в наш сервер:

image

GraphQL


Существуют и альтернативы REST API, например GraphQL. Данный стандарт разработан Facebook и позволяет избавиться от недостатков REST:
  • В REST API взаимодействие происходит с использованием многочисленные endpoints. В GraphQL одна.
  • В REST API для получения данных от разных endpoints необходимо осуществить множественные запросы. В GraphQL все необходимые данные можно получить одним запросом.
  • В REST API клиент не может контролировать объём и содержимое данных в ответе. В GraphQL набор возвращаемых данных предсказуем, т.к. задается в запросе. Это сразу избавляет от угрозы: Excessive Data Exposure (Разглашение конфиденциальных данных)
  • В REST API отсутствует обязательность формальной схемы. В GpaphQL создание схемы API с определенными типами данных обязательно, при этом автоматически получается документация и сервер для тестирования (самодокументироваемость и самотестируемость).
  • Клиент всегда может запросить схему API с сервера, тем самым синхронизировать формат своих запросов
  • Из коробки работают подписки, реализованные на технологии WebSockets

Какое API использовать, решается в зависимости от бизнес задач. REST API практически безальтернативно используется в промышленных системах и задачах интеграции между ними. Но для проектов, не требующих взаимодействия со сторонними системами вполне можно использовать более продвинутый GraphQL.

Регистрация,аутентификация и авторизация пользователей


Если приложение доступно из Интернета, то в форме регистрации имеет смысл использовать функциональность reCaptсha от Google для защиты от ботов и DoS атак. В нашем примере я использую пакет vue-recaptcha.
Для использованияreCaptсha нужно на сайте Google зарегистрировать приложение, получить sitekey и прописать его в настройках клиента. После этого на форме появится, известный всем, вопрос про робота:
image

В примере реализована регистрация пользователей с помощью логина/пароля ис использованием Google Account.
  • При регистрации по логин/пароль пароли в базе данных, разумеется, не хранятся в виде текста, а хранится только хеш. Для его генерации используется библиотекаbcrypt, которая реализует алгоритм хеширванияBlowfish. Сейчас уже есть мнение, что надо использовать более надежные алгоритмы: Password Hashing: Scrypt, Bcrypt and ARGON2.
  • При регистрации с помощью Google Account организация хранения аутентификационных данных проще, т.к. в базу записывается только token, полученный от Google.

Для реализации различных схем аутентификации есть библиотека Passport, которая упрощает работу и скрывает детали. Но чтобы понять как работают алгоритмы, в примере все сделано в соответствии со стандартами, которые я уже описывал в своей статье: Современные стандарты идентификации: OAuth 2.0, OpenID Connect, WebAuthn.
Далее в статье разберем практическую реализацию.

Аутентификация по логину/паролю (Basic Authentication)
Тут все достаточно просто. При получении запроса на логин сервер проверяет, что пользователь с присланнымemail существует и что хеш полученного пароля совпадает с хешом из базы. Далее:
  • Сервер формирует SessionId в формате UUID и Token в формате JWT (JSON Web Token)
  • Токену присваиватся время жизни, которое определяется бизнес задачами. Например, Google устанавливет своим токенам время жизни 1 час.
  • Для формирования токена используется пакетjsonwebtoken
  • В JWT помещается email пользователя, который подписывается ключом сервера. Ключ это текстовая строка, которая хранится, как конфигурационный параметр на сервере.Содержимое токена кодируется Base64
  • SessionId сохраняется в базе и отправляется клиенту в заголовке X-CSRF-Token
  • Token сохраняется в базе и отправляется клиенту в теле HTTP ответа
  • Получив SessionId клиент сохраняетSessionId в sessionStorageбраузера
  • Token записывается в localStorage или Сookiesбраузерадля реализации возможности запомнить меня на этом устройстве
  • В дальнейшем клиент будет присылать SessionId в HTTP заголовкеX-CSRF-Token:

X-CSRF-Token: 69e530cf-b641-445e-9866-b23c492ddbab

  • Token будет присылаться в заголовкеHTTP Authorization с префиксомBearer:

Authorization: BearereyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJpYXQiOjE2MDYxNTY3MTUsImV4cCI6MTYwNjc2MTUxNX0.h3br5wRYUhKIFs3SMN2ZPvMcwBxKn7GMIjJDzCLm_Bw

  • Сервер каждый раз проверяетSessionId на то, что сессия существует и связана с пользователем. Таким образом, гарантируется, что запрос приходит от залогиненного клиента, а не стороннего сайта злоумышленника.
  • Сервер при каждом запросе будет проверять присланныйToken на валидность:

  1. Токен подписан сервером;
  2. Пользователь с email существует;
  3. Время жизни токена не истекло. Если же оно истекло, то при выполнении предыдущих условий автоматически формируется новый токен.

В общем случае излишне генерировать и SessionId и Token. В нашем PoC для примера реализованы оба механизма. Посмотрим внимательней на наш JWT. Пример закодированного JWT токена из заголовка:
Authorization:yJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAdGVzdC5jb20iLCJpYXQiOjE2MDYxNTY3MTUsImV4cCI6MTYwNjc2MTUxNX0.h3br5wRYUhKIFs3SMN2ZPvMcwBxKn7GMIjJDzCLm_Bw

Раскодируем его открытыми средствами. Например самый простой способ загрузить на сайтhttps://jwt.io

image

Мы видим, что информация, помещенная вJWT не зашифрована, а только подписана ключом сервера и туда нельзя помещать чувствительные к разглашению данные!

Аутентификация с помощью Google Account (Token-Based Authentication)
Тут посложнее, поэтому нарисую диаграмму:

image

1. Пользователь заходит на GUI PoC, выбирает Login with Google".
2. Клиент запрашивает у сервера SessionId и настройки Google. Настройки надо предварительно получить с сайта Google, например, по инструкции из моей статьи: Современные стандарты идентификации: OAuth 2.0, OpenID Connect, WebAuthn
3. Клиент сохраняет SessionId в sessionStorageбраузера
4. Из браузера формируется GET запрос на GoogleAuthorization Server (красная стрелка). Для понимания алгоритма, надо обратить внимание, что ответ на этот запрос браузер получит от Google только в самом конце call flow на шаге 13 и это будет 307 (Redirect).

Формат GET запроса:
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=918876437901-g312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com&scope=openid+email&redirect_uri=http://localhost:8081/redirect&access_type=offline&state=450c2fd9-0a5e-47d2-8ed5-dd1ff4670b58

В этом запросе:
  • accounts.google.com/o/oauth2/v2/auth endpoint для начала аутентификации. У Google есть, кстати, адрес, по которому можно посмотреть актуальный список всех Google API endpoints:https://accounts.google.com/.well-known/openid-configuration
  • response_type=code параметр говорит, что ожидаем получить в ответAuthorization Code
  • client_id Client ID, выданный при регистрации приложения на Google (в примере указан не настоящий)
  • scope=openid email к каким данным пользователя мы хотим получить доступ
  • redirect_uri = localhost:8081/redirectCallback адрес, заданный при регистрации приложения. В нашем случае это адрес на нашем сервере, который получит запрос от Google
  • state = SessionId, который передается между клиентом, Google и сервером для защиты от вмешательства внешнего злоумышленника

5. Google Authorization Server показывает стандартную Google форму логина.
6. Пользователь вводит свои логин/пароль от Google аккаунта.
7. Google проверяет пользователя и делает GET запрос на адрес Callback с результатом аутентификации, Authorization Code в параметре code и нашем SessionId в параметреstate:
  • state: '450c2fd9-0a5e-47d2-8ed5-dd1ff4670b58',
  • code: '4/0AY0e-g4pg_vSL1PwUWhfDYj3gpiVPUg20qMkTY93JYhmrjttedYwbH376D_BvzZGmjFdmQ',
  • scope: 'email openid www.googleapis.com/auth/userinfo.email',
  • authuser: '1',
  • prompt: 'consent'

8. Сервер, не отправляя пока ответ на GET, формирует POST запрос с Authorization Code, Client ID и Client Secret:
POSThttps://oauth2.googleapis.com/token?code=4/0AY0e-g4pg_vSL1PwUWhfDYj3gpiVPUg20qMkTY93JYhmrjttedYwbH376D_BvzZGmjFdmQ&client_id=918876437901-g312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com&client_secret=SUmydv3-7ZDTIh8аНК85chTOt&grant_type=authorization_code&redirect_uri=http://localhost:8081/redirect

  • oauth2.googleapis.com/token это endpoint для получения token
  • code только что присланный code
  • client_id Client ID, выданный при регистрации приложения на Google (в примере указан не настоящий)
  • client_secret Client Secret, выданный при регистрацииприложения на Google (в примере указан не настоящий)
  • grant_type=authorization_code единственно возможное значение из стандарта

9. Google проверяет присланные данные и формирует access token в формате JWT (JSON Web Token), подписанный своим приватным ключом. В этом же JWT может содержаться и refresh token, c помощью которого возможно продолжение сессии после ее окончания:
  • access_token: 'ya29.a0AfH6SMBH70l6wUe1i_UKfjJ6JCudA_PsIIKXroYvzm_xZjQrCK-7PUPC_U-3sV06g9q7OEWcDWYTFPxoB1StTpqZueraUYVEWisBg46m1kQAtIqhEPodC-USBnKFIztGWxzxXFX47Aag',
  • expires_in: 3599,
  • refresh_token: '1//0cAa_PK6AlemYCgYIARAAGAwSNwF-L9IrdUt1gzglxh5_L4b_PwoseFlQA1XDhqte7VMzDtg',
  • scope: 'openid www.googleapis.com/auth/userinfo.email',
  • token_type: 'Bearer',
  • id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImRlZGMwMTJkMDdmNTJhZWRmZDVmOTc3ODRlMWJjYmUyM2MxOTcfobDCf2VrxXb6CCxoL_dZq1WnlEjBZx_Sf6Rg_tn3x4gWtusO1oe_bJx_gvlSLxtvSOdO_kPB2uGGQHr3xzF_Evr-S-BiGS8zMuIkslyN6fU7P7BdNVyOYAIYFvHikyIpAoesV2Fd2yBSngBwGmWfrHL7Z2415UrnlCG4H1Nw'

10. Сервер валидирует пришедший Token. Для этого нужен открытый ключ от Google. Актуальные ключи находятся по определенному адресу и периодически меняются:https://www.googleapis.com/oauth2/v1/certs.Поэтому надо или получать их каждый раз по этому адресу, либо хардкодить и следить за изменениями самому.Декодируем пришедший token, для нас самое главное это подтвержденный email пользователя:
decodedIdToken: {iss: 'https://accounts.google.com',azp: '918962537901-gi8oji3qk312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com',aud: '918962537901-gi8oji3qk312pqdhg5bcju4hpr3efknv.apps.googleusercontent.com',sub: '101987547227421522632',email: 'work.test.mail.222@gmail.com',email_verified: true,at_hash: 'wmbkGMnAKOnfAKtGQFpXQw',iat: 1606220748,exp: 1606224348}

11. Проверяем, что email пользователя существует, или создаём нового пользователя c email из токена. При этомSessionId и полученные от Google токены сохраняются в базе.
12. Только теперь отвечаем Google HTTP кодом 307 (Redirect) и заголовком Location c адресом на клиенте:
HTTP Location:http://localhost:8080/googleLogin

13. И только теперь Google отвечает браузеру с тем же кодом307 (Redirect) и заголовком Location с заданным нами адресом
14. Браузер переходит на адрес, указанный вLocation иклиент определяет, что произошла успешная аутентификация пользователя с помощью Google аккаунта
15. Клиент, по сохраненному в sessionStorage SessionId, получает на сервере токен и данные пользователя
16. Клиент сохраняет токен в localStorage браузера

На этом процедура аутентификации с помощью Google аккаунта завершена и можно переходить к штатной работе приложения.

Прием, обработка и визуализация данных


Сделаем стандартный вид приложения, как рекомендует Google:
  • Drawer (navigation-drawer) в левой стороне
  • Меню сверху (v-app-bar)
  • Footer внизу (v-footer)
  • Для визуализации полученных данных используем карточки (Data Cards)

image

Для создания новых данных используется простая форма ввода:

image

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

  1. Для Vue есть библиотекаVuex, реализующаяstate management pattern.
  2. Есть универсальная библиотека Redux основанная на Flux от Facebook. Она реализует концепцию состояний, используя понятия action, state, view.

  • В примере я использовал Vuex.
  • Также необходимRouter для реализации переходов между страницами.

Мой опыт показывает, что для создания стандартных графических интерфейсов подходят стандартные средства, позволяющие делать приличные интерфейсы, даже без наличия дизайнерских навыков.

Архитектура


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

Заключение


  • Мы рассмотрели типичной проект PoC в архитектуре клиент-сервер для использования в вебе.
  • Поняли, что даже минимальный проект требует обширных знаний и значительных усилий для реализации.
  • В результате получили заготовку, которую можно использовать для начала любых проектов:

Исходники на Github:https://github.com/AlexeySushkov/common-test-db
Пример запущен в облаке Amazon, можно посмотреть, как он выглядит вживую:http://globalid.tech/

Остается только в канун Нового Года пожелать, чтобы сбылись все ваши самые заветные PoC!

It's only the beginning!
Подробнее..

Атаки на JSON Web Tokens

09.01.2021 12:13:57 | Автор: admin


Содержание:


  • Что такое JWT?
    • Заголовок
    • Полезная нагрузка
    • Подпись
    • Что такое SECRET_KEY?
  • Атаки на JWT:
    • Базовые атаки:
      1. Нет алгоритма
      2. Изменяем алгоритм с RS256 на HS256
      3. Без проверки подписи
      4. Взлом секретного ключа
      5. Использование произвольных файлов для проверки
    • Продвинутые атаки:
      1. SQL-инъекция
      2. Параметр поддельного заголовка
      3. Внедрение заголовка ответа HTTP
      4. Прочие уязвимости

Что такое JSON Web Token?


Веб-токен JSON обычно используется для авторизации в клиент-серверных приложениях. JWT состоит из трех элементов:


  • Заголовок
  • Полезная нагрузка
  • Подпись

Заголовок


Это объект JSON, который представляет собой метаданные токена. Чаще всего состоит из двух полей:


  • Тип токена
  • Алгоритм хэширования

Официальный сайт предлагает два алгоритма хэширования:


  • HS256
  • RS256

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


Полезная нагрузка


Это также объект JSON, который используется для хранения такой информации о пользователе, как:


  • идентификатор
  • имя пользователя
  • роль
  • время генерации токена и т.д.

Подпись


Это наиболее важная часть, поскольку она определяет целостность токена путем подписания заголовка и полезной нагрузки в кодировке Base64-URL, разделенных точкой (.) с секретным ключом. Например, чтобы сгенерировать токен с помощью алгоритма HS256, псевдокод будет таким:


// Use Base64-URL algorithm for encoding and concatenate with a dotdata = (base64urlEncode(header) + '.' + base64urlEncode(payload))// Use HS256 algorithm with "SECRET_KEY" string as a secretsignature = HMACSHA256(data , SECRET_KEY)// Complete tokenJWT = data + "." + base64UrlEncode(signature)


Что такое SECRET_KEY?


Как правило, JWT может быть сгенерирован с помощью двух механизмов шифрования, таких как:


  • Симметричное
  • Ассиметричное

Симметричное шифрование:


Этот механизм требует единственного ключа для создания и проверки JWT.


Например, пользователь "Vasya" сгенерировал JWT с h1dd1n_m1ss1g3 в качестве секретного ключа. Любой человек, знающий этот ключ, может с его помощью изменить токен. JWT при этом останется действительным.


Самый распространенный алгоритм для этого типа HS256.


Асимметричное шифрование:


Этот механизм требует открытого ключа для проверки и закрытого ключа для подписи.


Например, если "Vasya" использовал это шифрование, то он единственный, кто может создать новый токен, используя закрытый ключ, тогда как "Petya" может только проверить токен с помощью открытого ключа, но не может его изменить.


Наиболее распространенный алгоритм для этого типа RS256.



Атаки на JWT


Чтобы подделать токен, необходимо иметь правильные ключи (например, секретный ключ для HS256, открытый и закрытый ключи для RS256), но если конфигурация JWT не реализована правильно, то есть много способов обойти элементы управления, которые позволяют изменить токен и получить несанкционированный доступ.


Базовые атаки


Для выполнения всех этих атак нам понадобиться JWT_Tool


1. Нет алгоритма


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


// Modified Header of JWT after changing the "alg" parameter{  "alg": "none",  "typ": "JWT"}

Команда:


python3 jwt_tool.py <JWT> -X a


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


2. Изменяем алгоритм с RS256 на HS256


Как было сказано выше, алгоритму RS256 нужен закрытый ключ для подделки данных и соответствующий открытый ключ для проверки подлинности подписи. Но если мы сможем изменить алгоритм подписи с RS256 на HS256, мы заставим приложение использовать только один ключ для выполнения обеих задач, что является нормальным поведением алгоритма HMAC.


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


Команда:


python3 jwt_tool.py <JWT> -S hs256 -k public.pem

В данном случае мы сначала загружаем открытый ключ (public.pem) из приложения, а затем подписываем токен с помощью алгоритма HS256, используя этот ключ. Таким образом, мы можем создавать новые токены и вставлять полезную нагрузку в любое существующее утверждение.


3. Без проверки подписи


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


Команда:


python3 jwt_tool.py <JWT> -I -pc name -pv admin

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


4. Взлом секретного ключа


Мы можем получить доступ к файлу SECRET_KEY с помощью уязвимостей, таких как


  • LFI
  • XXE
  • SSRF

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


Для этой цели можно использовать расширение BurpSuite под названием JWT Heartbreaker.


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


Но чтобы убедиться, что полученная нами строка является действительным ключом SECRET_KEY или нет? Мы можем использовать функцию Crack в jwt_tool.


Команда:


python3 jwt_tool.py <JWT> -C -d secrets.txt // Use -p flag for a string


5. Использование произвольных файлов для проверки


Key ID (kid) это необязательный заголовок, имеющий строковый тип, который используется для обозначения конкретного ключа, присутствующего в файловой системе или базе данных, а затем использования его содержимого для проверки подписи. Этот параметр полезен, если приложение имеет несколько ключей для подписи токенов, но может быть опасным, если он является инъекционным, поскольку в этом случае злоумышленник может указать на конкретный файл, содержимое которого предсказуемо.


Например, /dev/null называется нулевым файлом устройства и всегда ничего не возвращает, поэтому он отлично работает в системах на основе Unix.


Команда:


python3 jwt_tool.py <JWT> -I -hc kid -hv "../../dev/null" -S hs256 -p ""

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


Другое решение проблемы:


python3 jwt_tool.py -I -hc kid -hv "путь / к / файлу" -S hs256 -p "Содержимое файла"

Продвинутые атаки:


1. SQL-инъекция


Эта уязвимость может возникнуть, если любой параметр, который извлекает какое-либо значение из базы данных, не очищается должным образом. Благодаря чему можно решать CTF задачи.


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


Команда для подсчета количества столбцов:


python3 jwt_tool.py <JWT> -I -pc name -pv "imparable' ORDER BY 1--" -S hs256 -k public.pem// Increment the value by 1 until an error will occur

2. Параметр поддельного заголовка


JSON Web Key Set (JWKS) это набор открытых ключей, которые используются для проверки токена. Вот пример:



Этот файл хранится на доверенном сервере, приложение может указывать на этот файл через параметры заголовка:


  • jku
  • x5u

Но мы можем управлять URL-адресом с помощью таких уловок, как:


  • открытый редирект
  • добавление символа @ после имени хоста и т. д.

Затем мы можем перенаправить Приложение на наш вредоносный сервер вместо доверенного сервера и генерировать новые токены, так как мы контролируем как открытые, так и закрытые ключи.


JSON Set URL (jku):


Этот параметр указывает на набор открытых ключей в формате JSON (атрибуты n и e в JWKS), а jwt_tool автоматически создает файл JWKS с именем jwttool_custom_jwks.json для этой атаки при первом запуске инструмента после установки.


Команда:


python3 jwt_tool.py <JWT> -X s -ju "https://attacker.com/jwttool_custom_jwks.json"

X.509 URL (x5u):


Этот параметр указывает на сертификат открытого ключа X.509 или цепочку сертификатов (атрибут x5c в JWKS). Вы можете сгенерировать этот сертификат с соответствующим закрытым ключом следующим образом:


openssl req -newkey rsa:2048 -nodes -keyout private.pem -x509 -days 365 -out attacker.crt -subj "/C=AU/L=Brisbane/O=CompanyName/CN=pentester"

Здесь с использованием OpenSSL сертификат был создан в attacker.crt, который теперь может быть встроен в файл JWKS с атрибутом x5c, а его эксплуатация может осуществляться следующим образом:


python3 jwt_tool.py <JWT> -S rs256 -pr private.pem -I -hc x5u -hv "https://attacker.com/custom_x5u.json"

Встроенные открытые ключи:


Если сервер встраивает открытые ключи непосредственно в токен с помощью параметров jwk (JSON Web Key) или x5c (цепочка сертификатов X.509), попробуйте заменить их своими собственными открытыми ключами и подписать токен соответствующим закрытым ключом.


3. Внедрение заголовка ответа HTTP


Предположим, что если приложение ограничивает любой управляемый URL-адрес в параметрах jku или x5c, тогда мы можем использовать уязвимость внедрения заголовка ответа, чтобы добавить встроенный JWKS в ответ HTTP и заставить приложение использовать это для проверки подписи.


4. Прочие уязвимости


Веб-токены JSON это еще одна форма пользовательского ввода, все параметры в которой должны быть очищены должным образом, иначе это может привести к уязвимостям, таким как:


  • LFI
  • RCE и другим.

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


  • XSS
  • CSRF
  • CORS и т. д.

Удачных пентестов!


image

Подробнее..

REST API с использованием Spring Security и JWT

05.03.2021 00:08:39 | Автор: admin

Рано или поздно каждый Java-разработчик столкнется с необходимостью реализовать защищенное REST API приложение. В этой статье хочу поделиться своей реализацией этой задачи.

1. Что такое REST?

REST (от англ. Representational State Transfer передача состояния представления) это общие принципы организации взаимодействия приложения/сайта с сервером посредством протокола HTTP.

Диаграмма ниже показывает общую модель.

Всё взаимодействие с сервером сводится к 4 операциям (4 это необходимый и достаточный минимум, в конкретной реализации типов операций может быть больше):

  1. Получение данных с сервера (обычно в формате JSON, или XML);

  2. Добавление новых данных на сервер;

  3. Модификация существующих данных на сервере;

  4. Удаление данных на сервере

Более подробно можно прочесть в остальных источниках, статей о REST много.

2. Задача

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

3. Технологии

Для решения используем фреймворк Spring Boot и Spring Web, для него требуется:

  1. Java 8+;

  2. Apache Maven

Авторизация и валидация будет выполнена силами Spring Security и JsonWebToken (JWT).
Для уменьшения кода использую Lombok.

4. Создание приложения

Переходим к практике. Создаем Spring Boot приложение и реализуем простое REST API для получения данных пользователя и списка пользователей.

4.1 Создание Web-проекта

Создаем Maven-проект SpringBootSecurityRest. При инициализации, если вы это делаете через Intellij IDEA, добавьте Spring Boot DevTools, Lombok и Spring Web, иначе добавьте зависимости отдельно в pom-файле.

4.2 Конфигурация pom-xml

После развертывания проекта pom-файл должен выглядеть следующим образом:

  1. Должен быть указан parent-сегмент с подключенным spring-boot-starter-parent;

  2. И установлены зависимости spring-boot-starter-web, spring-boot-devtools и Lombok.

<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://personeltest.ru/away/maven.apache.org/POM/4.0.0" xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://personeltest.ru/away/maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>2.3.5.RELEASE</version>        <relativePath/> <!-- lookup parent from repository -->    </parent>    <groupId>com</groupId>    <artifactId>springbootsecurityrest</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>springbootsecurityrest</name>    <description>Demo project for Spring Boot</description>    <properties>        <java.version>15</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-devtools</artifactId>            <scope>runtime</scope>            <optional>true</optional>        </dependency>        <dependency>            <groupId>org.projectlombok</groupId>            <artifactId>lombok</artifactId>            <optional>true</optional>        </dependency>        <!--Test-->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-test</artifactId>            <scope>test</scope>            <exclusions>                <exclusion>                    <groupId>org.junit.vintage</groupId>                    <artifactId>junit-vintage-engine</artifactId>                </exclusion>            </exclusions>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>                <configuration>                    <excludes>                        <exclude>                            <groupId>org.projectlombok</groupId>                            <artifactId>lombok</artifactId>                        </exclude>                    </excludes>                </configuration>            </plugin>        </plugins>    </build></project>

4.3 Создание ресурса REST

Разделим все классы на слои, создадим в папке com.springbootsecurityrest четыре новые папки:

  • model для хранения POJO-классов;

  • repository в полноценных проектах используется для взаимодействия с БД, но т.к. у нас ее нет, то он будет содержать список пользователей;

  • service слой сервиса, прослойка между контролером и слоем ресурсов, используется для получения данных из ресурса, их проверки и преобразования (если это необходимо);

  • rest будет содержать в себе классы контроллеры.

В папке model создаем POJO класс User.

import lombok.AllArgsConstructor;import lombok.Data;@Data@AllArgsConstructorpublic class User {    private String login;    private String password;    private String firstname;    private String lastname;    private Integer age;}

В папке repository создаём класс UserRepository c двумя методами:

  1. getByLogin который будет возвращать пользователя по логину;

  2. getAll который будет возвращать список всех доступных пользователей. Чтобы Spring создал бин на основании этого класса, устанавливаем ему аннотацию @Repository.

@Repositorypublic class UserRepository {      private List<User> users;    public UserRepository() {        this.users = List.of(                new User("anton", "1234", "Антон", "Иванов", 20),                new User("ivan", "12345", "Сергей", "Петров", 21));    }    public User getByLogin(String login) {        return this.users.stream()                .filter(user -> login.equals(user.getLogin()))                .findFirst()                .orElse(null);    }    public List<User> getAll() {        return this.users;    }

В папке service создаем класс UserService. Устанавливаем классу аннотацию @Service и добавляем инъекцию бина UserRepository. В класс добавляем метод getAll, который будет возвращать всех пользователей и getByLogin для получения одного пользователя по логину.

@Servicepublic class UserService {    private UserRepository repository;    public UserService(UserRepository repository) {        this.repository = repository;    }    public List<User> getAll() {        return this.repository.getAll();    }    public User getByLogin(String login) {        return this.repository.getByLogin(login);    }}

Создаем контроллер UserController в папке rest, добавляем ему инъекцию UserService и создаем один метод getAll. С помощью аннотации @GetMapping указываем адрес контроллера, по которому он будет доступен клиенту и тип возвращаемых данных.

@RestControllerpublic class UserController {    private UserService service;    public UserController(UserService service) {        this.service = service;    }    @GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE)    public @ResponseBody List<User> getAll() {        return this.service.getAll();    }}

Запускаем приложение и проверяем, что оно работает, для этого достаточно в браузере указать адрес http://localhost:8080/users, если вы все сделали верно, то увидите следующее:

5. Spring Security

Простенькое REST API написано и пока оно открыто для всех. Двигаемся дальше, теперь его необходимо защитить, а доступ открыть только авторизованным пользователям. Для этого воспользуемся Spring Security и JWT.

Spring Security это Java/JavaEE framework, предоставляющий механизмы построения систем аутентификации и авторизации, а также другие возможности обеспечения безопасности для корпоративных приложений, созданных с помощью Spring Framework.

JSON Web Token (JWT) это открытый стандарт (RFC 7519) для создания токенов доступа, основанный на формате JSON. Как правило, используется для передачи данных для аутентификации в клиент-серверных приложениях. Токены создаются сервером, подписываются секретным ключом и передаются клиенту, который в дальнейшем использует данный токен для подтверждения своей личности.

5.1 Подключаем зависимости

Добавляем новые зависимости в pom-файл.

<!--Security--><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-security</artifactId></dependency><dependency>    <groupId>io.jsonwebtoken</groupId>    <artifactId>jjwt</artifactId>    <version>0.9.1</version></dependency><dependency>    <groupId>jakarta.xml.bind</groupId>    <artifactId>jakarta.xml.bind-api</artifactId>    <version>2.3.3</version></dependency>

5.2 Генерация и хранения токена

Начнем с генерации и хранения токена, для этого создадим папку security и в ней создаем класс JwtTokenRepository с имплементацией интерфейса CsrfTokenRepository (из пакета org.springframework.security.web.csrf).

Интерфейс указывает на необходимость реализовать три метода:

  1. Генерация токена в методе generateToken;

  2. Сохранения токена saveToken;

  3. Получение токена loadToken.

Генерируем токен силами Jwt, пример реализации метода.

@Repositorypublic class JwtTokenRepository implements CsrfTokenRepository {    @Getter    private String secret;    public JwtTokenRepository() {        this.secret = "springrest";    }    @Override    public CsrfToken generateToken(HttpServletRequest httpServletRequest) {        String id = UUID.randomUUID().toString().replace("-", "");        Date now = new Date();        Date exp = Date.from(LocalDateTime.now().plusMinutes(30)                .atZone(ZoneId.systemDefault()).toInstant());        String token = "";        try {            token = Jwts.builder()                    .setId(id)                    .setIssuedAt(now)                    .setNotBefore(now)                    .setExpiration(exp)                    .signWith(SignatureAlgorithm.HS256, secret)                    .compact();        } catch (JwtException e) {            e.printStackTrace();            //ignore        }        return new DefaultCsrfToken("x-csrf-token", "_csrf", token);    }    @Override    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {    }    @Override    public CsrfToken loadToken(HttpServletRequest request) {        return null;    }}

Параметр secret является ключом, необходимым для расшифровки токена, оно может быть постоянным для всех токенов, но лучше сделать его уникальным только для пользователя, например для этого можно использовать ip-пользователя или его логин. Дата exp является датой окончания токена, рассчитывается как текущая дата плюс 30 минут. Такой параметр как продолжительность жизни токена рекомендую вынести в application.properties.

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

    @Override    public void saveToken(CsrfToken csrfToken, HttpServletRequest request, HttpServletResponse response) {        if (Objects.nonNull(csrfToken)) {            if (!response.getHeaderNames().contains(ACCESS_CONTROL_EXPOSE_HEADERS))                response.addHeader(ACCESS_CONTROL_EXPOSE_HEADERS, csrfToken.getHeaderName());            if (response.getHeaderNames().contains(csrfToken.getHeaderName()))                response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());            else                response.addHeader(csrfToken.getHeaderName(), csrfToken.getToken());        }    }    @Override    public CsrfToken loadToken(HttpServletRequest request) {        return (CsrfToken) request.getAttribute(CsrfToken.class.getName());    }

Сохранение токена выполняем в response (ответ от сервера) в раздел headers и открываем параметр для чтения фронта указав имя параметра в Access-Control-Expose-Headers.

Добавляем к классу еще один метод по очистке токена из response, будем использовать его при ошибке авторизации.

    public void clearToken(HttpServletResponse response) {        if (response.getHeaderNames().contains("x-csrf-token"))            response.setHeader("x-csrf-token", "");    }

5.3 Создание нового фильтра для SpringSecurity

Создаем новый класс JwtCsrfFilter, который является реализацией абстрактного класса OncePerRequestFilter (пакет org.springframework.web.filter). Класс будет выполнять валидацию токена и инициировать создание нового. Если обрабатываемый запрос относится к авторизации (путь /auth/login), то логика не выполняется и запрос отправляется далее для выполнения базовой авторизации.

public class JwtCsrfFilter extends OncePerRequestFilter {    private final CsrfTokenRepository tokenRepository;    private final HandlerExceptionResolver resolver;    public JwtCsrfFilter(CsrfTokenRepository tokenRepository, HandlerExceptionResolver resolver) {        this.tokenRepository = tokenRepository;        this.resolver = resolver;    }    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)            throws ServletException, IOException {        request.setAttribute(HttpServletResponse.class.getName(), response);        CsrfToken csrfToken = this.tokenRepository.loadToken(request);        boolean missingToken = csrfToken == null;        if (missingToken) {            csrfToken = this.tokenRepository.generateToken(request);            this.tokenRepository.saveToken(csrfToken, request, response);        }        request.setAttribute(CsrfToken.class.getName(), csrfToken);        request.setAttribute(csrfToken.getParameterName(), csrfToken);        if (request.getServletPath().equals("/auth/login")) {            try {                filterChain.doFilter(request, response);            } catch (Exception e) {                resolver.resolveException(request, response, null, new MissingCsrfTokenException(csrfToken.getToken()));            }        } else {            String actualToken = request.getHeader(csrfToken.getHeaderName());            if (actualToken == null) {                actualToken = request.getParameter(csrfToken.getParameterName());            }            try {                if (!StringUtils.isEmpty(actualToken)) {                    Jwts.parser()                            .setSigningKey(((JwtTokenRepository) tokenRepository).getSecret())                            .parseClaimsJws(actualToken);                        filterChain.doFilter(request, response);                } else                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));            } catch (JwtException e) {                if (this.logger.isDebugEnabled()) {                    this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));                }                if (missingToken) {                    resolver.resolveException(request, response, null, new MissingCsrfTokenException(actualToken));                } else {                    resolver.resolveException(request, response, null, new InvalidCsrfTokenException(csrfToken, actualToken));                }            }        }    }}

5.4 Реализация сервиса поиска пользователя

Теперь необходимо подготовить сервис для поиска пользователя по логину, которого будем авторизовывать. Для этого нам необходимо добавить к сервису UserService интерфейс UserDetailsService из пакета org.springframework.security.core.userdetails. Интерфейс требует реализовать один метод, выносить его в отдельный класс нет необходимости.

@Servicepublic class UserService implements UserDetailsService {    private UserRepository repository;    public UserService(UserRepository repository) {        this.repository = repository;    }    public List<User> getAll() {        return this.repository.getAll();    }    public User getByLogin(String login) {        return this.repository.getByLogin(login);    }    @Override    public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {        User u = getByLogin(login);        if (Objects.isNull(u)) {            throw new UsernameNotFoundException(String.format("User %s is not found", login));        }        return new org.springframework.security.core.userdetails.User(u.getLogin(), u.getPassword(), true, true, true, true, new HashSet<>());    }}

Полученного пользователя необходимо преобразовать в класс с реализацией интерфейса UserDetails или воспользоваться уже готовой реализацией из пакета org.springframework.security.core.userdetails. Последним параметром конструктора необходимо добавить список элементов GrantedAuthority, это роли пользователя, у нас их нет, оставим его пустым. Если пользователя по логину не нашли, то бросаем исключение UsernameNotFoundException.

5.5 Обработка авторизации

По результату успешно выполненной авторизации возвращаю данные авторизованного пользователя. Для этого создадим еще один контроллер AuthController с методом getAuthUser. Контроллер будет обрабатывать запрос /auth/login, а именно обращаться к контексту Security для получения логина авторизованного пользователя, по нему получать данные пользователя из сервиса UserService и возвращать их на фронт.

@RestController@RequestMapping("/auth")public class AuthController {    private UserService service;    public AuthController(UserService service) {        this.service = service;    }    @PostMapping(path = "/login", produces = MediaType.APPLICATION_JSON_VALUE)    public @ResponseBody com.springbootsecurityrest.model.User getAuthUser() {        Authentication auth = SecurityContextHolder.getContext().getAuthentication();        if (auth == null) {            return null;        }        Object principal = auth.getPrincipal();        User user = (principal instanceof User) ? (User) principal : null;        return Objects.nonNull(user) ? this.service.getByLogin(user.getUsername()) : null;    }}

5.6 Обработка ошибок

Что бы видеть ошибки авторизации или валидации токена, необходимо подготовить обработчик ошибок. Для этого создаем новый класс GlobalExceptionHandler в корне com.springbootsecurityrest, который является расширением класса ResponseEntityExceptionHandler с реализацией метода handleAuthenticationException.

Метод будет устанавливать статус ответа 401 (UNAUTHORIZED) и возвращать сообщение в формате ErrorInfo.

@RestControllerAdvicepublic class GlobalExceptionHandler extends ResponseEntityExceptionHandler {    private JwtTokenRepository tokenRepository;    public GlobalExceptionHandler(JwtTokenRepository tokenRepository) {        this.tokenRepository = tokenRepository;    }    @ExceptionHandler({AuthenticationException.class, MissingCsrfTokenException.class, InvalidCsrfTokenException.class, SessionAuthenticationException.class})    public ErrorInfo handleAuthenticationException(RuntimeException ex, HttpServletRequest request, HttpServletResponse response){        this.tokenRepository.clearToken(response);        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);        return new ErrorInfo(UrlUtils.buildFullRequestUrl(request), "error.authorization");    }    @Getter public class ErrorInfo {        private final String url;        private final String info;        ErrorInfo(String url, String info) {            this.url = url;            this.info = info;        }    }}

5.7 Настройка конфигурационного файла Spring Security.

Все данные подготовили и теперь необходимо настроить конфигурационный файл. В папке com.springbootsecurityrest создаем файл SpringSecurityConfig, который является реализацией абстрактного класса WebSecurityConfigurerAdapter пакета org.springframework.security.config.annotation.web.configuration. Помечаем класс двумя аннотациями: Configuration и EnableWebSecurity.

Реализуем метод configure(AuthenticationManagerBuilder auth), в класс AuthenticationManagerBuilder устанавливаем сервис UserService, для того что бы Spring Security при выполнении базовой авторизации мог получить из репозитория данные пользователя по логину.

@Configuration@EnableWebSecuritypublic class SpringSecurityConfig extends WebSecurityConfigurerAdapter {    @Autowired    private UserService service;    @Autowired    private JwtTokenRepository jwtTokenRepository;    @Autowired    @Qualifier("handlerExceptionResolver")    private HandlerExceptionResolver resolver;    @Bean    public PasswordEncoder devPasswordEncoder() {        return NoOpPasswordEncoder.getInstance();    }      @Override    protected void configure(HttpSecurity http) throws Exception {         }    @Override    protected void configure(AuthenticationManagerBuilder auth) throws Exception {        auth.userDetailsService(this.service);    }}

Реализуем метод configure(HttpSecurity http):

    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .sessionManagement()                    .sessionCreationPolicy(SessionCreationPolicy.NEVER)                .and()                    .addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class)                    .csrf().ignoringAntMatchers("/**")                .and()                    .authorizeRequests()                    .antMatchers("/auth/login")                    .authenticated()                .and()                    .httpBasic()                    .authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e)));    }

Разберем метод детальнее:

  1. sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) - отключаем генерацию сессии;

  2. addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class).csrf().ignoringAntMatchers("/**") - указываем созданный нами фильтр JwtCsrfFilter в расположение стандартного фильтра, при этом игнорируем обработку стандартного;

  3. .authorizeRequests().antMatchers("/auth/login").authenticated() для запроса /auth/login выполняем авторизацию силами security. Что бы не было двойной валидации (по токену и базовой), запрос был добавлен в исключение к классу JwtCsrfFilter;

  4. .httpBasic().authenticationEntryPoint(((request, response, e) -> resolver.resolveException(request, response, null, e))) - ошибки базовой авторизации отправляем в обработку GlobalExceptionHandler

6. Проверка функционала

Для проверки использую Postman. Запускаем бэкенд и выполняем запрос http://localhost:8080/users с типом GET.

Токена нет, валидация не пройдена, получаем сообщение с 401 статусом.

Пытаемся авторизоваться с неверными данными, выполняем запрос http://localhost:8080/auth/login с типом POST, валидация не выполнена, токен не получен, вернулась ошибка с 401 статусом.

Авторизуемся с корректными данными, авторизация выполнена, получен авторизованный пользователь и токен.

Повторяем запрос http://localhost:8080/users с типом GET, но с полученным токеном на предыдущем шаге. Получаем список пользователей и обновленный токен.

Заключение

В этой статье рассмотрели один из примеров реализации REST приложения с Spring Security и JWT. Надеюсь данный вариант реализации кому то окажется полезным.

Полный код проекта выложен доступен на github

Подробнее..

Перевод REST API в Symfony (без FosRestBundle) с использованием JWT аутентификации. Часть 1

06.07.2020 18:16:50 | Автор: admin

Перевод статьи подготовлен в преддверии старта курса Symfony Framework.





В первой части статьи мы рассмотрим самый простой способ реализации REST API в проекте Symfony без использования FosRestBundle. Во второй части, которую я опубликую следом, мы рассмотрим JWT аутентификацию. Прежде чем мы начнем, сперва мы должны понять, что на самом деле означает REST.


Что означает Rest?


REST (Representational State Transfer передача состояния представления) это архитектурный стиль разработки веб-сервисов, который невозможно игнорировать, потому что в современной экосистеме существует большая потребность в создании Restful-приложений. Это может быть связано с уверенным подъемом позиций JavaScript и связанных фреймворков.


REST API использует протокол HTTP. Это означает, что когда клиент делает какой-либо запрос к такому веб-сервису, он может использовать любой из стандартных HTTP-глаголов: GET, POST, DELETE и PUT. Ниже описано, что произойдет, если клиент укажет соответствующий глагол.


  • GET: будет использоваться для получения списка ресурсов или сведений о них.
  • POST: будет использоваться для создания нового ресурса.
  • PUT: будет использоваться для обновления существующего ресурса.
  • DELETE: будет использоваться для удаления существующего ресурса.

REST не имеет состояний (state), и это означает, что на стороне сервера тоже нет никаких состояний запроса. Состояния остаются на стороне клиента (пример использование JWT для аутентификации, с помощью которого мы собираемся засекьюрить наш REST API). Таким образом, при использовании аутентификации в REST API нам нужно отправить аутентификационный заголовок, чтобы получить правильный ответ без хранения состояния.


Создание проекта Symfony:


Во-первых, мы предполагаем, что вы уже установили PHP и менеджер пакетов Сomposer для создания нового проекта Symfony. С этим всем в наличии создайте новый проект с помощью следующей команды в терминале:


composer create-project symfony/skeleton demo_rest_api


Создание проекта Symfony


Мы используем базовый скелет Symfony, который рекомендуется для микросервисов и API. Вот как выглядит структура каталогов:



Структура проекта


Config: содержит все настройки бандла и список бандлов в bundle.php.
Public: предоставляет доступ к приложению через index.php.
Src: содержит все контроллеры, модели и сервисы
Var: содержит системные логи и файлы кэша.
Vendor: содержит все внешние пакеты.


Теперь давайте установим некоторые необходимые пакеты с помощью Сomposer:


composer require symfony/orm-packcomposer require sensio/framework-extra-bundle

Мы установили sensio/framework-extra-bundle, который поможет нам упростить код, используя аннотации для определения наших маршрутов.


Нам также необходимо установить symphony/orm-pack для интеграции с Doctrine ORM, чтобы соединиться с базой данных. Ниже приведена конфигурация созданной мной базы данных, которая может быть задана в файле .env.



.env файл конфигурации


Теперь давайте создадим нашу первую сущность. Создайте новый файл с именем Post.php в папке src/Entity.


<?php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /**  * @ORM\Entity  * @ORM\Table(name="post")  * @ORM\HasLifecycleCallbacks()  */ class Post implements \JsonSerializable {  /**   * @ORM\Column(type="integer")   * @ORM\Id   * @ORM\GeneratedValue(strategy="AUTO")   */  private $id;  /**   * @ORM\Column(type="string", length=100   *   */  private $name;  /**   * @ORM\Column(type="text")   */  private $description;  /**   * @ORM\Column(type="datetime")   */  private $create_date;  /**   * @return mixed   */  public function getId()  {   return $this->id;  }  /**   * @param mixed $id   */  public function setId($id)  {   $this->id = $id;  }  /**   * @return mixed   */  public function getName()  {   return $this->name;  }  /**   * @param mixed $name   */  public function setName($name)  {   $this->name = $name;  }  /**   * @return mixed   */  public function getDescription()  {   return $this->description;  }  /**   * @param mixed $description   */  public function setDescription($description)  {   $this->description = $description;  }  /**   * @return mixed   */  public function getCreateDate(): ?\DateTime  {   return $this->create_date;  }  /**   * @param \DateTime $create_date   * @return Post   */  public function setCreateDate(\DateTime $create_date): self  {   $this->create_date = $create_date;   return $this;  }  /**   * @throws \Exception   * @ORM\PrePersist()   */  public function beforeSave(){   $this->create_date = new \DateTime('now', new \DateTimeZone('Africa/Casablanca'));  }  /**   * Specify data which should be serialized to JSON   * @link https://php.net/manual/en/jsonserializable.jsonserialize.php   * @return mixed data which can be serialized by <b>json_encode</b>,   * which is a value of any type other than a resource.   * @since 5.4.0   */  public function jsonSerialize()  {   return [    "name" => $this->getName(),    "description" => $this->getDescription()   ];  } }

И после этого выполните команду: php bin/console doctrine:schema:create для создания таблицы базы данных в соответствии с нашей сущностью Post.


Теперь давайте создадим PostController.php, куда мы добавим все методы, взаимодействующие с API. Он должен быть помещен в папку src/Controller.


<?php /**  * Created by PhpStorm.  * User: hicham benkachoud  * Date: 02/01/2020  * Time: 22:44  */ namespace App\Controller; use App\Entity\Post; use App\Repository\PostRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; /**  * Class PostController  * @package App\Controller  * @Route("/api", name="post_api")  */ class PostController extends AbstractController {  /**   * @param PostRepository $postRepository   * @return JsonResponse   * @Route("/posts", name="posts", methods={"GET"})   */  public function getPosts(PostRepository $postRepository){   $data = $postRepository->findAll();   return $this->response($data);  }  /**   * @param Request $request   * @param EntityManagerInterface $entityManager   * @param PostRepository $postRepository   * @return JsonResponse   * @throws \Exception   * @Route("/posts", name="posts_add", methods={"POST"})   */  public function addPost(Request $request, EntityManagerInterface $entityManager, PostRepository $postRepository){   try{    $request = $this->transformJsonBody($request);    if (!$request || !$request->get('name') || !$request->request->get('description')){     throw new \Exception();    }    $post = new Post();    $post->setName($request->get('name'));    $post->setDescription($request->get('description'));    $entityManager->persist($post);    $entityManager->flush();    $data = [     'status' => 200,     'success' => "Post added successfully",    ];    return $this->response($data);   }catch (\Exception $e){    $data = [     'status' => 422,     'errors' => "Data no valid",    ];    return $this->response($data, 422);   }  }  /**   * @param PostRepository $postRepository   * @param $id   * @return JsonResponse   * @Route("/posts/{id}", name="posts_get", methods={"GET"})   */  public function getPost(PostRepository $postRepository, $id){   $post = $postRepository->find($id);   if (!$post){    $data = [     'status' => 404,     'errors' => "Post not found",    ];    return $this->response($data, 404);   }   return $this->response($post);  }  /**   * @param Request $request   * @param EntityManagerInterface $entityManager   * @param PostRepository $postRepository   * @param $id   * @return JsonResponse   * @Route("/posts/{id}", name="posts_put", methods={"PUT"})   */  public function updatePost(Request $request, EntityManagerInterface $entityManager, PostRepository $postRepository, $id){   try{    $post = $postRepository->find($id);    if (!$post){     $data = [      'status' => 404,      'errors' => "Post not found",     ];     return $this->response($data, 404);    }    $request = $this->transformJsonBody($request);    if (!$request || !$request->get('name') || !$request->request->get('description')){     throw new \Exception();    }    $post->setName($request->get('name'));    $post->setDescription($request->get('description'));    $entityManager->flush();    $data = [     'status' => 200,     'errors' => "Post updated successfully",    ];    return $this->response($data);   }catch (\Exception $e){    $data = [     'status' => 422,     'errors' => "Data no valid",    ];    return $this->response($data, 422);   }  }  /**   * @param PostRepository $postRepository   * @param $id   * @return JsonResponse   * @Route("/posts/{id}", name="posts_delete", methods={"DELETE"})   */  public function deletePost(EntityManagerInterface $entityManager, PostRepository $postRepository, $id){   $post = $postRepository->find($id);   if (!$post){    $data = [     'status' => 404,     'errors' => "Post not found",    ];    return $this->response($data, 404);   }   $entityManager->remove($post);   $entityManager->flush();   $data = [    'status' => 200,    'errors' => "Post deleted successfully",   ];   return $this->response($data);  }  /**   * Returns a JSON response   *   * @param array $data   * @param $status   * @param array $headers   * @return JsonResponse   */  public function response($data, $status = 200, $headers = [])  {   return new JsonResponse($data, $status, $headers);  }  protected function transformJsonBody(\Symfony\Component\HttpFoundation\Request $request)  {   $data = json_decode($request->getContent(), true);   if ($data === null) {    return $request;   }   $request->request->replace($data);   return $request;  } }

Здесь мы определили пять маршрутов:


GET /api/posts: вернет список постов.



api получения всех постов


POST /api/posts: создаст новый пост.



api добавления нового поста


GET /api/posts/id: вернет пост, соответствующий определенному идентификатору,



получение конкретного поста


PUT /api/posts/id: обновит пост.



обновление поста


Это результат после обновления:



пост после обновления


DELETE /api/posts/id: удалит пост.



удаление поста


Это результат получения всех постов после удаления поста с идентификатором 3:



все посты после удаления


Исходный код можно найти здесь


Заключение


Итак, теперь мы понимаем, что такое REST и Restful. Restful API должен быть без состояний. Мы знаем, как создать Restful-приложение, используя HTTP-глаголы. В общем, теперь мы хорошо понимаем REST и готовы профессионально создавать Restful-приложения.


В следующей статье мы рассмотрим, как обеспечить секьюрность API с помощью JWT аутентификации.




Узнать подробнее о курсе Symfony Framework



Подробнее..

Haproxy программирование и конфигурирование средствами Lua

05.12.2020 22:10:10 | Автор: admin
Сервер Haproxy имеет встроенные средства для выполнения скриптов Lua.

Язык программирования Lua для расширения возможностей различных серверов используется очень широко. Например, на Lua можно программировать для серверов Redis, Nginx (nginx-extras, openresty), Envoy. Это вполне закономерно, так как язык программирования Lua как раз и был разработан для удобства встраивания в приложения в качестве скриптового языка.

В этом сообщении я рассмотрю варианты использования Lua для расширения возможностей Haproxy.

Согласно документации, скрипты Lua на сервере Haproxy могут выполняться в шести контекстах:

body context (контекст времени загрузки конфигурации сервера Haproxy, когда выполняются скрипты, заданные директивой lua-load);
init context (контекст функций, которые вызываются сразу после загрузки конфигурации, и зарегистрированы системной функции core.register_init(function);
task context (контекст функций, выполняемых по расписанию и зарегистрированных системной функцией core.register_task(function));
action context (контекст функций, зарегистрированных системной функцией сore.register_action(function));
sample-fetch context (контекст функций, зарегистрированных системной функцией сore.register_fetches(function));
converter context (контекст функций, зарегистрированных системной функцией сore.register_converters(function)).

Фактически есть еще один контекст выполнения, который не указан в документации:
service context (контекст функций, зарегистрированных системной функцией сore.register_service(function));

Начнем с самой простой конфигурации сервера Haproxy. Конфигурация состоит из двух секций frontend то есть то, к чему обращается клиент с запросом, и backend то, куда проксируется запрос клиента через сервер Haproxy:

frontend jwt        mode http        bind *:80        use_backend backend_appbackend backend_app        mode http        server app1 app:3000


Теперь все запросы, приходящие на порт 80 Haproxy будут перенаправлены на порт 3000 сервера app.

Services



Services это функции, определенные в скриптах Lua, которые формируют ответ без обращения к бэкенду. Эти функции регистрируются вызовом системной функции сore.register_service(function)).

Определим простейший Service в файле guarde.lua:

function _M.hello_world(applet)  applet:set_status(200)  local response = string.format([[<html><body>Hello World!</body></html>]], message);  applet:add_header("content-type", "text/html");  applet:add_header("content-length", string.len(response))  applet:start_response()  applet:send(response)end


И зарегистрируем ее как Service в файле register.lua:

package.path = package.path  .. "./?.lua;/usr/local/etc/haproxy/?.lua"local guard = require("guard")core.register_service("hello-world", "http", guard.hello_world);


Параметр http является триггером, который допускает использование Service только в контексте http запроса (mode http).

Дополним конфигурацию сервера Haproxy:

global        lua-load /usr/local/etc/haproxy/register.luafrontend jwt        mode http        bind *:80        use_backend backend_app        http-request use-service lua.hello-world   if { path /hello_world }backend backend_app        mode http        server app1 app:3000


Теперь, обратившись к серверу Haproxy с запросом /hello_world, клиент получит не ответ с проксируемого сервера, а ответ сервиса lua.hello-world.

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

Actions



Actions действия, выполняемые после получения запроса от клиента или после получения ответа от проксируемого сервера. Actions могут выполнять асинхронные операции (например запросы к базе данных) и не имеют возвращаемого значения. С сервером Actions общаются путем установки переменных контекста запроса. Контекст запроса предается в качестве параметра при вызове Action. Традиционно имя этого параметра txn. Создадим Action, который будет проверять наличие авторизации Bearer в запросе:

function _M.validate_token_action(txn)  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return txn:set_var("txn.not_authorized", true);  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return txn:set_var("txn.not_authorized", true);  end  if claim.exp < os.time() then    return txn:set_var("txn.authentication_timeout", true);  end  txn:set_var("txn.jwt_authorized", true);end


Зарегистрируем этот Action:

core.register_action("validate-token", { "http-req" }, guard.validate_token_action);


Параметр { http-req } является триггером, который позволяет использовать этот Action только в контексте http и только на этапе запроса клиента (и запрещает использовать на этапе ответа проксируемого сервера).

В конфигурации Haproxy, Action регистрируется в секции http-request:

frontend jwt        mode http        bind *:80        http-request use-service lua.hello-world   if { path /hello_world }        http-request lua.validate-token                 if { path -m beg /api/ }


На основании значения переменных, установленных в Action, формируются ACL (Access Control Lists) ключевой элемент в конфигурациях Haproxy:

        acl jwt_authorized  var(txn.jwt_authorized) -m bool        use_backend app if jwt_authorized { path -m beg /api/ }


Полный листинг конфигурации сервера Haproxy для Action validate-token:

global        lua-load /usr/local/etc/haproxy/register.luafrontend jwt        mode http        bind *:80        http-request use-service lua.hello-world   if { path /hello_world }        http-request lua.validate-token            if { path -m beg /api }        acl bad_request            var(txn.bad_request)               -m bool        acl not_authorized         var(txn.not_authorized)            -m bool        acl authentication_timeout var(txn.authentication_timeout)    -m bool        acl too_many_request       var(txn.too_many_request)          -m bool        acl jwt_authorized         var(txn.jwt_authorized)            -m bool        http-request deny deny_status 400 if bad_request { path -m beg /api/ }        http-request deny deny_status 401 if !jwt_authorized { path -m beg /api/ } || not_authorized { path -m beg /api/ }        http-request return status 419 content-type text/html string "Authentication Timeout" if authentication_timeout { path -m beg /api/ }        http-request deny deny_status 429 if too_many_request { path -m beg /api/  }        http-request deny deny_status 429 if too_many_request { path -m beg /auth/  }        use_backend app if { path /hello }        use_backend app if { path /auth/login }        use_backend app if jwt_authorized { path -m beg /api/ }backend app        mode http        server app1 app:3000


Fetches



Fetches это значения которые вычисляются в процессе запроса. Они могут быть только синхронными, и принимают параметры, заданные в конфигурации Haproxy. Например, та же самая проверка авторизации может быть выполнена как Fetch:

function _M.validate_token_fetch(txn)  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return "not_authorized";  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return "not_authorized";  end  if claim.exp < os.time() then    return "authentication_timeout";  end  return "jwt_authorized:" .. claim.jti;endcore.register_fetches("validate-token", _M.validate_token_fetch);


Установка ACL по значениям из Fetches задается так:

       http-request set-var(txn.validate_token) lua.validate-token()        acl bad_request var(txn.validate_token) == "bad_request" -m bool        acl not_authorized var(txn.validate_token) == "not_authorized" -m bool        acl authentication_timeout var(txn.validate_token) == "authentication_timeout" -m bool        acl too_many_request var(txn.validate_token) == "too_many_request" -m bool        acl jwt_authorized var(txn.validate_token) -m beg "jwt_authorized"


Converters



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

Соаздадим Converter, который будет заголовку Authorization преобразовывать в строку:

function _M.validate_token_converter(auth_header_string)  local auth_header = core.tokenize(auth_header_string, " ")  if auth_header[1] ~= "Bearer" or not auth_header[2] then    return "not_authorized";  end  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});  if not claim then    return "not_authorized";  end  if claim.exp < os.time() then    return "authentication_timeout";  end  return "jwt_authorized";endcore.register_converters("validate-token-converter",  _M.validate_token_converter);


В файле конфигурации использование конвертера задается следующим образом:

        http-request set-var(txn.validate_token) hdr(authorization),lua.validate-token-converter


К значениею заголовка Authorization, который извлекается системным Fetch hdr() применяется Converter lua.validate-token-converter.

Stick Table



Stick Table это хранилище пар ключ-значение, которые оптимизировано для учета количества запросов в единицу времени, и служат, прежде всего, для защиты серверов от атак DDoS или брутфорса (напрмер перебора паролей или выкачки запросами REST больших объемов данных). В паре с такими средствами как Fetches и Converters, эти таблицы могут подсчитывать количество запросов, например, с определенным сессионным cookie или jti, не давая тем самым использовать одну авторизацию для организации распределенной атаки с сотен тысяч устройств. К положительным сторонам Stick Table относится скорость работы и простота конфигурирования. К отрицательным ограниченное количество регистров для учета значений (всего восемь регистров), потребление памяти, потеря данных после перегрузки сервера Haproxy. Рассмотрим как задаются правила в Stick Table:

        stick-table  type string  size 100k  expire 30s store http_req_rate(10s)        http-request track-sc1 lua.validate-token()        http-request deny deny_status 429 if { sc_http_req_rate(1) gt 3 }


Строка 1. Создается таблица. В качестве ключа используется значение типа строка. Максимальный размер таблицы 100k. Срок хранения ключа 30 секунд. В качестве значения будут накапливаться количество запросов за последние 10 секунд с одинаковым значением ключа типа строка.
Строка 2. Задается значение ключа, полученного из Fetch lua.validate-token() и регистр 1, в котором будут накапливаться значения (track-sc1)
Строка 3. Если количество запросов с ключом, заданными в строке 2, накопленных в регистре с номером 1 (sc_http_req_rate(1)) превышает 3 сервер отдает ответ со статусом 429.

Код использованный в данном сообщении доступен в репозитарии. В частности, там есть файл docker-compose.yml, который поможет поднять необходимую для работы среду.

apapacy@gmail.com
5 декабря 2020 года.
Подробнее..

Spring Security пример REST-сервиса с авторизацией по протоколу OAuth2 через BitBucket и JWT

17.11.2020 00:09:32 | Автор: admin
В предыдущей статье мы разработали простое защищенное веб приложение, в котором для аутентификации пользователей использовался протокол OAuth2 с Bitbucket в качестве сервера авторизации. Кому-то такая связка может показаться странной, но представьте, что мы разрабатываем CI (Continuous Integration) сервер и хотели бы иметь доступ к ресурсам пользователя в системе контроля версий. Например, по такому же принципу работает довольно известная CI платформа drone.io.

В предыдущем примере для авторизации запросов к серверу использовалась HTTP-сессия (и куки). Однако для реализации REST-сервиса данный способ авторизации не подходит, поскольку одним из требований REST архитектуры является отсутсвие состояния. В данной статье мы реализуем REST-сервис, авторизация запросов к которому будет осуществляться с помощью токена доступа (access token).

Немного теории


Аутентификация это процесс проверки учётных данных пользователя (логин/пароль). Проверка подлинности пользователя осуществляется путём сравнения введённого им логина/пароля с сохраненными данными.
Авторизация это проверка прав пользователя на доступ к определенным ресурсам. Авторизация выполняется непосредственно при обращении пользователя к ресурсу.

Рассмотрим порядок работы двух вышеупомянутых способов авторизации запросов.
Авторизация запросов с помощью HTTP-сессии:
  • Пользователь проходит аутентификацию любым из способов.
  • На сервере создается HTTP-сессия и куки JSESSIONID, хранящий идентификатор сессии.
  • Куки JSESSIONID передается на клиент и сохраняется в браузере.
  • С каждым последующим запросом на сервер отправляется куки JSESSIONID.
  • Сервер находит соответствующую HTTP-сессию с информацией о текущем пользователе и определяет имеет ли пользователь права на выполнение данного вызова.
  • Для выполнения выхода из приложения необходимо удалить с сервера HTTP-сессию.


Авторизация запросов с помощью токена доступа:
  • Пользователь проходит аутентификацию любым из способов.
  • Сервер создает токен доступа, подписанный секретным ключом, а затем отправляет его клиенту. Токен содержит идентификатор пользователя и его роли.
  • Токен сохраняется на клиенте и передается на сервер с каждым последующим запросом. Как правило для передачи токена используетя HTTP заголовок Authorization.
  • Сервер сверяет подпись токена, извлекает из него идентификатор пользователя, его роли и определяет имеет ли пользователь права на выполнение данного вызова.
  • Для выполнения выхода из приложения достаточно просто удалить токен на клиенте без необходимости взаимодействия с сервером.


Распространенным форматом токена доступа в настоящее время является JSON Web Token (JWT). Токен в формате JWT содержит три блока, разделенных точками: заголовок (header), набор полей (payload) и сигнатуру (подпись). Первые два блока представлены в JSON-формате и закодированы в формат base64. Набор полей может состоять как из зарезервированных имен (iss, iat, exp), так и произвольных пар имя/значение. Подпись может генерироваться как при помощи симметричных, так и асимметричных алгоритмов шифрования.

Реализация


Мы реализуем REST-сервис, предоставляющий следующее API:
  • GET /auth/login запустить процесс аутентификации пользователя.
  • POST /auth/token запросить новую пару access/refresh токенов.
  • GET /api/repositories получить список Bitbucket репозиториев текущего пользователя.


Высокоуровневая архитектура приложения.

Заметим, что поскольку приложение состоит из трех взаимодействующих компонентов, помимо того, что мы выполняем авторизацию запросов клиента к серверу, Bitbucket авторизует запросы сервера к нему. Мы не будем настраивать авторизацию методов по ролям, чтобы не делать пример сложнее. У нас есть только один API метод GET /api/repositories, вызывать который могут только аутентифицированные пользователи. Сервер может выполнять на Bitbucket любые операции, разрешенные при регистрации OAuth клиента.

Процесс регистрации OAuth клиента описан в предыдущей статье.

Для реализации мы будем использовать Spring Boot версии 2.2.2.RELEASE и Spring Security версии 5.2.1.RELEASE.

Переопределим AuthenticationEntryPoint.


В стандартном веб-приложении, когда осуществляется обращение к защищенному ресурсу и в секьюрити контексте отсутствует объект Authentication, Spring Security перенаправляет пользователя на страницу аутентификации. Однако для REST-сервиса более подходящим поведением в этом случае было бы возвращать HTTP статус 401 (UNAUTHORIZED).

RestAuthenticationEntryPoint
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {    @Override    public void commence(            HttpServletRequest request,            HttpServletResponse response,            AuthenticationException authException) throws IOException {        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());    }}


Создадим login endpoint.


Для аутентификации пользователя мы по-прежнему используем OAuth2 с типом авторизации Authorization Code. Однако на предыдущем шаге мы заменили стандартный AuthenticationEntryPoint своей реализацией, поэтому нам нужен явный способ запустить процесс аутентификации. При отправке GET запроса по адресу /auth/login мы перенаправим пользователя на страницу аутентификации Bitbucket. Параметром этого метода будет URL обратного вызова, по которому мы возвратим токен доступа после успешной аутентификации.

Login endpoint
@Path("/auth")public class AuthEndpoint extends EndpointBase {...    @GET    @Path("/login")    public Response authorize(@QueryParam(REDIRECT_URI) String redirectUri) {        String authUri = "/oauth2/authorization/bitbucket";        UriComponentsBuilder builder = fromPath(authUri).queryParam(REDIRECT_URI, redirectUri);        return handle(() -> temporaryRedirect(builder.build().toUri()).build());    }}


Переопределим AuthenticationSuccessHandler.


AuthenticationSuccessHandler вызывается после успешной аутентификации. Сгенерируем тут токен доступа, refresh токен и выполним редирект по адресу обратного вызова, который был передан в начале процесса аутентификации. Токен доступа вернем параметром GET запроса, а refresh токен в httpOnly куке. Что такое refresh токен разберем позже.

ExampleAuthenticationSuccessHandler
public class ExampleAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {    private final TokenService tokenService;    private final AuthProperties authProperties;    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    public ExampleAuthenticationSuccessHandler(            TokenService tokenService,            AuthProperties authProperties,            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.tokenService = requireNonNull(tokenService);        this.authProperties = requireNonNull(authProperties);        this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository);    }    @Override    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {        log.info("Logged in user {}", authentication.getPrincipal());        super.onAuthenticationSuccess(request, response, authentication);    }    @Override    protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {        Optional<String> redirectUri = getCookie(request, REDIRECT_URI).map(Cookie::getValue);        if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {            throw new BadRequestException("Received unauthorized redirect URI.");        }        return UriComponentsBuilder.fromUriString(redirectUri.orElse(getDefaultTargetUrl()))                .queryParam("token", tokenService.newAccessToken(toUserContext(authentication)))                .build().toUriString();    }    @Override    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {        redirectToTargetUrl(request, response, authentication);    }    private boolean isAuthorizedRedirectUri(String uri) {        URI clientRedirectUri = URI.create(uri);        return authProperties.getAuthorizedRedirectUris()                .stream()                .anyMatch(authorizedRedirectUri -> {                    // Only validate host and port. Let the clients use different paths if they want to.                    URI authorizedURI = URI.create(authorizedRedirectUri);                    return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())                            && authorizedURI.getPort() == clientRedirectUri.getPort();                });    }    private TokenService.UserContext toUserContext(Authentication authentication) {        ExampleOAuth2User principal = (ExampleOAuth2User) authentication.getPrincipal();        return TokenService.UserContext.builder()                .login(principal.getName())                .name(principal.getFullName())                .build();    }    private void addRefreshTokenCookie(HttpServletResponse response, Authentication authentication) {        RefreshToken token = tokenService.newRefreshToken(toUserContext(authentication));        addCookie(response, REFRESH_TOKEN, token.getId(), (int) token.getValiditySeconds());    }    private void redirectToTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {        String targetUrl = determineTargetUrl(request, response, authentication);        if (response.isCommitted()) {            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);            return;        }        addRefreshTokenCookie(response, authentication);        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);        getRedirectStrategy().sendRedirect(request, response, targetUrl);    }}


Переопределим AuthenticationFailureHandler.


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

ExampleAuthenticationFailureHandler
public class ExampleAuthenticationFailureHandler implements AuthenticationFailureHandler {    private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    public ExampleAuthenticationFailureHandler(            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository);    }    @Override    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {        String targetUrl = getFailureUrl(request, exception);        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);        redirectStrategy.sendRedirect(request, response, targetUrl);    }    private String getFailureUrl(HttpServletRequest request, AuthenticationException exception) {        String targetUrl = getCookie(request, Cookies.REDIRECT_URI)                .map(Cookie::getValue)                .orElse(("/"));        return UriComponentsBuilder.fromUriString(targetUrl)                .queryParam("error", exception.getLocalizedMessage())                .build().toUriString();    }}


Создадим TokenAuthenticationFilter.


Задача этого фильтра извлечь токен доступа из заголовка Authorization в случае его наличия, провалидировать его и инициализировать секьюрити контекст.

TokenAuthenticationFilter
public class TokenAuthenticationFilter extends OncePerRequestFilter {    private final UserService userService;    private final TokenService tokenService;    public TokenAuthenticationFilter(            UserService userService, TokenService tokenService) {        this.userService = requireNonNull(userService);        this.tokenService = requireNonNull(tokenService);    }    @Override    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException {        try {            Optional<String> jwtOpt = getJwtFromRequest(request);            if (jwtOpt.isPresent()) {                String jwt = jwtOpt.get();                if (isNotEmpty(jwt) && tokenService.isValidAccessToken(jwt)) {                    String login = tokenService.getUsername(jwt);                    Optional<User> userOpt = userService.findByLogin(login);                    if (userOpt.isPresent()) {                        User user = userOpt.get();                        ExampleOAuth2User oAuth2User = new ExampleOAuth2User(user);                        OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(oAuth2User, oAuth2User.getAuthorities(), oAuth2User.getProvider());                        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));                        SecurityContextHolder.getContext().setAuthentication(authentication);                    }                }            }        } catch (Exception e) {            logger.error("Could not set user authentication in security context", e);        }        chain.doFilter(request, response);    }    private Optional<String> getJwtFromRequest(HttpServletRequest request) {        String token = request.getHeader(AUTHORIZATION);        if (isNotEmpty(token) && token.startsWith("Bearer ")) {            token = token.substring(7);        }        return Optional.ofNullable(token);    }}


Создадим refresh token endpoint.


В целях безопасности время жизни токена доступа обычно делают небольшим. Тогда в случае его кражи злоумышленник не сможет пользоваться им бесконечно долго. Чтобы не заставлять пользователя выполнять вход в приложение снова и снова используется refresh токен. Он выдается сервером после успешной аутентификации вместе с токеном доступа и имеет большее время жизни. Используя его можно запросить новую пару токенов. Refresh токен рекомендуют хранить в httpOnly куке.

Refresh token endpoint
@Path("/auth")public class AuthEndpoint extends EndpointBase {...    @POST    @Path("/token")    @Produces(APPLICATION_JSON)    public Response refreshToken(@CookieParam(REFRESH_TOKEN) String refreshToken) {        return handle(() -> {            if (refreshToken == null) {                throw new InvalidTokenException("Refresh token was not provided.");            }            RefreshToken oldRefreshToken = tokenService.findRefreshToken(refreshToken);            if (oldRefreshToken == null || !tokenService.isValidRefreshToken(oldRefreshToken)) {                throw new InvalidTokenException("Refresh token is not valid or expired.");            }            Map<String, String> result = new HashMap<>();            result.put("token", tokenService.newAccessToken(of(oldRefreshToken.getUser())));            RefreshToken newRefreshToken = newRefreshTokenFor(oldRefreshToken.getUser());            return Response.ok(result).cookie(createRefreshTokenCookie(newRefreshToken)).build();        });    }}


Переопределим AuthorizationRequestRepository.


Spring Security использует объект AuthorizationRequestRepository для хранения объектов OAuth2AuthorizationRequest на время процесса аутентификации. Реализацией по умолчанию является класс HttpSessionOAuth2AuthorizationRequestRepository, который использует HTTP-сессию в качестве хранилища. Т.к. наш сервис не должен хранить состояние, эта реализация нам не подходит. Реализуем свой класс, который будет использовать HTTP cookies.

HttpCookieOAuth2AuthorizationRequestRepository
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {    private static final int COOKIE_EXPIRE_SECONDS = 180;    private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "OAUTH2-AUTH-REQUEST";    @Override    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {        return getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)                .map(cookie -> deserialize(cookie, OAuth2AuthorizationRequest.class))                .orElse(null);    }    @Override    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {        if (authorizationRequest == null) {            removeAuthorizationRequestCookies(request, response);            return;        }        addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);        String redirectUriAfterLogin = request.getParameter(QueryParams.REDIRECT_URI);        if (isNotBlank(redirectUriAfterLogin)) {            addCookie(response, REDIRECT_URI, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);        }    }    @Override    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {        return loadAuthorizationRequest(request);    }    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {        deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);        deleteCookie(request, response, REDIRECT_URI);    }    private static String serialize(Object object) {        return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));    }    @SuppressWarnings("SameParameterValue")    private static <T> T deserialize(Cookie cookie, Class<T> clazz) {        return clazz.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));    }}


Настроим Spring Security.


Соберем все проделанное выше вместе и настроим Spring Security.

WebSecurityConfig
@Configuration@EnableWebSecuritypublic static class WebSecurityConfig extends WebSecurityConfigurerAdapter {    private final ExampleOAuth2UserService userService;    private final TokenAuthenticationFilter tokenAuthenticationFilter;    private final AuthenticationFailureHandler authenticationFailureHandler;    private final AuthenticationSuccessHandler authenticationSuccessHandler;    private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository;    @Autowired    public WebSecurityConfig(            ExampleOAuth2UserService userService,            TokenAuthenticationFilter tokenAuthenticationFilter,            AuthenticationFailureHandler authenticationFailureHandler,            AuthenticationSuccessHandler authenticationSuccessHandler,            HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) {        this.userService = userService;        this.tokenAuthenticationFilter = tokenAuthenticationFilter;        this.authenticationFailureHandler = authenticationFailureHandler;        this.authenticationSuccessHandler = authenticationSuccessHandler;        this.authorizationRequestRepository = authorizationRequestRepository;    }    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .cors().and()                .csrf().disable()                .formLogin().disable()                .httpBasic().disable()                .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))                .exceptionHandling(eh -> eh                        .authenticationEntryPoint(new RestAuthenticationEntryPoint())                )                .authorizeRequests(authorizeRequests -> authorizeRequests                        .antMatchers("/auth/**").permitAll()                        .anyRequest().authenticated()                )                .oauth2Login(oauth2Login -> oauth2Login                        .failureHandler(authenticationFailureHandler)                        .successHandler(authenticationSuccessHandler)                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService))                        .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(authorizationRequestRepository))                );        http.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);    }}


Создадим repositories endpoint.


То ради чего и нужна была аутентификация через OAuth2 и Bitbucket возможность использовать Bitbucket API для доступа к своим ресурсам. Используем Bitbucket repositories API для получения списка репозиториев текущего пользователя.

Repositories endpoint
@Path("/api")public class ApiEndpoint extends EndpointBase {    @Autowired    private BitbucketService bitbucketService;    @GET    @Path("/repositories")    @Produces(APPLICATION_JSON)    public List<Repository> getRepositories() {        return handle(bitbucketService::getRepositories);    }}public class BitbucketServiceImpl implements BitbucketService {    private static final String BASE_URL = "https://api.bitbucket.org";    private final Supplier<RestTemplate> restTemplate;    public BitbucketServiceImpl(Supplier<RestTemplate> restTemplate) {        this.restTemplate = restTemplate;    }    @Override    public List<Repository> getRepositories() {        UriComponentsBuilder uriBuilder = fromHttpUrl(format("%s/2.0/repositories", BASE_URL));        uriBuilder.queryParam("role", "member");        ResponseEntity<BitbucketRepositoriesResponse> response = restTemplate.get().exchange(                uriBuilder.toUriString(),                HttpMethod.GET,                new HttpEntity<>(new HttpHeadersBuilder()                        .acceptJson()                        .build()),                BitbucketRepositoriesResponse.class);        BitbucketRepositoriesResponse body = response.getBody();        return body == null ? emptyList() : extractRepositories(body);    }    private List<Repository> extractRepositories(BitbucketRepositoriesResponse response) {        return response.getValues() == null                ? emptyList()                : response.getValues().stream().map(BitbucketServiceImpl.this::convertRepository).collect(toList());    }    private Repository convertRepository(BitbucketRepository bbRepo) {        Repository repo = new Repository();        repo.setId(bbRepo.getUuid());        repo.setFullName(bbRepo.getFullName());        return repo;    }}


Тестирование


Для тестирования нам понадобится небольшой HTTP-сервер, на который будет отправлен токен доступа. Сначала попробуем вызвать repositories endpoint без токена доступа и убедимся, что в этом случае получим ошибку 401. Затем пройдем аутентификацию. Для этого запустим сервер и перейдем в браузере по адресу http://localhost:8080/auth/login. После того как мы введем логин/пароль, клиент получит токен и вызовет repositories endpoint еще раз. Затем будет запрошен новый токен и снова вызван repositories endpoint с новым токеном.

OAuth2JwtExampleClient
public class OAuth2JwtExampleClient {    /**     * Start client, then navigate to http://localhost:8080/auth/login.     */    public static void main(String[] args) throws Exception {        AuthCallbackHandler authEndpoint = new AuthCallbackHandler(8081);        authEndpoint.start(SOCKET_READ_TIMEOUT, true);        HttpResponse response = getRepositories(null);        assert (response.getStatusLine().getStatusCode() == SC_UNAUTHORIZED);        Tokens tokens = authEndpoint.getTokens();        System.out.println("Received tokens: " + tokens);        response = getRepositories(tokens.getAccessToken());        assert (response.getStatusLine().getStatusCode() == SC_OK);        System.out.println("Repositories: " + IOUtils.toString(response.getEntity().getContent(), UTF_8));        // emulate token usage - wait for some time until iat and exp attributes get updated        // otherwise we will receive the same token        Thread.sleep(5000);        tokens = refreshToken(tokens.getRefreshToken());        System.out.println("Refreshed tokens: " + tokens);        // use refreshed token        response = getRepositories(tokens.getAccessToken());        assert (response.getStatusLine().getStatusCode() == SC_OK);    }    private static Tokens refreshToken(String refreshToken) throws IOException {        BasicClientCookie cookie = new BasicClientCookie(REFRESH_TOKEN, refreshToken);        cookie.setPath("/");        cookie.setDomain("localhost");        BasicCookieStore cookieStore = new BasicCookieStore();        cookieStore.addCookie(cookie);        HttpPost request = new HttpPost("http://localhost:8080/auth/token");        request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType());        HttpClient httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build();        HttpResponse execute = httpClient.execute(request);        Gson gson = new Gson();        Type type = new TypeToken<Map<String, String>>() {        }.getType();        Map<String, String> response = gson.fromJson(IOUtils.toString(execute.getEntity().getContent(), UTF_8), type);        Cookie refreshTokenCookie = cookieStore.getCookies().stream()                .filter(c -> REFRESH_TOKEN.equals(c.getName()))                .findAny()                .orElseThrow(() -> new IOException("Refresh token cookie not found."));        return Tokens.of(response.get("token"), refreshTokenCookie.getValue());    }    private static HttpResponse getRepositories(String accessToken) throws IOException {        HttpClient httpClient = HttpClientBuilder.create().build();        HttpGet request = new HttpGet("http://localhost:8080/api/repositories");        request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType());        if (accessToken != null) {            request.setHeader(AUTHORIZATION, "Bearer " + accessToken);        }        return httpClient.execute(request);    }}


Консольный вывод клиента.
Received tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDMxLCJleHAiOjE2MDU0NjY2MzF9.UuRYMdIxzc8ZFEI2z8fAgLz-LG_gDxaim25pMh9jNrDFK6YkEaDqDO8Huoav5JUB0bJyf1lTB0nNPaLLpOj4hw, refreshToken=BBF6dboG8tB4XozHqmZE5anXMHeNUncTVD8CLv2hkaU2KsfyqitlJpgkV4HrQqPk)Repositories: [{"id":"{c7bb4165-92f1-4621-9039-bb1b6a74488e}","fullName":"test-namespace/test-repository1"},{"id":"{aa149604-c136-41e1-b7bd-3088fb73f1b2}","fullName":"test-namespace/test-repository2"}]Refreshed tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDM2LCJleHAiOjE2MDU0NjY2MzZ9.oR2A_9k4fB7qpzxvV5QKY1eU_8aZMYEom-ngc4Kuc5omeGPWyclfqmiyQTpJW_cHOcXbY9S065AE_GKXFMbh_Q, refreshToken=mdc5sgmtiwLD1uryubd2WZNjNzSmc5UGo6JyyzsiYsBgOpeaY3yw3T3l8IKauKYQ)

Исходный код


Полный исходный код рассмотренного приложения находится на Github.

Ссылки



P.S.
Созданный нами REST-сервис работает по протоколу HTTP, чтобы не усложнять пример. Но поскольку токены у нас никак не шифруются, рекомендуется перейти на защищенный канал (HTTPS).
Подробнее..

Демистификация JWT

09.12.2020 18:14:11 | Автор: admin

Вокруг JWT сложилась много распространенных заблуждений. Одно из них, например, что JWT зашифровано (на самом деле только подписано и закодировано base64). На практике, я часто встречаюсь с довольно странными решениям, когда в JWT хранится идентификатор сессии (на самом деле если вы работаете с сессией то JWT вам не нужно). Или же в JWT хранится идентификатор пользователя, профайл которого запрашивается при каждом запросе из базы данных (на самом деле JWT имеет смысл, если Вы хотите уменьшить количество запросов в базу данных). Или, что еще более странно сами JWT хранятся в базе данных.


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


Желание написать об этом у меня возникло уже давно. И после просмотра очередного проекта с немного странным использованием JWT я все же решился.


Начнем с самой главной демистификации. JWT может выглядеть вот так:


eyJhbGciOiJSUzI1NiJ9.eyJpcCI6IjE3Mi4yMS4wLjUiLCJqdGkiOiIwNzlkZDMwMGFiODRlM2MzNGJjNWVkMTlkMjg1ZmRmZWEzNWJjYzExMmYxNDJiNmQ5M2Y3YmIxZWFmZTY4MmY1IiwiZXhwIjoxNjA3NTE0NjgxLCJjb3VudCI6MiwidHRsIjoxMH0.gH7dPMvf2TQaZ5uKVcm7DF4glIQNP01Dys7ADgsd6xcxOjpZ7yGhrgd3rMTHKbFyTOf9_EB5NEtNrtgaIsWTtCd3yWq21JhzbmoVXldJKDxjF841Qm4T6JfSth4vvDF5Ex56p7jgL3rkqk6WQCFigwwO2EJfc2ITWh3zO5CG05LWlCEOIJvJErZMwjt9EhmmGlj9B6hSsEGucCm6EDHVlof6DHsvbN2LM3Z9CyiCLNkGNViqr-jkDKbn8UwIuapJOrAT_dumeCWD1RYDL-WNHObaD3owX4iqwHss2yOFrUfdEynahX3jgzHrC36XSRZeEqmRnHZliczz99KeiuHfc56EF11AoxH-3ytOB1sMivj9LID-JV3ihaUj-cDwbPqiaFv0sL-pFVZ9d9KVUBRrkkrwTLVErFVx9UH9mHmIRiO3wdcimBrKpkMIZDTcU9ukAyaYbBlqYVEoTIGpom29u17-b05wY3y12lCA2n4ZqOceYiw3kyd46IYTGeiNmouG5Rb5ld1HJzyqsNDQJhwdibCImdCGhRuKQCa6aANIqFXM-XSvABpzhr1UmxDijzs30ei3AD8tAzkYe2cVhv3AyG63AcFybjFOU8cvchxZ97jCV32jYy6PFphajjHkq1JuZYjEY6kj7L-tBAFUUtjNiy_e0QSSu5ykJaimBsNzYFQ

Если его декодировать base64 миф о "секретности" сразу же разрушается:


{"alg":"RS256"}{"ip":"172.21.0.5","jti":"079dd300ab84e3c34bc5ed19d285fdfea35bcc112f142b6d93f7bb1eafe682f5","exp":1607514681,"count":2,"ttl":10} O2Mrn%!OPzN{hk11l\9Mkd    Z&WJP%^D8*X|!C&D0Di?Aknue7bB 6AV*9)S.jNv    `EcG9*6kQDv_xzEdgbs<wP("?K ?WxiHp<>,/EU]T-Q+\}Pfbu7ZTJ jhon-v 6j9:!z#fEewQ*44    bl"&t!F    *s>]+U&8z-@Fap2p\S}0hy*b1H/AU3bA$)   j)

Первая часть в фигурных скобках называется JOSE Header и описана https://tools.ietf.org/html/rfc7515. Может содержать поля, из которых наиболее важное alg. Если задать {"alg":"none"} токен считается валидным без подписи. И таким образом к Вашему API может получить доступ любой с токеном, сформированным вручную без подписи. В настоящее время большинство библиотек отвергают такие токены по умолчанию, но все же проверьте свои API на всякий случай.


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


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


Таким образом JWT это просто текст JSON, имеющий криптографическую подпись.


"Открыв" этот факт, можно поставить вопрос, в каких случаях рационально или не рационально использовать JWT. Можно предположить два основных кейса (если кто-то найдет еще готов добавить в текст).


  1. Микросервисы. Данные (любые не обязательно авторизация, а например "корзина" с товарами и зафиксированными ценами товаров) формируются и подписываются на одном микросервисе, а используются на другом микросервисе, который проверяет подпись токена публичным ключом.


  2. Авторизация. Этот кейс может быть полезен и для монолита, если нужно сократить количество запросов в базу данных. При реализации "традиционной" сессии каждый запрос API генерирует дополнительный запрос к базе данных. С JWT все, что берется в базе данных помещается в JWT и подписывается.



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


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


Следующий вопрос. Если время жизни JWT небольшое, то как обновлять JWT после окончания срока действия (это актуально для авторизационных токенов)? Для этого предлагается использовать второй, "условно-постоянный" токен с более продолжительным сроком действия. По истечении срока действия авторизационного токена его обновляют с использованием "условно-постоянного" токена.


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


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


apapacy@gmail.com
9 декабря 2020 года

Подробнее..
Категории: Api , Authentication , Json , Jwt , Authorization , Passport.js

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru