Настройка jwt-username-password authentication через spring-security-oauth2-resource-server

от автора

Добрый день!

В этой статье я хотел бы рассказать, как настроить простейшую jwt аутентификацию, без создания кастомных фильтров для генерации и валидации токенов. На мой взгляд найти пример конфигурации в «этих ваших интернетах», да такой чтобы над каждым методом не висело deprecated не самая простая задача, особенно для начинающих, а не начинающим эти примеры наверное и не нужны :).

Security Flow

В общем виде Spring Security ведет себя как показано на рисунке:

spring security flow
spring security flow
  1. Фильтры перехватывают каждый запрос и проверяют требуется ли аутентификация/авторизация для доступа к ресурсу.

  2. Фильтры (например UserNamePasswordAuthenticationFilter) извлекают из запроса данные пользователя подготавливают объект типа Authentication.

  3. AuthenticationManager перенаправляет запрос от фильтра в доступные AuthenticationProvider (в нашем случае их будет 2: DaoAuthenticationProvider — для входа по логину и паролю и JwtAuthenticationProvider — предоставляемый OAuth2 Resource Server) .

  4. AuthenticationProvider содержит логику по валидации данных пользователя.

  5. UserDetailsService отвечает за доступ к информации о пользователе хранящейся в БД.

  6. PaswordEncoder интерфейс для хэширования паролей пользователя.

  7. Объект Authentication c информацией об аутентификации возвращается в AuthenticationManager.

  8. AuthenticationManager проверяет успешна аутентификация или нет. Если да, Authentication возвращается к фильтрам помещается в SecurityContex (9), если нет то пробует через другой доступный AuthenticationProvider.

А теперь перейдем к собственной реализации.

1. Добавим зависимости

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency>          <dependency>             <groupId>org.springframework.boot</groupId>             <artifactId>spring-boot-configuration-processor</artifactId>             <optional>true</optional>         </dependency>                  <dependency>             <groupId>org.springframework.boot</groupId>             <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>         </dependency>  <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</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>

Здесь вместо spring-boot-starter-security будем использовать spring-boot-starter-oauth2-resource-server, которая включает в себя ряд других зависимостей (security-core, security-core, security-oauth2-jose, security-oauth2-jose-resource-server).

2. Заполняем application.properties

#rsa keys rsa.private-key=classpath:certs/private.pem rsa.public-key=classpath:certs/public.pem  #db credentials spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.datasource.url=jdbc:postgresql://localhost:5432/jwtDb spring.datasource.username=postgres spring.datasource.password=bestuser  #auto creating db schemas with hibernate spring.jpa.show-sql=true spring.jpa.generate-ddl=true spring.jpa.hibernate.ddl-auto=create  #for sql files (can write data and create schemas) spring.jpa.defer-datasource-initialization=true spring.sql.init.mode=always

3. Создаем entity классы и repository

@Table(name="users") @Entity @Data public class User {  @Id @Column(name="id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name="email") private String email;  @Column(name="password") private String password;      @ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER)     @JoinTable(name = "user_role",             joinColumns = @JoinColumn(name = "user_id"),             inverseJoinColumns = @JoinColumn(name = "role_id")) private Set<Role> roles; }  @Table(name="roles") @Entity @Data public class Role {  @Id @Column(name="id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;  @Column(name="role_name") private String roleName; }  public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); }

4. Создаем rsa ключи

Jwt токены рекомендуется подписывать ассиметричными ключами, более того, NimbusJwtEncoder и NimbusJwtDecoder, бины которых мы создадим в конфигурации, потребуют именно такую пару ключей. Создадим новую папку в resources и сгенерируем в ней ключи с помощью следующих команд:

openssl genrsa  -out keypair.pem 2048 openssl rsa  -in keypai.pem  -pubout  -out public.pem openssl pkc8  -topk8  -inform PEM  -outform PEM  -nocrypt  -in keypair.pem  -out private.pem 

Теперь нужно как-то получить доступ к этим ключам из application.properties, для этого создадим:

@ConfigurationProperties(prefix ="rsa") public record RsaProperties(RSAPrivateKey privateKey, RSAPublicKey publicKey) { }

*не забудьте добавить @EnableConfigurationProperties(RsaProperties.class) в main.

5. Создаем UserDetailsService и UserDetails

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

Реализация UserDetailsService будет выглядеть так:

public class CustomUsrDetailsService implements UserDetailsService{  @Autowired private UserRepository userRepo;  @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepo.findByEmail(email).orElseThrow(()-> new UsernameNotFoundException("User with email = "+email+" not exist!")); return new CustomUsrDetails(user); } }

Здесь нам нужно переопределить всего один метод который возвращает UserDetails — интерфейс аккумулирующий в себе информацию о пользователе (логин, пароль, права доступа и пр.). Его реализация приведена ниже:

public class CustomUsrDetails implements UserDetails {  private static final long serialVersionUID = 1L; private User user;  public CustomUsrDetails(User user) { this.user = user; }  @Override public Collection<? extends GrantedAuthority> getAuthorities() { Set<Role> roles = user.getRoles();  List<SimpleGrantedAuthority> authorities = new ArrayList<>(); for(Role role : roles) {authorities.add(new SimpleGrantedAuthority(role.getRoleName()));} return authorities; }  @Override public String getPassword() {return user.getPassword();}  @Override public String getUsername() {return user.getEmail();}  @Override public boolean isAccountNonExpired() {return true;}  @Override public boolean isAccountNonLocked() {return true;}  @Overridepublic boolean isCredentialsNonExpired() {return true;}  @Override public boolean isEnabled() {return true;} }

6. Security Config

@EnableGlobalMethodSecurity(prePostEnabled = true) @EnableWebSecurity @Configuration public class AppSecurityConfig {  private final RsaProperties rsaKeys;  public AppSecurityConfig(RsaProperties rsaKeys) { this.rsaKeys = rsaKeys; }  @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }  @Bean     public UserDetailsService customUserDetailsService() {         return new CustomUsrDetailsService();     }   @Bean public AuthenticationManager authManager() {         var authProvider = new DaoAuthenticationProvider();         authProvider.setUserDetailsService(customUserDetailsService());         authProvider.setPasswordEncoder(passwordEncoder());         return new ProviderManager(authProvider); }  @Bean JwtEncoder jwtEncoder() { JWK jwk = new RSAKey.Builder(rsaKeys.publicKey()).privateKey(rsaKeys.privateKey()).build(); JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk)); return new NimbusJwtEncoder(jwkSource); }  @Bean JwtDecoder jwtDecoder() { return NimbusJwtDecoder.withPublicKey(rsaKeys.publicKey()).build(); }  @Bean TokenService tokenService() { return new TokenService(jwtEncoder()); }  @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {  return http                     .csrf(csrf -> csrf.disable())                     .authorizeRequests(auth -> auth                         .mvcMatchers("/login").permitAll()                         .mvcMatchers("/token/refresh").permitAll()                         .mvcMatchers("/admin").hasAuthority("SCOPE_adm")                         .mvcMatchers("/user").hasAuthority("SCOPE_usr")                         .anyRequest().authenticated())                     .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))                     .oauth2ResourceServer(OAuth2ResourceServerConfigurer :: jwt )                         .build(); }  }

У нас как и упоминалось ранее будет 2 AuthenticationProvide

  1. JwtAuthenticationProvider

1.1 Фильтр считывает токен и передает его в AuthenticationManager

1.2 ProviderManager выбирает JwtAuthenticationProvider

1.3 JwtAuthenticationProvider выполняет валидацию токена с помощью JwtDecoder

1.4 JwtAuthenticationProvider конвертирует токен в объект Authentication типа JwtAuthenticationToken

1.5 JwtAuthenticationToken помещается в SecurityContextHolder

При этом все эти манипуляции выполняются «под капотом» c помощью OAuth2ResourceServerConfigurer включенного в SecurityFilterChain. Нам остается только создать бины JwtEncoder, JwtDecoder и создать TokenService для генерации access и refresh токенов и вынимания из них username.

public class TokenService  {  private final JwtEncoder jwtEncoder;  public TokenService(JwtEncoder jwtEncoder) { super(); this.jwtEncoder = jwtEncoder; }      public String generateAccessToken(CustomUsrDetails usrDetails) {         Instant now = Instant.now();         String scope = usrDetails.getAuthorities().stream()                 .map(GrantedAuthority::getAuthority)                 .collect(Collectors.joining(" "));              JwtClaimsSet claims = JwtClaimsSet.builder()                 .issuer("self")                 .issuedAt(now)                 .expiresAt(now.plus(2, ChronoUnit.MINUTES))                 .subject(usrDetails.getUsername())                 .claim("scope", scope)                 .build();         return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();     }      public String generateRefreshToken(CustomUsrDetails usrDetails) {         Instant now = Instant.now();         String scope = usrDetails.getAuthorities().stream()                 .map(GrantedAuthority::getAuthority)                 .collect(Collectors.joining(" "));                  JwtClaimsSet claims = JwtClaimsSet.builder()                 .issuer("self")                 .issuedAt(now)                 .expiresAt(now.plus(10, ChronoUnit.MINUTES))                 .subject(usrDetails.getUsername())                 .claim("scope", scope)                 .build();         return this.jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();     }          public String parseToken(String token) {     try { SignedJWT decodedJWT = SignedJWT.parse(token); String subject = decodedJWT.getJWTClaimsSet().getSubject(); return subject; } catch (ParseException e) { e.printStackTrace(); }     return null;     } }
  1. DaoAuthenticationProvider

2.1 Фильтр берет введенные логин и пароль и передает UsernamePasswordAuthenticationToken в AuthenticationManager

2.2 AuthenticationManager выбирает DaoAuthenticationProvider

2.3 DaoAuthenticationProvider проверяет UserDetails через UserDetailsService (у нас они реализованы как CustomUsrDetails и CustomUsrDetailsService)

2.4 DaoAuthenticationProvider проверяет пароль полученный из UserDetails с помощью BcryptPasswordEncoder

2.5 UsernamePasswordAuthenticationToken помещается в SecurityContextHolder

7. EndPoints

Теперь осталось создать эндпоинты для логина, обновления токенов, и проверки прав доступа.

@RestController public class AuthController {  private final TokenService tokenService; private final AuthenticationManager authManager; private final CustomUsrDetailsService usrDetailsService;   public AuthController(TokenService tokenService, AuthenticationManager authManager, CustomUsrDetailsService usrDetailsService) { super(); this.tokenService = tokenService; this.authManager = authManager; this.usrDetailsService = usrDetailsService; }   record LoginRequest(String username, String password) {}; record LoginResponse(String message, String access_jwt_token, String refresh_jwt_token) {}; @PostMapping("/login") public LoginResponse login(@RequestBody LoginRequest request) {  UsernamePasswordAuthenticationToken authenticationToken =  new UsernamePasswordAuthenticationToken(request.username, request.password); Authentication auth = authManager.authenticate(authenticationToken);  CustomUsrDetails user = (CustomUsrDetails) usrDetailsService.loadUserByUsername(request.username); String access_token = tokenService.generateAccessToken(user); String refresh_token = tokenService.generateRefreshToken(user);  return new LoginResponse("User with email = "+ request.username + " successfully logined!"  , access_token, refresh_token); }  record RefreshTokenResponse(String access_jwt_token, String refresh_jwt_token) {}; @GetMapping("/token/refresh") public RefreshTokenResponse refreshToken(HttpServletRequest request) {  String headerAuth = request.getHeader("Authorization");   String refreshToken = headerAuth.substring(7, headerAuth.length());  String email = tokenService.parseToken(refreshToken); CustomUsrDetails user = (CustomUsrDetails) usrDetailsService.loadUserByUsername(email); String access_token = tokenService.generateAccessToken(user); String refresh_token = tokenService.generateRefreshToken(user);  return new RefreshTokenResponse(access_token, refresh_token); } }
@RestController public class MyController {  @GetMapping("/admin") public String homeAdmin(Principal principal) { return "Hello mr. " + principal.getName(); }  @GetMapping("/user") public String homeUser(Principal principal) { return "Hello mr. " + principal.getName(); } }

8. Заключение

Данный подход хоть и работает, но никак не претендует на правильное решение 🙂


ссылка на оригинал статьи https://habr.com/ru/post/697694/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *