Spring Security With JWT 入门教程

概述

GitHub 源码地址:https://github.com/yifanzheng/spring-security-jwt

Spring Security 是 Spring 全家桶中一个功能强大且高度可定制的身份验证和访问控制框架。与所有 Spring 项目一样,我们可以轻松扩展 Spring Security 以满足自定义要求。

由于 Spring Security 功能十分强大,相比于其他技术来说很难上手,很多刚接触 Spring Security 的开发者很难通过文档或者视频就能将其进行运用到实际开发中。

在公司实习的时候接触到的一个项目就使用了 Spring Security 这个强大的安全验证框架来完成用户的登录模块,并且也是自己负责的一个模块。当时自己对 Spring Security 基本不熟悉,可以说是第一次接触,查阅了很多关于这方面的资料,看得似懂非懂的,并且还在导师的指导下都花了将近一周的时间才勉强完成。

Spring Security 对于初学者来说,的确很难上手。于是自己在工作之余对这部分知识进行了学习,并实现了一个简单的项目,主要使用了 Spring Boot 技术集成 Spring Security 和 Spring Data Jpa 技术。这个项目实现的比较简单,还有很多地方需要优化,希望有兴趣的朋友可以一起完善,期待你的 PR。

项目下载

权限控制

本 Demo 权限控制采用 RBAC 思想。简单地说,一个用户拥有若干角色,用户与角色形成多对多关系。

模型
权限模型

数据表设计

用户表与用户角色表是多对多的关系。因为这里比较简单,所以表设计上有点冗余。小伙伴们可以根据实际情况重新设计。
表设计

数据交互

用户登录 -> 后端验证登录并返回 token -> 前端携带 token 请求后端数据 -> 后端返回数据。
数据交互

项目核心类说明

WebCorsConfiguration

WebCorsConfiguration 配置类,主要解决 HTTP 请求跨域问题。这里需要注意的是,如果没有将 Authorization 头字段暴露给客户端的话,客户端是无法获取到 Token 信息的。

1
/**
2
 * WebCorsConfiguration 跨域配置
3
 *
4
 * @author star
5
 */
6
@Configuration
7
public class WebCorsConfiguration implements WebMvcConfigurer {
8
9
    /**
10
     * 设置swagger为默认主页
11
     */
12
    @Override
13
    public void addViewControllers(ViewControllerRegistry registry) {
14
        registry.addViewController("/").setViewName("redirect:/swagger-ui.html");
15
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
16
        WebMvcConfigurer.super.addViewControllers(registry);
17
    }
18
19
    @Bean
20
    public CorsFilter corsFilter() {
21
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
22
        CorsConfiguration config = new CorsConfiguration();
23
        config.setAllowCredentials(true);
24
        config.setAllowedOrigins(Collections.singletonList("*"));
25
        config.setAllowedMethods(Collections.singletonList("*"));
26
        config.setAllowedHeaders(Collections.singletonList("*"));
27
        // 暴露 header 中的其他属性给客户端应用程序
28
        config.setExposedHeaders(Arrays.asList(
29
                "Authorization", "X-Total-Count", "Link",
30
                "Access-Control-Allow-Origin",
31
                "Access-Control-Allow-Credentials"
32
        ));
33
        source.registerCorsConfiguration("/**", config);
34
        return new CorsFilter(source);
35
    }
36
37
}

WebSecurityConfig

WebSecurityConfig 配置类继承了 Spring Security 的 WebSecurityConfigurerAdapter 类。WebSecurityConfigurerAdapter 类提供了默认的安全配置,并允许其他类通过覆盖其方法来扩展它并自定义安全配置。

这里配置了如下内容:

  • 忽略某些不需要验证的就能访问的资源路径;

  • 设置 CustomAuthenticationProvider 自定义身份验证组件,用于验证用户的登录信息(用户名和密码);

  • 在 Spring Security 机制中配置需要验证后才能访问的资源路径、不需要验证就可以访问的资源路径以及指定某些资源只能被特定角色访问。

  • 配置请求权限认证异常时的处理类;

  • 将自定义的 JwtAuthenticationFilterJwtAuthorizationFilter 两个过滤器添加到 Spring Security 机制中。

1
2
/**
3
 * Web 安全配置
4
 *
5
 * @author star
6
 **/
7
@Configuration
8
@EnableWebSecurity
9
@EnableGlobalMethodSecurity(prePostEnabled = true)
10
@Import(SecurityProblemSupport.class)
11
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
12
13
    @Autowired
14
    private CorsFilter corsFilter;
15
16
    @Autowired
17
    private SecurityProblemSupport securityProblemSupport;
18
19
    /**
20
     * 使用 Spring Security 推荐的加密方式进行登录密码的加密
21
     */
22
    @Bean
23
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
24
        return new BCryptPasswordEncoder();
25
    }
26
27
    /**
28
     * 此方法配置的资源路径不会进入 Spring Security 机制进行验证
29
     */
30
    @Override
31
    public void configure(WebSecurity web) {
32
        web.ignoring()
33
                .antMatchers(HttpMethod.OPTIONS, "/**")
34
                .antMatchers("/app/**/*.{js,html}")
35
                .antMatchers("/v2/api-docs/**")
36
                .antMatchers("/i18n/**")
37
                .antMatchers("/test/**")
38
                .antMatchers("/content/**")
39
                .antMatchers("/webjars/springfox-swagger-ui/**")
40
                .antMatchers("/swagger-resources/**")
41
                .antMatchers("/swagger-ui.html");
42
    }
43
44
    // TODO 如果将登录接口暴露在 Controller 层,则注释此配置
45
    //@Override
46
    //protected void configure(AuthenticationManagerBuilder authenticationManagerBuilder) {
47
    //    // 设置自定义身份验证组件,用于从数据库中验证用户登录信息(用户名和密码)
48
    //    CustomAuthenticationProvider authenticationProvider = new CustomAuthenticationProvider(bCryptPasswordEncoder());
49
    //    authenticationManagerBuilder.authenticationProvider(authenticationProvider);
50
    //}
51
52
    /**
53
     * 定义安全策略,设置 HTTP 访问规则
54
     */
55
    @Override
56
    protected void configure(HttpSecurity http) throws Exception {
57
        http
58
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
59
                .exceptionHandling()
60
                // 当用户无权访问资源时发送 401 响应
61
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
62
                // 当用户访问资源因权限不足时发送 403 响应
63
                .accessDeniedHandler(securityProblemSupport)
64
             .and()
65
                // 禁用 CSRF
66
                .csrf().disable()
67
                .headers().frameOptions().disable()
68
             .and()
69
                .logout().logoutUrl("/auth/logout").and()
70
                .authorizeRequests()
71
                 // 指定路径下的资源需要进行验证后才能访问
72
                .antMatchers("/").permitAll()
73
                // 配置登录地址
74
                .antMatchers(HttpMethod.POST, SecurityConstants.AUTH_LOGIN_URL).permitAll()
75
                .antMatchers(HttpMethod.POST,"/api/users/register").permitAll()
76
                // 其他请求需验证
77
                .anyRequest().authenticated()
78
             .and()
79
                // TODO 添加用户登录验证过滤器,将登录请求交给此过滤器处理,如果将登录接口暴露在 Controller 层,则注释这行
80
               //  .addFilter(new JwtAuthenticationFilter(authenticationManager()))
81
                // 不需要 session(不创建会话)
82
                .sessionManagement()
83
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
84
             .and()
85
               .apply(securityConfigurationAdapter());
86
        super.configure(http);
87
    }
88
89
    private JwtConfigurer securityConfigurationAdapter() throws Exception{
90
        return new JwtConfigurer(new JwtAuthorizationFilter(authenticationManager()));
91
    }
92
}

CustomAuthenticationProvider (已过时)

CustomAuthenticationProvider 自定义用户身份验证组件类,它用于验证用户登录信息是否正确。需要将其配置到 Spring Sercurity 机制中才能使用。

1
/**
2
 * CustomAuthenticationProvider 自定义用户身份验证组件
3
 *
4
 * <p>
5
 * 提供用户登录密码验证功能。根据用户名从数据库中取出用户信息,进行密码验证,验证通过则赋予用户相应权限。
6
 *
7
 * @author star
8
 */
9
public class CustomAuthenticationProvider implements AuthenticationProvider {
10
11
    private final UserService userService;
12
13
    private BCryptPasswordEncoder bCryptPasswordEncoder;
14
15
    public CustomAuthenticationProvider(BCryptPasswordEncoder bCryptPasswordEncoder) {
16
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
17
        this.userService = SpringSecurityContextHelper.getBean(UserService.class);
18
    }
19
20
    @Override
21
    public Authentication authenticate(Authentication authentication) throws BadCredentialsException, UsernameNotFoundException {
22
        // 获取验证信息中的用户名和密码 (即登录请求中的用户名和密码)
23
        String userName = authentication.getName();
24
        String password = authentication.getCredentials().toString();
25
        // 根据登录名获取用户信息
26
        User user = userService.getUserByName(userName);
27
        // 验证登录密码是否正确。如果正确,则赋予用户相应权限并生成用户认证信息
28
        if (user != null && this.bCryptPasswordEncoder.matches(password, user.getPassword())) {
29
            List<String> roles = userService.listUserRoles(userName);
30
            // 如果用户角色为空,则默认赋予 ROLE_USER 权限
31
            if (CollectionUtils.isEmpty(roles)) {
32
                roles = Collections.singletonList(UserRoleConstants.ROLE_USER);
33
            }
34
            // 设置权限
35
            List<GrantedAuthority> authorities = roles.stream()
36
                    .map(SimpleGrantedAuthority::new)
37
                    .collect(Collectors.toList());
38
            // 生成认证信息
39
            return new UsernamePasswordAuthenticationToken(userName, password, authorities);
40
        }
41
        // 验证不成功就抛出异常
42
        throw new BadCredentialsException("The userName or password error.");
43
44
    }
45
46
    @Override
47
    public boolean supports(Class<?> aClass) {
48
        return aClass.equals(UsernamePasswordAuthenticationToken.class);
49
    }
50
51
}

JwtAuthenticationFilter(已过时)

JwtAuthenticationFilter 用户登录验证过滤器,主要配合 CustomAuthenticationProvider 对用户登录请求进行验证,检查登录名和登录密码。如果验证成功,则生成 token 返回。

1
/**
2
 * JwtAuthenticationFilter 用户登录验证过滤器
3
 *
4
 * <p>
5
 * 用于验证使用 URL 地址是 {@link SecurityConstants#AUTH_LOGIN_URL} 进行登录的用户请求。
6
 * 通过检查请求中的用户名和密码参数,并调用 Spring 的身份验证管理器进行验证。
7
 * 如果用户名和密码正确,那么过滤器将创建一个 token,并在 Authorization 标头中将其返回。
8
 * 格式:Authorization: "Bearer + 具体 token 值"</p>
9
 *
10
 * @author star
11
 **/
12
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
13
14
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
15
16
    private final AuthenticationManager authenticationManager;
17
18
    private final ThreadLocal<Boolean> rememberMeLocal = new ThreadLocal<>();
19
20
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
21
        this.authenticationManager = authenticationManager;
22
        // 指定需要验证的登录 URL
23
        super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL);
24
    }
25
26
    @Override
27
    public Authentication attemptAuthentication(HttpServletRequest request,
28
                                                HttpServletResponse response) throws AuthenticationException {
29
        try {
30
            // 获取用户登录信息,JSON 反序列化成 UserDTO 对象
31
            UserLoginDTO loginUser = new ObjectMapper().readValue(request.getInputStream(), UserLoginDTO.class);
32
            rememberMeLocal.set(loginUser.getRememberMe());
33
            // 根据用户名和密码生成身份验证信息
34
            Authentication authentication = new UsernamePasswordAuthenticationToken(loginUser.getUserName(), loginUser.getPassword(), new ArrayList<>());
35
            // 这里返回 Authentication 后会通过我们自定义的 {@see CustomAuthenticationProvider} 进行验证
36
            return this.authenticationManager.authenticate(authentication);
37
        } catch (IOException e) {
38
            e.printStackTrace();
39
            return null;
40
        }
41
42
    }
43
44
    /**
45
     * 如果验证通过,就生成 token 并返回
46
     */
47
    @Override
48
    protected void successfulAuthentication(HttpServletRequest request,
49
                                            HttpServletResponse response,
50
                                            FilterChain chain,
51
                                            Authentication authentication) {
52
        try {
53
            // 获取用户信息
54
            String username = null;
55
            // 获取身份信息
56
            Object principal = authentication.getPrincipal();
57
            if (principal instanceof UserDetails) {
58
                UserDetails user = (UserDetails) principal;
59
                username = user.getUsername();
60
            } else if (principal instanceof String) {
61
                username = (String) principal;
62
            }
63
            // 获取用户认证权限
64
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
65
            // 获取用户角色权限
66
            List<String> roles = authorities.stream()
67
                    .map(GrantedAuthority::getAuthority)
68
                    .collect(Collectors.toList());
69
            boolean isRemember = this.rememberMeLocal.get();
70
            // 生成 token
71
            String token = JwtUtils.generateToken(username, roles, isRemember);
72
            // 将 token 添加到 Response Header 中返回
73
            response.addHeader(SecurityConstants.TOKEN_HEADER, token);
74
        } finally {
75
            // 清除变量
76
            this.rememberMeLocal.remove();
77
        }
78
    }
79
80
    /**
81
     * 如果验证证不成功,返回错误信息提示
82
     */
83
    @Override
84
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException {
85
        logger.warn(authenticationException.getMessage());
86
87
        if (authenticationException instanceof UsernameNotFoundException) {
88
            response.sendError(HttpServletResponse.SC_NOT_FOUND, authenticationException.getMessage());
89
            return;
90
        }
91
92
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authenticationException.getMessage());
93
    }
94
}

此过滤器继承了 UsernamePasswordAuthenticationFilter 类,并重写了三个方法:

  • attemptAuthentication: 此方法用于验证用户登录信息;

  • successfulAuthentication: 此方法在用户验证成功后会调用;

  • unsuccessfulAuthentication: 此方法在用户验证失败后会调用。

同时,通过 super.setFilterProcessesUrl(SecurityConstants.AUTH_LOGIN_URL) 方法重新指定需要进行验证的登录请求。

当登录请求进入此过滤器时,会先进入 attemptAuthentication 方法,通过此方法从登录请求中获取用户名和密码,并使用authenticationManager.authenticate(authenticate) 对用户信息进行认证,当执行此方法后会进入 CustomAuthenticationProvider 组件并调用 authenticate(Authentication authentication) 方法进行验证。如果验证成功后会返回一个 Authentication 对象(它里面包含了用户的完整信息,如角色权限),然后会去调用 successfulAuthentication 方法;如果验证失败,就会去调用 unsuccessfulAuthentication 方法。

至此,整个验证过程就结束了。

JwtAuthorizationFilter

JwtAuthorizationFilter 用户请求授权过滤器,用于从用户请求中获取 token 信息,并对其进行验证,同时加载与 token 相关联的用户身份认证信息,并添加到 Spring Security 上下文中。

1
/**
2
 * JwtAuthorizationFilter 用户请求授权过滤器
3
 *
4
 * <p>
5
 * 提供请求授权功能。用于处理所有 HTTP 请求,并检查是否存在带有正确 token 的 Authorization 标头。
6
 * 如果 token 有效,则过滤器会将身份验证数据添加到 Spring 的安全上下文中,并授权此次请求访问资源。</p>
7
 *
8
 * @author star
9
 */
10
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
11
12
    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
13
        super(authenticationManager);
14
    }
15
16
    @Override
17
    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
18
        // 从 HTTP 请求中获取 token
19
        String token = this.getTokenFromHttpRequest(request);
20
        // 验证 token 是否有效
21
        if (StringUtils.hasText(token) && JwtUtils.validateToken(token)) {
22
            // 获取认证信息
23
            Authentication authentication = JwtUtils.getAuthentication(token);
24
            // 将认证信息存入 Spring 安全上下文中
25
            SecurityContextHolder.getContext().setAuthentication(authentication);
26
        }
27
        // 放行请求
28
        filterChain.doFilter(request, response);
29
30
    }
31
32
    /**
33
     * 从 HTTP 请求中获取 token
34
     *
35
     * @param request HTTP 请求
36
     * @return 返回 token
37
     */
38
    private String getTokenFromHttpRequest(HttpServletRequest request) {
39
        String authorization = request.getHeader(SecurityConstants.TOKEN_HEADER);
40
        if (authorization == null || !authorization.startsWith(SecurityConstants.TOKEN_PREFIX)) {
41
            return null;
42
        }
43
        // 去掉 token 前缀
44
        return authorization.replace(SecurityConstants.TOKEN_PREFIX, "");
45
    }
46
47
}

所有的用户请求都会经过此过滤器,当请求进入过滤器后会经历如下步骤:

  • 首先,从请求中获取 token 信息,并检查 token 的有效性。

  • 如果 token 有效,则解析 token 获取用户名,然后使用用户名从数据库中获取用户角色信息,并在 Spring Security 的上下文中设置身份验证。

  • 如果 token 无效或请求不带 token 信息,则直接放行。

特别说明,这里用户的角色信息,是从数据库中重新获取的。其实,这里也可以换成从 token 信息中解析出用户角色,这样可以避免直接访问数据库。

但是,直接从数据库获取用户信息也是很有帮助的。例如,如果用户角色已更改,则可能要禁止使用此 token 进行访问。

JwtUtils

JwtUtils 工具类,在用户登录成功后,主要用于生成 token,并验证用户请求中发送的 token。

1
/**
2
 * Jwt 工具类,用于生成、解析与验证 token
3
 *
4
 * @author star
5
 **/
6
public final class JwtUtils {
7
8
    private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);
9
10
    private static final byte[] secretKey = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
11
12
    private JwtUtils() {
13
        throw new IllegalStateException("Cannot create instance of static util class");
14
    }
15
16
    /**
17
     * 根据用户名和用户角色生成 token
18
     *
19
     * @param userName   用户名
20
     * @param roles      用户角色
21
     * @param isRemember 是否记住我
22
     * @return 返回生成的 token
23
     */
24
    public static String generateToken(String userName, List<String> roles, boolean isRemember) {
25
        byte[] jwtSecretKey = DatatypeConverter.parseBase64Binary(SecurityConstants.JWT_SECRET_KEY);
26
        // 过期时间
27
        long expiration = isRemember ? SecurityConstants.EXPIRATION_REMEMBER_TIME : SecurityConstants.EXPIRATION_TIME;
28
        // 生成 token
29
        String token = Jwts.builder()
30
                // 生成签证信息
31
                .setHeaderParam("typ", SecurityConstants.TOKEN_TYPE)
32
                .signWith(Keys.hmacShaKeyFor(jwtSecretKey), SignatureAlgorithm.HS256)
33
                .setSubject(userName)
34
                .claim(SecurityConstants.TOKEN_ROLE_CLAIM, roles)
35
                .setIssuer(SecurityConstants.TOKEN_ISSUER)
36
                .setIssuedAt(new Date())
37
                .setAudience(SecurityConstants.TOKEN_AUDIENCE)
38
                // 设置有效时间
39
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
40
                .compact();
41
        return token;
42
    }
43
44
    /**
45
     * 验证 token 是否有效
46
     *
47
     * <p>
48
     * 如果解析失败,说明 token 是无效的
49
     *
50
     * @param token token 信息
51
     * @return 如果返回 true,说明 token 有效
52
     */
53
    public static boolean validateToken(String token) {
54
        try {
55
            getTokenBody(token);
56
            return true;
57
        } catch (ExpiredJwtException e) {
58
            logger.warn("Request to parse expired JWT : {} failed : {}", token, e.getMessage());
59
        } catch (UnsupportedJwtException e) {
60
            logger.warn("Request to parse unsupported JWT : {} failed : {}", token, e.getMessage());
61
        } catch (MalformedJwtException e) {
62
            logger.warn("Request to parse invalid JWT : {} failed : {}", token, e.getMessage());
63
        } catch (IllegalArgumentException e) {
64
            logger.warn("Request to parse empty or null JWT : {} failed : {}", token, e.getMessage());
65
        }
66
        return false;
67
    }
68
69
    /**
70
     * 根据 token 获取用户认证信息
71
     *
72
     * @param token token 信息
73
     * @return 返回用户认证信息
74
     */
75
    public static Authentication getAuthentication(String token) {
76
        Claims claims = getTokenBody(token);
77
        // 获取用户角色字符串
78
        List<String> roles = (List<String>)claims.get(SecurityConstants.TOKEN_ROLE_CLAIM);
79
        List<SimpleGrantedAuthority> authorities =
80
                Objects.isNull(roles) ? Collections.singletonList(new SimpleGrantedAuthority(UserRoleConstants.ROLE_USER)) :
81
                        roles.stream()
82
                                .map(SimpleGrantedAuthority::new)
83
                                .collect(Collectors.toList());
84
        // 获取用户名
85
        String userName = claims.getSubject();
86
87
        return new UsernamePasswordAuthenticationToken(userName, token, authorities);
88
89
    }
90
91
    private static Claims getTokenBody(String token) {
92
        return Jwts.parser()
93
                .setSigningKey(secretKey)
94
                .parseClaimsJws(token)
95
                .getBody();
96
    }
97
}

请求认证流程说明

本项目中出现了两个过滤器,分别是 JwtAuthenticationFilterJwtAuthorizationFilter。当用户发起请求时,都会先进入 JwtAuthorizationFilter 过滤器。如果请求是登录请求,又会进入 JwtAuthenticationFilter 过滤器。也就是说,只有是指定的登录请求才会进入 JwtAuthenticationFilter 过滤器。通过过滤器后,就进入 Spring Security 机制中。
由于已将登录接口暴露在了 Controller 层,所以登录请求不会经过 JwtAuthenticationFilter 过滤器,它已经废弃。请求认证过程将变成,所有的请求会先经过 JwtAuthorizationFilter 过滤器,然后进入 Spring Security 机制中。

测试 API

注册账号
register
登录
login
带上正确的 token 访问需要身份验证的资源
correctToken
带上不正确的 token 访问需要身份验证的资源
incorrectToken

不带 token 访问需要身份验证的资源
noToken

项目调整记录

  • 增加 Swagger UI,方便查看项目接口。
  • 增加全局异常捕获功能。
  • 增加 JPA 审计功能,完善数据表审计信息。
  • 在 Controller 层中暴露用户登录接口(/api/auth/login)。
  • 完善项目详解内容。

参考文档