Bootstrap

安全管理 | 前后端方案详解:Vue/SpringBoot+SpringSecurity+JWT

背景

前后端分离项目,直接暴露api接口,是很危险的一件事情,因此需要引入安全管理框架。在安全管理这个领域,我们熟知的框架有Shiro、Spring Security,考虑到后端使用Spring Boot,并且它Spring Security提供了自动化配置方案,可以零配置使用,因此选择Spring Security。 

当然Shiro与SSM(Spring+SpringMVC+MyBatis)更搭配。

文章篇幅较长,建议在PC上浏览,且其左侧有【目录】,便于快速预览大纲以及定位!

一、安全管理SpringSecurity

1、概述

Spring Security是一个功能强大且高度可定制的身份验证和访问控制框架,致力于为Java应用程序提供身份验证和授权。具有如下特点:

1. 全面和可扩展的身份验证和授权支持 2. 防御会话固定,点击劫持,跨站点请求伪造等攻击 3. Servlet API集成 4. 与Spring Web MVC的可选集成

2、执行路程

通过流程图,可以认为SpringSecurity是一组filter过滤器组成的权限认证。

流程图说明:

WebAsyncManagerIntegrationFilter:

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

SecurityContextPersistenceFilter:

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

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

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

LogoutFilter:用于处理退出登录。

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

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

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

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

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

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

SessionManagementFilter:管理 session 的过滤器

ExceptionTranslationFilter:处理 AccessDeniedException 和 AuthenticationException 异常。

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

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

3、注解

这里讲解下方法级安全的几个注解,@PreAuthorize, @PostAuthorize, @Secured。

3.1 开启注解
// prePostEnabled:开启@PreAuthorize, @PostAuthorize
// securedEnabled:开启@Secured
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter{

}
3.2 @Secured
@GetMapping("/hello")
@Secured({"ROLE_normal","ROLE_admin"})
public String hello() {
    return "hello world";
}

说明:拥有normal或者admin角色的用户都可以访问hello方法,但若要求同时拥有则,则@Secured无能为力。

3.3 @PreAuthorize
@GetMapping("/hello")
// 拥有normal或者admin角色的用户都可以方法helloUser()方法。
@PreAuthorize("hasAnyRole('normal','admin')")
// 同时拥有normal、admin两个角色可以访问
// @PreAuthorize("hasRole('normal') AND hasRole('admin')")
public String hello() {
    return "hello world";
}
3.4 @PostAuthorize

在方法执行后再进行权限校验,适合验证带有返回值的权限。

@GetMapping("/hello")
@PostAuthorize(" returnObject!=null &&  returnObject.username == authentication.name")
public User hello() {
    Object pricipal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    User user;
    if("anonymousUser".equals(pricipal)) {
        user = null;
    }else {
        user = (User) pricipal;
    }
    return user;
}

4、自定义配置

Spring Boot为Spring Security提供的可零配置的自动化配置方案本文不做介绍。

4.1 添加依赖

环境:采用Spring Initializr快速构建的Spring Boot。


   org.springframework.boot
   spring-boot-starter-security
4.2 创建SpringSecurity自定义配置类
EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter
{
    /**
     * 自定义用户认证逻辑
     */
    @Autowired
    private UserDetailsService userDetailsService;
    
    /**
     * 认证失败处理类
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出处理类
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token认证过滤器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    /**
     * 跨域过滤器
     */
    @Autowired
    private CorsFilter corsFilter;
    
    /**
     * 解决 无法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception
    {
        return super.authenticationManagerBean();
    }

    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception
    {
        httpSecurity
                // CSRF禁用,因为不使用session
                .csrf().disable()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                // 对于登录login 验证码captchaImage 允许匿名访问
                .antMatchers("/login", "/captchaImage").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                .antMatchers("/profile/**").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    
    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
{
    private static final long serialVersionUID = -8970718410437077606L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
            throws IOException
    {
        int code = HttpStatus.UNAUTHORIZED;
        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }
}
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
    @Autowired
    private TokenService tokenService;

    /**
     * 退出处理
     * 
     * @return
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser))
        {
            String userName = loginUser.getUsername();
            // 删除用户缓存记录
            tokenService.delLoginUser(loginUser.getToken());
            // 记录用户退出日志
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
        }
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));
    }
}

5、自定义请求权限校验

5.1 自定义权限服务
@Service("ss")
public class PermissionService{
    public boolean hasPermi(String permission){
        if (StringUtils.isEmpty(permission)){
            return false;
        }
        // 通过请求头的token字段,去获取登录用户信息,token生成下面介绍。
        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions())){
            return false;
        }
        return hasPermissions(loginUser.getPermissions(), permission);
    }
    .... // 其他代码省略
}

public class ServletUtils{
    public static HttpServletRequest getRequest(){
        return getRequestAttributes().getRequest();
    }

    public static ServletRequestAttributes getRequestAttributes(){
        RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
        return (ServletRequestAttributes) attributes;
    }
    ... // 其他代码省略
}
5.2 请求添加注解
@Log(title = "角色管理", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:role:export')")
@GetMapping("/export")
public AjaxResult export(SysRole role)
{
    List list = roleService.selectRoleList(role);
    ExcelUtil util = new ExcelUtil(SysRole.class);
    return util.exportExcel(list, "角色数据");
}
5.3 自定义用户认证逻辑

通过SpringSecurity集成JWT的方式来实现,4.2节配置文件已声明,下面一起看看具体实现方式。

二、用户身份验证JWT

1、概述

JWT是JSON Web Token的简称,是一个开放标准(RFC 7519),用于作为JSON对象在各方之间安全的传输信息。该信息可以被验证和信任,属于数字签名,存在于客户端。

JWT广泛应用于Authorization(授权)和Informatica Exchange(信息交换)。一方面是因为它开销小,并且可以轻松跨域使用;另一方面是JWT可以被签名,如使用公钥/秘钥对,确定发送方,并且签名是使用头和有效负载计算,还可以验证内容有无被篡改。

2、JWT结构

JWT是由Header、Payload、Signature三部分组成,直接用圆点(".")连接。典型的JWT看起来像下面这个样式:

aaa.bbb.ccc

其中Header和Payload可以通过base64解密出来,因此通常不能在Payload中放置敏感的信息。

Header的构成:由token类型和算法名称组成

{
    'alg': "HS256", // 算法名称,如:HMAC/SHA256/RSA等
    'typ': "JWT"    // token的类型
}

Payload的构成:包含声明,通常是关于实体(用户)和其他数据的声明,base64对这个JSON编码就得到了Payload。

{
   user_key:'ada_xbasf_weet',
   time:'12394395'
}

Signature的构成:需由经过编码的header、payload和一个秘钥,经过header指定的签名算法进行签名而生成。例如:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

签名用于消息传递中有无被篡改,并且使用私钥签名的token,还可以验证JWT的发送方。

3、 JWT认证流程

4、JWT生成

用户登录成功后,我们这里将用户信息写入redies,并使用uuid随机数作为key,配置过期时间等数据。 同时使用uuid,作为JWT的声明,创建token返回前端,代码仅供流程参考:

@Component
public class TokenService{
    // 用户登录后调用的创建token方法
    public String createToken(LoginUser loginUser)
    {   
        // 生成随机uuid
        String token = IdUtils.fastUUID();
        loginUser.setToken(token);
        // 设置用户代理信息
        setUserAgent(loginUser);
        // 刷新token有效期,登录信息写redies
        refreshToken(loginUser);

        // 建立数据声明(Preload),创建token
        Map claims = new HashMap<>();
        claims.put("user_key", token);
        return createToken(claims);
    }

    // 用数据声明创建token
    private String createToken(Map claims)
    {
        String token = Jwts.builder()
                .setClaims(claims)
                .signWith(SignatureAlgorithm.HS512, secret).compact();
        return token;
    }

    // 从令牌中获取数据声明
    private Claims parseToken(String token)
    {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }
    ...   // 其他代码省略
}

5、集成JWT

我们在SpringSecurity中已经添加了Jwt过滤器,用于验证token的有效性,下面看看实现:

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        // 通过令牌获取登录用户信息
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {   
            // 验证令牌有效期,低于10分钟过期,则刷新
            tokenService.verifyToken(loginUser);
            // usernamePasswordAuthenticationtoken:对用户名和密码约定进行了一定的封装,将username复制到了principal,而将password赋值到了credentials.
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            //WebAuthenticationDetailsSource: 提供登录请求的用户的信息
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            // 重新设置当前的用户信息
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

至此我们简单梳理实现了,登录令牌(token)的生成,以及SpringSecurity集成JWT对token的验证。

三、前端认证权限控制

1、axios封装请求携带token

import axios from 'axios'
import store from '@/store'
import { getToken } from '@/utils/auth'

axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'
// 创建axios实例
const service = axios.create({
  // axios中请求配置有baseURL选项,表示请求URL公共部分
  baseURL: process.env.VUE_APP_BASE_API,
  // 超时
  timeout: 30 * 1000
})
// request拦截器
service.interceptors.request.use(config => {
  // 是否需要设置 token
  const isToken = (config.headers || {}).isToken === false
  if (getToken() && !isToken) {
    config.headers['Authorization'] = 'Bearer ' + getToken() // 让每个请求携带自定义token 请根据实际情况自行修改
  }
  return config
}, error => {
  console.log(error)
  Promise.reject(error)
})

// 其他代码省略
...

// @/utils/auth 文件
import Cookies from 'js-cookie'

const TokenKey = 'Admin-Token'

export function getToken() {
  return Cookies.get(TokenKey)
}

export function setToken(token) {
  return Cookies.set(TokenKey, token)
}

export function removeToken() {
  return Cookies.remove(TokenKey)
}

2、页面模块权限控制

页面模块根据用户权限来展示,这里可以使用Vue的指令来实现。

// 声明权限指令 hasPermis.js

//操作权限处理
import store from '@/store'

export default {
  inserted(el, binding, vnode) {
    const { value } = binding
    const all_permission = '*:*:*'
    // 权限信息存储在vuex的store
    const permissions = store.getters && store.getters.permissions
    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value
      // 判断是否存在权限
      const hasPermissions = permissions.some(permission => {
        return all_permission === permission || permissionFlag.includes(permission)
      })
      // 不存在权限,则删除元素
      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    } else {
      throw new Error(`请设置操作权限标签值`)
    }
  }
}

// 注册指令
import hasPermis from './hasPermis.js'

const install = function(Vue) {
  Vue.directive('hasPermi', hasPermi)
}

if (window.Vue) {
  window['hasPermi'] = hasPermi
  Vue.use(install); // eslint-disable-line
}

export default install

3、Vue指令讲解

Vue指令原理:

常用于对Dom的底层进行操作。

指令本质上是装饰器,是vue对HTML元素的扩展,给HTML元素增加自定义功能,语义化HTML标签。

vue编译DOM时,会执行与指令关联的JS代码,即找到指令对象,执行指令对象的相关方法。

Vue指令钩子函数:

Vue.directive('my-directive', {
  // 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
  bind: function () {},
  // 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
  inserted: function () {},
  // 所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。
  // 指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新
  update: function () {},
  // 指令所在组件的 VNode 及其子 VNode 全部更新后调用。
  componentUpdated: function () {},
  // 只调用一次,指令与元素解绑时调用
  unbind: function () {}
})

四、总结

至此本文梳理了前后端安全管理实现,涉及SpringSecurity、JWT以及axios封装请求、Vue指令,能够较完备的满足前后端分离开发的用户身份认证、UI权限控制、接口权限控制等需求。当然权限通常是根据角色挂靠,关于角色的控制,与权限同理,便不再叙述。