Добрый день!

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

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

Комментарии (0)