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

Spring security

Из песочницы Контролируем и сохраняем сессии, используя Spring

04.08.2020 00:05:36 | Автор: admin
Привет, Хабр.

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

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

  • закрыть прошлую сессию и открыть новую
  • не закрывать старую сессию и не открывать новую сессию

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

И нужно учесть 2 возможности инвалидации сессии:

  • разлогин пользователя (т.е. нажатие пользователем кнопки logout)
  • автоматический разлогин после 30 минут бездействия

Сохранение сессий при перезагрузке


Для начала нужно научиться создавать и сохранять сессии(сохранять будем в бд, но возможно сохранять и в redis, например). В этом нам поможет Spring security и spring session jdbc. В build.gradle добавляем 2 зависимости:

implementation(            'org.springframework.boot:spring-boot-starter-security',            'org.springframework.session:spring-session-jdbc'    )

Создадим свой WebSecurityConfig, в котором включим сохранение сессий в бд с помощью аннотации @EnableJdbcHttpSession

@EnableWebSecurity@EnableJdbcHttpSession@RequiredArgsConstructorpublic class WebSecurityConfig extends WebSecurityConfigurerAdapter {    private final UserDetailsService userDetailsService;    private final PasswordEncoder passwordEncoder;    private final AuthenticationFailureHandler securityErrorHandler;    private final ConcurrentSessionStrategy concurrentSessionStrategy;    private final SessionRegistry sessionRegistry;    @Override    protected void configure(AuthenticationManagerBuilder auth) throws Exception {        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);    }    @Override    protected void configure(HttpSecurity http) throws Exception {        http                .cors().and()                //для защиты о csrf атак                .csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).and()                .httpBasic().and()                .authorizeRequests()                .anyRequest()                .authenticated().and()                //Логаут                .logout()                .logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))                //Возвращаем при логауте 200(по умолчанию возвращается 203)                .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))                //Инвалидируем сессию при логауте                .invalidateHttpSession(true)                .clearAuthentication(true)                //Удаляем всю информацию с фронта при логауте(т.е. чистим куки, хидеры и т.д.)                .addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(Directive.ALL)))                .permitAll().and()                //Включаем менеджер сессий(для контроля количества сессий)                .sessionManagement()                //Указываем макимальное возможное количество сессий(тут указано не 1, т.к. мы будем пользоваться своей кастомной стратегией, объяснение будет ниже)                .maximumSessions(3)                //При превышение количества активных сессий(3) выбрасывается исключение  SessionAuthenticationException                .maxSessionsPreventsLogin(true)                //Указываем как будут регестрироваться наши сессии(тогда во всем приложение будем использовать именно этот бин)                .sessionRegistry(sessionRegistry).and()                //Добавляем нашу кастомную стратегию для проверки кличества сессий                .sessionAuthenticationStrategy(concurrentSessionStrategy)                //Добавляем перехватчик для исключений                .sessionAuthenticationFailureHandler(securityErrorHandler);    }    //для инвалидации сессий при логауте    @Bean    public static ServletListenerRegistrationBean httpSessionEventPublisher() {        return new ServletListenerRegistrationBean(new HttpSessionEventPublisher());    }    @Bean    public static SessionRegistry sessionRegistry(JdbcIndexedSessionRepository sessionRepository) {        return new SpringSessionBackedSessionRegistry(sessionRepository);    }    @Bean    public static PasswordEncoder passwordEncoder() {        return new BCryptPasswordEncoder(12);    }}

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

Для сохранения сессий в бд так же необходимо в application.yml добавить проперти(в моем проекте используется postgresql):

spring:  datasource:    url: jdbc:postgresql://localhost:5432/test-db    username: test    password: test    driver-class-name: org.postgresql.Driver  session:    store-type: jdbc

Можно также указать время жизни сессии(по умолчанию 30 минут) с помощью проперти:

server.servlet.session.timeout

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

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

<changeSet id="0.1" failOnError="true">    <comment>Create sessions table</comment>    <createTable tableName="spring_session">      <column name="primary_id" type="char(36)">        <constraints primaryKey="true"/>      </column>      <column name="session_id" type="char(36)">        <constraints nullable="false" unique="true"/>      </column>      <column name="creation_time" type="bigint">        <constraints nullable="false"/>      </column>      <column name="last_access_time" type="bigint">        <constraints nullable="false"/>      </column>      <column name="max_inactive_interval" type="int">        <constraints nullable="false"/>      </column>      <column name="expiry_time" type="bigint">        <constraints nullable="false"/>      </column>      <column name="principal_name" type="varchar(1024)"/>    </createTable>    <createIndex tableName="spring_session" indexName="spring_session_session_id_idx">      <column name="session_id"/>    </createIndex>    <createIndex tableName="spring_session" indexName="spring_session_expiry_time_idx">      <column name="expiry_time"/>    </createIndex>    <createIndex tableName="spring_session" indexName="spring_session_principal_name_idx">      <column name="principal_name"/>    </createIndex>    <createTable tableName="spring_session_attributes">      <column name="session_primary_id" type="char(36)">        <constraints nullable="false" foreignKeyName="spring_session_attributes_fk" references="spring_session(primary_id)" deleteCascade="true"/>      </column>      <column name="attribute_name" type="varchar(1024)">        <constraints nullable="false"/>      </column>      <column name="attribute_bytes" type="bytea">        <constraints nullable="false"/>      </column>    </createTable>    <addPrimaryKey tableName="spring_session_attributes" columnNames="session_primary_id,attribute_name" constraintName="spring_session_attributes_pk"/>    <createIndex tableName="spring_session_attributes" indexName="spring_session_attributes_idx">      <column name="session_primary_id"/>    </createIndex>    <rollback>      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_idx"/>      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_pk"/>      <dropIndex tableName="spring_session_attributes" indexName="spring_session_attributes_fk"/>      <dropIndex tableName="spring_session" indexName="spring_session_principal_name_idx"/>      <dropIndex tableName="spring_session" indexName="spring_session_expiry_time_idx"/>      <dropIndex tableName="spring_session" indexName="spring_session_session_id_idx"/>      <dropTable tableName="spring_session_attributes"/>      <dropTable tableName="spring_session"/>    </rollback>  </changeSet>

Ограничиваем количество сессия


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

.maximumSessions(1)

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

Наша кастомная стратегия будет наследником.

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

@Slf4j@Componentpublic class ConcurrentSessionStrategy extends ConcurrentSessionControlAuthenticationStrategy {    //параметр для определения выбора пользователя(true - закрываем прошлую активную сессию)    private static final String FORCE_PARAMETER_NAME = "force";    //сервис для нотификации пользователя    private final NotificationService notificationService;    //кастомный сервис для управления сессиями    private final SessionsManager sessionsManager;    public ConcurrentSessionStrategy(SessionRegistry sessionRegistry, NotificationService notificationService,            SessionsManager sessionsManager) {        super(sessionRegistry);        //такую же настройку указывали в конфиге        super.setExceptionIfMaximumExceeded(true);       //в нашей стратегии указываем, что активная сессия может быть только 1        super.setMaximumSessions(1);        this.notificationService = notificationService;        this.sessionsManager = sessionsManager;    }    @Override    public void onAuthentication(Authentication authentication, HttpServletRequest request,            HttpServletResponse response)            throws SessionAuthenticationException {        try {            //отдаем обработку методу суперкласса(он вернет SessionAuthenticationException если активных сессий больше чем 1)            super.onAuthentication(authentication, request, response);        } catch (SessionAuthenticationException e) {            log.debug("onAuthentication#SessionAuthenticationException");            //получаем детали пользователя текущей сессии(в них можем хранить все, что нам нужно о пользователе)            UserDetails userDetails = (UserDetails) authentication.getPrincipal();            String force = request.getParameter(FORCE_PARAMETER_NAME);            //если параметр из хидера  'force' пустой, значит, пользователь еще не выбирал            if (StringUtils.isBlank(force)) {                log.debug("onAuthentication#Multiple choices when login for user: {}", userDetails.getUsername());                throw e;            }           //если параметр из хидера  'force' = false, значит, пользователь выбрал инвалидировать текущую сессию(по сути она и так будет не валидной)            if (!Boolean.parseBoolean(force)) {                log.debug("onAuthentication#Invalidate current session for user: {}", userDetails.getUsername());                throw e;            }            log.debug("onAuthentication#Invalidate old session for user: {}", userDetails.getUsername());            //удаляем все активные сессии пользователя, кроме текущей            sessionsManager.deleteSessionExceptCurrentByUser(userDetails.getUsername());            //отправляем уведомления администратору(тут можно узнать ip пользователя или еще какую-нибудь доп. информацию, которая необходима)            notificationService.notify(request, userDetails);        }    }}

Осталось описать удаление активных сессий, кроме текущей. Для этого в имплементации SessionsManager реализуем метод deleteSessionExceptCurrentByUser:

@Service@RequiredArgsConstructor@Slf4jpublic class SessionsManagerImpl implements SessionsManager {    private final FindByIndexNameSessionRepository sessionRepository;    @Override    public void deleteSessionExceptCurrentByUser(String username) {        log.debug("deleteSessionExceptCurrent#user: {}", username);        //Получаем session id текущего пользователя        String sessionId = RequestContextHolder.currentRequestAttributes().getSessionId();        //Удаляем все сессии кроме текущей        sessionRepository.findByPrincipalName(username)                .keySet().stream()                .filter(key -> !sessionId.equals(key))                .forEach(key -> sessionRepository.deleteById((String) key));    }}

Обработка ошибок при превышение ограничения сессий


Как можно заметить, при отсутствии параметра force(или когда он равен false) мы бросаем исключение SessionAuthenticationException из нашей стратегии. Мы бы хотели вернуть фронту не ошибку, а 300 статус(чтобы фронт знал, что нужно показать сообщение пользователю для выбора действия). Для этого реализуем перехватчик, который мы добавили в

.sessionAuthenticationFailureHandler(securityErrorHandler)

@Component@Slf4jpublic class SecurityErrorHandler extends SimpleUrlAuthenticationFailureHandler {    @Override    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,            AuthenticationException exception)            throws IOException, ServletException {        if (!exception.getClass().isAssignableFrom(SessionAuthenticationException.class)) {            super.onAuthenticationFailure(request, response, exception);        }        log.debug("onAuthenticationFailure#set multiple choices for response");        response.setStatus(HttpStatus.MULTIPLE_CHOICES.value());    }}

Заключение


Управление сессиями оказалось не такое страшное, как представлялось в начале. Spring позволяет гибко настраивать свои стратегии для этого. А с помощью перехватчика ошибок можно вернуть фронту любое сообщение и статус.

Надеюсь, что эта статья будем кому-нибудь полезна.
Подробнее..

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

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

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

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

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

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

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

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

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

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

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

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

2. Задача

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

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

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

  1. Java 8+;

  2. Apache Maven

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

5. Spring Security

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Заключение

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

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

Подробнее..

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

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

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

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


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

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


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


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

Реализация


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


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

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

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

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

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


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

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


Создадим login endpoint.


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

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


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


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

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


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


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

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


Создадим TokenAuthenticationFilter.


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

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


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


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

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


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


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

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


Настроим Spring Security.


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

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


Создадим repositories endpoint.


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

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


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


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

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


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

Исходный код


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

Ссылки



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

Категории

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

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