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
即为 JwtDecoder
中 decode
方法返回的 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:
评论