Дорогой друг, представь что ты архитектор (а также бэк и немного фронт-разработчик в одном лице) и от тебя требуют создать полноценную систему связанных сервисов. Сегодня мы этим и займёмся начиная с создания проекта и заканчивая рекомендациями запуска нашей системы на прод-стенде.
Точнее, мы создадим и увяжем в единую структуру
-
Сервис авторизации (AuthService) который будет по протоколу oauth 2.0 выдавать доступы к нашим приложениям.
-
Сервис конфигурации (SpringCloudConfig) который будет раздавать настройки всем нашим приложениям которым требуются настройки. Также он даст нам возможность обновлять настройки приложения без необходимости перезапускать само приложение и/или данный сервер конфигурации.
-
Гейтвей (SpringCloudGateway) который отвечает за пробрасывание запросов из условно не безопасной зоны в условно безопасную зону где расположены все остальные сервисы.
-
Сервис мониторинга (SpringBootAdminServer) предоставляющий простую систему мониторинга множества работающих сервисов.
-
Сервис-приложение 1 (WebApplication) представляющий собой обычный веб-сервис с доступом к БД и ручной авторизаций через сервер-авторизации.
-
Сервис-приложение 2 (WebSecurityApplication) представляющий собой обычный веб-сервис с авторизацией частично использующей механизмы spring security
-
Сервис-приложение 3 (WebGenerateApplication) не требующий авторизации, но зато демонстрирующий использование популярного при разработке принципа «api first» и автогенерации кода.
Ссылка на гитхаб: https://github.com/upswet/SimpleSpringExample
Рассматриваемые технологии и принципы
-
Spring: spring web, spring data, spring security (частично), spring actuator, spring cloud config, spring cloud gateway, spring boot admin
-
протокол авторизации oauth 2 (частично)
-
api-first (с автогенерацией кода через org.openapi.generator)
-
gradle
Можно рассматривать данную статью как заготовку для создания полноценной сервис-ориентированной или микросервисной инфраструктуре.
За пределами данного описания остались такие потенциально полезные вещи, как
-
Асинхронная передача сообщений через брокер сообщений, например Apache Kafka
-
Реестр сервисов, например spring cloud eureka. Впрочем, рискну утверждать что он вам не нужен если вы используете доккер-контейнеры и хоть какую-то систему оркестрации, хотя бы даже docker compose), а лучше что типа kubernetes
-
Более полное использование spring security (однако я настаиваю, что описанная ниже система авторизации вполне надёжна для продакшена. Тогда как если очень плотно сеть на какую-то определённую версию спринг-секьюрити, то может быть очень сложно слезть с неё если это понадобится). Впрочем, это дискуссионный вопрос.
Отказ от ответственности
Используйте на свой риск и страх и всё такое.
Статья имеет много заимствований. Постараюсь везде оставлять ссылки на исходные ресурсы для самостоятельного вдумчивого изучения читателем.
Создадим многомодульный gradle-проект
В целом теория создания градле-преоктов прекрасно описана в https://www.book2s.com/tutorials/gradle-dependency-management.html
Однако, помолясь Колмогорову и Глушко, начнём потихоньку
Создадим родительский каталог для нашего мультимодального проекта SimpleSpring
Добавим в каталог файлы
settings.gradle — в нём будем прописывать ссылки на наши отдельные проекты в рамках нашего «большого» проекта
Для начала добавим туда только одну строчку — название «большого» проекта
rootProject.name = ‘SpringSimple’
Также добавим файл build.gradle — в нём прописываются зависимости проекта и инструкции для сборки
содержимое файла build.gradle
/*Укажем какие плагины мы хотим использовать (они будут загружены, но не активны)*/ plugins { id 'java' id 'org.springframework.boot' version '3.3.1' id 'io.spring.dependency-management' version '1.1.5' id 'org.openapi.generator' version '7.10.0' } /*Указываем конфигурацию разрешающую модифицировать код во время сборки Если коротко, то это необходимо для использования объявленной ниже библиотеки lombok*/ configurations { compileOnly { extendsFrom annotationProcessor } } /*Указываем репозитории, то есть откуда будут скачены необходимые библиотеки. Здесь указан общедоступный репозиторий.*/ repositories { mavenCentral() } /*В этой секции мы настраиваем подпроекты. То есть всё, что здесь указано будет применено к каждому подпроекту вместе с тем что указано в ЕГО личном файле build.gradle.*/ subprojects { /*Применим три из четырёх скаченых выше плагинов. Почему не все четыре сразу? Потому, что четвёртый плагин нужен не в каждом подпроекте. Там где он будет нужен, мы применим его отдельно.*/ apply plugin: 'java' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' /*Для каждого подпроекта добавим ещё один репозиторий (кроме общедоступного объявленного выше). На этот раз это будет "файловый репозиторий", то есть все jar-файлы положенные в папку libs для каждого подпроекта Зачем это нужно? На случай если мы захотим загрузить какую-конкретную библиотеку не скачивая её из центрального репозитория*/ repositories { mavenCentral() flatDir { dirs 'libs' } } /*Объявим зависимости, то есть нужные нам библиотеки. Напомнию что они будут доступны для каждого подпроекта так как уазаны в данной секции subprojects*/ dependencies { //позволяет нам использовать разные полезные аннотации, например PostConstructor implementation 'javax.annotation:javax.annotation-api:1.3.2' //Требуется для работы с jwt-токенами. implementation 'com.auth0:java-jwt:3.18.2' //Требуется для испольования spring actuator подробнее можно посмотреть https://www.concretepage.com/spring-boot/spring-boot-actuator-endpoints, https://www.book2s.com/tutorials/spring-boot-actuator.html /*Если коротко, то spring actuator добавляет к приложению настраиваемые служебные апи вызывая которые можно получать служебную инфомрацию о приложении*/ implementation 'org.springframework.boot:spring-boot-starter-actuator' //spring admin client //Клиент к сервису спринг-бут-админ аккамулирующую информацию получаемую им через spring actuator implementation 'de.codecentric:spring-boot-admin-starter-client:3.1.5' //swagger //Очень полезный инструмент для тестирования веб-сервисов который мы опробуем ниже implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' //подробнее можно посмотреть https://www.baeldung.com/spring-rest-openapi-documentation //spring cloud config //Необходимо для получения конфигов с конфиг-сервиса. То есть клиент к конфиг-сервису implementation 'org.springframework.cloud:spring-cloud-starter-config' implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' //lombok /*Крайне полезная библиотека сильно сокрашающая написание кода путём автогенерации недостающего кода по аннотациям. Именно поэтому ей требутся разрешение на модификацию нашего кода перед его компиляцией*/ compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' } //Установим (и затем испльзуем для скачивания нужной библиотеки) переменную с версией спринговой библиотеки ext { set('springCloudVersion', "2023.0.1") } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } }
В принципе этого уже достаточно для начала разработки.
Откроем наш проект в среде разработки IntelliJ IDEA (далее просто идея) и она, поняв что мы создаём градле-проект, сама скачает градле-врапер и все необходимые библиотеки.
Чтобы сделать тоже самое (установить градле и с его помощью скачать зависимости проекта, а также собрать его при необходимости) можно воспользоваться мануалом.
Опять же, более подробно см вот эту главу мануала
Что делать если мы хотим чтобы одни из модулей нашего проекта сам был бы многомодульным проектом. Или что делать если мы хотим просто объединить несколько наших модулей в группу?
Сервис конфигурации (SpringCloudConfig)
Начнём с сервиса конфигурации который будет выдавать конфиги всем остальным сервисам которые его об этом попросят.
Подробнее можно посмотреть здесь:
https://sysout.ru/spring-cloud-configuration-server/
https://www.czetsuyatech.com/2019/10/spring-config-using-git.html
https://docs.spring.io/spring-cloud-config/docs/current/reference/html/
https://www.baeldung.com/spring-cloud-config-without-git
Попросив удачи у Брудно примемся за дело
Создание проекта
Создадим каталог проекта SpringCloudConfig внутри SpringSimple согласно указанной ниже структуре
SpringSimple/
│
├── build.gradle
├── settings.gradle
│
├── SpringCloudConfig/
│ ├── build.gradle
│ ├── git-local-repo/
│ │ ├── * (куски конфигов)
│ └── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── ru/
│ │ │ │ ├── configs/
│ │ │ │ │ ├── SpringCloudConfig.java
│ │ ├── resources/
│ │ │ ├──application.properties
│ └── test/
где git-local-repo — локальный гит-репозиторий (или просто каталог) в котором будут лежать куски конфигов для раздачи их приложениям.
Также добавим наш новый проект в файл SpringSimple\settings.gradle как include ‘SpringCloudConfig’
build.gradle
Здесь описываются зависимости уникальные для данного проекта (помним что к ним добавятся все зависимости и настройки из файла родительского проекта (SimpleSpring\build.gradle))
//настройи для сборки jar-файла проекта group = 'ru.configs' version = '1.0-SNAPSHOT' dependencies { //добавим ровно одну уникальную зависимости "конфиг-сервер" implementation 'org.springframework.cloud:spring-cloud-config-server' }
SpringCloudConfig.java
package ru.configs; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.config.server.EnableConfigServer; @SpringBootApplication /*Вот эта аннотация (EnableConfigServer) объявляет что данное приложение является конфиг-сервер*/ @EnableConfigServer public class SpringCloudConfig { public static void main(String[] args) { SpringApplication.run(SpringCloudConfig.class, args); } }
содержимое файла application.properties указано ниже
Разработка для проекта конфиг-сервера не требуется, спринг уже сделал всё за нас. Требуется только настроить приложение
Настройка приложения
application.properties
#имя приложения spring.application.name=SpringCloudConfig # Порт веб-сервера server.port = 8888 # урл спринг-бут-админ сервера куда данное приложение будет отчитываться о своём состоянии spring.boot.admin.client.url=http://localhost:8099 # Расположение Git-репозитория #После запуска Config Server можно получить доступ к конфигурациям по адресу http://localhost:8888/{applicationName}/{profile} #http://localhost:8888/application/default здесь application — имя файла (без расширения), default — имя профиля (по умолчанию default). #remoute # spring.cloud.config.server.git.uri=https://github.com/myluckagain/config.git # Проверить, что при запуске сервиса не будет проблем с репозиторием #spring.cloud.config.server.git.cloneOnStart=true #local #spring.cloud.config.server.git.uri=d:\\Work\\Project\\SpringSimple\\SpringSimple\\SpringCloudConfig\\git-local-repo #чтобы хранить у себя в ресурсах (resources/config/*) #spring.cloud.config.server.native.search-locations=classpath:/config #spring.cloud.config.server.git.search-paths=config-server/config #Чтобы хранить локально без гит-а spring.profiles.active=native spring.cloud.config.server.native.search-locations=file:///d:\\Work\\Project\\SpringSimple\\SpringSimple\\SpringCloudConfig\\git-local-repo
Если вы установили у себя гит-локально, то раскоментируйте строчку 18, а строки 25 и 26 закоментируйте. В строке 18 пропишите актуальный путь.
Если вы хотите использовать для хранения кусков настроек которые будете раздавать файловую систему то оставьте как есть просто прописав в строке 25 актуальный путь до директории
Настроим конфиги для раздачи другим приложениям
Куски конфигов это файлы содержащие настройки для спринг-приложений в форматах .properties или .yml.
Приложение подключенное к конфиг-серверу сможет запросить те конфиги которые ему нужны по их имени.
Имя конфига складывается из application-ИмяКонфига.properties.
В нашем случае это будут
application-actuator.properties
#настройки актуатора. Добавляет к приложению эндпойны по которым можно запросить доп инф о приложении #В нашем случае разрешаем все эндпойнты (.exposure.include=*) так как большая часть из них по умолч закрыта #https://www.baeldung.com/spring-boot-actuators management.endpoint.shutdown.enabled=true management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always management.endpoint.health.group.custom.include=* management.endpoint.health.group.custom.show-components=always management.endpoint.health.group.custom.show-details=always
application-adminClient.properties
#настройки доступа к спринг-бут-админ серверу. #здесь имеет смысл настроить безопасный доступ к служебным эндпойнтам, #но нам это не требуется так как все наши приложения работают в безопасной зоне spring.boot.admin.client.url=http://localhost:8099
application-authSecret.properties
#секретные пароли для шифрования/расшифровки токенов доступа #по хорошему должны либо храниться в vault-хранилище, либо задваться при запуске приложения #хранить их в открытом виде - плохая идея auth.jwt.secret.access=super-secret-key- auth.jwt.secret.refresh=super-secret-key-
application-AuthService.properties
#локальные настрйоки для сервера авторизации #в частности бд где мы будем хранить пользователей и хэши их паролей #h2 db spring.datasource.url: jdbc:h2:~/MYDB1;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;AUTO_SERVER=TRUE #spring.datasource.url: jdbc:h2:mem:MYDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE spring.datasource.driverClassName: org.h2.Driver spring.datasource.username: sa spring.datasource.password: spring.jpa.database-platform: org.hibernate.dialect.H2Dialect #http://localhost:8080/h2-console spring.h2.console.enabled=true
Обратите внимание, что вы разрешили доступ к веб-консоли h2-бд для удобства разработки.
application-ext.properties
#просто некоторая настройка на которой ниже покажем возможность обновления настроек без перезагрузки приложения myValue=ttttt2
application-hibernate.properties
#Настройки hibernate # Выключаем предварительную инициализацию бд. О spring.datasource.initialization-mode=never spring.jpa.defer-datasource-initialization = false spring.sql.init.mode = never #устанавливаем кодировку по умочанию spring.datasource.sql-script-encoding= UTF-8 spring.sql.init.encoding = UTF-8 # Если потребуется отладка сформирвоанных hiberate sql-запросов то расскоментарите строчки ниже #spring.jpa.show-sql=true #spring.jpa.properties.hibernate.format_sql=true #logging.level.org.hibernate.SQL=DEBUG #logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE #включим ленивую загрузку сущностей. То есть хибернейт не будет загружать связанные сущности пока они не потребуются spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true #дадим хибернейту возможность самому создавать (или дополнять) структуру бд при старте согласно описанным сущностям spring.jpa.hibernate.ddl-auto=update
application-swagger.properties
#swagger #http://localhost:8080/api-docs #http://localhost:8080/api-docs.yaml #http://localhost:8080/swagger-ui/index.html springdoc.api-docs.enabled=true springdoc.swagger-ui.enabled=true springdoc.swagger-ui.path=swagger-ui.html springdoc.api-docs.path=/api-docs
application-WebApplication1.properties
#локальные настройки для приложения WebApplication #если истина, то авторизация включена, иначе - выключена auth.enabled=true # inout answer on ResponseStatusException server.error.include-message=always #h2 db #spring.datasource.url: jdbc:h2:~/MYDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;AUTO_SERVER=TRUE spring.datasource.url: jdbc:h2:mem:MYDB;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE spring.datasource.driverClassName: org.h2.Driver spring.datasource.username: sa spring.datasource.password: spring.jpa.database-platform: org.hibernate.dialect.H2Dialect #http://localhost:8080/h2-console spring.h2.console.enabled=true ## MULTIPART (MultipartProperties) # Enable multipart uploads spring.servlet.multipart.enabled=true # Threshold after which files are written to disk. spring.servlet.multipart.file-size-threshold=2KB # Max file size. spring.servlet.multipart.max-file-size=200MB # Max Request Size spring.servlet.multipart.max-request-size=215MB
application-WebSecurityApplication.properties
#локальные настройи для приложения WebSecurityApplication #создадим для спринг-серкьютрити пользователя и пароль по умолчанию. Нигде не будем использовать их spring.security.user.name=u2 spring.security.user.password=p2
Запуск проекта
Как и любой градле проект на спринг-буте, он запускается через таску boot-Run, а именно
При запуске вы можете увидеть в логах ошибку вида «Failed to register application as Application» означающую что конфиг-сервер не смог отправить свои метрики спринг-бут-админ-серверу (как мы это настроили в файле application.properties).
Тестирование работоспособности конфиг-сервера
Запросим конфиг по умолчанию через http://localhost:8888/application/default и ничего не получим так как у нас нет конфига по умолчанию (то есть конфига git-local-repo\application.properties)
Запросим конфиг для профиля ext http://localhost:8888/application/ext и получим его
Наконец запросим конфиги сразу для двух профилей ext и actuator как http://localhost:8888/application/ext, actuator и получим их
Важный момент: конфиг-сервер сам будет следить за актуальностью конфигов и вам нужно будет просто положить новый конфиг в соотв каталог на диске (если конфиг-сервер настроен смотреть на папку) или закоммитить изменения в гит (если настроен смотреть на гит) чтобы изменения вступили в силу. Перезапускать приложение конфиг-сервера не требуется.
Сервис авторизации (AuthService)
Попросив удачи у Лебедева, начнём потихоньку.
Создание проекта
Создадим каталог проекта AuthService внутри SpringSimple согласно указанной ниже структуре
SpringSimple/
│
├── build.gradle
├── settings.gradle
│
├── AuthService/
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── ru/
│ │ │ │ ├── auth/
│ │ │ │ │ ├── AuthService.java
│ │ ├── resources/
│ │ │ ├──application.properties
│ └── test/
Содержимое файла AuthService.java
package ru.auth; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class AuthService { public static void main(String[] args) { SpringApplication.run(AuthService.class, args); } }
build.gradle
Добавим сюда локальные зависимости и инструкции сборки для данного проекта. Напомню что они объединяться с зависимостями и инструкциями из файла родительского проекта SpringSimple\build.gradle из секции subproject
group = 'ru.auth' version = '1.0-SNAPSHOT' dependencies { //spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' //db runtimeOnly 'com.h2database:h2' testImplementation 'com.h2database:h2:2.2.224' //Bcrypt. Для шифрования пароля и проверки по хэшу implementation group: 'org.mindrot', name: 'jbcrypt', version: '0.4' }
application.properties
spring.application.name=AuthService server.port=8079 #config spring.profiles.active=authSecret, swagger, AuthService, hibernate, adminClient, actuator spring.cloud.config.fail-fast=true spring.cloud.config.uri=http://localhost:8888 spring.config.import=optional:configserver:http://localhost:8888 spring.cloud.config.import-check.enabled=true
в строке 5 перечислим профили которые мы хотим получить от конфиг-сервера.
Ниже идут настройки подключения к конфиг-серверу
Добавим в SpringSimple\settings.gradle ссылку на наш новый проект как
include ‘AuthService’
Совсем немного теории о протоколе авторизации OAuth 2.0
OAuth 2.0 — протокол авторизации, позволяющий выдать одному сервису (приложению) права на доступ к ресурсам пользователя на другом сервисе. Протокол избавляет от необходимости доверять приложению логин и пароль, а также позволяет выдавать ограниченный набор прав, а не все сразу.
Одним из преимуществ аутентификации с использованием JWT является возможность выделить процессы генерации токенов и обработки данных пользователей в отдельное приложение, переложив проверку валидности токенов на клиентские приложения. Такой подход идеально подходит для микросервисной архитектуры.
Сервис аутиентификации будет отвечать за генерацию токенов, а сервисы ресурсов — за бизнес-логику. Приложение сервиса-ресурсов не сможет выдавать токены, но будет иметь возможность их валидировать.
Рассмотрим процесс аутентификации пошагово.
-
Запрос с логином и паролем. Клиент (чаще всего это фронтенд) отправляет запрос с объектом, содержащим логин и пароль на сервер аутентификации.
В ответ он получает пару токенов: access и refresh -
Использование access-токена. Клиент использует access-токен для взаимодействия с API соответствующего сервера ресурсов.
-
Обновление access-токена. Важный момент: время жизни access-токена очень мало. Обычно это порядке пяти минут, когда срок действия access-токена истекает, клиент отправляет refresh-токен серверу авторизации и получает новый access-токен. Этот процесс повторяется до тех пор, пока не истечёт срок действия refresh-токена который обычно достаточно велик и может достигать 30 дней.
Для обновления самого refresh-токена нужно отправить на сервер-авторизации запрос с действующим (ещё не истёкшим) access-токеном и обновляемым refresh-токеном. В ответ придёт новый refresh-токен. Или же можно просто заново выполнить запрос с логином и паролем к серверу авторизации для получения новой пары токенов
Дополнительно см
https://struchkov.dev/blog/ru/jwt-implementation-in-spring/
https://habr.com/ru/companies/vk/articles/115163/
Разработка
Добавим сервисы работы с токеном
Интерфейс TokenService.java
package ru.auth.service; import com.auth0.jwt.interfaces.DecodedJWT; import java.util.List; /**Генерация jwt-токена для клиента*/ public interface TokenService { String generateAccessToken(String clientId, String audience, String roles); String generateRefreshToken(String clientId, String audience); Boolean checkAccessToken(String accessToken); Boolean checkRefreshToken(String refreshToken); DecodedJWT decodeRefreshToken(String refreshToken); }
DefaultTokenService.java — реализация интерфейса
package ru.auth.service; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; @Service @Slf4j public class DefaultTokenService implements TokenService { @Value("${auth.jwt.secret.access}") private String secretAccessKey; @Value("${auth.jwt.secret.refresh}") private String secretRefreshKey; /**Сгенерировать токен для клиента * @param clientId - клиент для которого генерируем токен * @param secretKey - секретный ключ на основе которого будем его генерировать * @param minuts - через сколько минут истечёт срок действия токена * @param audience - для какой системы генерируем токен * @param roles - список ролей доступных текущему пользователю для данной системы чере запятую * @return - сгенерированный токен * */ private String generateToken(String clientId, String secretKey, Long minuts, String audience, String roles) { Algorithm algorithm = Algorithm.HMAC256(secretKey); Instant now = Instant.now(); Instant exp = now.plus(minuts, ChronoUnit.MINUTES); return JWT.create() .withIssuer("auth-service") //кто создал токен .withClaim("roles",roles) //с какими ролями .withAudience(audience) //для какого приложения (сервиса-ресурсов) .withSubject(clientId) //идентификатор клиента. Обычно это его почта или номер телефона .withIssuedAt(Date.from(now))//когда был создан токен .withExpiresAt(Date.from(exp)) //когда он истечёт .sign(algorithm); } @Override public String generateAccessToken(String clientId, String audience, String roles) { return generateToken(clientId, secretAccessKey, 5L, audience, roles); } @Override public String generateRefreshToken(String clientId, String audience) { return generateToken(clientId, secretRefreshKey, 666L, audience, null); } /**Сгенерировать токен для клиента * @param token - проверяемый токен * @param secretKey - секретный ключ которым он был закодирован * @return - истина, если токен валиден и ложь если не валиден*/ private boolean checkToken(String token, String secretKey) { Algorithm algorithm = Algorithm.HMAC256(secretKey); JWTVerifier verifier = JWT.require(algorithm).build(); try { DecodedJWT decodedJWT = verifier.verify(token); return true; } catch (JWTVerificationException e) { log.error("Token is invalid: " + e.getMessage()); return false; } } /**Декодировать токен * @param refreshToken - рефреш-токен * @return - декодированный токен или исключение*/ @Override public DecodedJWT decodeRefreshToken(String refreshToken) { Algorithm algorithm = Algorithm.HMAC256(secretAccessKey); JWTVerifier verifier = JWT.require(algorithm).build(); return verifier.verify(refreshToken); } @Override public Boolean checkAccessToken(String accessToken) { return checkToken(accessToken, secretAccessKey); } @Override public Boolean checkRefreshToken(String refreshToken) { return checkToken(refreshToken, secretRefreshKey); } }
сгенерированный токен можно распарсить и посмотреть на сайте https://jwt.io/
Добавим сущности для хранения клиента в бд
Будем использовать hibernate.
Помним, что настройка spring.jpa.hibernate.ddl-auto=update разрешает хибернейту самому создавать таблицы согласно описанным сущностям (очень удобно для разработки, но нежелательно использовать в продакшене)
spring.datasource.url: jdbc:h2:~/MYDB1;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;AUTO_SERVER=TRUE
Данная настройка говорит что база h2 будет храниться на диске (то есть будет доступна после перезапуска приложения) в файле MYDB1.
ClientEntity.java — описание сущности для хранения клиента
package ru.auth.db.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "clients", indexes = { @Index(name = "clientId_audience", columnList = "clientId, audience", unique = true) //добавим индекс в таблицу для быстрого поиска }) /**Сущность для хранения польователей*/ public class ClientEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /**УИД-клиента или его псевдоним*/ private String clientId; /**Его роли в рамках данной audience через запятую*/ private String roles; /**Система для которой клиенту предоставлен доступ под нужной ролью*/ private String audience; /**Хэш его пароля*/ private String hash; }
ClientRepository.java — просто репозиторий для доступа к клиентам в бд
package ru.auth.db.repository; import org.springframework.data.repository.CrudRepository; import ru.auth.db.entity.ClientEntity; import java.util.Optional; public interface ClientRepository extends CrudRepository<ClientEntity, String> { Optional<ClientEntity> findByClientIdAndAudience(String clientId, String audience); //из интересного здесь разве только этот запрос к бд код которого сгененрирует хибернейт по имени метода }
Добавим конфиг WebConfig.java чтобы не ловить в браузере CORS-ошибку при использовании сваггера через гейтвей
package ru.auth.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; //https://www.baeldung.com/spring-cors /**Разрешим CORS-запросы на этот веб-сервер чтобы сваггер с шлюза мог успешно перенаправлять запросы сюда*/ @Configuration @EnableWebMvc public class WebConfig implements WebMvcConfigurer { @Override /**Разрешаем CORS для сваггера*/ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**"); } }
Этот конфиг нужен исключительно для того чтобы при доступа из сваггера шлюза (гейтвея) к сваггеру сервера авторизации не ловить ошибку «подмены» хоста и порта.
Если совсем кратко, то бразуеры по умолчанию запрещают CORS то есть возможность одному бэк-серверу пересылать (вместо того чтобы ответить самому) запрос клиента на другой бэк-сервера с другим портом или хостом который уже и отвит клиенту от своего лица.
CORS запрещён по умолчанию чтобы защитить от совсем глупых мошенников которые иначе могли бы сделать свой промежуточный сайт принимающий запросы от клиента, затем отправляющий их на настоящий сервер и транслирующий ответы обратно клиенту.
DTO — data transfer object. То есть объекты для передачи данных (в нашем случае между фронтом и бэком, то есть сервисом авторизации)
AuthRequestDto.java
package ru.auth.api.dto; import lombok.Value; /**Дто для запросоу аутентификации*/ @Value public class AuthRequestDto { String clientId; String audience; String clientSecret; }
AuthResponseDto.java
package ru.auth.api.dto; import lombok.*; /**Дто для ответа на запрос аутентифиакции*/ @Value public class AuthResponseDto { private final String type = "Bearer"; String accessToken; String refreshToken; }
ErrorResponseDto
package ru.auth.api.dto; import lombok.Value; /**Дто для ошибочного ответа*/ @Value public class ErrorResponseDto { String message; }
AuthController.java
Наш контроллер будет содержать метод /auth/reg для регистрации нового клиента. Это метод должны использовать администраторы и, естественно, на прод-стенде он должен лежать в отдельном веб-контроллере, быть отдельно защищённым, а может быть даже и отсутствовать вовсе. С тем же успехом администраторы могут работать напрямую с бд, только хэш паролей им придётся вычислять руками. Напомню что пароли не хранятся в открытом виде. Вместо этого в базе данных содержатся их хэши по которым можно только проверить валидность введённого пароля, но нельзя узнать сам пароль.
Дополнительно обратите внимание на аннотацию @ExceptionHandlerуказывающую что аннотированный ею метод будет вызываться в случае если любой другой метод контроллера упадёт с соответствующим исключением.
Ещё важный момент: по плану мы используем brear-авторизацию. То есть наш аccess-токен когда фронт отправит запрос в приложение сервиса-ресурсов должен содержаться в служебном заголовке http-запроса Authorization.
Это вполне безопасно согласно выбранной нами архитектуре когда в условиях небезопасного контура запрос идёт до гейтвея по https.
Если вы хотите ещё большей безопасности (хотя в нашем случае это явно избыточно) вы можете использовать для передачи токена cookie которые хороши тем, что браузер (то есть фронт-клиент) не сможет изменить их вручную. Но, в этом случае сервису-ресурсов потребуется также взаимодействовать с сервисом-авторизации напрямую для дополнительной проверки валидности ацесс-токена.
В нашем случае это явно избыточно.
package ru.auth.api; import com.auth0.jwt.interfaces.DecodedJWT; import lombok.RequiredArgsConstructor; import org.mindrot.jbcrypt.BCrypt; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import ru.auth.api.dto.AuthRequestDto; import ru.auth.api.dto.AuthResponseDto; import ru.auth.api.dto.ErrorResponseDto; import ru.auth.db.entity.ClientEntity; import ru.auth.db.repository.ClientRepository; import ru.auth.service.TokenService; import javax.security.auth.login.LoginException; import java.util.Optional; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/auth") @RequiredArgsConstructor public class AuthController { private final ClientRepository userRepository; private final TokenService tokenService; private final Map<String, String> refreshStorage = new HashMap<>(); //хранилище выданных рефреш-токенов. Для дополнительной проверки что мы действительно выдавали данный токен /**Сервис только для админов. Зарегистрировать нового клиента * @param clientId - клиент * @param clientSecret - пароль, * @param audience - система на которую даём права клиенту * @param roles - права которые даём*/ @PostMapping("/reg") public void register(String clientId, String clientSecret, String audience, String roles) throws LoginException { if(userRepository.findByClientIdAndAudience(clientId, audience).isPresent()) throw new LoginException("Client with id=" + clientId+" audience="+audience + " already registered"); String hash = BCrypt.hashpw(clientSecret, BCrypt.gensalt()); userRepository.save(new ClientEntity(null,clientId, roles, audience, hash)); } /**Логин * @param user - пользователь для которого хотим получить токены на доступ к заданной системе * @return - ацесс и рефреш токены*/ @PostMapping("/login") public AuthResponseDto login(@RequestBody AuthRequestDto user) throws LoginException{ checkCredentials(user.getClientId(), user.getClientSecret(), user.getAudience()); return generateNewAccessAndRefreshTokens(user.getClientId(), user.getAudience()); } /**Обновить асцесс токен по рефреш токену * @param refreshToken - рефреш токен * @return - новый ацесс токен*/ @PostMapping("/access") public AuthResponseDto getUpdateAccessToken(String refreshToken) throws LoginException{ if (tokenService.checkRefreshToken(refreshToken)){ DecodedJWT decodedJWT = tokenService.decodeRefreshToken(refreshToken); String clientId=decodedJWT.getSubject(); String audience=decodedJWT.getAudience().get(0); String savingRefreshToken = this.refreshStorage.get(clientId+"-"+audience); if (!refreshToken.equals(savingRefreshToken)) throw new LoginException("Refresh token not found"); ClientEntity user=userRepository.findByClientIdAndAudience(clientId, audience).get(); return new AuthResponseDto(tokenService.generateAccessToken(user.getClientId(), user.getAudience(), user.getRoles()), null); } return new AuthResponseDto(null, null); } /**Обновить ацесс и рефреш токен по рефреш токену * @param refreshToken - рефреш токен * @return - ацесс и рефреш токены*/ @PostMapping("/refresh") public AuthResponseDto getUpdateAccessAndRefreshToken(String refreshToken) throws LoginException{ if (tokenService.checkRefreshToken(refreshToken)){ DecodedJWT decodedJWT = tokenService.decodeRefreshToken(refreshToken); String clientId=decodedJWT.getSubject(); String audience=decodedJWT.getAudience().get(0); String savingRefreshToken = this.refreshStorage.get(clientId+"-"+audience); if (!refreshToken.equals(savingRefreshToken)) throw new LoginException("Refresh token not found"); return generateNewAccessAndRefreshTokens(clientId, audience); } return new AuthResponseDto(null, null); } @ExceptionHandler({LoginException.class}) public ResponseEntity<ErrorResponseDto> handleUserRegistrationException(Exception ex) { return ResponseEntity .badRequest() .body(new ErrorResponseDto(ex.getMessage())); } /**Проверить пароль для данного пользователя и данной системы * @param clientId - клиент * @param clientSecret - пароль * @param audience - система на которую даём права клиенту*/ private void checkCredentials(String clientId, String clientSecret, String audience) throws LoginException { Optional<ClientEntity> optionalUserEntity = userRepository.findByClientIdAndAudience(clientId, audience); if (optionalUserEntity.isEmpty()) throw new LoginException("Client with id=" + clientId+" and audience="+audience + " not found"); ClientEntity clientEntity = optionalUserEntity.get(); if (!BCrypt.checkpw(clientSecret, clientEntity.getHash())) throw new LoginException("Secret is incorrect"); } /**Сгенерировать новые ацесс и рефреш токены*/ private AuthResponseDto generateNewAccessAndRefreshTokens(String clientId, String audience) { String roles=userRepository.findByClientIdAndAudience(clientId, audience).get().getRoles(); String access=tokenService.generateAccessToken(clientId, audience, roles); String refresh=tokenService.generateRefreshToken(clientId, audience); this.refreshStorage.put(clientId+"-"+ audience, refresh); return new AuthResponseDto(access, refresh); } }
В конечном счёте наш сервер авторизации будет выглядеть как
Тестируем сервис авторизации
Для этого необходимо запустить как минимум сервис конфигурации так как иначе сервис авторизации не сможет получить свои конфиги
Помните мы подключали swagger? Давайте пользоваться.
Заходим на http://localhost:8079/swagger-ui/index.html
Видим все методы нашего веб—контроллера
Представим что мы админы и зарегистрируем нашего первого клиента
Помним что наши клиенты хранятся в бд h2 и мы разрешили в настройках доступ к ней через веб-консоль.
Заходим через http://localhost:8079/h2-console
И регистрируем других клиентов для обращения к приложениям сервисов-ресурсов которые мы напишем чуть позже
Можете ради интереса выполнить операцию логина получив токены и операцию рефреша обновляющую значение ацесс-токена.
Мы рассмотрим этот момент ниже, в проекте гейтвея
Дополнительно можно посмотреть следующие ресурсы
https://tproger.ru/articles/pishem-java-veb-prilozhenie-na-sovremennom-steke-s-nulja-do-mikroservisnoj-arhitektury-chast-2
https://struchkov.dev/blog/ru/jwt-implementation-in-spring/
https://habr.com/ru/articles/784508/
Первое приложение сервера-ресурсов с авторизацией без использования спринг-секьюрити (WebApplication)
Создание проекта
Внутри родительского каталоги SpringSimple создадим каталог Apps где будут лежать все наши приложения и внутри него создадим каталог нашего приложения WebApplication
В файл родительского проекта SpringSimple\settings.gradle добавим ссылку на наш новый проект: include ‘Apps:WebApplication’
build.gradle
К зависимостям импортируемым из родительского проекта (из секции subprojects файла SpringSimple\build.gradle) добавим зависимости для работы с бд (в том числе посредством hiberate) и для создания веб-контроллеров
group = 'ru.app' version = '1.0-SNAPSHOT' dependencies { //spring implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' //db runtimeOnly 'com.h2database:h2' //runtimeOnly 'org.postgresql:postgresql' testImplementation 'com.h2database:h2:2.2.224' }
application.properties
Файл настроек, кроме имени приложения и порта содержит только параметры подключения к конфиг-серверу и те конфиги которые мы хотим получить от него
spring.application.name=WebApplication1 server.port=8080 #config spring.profiles.active=authSecret, swagger, hibernate, WebApplication1, actuator, ext, adminClient spring.cloud.config.fail-fast=true spring.cloud.config.uri=http://localhost:8888 spring.config.import=optional:configserver:http://localhost:8888 spring.cloud.config.import-check.enabled=true
WebApplication.java
package ru.app; import org.springframework.boot.SpringApplication; import org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import ru.app.db.service.GenerateDataInDbService; import ru.app.utils.ApplicationContextProvider; @SpringBootApplication public class WebApplication { public static void main(String[] args) { //запуск спринга SpringApplication.run(WebApplication.class, args); //нагенерируем данные в бд та как она, согласно настройкам, хранится в памяти и, следоватлеьно, уничтожается после остановки приложения ApplicationContextProvider.getBean(GenerateDataInDbService.class).generate(); } }
Разработка
Полезные утилиты
ObjectMapperWrapper позволяет сериализовать объекты в json-строки и разворачивать их обратно.
Стоит обратить внимание на то, что в нашем случае маппер создаётся и настраивается ровно один раз при старте приложения. Также обратите внимание на настройки сериализации в мапере
package ru.app.utils; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import lombok.SneakyThrows; /**Враппер для мапера из джсона в объект и обратно*/ public class ObjectMapperWrapper { private static final ObjectMapper mapper; static{ mapper =new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); mapper.registerModule(new JavaTimeModule()); mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); } /**Преобразует json-строку в объект*/ @SneakyThrows public static <T> T readValue(String content, Class<T> valueType){ return mapper.readValue(content, valueType); } /**Преобразует объект в json-строку*/ @SneakyThrows public static String writeValueAsString(Object value) { return mapper.writeValueAsString(value); } }
Крайне полезная штука ApplicationContextProvider позволяет использовать спринговский контекст для получения бинов динамически, по их имени или классу и, главное, в статических методах
package ru.app.utils; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.stereotype.Component; /**Даёт возможность работать со спринговским контекстом в статических методах и/или методах класса не являющихся компонентами спринга*/ @Component public class ApplicationContextProvider implements ApplicationContextAware { private static ApplicationContext CONTEXT; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { CONTEXT = applicationContext; } public static <T> T getBean(Class<T> beanClass) { return CONTEXT.getBean(beanClass); } public static Object getBean(String beanName) { return CONTEXT.getBean(beanName); } }
public class Utils { /**Вернёт первый не нулевой объект из переданных*/ public static <T> T coalesce(T... items) { for (T i : items) if (i != null) return i; return null; } }
Работа с БД
Всегда создавайте общего наследника для ваших сущностей, это решает множество проблем.
В нашем случае, имея общего абстрактного предка AbstractEntity и сервис для работы с бд EntityService мы можем не использовать репозитории (или использовать их только для поиска) а также реализовать простое переключение между стартегиями мягкого (когда запись не удаляется из бд, только проставляется признак удаления) и жёсткого (когда она действительно удаляется) удаления.
package ru.app.db.entity; import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.experimental.FieldDefaults; import org.hibernate.annotations.*; import org.springframework.core.annotation.AnnotatedElementUtils; import ru.app.db.service.EntityService; import java.io.Serializable; import java.lang.reflect.Field; import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; /**Общий абстрактный предок для всех сущностей*/ @MappedSuperclass @Getter @Setter @NoArgsConstructor @FieldDefaults(level = AccessLevel.PUBLIC) @SQLRestriction("deleted = false")//аннотация говорит хиберйнейту игнорировать записи имеющие признак удаления (необходимо для реализации стратегии "мягкого" удаления) public abstract class AbstractEntity implements Cloneable, Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id; @Column(columnDefinition = "boolean DEFAULT false") @JsonIgnore Boolean deleted = false; @CreationTimestamp //https://www.baeldung.com/hibernate-creationtimestamp-updatetimestamp LocalDateTime createdDt; @UpdateTimestamp LocalDateTime modifiedDt; /**Вызывается перед сохранением. Здесь можно проверять сложные условия перед сохранением или как-то изменять поля*/ public void beforeSave(){} /**Вызывается перед удалением. Здесь можно проверять сложные условия перед удалением и не дать удалить*/ public void beforeDelete(){} /**Сохранить сущность * @param entity - сохраняемая сущности * @return - сама сущность*/ static public<A extends AbstractEntity> A save(A entity){ return EntityService.self.save(entity); } /**Получить сущность по её уид-у * @param id - уид сущности * @param entityClass - класс искомой сущности * @return - сама сущность или исключение если её не было найдено*/ static public<A extends AbstractEntity> A findById(Class<A> entityClass, Long id){ return EntityService.self.findById(entityClass, id); } /** Удалить сущность * @param entity - удаляемая сущность **/ static public <A extends AbstractEntity> void delete(A entity){ EntityService.self.delete(entity); } /**Получить набор сущностей по запросу * @param query- запрос * @param isNativeQuery - истина если нативный скл запрос и ложь если hql-запрос * @param entityClass - класс искомой сущности * @return - сама сущность или исключение если её не было найдено * * Примеры запросов * "select distinct e from WorkerEntity e where element(e.contacts).contact like '"+"%@%"+"'" * */ static public<A extends AbstractEntity> List<A> findForQuery(String query, Boolean isNativeQuery, Class<A> entityClass){ List<A> list = isNativeQuery ? EntityService.self.getEm().createNativeQuery(query, entityClass).getResultList() : EntityService.self.getEm().createQuery(query, entityClass).getResultList(); return list.stream().map(e -> { e = EntityService.lazyInitializer(e); return e; }).collect(Collectors.toList()); } @Override public boolean equals(Object obj) { if (this.getId()==null || ((AbstractEntity) obj).getId()==null) return false; if (!this.getClass().equals(obj.getClass())) return false; return ((AbstractEntity) obj).getId().equals(this.getId()); } }
EntityService
package ru.app.db.service; import jakarta.annotation.PostConstruct; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.hibernate.Session; import org.hibernate.proxy.HibernateProxy; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.app.db.entity.AbstractEntity; import java.util.List; /**Сервис работы с сущностями в ДБ */ @Service @Slf4j @RequiredArgsConstructor @Getter public class EntityService { @PersistenceContext private EntityManager em; public static EntityService self; /**Истина - жёсткое удаления, ложь - мягкое удаление*/ @Value("${app.delete-is-true:false}") private boolean deleteIsTrue; @PostConstruct public void postConstructor(){ self=this; } /** * Получить ссылку на сущность по её уид-у (можно исп для проверки существования сущности) * * @param id - уид сущности * @param entityClass - класс искомой сущности * @return - сущность или null, если сущности не существует */ public <A extends AbstractEntity> A getReferenceForId(Class<A> entityClass, Long id){ /*Hibernate не выполняет SQL-запрос, когда вы вызываете метод getReference. Если сущность не является управляемой, Hibernate инстанцирует прокси-объект и инициализирует атрибут первичного ключа Это очень похоже на неинициализированную ассоциацию с ленивой загрузкой, которая тоже дает вам прокси-объект. В обоих случаях устанавливаются только атрибуты первичного ключа. Как только вы попытаетесь получить доступ к первому же атрибуту, не являющемуся первичным ключем, Hibernate выполнит запрос к базе данных, чтобы получить все атрибуты. Это также первый раз, когда Hibernate проверит, существует ли указанный объект сущности. Если выполненный запрос не возвращает ожидаемого результата, Hibernate генерирует исключение * */ return em.getReference(entityClass, id); } /** * Получить сущность по её уид-у * * @param id - уид сущности * @param entityClass - класс искомой сущности * @return - сущность */ @SuppressWarnings("unchecked") @SneakyThrows public <A extends AbstractEntity> A findById(Class<A> entityClass, Long id) { try { A e = (A) em.createQuery("from " + entityClass.getSimpleName() + " where id=" + id.toString()).getSingleResult(); return e; } catch (Exception ex) { log.error("No find entity for id"+id, ex); return null; } } /** * Получить набор сущностей по условию * * @param hql - запрос со всеми условиями * @param startPosition - с какой записи получать * @param maxResult - по какую запись получить * @param entityClass - класс искомой сущности * @return - сущность */ @SuppressWarnings("unchecked") @SneakyThrows public <A extends AbstractEntity> List<A> findByAllFromConditionals(Class<A> entityClass, String hql, Integer startPosition, Integer maxResult) { if (hql.toLowerCase().contains("insert") || hql.toLowerCase().contains("update")) throw new RuntimeException("bad sql request: "+hql); if (startPosition == null) startPosition = 0; if (maxResult == null) maxResult = Integer.MAX_VALUE; try { log.info(hql); List<A> list = em.createQuery("select distinct e "+hql, entityClass).setFirstResult(startPosition).setMaxResults(maxResult).getResultList(); return list; } catch (Exception ex) { log.error("Error select for: " + hql, ex); throw ex; } } /** * Получить количество сущностей в выбранном наборе согласно условиям * * @param hql - запрос со всеми условиями * @return - общее количество выбранных сущностей */ @SuppressWarnings("unchecked") @SneakyThrows public <A extends AbstractEntity> Integer findByAllFromConditionalsCount(String hql) { if (hql.toLowerCase().contains("insert") || hql.toLowerCase().contains("update")) throw new RuntimeException("bad sql request: "+hql); int i=hql.indexOf("order"); if (i>0) hql=hql.substring(0, i); try { return Integer.parseInt(em.createQuery("select count(distinct e) " + hql).getSingleResult().toString()); } catch (Exception ex) { log.error("Error count select for: " + hql, ex); throw ex; } } /** * Сохранить сущность в бд * * @param entity - сохраняемая сущность * @return - сохранённую сущность (мб добавить ай-ди или другие поля) */ @SneakyThrows @Transactional public <S extends AbstractEntity> S save(S entity) { entity.beforeSave(); if (entity.getId() == null) em.persist(entity); else entity=em.merge(entity); return entity; } /**Удаляем запись. Удаление или настоящее, или псевдо-удаление в зависимости от настроек*/ @Transactional public void delete(AbstractEntity entity) { entity.beforeDelete(); //em.remove(em.contains(entity) ? entity : em.merge(entity));//delete entity if (deleteIsTrue) em.remove(em.contains(entity) ? entity : em.merge(entity));//hard delete entity else{//soft delete entity.setDeleted(true); //todo: надо добавить также удаление всех связанных сущностей согласно CascadeType /*также для реализации "мягкого" удаления можно использовать аннотации вида @SQLDelete(sql = "update Worker_Entity set deleted=true where id=?") но тогда имя таблицы приходится задавать как константу @SoftDelete(columnName = "deleted") но тогда нельзя использовать lazy-стратегии загрузки связанных сущностей*/ save(entity); } } /**Получить класс сущности по её текствому имени посредством поиска во всех сущностях * @param entityClassName - имя класса сущности * @return - класс сущности*/ public Class<? extends AbstractEntity> getEntityClassForName(String entityClassName){ var entityImpl =em.getMetamodel().getEntities().stream().filter(elem -> elem.getName().equals(entityClassName)).findFirst().orElseThrow(()-> new RuntimeException("no find entity class with class name is "+entityClassName)); return (Class<? extends AbstractEntity>) entityImpl.getJavaType(); } /** * Когда мы получаем вложенную сущность через ленивую загрузку, иногда приходится принудительно инициализировать её поля. Новый запрос в БД не идёт * * @param ae - потомок AbstractEntity, вложенная сущность полученная через ленивую загрузку * @return - та же сущность, но с инициализированными полями */ @SuppressWarnings("unchecked") public static <R extends AbstractEntity> R lazyInitializer(R ae) { if (ae != null) if (ae.id == null) if (ae.getId() != null) return (R) ((HibernateProxy) ae).getHibernateLazyInitializer().getImplementation(); //если по каким-то причинам поле не было извлечено, то извлечём его принудительно else return null; else return ae; return null; } }
Наши сущности: работник и его контакты.
Разумеется они обязательно наследуются от AbstractEntity
/**Сотрудник*/ @Entity @Getter @Setter @FieldDefaults(level = AccessLevel.PUBLIC) @Lazy @SQLRestriction("deleted = false") @JsonInclude(JsonInclude.Include.NON_NULL) //при сериализации в json не сериализовать нулевые значения public class WorkerEntity extends AbstractEntity { /**Имя сотрудника*/ @JsonIgnore String lastName=""; @JsonIgnore String firstName=""; public String getFullName(){ return lastName +" " +firstName; } /**Контакты сотрудника*/ @OneToMany(mappedBy = "worker", fetch = FetchType.LAZY, cascade = CascadeType.ALL) //@JsonManagedReference Set<ContactEntity> contacts=new HashSet<>(); /**Заметки о сотруднике*/ @ElementCollection List<String> notes=new ArrayList<>(); } /**Контакт*/ @Entity @Getter @Setter @FieldDefaults(level = AccessLevel.PUBLIC) @SQLRestriction("deleted = false") @JsonInclude(JsonInclude.Include.NON_NULL) public class ContactEntity extends AbstractEntity { /**тип контакта*/ @Enumerated(EnumType.STRING) ContactTypeEnum type; /**сам контакт*/ String contact; /**поле обратной связи*/ @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) //@JsonBackReference https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion @JsonIgnore //не сериалзовать поле обратной связи так как иначе получим рекурсию при сериализации WorkerEntity worker; }
Соответственно пример работы с сущностями:
/**Сгенерируем тестовые данные для бд: сотрудников и их контакты*/ @Transactional public void generate(){ Random randomizer = new Random(); for(int i=0; i < 10; i++){ WorkerEntity worker = new WorkerEntity(); worker.setLastName(List.of("Иванов","Сидоров","Козлов","Петров","Багров").get(randomizer.nextInt(5))); worker.setFirstName(List.of("Иван","Сидор","Илья","Александр","Данила").get(randomizer.nextInt(5))); for(int j=0; j < randomizer.nextInt(4); j++){ ContactEntity contact = new ContactEntity(); contact.setType(getRandomValueFromEnum(ContactTypeEnum.values())); contact.setContact("123456"); contact.setWorker(worker); contact=AbstractEntity.save(contact); worker.getContacts().add(contact); } for(int j=0; j < randomizer.nextInt(4); j++) worker.getNotes().add(List.of("Хороший сотрудник","Плохой сотрудник","Вечно опаздывает","Постоянно перерабатывает","Коллег называет братьями и спрашивает 'в чём сила?'").get(randomizer.nextInt(4))); worker = AbstractEntity.save(worker); } } //выборка по условию (или без условия): AbstractEntity.findForQuery("select we from WorkerEntity we",false, WorkerEntity.class) //поиск по уид-у AbstractEntity.findById(WorkerEntity.class, workerId) //поиск и удаление AbstractEntity.delete(AbstractEntity.findById(WorkerEntity.class, workerId)); /** * Выбрать случайный элемент из перечислеия */ private static <V extends Enum<V>> V getRandomValueFromEnum(V[] enumArray) { return Arrays.stream(enumArray).toList().get((new Random()).nextInt(enumArray.length)); }
Програмные конфиги
AppProperties
Хранит настройки приложения. Доступен в статическом контексте
/**Классс свойств*/ @Component @Getter @FieldDefaults(level = AccessLevel.PRIVATE) public class AppProperties { /**УИД-приложения*/ @Value("${spring.application.name:none}") String appName; /**Включена ли аутентификация*/ @Value("${auth.enabled:true}") private Boolean enabled; public static AppProperties self; @PostConstruct public void postConstruct(){ self=this; } }
SwaggerConfig — настройки свагера для приложения
package ru.app.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.info.Contact; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.annotations.security.SecurityScheme; //https://struchkov.dev/blog/ru/api-swagger/ //описание свагерра @OpenAPIDefinition( info = @Info( title = "Тестовое Api", description = "Пример описания АПИ", version = "1.0.0", contact = @Contact( name = "Иван Иванов", email = "ivan@ivanov.ruv", url = "https://ivan.ivanov.ru" ) ) ) //авторизация которую должен исп сваггер @SecurityScheme( name = "JWT", type = SecuritySchemeType.HTTP, bearerFormat = "JWT", scheme = "bearer" ) /*@SecurityScheme( //for spring security name = "jsessionid", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.COOKIE, paramName = "JSESSIONID" )*/ public class SwaggerConfig { }
WebMvcConfig
Настроим конфигурацию так, чтобы она пропускала CORS-запросы (так как мы хотим работать с данным сервером-ресурсов через гейтвей)
Также иногда бывает полезно добавить перехватчик rest-запросов, например, для логирования запросов и ответов приложения
package ru.app.config; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.util.ContentCachingResponseWrapper; import java.util.Enumeration; @Configuration public class WebMvcConfig implements WebMvcConfigurer { /**Разрешаем CORS для сваггера*/ @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**"); //registry.addMapping("/api-docs"); //только для сваггера } /**Добавим свой перехватчик*/ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoggerInterceptor()); } } //https://www.baeldung.com/spring-mvc-handlerinterceptor /**Создадим собственный перехватчик для возможности логирования входных и выходных запросов и пропишем его*/ @Slf4j class LoggerInterceptor implements HandlerInterceptor { @Override /**Как следует из названия, перехватчик вызывает preHandle() перед обработкой запроса. По умолчанию этот метод возвращает true, чтобы отправить запрос дальше, к методу обработчика. Однако мы можем указать Spring остановить выполнение, вернув false. Мы можем использовать хук для записи информации о параметрах запроса, например о том, откуда пришёл запрос.*/ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { log.info("[preHandle][" + request + "]" + "[" + request.getMethod() + "]" + request.getRequestURI() + getParameters(request)); return true; } @Override /**Мы можем использовать этот метод для получения данных запроса и ответа после отображения представления*/ public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { if (ex != null){ ex.printStackTrace(); } log.info("[afterCompletion][" + request + "][exception: " + ex + "] => "+response); } private String getParameters(HttpServletRequest request) { StringBuffer posted = new StringBuffer(); Enumeration<?> e = request.getParameterNames(); if (e != null) { posted.append("?"); } while (e.hasMoreElements()) { if (posted.length() > 1) { posted.append("&"); } String curr = (String) e.nextElement(); posted.append(curr + "="); if (curr.contains("password") || curr.contains("pass") || curr.contains("pwd")) { posted.append("*****"); } else { posted.append(request.getParameter(curr)); } } String ip = request.getHeader("X-FORWARDED-FOR"); String ipAddr = (ip == null) ? getRemoteAddr(request) : ip; if (ipAddr!=null && !ipAddr.equals("")) { posted.append("&_psip=" + ipAddr); } return posted.toString(); } private String getRemoteAddr(HttpServletRequest request) { String ipFromHeader = request.getHeader("X-FORWARDED-FOR"); if (ipFromHeader != null && ipFromHeader.length() > 0) { log.debug("ip from proxy - X-FORWARDED-FOR : " + ipFromHeader); return ipFromHeader; } return request.getRemoteAddr(); } }
Авторизация
Чтобы добавить авторизацию к нашему приложению опишем фильтр который будет запускаться для каждого входящего запроса и решать обрабатывать его ил нет
AuthorizationFilter
package ru.app.api.auth; import com.auth0.jwt.interfaces.DecodedJWT; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.Arrays; import java.util.List; /**Фильтр проверяющий ацесс-токен для авторизации который надо получать у auth-service если в настройках включена авторизация*/ @Component @RequiredArgsConstructor public class AuthorizationFilter extends OncePerRequestFilter { private final TokenService tokenService; @Value("${auth.enabled}") private boolean enabled; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if (!enabled //здесь мы выбираем пропустить запрос без авторизации при определённых условиях || request.getMethod().equals(HttpMethod.OPTIONS.name()) || request.getRequestURI().contains("/public/") || request.getRequestURI().contains("/h2-console") || request.getRequestURI().contains("/actuator") || request.getRequestURI().contains("/swagger-ui") || request.getRequestURI().contains("/api-docs") ) { filterChain.doFilter(request, response); return; } //здесь, соответственно, мы требуем авторизацию. Если её нет - отказываем. Если есть - проверяем токен и если он валиден, то сохраняем список ролей пользователя в RequestContextHolder.getRequestAttributes().setAttribute String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader == null || authHeader.isBlank()) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); else { DecodedJWT decodedJWT = checkAuthorization(authHeader); if (decodedJWT==null) response.setStatus(HttpServletResponse.SC_FORBIDDEN); else { //Авторизация прошла успешно //заполним контекст запроса //получать как RequestContextHolder.getRequestAttributes().getAttribute("roles", 0); RequestContextHolder.getRequestAttributes().setAttribute("clientId", decodedJWT.getSubject(), 0); RequestContextHolder.getRequestAttributes().setAttribute("roles", Arrays.stream(decodedJWT.getClaim("roles").asString().split(",")).toList().stream().map(String::trim).toList(), 0); filterChain.doFilter(request, response); } } } private DecodedJWT checkAuthorization(String auth) { if (!auth.startsWith("Bearer ")) return null; String token = auth.substring(7); return tokenService.checkToken(token); } }
Обратите внимание что мы не требуем авторизации для некоторых служебных апи, а также для запросов типа OPTIONS
Для всех остальных запросов мы проверяем валидность токена. Если он валиден то сохраняем список ролей пользователя в контексте запроса RequestContextHolder
AuthorizationService — содержит методы для проверки ролей пользователя при доступе к апи и/или методам
@Slf4j public class AuthorizationService { /** * Проверит, что текущий пользователь имеет указанную роль или поднимет исключение * Если код выполнялся от лица системы, то исключения не будет */ public static void checkRole(String roleName) { if (!AppProperties.self.getEnabled()){ log.info("authorization disabled"); return; } try { String clientId = (String) RequestContextHolder.getRequestAttributes().getAttribute("clientId", 0); List<String> roles = (List<String>) RequestContextHolder.getRequestAttributes().getAttribute("roles", 0); Boolean result = roles.contains(roleName); log.info("clientId={} with roles={} check roleName={} result is {}", clientId, roles, roleName, result); if (!result) throw new RuntimeException("clientId=" + clientId + " no have role=" + roleName); } catch (NullPointerException e) { log.info("clientId={} with roles={} check roleName={} result is {}", "system", "[*]", roleName, true); } } /** * Проверит, что текущий пользователь имеет все указанные роли или поднимет исключение * Если код выполнялся от лица системы, то исключения не будет */ public static void checkRolesAnd(List<String> roles) { roles.forEach(roleName -> checkRole(roleName)); } /** * Проверит, что текущий пользователь имеет хотя бы одну указанныю роль или поднимет исключение * Если код выполнялся от лица системы, то исключения не будет */ public static void checkRolesOr(List<String> roles) { boolean result=false; for (String roleName : roles) try { checkRole(roleName); result=true; break; } catch (RuntimeException re) {} if (!result) throw new RuntimeException("No find role in client for roles=" + roles); } }
Соотвтетственно чтобы разрешить доступ к апи только пользователю с ролью ROLE_WEB мы пишем
@GetMapping("/hello") public AnswerDto hello(@RequestParam(required = false) String name) { AuthorizationService.checkRole("ROLE_WEB"); return new AnswerDto().setData("Hello, " + name); }
Аналогичным образом можно закрывать доступ в сервисах.
Можно оперировать несколькими ролями используя checkRolesAnd или checkRolesOr
Сервис для работы с токеном доступа
/**Проверка токена полученного клиентом у AuthService-а*/ public interface TokenService { /** Вернёт раскодирвоанный jwt-токен если он валиден или null если не валиден*/ DecodedJWT checkToken(String token); } @Service @Slf4j @RequiredArgsConstructor public class DefaultTokenService implements TokenService { @Value("${auth.jwt.secret.access}") private String secretKey; @Value("${spring.application.name}") private String appName; @Override public DecodedJWT checkToken(String token) { Algorithm algorithm = Algorithm.HMAC256(secretKey); JWTVerifier verifier = JWT.require(algorithm).build(); try { DecodedJWT decodedJWT = verifier.verify(token); if (!decodedJWT.getIssuer().equals("auth-service")) { log.error("Issuer is incorrect"); return null; } if (!decodedJWT.getAudience().contains(appName)) { log.error("Audience is incorrect"); return null; } return decodedJWT; } catch (JWTVerificationException e) { log.error("Token is invalid: " + e.getMessage()); return null; } } }
Реалзизация контроллеров опущена. Можете посмотреть её в исходниках на гитхабе.
Не забудьте только добавить к контроллерам аннотацию @SecurityRequirement(name = «JWT») иначе сваггер не поймёт что это защищённые методы и не будет добавлять к ним ацесс-токен. Впрочем вы можете вызвать их вручную из postman-а
Дополнительно предлагаю обратить внимание исходниках на перехватчик и обработчик исключений. Иногда бывает полезно обработать все ошибки возникающие при вызове апи единообразным образом
/**Перехват и пользовательская обработка исключений*/ @ControllerAdvice @Slf4j public class DefaultAdvice { @ExceptionHandler(Exception.class) public ResponseEntity<AnswerDto> handleException(Exception e) { log.error("Перехвачена ошибка: ",e); AnswerDto response = new AnswerDto(); response.setErrorText(e.getClass().getName()+": "+e.getLocalizedMessage()+(e.getCause()!=null ? " ("+e.getCause().toString()+")" : "")); response.setErrorType(e.getClass().getSimpleName()); return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); } }
А также на листенер позволяющий отловить вызов метода /actuator/refresh заставляющего спринг-приложение перечитать конфигурацию получив её заново от конфиг-сервера без перезапуска
import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.cloud.context.environment.EnvironmentChangeEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Service; //https://www.baeldung.com/spring-reloading-properties //https://habr.com/ru/companies/otus/articles/590761/ /**Пример как отловить выполнение /actuator/refresh*/ @Service @RefreshScope @Slf4j public class MyRefreshListener implements ApplicationListener<EnvironmentChangeEvent> { @Value("${myValue:none}") private String myValue; @Override public void onApplicationEvent(EnvironmentChangeEvent event) { if(event.getKeys().contains("myValue")) { log.info("REFRESH! myValue={}",myValue); } } }
Использование
При попытке вызвать любое апи без токена получаем ошибку «не авторизован»
Авторизуемся на сервисе авторизации и вставим токен в сваггер сервиса-ресурсов
Видим что все апи успешно вызываются хроме хэллоу-апи которое требует отсутствующую у данного пользователя роль ROLE-WEB.
Если авторизоваться на сервере авторизации под пользователем client1 у которого эта роль есть, то хэллоу-апи также станет доступно
Второе приложение сервера-ресурсов(WebSecurityApplication). Используем элементы спринг-секьюрити
В прошлом сервере-ресурсов нам пришлось самим проверять ролевую модель и управлять доступом пользователя. Но можно переложить эту задачу на спринг-секьюрити.
Сразу хочу ответить на вопрос: зачем городить огород, писать самостоятельную авторизацию и ограниченно использовать секьюрити, когда можно просто использовать спринг-секьюрити?
1. Сприг-секьюрити слишком подвержен изменениям. Постоянно выходят новые версии, старые методы становятся неподдерживаемыми, возникают новые классы и так далее. С моей точки зрения это говорит о том, что данный проект ещё несколько сыроват.
2. Использование сприг-секьюрити накладывает дополнительные архитектурные ограничения. Иногда эти ограничения идут в разрез с планируемой архитектурой.
3. Наконец что если я хочу написать действительно «микро»-сервис, то есть вообще без использования спринга?
В любом случае, я считаю, что используя сторонние инструменты надо как минимум понимать как они работают, а как максимум уметь самому реализовать что-то подобное (сердце того же спринга с его контекстом и фабрикой бинов вполне можно написать самому за пару недель).
На мой взгляд использование спринг-секьюрити полностью оправдано для защиты одиночного приложения, но может быть сомнительно для нескольких взаимодействующих приложений.
Итак. попросив удачи у Ершова приступим
Добавим к зависимостям в build.gradle
application.properties
spring.application.name=WebSecurityApplication1 server.port=8081 #config spring.profiles.active=authSecret, swagger, WebSecurityApplication, actuator, adminClient spring.cloud.config.fail-fast=true spring.cloud.config.uri=http://localhost:8888 spring.config.import=optional:configserver:http://localhost:8888 spring.cloud.config.import-check.enabled=true
Настроим конфигурацию спринг-секьюрити
@Configuration @EnableWebSecurity @EnableMethodSecurity(securedEnabled = true) //чтобы можно было исп аннотацию Secured на методах, подробнее см https://www.baeldung.com/spring-security-method-security @RequiredArgsConstructor public class SecurityConfiguration { private final AuthorizationFilter authorizationFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) // Своего рода отключение CORS (разрешение запросов со всех доменов) .cors(cors -> cors.configurationSource(request -> { var corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowedOriginPatterns(List.of("*")); corsConfiguration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); corsConfiguration.setAllowedHeaders(List.of("*")); corsConfiguration.setAllowCredentials(true); return corsConfiguration; })) // Настройка доступа к конечным точкам .authorizeHttpRequests(request -> request // Можно указать конкретный путь, * - 1 уровень вложенности, ** - любое количество уровней вложенности /*permitAll - Эндпоинт доступен всем пользователям, и авторизованным и нет authenticated - Только авторизованные пользователи hasRole - Пользователь должен иметь конкретную роль, и, соответственно быть авторизованным hasAnyRole - Должен иметь одну из перечисленных ролей (не представлено в коде)*/ .requestMatchers("/actuator/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-resources/*", "/api-docs/**").permitAll() .requestMatchers("/app/v1/**").hasRole("WEB") .anyRequest().authenticated()) .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS)) //.authenticationProvider(authenticationProvider()) .addFilterBefore(authorizationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }
Видим, что здесь мы вручную применяем наш фильтр AuthorizationFilter который изменится соответствующим образом
/**Фильтр проверяющий ацесс-токен для авторизации который надо получать у auth-service если в настройках включена авторизация*/ @Component @RequiredArgsConstructor public class AuthorizationFilter extends OncePerRequestFilter { private final TokenService tokenService; protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { /* не нужно проверять так как доступ к разрешённым ресурсам разерешён в конфигурации. В данном фильтре проверяем строго токен для доступа к ресурсам требующим авторизации if ( request.getRequestURI().contains("/public/") || request.getRequestURI().contains("/actuator") || request.getRequestURI().contains("/swagger-ui") || request.getRequestURI().contains("/api-docs") ) { filterChain.doFilter(request, response); return; }*/ String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader==null || authHeader.isEmpty() || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } if (authHeader == null || authHeader.isBlank()) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); else { DecodedJWT decodedJWT = checkAuthorization(authHeader); if (decodedJWT==null) response.setStatus(HttpServletResponse.SC_FORBIDDEN); else { //Авторизация прошла успешно //Создадим сприговский контекст безопасности //получать как SecurityContextHolder.getContext().getAuthentication() //Имя авторизованного пользователя как SecurityContextHolder.getContext().getAuthentication().getName() List<SimpleGrantedAuthority> roles = Arrays.stream(decodedJWT.getClaim("roles").asString().split(",")).toList().stream().map(String::trim).map(SimpleGrantedAuthority::new).toList(); SecurityContext context = SecurityContextHolder.createEmptyContext(); UsernamePasswordAuthenticationToken authToken =new UsernamePasswordAuthenticationToken(decodedJWT.getSubject(), null, roles); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); context.setAuthentication(authToken); SecurityContextHolder.setContext(context); //продолжим обработку цепочки фильтров filterChain.doFilter(request, response); } } } private DecodedJWT checkAuthorization(String auth) { if (!auth.startsWith("Bearer ")) return null; String token = auth.substring(7); return tokenService.checkToken(token); } }
Видим что проверка на запросы для которых не надо проверять авторизацию ушла в SecurityConfiguration, а информацию о сеансе пользователя и его ролях мы теперь сохраняем в SecurityContext
Соответственно теперь, имея спринговский контекст безопасности мы можем использовать вместо кастомной проверки ролей спринговские аннотации @Secured({«ROLE_HELLO»})для проверки доступа к методам сервиса и @PreAuthorize(«hasRole(‘ADMIN’)»)для проверки доступа к апи
Приложение (WebGenerateApplication) без авторизации демонстрирующее пример автогенерации кода согласно принципу «api first»
build.gradle
Здесь мы как раз будем использовать плагин для автогенрации кода ‘org.openapi.generator’
Подробнее про него см
https://habr.com/ru/companies/spring_aio/articles/833096/
https://openvalue.blog/posts/2023/11/26/communicating_our_apis_part2/
https://openapi-generator.tech/docs/generators/#server-generators
Мы создаём связанную задачу compileJava чтобы при каждом компилировании нашего проекта происходила автоматическая генерация кода согласно апи описанному в petclinic-spec.yml.
Мы генерируем код для бэка на языке java и под библиотеку спринга, но также можно сгенерировать клиентский код, например, под ангуляр для фронта.
Также обратите внимание на конструкцию sourceSet которая позволяет добавить автосгенерированный код как дополнительное дерево исходников
group = 'ru.app.gen' version = '0.0.1-SNAPSHOT' apply plugin: 'org.openapi.generator' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' implementation 'jakarta.validation:jakarta.validation-api:3.0.2' implementation 'javax.servlet:javax.servlet-api:3.0.1' implementation 'org.openapitools:jackson-databind-nullable:0.2.6' } tasks.named('compileJava') { dependsOn(tasks.openApiGenerate) } //restclient /* openApiGenerate { generatorName.set('java') configOptions.set([ library: 'restclient', openApiNullable: 'false' ]) inputSpec = "${projectDir}/src/main/resources/petclinic-spec.yml" outputDir = "${projectDir}/build/generated/java-rest-client" ignoreFileOverride.set(".openapi-generator-java-sources.ignore") invokerPackage.set('com.myapp') modelPackage.set('com.myapp.model') apiPackage.set('com.myapp.api') } */ //web-server openApiGenerate { generatorName = 'spring' configOptions.set([ library: 'spring-boot', openApiNullable: 'false', generateSupportingFiles: 'false', interfaceOnly: 'true', useSpringBoot3: 'true' ]) inputSpec = "${projectDir}/src/main/resources/petclinic-spec.yml" outputDir = "${projectDir}/build/generated/java-server" apiPackage = "com.example.api" modelPackage = "com.example.model" //configOptions = [dateLibrary: "java8"] } sourceSets { main { java { srcDirs("$buildDir/generated/java-server/src/main/java") //server srcDirs("$buildDir/generated/java-rest-client/src/main/java") //client } } }
application.properties
spring.application.name=open-api-demo server.port=8082 #config spring.profiles.active=swagger, actuator, adminClient spring.cloud.config.fail-fast=true spring.cloud.config.uri=http://localhost:8888 spring.config.import=optional:configserver:http://localhost:8888 spring.cloud.config.import-check.enabled=true
У нас есть возможность запустить автогенерацию кода вручную
Плагин сгенерирует нам, согласно настройкам, готовые апи, дто и так далее
А исспользовать сгенерированные апи добавив собственный кастомный код мы можем следующим образом (для простоты здесь не создаётся отдельный контроллер. Код добавлен в главный класс приложения)
@SpringBootApplication @RestController public class WebGenerateApplication implements PetsApi { public static void main(String[] args) { SpringApplication.run(WebGenerateApplication.class, args); } @Override public ResponseEntity<Pet> getPet(Integer petId) { Pet pet = new Pet(); pet.setName("Всем привет, это тестовый пример!"); return ResponseEntity.ok(pet); } }
Как видите мы просто расширили сгенерированный контроллер PetsApi и добавили кастомную логику для одного из его методов.
Протестируем с помощью сваггера зайдя по ссылке http://localhost:8082/swagger-ui/index.html
Как видим все сгенерированные контроллеры активны и один из них, который мы переписали, выполняет наш кастомный код.
Гейтвей
Его задача дать возможность клиентам из небезопасного контура контролируемо вызывать апи для приложений из безопасного контура.
Для создания гейтвея мы будем использовать Spring Cloud Gateway и вся наша работа сведётся к правильной настройке.
Дополнительно гейтвей можно использовать для балансировки нагрузки между сервисами или разными экземплярами одного и того же сервиса (но это за пределами данного мануала).
Вы также можете использовать реестр сервисов типа Spring Cloud Eureka чтобы иметь возможность прописывать пседвонимы вместо конкретных хостов и портов. Но если вы используете docker compose или, тем более, систему оркестрации типа kubernetes, то реестр сервисов вам как бы и не нужен. Вы и так можете создавать виртуальные машины с любыми доменными именами в рамках своей виртуальной сети.
build.gradle
group = 'ru.gateway' version = '1.0-SNAPSHOT' dependencies { //spring implementation 'org.springframework.cloud:spring-cloud-starter-gateway' testImplementation 'org.springframework.boot:spring-boot-starter-test' //swagger(webflux) for gateway implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.6.0' //https://www.baeldung.com/spring-cloud-gateway-integrate-openapi }
SpringCloudGateway.java
@SpringBootApplication @EnableScheduling //включаем возможность запуска задач по расписанию. Потребуется дальше когда будем создавать метрики для активатора public class SpringCloudGateway { public static void main(String[] args) { SpringApplication.run(SpringCloudGateway.class, args); } }
.
application.yml
Самое главное — настройки маршрутизации
Обратите внимание на настройки springdoc.swagger-ui для пробрасывания сваггеров всех присложений.
И, разумеется, на настройки routes которые описывают саму маршрутизацию.
predicates — описывает шаблон условия когда срабатывает данная настройка (и помним, что «**» означает «что угодно дальше, любое продолжения адреса»)
uri — куда перенаправляется запрос
Фильтр типа RewritePath описывает замену в урл-адресе запроса одной подстроки на другую.
#web server: port: 80 management: endpoint: health: show-details: always info: enabled: true shutdown: enabled: true #запрос на выклю приложения curl -i -X POST http://localhost:80/actuator/shutdown gateway: enabled: true endpoints: web: exposure: include: gateway, health, metrics, shutdown, info, myCustomEndpoint #http://localhost/actuator/gateway/routes #http://localhost/webjars/swagger-ui/index.html?urls.primaryName=gateway-service springdoc: enable-native-support: true api-docs: enabled: true path: /api-docs swagger-ui: enabled: true path: /swagger-ui urls: #Gateway(this) - name: gateway-service primaryName: API Gateway Service url: /api-docs #AuthService http://localhost/webjars/swagger-ui/index.html?urls.primaryName=auth-service - name: auth-service primaryName: API Auth Service url: http://localhost:8079/api-docs #WebApplication1 http://localhost/webjars/swagger-ui/index.html?urls.primaryName=web-app1-service - name: web-app1-service primaryName: API Application 'WebApplication1' url: http://localhost:8080/api-docs #WebSecurityApplication http://localhost/webjars/swagger-ui/index.html?urls.primaryName=web-sec-app-service - name: web-sec-app-service primaryName: API Application 'WebSecurityApplication' url: http://localhost:8081/api-docs #WebGenerateApplication http://localhost/webjars/swagger-ui/index.html?urls.primaryName=web-gen-app-service - name: web-gen-app-service primaryName: API Application 'WebGenerateApplication' url: http://localhost:8082/api-docs spring: boot: admin: client: url: http://localhost:8099 application: name: Gateway cloud: gateway: httpclient: ssl: #доверять всем сертификатам useInsecureTrustManager: true routes: #AuthService - http://localhost:80/AuthService/login - id: AuthService-registration uri: http://localhost:8079 predicates: - Path=/AuthService/** filters: - RewritePath=/AuthService, /auth #WebApplication1 #запрашивать как http://localhost:80/WebApplication1/web/hello?name=rrrrr - id: WebApplication1-web uri: http://localhost:8080 #урл сервиса куда перенаправляем запрос predicates: #условие по которому запрос перенаправляется - Path=/WebApplication1/web/** filters: #как модифицируется запрос на пути туда и обратно - RewritePath=/WebApplication1/web, /app/v1 #запрашивать как http://localhost:80/WebApplication1/db/1 - id: WebApplication1-db uri: http://localhost:8080 predicates: - Path=/WebApplication1/db/** filters: - RewritePath=/WebApplication1/db, /app/db/worker #WebSecurityApplication как http://localhost:80/WebSecurityApplication/web/hello1 - id: WebSecurityApplication-web uri: http://localhost:8081 predicates: - Path=/WebSecurityApplication/web/** filters: - RewritePath=/WebSecurityApplication/web, /app/v1 #WebGenerateApplication - id: WebGenerateApplication-pets uri: http://localhost:8082 predicates: - Path=/WebGenerateApplication/** filters: - RewritePath=/WebGenerateApplication, / #other - id: help uri: https://spring.io/guides predicates: - Path=/help filters: - RedirectTo=302, https://spring.io/guides
Тестируем гейтвей
Используем сваггер зайдя на http://localhost/swagger-ui (или же на http://localhost/webjars/swagger-ui/index.html?urls.primaryName=gateway-service)
Как видим, сваггер прекрасно работает.
Также мы можем имитировать фронт-клиента используя postman и вызывая соответствующие апи приложений из безопасного контура обращаясь к гейтвею по заданным урл-ам,например:
на http://localhost:80/AuthService/login для получения токенов доступа по логину и паролю.
на http://localhost:80/WebApplication1/web/hello?name=rrrrr для доступа к хллоу-апи первого сервиса-ресурсов
на http://localhost:80/WebSecurityApplication/web/hello1 для доступа к апи второго сервера-ресурсов и так далее
Для примера напишем собственную метрику для актуатора
Напишем собственную метрику которую можно вызывать через spring actuator и, соответственно, получать в спринг-бут-админ-сервере
Пример кастомного эндпойнта
Вызывать, соответственно, как http://localhost/actuator/myCustomEndpoint
package ru.gateway.actuator; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import lombok.ToString; import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; import org.springframework.stereotype.Component; /**Пример самописного эндпойнта для актуатора*/ @Component @Endpoint(id = "myCustomEndpoint") public class MyCustomEndpoint { MyCustomData data=new MyCustomData(101, "Hello"); @ReadOperation //GET http://localhost/actuator/myCustomEndpoint public MyCustomData getData() { return data; } @WriteOperation //POST public void writeData(Integer id, String msg) { data.i=id; data.s=msg; } @DeleteOperation //DELETE public Integer deleteData() { data.i=-1; data.s=""; return 0; } } @AllArgsConstructor @NoArgsConstructor @Data class MyCustomData{ Integer i; String s; }
Разумеется должны быть включены разрешения в настройке
management.endpoints.web.exposure.include: * чтобы разрешить доступ ко всем эндпойнтам или перечислить доступные указав и наш тоже myCustomEndpoint
Пример собственных метрик
Чтобы мерики изменялись во времени создадим шедуллер который будет вызываться каждую секунду
/**Шедуллер для обработки кастомных меток * https://habr.com/ru/companies/otus/articles/650871/ * Из MeterRegistry можно инстанцировать следующие типы счетчиков: * Counter: сообщает только о результатах подсчета указанного свойства приложения. * Gauge: показывает текущее значение измерительного прибора * Timers: измеряет задержки или частоту событий * DistributionSummary: обеспечивает дистрибуцию событий и простую итоговую сводку.*/ @Component public class MetricScheduler { private final AtomicInteger testGauge; private final Counter testCounter; public MetricScheduler(MeterRegistry meterRegistry) { // Counter vs. gauge, summary vs. histogram // https://prometheus.io/docs/practices/instrumentation/#counter-vs-gauge-summary-vs-histogram testGauge = meterRegistry.gauge("custom_gauge", new AtomicInteger(0)); testCounter = meterRegistry.counter("custom_counter"); } @Scheduled(fixedRateString = "1000", initialDelayString = "0") public void schedulingTask() { testGauge.set(getRandomNumberInRange(0, 100)); testCounter.increment(); } private static int getRandomNumberInRange(int min, int max) { if (min >= max) { throw new IllegalArgumentException("max must be greater than min"); } Random r = new Random(); return r.nextInt((max - min) + 1) + min; } }
Вызывать как
http://localhost/actuator/metrics/custom_gauge
http://localhost/actuator/metrics/custom_counter
Подробнее см https://habr.com/ru/companies/otus/articles/650871/
Подробнее см
https://tproger.ru/articles/pishem-java-veb-prilozhenie-na-sovremennom-steke-s-nulja-do-mikroservisnoj-arhitektury-chast-3
https://sysout.ru/spring-cloud-api-gateway/?ysclid=m5ergtfz7887528745
https://www.concretepage.com/spring-boot/spring-cloud-gateway
https://cloud.spring.io/spring-cloud-gateway/reference/html/
SpringBootAdminServer
Прекрасный инструмент для отслеживания множества работающих приложений с возможность рассылки оповещений. Особенно прекрасен он тем, что достаточно разрешить использование spring actuator у отслеживаемых приложений и прописать им в настройки параметры подключения к спринг-бут-админ-серверу чтобы получить возможность мониторинга
Подробнее можно посмотреть https://habr.com/ru/articles/479954/
build.gradle
group = 'ru.configs' version = '1.0-SNAPSHOT' dependencies { //spring implementation 'org.springframework.boot:spring-boot-starter-web' //spring boot admin implementation 'de.codecentric:spring-boot-admin-starter-server:3.1.5' //add security //implementation 'org.springframework.boot:spring-boot-starter-security:3.1.5' }
application.properties
spring.application.name=SpringBootAdminServer server.port=8099 #http://localhost:8099/applications #http://localhost:8099/spring-security-mvc-login/login.html #spring.boot.admin.routes.endpoints=env, metrics, trace, jolokia, info, configprops
Заходим на http://localhost:8099/applications и видим все наши запущенные сервисы.
Можно получить выбранные метрики по различным сервисам
Статья изначально задумывалась как большая подсказка для разработки инфраструктуры с нуля в сжатые сроки.
Дорогой друг, надеюсь её большой объём не утомил тебя и может быть ты даже сумел вынести что-нибудь полезное для себя.
Ссылка на исходники: https://github.com/upswet/SimpleSpringExample
ссылка на оригинал статьи https://habr.com/ru/articles/872776/
Добавить комментарий