Добрый день!
В этой статье я хотел бы рассказать, как настроить простейшую jwt аутентификацию, без создания кастомных фильтров для генерации и валидации токенов. На мой взгляд найти пример конфигурации в «этих ваших интернетах», да такой чтобы над каждым методом не висело deprecated не самая простая задача, особенно для начинающих, а не начинающим эти примеры наверное и не нужны :).
Security Flow
В общем виде Spring Security ведет себя как показано на рисунке:
-
Фильтры перехватывают каждый запрос и проверяют требуется ли аутентификация/авторизация для доступа к ресурсу.
-
Фильтры (например UserNamePasswordAuthenticationFilter) извлекают из запроса данные пользователя подготавливают объект типа Authentication.
-
AuthenticationManager перенаправляет запрос от фильтра в доступные AuthenticationProvider (в нашем случае их будет 2: DaoAuthenticationProvider — для входа по логину и паролю и JwtAuthenticationProvider — предоставляемый OAuth2 Resource Server) .
-
AuthenticationProvider содержит логику по валидации данных пользователя.
-
UserDetailsService отвечает за доступ к информации о пользователе хранящейся в БД.
-
PaswordEncoder интерфейс для хэширования паролей пользователя.
-
Объект Authentication c информацией об аутентификации возвращается в AuthenticationManager.
-
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
-
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; } }
-
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/
Добавить комментарий