撰于 阅读 43

Spring Security 整合 JWT :实现身份认证

1. 什么是 JWT?

JWT(JSON Web Token)是一种用于在不同应用之间安全传输信息的开放标准(RFC 7519)。它是一种基于 JSON 的轻量级令牌,由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。JWT 被广泛用于实现身份验证和授权,特别适用于前后端分离的应用程序。

令牌类似下面这一大长串:

eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJxdWFueGlhb2hhIiwiaXNzIjoicXVhbnhpYW9oYSIsImlhdCI6MTY5Mjk1OTY2MSwiZXhwIjoxNjkyOTYzMjYxfQ.wbqbn23C9vAe5sQZRCBzrIM4SiN1eNl55NIONmHoiPHPHSSu0QJGgPGUin80hA4XgMHEqN1Wm5KJlmKKucUyGQ

可以看到,由 header.payload.signature 三部分组成,你可以在此网站: https://jwt.io/ 上获得解析结果:

2. 为什么要使用 JWT?

JWT 提供了一种在客户端和服务器之间传输安全信息的简单方法,具有以下优点:

  • 无状态性(Stateless):JWT 本身包含了所有必要的信息,无需在服务器端存储会话信息,每个请求都可以独立验证。
  • 灵活性:JWT 可以存储任意格式的数据,使其成为传递用户信息、权限、角色等的理想选择。
  • 安全性:JWT 使用签名进行验证,确保信息在传输过程中不被篡改。
  • 跨平台和跨语言:由于 JWT 使用 JSON 格式,它在不同的编程语言和平台之间都可以轻松传递。

3. 开始动手

3.1 添加 JWT 依赖

这里我们选择 Java JWT : JSON Web Token for Java and Android (简称 JJWT) 库。首先,在父模块中的 pom.xml 中声明版本号:

    <!-- 版本号统一管理 -->
    <properties>
        // 省略...
        <jjwt.version>0.11.2</jjwt.version>
    </properties>
    
       <!-- 统一依赖管理 -->
    <dependencyManagement>
        <dependencies>
            <!--   JWT   -->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-api</artifactId>
                <version>${jjwt.version}</version>
            </dependency>
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-impl</artifactId>
                <version>${jjwt.version}</version>
            </dependency>
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt-jackson</artifactId>
                <version>${jjwt.version}</version>
            </dependency>
            
        </dependencies>
    </dependencyManagement>

然后,在jwt模块中的 pom.xml 文件中,引入该依赖,添加内容如下:

        <!-- JWT -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
        </dependency>

3.2 JwtTokenHelper 工具类

@Component
public class JwtTokenHelper implements InitializingBean {

    /**
     * 签发人
     */
    @Value("${jwt.issuer}")
    private String issuer;
    /**
     * 秘钥
     */
    private Key key;

    /**
     * JWT 解析
     */
    private JwtParser jwtParser;

    /**
     * 解码配置文件中配置的 Base 64 编码 key 为秘钥
     * @param base64Key
     */
    @Value("${jwt.secret}")
    public void setBase64Key(String base64Key) {
        key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(base64Key));
    }


    /**
     * 初始化 JwtParser
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        // 考虑到不同服务器之间可能存在时钟偏移,setAllowedClockSkewSeconds 用于设置能够容忍的最大的时钟误差
        jwtParser = Jwts.parserBuilder().requireIssuer(issuer)
                .setSigningKey(key).setAllowedClockSkewSeconds(10)
                .build();
    }

    /**
     * 生成 Token
     * @param username
     * @return
     */
    public String generateToken(String username) {
        LocalDateTime now = LocalDateTime.now();
        // Token 一个小时后失效
        LocalDateTime expireTime = now.plusHours(1);

        return Jwts.builder().setSubject(username)
                .setIssuer(issuer)
                .setIssuedAt(Date.from(now.atZone(ZoneId.systemDefault()).toInstant()))
                .setExpiration(Date.from(expireTime.atZone(ZoneId.systemDefault()).toInstant()))
                .signWith(key)
                .compact();
    }

    /**
     * 解析 Token
     * @param token
     * @return
     */
    public Jws<Claims> parseToken(String token) {
        try {
            return jwtParser.parseClaimsJws(token);
        } catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            throw new BadCredentialsException("Token 不可用", e);
        } catch (ExpiredJwtException e) {
            throw new CredentialsExpiredException("Token 失效", e);
        }
    }

    /**
     * 生成一个 Base64 的安全秘钥
     * @return
     */
    private static String generateBase64Key() {
        // 生成安全秘钥
        Key secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS512);

        // 将密钥进行 Base64 编码
        String base64Key = Base64.getEncoder().encodeToString(secretKey.getEncoded());

        return base64Key;
    }

    public static void main(String[] args) {
        String key = generateBase64Key();
        System.out.println("key: " + key);
    }
}

上述代码中,Token 令牌的初始化工作在 generateToken() 方法中完成,主要是通过 Jwts.builder 返回的 JwtBuilder 来做的。令牌的解析工作交给了 JwtParser 类,在 parseToken() 方法中完成。

与之对应的,工具类中注入的一些参数,如 jwt 的签发人、秘钥,需要在 applicaiton.yml 中配置好:

jwt:
  # 签发人
  issuer: chengzi
  # 秘钥
  secret: jElxcSUj38+Bnh73T68lNs0DfBSit6U3whQlcGO2XwnI+Bo3g4xsiCIPg8PV/L0fQMis08iupNwhe2PzYLB9Xg==

3.3 如何生成安全的秘钥?

在 JwtTokenHelper 中,已经定义好了一个 generateBase64Key() 方法,它专门用于生成一个 Base64 的安全秘钥,执行 main() 方法即可,然后将生成好的秘钥配置到 yml 文件中。

3.4 PasswordEncoder 密码加密

在系统中,安全存储用户密码是至关重要的。使用明文存储密码容易受到攻击,相信大家都看过某些网站用户账户被黑,密码都是明文保存的新闻,因此使用密码加密技术来保护用户密码是必不可少的。

在 jwt 模块中新建 config 包,并创建 PasswordEncoderConfig 配置类,代码如下:

@Component
public class PasswordEncoderConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
        return new BCryptPasswordEncoder();
    }

    public static void main(String[] args) {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode("chengzi"));
    }
}

PasswordEncoder 接口是 Spring Security 提供的密码加密接口,它定义了密码加密和密码验证的方法。通过实现这个接口,您可以将密码加密为不可逆的哈希值,以及在验证密码时对比哈希值。

4. 实现 UserDetailsService:Spring Security 用户详情服务

4.1 什么是 UserDetailsService?

UserDetailsService 是 Spring Security 提供的接口,用于从应用程序的数据源(如数据库、LDAP、内存等)中加载用户信息。它是一个用于将用户详情加载到 Spring Security 的中心机制。UserDetailsService 主要负责两项工作:

  • 加载用户信息: 从数据源中加载用户的用户名、密码和角色等信息。
  • 创建 UserDetails 对象: 根据加载的用户信息,创建一个 Spring Security 所需的 UserDetails 对象,包含用户名、密码、角色和权限等。

4.2 自定义实现类

新建 service 包,并创建 UserDetailServiceImpl 实现类:

@Service
@Slf4j
public class UserDetailServiceImpl implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 从数据库中查询
        // ...

        // 暂时先写死,密码为 chengzi, 这里填写的密文,数据库中也是存储此种格式
        // authorities 用于指定角色,这里写死为 ADMIN 管理员
        return User.withUsername("chengzi")
                .password("$2a$10$n7RJ1q.RnXx5M3O6B0i0he04fZOPjIJpyWcKuicW1bFyFHWhlGose")
                .authorities("ADMIN")
                .build();
    }
}

上述代码中,我们实现了 UserDetailsService 接口,并重写了 loadUserByUsername() 方法,该方法用于根据用户名加载用户信息的逻辑 ,正常需要从数据库中查询,这里我们先写死,继续开发后面的功能,后续再回过头来改造。

5. 自定义认证过滤器

接下来,我们自定义一个用于认证的过滤器,新建 /filter 包,并创建JwtAuthenticationFilter 过滤器,代码如下:

public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {


    /**
     * 指定用户登录的访问地址
     */
    public JwtAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        ObjectMapper mapper = new ObjectMapper();
        // 解析提交的 JSON 数据
        JsonNode jsonNode = mapper.readTree(request.getInputStream());
        JsonNode usernameNode = jsonNode.get("username");
        JsonNode passwordNode =  jsonNode.get("password");

        // 判断用户名、密码是否为空
        if (Objects.isNull(usernameNode) || Objects.isNull(passwordNode)
            || StringUtils.isBlank(usernameNode.textValue()) || StringUtils.isBlank(passwordNode.textValue())) {
            throw new UsernameOrPasswordNullException("用户名或密码不能为空");
        }

        String username = usernameNode.textValue();
        String password = passwordNode.textValue();

        // 将用户名、密码封装到 Token 中
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                = new UsernamePasswordAuthenticationToken(username, password);
        return getAuthenticationManager().authenticate(usernamePasswordAuthenticationToken);
    }
}

此过滤器继承了 AbstractAuthenticationProcessingFilter,用于处理 JWT(JSON Web Token)的用户身份验证过程。在构造函数中,调用了父类 AbstractAuthenticationProcessingFilter 的构造函数,通过 AntPathRequestMatcher 指定了处理用户登录的访问地址。这意味着当请求路径匹配 /login 并且请求方法为 POST 时,该过滤器将被触发。

attemptAuthentication() 方法用于实现用户身份验证的具体逻辑。首先,我们解析了提交的 JSON 数据,并获取了用户名、密码,校验是否为空,若不为空,则将它们封装到 UsernamePasswordAuthenticationToken 中。最后,使用 getAuthenticationManager().authenticate() 来触发 Spring Security 的身份验证管理器执行实际的身份验证过程,然后返回身份验证结果。

6. 自定义用户名或密码不能为空异常

上面过滤器代码中,有个动作是校验用户名、密码是否为空,为空则抛出 UsernameOrPasswordNullException 异常,此类是自定义的得来的。新建包 /exception, 在此包中创建该类:

public class UsernameOrPasswordNullException extends AuthenticationException {
    public UsernameOrPasswordNullException(String msg, Throwable cause) {
        super(msg, cause);
    }

    public UsernameOrPasswordNullException(String msg) {
        super(msg);
    }
}

注意,需继承自 AuthenticationException,只有该类型异常,才能被后续自定义的认证失败处理器捕获到。

7. 自定义处理器

用户登录后,我们还需要处理其对应的结果,如登录成功,则返回 Token 令牌,登录失败,则返回对应的提示信息。在 Spring Security 中,AuthenticationFailureHandler 和 AuthenticationSuccessHandler 是用于处理身份验证失败和成功的接口。它们允许您在用户身份验证过程中自定义响应,以便更好地控制和定制用户体验。

7.1 自定义认证成功处理器 RestAuthenticationSuccessHandler

新建 /handler 包,并创建 RestAuthenticationSuccessHandler 类:

@Component
@Slf4j
public class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private JwtTokenHelper jwtTokenHelper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 从 authentication 对象中获取用户的 UserDetails 实例,这里是获取用户的用户名
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        // 通过用户名生成 Token
        String username = userDetails.getUsername();
        String token = jwtTokenHelper.generateToken(username);

        // 返回 Token
        LoginRspVO loginRspVO = LoginRspVO.builder().token(token).build();

        ResultUtil.ok(response, Response.success(loginRspVO));
    }
}

此类实现了 Spring Security 的 AuthenticationSuccessHandler 接口,用于处理身份验证成功后的逻辑。首先,从 authentication 对象中获取用户的 UserDetails 实例,这里是主要是获取用户的用户名,然后通过用户名生成 Token 令牌,最后返回数据。

7.2 自定义认证失败处理器

在 /handler 包下,创建 RestAuthenticationFailureHandler 认证失败处理器:

@Component
@Slf4j
public class RestAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.warn("AuthenticationException: ", exception);
        if (exception instanceof UsernameOrPasswordNullException) {
            // 用户名或密码为空
            ResultUtil.fail(response, Response.fail(exception.getMessage()));
        } else if (exception instanceof BadCredentialsException) {
            // 用户名或密码错误
            ResultUtil.fail(response, Response.fail(ResponseCodeEnum.USERNAME_OR_PWD_ERROR));
        }

        // 登录失败
        ResultUtil.fail(response, Response.fail(ResponseCodeEnum.LOGIN_FAIL));
    }
}

通过自定义了一个实现了 Spring Security 的 AuthenticationFailureHandler 接口类,用于在用户身份验证失败后执行一些逻辑。首先,我们打印了异常日志,方便后续定位问题,然后对异常的类型进行判断,通过 ResultUtil 工具类,返回不同的错误信息,如用户名或者密码为空、用户名或密码错误等,若未判断出异常是什么类型,则统一提示为 登录失败。

8. 自定义 JWT 认证功能配置

完成了以上前置工作后,我们开始配置 JWT 认证相关的配置。在 /config 包下新建 JwtAuthenticationSecurityConfig, 代码如下:

@Configuration
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private RestAuthenticationSuccessHandler restAuthenticationSuccessHandler;

    @Autowired
    private RestAuthenticationFailureHandler restAuthenticationFailureHandler;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        // 自定义的用于 JWT 身份验证的过滤器
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
        filter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));

        // 设置登录认证对应的处理类(成功处理、失败处理)
        filter.setAuthenticationSuccessHandler(restAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(restAuthenticationFailureHandler);

        // 直接使用 DaoAuthenticationProvider, 它是 Spring Security 提供的默认的身份验证提供者之一
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        // 设置 userDetailService,用于获取用户的详细信息
        provider.setUserDetailsService(userDetailsService);
        // 设置加密算法
        provider.setPasswordEncoder(passwordEncoder);
        httpSecurity.authenticationProvider(provider);
        // 将这个过滤器添加到 UsernamePasswordAuthenticationFilter 之前执行
        httpSecurity.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    }
}

上述代码是一个 Spring Security 配置类,用于配置 JWT(JSON Web Token)的身份验证机制。它继承了 Spring Security 的 SecurityConfigurerAdapter 类,用于在 Spring Security 配置中添加自定义的认证过滤器和提供者。通过重写 configure() 方法,我们将之前写好过滤器、认证成功、失败处理器,以及加密算法整合到了 httpSecurity 中。

9. 应用 JWT 认证功能配置

接下来,我们编辑验证模块中的 Spring Security 配置 WebSecurityConfig 类,修改内容如下:

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable(). // 禁用 csrf
                formLogin().disable() // 禁用表单登录
                .apply(jwtAuthenticationSecurityConfig) // 设置用户登录认证相关配置
             .and()
                .authorizeHttpRequests()
                .mvcMatchers("/admin/**").authenticated() // 认证所有以 /admin 为前缀的 URL 资源
                .anyRequest().permitAll() // 其他都需要放行,无需认证
             .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 前后端分离,无需创建会话
    }
}

上述代码中,在 configure() 方法中,首先禁用了 CSRF(Cross-Site Request Forgery)攻击防护。在前后端分离的情况下,通常不需要启用 CSRF 防护。同时,还禁用了表单登录,并应用了 JWT 相关的配置类 JwtAuthenticationSecurityConfig。最后,配置会话管理这块,将会话策略设置为无状态(STATELESS),适用于前后端分离的情况,无需创建会话。

10. 结语

Spring Security JWT 提供了一种安全且灵活的方式来实现身份验证和授权,适用于前后端分离的应用程序。通过使用 JWT,您可以实现无状态的身份验证机制,提高应用程序的安全性和可维护性。