基于JWT与Spring Security的鉴权系统

584 阅读5分钟

JWT

1.什么是JWT

JWT是一种JSON类型的token令牌,用于各方将信息作为JSON对象进行安全传输,传输过程中还可进行数据加密、签名等处理

2.JWT的结构

JWT由三部分组成

  • Header 标头
    • 通常由令牌类型(如JWT)和所使用的签名算法组成
  • Payload 负载
    • 通常搭载非敏感信息,如用户id、名称等常用非敏感信息,将其保存在客户端,以减少向服务端请求
  • Signature 签名
    • 该部分会使用base64编码后的Header、Payload以及服务端提供的密钥使用Header中指定的签名算法进行加密,判断信息是否被篡改主要就是通过该部分

前两部分会通过Base64编码加密,但这是可逆的,故不会存放敏感信息

3.基于token的鉴权机制

用户登录请求的流程通常为:
1.用户输入账号密码传给服务器
2.服务器对账号密码进行匹配验证
3.验证成功后将用户部分非敏感信息插入Payload生成token返回给客户端
4.客户端将接收到的token保存在本地,每次请求数据时在请求头附上token
5.服务端对请求头中的token进行签名验证,验证通过则放行

鉴权系统-1

根据上文的鉴权流程,结合我的网站需求我们可以得到如下设计

  • 前端:
    • 增添路由守卫,在路由跳转前检测是否已获取token

    需注意该检测并不能阻止用户继续访问页面,因为本地存储用户是可修改的,只能配合后端拦截做到不返回数据

    • 需要将登录请求返回的token保存,请求时将token加入请求头
    • 增加角色检测,在组件渲染前先判断角色是否有权限
  • 后端:
    • 编写JWT工具类,简化token生成以及验证调用
    • 对于非公开数据请求都进行签名验证
    • 对用户的请求还需要鉴权

Spring Security

仅仅通过JWT,我们只能实现用户认证,即用户是否能访问该系统,简单说就是是否登录了, 我们还需要对用户进行权限控制,即用户是否有权限访问某个接口。为了解决这个问题,我引入了Spring Security,基于Spring的安全实现

认证流程

image.png

  • AuthencationoManager:定义了认证authentication的方法
  • Authentication:封装用户相关信息的对象
  • UserDetailsService:加载用户特定数据的接口
  • UserDetails:Service将查询到的数据封装为UserDetails对象返回,然后再将这些信息封装到Authentication中

三个核心组件

  • Authentication:存储认证信息
  • SecurityContext:上下文对象,用来获取Authentication
  • SecurityContextHolder:上下文管理对象,用于获取SecurityContext

由此可见,要实现自己的认证业务,主要关注DaoAuthentication以及UserDetailsService的实现,后者用于查询用户信息,前者用于对查询到的用户信息进行验证封装,当AuthencationoManager调用authenticate方法时,该方法会调用UserDetailsService的loadUserByUsername,当我们需要鉴权时,如检测用户是否有某角色,我们便可以重写loadUserByUsername方法,将用户用有角色一同封装返回

但在实际情况中,我们并不希望每写一个接口就调用一次authenticate验证,所以我们需要编写一个过滤器,过滤器中我们可以直接解析token,从中获取userId,然后获取用户信息以及权限列表(角色列表),直接将其封装到Authentication的实现类UsernamePasswordAuthenticationToken中,存入上下文,这样做可以直接使用Spring Security原生的@PreAuthorize等注解,依赖于UsernamePasswordAuthenticationToken中的authorities(我们权限列表就是存入这里)进行用户权限判断

鉴权系统-2

根据对Spring Security的分析,我们可以得到后端的进一步设计

  • 装载Spring Security
  • 配置哪些接口需要权限控制
  • 编写过滤器
    • 解析token是否合法
    • 获取用户权限封装至UsernamePasswordAuthenticationToken并存入上下文
  • 异常处理,捕捉权限不足抛出的错误AccessDeniedException

装载了Spring Security后,我们也可以减少前端关于当前用户部分信息的传输,如用户id,我们可以直接从ss线程中的上下文获取

具体实现

SecurityConfig

  • 配置跨域源
  • 权限控制接口
  • 配置Filter
@Configuration
@EnableMethodSecurity(jsr250Enabled = true, securedEnabled = true)
public class SecurityConfig {

    @Autowired
    TokenFilter tokenFilter;

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .authorizeHttpRequests(auth ->
                auth
                        .requestMatchers("/public").permitAll()//公共接口都放行
                         .anyRequest().permitAll())//其他接口通过注解@@PreAuthorize进行权限控制
                .csrf(AbstractHttpConfigurer::disable);
        http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);//配置Filter
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(List.of("http://localhost:8081","https://momoyouta.github.io","https://www.momoyouta.fun")); // 允许源
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true); // 允许携带 Cookie
        config.setMaxAge(3600L); // 预检请求的缓存时间
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); // 对所有路径生效
        return source;
    }

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

TokenFilter

  • 解析JWT,验证合法性
  • 获取用户缓存用信息,存入上下文
@Component
@Slf4j
public class TokenFilter extends OncePerRequestFilter {

    private static final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader(KeyProperties.TOKEN_HEADER);
        if(token == null||token.isEmpty()) { //无token
            filterChain.doFilter(request, response);
            return;
        }
        Claims claims = null;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (ExpiredJwtException e) { //异常处理
            String errorInfo = "accessToken已经过期";
            WebUtil.renderString(response, errorInfo); //立即返回请求工具类,直接返回错误信息
            return;
        } catch (Exception e) { //异常处理
            String errorInfo = "accessToken解析失败";
            WebUtil.renderString(response, errorInfo);
            return;
        }
        //需注意JSON解析,存入JWT时存入JSON,方便解析
        UserCacheVO userCacheVO = objectMapper.readValue(claims.getSubject(), UserCacheVO.class);
        String userId=userCacheVO.getId();
        List<GrantedAuthority> authorities = new ArrayList<>();
        for(String role: userCacheVO.getIdentities()){
            authorities.add(new SimpleGrantedAuthority(role));
        }//获取权限列表如['user','admin',...]
        UsernamePasswordAuthenticationToken authenticationToken = //封装Authentication实现类并存入上下文
                new UsernamePasswordAuthenticationToken(userId, null, authorities);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }

}

异常处理

  • 权限不足异常AccessDeniedException
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(AccessDeniedException.class)
    public Result<String> handleAccessDeniedException(AccessDeniedException e) {
        return Result.error(2,"无权限访问");
    }//Result为自定义响应体
}