Spring Security跨服务认证的JWT共享机制实现方案

Spring Security跨服务认证的JWT共享机制实现方案

大家好,今天我们来聊聊在微服务架构下,如何利用JWT (JSON Web Token) 实现跨服务的认证与授权。在单体应用中,Spring Security配合Session机制可以很好地完成认证授权,但在微服务架构中,每个服务都是独立部署和扩展的,Session共享变得复杂且难以维护。JWT由于其无状态性,成为了微服务认证授权的首选方案。

1. 认证授权的挑战与JWT的优势

在微服务架构中,用户认证和授权面临以下挑战:

  • 服务间认证: 各个服务如何确认请求来自已认证的用户?
  • Session共享问题: 如何在多个服务间共享Session信息?传统Session共享方案(如Session复制、共享Session存储)复杂且存在性能瓶颈。
  • 单点登录 (SSO): 如何实现用户在一个地方登录,所有服务都自动认证?

JWT的优势在于:

  • 无状态性: JWT包含用户身份信息,服务无需保存Session,每次请求都携带JWT进行验证。
  • 可扩展性: 无需共享Session,服务可以独立扩展。
  • 跨域支持: JWT可以方便地用于跨域认证。
  • 安全性: JWT可以使用签名算法保证其完整性和不可篡改性。

2. JWT的基本原理

JWT是一个字符串,由三个部分组成,分别是:

  • Header (头部): 描述JWT的元数据,通常包含签名算法和Token类型。例如:
{
  "alg": "HS256",
  "typ": "JWT"
}
  • Payload (载荷): 包含用户的声明信息,例如用户ID、用户名、权限等。Payload可以包含注册声明 (Registered Claims)、公共声明 (Public Claims) 和私有声明 (Private Claims)。例如:
{
  "sub": "user123",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}
  • Signature (签名): 通过将Header和Payload进行Base64编码后,使用Header中指定的签名算法(如HMAC SHA256)和一个密钥进行签名,用于验证Token的完整性。例如:
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

这三部分用.连接起来,就形成了完整的JWT。

3. Spring Security集成JWT的实现方案

下面我们通过一个简单的例子,演示如何在Spring Security中集成JWT,实现跨服务的认证授权。假设我们有两个服务:Auth Service (认证服务) 和 Resource Service (资源服务)。

3.1 Auth Service (认证服务)

Auth Service负责用户认证,成功后生成JWT并返回给客户端。

  • 依赖:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
  • 实体类 (User):
import lombok.Data;

@Data
public class User {
    private String username;
    private String password;
    private String role; // 用户的角色,例如 "ADMIN", "USER"
}
  • JWT工具类 (JwtUtil):
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

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

    @Value("${jwt.expiration}")
    private long expiration;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public String generateToken(String username, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .signWith(SignatureAlgorithm.HS256, secret).compact();
    }

    public Boolean validateToken(String token, String username) {
        final String usernameFromToken = extractUsername(token);
        return (usernameFromToken.equals(username) && !isTokenExpired(token));
    }
}
  • 配置application.properties:
jwt.secret=yourSecretKey
jwt.expiration=3600 # Token过期时间,单位秒
  • 认证Controller:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

    @Autowired
    private JwtUtil jwtUtil;

    // 模拟用户认证
    private boolean authenticate(String username, String password) {
        // 在实际场景中,你需要从数据库或其他地方验证用户凭据
        return "admin".equals(username) && "password".equals(password);
    }

    @PostMapping("/authenticate")
    public ResponseEntity<?> authenticate(@RequestBody User user) {
        if (authenticate(user.getUsername(), user.getPassword())) {
            // 假设用户角色为 "ADMIN"
            String token = jwtUtil.generateToken(user.getUsername(), "ADMIN");
            return ResponseEntity.ok(new AuthenticationResponse(token));
        } else {
            return ResponseEntity.status(401).body("Invalid credentials");
        }
    }
}

//  简单的响应类
@Data
class AuthenticationResponse {
    private final String token;
}
  • Spring Security配置 (SecurityConfig):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/authenticate").permitAll() // 允许访问 /authenticate 接口
                .anyRequest().authenticated(); // 其他请求需要认证
    }

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

3.2 Resource Service (资源服务)

Resource Service需要验证请求头中的JWT,并根据JWT中的信息进行授权。

  • 依赖: (与Auth Service相同)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.11.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version>
    <scope>runtime</scope>
</dependency>
  • JWT工具类 (JwtUtil): (与Auth Service相同)
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

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

    @Value("${jwt.expiration}")
    private long expiration;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public String generateToken(String username, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);
        return createToken(claims, username);
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .signWith(SignatureAlgorithm.HS256, secret).compact();
    }

    public Boolean validateToken(String token, String username) {
        final String usernameFromToken = extractUsername(token);
        return (usernameFromToken.equals(username) && !isTokenExpired(token));
    }
}
  • 配置application.properties: (注意:jwt.secret 必须与Auth Service一致)
jwt.secret=yourSecretKey
jwt.expiration=3600 # Token过期时间,单位秒
  • JWT过滤器 (JwtRequestFilter):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService; // 模拟UserDetailsService,实际从数据库获取用户信息

    @Autowired
    private JwtUtil jwtUtil;

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

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            try {
                username = jwtUtil.extractUsername(jwt);
            } catch (Exception e) {
               // Token解析失败,记录日志
                System.err.println("Token解析失败: " + e.getMessage());
            }

        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); // 模拟从数据库获取用户信息,实际从数据库获取

            if (jwtUtil.validateToken(jwt, username)) {

                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }
}
  • UserDetailsService (模拟):
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 模拟从数据库获取用户信息
        if ("admin".equals(username)) {
            return new User("admin", "password", new ArrayList<>()); // 密码不重要,因为JWT已经验证过了
        } else {
            throw new UsernameNotFoundException("User not found: " + username);
        }
    }
}
  • 资源Controller:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ResourceController {

    @GetMapping("/resource")
    public String getResource() {
        return "This is a protected resource!";
    }

    @GetMapping("/admin/resource")
    public String getAdminResource() {
        return "This is an admin protected resource!";
    }
}
  • Spring Security配置 (SecurityConfig):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService myUserDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(myUserDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/authenticate").permitAll() // 允许访问 /authenticate 接口
                .antMatchers("/admin/**").hasAuthority("ADMIN") // 需要ADMIN权限
                .anyRequest().authenticated() // 其他请求需要认证
                .and().sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 无状态Session

        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

3.3 流程说明

  1. 客户端向Auth Service的/authenticate接口发送用户名和密码。
  2. Auth Service验证用户凭据,如果验证成功,则生成JWT。JWT的Payload中包含用户名和角色信息。
  3. Auth Service将JWT返回给客户端。
  4. 客户端将JWT存储在本地 (例如,LocalStorage或Cookie)。
  5. 客户端在访问Resource Service时,将JWT放在Authorization请求头中,格式为Bearer <JWT>
  6. Resource Service的JwtRequestFilter拦截请求,从Authorization头中提取JWT。
  7. JwtRequestFilter使用JwtUtil验证JWT的签名和过期时间。
  8. 如果JWT有效,JwtRequestFilter从JWT中提取用户名和角色信息,并创建一个UsernamePasswordAuthenticationToken
  9. JwtRequestFilterUsernamePasswordAuthenticationToken设置到SecurityContextHolder中。
  10. Resource Service根据SecurityContextHolder中的认证信息进行授权,判断用户是否具有访问资源的权限。

4. JWT共享的关键点

  • 统一的密钥 (secret): Auth Service和Resource Service必须使用相同的密钥来签名和验证JWT。密钥应该安全地存储,并定期轮换。
  • 统一的JWT工具类 (JwtUtil): Auth Service和Resource Service可以使用相同的JwtUtil类,或者各自实现,但必须保证签名算法和密钥一致。
  • 清晰的权限设计: Payload中的权限信息需要清晰定义,方便Resource Service进行授权判断。可以使用角色 (Role-Based Access Control, RBAC) 或权限 (Permission-Based Access Control) 模型。

5. 高级话题

  • 刷新Token (Refresh Token): 为了避免频繁重新登录,可以使用刷新Token机制。当JWT过期时,客户端可以使用刷新Token向Auth Service请求新的JWT。
  • JWT存储: 客户端可以安全地存储JWT,例如使用HttpOnly Cookie或加密的LocalStorage。
  • OAuth 2.0集成: 可以将JWT与OAuth 2.0协议结合使用,实现更完善的认证授权流程。
  • 服务发现与配置中心: 在微服务架构中,可以使用服务发现 (例如Eureka, Consul) 和配置中心 (例如Spring Cloud Config, Apollo) 来动态管理Auth Service和Resource Service的配置信息,包括JWT密钥。
  • OIDC (OpenID Connect): OIDC 是一个基于 OAuth 2.0 的身份验证协议。它允许客户端验证用户的身份,并获取用户的基本信息,例如姓名、电子邮件地址等。OIDC 可以简化认证流程,并提供更高的安全性。

6. 代码示例:刷新Token的实现

为了支持刷新Token,我们需要在Auth Service中增加以下功能:

  • 数据库存储RefreshToken: 创建一个RefreshToken表,用于存储RefreshToken以及与之关联的用户信息。
  • 生成RefreshToken的接口: 在用户登录成功后,除了生成JWT,还需要生成一个RefreshToken并将其存储到数据库中。
  • 刷新Token的接口: 提供一个接口,接收RefreshToken,验证其有效性,如果有效则生成新的JWT和RefreshToken,并更新数据库中的RefreshToken。

以下是示例代码:

  • RefreshToken实体类:
import lombok.Data;

import javax.persistence.*;
import java.time.Instant;

@Entity
@Data
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String token;

    private String username;

    private Instant expiryDate;
}
  • RefreshTokenRepository接口:
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);
    void deleteByUsername(String username);
}
  • RefreshTokenService类:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.UUID;

@Service
public class RefreshTokenService {

    @Value("${jwt.refresh.expiration}")
    private Long refreshTokenDurationMs;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    public RefreshToken createRefreshToken(String username) {
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUsername(username);
        refreshToken.setToken(UUID.randomUUID().toString());
        refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));

        refreshToken = refreshTokenRepository.save(refreshToken);
        return refreshToken;
    }

    public RefreshToken verifyExpiration(RefreshToken token) {
        if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
            refreshTokenRepository.delete(token);
            throw new RuntimeException(token.getToken() + " Refresh token was expired. Please make a new signin request");
        }

        return token;
    }

    public void deleteByUsername(String username) {
        refreshTokenRepository.deleteByUsername(username);
    }
}
  • AuthController更新:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private RefreshTokenService refreshTokenService;

    // 模拟用户认证
    private boolean authenticate(String username, String password) {
        // 在实际场景中,你需要从数据库或其他地方验证用户凭据
        return "admin".equals(username) && "password".equals(password);
    }

    @PostMapping("/authenticate")
    public ResponseEntity<?> authenticate(@RequestBody User user) {
        if (authenticate(user.getUsername(), user.getPassword())) {
            // 假设用户角色为 "ADMIN"
            String token = jwtUtil.generateToken(user.getUsername(), "ADMIN");
            RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getUsername());

            return ResponseEntity.ok(new AuthenticationResponse(token, refreshToken.getToken()));
        } else {
            return ResponseEntity.status(401).body("Invalid credentials");
        }
    }
}

//  更新响应类
@Data
class AuthenticationResponse {
    private final String token;
    private final String refreshToken;
}
  • 新增刷新token接口:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RefreshTokenController {

    @Autowired
    private RefreshTokenService refreshTokenService;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/refreshtoken")
    public ResponseEntity<?> refreshtoken(@RequestBody RefreshTokenRequest request) {
        String requestRefreshToken = request.getRefreshToken();

        return refreshTokenRepository.findByToken(requestRefreshToken)
                .map(refreshTokenService::verifyExpiration)
                .map(RefreshToken::getUsername)
                .map(username -> {
                    String token = jwtUtil.generateToken(username, "ADMIN"); // 假设刷新后角色不变
                    return ResponseEntity.ok(new AuthenticationResponse(token, requestRefreshToken));
                })
                .orElseThrow(() -> new RuntimeException("Refresh token is not in database!"));
    }
}

@Data
class RefreshTokenRequest {
    private String refreshToken;
}
  • application.properties添加refreshToken过期时间:
jwt.refresh.expiration=86400000 # Refresh Token过期时间,单位毫秒,这里设置为24小时

这个代码示例展示了如何在Auth Service中实现RefreshToken的生成、存储和验证。 在客户端,需要修改逻辑为:如果JWT过期,则使用RefreshToken向/refreshtoken接口请求新的JWT和RefreshToken。

7. 总结一下

通过以上步骤,我们实现了一个基于JWT的跨服务认证授权方案。该方案具有无状态、可扩展、跨域支持等优点,适用于微服务架构。需要注意的是,密钥管理、权限设计和刷新Token机制是保障安全性的关键。此外,还可以考虑与OAuth 2.0和OIDC等协议集成,构建更完善的认证授权体系。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注