Spring Security: начало работы и первые шаги на практике
После того как мы в теории разобрали важные детали о Spring Security нужно учиться работать с ним на практике.
Создание приложения с Spring Security
Создавать наше приложение будем по аналогии с соц. сетями, где можно создать/редактировать/удалить свою страницу, а чужие страницы можно только смотреть. Только вместо фронтенда будем создавать 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://local
host: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 = "us
ers&quo
t;)@Get
ter@Setter@NoArgsC
onstructorpublic class User implements Use
rDetail
s { @Id @GeneratedValue(strategy = GenerationType
.IDENTITY) @Column(name = "id"
;, nullable = false) private Long id; // В продакшене нуж
но использовать UUID @Column(name = "username", nu
llable = false, unique = tru
e)
private String username; @Column(name =
"password", nullable = false) private String pa
ssword; // хранится в зашифрованном виде // Это по
ле это признак того, что аккаунт ак
тиве
н private boolean enabled = true; @El
ementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles&q
uot;, joinColumns = @JoinColumn(
name = "user_id")) @Enumerated(EnumType.STRIN
G)
private Set
<Role> roles = new HashSet<>(); @Override public Col
lection<? extends GrantedAuthority> get
Authorities() { return roles.stream().map(role ->
new SimpleGrantedAuthority("
ROLE_
"+role.n
ame())) .collect(Collectors.
toSet()); } @Overr
ide
public @Nul
lable String getPassword() {
return password; }
@
Override p
ublic String getUsername() { return
username; } @
Overr
ide public
boolean isAccountNonExpired() { r
eturn true; }
@Over
ride publi
c boolean isAccountNonLocked() { return
true; } @Over
ride
public boo
lean 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.
Спасибо каждому кто дочитал эту статью! Надеюсь она оказалась полезной и вы узнали что-то новое. А если ты более опытный человек, и нашёл у меня какие-либо ошибки, можешь написать комментарий, я буду только рад :)
