Spring Security整合JWT实现权限认证和授权

Posted 活着z

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security整合JWT实现权限认证和授权相关的知识,希望对你有一定的参考价值。

1jwt相关

  1. JWT是JSON Web Token的缩写,即JSON Web令牌,是一种自包含令牌。 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。
  2. JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源。比如用在用户登录上。
  3. JWT最重要的作用就是对 token信息的防伪作用。
  4. 一个JWT由三个部分组成:JWT头、有效载荷、签名哈希最后由这三者组合进行base64url编码得到JWT

1.引入jwt依赖

<dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt</artifactId>
       <version>0.7.0</version>
 </dependency>

2.jwt相关配置

public class JwtHelper 

    //token过期时间
    private static long tokenExpiration = 365 * 24 * 60 * 60 * 1000;
    //加密秘钥
    private static String tokenSignKey = "123456";

    //根据用户id和用户名称生成token字符串
    public static String createToken(String userId, String username) 
        String token = Jwts.builder()
                .setSubject("AUTH-USER")
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
                .claim("userId", userId)
                .claim("username", username)
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                .compressWith(CompressionCodecs.GZIP)
                .compact();
        return token;
    

    //从token字符串获取userid
    public static String getUserId(String token) 
        try 
            if (StringUtils.isEmpty(token)) return null;

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            String userId = (String) claims.get("userId");
            return userId;
         catch (Exception e) 
            e.printStackTrace();
            return null;
        
    

    //从token字符串获取username
    public static String getUsername(String token) 
        try 
            if (StringUtils.isEmpty(token)) return "";

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            return (String) claims.get("username");
         catch (Exception e) 
            e.printStackTrace();
            return null;
        
    

通过jwt可以生成token,可以token解析到用户的信息等等。

2.Spring Security

Spring 是非常流行和成功的 Java 应用开发框架,Spring Security 正是 Spring 家族中的成员。Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。

正如你可能知道的关于安全方面的两个核心功能是“认证”和“授权”,一般来说,Web 应用的安全性包括用户认证(Authentication)和用户授权(Authorization)两个部分,这两点也是 SpringSecurity 重要核心功能。

(1)用户认证指的是:验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码,系统通过校验用户名和密码来完成认证过程。

通俗点说就是系统认为用户是否能登录

(2)用户授权指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。

通俗点讲就是系统判断用户是否有权限去做某些事情。

SpringSecurity 特点:

  1. 和 Spring 无缝整合。
  2.  全面的权限控制。
  3. 专门为 Web 开发而设计。
  4. ​ 旧版本不能脱离 Web 环境使用。
  5. ​ 新版本对整个框架进行了分层抽取,分成了核心模块和 Web 模块。单独引入核心模块就可以脱离 Web 环境。
  6. 重量级。

Shiro 特点:

Apache 旗下的轻量级权限控制框架。

  1. 轻量级。Shiro 主张的理念是把复杂的事情变简单。针对对性能有更高要求
  2. 的互联网应用有更好表现。
  3. 通用性。
  4. ​ 好处:不局限于 Web 环境,可以脱离 Web 环境使用。
  5. ​ 缺陷:在 Web 环境下一些特定的需求需要手动编写代码定制。

其实Spring Security可以根据以下图流程进行开发

  1. 创建自定义密码组件
  2. 创建自定义用户对象
  3. 创建方法查询用户信息
  4. 创建自定义认证过滤器
  5. 创建返回信息工具类
  6. 创建认证解析过滤器
  7. 创建配置用户认证全局信息

1.引入依赖   

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

2. 创建自定义密码组件

@Component
public class CustomMD5Password implements PasswordEncoder 
    @Override
    public String encode(CharSequence charSequence) 
        return MD5.encrypt(charSequence.toString());
    

    @Override
    public boolean matches(CharSequence charSequence, String s) 
        return s.equals(MD5.encrypt(charSequence.toString()));
    

登录会来到这进行密码校对判断

3.创建自定义用户对象

/**
 * 自定义用户类
 */
public class CustomUser extends User 


    /**
     * 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象
     */
    private SysUser sysUser;

    public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) 
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    

    public SysUser getSysUser() 
        return sysUser;
    

    public void setSysUser(SysUser sysUser) 
        this.sysUser = sysUser;
    

实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作

4.创建方法查询用户信息

@Component
public class UserDetailServiceImpl implements UserDetailsService 
    @Autowired
    private SysUserService sysUserService;

    @Autowired
    private SysMenuService sysMenuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException 
        SysUser sysUser = sysUserService.getUserInfoByUserName(username);
        if (sysUser == null) 
            throw new UsernameNotFoundException("用户不存在");
        
        if (sysUser.getStatus().intValue() == 0) 
            throw new RuntimeException("用户被禁用了");
        
        //根据用户id查询权限数据
        List<String> userPermsList = sysMenuService.getUserButtonList(sysUser.getId());
        //转换为security要求的格式
        List<SimpleGrantedAuthority> authorities=new ArrayList<>();
        for (String s : userPermsList) 
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(s.trim());
            authorities.add(simpleGrantedAuthority);
        
        CustomUser customUser = new CustomUser(sysUser, authorities);
        return customUser;
    

5.创建自定义认证过滤器

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter 

    private RedisTemplate redisTemplate;

    private LoginLogService loginLogService;

    public TokenLoginFilter(AuthenticationManager authenticationManager, RedisTemplate redisTemplate, LoginLogService loginLogService) 
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);

        //指定登录接口及提交方式,可以指定任意路径
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/system/index/login", "POST"));
        this.redisTemplate = redisTemplate;
        this.loginLogService = loginLogService;
    

    //获取用户名和密码,认证
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException 
        try 
            LoginVo loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class);
            Authentication authentication = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());
            return this.getAuthenticationManager().authenticate(authentication);
         catch (IOException e) 
            e.printStackTrace();
        
        return null;
    

    //认证成功就会调用此方法
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException 
        //获取认证对象
        CustomUser customUser = (CustomUser) authResult.getPrincipal();

        //保存权限数据
        redisTemplate.opsForValue().set(customUser.getUsername(),
                JSON.toJSONString(customUser.getAuthorities()));

        //记录登录日志
        loginLogService.recordLoginLog(customUser.getUsername(), 0,
                IpUtil.getIpAddress(request), "登录成功");

        //生成token
        String token = JwtHelper.createToken((customUser.getSysUser().getId()).toString(), customUser.getSysUser().getUsername());
        Map<String, Object> map = new HashMap<>();
        map.put("token", token);
        ResponseUtil.out(response, Result.ok(map));
    

    //认证失败
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
                                              HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException 
        if (failed.getCause() instanceof RuntimeException) 
            ResponseUtil.out(response, Result.build(null, 204, failed.getMessage()));
         else 
            ResponseUtil.out(response, Result.build(null, ResultCodeEnum.LOGIN_MOBLE_ERROR));
        
    

6.创建返回信息工具类

public static void out(HttpServletResponse response, Result r) 
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try 
            mapper.writeValue(response.getWriter(), r);
         catch (IOException e) 
            e.printStackTrace();
        
    

7.创建认证解析过滤器

因为用户登录状态在token中存储在客户端,所以每次请求接口请求头携带token, 后台通过自定义token过滤器拦截解析token完成认证并填充用户信息实体。

public class TokenAuthenticationFilter extends OncePerRequestFilter 
    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(RedisTemplate redisTemplate) 
        this.redisTemplate = redisTemplate;
    

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException 
        logger.info("uri:" + request.getRequestURI());
        //如果是登录接口,直接放行
        if ("/admin/system/index/login".equals(request.getRequestURI())) 
            chain.doFilter(request, response);
            return;
        

//        if ("/prod-api/admin/system/index/login".equals(request.getRequestURI())) 
//            chain.doFilter(request, response);
//            return;
//        

        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if (null != authentication) 
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
         else 
            ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION));
        
    

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) 
        // token置于header里
        String token = request.getHeader("token");
        logger.info("token:" + token);
        if (!StringUtils.isEmpty(token)) 
            String username = JwtHelper.getUsername(token);
            logger.info("username:" + username);
            if (!StringUtils.isEmpty(username)) 
                String authoritiesString =
                        (String) redisTemplate.opsForValue().get(username);
                List<Map> mapList = JSON.parseArray(authoritiesString, Map.class);
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                for (Map map : mapList) 
                    authorities.add(new SimpleGrantedAuthority((String) map.get("authority")));
                
                return new UsernamePasswordAuthenticationToken(username, null, authorities);
            
        
        return null;
    

8.创建配置用户认证全局信息

@Configuration
@EnableWebSecurity //开启springSecurity默认行为
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启注解功能,默认禁用注解
public class WebSecurityConfig extends WebSecurityConfigurerAdapter 
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private CustomMD5Password customMd5PasswordEncoder;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private LoginLogService loginLogService;

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception 
        return super.authenticationManager();
    

    @Override
    protected void configure(HttpSecurity http) throws Exception 
        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        http
                //关闭csrf
                .csrf().disable()
                // 开启跨域以便前端调用接口
                .cors().and()
                .authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的
                .antMatchers("/admin/system/index/login").permitAll()
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()
                .and()
                //TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,
                // 这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。
                .addFilterBefore(new TokenAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
                .addFilter(new TokenLoginFilter(authenticationManager(),redisTemplate,loginLogService));

        //禁用session
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(customMd5PasswordEncoder);
    

    /**
     * 配置哪些请求不拦截
     * 排除swagger相关请求
     *
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception 
        web.ignoring().antMatchers("/favicon.ico", "/swagger-resources/**", "/webjars/**", "/v2/**", "/swagger-ui.html/**", "/doc.html");
    

3.用户授权

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。判断当前用户是否拥有访问当前资源所需的权限。

1.操作步骤

1.前面登录时执行loadUserByUsername方法时,return new CustomUser(sysUser, Collections.emptyList());后面的空数据对接就是返回给Spring Security的权限数据。

在TokenAuthenticationFilter中登录时我们把权限数据保存到redis中(用户名为key,权限数据为value即可),这样通过token获取用户名即可拿到权限数据,这样就可构成出完整Authentication对象。

2.Spring Security默认是禁用注解的,要想开启注解,需要在继承WebSecurityConfigurerAdapter的类上加@EnableGlobalMethodSecurity注解,来判断用户对某个控制层的方法是否具有访问权限
通过@PreAuthorize标签控制controller层接口权限

 @PreAuthorize("hasAnyAuthority('bnt.sysRole.list')")
    @ApiOperation("条件分页查询")
    @GetMapping("/page/limit")
    public Result findPageQueryRole(@PathVariable("page") Long page,
                                    @PathVariable("limit") Long limit,
                                    SysRoleQueryVo vo) 

        //创建page对象
        Page<SysRole> pageParam = new Page<>(page, limit);

        //调用service方法
        IPage<SysRole> iPage = sysRoleService.selectPage(pageParam, vo);
        return Result.ok(iPage);

    

这样就可以操作是否具有该权限如果没有该权限会报异常(AccessDeniedException ),注解里的参数对应数据库里的值。

3.自定义异常返回

@ExceptionHandler(AccessDeniedException.class)
@ResponseBody
public Result error(AccessDeniedException e) throws AccessDeniedException 
    return Result.build(null, ResultCodeEnum.PERMISSION);

AccessDeniedException需要引入依赖,Spring Security对应的异常

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <scope>provided</scope>
</dependency>

4.测试

1.账号不正确

2.没有token值

 3.有token正确返回数据

 

 5.工具类

1.树形展示工具类

 /**
     * 构建树形结构
     * 步骤:
     * 1.获取所有菜单集合
     * 2.找到菜单根节点,顶层数据==>parent==0 【对应的id==1】
     * 3.拿着id==1的值,对比菜单所有数据 谁的parentid==1 数据封装到id==1里面
     *
     * @param list
     * @return
     */
    public static List<SysMenu> buildTree(List<SysMenu> list) 
        //创建一个集合封装数据
        List<SysMenu> trees = new ArrayList<>();
        //遍历所有菜单集合
        for (SysMenu sysMenu : list) 
            //找到递归路口 parentId=0
            if (sysMenu.getParentId().longValue() == 0) 
                trees.add(findChildren(sysMenu, list));
            
        
        return trees;
    

    /**
     * 从根节点进行递归查询,查询子节点
     * 判断根节点id=子节点parentId 是否相同,如果相同是子节点
     *
     * @param sysMenu
     * @param treeNodes
     * @return
     */
    private static SysMenu findChildren(SysMenu sysMenu, List<SysMenu> treeNodes) 
        //数据初始化
        sysMenu.setChildren(new ArrayList<>());
        //遍历递归查找
        for (SysMenu it : treeNodes) 
            //获取当前菜单id
            Long cid = sysMenu.getId();
            //获取所有菜单的parentId
            Long parentId = it.getParentId();
            //比对
            if (cid == parentId) 
                if (sysMenu.getChildren() == null) 
                    sysMenu.setChildren(new ArrayList<>());
                
                sysMenu.getChildren().add(findChildren(it, treeNodes));
            
        
        return sysMenu;
    

2.获取ip地址工具类

public class IpUtil 

    public static String getIpAddress(HttpServletRequest request) 
        String ipAddress = null;
        try 
            ipAddress = request.getHeader("x-forwarded-for");
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) 
                ipAddress = request.getHeader("Proxy-Client-IP");
            
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) 
                ipAddress = request.getHeader("WL-Proxy-Client-IP");
            
            if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) 
                ipAddress = request.getRemoteAddr();
                if (ipAddress.equals("127.0.0.1")) 
                    // 根据网卡取本机配置的IP
                    InetAddress inet = null;
                    try 
                        inet = InetAddress.getLocalHost();
                     catch (UnknownHostException e) 
                        e.printStackTrace();
                    
                    ipAddress = inet.getHostAddress();
                
            
            // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
            if (ipAddress != null && ipAddress.length() > 15)  // "***.***.***.***".length()
                // = 15
                if (ipAddress.indexOf(",") > 0) 
                    ipAddress = ipAddress.substring(0, ipAddress.indexOf(","));
                
            
         catch (Exception e) 
            ipAddress="";
        
        // ipAddress = this.getRequest().getRemoteAddr();

        return ipAddress;
    

    public static String getGatwayIpAddress(ServerHttpRequest request) 
        HttpHeaders headers = request.getHeaders();
        String ip = headers.getFirst("x-forwarded-for");
        if (ip != null && ip.length() != 0 && !"unknown".equalsIgnoreCase(ip)) 
            // 多次反向代理后会有多个ip值,第一个ip才是真实ip
            if (ip.indexOf(",") != -1) 
                ip = ip.split(",")[0];
            
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = headers.getFirst("Proxy-Client-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = headers.getFirst("WL-Proxy-Client-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = headers.getFirst("HTTP_CLIENT_IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = headers.getFirst("HTTP_X_FORWARDED_FOR");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = headers.getFirst("X-Real-IP");
        
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) 
            ip = request.getRemoteAddress().getAddress().getHostAddress();
        
        return ip;
    

全网最细致SpringBoot整合Spring Security + JWT实现用户认证

【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证

  登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + JWT实现登录及用户认证

文章目录

前置知识:Session、Cookie与Token

session与cookie

  在一些传统项目中,我们或许会用session来保存用户信息,进行用户认证。而现在基本上都用token来代替session,为什么会出现这样的变化,我们来聊聊session、cookie以及token
  在谈session和cookie前,首先我们来谈谈会话。http本身是无状态协议,服务器无法识别每一次HTTP请求的出处(不知道来自于哪个终端),它只会接受到一个请求信号,所以就存在一个问题:将用户的响应发送给相应的用户,必须有一种技术来让服务器知道请求来自哪,这就是会话技术。
  会话就是客户端和服务器之间发生的一系列连续的请求和响应的过程。会话状态指服务器和浏览器在会话过程中产生的状态信息,借助于会话状态,服务器能够把属于同一次会话的一系列请求和响应关联起来。
  实现会话有两种方式:session和cookie。Session通过在服务器端记录信息确定用户身份,相应的也增加了服务器的存储压力。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是Session。属于同一次会话的请求都有一个相同的标识符,sessionID,客户端浏览器再次访问时只需要通过sessionID从Session中查找该客户的状态就可以了。那么后端是怎么把sessionID返回给客户端的?可以通过设置cookie的方式返回给客户端,若浏览器禁止cookie,则可以通过URL重写的方式发送。
  刚刚提到了cookie,Cookie是服务端在HTTP响应中附带传给浏览器的一个小的文本文件,一旦浏览器保存了某个Cookie,在之后的请求和响应过程中,会将此Cookie来回传递,这样就可以通过Cookie这个载体完成客户端和服务端的数据交互。
  使用Session进行用户认证时,当用户第一次通过浏览器使用用户名和密码访问服务器时,服务器会验证用户数据,验证成功后在服务器端写入session数据,向客户端浏览器返回sessionid,浏览器将sessionid保存在cookie中,当用户再次访问服务器时,会携带sessionid,服务器会拿着sessionid从服务器获取session数据,然后进行用户信息查询,查询到,就会将查询到的用户信息返回,从而实现状态保持,流程如下图:

session的弊端

  • 服务器压力增大
      通常session是存储在内存中的,每个用户通过认证之后都会将session数据保存在服务器的内存中,而当用户量增大时,服务器的压力增大。
  • CSRF跨站伪造请求攻击
      一般session是基于cookie进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。即使不用cookie,用重写url方式发送sessionId,那就更容易被截获信息了
  • 扩展性不强
      想象这么一个场景,若项目在多个服务器上部署,那我再其中一台登录了,称为A,session也保存到A中,万一下次我访问到另外一台服务器B怎么办?B上没有A的session呢?为了解决这个问题,我们需要将session保存到数据库中,所以每次保存这些session信息就是一个负担了,增加了服务器的存储压力。

token

  token的意思是“令牌”,是服务端生成的一串加密字符串(服务器端并不进行保存),作为客户端进行请求的一个标识。当用户第一次登录后,服务器生成一个token并将此token返回给客户端浏览器,以后客户端只需带上这个token前来请求数据即可,无需再次带上用户名和密码。
  浏览器会将接收到的token值存储在Local Storage中,浏览器再次访问时服务器端时,服务器对浏览器传来的token值进行解密,解密完成后进行用户数据的查询,如果查询成功,则通过认证,实现状态保持,所以,即使有了多台服务器,服务器也只是做了token的解密和用户数据的查询,它不需要在服务端去保留用户的认证信息或者会话信息,这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,同时服务器也不需要保存token,token的出现就解决了session的弊端,成为了session的替代品。
  使用token进行用户认证的流程如下图:

session与token的总结

  token 是无状态的,后端不需要记录信息,每次请求过来进行解密就能得到对应信息。
  session 是有状态的,需要后端每次去检索id的有效性。不同的session都需要进行保存,但也可以设置单点登录,减少保存的数据。
  session与token的选择是空间与时间博弈,为什么这么说呢,是因为token不需要保存,不占存储空间,但每次访问都需要进行解密,消耗了一定的时间。
  在一般的前后端分离项目中,token展现出了它的优势,成为了比session更好的选择

JWT

  JWT其实就是一种被广泛使用的token,它的全称是JSON Web Token,它通过数字签名的方式,以JSON对象为载体,在不同的服务终端之间安全地传输信息。
  JWT最常见的使用场景就是授权认证,一旦用户登录,后续每个请求都将包含JWT,系统在每次处理用户请求之前,都要先进行JWT安全校验,通过之后再进行处理。
  JWT由3部分组成,用.拼接,如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
  这三部分分别是:

  • Header
      Header中保存了令牌类型type和所使用的的加密算法,例如:

  'typ': 'JWT',
  'alg': 'HS256'

  • Payload
      Payload中包含的是请求体和其它一些数据,例如包含了和用户相关的一些信息

  "sub": "1234567890",
  "name": "John Doe",
  "admin": true

  • Signature
      Signature签名属于jwt的第三部分。主要是把头部的base64UrlEncode与负载的base64UrlEncode拼接起来,再进行HMACSHA256加密等最终得到的结果作为签名部分。例如:
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);

var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

登录及用户认证流程设计

  假设我们要设计这样一个登录功能:用户输入用户名、密码以及图片验证码进行登录,用户认证功能则是对于用户的每次请求,都需要校验用户信息,若不正确,则拒绝请求
  这里不同于最简单的用户名密码登录,加入了图片验证码,验证码是为了防止非正常用户伪造请求进行登录,是一个较为重要的功能。
  根据我们之前对于token和JWT的介绍,我们知道,对于首次登录,浏览器是没有JWT信息的,用户需输入用户名、密码和验证码完成登录,后端对验证码、用户名、密码进行校验后,若校验成功,则返回JWT给前端,完成登录。登录后的每次请求,请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口
  我们先来看首次登录,后端需要对验证码和用户信息进行校验,我们不妨先校验验证码,再校验用户名密码。
  对于验证码的校验,我们知道一般的前后端分离使用token,不使用session,那么后台必须保证当前用户输入的验证码是用户开始请求页面时候的验证码,必须保证验证码的唯一性。举个例子:
  A用户看到的验证码是:ABC;B用户看到的验证码是:DEF。后台存储了ABC和DEF这2个验证码,如果不限定A用户输入的验证码是ABC,那么当A用户碰巧输入DEF,然后用户名和密码也是正确的话,A用户也是可以登录系统的。
  也就是说,每个用户的请求都需要对应一个唯一的验证码,这一切的麻烦都源于http本身是无状态协议,我们需要保存用户与验证码之间的对应关系,而现在我们又不能使用session,不能用SessionID的字段和验证码对应,那么我们该如何做呢?
  有一种方式是前端生成一个随机数(UUID形式),保存在localstorage里,对应着某个用户,前端带上随机数参数访问后端接口,后端用加密算法加密该随机数rand,生成验证码,即verify_code = f(rand)。当用户提交验证码的时候,之前的随机数一起带过来,后端再通过之前的加密规则验证输入的验证码是否正确。也即构造了随机数和验证码的对应关系
  其实上述做法有点类似于token的做法,不过这种做法有几个问题,一是验证码强行和一个前端给的一串随机数通过一个算法f产生了联系,前端的请求可以随意伪造,随机数参数也可以五花八门,可能会导致一些意想不到的bug发生,验证码应保持随机性和独立性,不应该和一个随机数强行通过函数f关联。二是采用这种方式,后端需要进行两次加密过程生成验证码,会造成不必要的时间开销。
  我们采用另一种方式类似于session的做法来完成验证码校验过程,首先,我们还是得构造随机数(UUID形式,代表着某个用户),它和验证码一一对应。不过这次我们将随机数的生成把握在后端手里,毕竟老话说得好:作为一个后端,不要相信前端传过来的任何参数(手动狗头),把随机数的生成把握在后端手中,这种方式更加安全。我们仿照session的原理,牺牲一部分存储空间,将随机数和对应的验证码作为key-value键值对形式进行存储,然后将生成的随机数返回给前端,前端在登录请求时将该随机数以及用户输入的验证码传给后端,后端就能通过该随机数进行查询,校验输入的验证码和正确验证码是否一致。我们可以引入redis中间件来完成随机数和验证码的存储,因为一个验证码对应一个用户的一次登录过程,所以当验证成功时,我们将redis中存储的验证码和随机码删除,采用这种方式也不会消耗多少存储空间。
  解决了令人头疼的验证码,登录流程就很清晰了,流程如下:

  在首次登录过后,浏览器将保存jwt,在之后的所有请求中(包括再次登录请求),请求头都将携带Jwt进行身份认证,若认证成功则能访问后端接口。
  弄清楚登录及用户认证流程后,接下来我们将使用SpringBoot整合Spring Security + JWT来实现上述流程,在使用Spring Security之前,我们先来看看它的基本原理,要不然用起来会很懵

前置知识:Spring Security

  Spring Security是Spring家族中的安全框架,可以用来做用户验证和权限管理等。Spring Security是一款重型框架,不过功能十分强大。一般来说,如果项目中需要进行权限管理,具有多个角色和多种权限,我们可以使用Spring Security。如果是较为简单的项目,只需要控制一下某些接口只有登录后才能访问,则可以使用Shiro框架,Shiro也是一款安全框架,它是一款轻量级框架,功能没有Spring Security多,但使用起来要简单不少。

  SpringSecurity 采用的是责任链的设计模式,是一堆过滤器链的组合,它有一条很长的过滤器链。 Spring Security 的执行流程图如下所示:


  现在来一一解释每一个过滤器链的功能是什么:

  • 1、WebAsyncManagerIntegrationFilter:
      将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。

  • 2、SecurityContextPersistenceFilter:
      在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。

  • 3、HeaderWriterFilter:
      用于将头信息加入响应中。

  • 4、CsrfFilter:
      用于处理跨站请求伪造。

  • 5、LogoutFilter:
      用于处理退出登录。

  • 6、UsernamePasswordAuthenticationFilter:
      用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。

  • 7、DefaultLoginPageGeneratingFilter:
      如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。

  • 8、BasicAuthenticationFilter:
      检测和处理 http basic 认证。

  • 9、RequestCacheAwareFilter:
      用来处理请求的缓存。

  • 10、SecurityContextHolderAwareRequestFilter:
      主要是包装请求对象request。

  • 11、AnonymousAuthenticationFilter:
      检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。

  • 12、SessionManagementFilter:
      管理 session 的过滤器

  • 13、ExceptionTranslationFilter:
      处理 AccessDeniedException 和 AuthenticationException 异常。

  • 14、FilterSecurityInterceptor:
      可以看做过滤器链的出口。

  • 15、RememberMeAuthenticationFilter:
      当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

  看到Spring Security这么复杂,我们可能已经崩溃了,但其实它并没有看上去那么吓人,因为Spring Security已经对很多过滤器部分提供了默认实现,程序员只需要按照自己项目的需求增加和修改少量代码即可。不过这么做也有个坏处,那就是不懂Spring Security原理的程序员,可能会看不懂代码中的登录逻辑,觉得莫名其妙就进行完用户验证了。

根据自己的项目需求实现SpringSecurity中的部分过滤器

  我们可以根据自己的项目需求来设计一个security的认证方案,结合我们之前提到的登录和认证需求,可以得到这样一个流程:

  需要注意的是,SpringSecurity不提供图片验证码过滤器,因此我们在UsernamePasswordAuthenticationFilter前加入自定义的图片验证码过滤器
  根据上述流程,我们列出需要自己实现的过滤器和处理器等:

  • 1、LogoutSuccessHandler:
      表示登出处理器
  • 2、验证码过滤器Filter
  • 3、登录认证成功、失败处理器
  • 4、BasicAuthenticationFilter:
      该过滤器用于普通http请求进行身份认证
  • 5、AuthenticationEntryPoint:
      表示认证失败处理器
  • 6、AccessDenieHandler:
      用户发起无权限访问请求的处理器
  • 7、UserServiceDatils 接口:
      该接口十分重要,用于从数据库中验证用户名密码
  • 8、PasswordEncoder密码验证器

正式开始整合Spring Security和JWT

  弄清了我们需要实现哪些代码,接下来我们就正式开始整合过程

pom.xml添加相应依赖

  我们需添加Spring Security和JWT依赖,还需添加redis依赖,以及一些工具类,例如hutool,编码工具类,以及google的验证码工具类等:

        <!-- springboot security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>com.github.axet</groupId>
            <artifactId>kaptcha</artifactId>
            <version>0.0.9</version>
        </dependency>
        <!-- hutool工具类-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.3.3</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.11</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
            <version>1.15</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

写一个JWT工具类

  我们需要写一个JWT工具类JwtUtils,该工具类需要有3个功能:生成JWT、解析JWT、判断JWT是否过期。直接上代码:

import io.jsonwebtoken.*;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.Date;

@Data
@Component
@ConfigurationProperties(prefix = "xiaolinbao.jwt")
public class JwtUtils 

    private long expire;
    private String secret;
    private String header;

    // 生成JWT
    public String generateToken(String username) 

        Date nowDate = new Date();
        Date expireDate = new Date(nowDate.getTime() + 1000 * expire);

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(username)
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)    // 7天过期
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    

    // 解析JWT
    public Claims getClaimsByToken(String jwt) 
        try 
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(jwt)
                    .getBody();
         catch (Exception e) 
            return null;
        
    

    // 判断JWT是否过期
    public boolean isTokenExpired(Claims claims) 
        return claims.getExpiration().before(new Date());
    


  我们可以配置JWT的有效时间和加密算法所需使用的秘钥,以及返回给前端时在Http response的Header中所叫的名字。这种配置项我们需写入application.yml中,然后使用@ConfigurationProperties注解接收,这样能便于我们日后修改配置。
  使用@ConfigurationProperties注解可以读取配置文件中的信息,只要在 Bean 上添加上了这个注解,指定好配置文件中的前缀,那么对应的配置文件数据就会自动填充到 Bean 的属性中
  application.yml中的配置如下:

xiaolinbao:
  jwt:
    header: Authorization
    expire: 604800 # 7天,s为单位
    secret: abcdefghabcdefghabcdefghabcdefgh

写登录认证成功、失败处理器LoginSuccessHandler、LoginFailureHandler

  登录失败后,我们需要向前端发送错误信息,登录成功后,我们需要生成JWT,并将JWT返回给前端
  我们先定义后端返回给前端的统一封装结果Result:

import lombok.Data;

import java.io.Serializable;

@Data
public class Result implements Serializable 

    private int code;
    private String msg;
    private Object data;

    public static Result succ(Object data) 
        return succ(200, "操作成功", data);
    

    public static Result fail(String msg) 
        return fail(400, msg, null);
    

    public static Result succ (int code, String msg, Object data) 
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    

    public static Result fail (int code, String msg, Object data) 
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    

  为什么要定义这个,以及为什么这么写,可以看我的这篇博客:SpringBoot + Vue前后端分离开发:全局异常处理及统一结果封装

  接下来我们来写LoginSuccessHandler、LoginFailureHandler,直接上代码:

@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler 

    @Autowired
    JwtUtils jwtUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException 
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = httpServletResponse.getOutputStream();

        // 生成JWT,并放置到请求头中
        String jwt = jwtUtils.generateToken(authentication.getName());
        httpServletResponse.setHeader(jwtUtils.getHeader(), jwt);

        Result result = Result.succ("SuccessLogin");

        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    

@Component
public class LoginFailureHandler implements AuthenticationFailureHandler 

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException 
        httpServletResponse.setContentType("application/json;charset=UTF-8");
        ServletOutputStream outputStream = httpServletResponse.getOutputStream();

        String errorMessage = "用户名或密码错误";
        Result result;
        if (e instanceof CaptchaException) 
            errorMessage = "验证码错误";
            result = Result.fail(errorMessage);
         else 
            result = Result.fail(errorMessage);
        
        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    

  LoginSuccessHandler、LoginFailureHandler分别需要实现AuthenticationSuccessHandler和AuthenticationFailureHandler接口,需分别重写接口的onAuthenticationSuccess、onAuthenticationFailure方法,onAuthenticationSuccess方法的参数为HttpServletRequest、HttpServletResponse以及Authentication,onAuthenticationFailure方法的第三个参数与其不同,是AuthenticationException,表示登录失败对应的异常

  onAuthenticationFailure方法用于向前端返回错误信息,登录失败有可能是用户名密码错误,有可能是验证码错误,这里我们自定义了验证码错误的异常,它继承了Spring Security的AuthenticationException:

public class CaptchaException extends AuthenticationException 

    public CaptchaException(String msg) 
        super(msg);
    

  SpringSecurity中的接口Authentication继承了接口Principal,Principal接口表示主体的抽象概念,可用于表示任何实体,例如个人、公司和登录 ID,一般用来表示用户认证相关信息,调用其getName方法可以获得用户名

写RedisUtil工具类以及验证码配置

  RedisUtil工具类没什么好说的,网上有很多现成的

  验证码生成使用的是谷歌的验证码工具类,配置类如下:

@Configuration
public class KaptchaConfig 

    @Bean
    DefaultKaptcha producer() 
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "4");
        properties.put("kaptcha.image.height", "40");
        pro

以上是关于Spring Security整合JWT实现权限认证和授权的主要内容,如果未能解决你的问题,请参考以下文章

Spring Security整合JWT,实现单点登录,So Easy~!

spring boot + jwt + spring security前后端分离实现权限控制

Spring boot+Spring security+JWT实现前后端分离登录认证及权限控制

#私藏项目实操分享# Spring专题「开发实战」Spring Security与JWT实现权限管控以及登录认证指南

Spring Boot Security OAuth2 实现支持JWT令牌的授权服务器

Spring Security + JWT 实现一个权限系统,写的太好了吧!