После того как мы в теории разобрали важные детали о Spring Security нужно учиться работать с ним на практике.

Создание приложения с Spring Secutity

Создавать наше приложение будем по аналогии с соц. сетями, где можно создать/редактировать/удалить свою страницу, а чужие страницы можно только смотреть. Только вместо фронтенда будем создавать Rest соединение с выдуманным фронтом.
Будем использовать Spring Boot в ходе разработки. Можно воспользоваться сайтом http://start.spring.io/ либо же в Intellij Idea Ultimate (важно чтобы была именно Ultimate версия, потому что она поддерживает Spring Boot).
В ходе создания приложения будем использовать данные параметры внутри Intellij Idea.
Важное примечание: код будет не Production Ready, потому что это обучающий материал.

Параметры создания нового проекта
Параметры создания нового проекта

А из основных зависимостей у нас будет: web, lombok, security, spring data jpa и postgresql driver

Но это не все зависимости, потому что для подключения JWT в недолгом будущем, нужны будут 3 определённые зависимости которые мы скоро подключим.

После создания проекта у нас должен появиться pom.xml файл с такими зависимостями (зависимости для JWT уже добавлены в самом начале). Я поясню что дают зависимости для JWT и стартер spring-boot-starter-security, остальные думаю не стоит разбирать поскольку они самые основные для любого Spring Boot проекта, ну, а lombok это вкусовщина и он может доставить проблем, но не в моём случае, потому что я его не буду гонять на сложные вещи.

 <dependencies>

        <!-- JWT -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.12.3</version>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.12.3</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.12.3</version>
            <scope>runtime</scope>
        </dependency>

        <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-webmvc</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-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

Далее чтобы наш проект работал, нужно в application.properties и Docker-compose.yml прописать настройки для постгреса, вместо докера базу данных можно запустить например через DBeaver. Я буду писать их в докере и будет это выглядеть следующим образом:

services:
  db:
    #Образ из которого будет создаваться контейнер
    image: postgres:latest
    
    #Переменные окружения для базы данных
    environment:
      #Пароль
      POSTGRES_PASSWORD: postgres
      #Пользователь
      POSTGRES_USER: postgres
      #Название
      POSTGRES_DB: security
    ports:
      #Порт на котором будет работать база данных
      - "5432:5432"

Отлично, зависимости и Docker-compose.yml у нас есть, осталось только прописать настройки в application.properties

spring.application.name=SecurityCourse
#Порт на котором будет работать приложение
server.port=8080

spring.datasource.driver-class-name=org.postgresql.Driver

#Тут идёт подстановка параметров из Docker-compose.yml в application.properties
spring.datasource.password=${POSTGRES_PASSWORD:postgres}
spring.datasource.username=${POSTGRES_USER:postgres}
spring.datasource.url=jdbc:postgresql://localhost:5432/${POSTGRES_DB:security}

#ddl-auto=create-drop нужно чтобы при каждом запуске все таблицы пересоздавались заново
spring.jpa.hibernate.ddl-auto=create-drop

После того как мы прописали все необходимые параметры, можем со спокойной душой запускать Docker-compose.yml и затем и наше Spring Boot приложение!

Успешный запуск приложения
Успешный запуск приложения

Как мы можем видеть, приложение запустилось и в логах мы видим сгенерированый пароль. Важно отметить, что этот пароль временный и принадлежит пользователю "user", которого в ближайшем будущем мы переопределим.
Поскольку наше приложение, можем посмотреть что будет если попробовать зайти на наш сайт http://localhost:8080/
При первом заходе нас сразу редиректит по пути http://localhost:8080/login

Страница авторизации
Страница авторизации

Далее можем ввести в поле username - user, а в поле password - <то что мы увидели в логах консоли выше>

После успешного входа нас перекинет на страничку с ошибкой, в целом это правильное поведение, потому что у нас нет никаких контроллеров, сервисов и тд. В некоторых случаях в коде ошибки можно увидеть код 999, он указывает на блокировку со стороны встроенной системы безопасности, которая заменяет коды ошибок: 401, 403 и 404.

Страница с ошибкой
Страница с ошибкой

Теперь из интереса можно перейти на страницу http://localhost:8080/logout

Страница выхода из профиля
Страница выхода из профиля

После того как мы нажмём на кнопку Log Out , нас перекинет на страницу авторизации и в параметрах url-запроса мы сможем увидеть параметр login?logout. Знаю, много тафталогии, но что поделать?

Страничка авторизации с успешным выходом из профиля
Страничка авторизации с успешным выходом из профиля

Если в поля логина и пароля ввести какую-нибудь белеберду, то наше приложение покажет нам ошибку, что мы введи неправильные данные, и в параметре запроса можно увидеть: ?error

Ошибка ввода данных в поля логина и пароля
Ошибка ввода данных в поля логина и пароля

UserDetails, UserDetailsService и всё с ними связанное

Теперь когда мы поигрались с базовыми возможностями Spring Security можно поговорить про UserDetails и UserDetailsService. Они является своего рода фундаментом который определяет кто такой пользователь в системе. Всё остальное строится поверх этого фундамента. Очень важно понимать их до мелочей, поскольку это та же сущность, которую мы будем защищать в дальнейшем.

  • UserDetails - это интерфейс Spring Security, который описывает "как выглядит пользователь с точки зрения фреймворка". Потому что сам фреймворк ничего от слова совсем не знает про таблицу users (которая будет ниже) - он умеет работать только с этим интерфейсом. Поэтому нужно будет создать сущность User которая будет реализовывать данный интерфейс.

  • UserDetailsService - это сервис который Spring Security вызывает во время аутентификации, чтобы загрузить пользователя по имени. Он является единственной точкой входа, через которую фреймворк узнаёт о существовании наших пользователей. Без этой точки входа аутентификация не будет знать, откуда брать данные пользователя.

Теперь перед реализацией UserDetails и UserDetailsService нужно создать enum с ролями пользователей и UserRepository для хибера, чтобы мы могли его заинжектить в будущий UserDetailsServiceImpl. Этот enum я создам в главной папке где находится класс SecurityCourseApplication

public enum Role {
  USRER, //обычный пользователь: видит и редактирует только свои данные
  ADMIN  //администратор: имеет доступ ко всем данным независимо от владельца
}

Далее создадим сущность User которая будет реализовывать интерфейс UserDetails, и поместим эту сущность в папку entity по пути ru.khalov.securitycourse.entity, ниже покажу как это должно выглядеть.

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id; // В продакшене нужно использовать UUID

    @Column(name = "username", nullable = false, unique = true)
    private String username;
    
    @Column(name = "password", nullable = false)
    private String password; // хранится в зашифрованном виде

    // Это поле это признак того, что аккаунт активен
    private boolean enabled = true;
    
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"))
    @Enumerated(EnumType.STRING)
    private Set<Role> roles = new HashSet<>();
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream().map(role -> 
                new SimpleGrantedAuthority("ROLE_"+role.name()))
                .collect(Collectors.toSet());
    }

    @Override
    public @Nullable String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

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

Мы описали класс User, супер. Но теперь стоит пояснить за аннотации над полем roles:

  • @ElementCollection - она помечает коллекцию не-сущностей, то есть embedded-объектов или базовых типов, которую нужно хранить в отдельной таблице. Важно что в отличае от @OneToMany, здесь элементы коллекции - самостоятельные JPA-сущности, то есть у них нет @Entity. Параметр fetch = FetchType.EAGER - указывает на то, что подгрузить эту коллекцию сразу (по умному: не лениво) при загрузке основной сущности.

  • @CollectionTable - она задаёт параметры вспомогательной таблицы, в которой будут храниться элементы коллекции. Параметр: name = "user_roles" - это имя таблицы в базе данных, параметр: joinColumns - это колонка внешнего ключа, которая связывает доп таблицу с основной по определённому ключу. @JoinColunm(name = "user_id") - это имя FK - колонки в таблице "user_roles".

  • @Enumirated - она указывает, как сохранять значения enum в базе данных. Параметр: EnumType.STRING - сохраняет строковое имя константы enum, например "ADMIN".

Дальше необходимо пояснить за некоторые методы которые реализует наша сущность User:

  • public Collection<? extends GrantedAuthority> getAuthorities() - этот метод возвращает список прав (authorities) пользователя в формате, который понимает Spring Security. Каждая роль превращается в GrandedAuthirity (интерфейс который реализуется в классе SimpleGrandedAuthority) с префиксом "ROLE_". Это соглашение, на котором строится проверка hasRole() которую релизуем позже. А возвращает метод набор прав которые есть у пользователя например: {ROLE_USER} или {ROLE_USER, ROLE_ADMIN}

  • public boolean isAccountNonExpired() - этот метод показывает, что учётная запись не истекла по сроку действия. В основном не используется и по дефлоту возвращается true.

  • public boolean isAccountNonLocked() - этот метод показывает, что учётка не заблокирована к примеру после нескольких неудачных попыток входа, или после блокировки администратором. Если вернуть false - то Spring Security не даст пользователю пройти аутентификацию, несмотря на верность логина+пароля.

  • public boolean isCredentialsNonExpired() - этот метод показывает, что пароль не устарел, например если пользователь поменял пароль, и при входе пользователь ввёл старый пароль, приложение скажет что пароль устарел.

  • public boolean isEnabled() - этот метод показывает, что аккаунт активен, например пользователь временно заморозил свою страницу потому что перешёл на другую страницу или временно перешёл в другую соц. сеть, но через время вернётся и снова зайдёт на страницу.

Важно заметить, что у сущности User нет метода isOwner(), потому что этот метод относится к бизнес-логике и его нужно выносить в отдельный сервис.

Далее нужно написать UserRepository, это будет самый банальный репозиторий который экстендится от JpaRepository<>

@Repository //не обязательна поскольку внутри JpaRepository эта аннотация уже есть
public interface UserRepository extends JpaRepository<User, Long> {

    //Поиск пользователя по его уникальому имени, если пользователь не найден,
    //вернётся пустота, но это обработается в будущем
    Optional<User> findByUsername(String username);

    //Проверяет занят логин или нет. Используется при регистрации,
    //чтобы не создавать двух и более людей с одним именем
    boolean existsByUsername(String username);
}

Теперь можно создавать UserDetailsServiceImpl который будет реализовывать UserDetailsService:

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        return userRepository.findByUsername(username).orElseThrow(() ->
                new UsernameNotFoundException("User not found: " + username)
            );
    }
}

Spring Security вызывает этот сервис во время аутентификации, чтобы загрузить пользователя по имени. Это как раз та самая единственная точка входа, через которую ферймворк узнаёт о существовании таблицы "users". Повторюсь, но это правда достаточно важно, без этого сервиса, аутентификация не будет знать откуда брать данные пользователя!

Думаю на этом данную статью завершить, и продолжить уже в следующей, поскольку в этой статье уже есть пара вещей которые стоит запомнить как в теории, так и на практике. В дальнейших статьях будем реализовывать PasswordEncoder, Session+Cookie аутентификации через REST.

Спасибо каждому кто дочитал эту статью! Надеюсь она оказалась полезной и вы узнали что-то новое. А если ты более опытный человек, и нашёл у меня какие-либо ошибки, можешь написать комментарий, я буду только рад :)