Рано или поздно каждый Java-разработчик столкнется с необходимостью реализовать защищенное REST API приложение. В этой статье хочу поделиться своей реализацией этой задачи.
1. Что такое REST?
REST (от англ. Representational State Transfer — «передача состояния представления») – это общие принципы организации взаимодействия приложения/сайта с сервером посредством протокола HTTP.
Диаграмма ниже показывает общую модель.

Всё взаимодействие с сервером сводится к 4 операциям (4 — это необходимый и достаточный минимум, в конкретной реализации типов операций может быть больше):
Получение данных с сервера (обычно в формате JSON, или XML);
Добавление новых данных на сервер;
Модификация существующих данных на сервере;
Удаление данных на сервере
Более подробно можно прочесть в остальных источниках, статей о REST много.
2. Задача
Необходимо подготовить защищенное REST приложение, доступ к которому может быть осуществлен только для авторизованного пользователя. Авторизация с передачей логина и пароля выполняется отдельным запросом, при успешной авторизации система должна сгенерировать и вернуть токен. Валидация остальных запросов должна быть осуществлена по токену.
Схема нашего приложения будет выглядеть следующим образом:

3. Технологии
Для решения используем фреймворк Spring Boot и Spring Web, для него требуется:
Java 8+;
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-файл должен выглядеть следующим образом:
Должен быть указан parent-сегмент с подключенным spring-boot-starter-parent;
И установлены зависимости spring-boot-starter-web, spring-boot-devtools и Lombok.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://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 @AllArgsConstructor public class User { private String login; private String password; private String firstname; private String lastname; private Integer age; }
В папке repository создаём класс UserRepository c двумя методами:
getByLogin – который будет возвращать пользователя по логину;
getAll – который будет возвращать список всех доступных пользователей. Чтобы Spring создал бин на основании этого класса, устанавливаем ему аннотацию @Repository.
@Repository public 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 для получения одного пользователя по логину.
@Service public 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 указываем адрес контроллера, по которому он будет доступен клиенту и тип возвращаемых данных.
@RestController public 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).
Интерфейс указывает на необходимость реализовать три метода:
Генерация токена в методе generateToken;
Сохранения токена – saveToken;
Получение токена – loadToken.
Генерируем токен силами Jwt, пример реализации метода.
@Repository public 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. Интерфейс требует реализовать один метод, выносить его в отдельный класс нет необходимости.
@Service public 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.
@RestControllerAdvice public 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 @EnableWebSecurity public 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))); }
Разберем метод детальнее:
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) - отключаем генерацию сессии;
addFilterAt(new JwtCsrfFilter(jwtTokenRepository, resolver), CsrfFilter.class).csrf().ignoringAntMatchers("/**") - указываем созданный нами фильтр JwtCsrfFilter в расположение стандартного фильтра, при этом игнорируем обработку стандартного;
.authorizeRequests().antMatchers("/auth/login").authenticated() для запроса /auth/login выполняем авторизацию силами security. Что бы не было двойной валидации (по токену и базовой), запрос был добавлен в исключение к классу JwtCsrfFilter;
.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. Надеюсь данный вариант реализации кому то окажется полезным.