TL;DR

这篇文章描述了不引入任何第三方库,只使用 Spring Security 及其配套组件实现 JWT 认证的模式。能够带来如下优势。

  • 更通用,且无第三方库引入,可简单跟随 Spring Boot 大版本更新。
  • 扩展性强,可作为单体应用使用,也可与每个微服务简单继承,后续更可简单修改配置即可兼容 OAuth2 与 OIDC,也可将登录模块扩展成单独服务。

Intro

这是不一定会有下一篇文章的第一篇。

JWT Authentication 是目前微服务时代常用的用户认证方式。由于 12 factors 建议 Web 服务应是无状态的,因此用户认证若使用 Session 则需要在其他持久化存储中维护状态。一种常用的手段是使用 Redis 存储 Session 信息,Web 服务从 Redis 中查找 Session 的 Key 来确认用户。

另一种方案是使用 Token 认证,客户端请求 API 时在 HTTP Header 中带上服务器端颁发的 Token,服务器通过该 Token 与授权服务请求用户信息。

常用的一种 Token 为 JSON Web Token

JSON Web Token (JWT) 是一种紧凑的、URL 安全的表示方式,用于在两方之间传输声明 (Claims)。JWT 中的声明被编码为一个 JSON 对象,该对象被用作 JSON Web Signature (JWS) 结构的有效载荷,或者作为 JSON Web Encryption (JWE) 结构的明文,使得声明可以被数字签名或用消息认证码 (MAC) 和/或加密来保护完整性。 – RFC7519

类似的解决方案还有 SAML(Security Assertion Markup Language Tokens)SMT(Simple Web Token)

JWT 被 OAuth2 协议作为默认 Bearer Token 实现。因此可以利用 Spring Security 对 OAuth2 的集成实现 JWT 认证。

验证与 Decode JWT

Spring OAuth2 Resource Server 提供了 BearerTokenAuthenticationFilter 将 Bearer Token 解析为 BearerTokenAuthenticationToken 并使用 AuthenticationManager 接口进行认证。且默认已经提供 JwtAuthenticationProvider,我们只需要传入自定义的实现了 JwtDecoder 接口的 Bean 即可完成。验证完成后,Spring Security 上下文中的 Authentication 即为 JwtAuthenticationToken,其中的 Principal 即为 JwtDecoderdecode 方法返回的 Jwt,因此可以轻松的与 @PreAuthorize 等注解和 SpEL 表达式兼容。

可见,要实现 JWT 认证,不需要自行实现 Filter 等,只需要实现 JwtDecoder 即可。

同时,Spring Security 在 spring-security-oauth2-jose 中提供了一套使用 Nimbus 的 JWT 实现,该依赖已包含在 spring-boot-starter-oauth2-resource-server 中,无需再手动引入。

使用 Spring 提供的 JwtDecoder 实现。

在配置类中注入 JwtDecoder 的 Bean,若没有其他 AuthenticationProvider 实现类,则 Spring Boot 会自动注入 JwtAuthenticationProvider

JwtDecoder 的实现类 NumbusJwtDecoder支持三种方式构造,分别为传入 JWK Set Uri,传入非对称加密的 Public Key,以及对称加密的共享 Key。本例为简化实现,使用共享 Key 方式构造。

首先,为解耦,创建配置类,使得可以使用 Properties 进行变量配置。

 1@Getter
 2@Setter
 3@ConfigurationProperties(prefix = "jwt")
 4@Component
 5public class JwtProperties {
 6    @Min(32)
 7    private String secret = "Please-Change-This-Not-Safety-Key-Please-Please";
 8    private String issuer;
 9    /*
10    * for minutes, default: 30
11    */
12    private Long expireTime = 30L;
13    private Long refreshExpireTime = 60L;
14}

在配置类中注入,并构造 JwtDecoder

 1@Configuration
 2public class SecurityConfiguration {
 3    @Bean
 4    public SecretKey jwtSecretKey(JwtProperties jwtProperties) {
 5        // https://docs.oracle.com/en/java/javase/21/docs/specs/security/standard-names.html#keygenerator-algorithms
 6        return new SecretKeySpec(jwtProperties.getSecret().getBytes(), "HmacSHA256");
 7    }
 8
 9    @Bean
10    public JwtDecoder jwtDecoder(SecretKey key) {
11        return NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256)
12                .build();
13    }
14}

这里使用了 HS256 算法进行签名来保证 JWT 不会被篡改。

由于未使用加密,因此 JWT 为明文。在保密性要求较高的场合请使用 RSA 非对称加密证书对 JWT 进行加密,或使用 JWK Set 以方便证书轮换。

接下来配置 SecurityFilterChain 即可。

 1@Configuration
 2public class SecurityConfiguratuon {
 3    @Bean
 4    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
 5        http
 6                .httpBasic(AbstractHttpConfigurer::disable)
 7                .formLogin(AbstractHttpConfigurer::disable)
 8                .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
 9                .csrf(AbstractHttpConfigurer::disable)
10                .cors(AbstractHttpConfigurer::disable)
11                .authorizeHttpRequests(c -> c
12                        .requestMatchers("/login/**").permitAll()
13                        .requestMatchers("/error").permitAll()
14                        .requestMatchers("/register").permitAll()
15                        .anyRequest().authenticated()
16                )
17                .oauth2ResourceServer(c -> c.jwt(Customizer.withDefaults()));
18
19        return http.build();
20    }
21}

简单的 Authentication Server 实现

显然,最好的方法依然是实现完整的 OAuth2 协议,但就简单的应用而言,只要保证有简单的登录注册,登录时发 JWT 的功能就够了,这里的 JWT 相当于只作为 Token 使用。

登录毫无疑问也是通过 Spring Security 进行扩展的,因此仍然是要实现传统的 DaoAuthenticationProvider,我基于 JPA 实现了一个通用版本的 UserDetailsService

 1public class JpaUserDetailsService<T extends UserDetails> implements UserDetailsService {
 2    private final UserDetailsRepository<T, ?> repository;
 3
 4    public JpaUserDetailsManager(UserDetailsRepository<T, ?> userDetailsRepository) {
 5        this.repository = userDetailsRepository;
 6    }
 7
 8    @Override
 9    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
10        return repository.findByUsername(username)
11                .orElseThrow(() -> new UsernameNotFoundException("User %s not found".formatted(username)));
12    }
13}

为了这个 Repository,写 Example 查询需要用反射拿到泛形的实际类型,但由于臭名昭著的类型擦除,实际是做不到的。因此不如写一个 Repository Interface 让 UserDetails 实现类的 Entity 从这个接口继承。

1@NoRepositoryBean
2public interface UserDetailsRepository<T extends UserDetails, ID> extends JpaRepository<T, ID> {
3    Optional<T> findByUsername(String username);
4    boolean existsByUsername(String username);
5}

当然另一个方法是构造 JpaUserDetailsService 的时候将 Class<T extends UserDetails> 实际类型传入,然后用反射解决问题,但我主张少用反射,而且这样也完全可以解决问题。

下一步是构造 JWT 的生成工具们。我看很多喜欢写静态工具类,我这里为了一定的灵活度,参考了 Spring Security Authentication Server 的实现做了简单的包装。

规定一个具有 generate 方法的 JwtTokenGenerate 接口。

1public interface OAuth2TokenGenerator<T extends OAuth2Token> {
2    T generate(JwtTokenContext context);
3}

为其创建两个实现类,第一个为 JwtAccessTokenGenerator,另一个为 DelegatingTokenGenerator,作为多 Generator 的聚合。

 1public class JwtAccessTokenGenerator implements OAuth2TokenGenerator<Jwt> {
 2    private final JwtEncoder jwtEncoder;
 3    private final JwtProperties jwtProperties;
 4
 5    public JwtAccessTokenGenerator(JwtEncoder jwtEncoder, JwtProperties jwtProperties) {
 6        this.jwtEncoder = jwtEncoder;
 7        this.jwtProperties = jwtProperties;
 8    }
 9
10    @Override
11    public Jwt generate(JwtTokenContext context) {
12        if (context.getType() == null ||
13            !context.getType().equals(JwtTokenType.ACCESS_TOKEN)) {
14            return null;
15        }
16
17        Instant issuedAt = Instant.now();
18        Instant expiresAt = issuedAt.plus(jwtProperties.getExpireTime(), ChronoUnit.MINUTES);
19
20        JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder();
21        if (StringUtils.hasText(jwtProperties.getIssuer())) {
22            claimsBuilder.issuer(jwtProperties.getIssuer());
23        }
24
25        claimsBuilder
26                .subject(context.getAuthentication().getName())
27                .issuedAt(issuedAt)
28                .expiresAt(expiresAt)
29                .id(UUID.randomUUID().toString())
30                .notBefore(issuedAt)
31                .claims((map) -> map.putAll(context.getClaims()));
32
33        // TODO: Properties
34        JwsAlgorithm jwsAlgorithm = MacAlgorithm.HS256;
35        JwsHeader.Builder jwsHeaderBuilder = JwsHeader.with(jwsAlgorithm);
36
37
38        return this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeaderBuilder.build(), claimsBuilder.build()));
39    }
40}
 1public class JwtDelegatingTokenGenerator implements OAuth2TokenGenerator<OAuth2Token> {
 2    private final List<OAuth2TokenGenerator<OAuth2Token>> tokenGenerators;
 3    @SafeVarargs
 4    public JwtDelegatingTokenGenerator(OAuth2TokenGenerator<? extends OAuth2Token>... OAuth2TokenGenerators) {
 5        Assert.notEmpty(OAuth2TokenGenerators, "jwtTokenGenerators cannot be empty");
 6        Assert.noNullElements(OAuth2TokenGenerators, "jwtTokenGenerator cannot be null");
 7        this.tokenGenerators = Collections.unmodifiableList(asList(OAuth2TokenGenerators));
 8    }
 9
10    @SuppressWarnings("unchecked")
11    private static List<OAuth2TokenGenerator<OAuth2Token>> asList(OAuth2TokenGenerator<? extends OAuth2Token>... oAuth2TokenGenerators) {
12        List<OAuth2TokenGenerator<OAuth2Token>> list = new ArrayList<>();
13
14        for (OAuth2TokenGenerator<? extends OAuth2Token> oAuth2TokenGenerator : oAuth2TokenGenerators) {
15            list.add((OAuth2TokenGenerator<OAuth2Token>) oAuth2TokenGenerator);
16        }
17
18        return list;
19    }
20
21    @Override
22    public OAuth2Token generate(JwtTokenContext context) {
23        for (OAuth2TokenGenerator<OAuth2Token> tokenGenerator : tokenGenerators) {
24            OAuth2Token token = tokenGenerator.generate(context);
25            if (token != null) {
26                return token;
27            }
28        }
29        return null;
30    }
31}

这样即可在接口基础上扩展其他实现类,如 RefreshTokenGenerator 等。

用于构造 Token 的上下文 POJO 实现如下

 1@Getter
 2@Builder
 3public class JwtTokenContext {
 4    private final JwtTokenType type;
 5    private final Map<String, Object> claims;
 6    private final Authentication authentication;
 7
 8    public JwtTokenContext(JwtTokenType type, Map<String, Object> claims, Authentication authentication) {
 9        this.type = type;
10        this.claims = claims;
11        this.authentication = authentication;
12    }
13
14    public static class JwtTokenContextBuilder {
15        public JwtTokenContext build() {
16            Map<JwtTokenType, Function<JwtTokenContextBuilder, Void>> action = new HashMap<>();
17
18            action.put(JwtTokenType.ACCESS_TOKEN, (b) -> {
19                Assert.notNull(b.authentication, "Authentication cannot be null");
20                if (b.claims == null) {
21                    b.claims = new HashMap<>();
22                }
23                return null;
24            });
25            action.put(JwtTokenType.REFRESH_TOKEN, (builder) -> {
26                Assert.notNull(builder.authentication, "Authentication cannot be null");
27                return null;
28            });
29
30            action.get(this.type).apply(this);
31            return new JwtTokenContext(this.type, this.claims, this.authentication);
32        }
33    }
34}

这里重写了 lombok 生成的 Builder,增加了 Build 前的判断。

JwtTokenType 也是一个简单的 POJO

1@Data
2public final class JwtTokenType implements Serializable {
3    private final String value;
4
5    public static final JwtTokenType ACCESS_TOKEN = new JwtTokenType("access_token");
6    public static final JwtTokenType REFRESH_TOKEN = new JwtTokenType("refresh_token");
7}

有了以上包装,就可以简单的实现业务逻辑了。

首先创建一些 Bean

 1@Configuration
 2public class AuthorizationConfiguration {
 3    @Bean
 4    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
 5        http
 6                .httpBasic(AbstractHttpConfigurer::disable)
 7                .formLogin(AbstractHttpConfigurer::disable)
 8                .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
 9                .csrf(AbstractHttpConfigurer::disable)
10                .cors(AbstractHttpConfigurer::disable)
11                .authorizeHttpRequests(c -> c
12                        .requestMatchers("/login/**").permitAll()
13                        .requestMatchers("/error").permitAll()
14                        .requestMatchers("/register").permitAll()
15                        .anyRequest().authenticated()
16                );
17
18        return http.build();
19    }
20
21    @Bean
22    public SecretKey jwtSecretKey(JwtProperties jwtProperties) {
23        // https://docs.oracle.com/en/java/javase/21/docs/specs/security/standard-names.html#keygenerator-algorithms
24        return new SecretKeySpec(jwtProperties.getSecret().getBytes(), "HmacSHA256");
25    }
26
27    @Bean
28    public JwtEncoder jwtEncoder(SecretKey key) {
29        JWKSource<SecurityContext> secret = new ImmutableSecret<>(key);
30        return new NimbusJwtEncoder(secret);
31    }
32
33    @Bean
34    public OAuth2TokenGenerator<OAuth2Token> jwtTokenGenerator(JwtEncoder jwtEncoder, JwtProperties jwtProperties) {
35        JwtAccessTokenGenerator jwtAccessTokenGenerator = new JwtAccessTokenGenerator(jwtEncoder, jwtProperties);
36        return new JwtDelegatingTokenGenerator(jwtAccessTokenGenerator);
37    }
38
39    @Bean
40    public BCryptPasswordEncoder passwordEncoder() {
41        return new BCryptPasswordEncoder();
42    }
43
44    @Bean
45    public JpaUserDetailsService<UserEntity> userDetailsService(UserEntityRepository userEntityRepository) {
46        return new JpaUserDetailsManager<>(userEntityRepository);
47    }
48
49    @Bean
50    public DaoAuthenticationProvider daoAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
51        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
52        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder);
53        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
54        return daoAuthenticationProvider;
55    }
56
57    @Bean
58    public AuthenticationManager authenticationManager(DaoAuthenticationProvider daoAuthenticationProvider, JwtAuthenticationProvider jwtAuthenticationProvider) {
59        return new ProviderManager(daoAuthenticationProvider, jwtAuthenticationProvider);
60    }
61}

接下来根据业务创建 MVC 即可。

 1@RestController
 2public class AuthController {
 3    private final AuthService authService;
 4
 5    public AuthController(AuthService authService) {
 6        this.authService = authService;
 7    }
 8
 9    @PostMapping("/login")
10    public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
11        LoginResponse response = authService.login(loginRequest);
12        return ResponseEntity.status(HttpStatusCode.valueOf(200))
13                .body(response);
14    }
15
16    @PostMapping("/register")
17    public ResponseEntity<?> register(@RequestBody RegisterRequest registerRequest) {
18        UserEntity user = authService.register(registerRequest);
19        return ResponseEntity.status(HttpStatusCode.valueOf(201)).body(
20                new RegisterResponse(user)
21        );
22    }
23
24    @GetMapping("/user/me")
25    public ResponseEntity<?> me(@AuthenticationPrincipal Jwt principal) {
26        return ResponseEntity.ok(principal);
27    }
28}
 1@Service
 2public class AuthService {
 3    private final AuthenticationManager authenticationManager;
 4    private final PasswordEncoder passwordEncoder;
 5    private final OAuth2TokenGenerator OAuth2TokenGenerator;
 6    private final UserEntityRepository userEntityReposity
 7
 8    public AuthService(AuthenticationManager authenticationManager, PasswordEncoder passwordEncoder, OAuth2TokenGenerator OAuth2TokenGenerator, UserEntityRepository userEntityRepository) {
 9        this.authenticationManager = authenticationManager;
10        this.passwordEncoder = passwordEncoder;
11        this.OAuth2TokenGenerator = OAuth2TokenGenerator;
12        this.userEntityRepository = userEntityRepository;
13    }
14
15    public LoginResponse login(LoginRequest loginRequest) {
16        Authentication authentication = authenticationManager.authenticate(
17                new UsernamePasswordAuthenticationToken(loginRequest.username(), loginRequest.password())
18        );
19        UserEntity user = (UserEntity) authentication.getPrincipal();
20
21        JwtTokenContext access = JwtTokenContext.builder()
22                .type(JwtTokenType.ACCESS_TOKEN)
23                .claims(Map.of(
24                        "authorities", user.getAuthorities()
25                ))
26                .authentication(authentication)
27                .build();
28
29        return new LoginResponse(user, OAuth2TokenGenerator.generate(access).getTokenValue());
30    }
31
32    public UserEntity register(RegisterRequest registerRequest) {
33        UserEntity user = (UserEntity) UserEntity.builder()
34                        .username(registerRequest.username())
35                        .password(registerRequest.password())
36                                .passwordEncoder(passwordEncoder::encode)
37                                        .build();
38
39        userEntityRepository.saveAndFlush(user);
40        return user;
41    }
42
43}

Github Repo: panxiao81/spring-security-jwt-using-oauth2

Reference:

OAuth 2.0 Resource Server JWT :: Spring Security

spring-projects/spring-authorization-server