Рано или поздно каждый 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. Надеюсь данный вариант реализации кому то окажется полезным.