Java · #java#spring-security#oauth2#jwt

Spring Security + OAuth2统一认证实战

2025.04.02 Java 7 min 2.9k
// 目录 · contents

前言

在微服务架构中,统一认证授权是核心基础设施之一。Spring Security作为Java生态中最成熟的安全框架,结合OAuth2协议,能够构建出灵活、安全的认证体系。本文将从Spring Security的核心架构出发,深入讲解OAuth2的各种授权流程,并给出完整的JWT集成方案。

Spring Security 核心架构

graph TB
    Client[客户端请求] --> DFP[DelegatingFilterProxy]
    DFP --> FCSB[FilterChainProxy]
    FCSB --> SF1[SecurityFilterChain 1]
    FCSB --> SF2[SecurityFilterChain 2]

    subgraph SecurityFilterChain
        F1[DisableEncodeUrlFilter]
        F2[SecurityContextPersistenceFilter]
        F3[HeaderWriterFilter]
        F4[CsrfFilter]
        F5[LogoutFilter]
        F6[UsernamePasswordAuthenticationFilter]
        F7[BearerTokenAuthenticationFilter]
        F8[ExceptionTranslationFilter]
        F9[AuthorizationFilter]
    end

    SF1 --> F1 --> F2 --> F3 --> F4 --> F5 --> F6 --> F7 --> F8 --> F9

    F6 --> AM[AuthenticationManager]
    AM --> AP1[DaoAuthenticationProvider]
    AM --> AP2[JwtAuthenticationProvider]
    AP1 --> UDS[UserDetailsService]
    UDS --> DB[(数据库)]

    style FCSB fill:#f96,stroke:#333
    style AM fill:#9cf,stroke:#333

Spring Security的核心是一组Servlet Filter组成的过滤器链。每个请求都会按顺序经过这些过滤器,每个过滤器负责一个特定的安全功能。

核心组件说明

  • FilterChainProxy:Spring Security的入口,管理多条SecurityFilterChain
  • AuthenticationManager:认证管理器,委托给具体的AuthenticationProvider
  • UserDetailsService:加载用户信息的核心接口
  • SecurityContext:存储当前认证信息,通过ThreadLocal传递

OAuth2 授权流程

OAuth2定义了四种授权模式,其中最常用的是授权码模式和客户端凭证模式。

sequenceDiagram
    participant User as 用户
    participant Client as 客户端应用
    participant AuthServer as 授权服务器
    participant Resource as 资源服务器

    Note over User,Resource: 授权码模式 (Authorization Code)

    User->>Client: 1. 访问受保护资源
    Client->>User: 2. 重定向到授权服务器
    User->>AuthServer: 3. 登录并授权
    AuthServer->>User: 4. 返回授权码(code)
    User->>Client: 5. 携带授权码回调
    Client->>AuthServer: 6. 用code换取access_token
    AuthServer->>Client: 7. 返回access_token + refresh_token
    Client->>Resource: 8. 携带access_token请求资源
    Resource->>Client: 9. 返回受保护资源

OAuth2 各授权模式对比

模式 适用场景 安全性 是否涉及用户
授权码模式 Web应用、SPA
PKCE模式 移动端、SPA
客户端凭证 服务间通信
密码模式(已废弃) 高度信任的应用

项目实战搭建

1. 依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependencies>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JWT 支持 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
</dependencies>

2. 自定义 UserDetailsService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Service
public class CustomUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;
private final RoleRepository roleRepository;

public CustomUserDetailsService(UserRepository userRepository,
RoleRepository roleRepository) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + username));

if (!user.isEnabled()) {
throw new DisabledException("Account is disabled: " + username);
}

List<SysRole> roles = roleRepository.findByUserId(user.getId());
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getCode()))
.collect(Collectors.toList());

// 加载权限
List<String> permissions = roleRepository.findPermissionsByRoles(
roles.stream().map(SysRole::getId).toList()
);
permissions.forEach(perm ->
authorities.add(new SimpleGrantedAuthority(perm)));

return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.accountLocked(user.isLocked())
.build();
}
}

3. JWT 令牌服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@Component
public class JwtTokenProvider {

@Value("${jwt.secret}")
private String secretKey;

@Value("${jwt.access-token-expiration:3600}")
private long accessTokenExpiration;

@Value("${jwt.refresh-token-expiration:604800}")
private long refreshTokenExpiration;

private SecretKey key;

@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}

/**
* 生成访问令牌
*/
public String generateAccessToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("authorities", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());

return Jwts.builder()
.setClaims(claims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis()
+ accessTokenExpiration * 1000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

/**
* 生成刷新令牌
*/
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis()
+ refreshTokenExpiration * 1000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

/**
* 从令牌中解析用户名
*/
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

/**
* 验证令牌有效性
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}

@SuppressWarnings("unchecked")
public List<String> extractAuthorities(String token) {
Claims claims = extractAllClaims(token);
return claims.get("authorities", List.class);
}

private <T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(extractAllClaims(token));
}

private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}

4. JWT 认证过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;

public JwtAuthenticationFilter(JwtTokenProvider tokenProvider,
UserDetailsService userDetailsService) {
this.tokenProvider = tokenProvider;
this.userDetailsService = userDetailsService;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {

String token = extractToken(request);

if (token != null && tokenProvider.validateToken(token)) {
String username = tokenProvider.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);
}

chain.doFilter(request, response);
}

private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

5. Security 配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

private final JwtAuthenticationFilter jwtFilter;
private final CustomUserDetailsService userDetailsService;

public SecurityConfig(JwtAuthenticationFilter jwtFilter,
CustomUserDetailsService userDetailsService) {
this.jwtFilter = jwtFilter;
this.userDetailsService = userDetailsService;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new CustomAccessDeniedHandler())
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}

@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}

6. 认证控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@RestController
@RequestMapping("/api/auth")
public class AuthController {

private final AuthenticationManager authManager;
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;

public AuthController(AuthenticationManager authManager,
JwtTokenProvider tokenProvider,
UserDetailsService userDetailsService) {
this.authManager = authManager;
this.tokenProvider = tokenProvider;
this.userDetailsService = userDetailsService;
}

@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));

SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();

String accessToken = tokenProvider.generateAccessToken(userDetails);
String refreshToken = tokenProvider.generateRefreshToken(userDetails);

return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
}

@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(
@RequestBody RefreshTokenRequest request) {

String refreshToken = request.getRefreshToken();
if (!tokenProvider.validateToken(refreshToken)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

String username = tokenProvider.extractUsername(refreshToken);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

String newAccessToken = tokenProvider.generateAccessToken(userDetails);
String newRefreshToken = tokenProvider.generateRefreshToken(userDetails);

return ResponseEntity.ok(new TokenResponse(newAccessToken, newRefreshToken));
}
}

RBAC 权限模型

erDiagram
    SYS_USER ||--o{ USER_ROLE : has
    SYS_ROLE ||--o{ USER_ROLE : has
    SYS_ROLE ||--o{ ROLE_PERMISSION : has
    SYS_PERMISSION ||--o{ ROLE_PERMISSION : has

    SYS_USER {
        bigint id PK
        varchar username UK
        varchar password
        boolean enabled
        boolean locked
        datetime created_at
    }

    SYS_ROLE {
        bigint id PK
        varchar code UK
        varchar name
        varchar description
    }

    SYS_PERMISSION {
        bigint id PK
        varchar code UK
        varchar name
        varchar resource_url
        varchar method
    }

    USER_ROLE {
        bigint user_id FK
        bigint role_id FK
    }

    ROLE_PERMISSION {
        bigint role_id FK
        bigint permission_id FK
    }

方法级权限控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
@RequestMapping("/api/users")
public class UserController {

@GetMapping
@PreAuthorize("hasAuthority('user:list')")
public ResponseEntity<Page<UserVO>> listUsers(Pageable pageable) {
return ResponseEntity.ok(userService.findAll(pageable));
}

@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<UserVO> createUser(@Valid @RequestBody CreateUserRequest req) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.create(req));
}

@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') and #id != authentication.principal.id")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}

@PutMapping("/{id}/roles")
@PreAuthorize("hasAuthority('user:assign-role')")
public ResponseEntity<Void> assignRoles(@PathVariable Long id,
@RequestBody List<Long> roleIds) {
userService.assignRoles(id, roleIds);
return ResponseEntity.ok().build();
}
}

自定义权限评估器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component("perm")
public class CustomPermissionEvaluator {

private final PermissionService permissionService;

public CustomPermissionEvaluator(PermissionService permissionService) {
this.permissionService = permissionService;
}

/**
* 检查当前用户是否有对指定资源的操作权限
*/
public boolean check(Authentication auth, String resource, String action) {
if (auth == null || !auth.isAuthenticated()) {
return false;
}

// 超级管理员拥有所有权限
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_SUPER_ADMIN"))) {
return true;
}

String required = resource + ":" + action;
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(required));
}
}

// 使用自定义权限评估器
@GetMapping("/reports/{id}")
@PreAuthorize("@perm.check(authentication, 'report', 'read')")
public ResponseEntity<Report> getReport(@PathVariable Long id) {
return ResponseEntity.ok(reportService.findById(id));
}

异常处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException {

response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

Map<String, Object> body = Map.of(
"status", 401,
"error", "Unauthorized",
"message", "Authentication required to access this resource",
"path", request.getRequestURI(),
"timestamp", Instant.now().toString()
);

objectMapper.writeValue(response.getOutputStream(), body);
}
}

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException exception) throws IOException {

response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);

Map<String, Object> body = Map.of(
"status", 403,
"error", "Forbidden",
"message", "You don't have permission to access this resource",
"path", request.getRequestURI(),
"timestamp", Instant.now().toString()
);

objectMapper.writeValue(response.getOutputStream(), body);
}
}

安全最佳实践

  1. Token过期策略:Access Token设置较短的过期时间(15-60分钟),通过Refresh Token续期。Refresh Token可以存储在数据库中并支持撤销。

  2. 密码安全:始终使用BCrypt等自适应哈希算法存储密码,设置合理的cost factor(建议12+)。

  3. CORS配置:生产环境严格限制允许的Origin,不要使用 * 通配符。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
  1. 防止暴力破解:实现登录失败次数限制,可以结合Redis实现滑动窗口计数。

  2. 日志审计:记录所有认证事件(登录成功/失败、Token刷新、权限拒绝),便于安全审计和问题排查。

  3. HTTPS强制:生产环境必须使用HTTPS,防止Token在传输过程中被截获。

总结

本文从Spring Security的过滤器链架构出发,讲解了OAuth2的授权流程和JWT令牌机制,并给出了完整的统一认证实战方案。核心要点:

  • Spring Security基于过滤器链实现安全控制,理解过滤器的执行顺序是关键
  • JWT是无状态认证的首选方案,但需要配合合理的过期策略和刷新机制
  • RBAC模型通过用户-角色-权限的三级结构实现灵活的访问控制
  • @PreAuthorize 和自定义权限评估器可以实现细粒度的方法级权限控制
  • 安全是系统工程,需要从传输层(HTTPS)、认证层(JWT)、授权层(RBAC)到审计层(日志)全面考虑
作者 · authorzt
发布 · date2025-04-02
篇幅 · length2.9k 字 · 7 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论