项目环境

本篇文章环境:Spring Boot + Mybatis + Spring Security + Redis + JWT

预期成果:实现具有验证码校验、RBAC 权限控制的前后端分离项目

注意:为什么要使用 Json Web Token(JWT)?这是因为前后端分离项目中,前端与后端之间的通信采用 RESTFul API 的交互方式进行交互。这种前后端分离的交互是无状态的交互方式,所以,每次交互都必须进行身份验证。而传统方案是根据用户信息生成token,将token 存入浏览器 cookie(或存入数据库),之后每次请求都会带上这个 cookie,由后端根据这个 cookie 来查询用户并验证是否过期。这种方案存在很多的问题,由于 cookie 是可以被 JavaScript 读取的,这会导致用户 token 泄露。

数据库设计

Web 的安全控制一般分为两个部分,一个是认证,一个是授权。认证即判断是否为合法用户,简单的说就是登录。用户名和密码匹配成功即认证成功。授权是基于已认证的前提下,根据用户的不同权限,开放不同的资源(本文简单化处理,认为资源就是 API,实际的资源可能包括菜单、静态图片等)。一般 RBAC 权限控制有三层,即:用户<–>角色<–>权限,用户与角色是多对多,角色和权限也是多对多,最后由权限控制资源(URL)的访问。本文为了便于处理,在数据库设计时对表内的字段进行了简化。
在这里插入图片描述

在这里我们先暂时不考虑权限,只考虑用户<–>角色<–>资源。(源码被注释的部分中含有用户、角色、权限三层控制)

认证管理

在这一步中,我们需要自定义 UserDetailsService ,将用户信息和权限注入进来,为后面的授权做准备。

在实现UserDetailsService之后,需要重写 loadUserByUsername 方法,参数是用户输入的用户名。返回值是UserDetails,这是一个接口,一般使用它的子类org.springframework.security.core.userdetails.User,它有三个参数,分别是用户名、密码和权限集。(实际开发中,我们可以将实体类中的 User 继承org.springframework.security.core.userdetails.User以满足更多需求)

并且实际应用中,为了减少对数据库的访问次数,我们通常会将权限集放入缓存中,下次可以直接从缓存中获取,可以有效提高效率。

UserDetailsService 实现类
package com.security.service;

import com.security.mapper.APIMapper;
import com.security.mapper.AuthoritiesMapper;
import com.security.mapper.RoleMapper;
import com.security.mapper.UserMapper;
import com.security.pojo.SysUser;
import com.security.utils.RedisUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service("userDetailsService")
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private RoleMapper roleMapper;

    @Autowired
    private AuthoritiesMapper authoritiesMapper;

    @Autowired
    private RedisUtils redisUtils;

    //自定义的登录逻辑
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = userMapper.queryUserByUsername(username);
        //根据用户名去数据库进行查询,如不存在则抛出异常
        if (user == null){
            throw new UsernameNotFoundException("用户不存在");
        }
        List<GrantedAuthority> authorities = new ArrayList<>();
        //方法一:使用用户、角色、资源建立关系,直接使用角色控制权限
        List<String> codeList = roleMapper.queryUserRole(user.getUsername());
        //添加权限信息进入缓存
        redisUtils.set(username, StringUtils.join(codeList,","),60 * 60);
        //方法二:添加权限(资源表),通过建立用户、角色、权限、资源之间的关系,使用"权限"实现按钮级别的权限控制
//        List<String> codeList = authoritiesMapper.queryAuthoritiesList(user.getUsername());
        codeList.forEach(code ->{
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code);
            authorities.add(simpleGrantedAuthority);
        });
        return new User(username, user.getPassword(), authorities);
    }
}

部分 mapper 接口和具体实现
package com.security.mapper;

import com.security.pojo.Role;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Mapper
@Repository
public interface RoleMapper {

    List<String> queryUserRole(@Param("username")String username);
    List<Role> selectListByUrl(String url);
}

package com.security.mapper;

import com.security.pojo.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

@Mapper
@Repository
public interface UserMapper {

    SysUser queryUserByUsername(@Param("username")String username);
}

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.security.mapper.RoleMapper">

    <select id="queryUserRole" resultType="String">
        select
            r.code
        from
            user_role ur
            left join sys_user u on ur.user_id = u.id
            left join sys_role r on ur.role_id = r.id
        where
            u.username = #{username}
    </select>

    <select id="selectListByUrl" resultType="Role">
        SELECT
            r.*
        FROM
            sys_role r
        LEFT JOIN sys_role_api ra ON ra.role_id = r.id
        LEFT JOIN sys_api sa ON sa.id = ra.api_id
        WHERE sa.api_url = #{url}
    </select>

</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.security.mapper.UserMapper">

    <select id="queryUserByUsername" resultType="SysUser">
        select * from sys_user where username = #{username}
    </select>

</mapper>

接着,创建一个配置类并继承自 WebSecurityConfigurerAdapter,并重写 configure(AuthenticationManagerBuilder auth) 方法就可以完成简单的登录认证了。在这一步里,我们需要自定义两个处理器,分别是成功处理器和失败处理器,以及统一的前后端通信消息体格式。

成功处理器
package com.security.config;

import com.security.common.ResponseBody;
import com.security.utils.JwtUtils;
import com.security.utils.ResponseBodyUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AjaxAuthenticationSuccessHandler extends JSONAuthentication implements AuthenticationSuccessHandler {

    //JWT处理工具类
    @Autowired
    private JwtUtils jwtUtils;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        ResponseBody responseBody = ResponseBodyUtil.success();

        //生成jwt Token
        String jwt = jwtUtils.generateToken(authentication.getName());
        httpServletResponse.setHeader(jwtUtils.getHeader(), jwt);
        //继承封装的输出JSON格式类,并调用父类方法即可
        this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);
    }
}

失败处理器
package com.security.config;

import com.security.common.ResponseBody;
import com.security.common.ResponseCode;
import com.security.utils.ResponseBodyUtil;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AjaxAuthenticationFailureHandler extends JSONAuthentication implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseBody responseBody = null;
        if (e instanceof AccountExpiredException) {
            //账号过期
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_EXPIRED);
        } else if (e instanceof BadCredentialsException) {
            //密码错误
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_ERROR);
        } else if (e instanceof CredentialsExpiredException) {
            //密码过期
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_CREDENTIALS_EXPIRED);
        } else if (e instanceof DisabledException) {
            //账号不可用
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_DISABLE);
        } else if (e instanceof LockedException) {
            //账号锁定
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_LOCKED);
        } else if (e instanceof InternalAuthenticationServiceException) {
            //用户不存在
            responseBody = ResponseBodyUtil.fail(ResponseCode.USER_ACCOUNT_NOT_EXIST);
        }else{
            //其他错误
            responseBody = ResponseBodyUtil.fail(ResponseCode.COMMON_FAIL);
        }
        //继承封装的输出JSON格式类,并调用父类方法即可
        this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);
    }
}

封装的 JSONAuthentication 抽象类

该抽象类主要是对处理器内都需要实现的一些功能的一个封装

package com.security.config;

import com.alibaba.fastjson.JSON;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

public abstract class JSONAuthentication {

    /**
     * 输出JSON格式数据
     * @param httpServletRequest
     * @param httpServletResponse
     * @param obj
     * @throws IOException
     * @throws ServletException
     */
    protected void WriteJSON(HttpServletRequest httpServletRequest,
                             HttpServletResponse httpServletResponse,
                             Object obj) throws IOException, ServletException {
        //设置编码格式
        httpServletResponse.setContentType("text/json;charset=utf-8");
        //处理跨域问题
        httpServletResponse.setHeader("Access-Control-Allow-Origin", "*");
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "POST, GET");

        //输出JSON
        PrintWriter out = httpServletResponse.getWriter();
        out.write(JSON.toJSONString(obj));
        out.flush();
        out.close();
    }
}

封装的消息体
package com.security.common;

import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.io.Serializable;

@Data
@Getter
@Setter
@NoArgsConstructor
public class ResponseBody<T> implements Serializable {
    private Boolean success;
    private Integer statusCode;
    private String msg;
    private T data;

    public ResponseBody(boolean success) {
        this.success = success;
        this.statusCode = success ? ResponseCode.SUCCESS.getCode() : ResponseCode.COMMON_FAIL.getCode();
        this.msg = success ? ResponseCode.SUCCESS.getMessage() : ResponseCode.COMMON_FAIL.getMessage();
    }

    public ResponseBody(boolean success, ResponseCode resultEnum) {
        this.success = success;
        this.statusCode = success ? ResponseCode.SUCCESS.getCode() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getCode() : resultEnum.getCode());
        this.msg = success ? ResponseCode.SUCCESS.getMessage() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
    }

    public ResponseBody(boolean success, T data) {
        this.success = success;
        this.statusCode = success ? ResponseCode.SUCCESS.getCode() : ResponseCode.COMMON_FAIL.getCode();
        this.msg = success ? ResponseCode.SUCCESS.getMessage() : ResponseCode.COMMON_FAIL.getMessage();
        this.data = data;
    }

    public ResponseBody(boolean success, ResponseCode resultEnum, T data) {
        this.success = success;
        this.statusCode = success ? ResponseCode.SUCCESS.getCode() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getCode() : resultEnum.getCode());
        this.msg = success ? ResponseCode.SUCCESS.getMessage() : (resultEnum == null ? ResponseCode.COMMON_FAIL.getMessage() : resultEnum.getMessage());
        this.data = data;
    }
}

响应码枚举类
package com.security.common;


/**
 * 状态码定义
 * #1001~1999 区间表示参数错误
 * #2001~2999 区间表示用户错误
 * #3001~3999 区间表示接口异常
 */

public enum ResponseCode {
    /* 成功 */
    SUCCESS(200, "成功"),

    /* 默认失败 */
    COMMON_FAIL(999, "失败"),

    /* 参数错误:1000~1999 */
    PARAM_NOT_VALID(1001, "参数无效"),
    PARAM_IS_BLANK(1002, "参数为空"),
    PARAM_TYPE_ERROR(1003, "参数类型错误"),
    PARAM_NOT_COMPLETE(1004, "参数缺失"),

    /* 用户错误 */
    USER_NOT_LOGIN(2001, "用户未登录"),
    USER_ACCOUNT_EXPIRED(2002, "账号已过期"),
    USER_CREDENTIALS_ERROR(2003, "密码错误"),
    USER_CREDENTIALS_EXPIRED(2004, "密码过期"),
    USER_ACCOUNT_DISABLE(2005, "账号不可用"),
    USER_ACCOUNT_LOCKED(2006, "账号被锁定"),
    USER_ACCOUNT_NOT_EXIST(2007, "账号不存在"),
    USER_ACCOUNT_ALREADY_EXIST(2008, "账号已存在"),
    USER_ACCOUNT_USE_BY_OTHERS(2009, "账号多点登录,账号下线"),
    USER_SESSION_INVALID(2010,"登录超时"),

    /* 业务错误 */
    NO_PERMISSION(3001, "没有权限");
    private Integer code;
    private String message;

    ResponseCode(Integer code, String message){
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    /**
     * 根据code获取message
     *
     * @param code
     * @return
     */
    public static String getMessageByCode(Integer code) {
        for (ResponseCode ele : values()) {
            if (ele.getCode().equals(code)) {
                return ele.getMessage();
            }
        }
        return null;
    }
}

WebSecurityConfigurerAdapter
package com.security.config;

import com.security.common.CaptchaFilter;
import com.security.common.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登录成功的处理器
     */
    @Autowired
    private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;

    /**
     * 登录失败的处理器
     */
    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //自定义数据库登录逻辑
        auth.userDetailsService(userDetailsService)
                //设置加密方式
                .passwordEncoder(passwordEncoder());
    }  

}

至此,我们已经可以完成一个非常简单的认证

注意:在配置完成 Spring Security 之后, Spring 会赠送 “/login”以及 "/logout"接口,无需自己实现。

但是,我们实际应用中不可能每次访问资源都重新登录,而且,前文中我们明确拒绝使用 cookie,那用什么方式实现身份验证呢?答案是 JWT。

JWT 过滤器

在这一步中,我们需要通过继承 BasicAuthenticationFilter 类并重写 doFilterInternal 方法来实现 JWT 解析、身份验证和自动登录。

要做到这一步,前端发送的所有请求的请求头必须带有 JWT,整体的流程是在第一次登录成功后,将 JWT 写入响应头,前端接收到之后将其存储,并在之后的每一次请求中,都将 JWT 写入请求头之中。并且由于之前登录时已经将权限信息写入缓存,所以在校验JWT 通过之后,应该先从缓存中取出权限,若缓存中没有权限才重新查询数据库。

package com.security.common;

import cn.hutool.core.util.StrUtil;
import com.security.mapper.APIMapper;
import com.security.mapper.RoleMapper;
import com.security.utils.JwtUtils;
import com.security.utils.RedisUtils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * JWT过滤器,使用token换取权限信息
 */

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    @Autowired
    private JwtUtils jwtUtils;

    @Autowired
    private APIMapper apiMapper;

    @Autowired
    private RoleMapper roleMapper;

    @Autowired
    private RedisUtils redisUtils;

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        //获取jwt
        String jwt = request.getHeader(jwtUtils.getHeader());
        if (StrUtil.isBlankOrUndefined(jwt)){
            //如果jwt为空,则过滤链继续往下执行
            chain.doFilter(request,response);
            return;
        }

        Claims claims = jwtUtils.parseToken(jwt);
        //判断jwt是否被篡改、是否解析异常
        if (claims == null){
            throw new JwtException("token 异常");
        }
        //判断jwt是否过期
        if (jwtUtils.isTokenExpired(claims)){
            throw new JwtException("token 过期");
        }

        String username = claims.getSubject();

        /**
         * 获取角色(权限)
         * 从缓存中获取权限,若缓存中没有则才正常从数据库中获取
         * 注意:如果用户权限发生改变时,需要将缓存中的数据删除
        */
        List<String> codeList = null;
        if (redisUtils.hashKey(username)){
            String value = redisUtils.get(username).toString();
            codeList = Arrays.asList(value.split(","));
        }else {
            //用户、角色、资源方案:使用角色控制权限
            codeList = roleMapper.queryUserRole(username);
            //用户、角色、权限、资源方案:使用“权限”控制权限
            //codeList = authoritiesMapper.queryAuthoritiesList(user.getUsername());
        }
        List<GrantedAuthority> authorities = new ArrayList<>();
        codeList.forEach(code ->{
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(code);
            authorities.add(simpleGrantedAuthority);
        });
        UsernamePasswordAuthenticationToken token
                = new UsernamePasswordAuthenticationToken(username, null, authorities);
        //根据上下文,获取用户的权限,实现自动登录
        SecurityContextHolder.getContext().setAuthentication(token);
        chain.doFilter(request,response);

    }
}

如果,认证在稍微复杂一点,加上验证码认证呢?

验证码

验证码的整体工作流程是:当前端调用验证码的 API 时,后端在生成随机字符 code 的同时,生成一个随机码 key,然后将随机码和验证码写入缓存,并将随机码和根据验证码生成的图片验证码返回给前端。前端登陆时,不仅返回用户名、密码、验证码,还需要将随机码 key 一同返回,后端收到随机码 key 之后,就可以从缓存中取出随机码 code,之后只需要将缓存中的 code 与用户输入的 code 进行比对即可。

/*获取验证码,借助hutool的验证码生成工具类*/
@GetMapping("/getCaptcha")
    public ResponseBody getCode() throws IOException {
        //生成随机码,作为验证码的key值,传给前端(方便验证时,根据key从redis中取出正确的验证码value)
        String key = UUID.randomUUID().toString();
        // 随机生成宽200、高100的 4 位验证码
        ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 100, 4, 4);
        String code = captcha.getCode();
        System.out.println("key:"+key);
        System.out.println(code);
        //写入到流中
        BufferedImage bufferedImage = captcha.getImage();
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ImageIO.write(bufferedImage,"jpg", outputStream);
        //进行base64编码
        BASE64Encoder base64Encoder = new BASE64Encoder();
        //编码前缀
        String str = "data:image/jpeg;base64,";
        //使用hutool自己提供的方法,直接获取base64编码
        //String base64 = str + captcha.getImageBase64();
        String base64Image = str + base64Encoder.encode(outputStream.toByteArray());
        //将验证码和对应的随机key值写入缓存数据库
        redisUtils.set(key,code,600);
        return ResponseBodyUtil.success(
                MapUtil.builder()
                    .put("key",key)
                    .put("base64Image", base64Image)
                    .build()
        );

由于Spring Security 本身并没有自带验证码过滤器,所以,我们可以通过继承 OncePerRequestFilter抽象类实现验证码过滤器 ,并且将该过滤器设置在用户名、密码、权限过滤器之前。这样每次访问接口都会经过此过滤器,我们可以获取请求路径,并判定当请求路径为/login时进入验证码验证流程。

package com.security.common;

import com.security.config.AjaxAuthenticationFailureHandler;
import com.security.exception.CaptchaException;
import com.security.utils.RedisUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 验证码过滤器
 */

@Component
public class CaptchaFilter extends OncePerRequestFilter {

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String url = httpServletRequest.getRequestURI();
        //当url为登录路径且请求方式为post时,进入该过滤器处理
        if ("/login".equals(url) && "POST".equals(httpServletRequest.getMethod())){
            try {
                validate(httpServletRequest);
            }catch (CaptchaException captchaException){
                //如果不正确,扑获到验证码异常就交给认证失败处理器
                ajaxAuthenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,captchaException);
            }

        }
        //验证成功,则过滤链继续往下执行
        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }

    //验证码校验逻辑
    private void validate(HttpServletRequest httpServletRequest) {
        String code = httpServletRequest.getParameter("code");
        String key = httpServletRequest.getParameter("key");
        //判断是否为空
        if (StringUtils.isBlank(code) || StringUtils.isBlank(key)){
            throw new  CaptchaException("验证码错误");
        }

        if (!code.equals(redisUtils.get(key))){
            throw new  CaptchaException("验证码错误");
        }
        //删除缓存,一次性使用
        redisUtils.del(key);
    }
}

由于上一步抛出了验证码异常,所以,我们需要实现该异常处理

package com.security.exception;

import org.springframework.security.core.AuthenticationException;

/**
 * 验证码校验异常
 */

public class CaptchaException extends AuthenticationException {
    public CaptchaException(String msg) {
        super(msg);
    }
}

修改 WebSecurityConfigurerAdapter

添加 JWT 与验证码过滤器之后的 Security 配置类:

package com.security.config;

import com.security.common.CaptchaFilter;
import com.security.common.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登录成功的处理器
     */
    @Autowired
    private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;

    /**
     * 登录失败的处理器
     */
    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    /**
     * 自定义验证码过滤器
     */
    @Autowired
    private CaptchaFilter captchaFilter;


    /**
     * jwt过滤器
     * @return
     * @throws Exception
     */
    @Bean
    JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
        return jwtAuthenticationFilter;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //自定义数据库登录逻辑
        auth.userDetailsService(userDetailsService)
                //设置加密方式
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //添加自定义过滤器,并设置在用户名密码权限过滤器之前
        http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilter(jwtAuthenticationFilter());

        //授权
        http.authorizeRequests()
                //放行不需要拦截的请求
                .antMatchers("/login","/getCaptcha").permitAll()
                //所有请求必须认证才能访问
                .anyRequest().authenticated()

                //注销
                .and()
                    .logout().permitAll()
                    //注销后,删除cookie
                    .deleteCookies("JSESSIONID")

                .and()
                    //设置登录方式为表单提交
                    .formLogin().permitAll()
                    //前后端分离:登录成功处理器,前端通过json数据进行页面跳转
                    .successHandler(ajaxAuthenticationSuccessHandler)
                    .failureHandler(ajaxAuthenticationFailureHandler)
                ;
        //关闭CSRF跨域
        http.csrf().disable();
       
    }

}

认证通过后,用户的权限信息会封装成一个User(此 User 非彼 User,而是 Spring Security 的 USer)放到 Spring 的全局缓存 SecurityContextHolder 中,以备后面访问资源时使用。

授权管理

在授权管理中,用户可以访问什么资源(API)取决于用户具有什么角色/权限。授权的流程如图所示:
在这里插入图片描述

AbstractSecurityInterceptor 权限拦截器

我们首先需要继承 AbstractSecurityInterceptor 资源管理拦截器抽象类,并实现 servler 的 Filter 接口,从而实现过滤 URL 并拦截请求。

package com.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.SecurityMetadataSource;
import org.springframework.security.access.intercept.AbstractSecurityInterceptor;
import org.springframework.security.access.intercept.InterceptorStatusToken;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

import javax.servlet.*;
import java.io.IOException;

/**
 * 权限拦截器
 */

@Component
public class UrlAbstractSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {

    @Autowired
    private FilterInvocationSecurityMetadataSource securityMetadataSource;

    @Autowired
    public void setUrlAccessDecisionManager(UrlAccessDecisionManager urlAccessDecisionManager) {
        super.setAccessDecisionManager(urlAccessDecisionManager);
    }

    @Override
    public Class<?> getSecureObjectClass() {
        return FilterInvocation.class;
    }

    @Override
    public SecurityMetadataSource obtainSecurityMetadataSource() {
        return this.securityMetadataSource;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
        invoke(fi);
    }

    private void invoke(FilterInvocation fi) throws IOException, ServletException {
        //FilterInvocation里面有一个被拦截的url
        //里面调用InvocationSecurityMetadataSource实现类的getAttributes(Object object)这个方法获取fi对应的所有权限
        //再调用AccessDecisionManager实现类的decide方法来校验用户的权限是否足够
        InterceptorStatusToken token = super.beforeInvocation(fi);
        try {
            //执行下一个拦截器
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        } finally {
            super.afterInvocation(token, null);
        }
    }
}

FilterInvocationSecurityMetadataSource 实现类

该类的主要功能就是通过当前的请求地址获取该地址需要的用户角色,并将访问该URL 所需要的角色权限信息传给决策器,由决策器进行表决。

package com.security.config;

import com.security.mapper.APIMapper;
import com.security.mapper.AuthoritiesMapper;
import com.security.mapper.RoleAPIMapper;
import com.security.mapper.RoleMapper;
import com.security.pojo.API;
import com.security.pojo.Authorities;
import com.security.pojo.Role;
import com.security.pojo.RoleAPI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.Collection;
import java.util.LinkedList;
import java.util.List;

/**
 * 获取该url所需要的用户角色权限信息
 */

@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    @Autowired
    private RoleMapper roleMapper;

    @Autowired
    private AuthoritiesMapper authoritiesMapper;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        /*
        // TODO 忽略url请放在此处进行过滤放行
        if ("/login".equals(requestUrl) || requestUrl.contains("logout")) {
            return null;
        }
         */
        //获取请求地址
        String requestUrl = ((FilterInvocation) object).getRequestUrl();

        //用户、角色、资源方案:查询具体某个接口的权限
        List<Role> roleList =  roleMapper.selectListByUrl(requestUrl);
        //用户、角色、权限、资源方案:查询某个具体的权限
        //List<Authorities> authoritiesList = authoritiesMapper.queryAuthoritiesByUrl(requestUrl);

        if(roleList == null || roleList.size() == 0){
            //请求路径没有配置权限,表明该请求接口可以任意访问
            return null;
        }
        String[] attributes = new String[roleList.size()];
        for(int i = 0;i<roleList.size();i++){
            attributes[i] = roleList.get(i).getCode();
        }
        return SecurityConfig.createList(attributes);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    /**
     * 改为true,否则注入时报错
     * @param aClass
     * @return
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

在这个实现类中,getAttributes(Object o) 方法返回的集合最终会来到 AccessDecisionManager 类。

AccessDecisionManager 实现类

授权管理器会通过spring的全局缓存 SecurityContextHolder 获取用户的权限信息,还会获取被拦截的 URL 和被拦截 URL 所需的全部权限,然后根据所配的策略进行判定。我们需要重写授权管理器的 decide() 方法,对访问的 URL 进行权限认证处理。decide() 方法接收的三个参数,第一个参数保存了当前登录用户的角色信息,第二个参数是请求的 URL,第三个参数是 getAttributes() 方法返回的权限集合。而当前请求所需的权限和当前用户具有的权限有一个符合即可正常访问。

package com.security.config;

import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Iterator;

/**
 * 对访问url进行权限认证处理
 */

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {

    /**
     *
     * @param authentication  当前登录用户的角色信息
     * @param o               请求的url
     * @param collection      由UrlFilterInvocationSecurityMetadataSource中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个)
     * @throws AccessDeniedException
     * @throws InsufficientAuthenticationException
     */
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, InsufficientAuthenticationException {

        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            //当前请求需要的权限
            String needRole = ca.getAttribute();
            //当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                //只要符合一个即可访问
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足!");
    }

    /**
     * 以下两个都要改为true,否则,注入的时候会报错
     * @param configAttribute
     * @return
     */
    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

由于我们上一步在用户权限与请求权限不相符合时抛出了AccessDeniedException异常,所以,我们需要自定义实现该异常。

权限不足处理器
package com.security.config;

import com.security.common.ResponseBody;
import com.security.common.ResponseCode;
import com.security.utils.ResponseBodyUtil;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 权限不足处理逻辑
 */

@Component
public class AjaxAccessDeniedHandler extends JSONAuthentication implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
        ResponseBody responseBody = ResponseBodyUtil.fail(ResponseCode.NO_PERMISSION);
        this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);
    }
}

匿名访问处理器

为了处理匿名访问 API,自定义实现匿名访问处理器

package com.security.config;

import com.security.common.ResponseBody;
import com.security.common.ResponseCode;
import com.security.utils.ResponseBodyUtil;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 自定义匿名访问无权访问资源处理逻辑
 */

@Component
public class AjaxAuthenticationEntryPoint extends JSONAuthentication implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        ResponseBody responseBody = ResponseBodyUtil.fail(ResponseCode.USER_NOT_LOGIN);
        this.WriteJSON(httpServletRequest,httpServletResponse,responseBody);
    }
}

修改 WebSecurityConfigurerAdapter

正常的业务还需要添加会话过期处理器、多点登录处理器、注销成功/失败处理器等,本文不做一一阐述,添加完所有处理器后的完整的 WebSecurityConfigurerAdapter 如下:

package com.security.config;

import com.security.common.CaptchaFilter;
import com.security.common.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登录成功的处理器
     */
    @Autowired
    private AjaxAuthenticationSuccessHandler ajaxAuthenticationSuccessHandler;

    /**
     * 登录失败的处理器
     */
    @Autowired
    private AjaxAuthenticationFailureHandler ajaxAuthenticationFailureHandler;

    /**
     * 匿名访问无权访问资源的处理器
     */
    @Autowired
    private AjaxAuthenticationEntryPoint ajaxAuthenticationEntryPoint;

    /**
     * 权限不足处理器
     */
    @Autowired
    private AjaxAccessDeniedHandler ajaxAccessDeniedHandler;

    /**
     * 注销操作处理器
     */
    @Autowired
    private AjaxLogoutHandler ajaxLogoutHandler;

    /**
     * 注销成功处理器
     */
    @Autowired
    private AjaxLogoutSuccessHandler ajaxLogoutSuccessHandler;

    /**
     * 会话过期处理器
     */
    @Autowired
    private AjaxInvalidSessionStrategy ajaxInvalidSessionStrategy;

    /**
     * 多点登录处理器
     */
    @Autowired
    private AjaxSessionInformationExpiredStrategy ajaxSessionInformationExpiredStrategy;

    /**
     * 自定义验证码过滤器
     */
    @Autowired
    private CaptchaFilter captchaFilter;

    /**
     * 获取访问url所需要的角色信息
     */
    @Autowired
    private UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;

    /**
     * 认证权限处理,将之前所获的角色权限与当前登录用户的角色对比,若包含其中一个即可正常访问
     */
    @Autowired
    private UrlAccessDecisionManager urlAccessDecisionManager;

    @Autowired
    private UrlAbstractSecurityInterceptor urlAbstractSecurityInterceptor;

    /**
     * jwt过滤器
     * @return
     * @throws Exception
     */
    @Bean
    JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
        return jwtAuthenticationFilter;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //自定义数据库登录逻辑
        auth.userDetailsService(userDetailsService)
                //设置加密方式
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //添加自定义过滤器,并设置在用户名密码权限过滤器之前
        http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilter(jwtAuthenticationFilter())
            .addFilterBefore(urlAbstractSecurityInterceptor, FilterSecurityInterceptor.class);

        //授权
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        //决策管理器
                        o.setAccessDecisionManager(urlAccessDecisionManager);
                        //安全元数据源
                        o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                        return o;
                    }
                })
                //放行不需要拦截的请求
                .antMatchers("/login","/getCaptcha").permitAll()
                //所有请求必须认证才能访问
                .anyRequest().authenticated()

                //注销
                .and()
                    .logout().permitAll()
                    .addLogoutHandler(ajaxLogoutHandler)
                    //注销成功
                    .logoutSuccessHandler(ajaxLogoutSuccessHandler)
                    //注销后,删除cookie
                    .deleteCookies("JSESSIONID")

                .and()
                    //设置登录方式为表单提交
                    .formLogin().permitAll()
                    .successHandler(ajaxAuthenticationSuccessHandler)
                    .failureHandler(ajaxAuthenticationFailureHandler)

                .and()
                    .exceptionHandling()
                    //权限不足处理
                    .accessDeniedHandler(ajaxAccessDeniedHandler)
                    //未登录,访问资源的异常
                    .authenticationEntryPoint(ajaxAuthenticationEntryPoint)

                //会话管理
                .and()
                    .sessionManagement()
                    //会话过期策略
                    .invalidSessionStrategy(ajaxInvalidSessionStrategy)
                    //最大允许登录数
                    .maximumSessions(1)
                    //达到最大登录数后,是否允许继续登录(否会挤掉已经登录的账户)
                    .maxSessionsPreventsLogin(false)
                    //多点登录处理方式
                    .expiredSessionStrategy(ajaxSessionInformationExpiredStrategy)
                ;
                 
        //关闭CSRF跨域
        http.csrf().disable();
        //前后端分离是无状态的,所以使用STATELESS策略
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

}
Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐