В прошлой статье мы познакомились с такой технологией как Keycloak. Кто не видел и хотел бы ознакомиться с ней - Keycloak интеграция со Spring boot. Если говорить вкратце, то Keycloak это технология, которая помогает реализовать безопасность для вашего приложения. Он автоматически генерирует JWT, что упрощает работу для разработчика. Но бывает так, что встроенной генерации JWT не достаточно или она не совсем подходит, тогда нужно создать свою реализацию данного токена. В данной статье мы рассмотрим как реализовать простую генерацию своего JWT.

Что будет разобрано в статье:

  1. Создание базы данных для пользователей и токенов.

  2. Написание сервиса сервисов.

  3. Написание собственного фильтра.

  4. Настройка конфига безопасности.

  5. Знакомство с обработчиками.

  6. Знакомство с DTO.

  7. Написание своих контроллеров.

  8. Тестирование в Postman.

Данная статья будет полезна начинающим специалистам, которые хотели бы разобраться со своей реализацией JWT. В этой статье также будут затронуты такие технологии, как JPA и Spring Security. С каждой мы очень подробно ознакомимся. Я постараюсь детально рассказать о всех моментах, которые будут затронуты в статье.

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

Для начала нам нужно создать наш проект. Для это нам поможет один из двух вариантов.

  1. Создать проект с помощью сайта Spring Initializer.

  2. Если есть IntelliJ IDEA Ultimate, то можно создать проект Spring прям в ней.

Добавим нужные зависимости и создадим проект:

Добавление зависимостей.
Добавление зависимостей.

Из зависимостей видно, что я буду использовать базу данных PostgreSQL. Это никак не влияет на разработку, вы можете использовать любую, которая удобна для вас, например, MySQL.

После того, как наш проект сформируется, у нас появится файл pom.xml, в котором будут прописаны все наши зависимости:

pom.xml
<?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>3.3.0</version>
		<relativePath/>
	</parent>
	<groupId>com.example</groupId>
	<artifactId>JWTAuthentication</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>JWTAuthentication</name>
	<description>JWTAuthentication</description>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.thymeleaf.extras</groupId>
			<artifactId>thymeleaf-extras-springsecurity6</artifactId>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
			<scope>test</scope>
		</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>

Чтобы в дальнейшем не отвлекаться на добавление зависимостей, давайте пропишем оставшиеся, которые нельзя выбрать при создании проекта. Для того чтобы найти нужную зависимость и актуальную версию, можно использовать сайт - https://mvnrepository.com/

		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-jackson</artifactId>
			<version>0.12.3</version>
			<scope>runtime</scope>
		</dependency>
		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-impl</artifactId>
			<version>0.12.3</version>
			<scope>runtime</scope>
		</dependency>
		<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-api</artifactId>
			<version>0.12.3</version>
		</dependency>

Эти зависимости нужны для создания и обработки JSON Web Tokens (JWT). Эти зависимости вместе обеспечивают полную функциональность для работы с JWT, включая создание, подпись, валидацию и декодирование токенов.

Чтобы использовать PostgreSQL, я воспользуюсь Docker. Для этого создадим в корневой папке нашего проекта файл docker-compose.yml и пропишем в нем загрузку образов и создание контейнеров.

docker-compose.yml
version: "3.9"

services:
  pg_db:
    image: postgres
    restart: always
    environment:
      - POSTGRES_DB=YOUR_NAME_DB
      - POSTGRES_USER=YOUR_NAME_USER
      - POSTGRES_PASSWORD=YOUR_PASSWORD
    volumes:
      - db:/var/lib/postgresql/data postgres
    ports:
      - "5435:5432"

volumes:
  db:

Не забудьте указать свои данные вместо:

YOUR_NAME_DB, YOUR_NAME_USER и YOUR_PASSWORD

Теперь пропишем настройки в наш application.yml. Данный код настраивает нашу базу данных.

application.yml
spring:
  application:
    name: JWTAuthentication
#settings of postgres
  datasource:
    url: jdbc:postgresql://localhost:5435/rent_db
    username: root
    password: root
    driverClassName: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: true

  • Настройки базы данных (PostgreSQL):

    • spring.datasource.url: Указывает URL для подключения к базе данных PostgreSQL. В данном случае, база данных находится на локальном хосте на порту 5435.

    • spring.datasource.username и spring.datasource.password: Определяют учетные данные для подключения к базе данных.

    • spring.datasource.driverClassName: Определяет класс драйвера JDBC для PostgreSQL. Это необходимо для загрузки соответствующего драйвера, чтобы Spring мог подключиться к базе данных.

  • Настройки JPA:

    • spring.jpa.hibernate.ddl-auto: Устанавливает стратегию для управления схемой базы данных. Значение update указывает Hibernate автоматически обновлять схему базы данных при изменениях в моделях данных.

    • spring.jpa.open-in-view: Указывает, что сессия Hibernate останется открытой на протяжении всего запроса веб-клиента. Это позволяет лениво загружать сущности, даже после завершения транзакции.

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

Сущности

Чтобы начать нашу работу, давайте создадим сущности, которые будут в нашей базе данных. Создадим под них отдельную папку entity. Далее создадим нашу первую сущность User, в которой будет хранится вся информация о пользователе.

entity.User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users_table")
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;
}   

Пропишем основные аннотации:

Аннотации Lombok

  1. @Data:

    • Автоматически генерирует стандартные методы, такие как getters, setters, toString, equals, и hashCode для всех полей класса. Это значительно сокращает количество шаблонного кода.

  2. @AllArgsConstructor:

    • Генерирует конструктор, который принимает все поля класса в качестве параметров. Это полезно для быстрой инициализации всех свойств объекта.

  3. @NoArgsConstructor:

    • Генерирует конструктор без параметров. Это полезно для создания объектов через фреймворки, которые требуют пустой конструктор, например, JPA.

Аннотации JPA

  1. @Entity:

    • Указывает, что класс является сущностью JPA и должен быть сопоставлен с таблицей в базе данных.

  2. @Table(name = "users_table"):

    • Определяет имя таблицы в базе данных, с которой будет связана сущность. В данном случае, таблица называется users_table.

После того как мы прописали аннотацию @Entity нам нужно прописать поле id. Это поле является обязательным при создании сущности JPA, которое отвечает за первичный ключ. Аннотация @Id как раз таки указывает на это поле.

@GeneratedValue(strategy = GenerationType.IDENTITY)
Указывает стратегию генерации значений для поля id. GenerationType.IDENTITY позволяет базе данных автоматически генерировать уникальные значения, обычно с использованием автоинкремента.

@Column(name = "column_name")
Указывает имя колонки в таблице, с которой связано поле. Это позволяет явно указать, какое имя колонки в базе данных соответствует полю класса. Эту аннотацию можно не прописывать, но я ее прописал, во избежании ошибок.

Далее пропишем поля для нашего пользователя. Эти поля будут хранить информацию о нем. Я пропишу основные: username, email, password.

entity.User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users_table")
public class User{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "username")
    private String username;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;
}   

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

Вот основные компоненты, которые предоставляет интерфейс UserDetails:

  1. getAuthorities:

    • Этот метод возвращает коллекцию объектов GrantedAuthority, которые представляют права доступа пользователя. Это могут быть роли или конкретные разрешения, которые определяют, что пользователь может делать в системе.

  2. getPassword и getUsername:

    • Эти методы возвращают пароль и имя пользователя соответственно. Они используются системой безопасности для аутентификации пользователя. В нашем случае данные методы реализуются аннотацией @Data.

  3. isAccountNonExpired:

    • Возвращает true, если учетная запись пользователя не истекла. Если учетная запись истекла, пользователь не может аутентифицироваться.

  4. isAccountNonLocked:

    • Возвращает true, если учетная запись пользователя не заблокирована. Заблокированные учетные записи не могут аутентифицироваться.

  5. isCredentialsNonExpired:

    • Возвращает true, если учетные данные (например, пароль) пользователя не истекли.

  6. isEnabled:

    • Возвращает true, если учетная запись пользователя включена. Отключенные учетные записи не могут аутентифицироваться.

Реализуя UserDetails, класс User становится совместимым с механизмами аутентификации Spring Security. Это позволяет использовать экземпляры класса User для проверки подлинности и авторизации в приложении, основанном на Spring Security. Таким образом, данный интерфейс связывает пользовательские данные с системой безопасности, обеспечивая их корректную обработку во время входа в систему и доступа к защищенным ресурсам.

entity.User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users_table")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "username")
    private String username;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Далее создадим, класс enum Role, в котором укажем роли (USER, ADMIN). Чаще всего данный класс создают, как отдельную таблицу, после чего связывают ее с таблицей User в отношении Many to Many, но в данной статье я откажусь от такой реализации, чтобы сократить и упростить статью.

entity.Role.java
public enum Role {

    USER,
    ADMIN
      
}

Теперь пропишем еще одно поле в User, которое будет хранить роль и переопределим метод getAuthorities().

entity.User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users_table")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "username")
    private String username;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority((role.name())));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Создадим сущность, которая будет хранить в себе access токен, refresh токен (про токены можно прочитать тут) и состояние выхода loggedOut. Также эту таблицу нужно связать с таблицей User в отношении Many to One. прочитать подробнее про отношения в SQL можно тут.

entity.Token.java
@Entity
@Table(name = "token_table")
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class Token {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "access_token")
    private String accessToken;

    @Column(name = "refresh_token")
    private String refreshToken;

    @Column(name = "is_logged_out")
    private boolean loggedOut;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
}

entity.User.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users_table")
public class User implements UserDetails {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "username")
    private String username;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;

    @OneToMany(mappedBy = "user")
    private List<Token> tokens;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority((role.name())));
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

  1. @ManyToOne:

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

    • Например, у каждого токена есть только один владелец (пользователь), но один пользователь может иметь много токенов, это будет отношением "многие токены к одному пользователю".

  2. @JoinColumn(name = "user_id"):

    • Эта аннотация используется для указания колонки, которая будет использоваться в качестве внешнего ключа для связи с другой таблицей. В данном случае, user_id — это имя колонки в таблице, которая ссылается на первичный ключ (поле id) таблицы User.

    • @JoinColumn определяет, какая именно колонка в текущей таблице будет содержать идентификатор записи из связанной таблицы (User).

  3. @OneToMany(mappedBy = "user"):

    • Определяет отношение "один ко многим" между сущностями. В данном случае, это означает, что один пользователь может иметь много токенов.

На этом создание сущностей закончено, давайте перейдем к созданию репозиториев для них.

Репозитории

Создадим новую папку repository, которая будет хранить в себе все наши репозитории. Репозитории для JPA сущностей в Spring служат для упрощения и абстракции операций с базой данных. Они предоставляют удобный интерфейс для выполнения различных операций с данными, таких как создание, чтение, обновление и удаление (CRUD), при этом минимизируя количество шаблонного кода, который разработчикам приходится писать.

Создадим интерфейс UserRepository. Данный интерфейс должен наследоваться от JpaRepository<>. В дженериках нужно указать <User, Long>. 1 - это класс для которого мы создаем репозиторий, в нашем случае User, 2 - это тип данных, под которым хранится наше поле id, у нас это Long.

repository.UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
  
}

Добавим в наш репозиторий 2 метода. Первый метод findByUsername() нужен для того, чтобы искать в нашей базе данных пользователя по username, второй метод findByEmail() нужен для того, чтобы искать в нашей базе данных пользователя по email.

repository.UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    Optional<User> findByEmail(String email);
}

Теперь создадим репозиторий для Token. И добавим 2 метода. Первый метод findByAccessToken() нужен, чтобы осуществлять поиск по access токену, второй метод findByRefreshToken()- по refresh токену.

repository.TokenRepository.java
public interface TokenRepository extends JpaRepository<Token, Long> {

    Optional<Token> findByAccessToken(String accessToken);

    Optional<Token> findByRefreshToken(String refreshToken);
}

Далее добавим метод findAllAccessTokenByUser() и с помощью аннотации @Querry пропишем запрос к нашей базе данных.

repository.TokenRepository.java
public interface TokenRepository extends JpaRepository<Token, Long> {

    @Query("""
            SELECT t FROM Token t inner join User u
            on t.user.id = u.id
            where t.user.id = :userId and t.loggedOut = false
            """)

    List<Token> findAllAccessTokenByUser(Long userId);

    Optional<Token> findByAccessToken(String accessToken);

    Optional<Token> findByRefreshToken(String refreshToken);
}

Запрос извлекает объекты Token, которые связаны с пользователем через внутреннее соединение, где идентификатор пользователя соответствует заданному параметру userId, и которые не отмечены как вышедшие из системы (loggedOut = false). Этот запрос нужен для получения всех активных токенов конкретного пользователя.

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

База данных
База данных

Как видно, все создалось верно. Перейдем к созданию сервисов.

Сервисы

Сервисы в Spring проектах играют важную роль в архитектуре приложения, разделяя бизнес-логику от других уровней, таких как представление (контроллеры) и доступ к данным (репозитории). В настоящих проектах, да и в целом для чистоты кода, стоит использовать разделение сервисов на интерфейсы и реализации, что соблюдает принципы SOLID. Данный подход делает систему более гибкой, тестируемой и расширяемой, что важно для долгосрочной поддержки и развития приложения. Я же продемонстрирую данный подход только на одном сервисе UserService, другие сервисы я реализую без этого подхода, чтобы сократить код и статью.

Создадим отдельную папку service для сервисов и создадим в ней интерфейс UserService. Далее нам нужно унаследовать UserDeteilsService — это интерфейс в Spring Security, который служит для загрузки данных о пользователе в процессе аутентификации. Он играет ключевую роль в механизме аутентификации, предоставляя информацию о пользователе, необходимую для проверки подлинности его учетных данных. Он предоставляет важный метод loadUserByUsername(). Основная цель этого метода - найти и вернуть информацию о пользователе по его имени пользователя (username). Это критически важно для аутентификации, так как система должна проверить, существует ли пользователь с заданными учетными данными.

service.UserService.java
public interface UserService extends UserDetailsService {

    boolean existsByUsername(String username);
    boolean existsByEmail(String email);

}

После создания интерфейса, нам нужно создать класс UserServiceImpl, где мы реализуем все методы.

UserServiceImpl
@Service
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    public UserServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // Поиск пользователя в репозитории
        return userRepository.findByUsername(username)
                // Если пользователь не найден, выбрасываем исключение
                .orElseThrow(() -> new UsernameNotFoundException("Пользователь с именем " + username + " не найден"));
    }

    @Override
    public boolean existsByUsername(String username) {
        User user = userRepository.findByUsername(username).orElse(null);
        if (user != null) {
            return true;
        }
        return false;
    }

    @Override
    public boolean existsByEmail(String email) {
        User user = userRepository.findByEmail(email).orElse(null);
        if (user != null) {
            return true;
        }
        return false;
    }
}

Данный класс пометим аннотацией @Service, она нужна, чтобы Spring понимал, какой это bean. Сервис для управления пользователями готов. С помощью данного сервиса мы можем узнать, существует ли в нашей базе данных пользователь под определенным username или email.

Далее я создам сервис для нашего JWT. Данный сервис будет генерировать access и refresh токены, также проверять их валидность и доставать из токена нужную информацию. Для начала давайте укажем секретный ключ, его вы можете сгенерировать на этом сайте. Пропишем этот ключ в application.yml, а также укажем время истечения срока для access и refresh токенов.

security:
  jwt:
    secret_key: 4eeab38d706831be4b36612ead768ef8182d1dd6f0e14e5dc934652e297fb16a
    access_token_expiration: 36000000 # 10 hours
    refresh_token_expiration: 252000000 # 7 days
application.yml
spring:
  application:
    name: JWTAuthentication
#settings of postgres
  datasource:
    url: jdbc:postgresql://localhost:5435/rent_db
    username: root
    password: root
    driverClassName: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: update
    open-in-view: true
#Security
security:
  jwt:
    secret_key: 4eeab38d706831be4b36612ead768ef8182d1dd6f0e14e5dc934652e297fb16a
    access_token_expiration: 36000000 # 10 hours
    refresh_token_expiration: 252000000 # 7 days

Создадим класс JwtService и пометим его аннотацией @Service. После чего пропишем 4 поля. 1 поле будет хранить в себе наш секретный ключ, 2 - время истечения для access токена, 3 - время истечения для refresh токена и 4 поле будет наш репозиторий TokenRepository. Также создадим конструктор.

service.JwtService.java
@Service
public class JwtService {

    @Value("${security.jwt.secret_key}")
    private String secretKey;

    @Value("${security.jwt.access_token_expiration}")
    private long accessTokenExpiration;

    @Value("${security.jwt.refresh_token_expiration}")
    private long refreshTokenExpiration;
    
    private final TokenRepository tokenRepository;

    public JwtService(TokenRepository tokenRepository) {
        this.tokenRepository = tokenRepository;
    }

С помощью аннотации @Value мы можем обратиться к значению из нашего application.yml, указав полный путь. Далее создадим наш первый метод getSgningKey(), который возвращает ключ подписи для JWT.

private SecretKey getSgningKey() {
        
        byte[] keyBytes = Decoders.BASE64URL.decode(secretKey);
  
        return Keys.hmacShaKeyFor(keyBytes);
    }

Decoders.BASE64URL.decode(secretKey); — декодируем секретный ключ, из формата Base64URL в массив байтов.

Keys.hmacShaKeyFor(keyBytes); — создание секретного ключа (типа SecretKey) на основе декодированного массива байтов. Этот ключ будет использован в HMAC (Hash-based Message Authentication Code) алгоритме для подписи данных.


Далее пропишем общий метод generateToken() для генерации наших токенов, чтобы не дублировать код.

private String generateToken(User user, long expiryTime) {
        JwtBuilder builder = Jwts.builder()
                .subject(user.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiryTime))
                .signWith(getSgningKey());

        return builder.compact();
    }

JwtBuilder builder = Jwts.builder();: инициализируем объект JwtBuilder, который используется для построения JWT.

.subject(user.getUsername());: устанавливаем субъект (username) токена. Это уникальная информация, идентифицирующая пользователя.

.issuedAt(new Date(System.currentTimeMillis()));: устанавливаем время создания токена на текущий момент. Это поле помогает определить, когда токен был создан.

.expiration(new Date(System.currentTimeMillis() + expiryTime));: устанавливаем время истечения токена, добавляя expiryTime миллисекунд к текущему времени. Это поле указывает, до какого момента токен считается действительным.

.signWith(getSgningKey());: подписываем токен с использованием секретного ключа, полученного из метода getSigningKey().

builder.compact();: метод compact() создает и возвращает токен в виде строки, которая является компактным, URL-безопасным представлением JWT.


Реализуем 2 метода. Один метод будет для генерации access токена, второй - refresh токена. В зависимости от токена мы будем передавать свое время истечения.

public String generateAccessToken(User user) {
       
        return generateToken(user, accessTokenExpiration);
    }
 public String generateRefreshToken(User user) {

        return generateToken(user, refreshTokenExpiration);
    }

После чего создадим метод extractAllClaims(), для получения всех данных из токена.

private Claims extractAllClaims(String token) {
  
        JwtParserBuilder parser = Jwts.parser();
        parser.verifyWith(getSgningKey());

        return parser.build()
                .parseSignedClaims(token)
                .getPayload();
    }

JwtParserBuilder parser = Jwts.parser();: инициализируем объект JwtParserBuilder, который будет использоваться для разбора и проверки JWT.

parser.verifyWith(getSgningKey());: проверяем подписи токена с использованием секретного ключа, полученного из метода getSgningKey(). Это обеспечивает, что токен, подписанный корректным ключом, будет считаться действительным.

parser.build(): завершаем настройку парсера, создавая готовый объект для разбора.

.parseSignedClaims(token): разбираем переданный токен, проверяя его подпись и извлекая данные. Если подпись не действительно или токен имеет повреждения, то этот вызов выбрасывает исключение.

.getPayload(): извлекаем payload токена, который содержит все данные. Payload — это часть токена, где хранится информация о пользователе.


Теперь займемся методом extractClaim, он будет использовать extractAllClaims для получения всех утверждений из токена. extractClaim более универсальным и позволяет извлекать различные части данных из JWT в зависимости от потребностей.

public <T> T extractClaim(String token, Function<Claims, T> resolver) {
  
        Claims claims = extractAllClaims(token);
  
        return resolver.apply(claims);
    }

Далее напишем метод extractUsername(), который будет доставать из токена только username. Данный метод нам потребуется, чтобы проверить, существует ли такой пользователь в нашей базе данных.

public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

Создадим метод extractExpiration(), который будет извлекать дату истечения токена.

private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

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

private boolean isAccessTokenExpired(String token) {
        return !extractExpiration(token).before(new Date());
    }

Теперь создадим метод isValid() для проверки валидности нашего access токена.

public boolean isValid(String token, UserDetails user) {
  
        String username = extractUsername(token);

        boolean isValidToken = tokenRepository.findByAccessToken(token)
                .map(t -> !t.isLoggedOut()).orElse(false);

        return username.equals(user.getUsername())
                && isAccessTokenExpired(token)
                && isValidToken;
    }

String username = extractUsername(token);- извлекаем username из токена.

boolean isValidToken = tokenRepository.findByAccessToken(token).map(t -> !t.isLoggedOut()).orElse(false);: проверяем, существует ли токен в репозитории и не был ли он отмечен как "вышедший".

username.equals(user.getUsername()) && isAccessTokenExpired(token) && isValidToken;: возвращает true, если выполняются все следующие условия:

  • username.equals(user.getUsername()): username из токена совпадает с username из UserDetails.

  • isAccessTokenExpired(token): токен не истек.

  • isValidToken: токен существует в репозитории и не помечен как "вышедший".


После того, как мы написали метод для проверки действительности access токена, создадим метод isValidRefresh() для проверки refresh токена.

public boolean isValidRefresh(String token, User user) {
  
        String username = extractUsername(token);
  
        boolean isValidRefreshToken = tokenRepository.findByRefreshToken(token)
                .map(t -> !t.isLoggedOut()).orElse(false);

        return username.equals(user.getUsername())
                && isAccessTokenExpired(token)
                && isValidRefreshToken;
    }

String username = extractUsername(token);: извлекаем username из токена.

boolean isValidRefreshToken = tokenRepository.findByRefreshToken(token).map(t -> !t.isLoggedOut()).orElse(false);: проверяем, существует ли токен обновления в репозитории и не был ли он отмечен как "вышедший".

username.equals(user.getUsername()) && isAccessTokenExpired(token) && isValidRefreshToken;: возвращает true, если выполняются следующие условия:

  • username.equals(user.getUsername()): username из токена совпадает с username из объекта User.

  • isAccessTokenExpired(token): токен не истек.

  • isValidRefreshToken: токен существует в репозитории и не помечен как "вышедший".


На этом наш сервис для JWT готов. Давайте посмотрим на полный код класса.

service.JwtService.java
@Service
public class JwtService {

    @Value("${security.jwt.secret_key}")
    private String secretKey;

    @Value("${security.jwt.access_token_expiration}")
    private long accessTokenExpiration;

    @Value("${security.jwt.refresh_token_expiration}")
    private long refreshTokenExpiration;

    private final TokenRepository tokenRepository;

    public JwtService(TokenRepository tokenRepository) {
        this.tokenRepository = tokenRepository;
    }


    public boolean isValid(String token, UserDetails user) {
      
        String username = extractUsername(token);

        boolean isValidToken = tokenRepository.findByAccessToken(token)
                .map(t -> !t.isLoggedOut()).orElse(false);

        return username.equals(user.getUsername())
                && isAccessTokenExpired(token)
                && isValidToken;
    }

  
    public boolean isValidRefresh(String token, User user) {
      
        String username = extractUsername(token);

        boolean isValidRefreshToken = tokenRepository.findByRefreshToken(token)
                .map(t -> !t.isLoggedOut()).orElse(false);

        return username.equals(user.getUsername())
                && isAccessTokenExpired(token)
                && isValidRefreshToken;
    }

  
    private boolean isAccessTokenExpired(String token) {
        return !extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }


    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }


    public <T> T extractClaim(String token, Function<Claims, T> resolver) {
        Claims claims = extractAllClaims(token);
        return resolver.apply(claims);
    }


    private Claims extractAllClaims(String token) {

        JwtParserBuilder parser = Jwts.parser();

        parser.verifyWith(getSgningKey());

        return parser.build()
                .parseSignedClaims(token)
                .getPayload();
    }


    public String generateAccessToken(User user) {

        return generateToken(user, accessTokenExpiration);
    }


    public String generateRefreshToken(User user) {

        return generateToken(user, refreshTokenExpiration);
    }


    private String generateToken(User user, long expiryTime) {
        JwtBuilder builder = Jwts.builder()
                .subject(user.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiryTime))
                .signWith(getSgningKey());

        return builder.compact();
    }


    private SecretKey getSgningKey() {

        byte[] keyBytes = Decoders.BASE64URL.decode(secretKey);

        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Фильтр

Теперь, когда мы реализовали данные сервисы, мы можем приступить к созданию своего фильтра. Для этого создадим папку filter и в ней класс JwtFilter. В этом филтре мы будем использовать два сервиса, ранее нами созданными - JwtService и UserService.

filter.JwtFilter.java
@Component
public class JwtFilter{

    private final JwtService jwtService;

    private final UserService userService;

    public JwtFilter(JwtService jwtService, UserService userService) {
        this.jwtService = jwtService;
        this.userService = userService;
    }
  }

Далее наследуемся от класса OncePerRequestFilter. Наследования от OncePerRequestFilter обеспечивает то, что фильтр выполняется только один раз за каждый HTTP-запрос. Он предоставляет метод doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException, который мы переопределяем под нужную нам реализацию фильтра.

filter.JwtFilter.java
@Component
public class JwtFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    private final UserService userService;

    public JwtFilter(JwtService jwtService, UserService userService) {
        this.jwtService = jwtService;
        this.userService = userService;
    }

  
    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if(authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
      
        String token = authHeader.substring(7);

        String username = jwtService.extractUsername(token);
      
        if(username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.loadUserByUsername(username);

            if(jwtService.isValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );

                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );

                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);

    }
}

String authHeader = request.getHeader("Authorization");: извлекаем значение заголовка Authorization из HTTP-запроса.

if(authHeader == null || !authHeader.startsWith("Bearer ")): проверяем, существует ли заголовок авторизации и начинается ли он с "Bearer ". Если условие не выполняется, фильтр просто передает управление следующему элементу в цепочке фильтров без выполнения дальнейших действий.

String token = authHeader.substring(7);: извлекаем сам токен, удаляя префикс "Bearer " из заголовка.

String username = jwtService.extractUsername(token);: извлекаем username, закодированное в токене, используя наш сервис jwtService.

if(username != null && SecurityContextHolder.getContext().getAuthentication() == null): проверяем, что пользователь еще не аутентифицирован в текущем контексте безопасности.

UserDetails userDetails = userService.loadUserByUsername(username);: загружаем данные пользователя из сервиса userService.

if(jwtService.isValid(token, userDetails)): проверяем, что токен действителен.

UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());: создаем объект, который содержит информацию о пользователе и его полномочия.

authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));: добавляем дополнительные детали из текущего запроса к объекту аутентификации.

SecurityContextHolder.getContext().setAuthentication(authToken);: устанавливаем созданный аутентификационный токен в текущий контекст безопасности, тем самым аутентифицируя пользователя в системе.

filterChain.doFilter(request, response);: передаем управление следующему фильтру в цепочке.


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

Обработчики

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

Для данного класса мы должны имплементировать интерфейс LogoutHandler, который предоставляет один метод logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) , который мы должны реализовать. Этот метод используется для обработки ситуаций, когда пользователь завершает сессию (выходит из профиля).

handler.CustomLogoutHandler.java
@Component
public class CustomLogoutHandler implements LogoutHandler {

    private final TokenRepository tokenRepository;

    public CustomLogoutHandler(TokenRepository tokenRepository) {
        this.tokenRepository = tokenRepository;
    }

    @Override
    public void logout(HttpServletRequest request,
                       HttpServletResponse response,
                       Authentication authentication) {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return;
        }

        String token = authHeader.substring(7);

        Token tokenEntity = tokenRepository.findByAccessToken(token).orElse(null);

        if (tokenEntity != null) {
            tokenEntity.setLoggedOut(true);
            tokenRepository.save(tokenEntity);
        }
    }
}

String authHeader = request.getHeader("Authorization");: извлекаем заголовок Authorization из HTTP-запроса.

if (authHeader == null || !authHeader.startsWith("Bearer ")): проверяем, существует ли заголовок авторизации и начинается ли он с "Bearer ". Если условие не выполняется, фильтр просто передает управление следующему элементу в цепочке фильтров без выполнения дальнейших действий.

String token = authHeader.substring(7);: извлекаем сам токен, удаляя префикс "Bearer " из заголовка.

Token tokenEntity = tokenRepository.findByAccessToken(token).orElse(null);: ищем сущность токена в репозитории.

if (tokenEntity != null): проверяем, был ли токен найден в репозитории.

tokenEntity.setLoggedOut(true);: устанавливаем флаг loggedOut для сущности токена в значение true.

tokenRepository.save(tokenEntity);: сохраняем обновленное состояние токена обратно в репозиторий.


Обработчик выхода закончен. Теперь сделаем обработчик доступа. Создадим класс CustomAccessDeniedHandler. Для данного класса мы должны имплементировать интерфейс AccessDeniedHandler, который предоставляет один метод handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException, который мы должны реализовать. Этот метод используется для обработки ситуаций, когда пользователь пытается получить доступ к ресурсу, к которому у него нет прав.

handler.CustomAccessDeniedHandler.java
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException, ServletException {

        response.setStatus(403);
    }
}

Данный обработчик будет отправлять стус 403. Этот статус означает "Forbidden" (Запрещено), что указывает клиенту, что сервер понимает запрос, но отказывается его выполнять из-за недостаточных прав у пользователя.

На этом написание наших обработчиков закончено, приступим к настройке SecurityConfig.

Конфиг

Создадим под него отдельную папку config, и создадим в нем класс SecurityConfig. Данный класс является основным для настройки нашей безопасности. В нем можно настроить доступ к конечным точкам, нужные нам фильтры, сессии и др. Данный класс нужно пометить двумя важными аннотациями @Configuration и @EnableWebSecurity. Первая аннотация будет указывать Spring о том, что данный класс является определенным компонентом, а вторая аннотация используется для активации веб-безопасности в нашем приложении. Также пропишем поля, которые будем использовать в нашем методе filterChain() и создадим для них конструктор.

config.SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtFilter jwtFIlter;

    private final UserService userService;

    private final CustomAccessDeniedHandler accessDeniedHandler;

    private final CustomLogoutHandler customLogoutHandler;

    public SecurityConfig(JwtFilter jwtFIlter,
                          UserService userService,
                          CustomAccessDeniedHandler accessDeniedHandler, 
                          CustomLogoutHandler customLogoutHandler) {
      
        this.jwtFIlter = jwtFIlter;
        this.userService = userService;
        this.accessDeniedHandler = accessDeniedHandler;
        this.customLogoutHandler = customLogoutHandler;
    }
}

Далее нам нужно реализовать важный метод filterChain(), который создает и настраивает цепочку фильтров безопасности для HTTP.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf(AbstractHttpConfigurer::disable);

        http.authorizeHttpRequests(auth -> {
          auth.requestMatchers("/login/**","/registration/**", "/css/**", "/refresh_token/**", "/")
            .permitAll();
          auth.requestMatchers("/admin/**").hasAuthority("ADMIN");
          auth.anyRequest().authenticated();
        })
          .userDetailsService(userService)
          .exceptionHandling(e -> {
            e.accessDeniedHandler(accessDeniedHandler);
            e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
          })
          .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
          .addFilterBefore(jwtFIlter, UsernamePasswordAuthenticationFilter.class)
          .logout(log -> {
            log.logoutUrl("/logout");
            log.addLogoutHandler(customLogoutHandler);
            log.logoutSuccessHandler((request, response, authentication) ->
                                     SecurityContextHolder.clearContext());
          });
      
      return http.build();
    }

http.csrf(AbstractHttpConfigurer::disable);: Отключаем механизм защиты от межсайтовых подделок запросов (CSRF). CSRF-защита не является необходимым, так как мы используем токен-ориентированною аутентификацию.

auth.requestMatchers("/login/**","/registration/**", "/css/**", "/refresh_token/**", "/").permitAll();: Все запросы к перечисленным URL разрешены без аутентификации.

auth.requestMatchers("/admin/**").hasAuthority("ADMIN");: Доступ к URL, начинающимся с /admin/, разрешен только с ролью "ADMIN".

auth.anyRequest().authenticated();: Все остальные запросы требуют аутентификации.

.userDetailsService(userService): Указываем, что для проверки пользователей будет использоваться наш userService.

e.accessDeniedHandler(accessDeniedHandler);: Устанавливаем наш обработчик, который будет вызываться при отказе в доступе.

e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));: Указываем, что при попытке запроса неаутентифицированного пользователя будет возвращаться HTTP статус 401 Unauthorized.

.sessionManagement(session -> session.sessionCreationPolicy(STATELESS)): Настраиваем управления сессиями как STATELESS, что означает отсутствие сохранения сессий на сервере.

.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class): Добавляем наш фильтр в цепочку безопасности перед фильтром аутентификации по логину и паролю.

log.logoutUrl("/logout");: Устанавливаем URL, который будем использовать для выхода пользователя из системы.

log.addLogoutHandler(customLogoutHandler);: Добавляем наш обработчик выхода.

log.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext());: Очищаем контекст безопасности после успешного выхода.


После того, как мы реализовали наш метод filterChain()добавим еще 2 метода passwordEncoder() и authenticationManager(). Первый метод будет отвечать за шифрование паролей, тем самым обеспечиваю безопасность, что пароль не рассекретят, второй - используется для обработки процесса аутентификации, проверяя, соответствует ли предоставленный логин и пароль данным в системе.

@Bean
public PasswordEncoder passwordEncoder() {
  
  return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
  
  return config.getAuthenticationManager();
}

BCryptPasswordEncoder использует BCrypt алгоритм для хеширования паролей. Он является устойчивым к атакам, таким как brute force и rainbow table.

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

config.SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtFilter jwtFIlter;

    private final UserService userService;

    private final CustomAccessDeniedHandler accessDeniedHandler;

    private final CustomLogoutHandler customLogoutHandler;

    public SecurityConfig(JwtFilter jwtFIlter,
                          UserService userService,
                          CustomAccessDeniedHandler accessDeniedHandler, 
                          CustomLogoutHandler customLogoutHandler) {
      
        this.jwtFIlter = jwtFIlter;
        this.userService = userService;
        this.accessDeniedHandler = accessDeniedHandler;
        this.customLogoutHandler = customLogoutHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.csrf(AbstractHttpConfigurer::disable);

        http.authorizeHttpRequests(auth -> {
          auth.requestMatchers("/login/**","/registration/**", "/css/**", "/refresh_token/**", "/")
            .permitAll();
          auth.requestMatchers("/admin/**").hasAuthority("ADMIN");
          auth.anyRequest().authenticated();
        })
          .userDetailsService(userService)
          .exceptionHandling(e -> {
            e.accessDeniedHandler(accessDeniedHandler);
            e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
          })
          .sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
          .addFilterBefore(jwtFIlter, UsernamePasswordAuthenticationFilter.class)
          .logout(log -> {
            log.logoutUrl("/logout");
            log.addLogoutHandler(customLogoutHandler);
            log.logoutSuccessHandler((request, response, authentication) ->
                                     SecurityContextHolder.clearContext());
          });
      
      return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
  
      return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
  
      return config.getAuthenticationManager();
    }
  
}

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

DTO (Response, request)

Чтобы начать писать сервис аутентификации, нужно создать классы отвечающие за response и request. Они являются разновидностью DTO. Это объект, который используется для передачи данных между различными частями приложения или между приложением и внешними системами. Для начала создадим класс AuthenticationResponseDto, который будет хранить в себе ответ с токенами. Данный класс предназначен для представления ответа, который сервер отправляет клиенту после успешной аутентификации. Под данные классы мы создадим отдельную папку dto.

dto.AuthenticationResponse.java
@Getter
public class AuthenticationResponseDto {

    private final String accessToken;

    private final String refreshToken;


    public AuthenticationResponseDto(String token, String refreshToken) {
        this.accessToken = token;
        this.refreshToken = refreshToken;
    }

}

Теперь реализуем request dto для регистрации и авторизации. При регистрации пользователь будет вводить username, email и password, именно эти поля и нужно прописать в нашем request dto.

dto.RegistrationRequestDto.java
@Data
public class RegistrationRequestDto {

    private String username;
    private String email;
    private String password;
}

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

Теперь пропишем такой же request dto для авторизации. В нем нам потребуется только username и password.

dto.LoginRequestDto.java
@Data
public class LoginRequestDto {

    private String username;
    private String password;
}

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

Сервис аутентификации

Теперь создадим в нашей папке service класс AuthenticationService. Пометим его аннотацией @Service и пропишем поля.

service.AuthenticationService.java
@Service
public class AuthenticationService {

    private final UserRepository userRepository;

    private final JwtService jwtService;

    private final PasswordEncoder passwordEncoder;

    private final AuthenticationManager authenticationManager;

    private final TokenRepository tokenRepository;


    public AuthenticationService(UserRepository userRepository,
                                 JwtService jwtService,
                                 PasswordEncoder passwordEncoder,
                                 AuthenticationManager authenticationManager,
                                 TokenRepository tokenRepository) {
        this.userRepository = userRepository;
        this.jwtService = jwtService;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
        this.tokenRepository = tokenRepository;
    }
  }

Приступим к написанию методов, начнем с самого простого - метод регистрации register(). Данный метод будет регистрировать пользователя, то есть сохранять его данные в базу данных.

public void register(RegistrationDto request) {

        User user = new User();

        user.setUsername(request.getUsername());
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setRole(Role.USER);

        user = userRepository.save(user);
    }

User user = new User();: Создаем новый экземпляр класса User, который будет представлять нового пользователя в системе.

user.setUsername(request.getUsername());: Устанавливаем username, полученный из объекта RegistrationDto, который мы прописали выше.

user.setEmail(request.getEmail());: Устанавливает email пользователя, также извлеченный из нашего RegistrationDto.

user.setPassword(passwordEncoder.encode(request.getPassword()));: Мы берем пароль из RegistrationDto и шифруем его с помощью passwordEncoder, затем устанавливаем уже зашифрованный пароль для нашего пользователя.

user.setRole(Role.USER);: Устанавливаем роль пользователя как USER.

user = userRepository.save(user);: Сохраняем нашего пользователя с его данными в базе данных. В результате, пользователь становится зарегистрированным в системе.


После того, как мы реализовали метод регистрации, пропишем метод авторизации authenticate(). Для начала стоит создать методы revokeAllToken()и saveUserToken().Первый будет аннулировать все действительные токены, а второй сохранять наши токены для нашего пользователя.

private void revokeAllToken(User user) {

  List<Token> validTokens = tokenRepository.findAllAccessTokenByUser(user.getId());

  if(!validTokens.isEmpty()){
    validTokens.forEach(t ->{
      t.setLoggedOut(true);
    });
  }
  
  tokenRepository.saveAll(validTokens);
}

List<Token> validTokens = tokenRepository.findAllAccessTokenByUser(user.getId());: Получаем все активные access токены, которые принадлежат данному пользователю.

if(!validTokens.isEmpty()): Проверяем, имеются ли активные токены в списке.

validTokens.forEach(t ->{ t.setLoggedOut(true); });: Для каждого токена в списке устанавливаем в loggedOut - true, который помечает токен как вышедший.

tokenRepository.saveAll(validTokens);: Все измененные токены сохраняем обратно в базу данных.


private void saveUserToken(String accessToken, String refreshToken, User user) {
  
  Token token = new Token();

  token.setAccessToken(accessToken);
  token.setRefreshToken(refreshToken);
  token.setLoggedOut(false);
  token.setUser(user);
  
  tokenRepository.save(token);
}

Token token = new Token();: Создаем новый экземпляр класса Token, который будет содержать информацию о токенах для конкретного пользователя.

token.setAccessToken(accessToken);: Устанавливаем сгенерированный access токен.

token.setRefreshToken(refreshToken);: Устанавливаем сгенерированный refresh токен.

token.setLoggedOut(false);: Устанавливаем в loggedOut - false, что указывает на то, что токен активен.

token.setUser(user);: Связываем токены с определенным пользователем.

tokenRepository.save(token);: Сохраняем наш объект Token в базе данных.


Теперь мы можем приступить к реализации метода authenticate().

public AuthenticationResponseDto authenticate(LoginRequestDto request) {
  
    authenticationManager.authenticate(
      new UsernamePasswordAuthenticationToken(
        request.getUsername(),
        request.getPassword()
      )
    );

    User user = userRepository.findByUsername(request.getUsername())
      .orElseThrow();

    String accessToken = jwtService.generateAccessToken(user);
    String refreshToken = jwtService.generateRefreshToken(user);

    revokeAllToken(user);

    saveUserToken(accessToken, refreshToken, user);

    return new AuthenticationResponseDto(accessToken, refreshToken);
}

authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));: Используем authenticationManager для проверки учетных данных пользователя. Он создает объект UsernamePasswordAuthenticationToken с именем пользователя и паролем, извлеченными из нашего LoginRequestDto. Если аутентификация не удается, будет выброшено исключение.

User user = userRepository.findByUsername(request.getUsername()).orElseThrow();: Находим пользователя в базе данных по username. Если пользователь не найден, выбрасывается исключение.

String accessToken = jwtService.generateAccessToken(user);: Генерируем access токен для пользователя.

String refreshToken = jwtService.generateRefreshToken(user);: Генерируем refresh токен для пользователя.

revokeAllToken(user);: Обнуляем все существующие токены пользователя, чтобы предотвратить использование старых токенов после аутентификации.

saveUserToken(accessToken, refreshToken, user);: Сохраняем сгенерированные токены для пользователя.

return new AuthenticationResponseDto(accessToken, refreshToken);: Возвращаем объект AuthenticationResponseDto, содержащий access токен и refresh токен, которые будут переданы клиенту.


Приступим к реализации завершающего метода в данном сервисе - refreshToken(). Данный метод будет обновлять наш access токен. Это нужно для поддержания пользовательской сессии активной после истечения срока действия access токена.

public ResponseEntity<AuthenticationResponseDto> refreshToken(
            HttpServletRequest request,
            HttpServletResponse response) {

  String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

  if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
  }
  
  String token = authorizationHeader.substring(7);
  String username = jwtService.extractUsername(token);

  User user = userRepository.findByUsername(username)
    .orElseThrow(() -> new UsernameNotFoundException("No user found"));

  if (jwtService.isValidRefresh(token, user)) {
    
    String accessToken = jwtService.generateAccessToken(user);
    String refreshToken = jwtService.generateRefreshToken(user);

    revokeAllToken(user);

    saveUserToken(accessToken, refreshToken, user);

    return new ResponseEntity<>(new AuthenticationResponseDto(accessToken, refreshToken), HttpStatus.OK);
    
  }

  return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);: Извлекаем заголовок авторизации из запроса.

if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")): Проверяем, что заголовок авторизации присутствует и начинается с "Bearer ".

String token = authorizationHeader.substring(7);: Извлекаем токен, удаляя префикс "Bearer ".

String username = jwtService.extractUsername(token);: Извлекаем username из токена.

User user = userRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("No user found"));: Находим пользователя по username. Если такой пользователь не найден, выбрасываем ошибку - UsernameNotFoundException.

if (jwtService.isValidRefresh(token, user)): Проверяем, является ли refresh токен действительным для данного пользователя.

String accessToken = jwtService.generateAccessToken(user);: Генерируем новый токен доступа.

String refreshToken = jwtService.generateRefreshToken(user);: Генерируем новый токен обновления.

revokeAllToken(user);: Аннулируем все старые токены пользователя.

saveUserToken(accessToken, refreshToken, user);: Сохраняем новые токены.

return new ResponseEntity<>(new AuthenticationResponseDto(accessToken, refreshToken), HttpStatus.OK);: Возвращаем новый AuthenticationResponseDto с токенами и статусом 200 OK.

Если токен обновления недействителен, возвращаем 401 Unauthorized.


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

Контроллеры

Создадим контроллер для аутентификации. Для наших контроллеров сделаем отдельную папку controller и создадим в ней класс AuthenticationController. Снабдим данный класс аннотацией @RestController. Эта аннотация объединяет две другие аннотации: @Controller и @ResponseBody. @RestController позволяет классам обрабатывать HTTP-запросы, такие как GET, POST, PUT, DELETE и другие. Они используют аннотации, такие как @GetMapping, @PostMapping и т. д., для сопоставления запросов с методами.

controller.AuthenticationController.java

@RestController
public class AuthenticationController {

  private final AuthenticationService authenticationService;
  
  private final UserService userService;

  public AuthenticationController(AuthenticationService authenticationService, UserService userService) {
    this.authenticationService = authenticationService;
    this.userService = userService;
  }
}

Реализуем POST метод register() для регистрации. Укажем конечную точку /registration.

@PostMapping("/registration")
public ResponseEntity<String> register(
  @RequestBody RegistrationRequestDto registrationDto) {
        
  if(userService.existsByUsername(registrationDto.getUsername())) {
    return ResponseEntity.badRequest().body("Имя пользователя уже занято");
  }
  
  if(userService.existsByEmail(registrationDto.getEmail())) {
    return ResponseEntity.badRequest().body("Email уже занят");
  }

  authenticationService.register(registrationDto);

  return ResponseEntity.ok("Регистрация прошла успешно");
}

@PostMapping("/registration"): Указывает, что метод обрабатывает POST-запросы по пути /registration.

@RequestBody RegistrationRequestDto registrationDto: Преобразуем данные запроса в объект RegistrationRequestDto.

Далее идет блок проверок на уникальность username и email.

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

if(userService.existsByUsername(registrationDto.getUsername())) { return ResponseEntity.badRequest().body("Имя пользователя уже занято"); }: Проверяем, есть ли в нашей базе данных пользователь с переданным username. Если такой пользователь найден, то отправляется "Имя пользователя уже занято".

if(userService.existsByEmail(registrationDto.getEmail())) { return ResponseEntity.badRequest().body("Email уже занят"); }: Проверяем, есть ли в нашей базе данных пользователь с переданным email. Если такой пользователь найден, то отправляется "Email уже занят".

uthenticationService.register(registrationDto): Если проверки на уникальность пройдены, то регистрируем пользователя.

return ResponseEntity.ok("Регистрация прошла успешно"): Если регистрация прошла успешно, то возвращаем HTTP-ответ с кодом статуса 200 OK и сообщением "Регистрация прошла успешно".


Реализуем POST метод authenticate() для авторизации. Укажем конечную точку /login.

@PostMapping("/login")
public ResponseEntity<AuthenticationResponseDto> authenticate(
  @RequestBody LoginRequestDto request) {
  
  return ResponseEntity.ok(authenticationService.authenticate(request));
}

return ResponseEntity.ok(authenticationService.authenticate(request));: Возвращаем HTTP-ответ с кодом статуса 200 OK, содержащий AuthenticationResponseDto, который хранит в себе токены.


И последний POST метод в данном контроллере будет refreshToken(). Укажем конечную точку /refresh_token. Данный метод будет отвечать за обновление токенов.

@PostMapping("/refresh_token")
public ResponseEntity<AuthenticationResponseDto> refreshToken(
  HttpServletRequest request,
  HttpServletResponse response) {
  
  return authenticationService.refreshToken(request, response);
}

HttpServletRequest request, HttpServletResponse response: Метод принимает стандартные объекты запроса и ответа HTTP, которые предоставляют доступ к заголовкам, параметрам и другим характеристикам HTTP-запроса.

return authenticationService.refreshToken(request, response);: Возвращаем новые токены, если обновление прошло успешно. Если нет, то статус 401.


controller.AuthenticationController.java
@RestController
public class AuthenticationController {

  private final AuthenticationService authenticationService;
  
  private final UserService userService;

  public AuthenticationController(AuthenticationService authenticationService, UserService userService) {
    this.authenticationService = authenticationService;
    this.userService = userService;
  }

  @PostMapping("/registration")
  public ResponseEntity<String> register(
  @RequestBody RegistrationRequestDto registrationDto) {
        
    if(userService.existsByUsername(registrationDto.getUsername())) {
      return ResponseEntity.badRequest().body("Имя пользователя уже занято");
    }
    
    if(userService.existsByEmail(registrationDto.getEmail())) {
      return ResponseEntity.badRequest().body("Email уже занят");
    }
  
    authenticationService.register(registrationDto);
  
    return ResponseEntity.ok("Регистрация прошла успешно");
  }

  @PostMapping("/login")
  public ResponseEntity<AuthenticationResponseDto> authenticate(
    @RequestBody LoginRequestDto request) {
    
    return ResponseEntity.ok(authenticationService.authenticate(request));
  }

  @PostMapping("/refresh_token")
  public ResponseEntity<AuthenticationResponseDto> refreshToken(
    HttpServletRequest request,
    HttpServletResponse response) {
    
    return authenticationService.refreshToken(request, response);
  }
}

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

Также снабдим ее аннотацией @RestController и пропишем 2 GET запроса. Первый будет доступен всем авторизированным пользователям, а второй будет доступен только для пользователя с ролью ADMIN.

controller.DemoController.java
@RestController
public class DemoController {

    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
      
        return ResponseEntity.ok("Hello User");
    }

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<String> helloA() {
      
        return ResponseEntity.ok("Hello Admin");
    }
}

Это два простых метода, которые при успешном запросе отправляют Hello User или Hello Admin. Второй ответ может получить только пользователь с ролью ADMIN, все это мы прописали еще в нашем конфиге безопасности, но для достоверности, я добавил аннотацию @PreAuthorize("hasRole('ADMIN')"), которая разрешает доступ только тем, у кого роль ADMIN.

На этом написание кода закончено. Можно переходить к тестам.

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

Для проведения тестов я буду использовать Postman. Давайте запустим наш проект и зайдем в приложение. Для того, чтобы сделать запрос, нажмите New Request.

Создание запроса
Создание запроса

Далее нам нужно выбрать метод POST и ввести наш хост с конечной точкой, для начала мы будем регистрировать пользователя, поэтому - http://localhost:8080/registration. После нам нужно во вкладке Body выбрать raw и ввести данные в формате JSON.

Ввод данных
Ввод данных

Отправляем запрос и смотрим вывод.

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

Мы получили статус 200 OK и сообщение Регистрация прошла успешна. Теперь попробуем зарегистрироваться под такими же username и email, Чтобы протестировать проверку на уникальность.

Проверка на уникальность
Проверка на уникальность

Мы получаем статус 400 Bad Request и сообщение Имя пользователя уже занято. Наша проверка на уникальность работает исправно. Посмотрим, занесся ли пользователь в нашу базу данных.

База данных с пользователями
База данных с пользователями

Пользователь корректно отображается в нашей базе данных. Обратим внимание, что пароль хеширован, а значит защищен от кражи.

Теперь попробуем войти под этими же данными. Создаем новый POST запрос и вводим наш хост с конечной точкой login - http://localhost:8080/login. И также вводим данные в формате JSON. Пробуем отправить запрос.

Запрос на авторизацию
Запрос на авторизацию

Мы получаем статус 200 OK и наши 2 токена. Первый является access токен, который нужно указывать при любом другом запросе, второй refresh токен, его нужно указывать только на конечной точке refresh_token. Посмотрим нашу базу данных.

База данных с токенами
База данных с токенами

Токены успешно сохранились и связались с нужным пользователем. Попробуем теперь войти на конечную точку /hello. Для этого создаем уже GET запрос и вводим наш URL - http://localhost:8080/hello. Пробуем выполнить запрос без передачи нашего access токена.

Попытка запроса без передачи токена
Попытка запроса без передачи токена

Нам выдает статус 401 Unauthorized. Теперь попробуем с передачей нашего токена. Для этого перейдите во вкладку Authorization и в меню выберите тип Bearer Token, после чего в поле укажите ваш access токен, который выдался нам при авторизации.

Попытка запроса с передачей токена
Попытка запроса с передачей токена

Мы получаем статус 200 OK и сообщение Hello User, что говорит о том, что все получилось. Теперь попробуем перейти на вкладку админа, для этого в URL меняем hello на admin и отправляем запрос.

Попытка попасть на страницу админа
Попытка попасть на страницу админа

Мы получаем статус 403, который указывали в своем обработчике, это означает, что пользователю отказано в доступе. Давайте поменяем в нашей базе данных роль с USER на ADMIN. И повторим запрос.

Изменение роли на ADMIN
Изменение роли на ADMIN
Повторная попытка зайти на страницу админа
Повторная попытка зайти на страницу админа

Теперь наш запрос прошел, и мы получили наше сообщение для админа.

Ну и последнее, что мы протестируем это работу refresh токена. Для этого создадим новый POST запрос и укажем URL - http://localhost:8080/refresh_token. Чтобы запрос прошел, нам нужно передать вместо access токена - refresh. Давайте попробуем сделать запрос.

Проверка refresh токена
Проверка refresh токена

Мы получили статус 200 ОК и 2 новых токена. Проверим их в нашей базе данных.

База данных с токенами
База данных с токенами

Как видим новые токены добавились, а старые токены стали аннулированы. Теперь для последующих запросов, нужно передавать новый access токен, так как старый стал недействителен.

Вывод

В этой статье мы рассмотрели, как реализовать свою генерацию JWT (JSON Web Tokens) в приложение на Spring, чтобы обеспечить надежную аутентификацию и авторизацию пользователей. А также познакомились с другими не менее важными инструментами.

Ключевые моменты:

  1. Написали настройки приложения.

  2. Создали сущности.

  3. Создали репозитории.

  4. Создали сервисы.

  5. Создали собственный фильтр.

  6. Настроили свой конфиг безопасности.

  7. Познакомились с DTO и обработчиками.

  8. Написали свои контроллеры.

  9. Провели тестирование при помощи Postman

Использование JWT в приложении на Spring значительно улучшает безопасность и производительность, позволяя создавать современное масштабируемое приложение. Надеюсь, что эта статья помогла вам понять основы работы с JWT. Я постарался детально разобрать каждый момент, чтобы любой начинающий мог понять и использовать это.

Весь код можно найти на моем GitHub.

Комментарии (4)


  1. Uint32
    10.12.2024 06:42

    Спасибо за интересные статьи. Понимая, что это всего лишь пример, обращу внимание на следующие аспекты:
    * Ходить при каждой проверке access token в базу - так себе идея. Создаст бутылочное горлышко. Да и вообще, если уж и хранить токены, то лучше в in-memory db.
    * Если всё же хочется отзывать токены быстро, можно использовать структуры на базе фильтра Блума, который периодически реплицировать по узлам endpoint, а при нахождении в нём уже осуществлять поход в базу.
    * Стоит использовать дополнительную информационную нагрузку токена как в подписанном, так и в зашифрованном виде, в зависимости от чувствительности информации.


    1. ivan_storozhev Автор
      10.12.2024 06:42

      Спасибо вам большое за комментарий!!

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


  1. sarkhan69
    10.12.2024 06:42

    В целом большая статья для джуников, присоединяюсь к комментарию выше и хотел бы добавить, когда выбираете зависимость для использования, например либа jwt 0.12.3 уже помечена как уязвимая и проекты, где есть КБ, могут заблокировать подобное вещи, стоит сразу смотреть последние версии без факта найденных уязвимостей, в вашем случае 0.12.6


    1. ivan_storozhev Автор
      10.12.2024 06:42

      Спасибо большое за комментарий!

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

      Еще раз спасибо за такое уточнение !