Добрый день!
В этой статье я хотел бы рассказать, как настроить простейшую 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. Заключение
Данный подход хоть и работает, но никак не претендует на правильное решение :-)