基于SpringSecurity + JWT的前后端分离登录模板(另有RBAC模型)

项目介绍

编程区新手,未来持续创作新项目

github仓库:https://github.com/neutron123ab/LoginTemplate

github主页:https://github.com/neutron123ab

​ 某天突发奇想,想做一个稍微完善点的登录系统,于是就开始编写这个项目,但目前这个项目还比较粗糙,后续我会不断完善,以后写项目就可以直接拿过来用了。

思路

​ 首先介绍一下这个登录系统的大致思路:

​ 前端先进行注册操作,为了数据传输安全,这里会先对前端输入的密码进行RSA加密,所用的公钥从后端接口中获取。后端接受到数据后使用密钥进行RSA解密,得到了密码明文。然后再对密码进行BCrypt加密,和用户信息一起存入数据库中。有人可能会问为什么这里不直接将前端加密后的密码存入数据库中,我的想法是:如果直接存储前端密文,别人不就有机会能直接获取数据库里面的数据了吗,所以还是把前端密文当做是一个前后端数据传输的临时产物吧。

这里再附上我的RSA工具类:

@Getter
public class RSAUtils {

    private final String publicKey;
    private final String privateKey;

    private static RSAUtils rsaUtils;
    private static RSA rsa;

    /**
     * 生成密钥对
     */
    private RSAUtils(){
        rsa = new RSA();
        publicKey = rsa.getPublicKeyBase64();
        privateKey = rsa.getPrivateKeyBase64();
    }

    /**
     * 单例模式获得工具类实例,防止频繁生成密钥对
     * @return 实例
     */
    public static RSAUtils getRsaUtils(){
        if(rsaUtils == null){
            rsaUtils = new RSAUtils();
        }
        return rsaUtils;
    }

    /**
     * 解密
     * @param password 密文
     * @return 明文
     */
    public String decodePassword(String password){

        String publicKey = rsaUtils.getPublicKey();
        String privateKey = rsaUtils.getPrivateKey();
        RSA rsa = new RSA(privateKey, publicKey);
        byte[] decrypt = rsa.decrypt(password, KeyType.PrivateKey);

        return new String(decrypt);
    }
}

​ 然后就是登录功能了。我的实现方案是:用户输入完用户名、密码后,后端进行校验,校验通过后Spring Security会生成一个认证信息。然后生成一个UUID字符串,将其作为key,前面的认证信息作为value存入redis中,同时为其设置一个过期时间,这个时间就是用户登录凭证的过期时间。之后再将前面的UUID作为payload,生成一个JWT字符串,然后使用jdk自带的keytool工具生成的证书文件对其进行签名,这里我使用的是nimbus-jose-jwt,它很适合进行RSA的签名和验签操作。

​ 登录成功后,会将这个JWT字符串返回给前端,前端之后每次请求都会携带着这个JWT字符串,后端对其进行验签,若验签通过,则解析JWT得到payload字段中的内容,也就是上面生成的UUID,在根据这个UUID去redis中查找用户信息,若能找到,则会进行一次Spring Security的验证操作,使其能够访问接口(后面还有授权操作);若查找失败,则说明用户凭证过期,需要重新登录。

具体实现

跨域问题

由于项目时前后端分离,所以必然存在着跨域问题,目前主要的解决方案是:

  • 在方法上添加@CrossOrigin注解
  • 配置过滤器

可是,在引入SpringSecurity后,上面两种方法都会失效,因为SpringSecurity拦截器的优先级更高,上面两种方式都会被拦截,解决办法是提高跨域过滤器的优先级,要敢于SpringSecrity拦截器的优先级。

但Spring Security有更加专业的跨域解决方法:

//跨域
@Bean
CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.addAllowedHeader("*");
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.addAllowedOrigin("*");
    corsConfiguration.setMaxAge(3600L);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", corsConfiguration);
    return source;
}

然后将该过滤器注册到spring security的过滤器链中即可

数据库表设计

​ 由于后面还要使用RBAC0模型,所以我一个设计了七张表,分别是:

  • 用户表
  • 角色表
  • 权限表
  • 资源表
  • 用户角色关联表
  • 角色权限关联表
  • 权限资源关联表

具体SQL如下:

# 用户表
CREATE TABLE IF NOT EXISTS `user`
(
    `id`                   INT(11) NOT NULL AUTO_INCREMENT COMMENT '用户id',
    `username`             VARCHAR(32)  DEFAULT NULL COMMENT '用户名',
    `password`             VARCHAR(255) DEFAULT NULL COMMENT '加密后的密码',
    `enabled`              TINYINT(1)   DEFAULT NULL COMMENT '账户是否可用',
    `accountNonExpired`    TINYINT(1)   DEFAULT NULL COMMENT '账户是否没有过期',
    `accountNonLocked`     TINYINT(1)   DEFAULT NULL COMMENT '账户是否没有锁定',
    `credentialNonExpired` TINYINT(1)   DEFAULT NULL COMMENT '凭证是否没有过期',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 角色表
CREATE TABLE IF NOT EXISTS `role`
(
    `id`     INT(11) NOT NULL AUTO_INCREMENT COMMENT '角色id',
    `name`   VARCHAR(32) DEFAULT NULL COMMENT '角色英文名',
    `nameZh` VARCHAR(32) DEFAULT NULL COMMENT '角色中文名',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 权限表
CREATE TABLE IF NOT EXISTS `permission`
(
    `id`     INT(11) NOT NULL AUTO_INCREMENT COMMENT '权限id',
    `name`   VARCHAR(32) DEFAULT NULL COMMENT '权限英文名',
    `nameZh` VARCHAR(32) DEFAULT NULL COMMENT '权限中文名',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 资源表
CREATE TABLE IF NOT EXISTS `resources`
(
    `id`   INT(11) NOT NULL AUTO_INCREMENT COMMENT '权限id',
    `name` VARCHAR(32) DEFAULT NULL COMMENT '权限中文名',
    `url`  varchar(32) DEFAULT NULL COMMENT '接口地址',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 用户-角色关联表
CREATE TABLE IF NOT EXISTS `user_role`
(
    `id`      INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
    `user_id` INT(11) DEFAULT NULL COMMENT '用户id',
    `role_id` INT(11) DEFAULT NULL COMMENT '角色id',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 角色-权限关联表
CREATE TABLE IF NOT EXISTS `role_permission`
(
    `id`            INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
    `role_id`       INT(11) DEFAULT NULL COMMENT '角色id',
    `permission_id` INT(11) DEFAULT NULL COMMENT '权限id',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

# 权限-资源关联表
CREATE TABLE IF NOT EXISTS `permission-resources`
(
    `id`            INT(11) NOT NULL AUTO_INCREMENT COMMENT '表id',
    `permission_id` INT(11) DEFAULT NULL COMMENT '权限id',
    `resources_id`  INT(11) DEFAULT NULL COMMENT '资源id',
    PRIMARY KEY (`id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

对应的实体类如下:

用户实体类

这里主要注意一下用户实体类中获取权限的方法,因为采用的是RBAC0模型,所以权限信息都与角色相关联,我们要先遍历用户具有的所有角色,然后将该角色具有的所有权限取出,存放到SimpleGrantedAuthority集合中,最后再将该集合添加到GrantedAuthority集合中,即可完成用户与权限的绑定

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    private Integer id;
    private String username;
    private String password;
    private boolean enabled;
    private boolean accountNonExpired;
    private boolean accountNonLocked;
    private boolean credentialNonExpired;
    private List<Role> roles;   //用户所具有的角色

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            List<SimpleGrantedAuthority> roleAuthorities = new ArrayList<>();
            for (Permission permission : role.getPermissions()) {
                //保存权限信息
                roleAuthorities.add(new SimpleGrantedAuthority(permission.getName()));
            }
            authorities.addAll(roleAuthorities);
        }

        return authorities;
    }

    @Override
    public boolean isAccountNonExpired() {
        return accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return credentialNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }
}

角色实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Role implements Serializable {
    private Integer id;
    private String name;    //角色名
    private String nameZh;  //角色中文名
    private List<Permission> permissions;  //角色所具有的权限
}

权限实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Permission implements Serializable {

    private Integer id;
    private String name;    //权限名
    private String nameZh;  //权限中文名

}

资源实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Resources implements Serializable {

    private Integer id;
    private String name;//权限名
    private String url; //接口地址
    private List<Permission> permissions;   //访问受保护对象所需要的权限

}

​ 可能会有人对资源实体类存在疑问:为什么角色关联权限是在角色实体类中使用List,而权限关联资源却是在资源实体类中使用List,变成了“资源关联权限”。其实,这并不是任意而为的,在后面的授权操作中,我们需要为所有的受保护资源分别关联上所有能够访问它们的权限,所以,使用“资源关联权限”这种方式能够方便后面的操作,在给受保护资源添加访问权限时,我们只需要使用resources.getPermissions()就能获取到该资源具备的所有权限了。

MyBatis使用

为了强化自己动手写SQL的能力,我选择了MyBatis作为ORM框架,但登录功能的sql还是比较简单的。

mapper接口

@Mapper
public interface UserMapper {
	//注册,添加用户
    Integer addUser(@Param("username") String username, @Param("password") String password);
	//根据用户名查询是否有该用户
    User queryUserByUsername(String username);
	//给用户绑定角色
    List getRolesByUserId(@Param("user_id") Integer user_id);
	//获取资源
    List getAllResources();
}

mapper.xml


DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.neutron.login_backend.mapper.UserMapper">

    
    <insert id="addUser">
        INSERT INTO security.user(username, password, enabled, accountNonExpired, accountNonLocked, credentialNonExpired) VALUES(#{username}, #{password}, 1, 1, 1, 1);
    insert>

    
    <select id="queryUserByUsername" resultType="com.neutron.login_backend.entity.User">
        SELECT *
        FROM security.user
        WHERE username = #{username}
    select>

    
    <select id="getRolesByUserId" resultMap="RoleResultMap">
        SELECT role.*, permission.id as pid, permission.name as pname, permission.nameZh as pnameZh
        FROM security.role role
                 LEFT JOIN security.user_role ur
                           ON role.id = ur.role_id AND ur.user_id = #{user_id}
                 LEFT JOIN security.role_permission rp
                           ON role.id = rp.role_id
                 LEFT JOIN security.permission permission
                           ON permission.id = rp.permission_id;
    select>

    <resultMap id="RoleResultMap" type="com.neutron.login_backend.entity.Role">
        <id property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="nameZh" column="nameZh"/>
        <collection property="permissions" ofType="com.neutron.login_backend.entity.Permission">
            <id property="id" column="pid"/>
            <result property="name" column="pname"/>
            <result property="nameZh" column="pnameZh"/>
        collection>
    resultMap>

    
    <select id="getAllResources" resultMap="ResourcesResultMap">
        SELECT resources.*, p.id as pid, p.name as pname, p.nameZh as pnameZh
        FROM security.resources resources
                LEFT JOIN security.`permission-resources` pr
                ON resources.id = pr.resources_id
                LEFT JOIN security.permission p
                ON pr.permission_id = p.id
    select>

    <resultMap id="ResourcesResultMap" type="com.neutron.login_backend.entity.Resources">
        <id property="id" column="id"/>
        <result property="name" column="name"/>
        <result property="url" column="url"/>
        <collection property="permissions" ofType="com.neutron.login_backend.entity.Permission">
            <id property="id" column="pid"/>
            <result property="name" column="pname"/>
            <result property="nameZh" column="pnameZh"/>
        collection>
    resultMap>

mapper>

注册

前端代码,加密

这里要导入jsencrypt包

function onClickSignUp(){
  if(show.value === true){
    //获取rsa公钥
    let publicKey;
    axios({
      method: 'get',
      url: 'http://localhost:8081/getPublicKey',
    }).then(function (resp){
      publicKey = resp.data.data
      //rsa加密
      let encrypt = new JSEncrypt();
      encrypt.setPublicKey(publicKey);
      let encodePassword = encrypt.encrypt(formLabelAlign.password);

      axios({
        method: 'post',
        url: 'http://localhost:8081/login/signUp',
        data: {
          username: formLabelAlign.username,
          password: encodePassword
        }
      }).then(function (resp){
        console.log(resp.data.data)
      })
    })
    show.value = false;
  } else {
    show.value = true;
  }
}

后端代码,解密,提供加密公钥

/**
 * 注册账号
 * @param user 请求体(用户名,密码)
 * @return  状态码
 */
@PostMapping("/signUp")
public Result<String> signUp(@RequestBody User user){
    //rsa解密
    String rawPassword = loginService.decodePassword(user.getPassword());
    BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
    //bcrypt加密
    String password = encoder.encode(rawPassword);
    if(userMapper.addUser(user.getUsername(), password) > 0){
        return Result.success("200");
    }
    return Result.error("400");
}
/**
 * 获取公钥
 * @return 公钥
 */
@GetMapping("/getPublicKey")
public Result<String> getPublicKey(){
    RSAUtils rsaUtils = RSAUtils.getRsaUtils();
    return Result.success(rsaUtils.getPublicKey());
}

登录

登录部分的难点主要是在前后端分离的情况下,怎么让前端的每次请求都能被Spring Security认证,以及JWT的签名和验签操作,下面我会具体分析。

自定义密码加密方案

在密码加密方案这块我选择了很久,目前主流的加密方案有:MD5,BCrypt,RSA,SCrypt等。

MD5虽然是不可逆的加密方案,但现在它很容易被彩虹表破解。而数据库中存放的密码最好是不可逆的,即无法或很难被解密,所以像RSA这类可以通过密钥解密的算法也被我舍弃了。最终我使用了安全性较好的BCrypt算法,该算法虽然也存在被彩虹表解密的风险,但需要黑客付出极大的时间成本,从性价比来看,它是可以接受的。

在最新的SpringSecurity中,由于WebSecurityConfigurerAdapter被废弃,所以我们需要在AuthenticationManager中添加自定义的密码加密方案PasswordEncoder,示例如下:

@Bean
public PasswordEncoder passwordEncoder() {
    //将SpringSecurity的加密方案改为BCrypt
    return new BCryptPasswordEncoder();	
}

@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider dao = new DaoAuthenticationProvider();
    dao.setUserDetailsService(userService);
    //将加密方案加入到AuthenticationManager中
    dao.setPasswordEncoder(passwordEncoder());	
    return new ProviderManager(dao);
}
public interface UserService extends UserDetailsService {
    @Override
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

登录验证

首先创建一个UsernamePasswordAuthenticationToken对象,并在参数中带上前端传过来的用户名和密码,之后将该对象交给AuthenticationManager进行认证操作

UsernamePasswordAuthenticationToken token =
    new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(token);
if(Objects.isNull(authenticate)){
    throw new RuntimeException("用户名或密码错误");
}

SpringSecurity的具体认证流程是:

  1. 执行authenticate(token)方法
  2. 由于是使用用户名/密码的方式登录,AuthenticationProvider的实现类是DaoAuthenticationProvider
  3. 执行DaoAuthenticationProvider中的retrieveUser方法,在该方法中,会执行我们前面继承UserDetailsService接口时重写的loadUserByUsername方法,去查找是否有该用户,如果没有,则抛出异常,否则就将用户信息返回
  4. 由于DaoAuthenticationProvider是继承自AbstractUserDetailsAuthenticationProvider并且没有重写authenticate方法,所以具体的认证逻辑位于AbstractUserDetailsAuthenticationProvider
  5. AbstractUserDetailsAuthenticationProviderretrieve方法中,会先去用户缓存中查找用户对象,如果查不到,就根据用户名调用retrieveUser方法,从数据库中加载用户,如果没有加载出用户,则会抛出异常
  6. 拿到用户对象后,先调用preAuthenticationChecks.check(user)方法检查用户状态,这个状态就是我们在User实体类中定义的accountNonExpired这些
  7. 用户状态检查通过后,又调用了additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication)方法进行密码的校验,此处正好解答了为什么在调用loadUserByUsername时也会完成对密码的校验的疑惑
  8. 最后调用postAuthenticationChecks.check方法检查密码是否过期
  9. 当所有步骤完成后,调用createSuccessAuthentication方法创建一个认证后的UsernamePasswordAuthenticationToken对象,该对象中包含了认证主体、凭证以及角色信息。

生成JWT

当前面的登录成功后,我们就要生成一个JWT字符串作为前端访问凭证。

首先,我们要获得一个jks证书文件,它可以帮我们管理公钥和私钥,这里使用的是jdk自带的keytool工具,在jdk的bin目录下面就能找到,可以使用如下命令生成证书:

keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks

有人可能会问,这里的RSA密钥对为什么不使用前面前端密码加密时用的密钥对。这里我主要是想将二者分开,且前端登录时用的密钥对是不固定的,每次登录时都会重新生成密钥对,如果在这里也使用动态的密钥对,可能会带来性能问题。

这里附上我的JWT生成、前面和验签方法:

@Service
public class JwtTokenServiceImpl implements JwtTokenService {

    /**
     * 获取密钥对
     * @return RSAKey
     */
    @Override
    public RSAKey generateRsaKey() {
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        KeyPair keyPair = keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
        //RSA公钥
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        //RSA私钥
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey).privateKey(privateKey).build();
    }

    /**
     * 生成JWT字符串
     * @param payloadStr 作为payload的信息
     * @param rsaKey 密钥对
     * @return jwt字符串
     */
    @Override
    public String generateTokenByRSA(String payloadStr, RSAKey rsaKey) throws JOSEException {
        //JWS头
        JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.RS256)
                .type(JOSEObjectType.JWT)
                .build();

        //荷载
        Payload payload = new Payload(payloadStr);
        //签名
        JWSObject jwsObject = new JWSObject(jwsHeader, payload);
        //生成签名器
        RSASSASigner rsassaSigner = new RSASSASigner(rsaKey);
        jwsObject.sign(rsassaSigner);

        return jwsObject.serialize();
    }

    /**
     * 验签
     * @param token jwt字符串
     * @param rsaKey rsaKey
     * @return  荷载信息
     * @throws ParseException
     * @throws JOSEException
     */
    @Override
    public String verifyToken(String token, RSAKey rsaKey) throws ParseException, JOSEException {
        //由jwt字符串生成jwsObject对象
        JWSObject jwsObject = JWSObject.parse(token);
        RSAKey publicKey = rsaKey.toPublicJWK();
        RSASSAVerifier verifier = new RSASSAVerifier(publicKey);
        if(!jwsObject.verify(verifier)){
            return null;    //验证失败则返回空
        }

        return jwsObject.getPayload().toString();
    }
}

然后,我们获得UUID和认证之后的用户信息,并将其存入Redis中,同时设置过期时间:

@PostMapping
public Result<String> login(@RequestBody User user) throws JOSEException {

    UsernamePasswordAuthenticationToken token =
        new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
    Authentication authenticate = authenticationManager.authenticate(token);
    if(Objects.isNull(authenticate)){
        throw new RuntimeException("用户名或密码错误");
    }

    //登录成功,返回JWT字符串
    RSAKey rsaKey = jwtTokenService.generateRsaKey();
    String key = UUID.randomUUID().toString();
    //将用户信息存入redis中
    User userInfo = (User) authenticate.getPrincipal();
    redisTemplate.opsForValue().set(key, userInfo, 3, TimeUnit.HOURS);

    return Result.success(jwtTokenService.generateTokenByRSA(key, rsaKey));
}

之后前端获取到jwt,为了方便管理,我将JWT存放在了vuex的state中:

function onClick(){
  axios({
    method: 'post',
    url: "http://localhost:8081/login",
    data: {
      ...formLabelAlign
    },
    headers: {
      'Access-Control-Allow-Origin': '*',
    }
  }).then(function (resp){
    console.log(resp.data)
    if(resp.data !== null){
      store.commit('setAuthorization', resp.data.data)	//vuex状态管理
      router.push('/index')
    }
  })
}

vuex:

import {createStore} from "vuex";

const store = createStore({
    state: {
        "Authorization": ''
    },
    mutations: {
        setAuthorization(state, newVal){
            state.Authorization = newVal
        }
    }
})

export default store

前端获取到JWT之后需要在所有请求的请求头中都携带上Authorization字段,这里我使用了Axios的拦截器:

import axios from "axios";
import store from "../store";

axios.interceptors.request.use(config => {
    config.headers['Authorization'] = store.state.Authorization
    return config
})

这样,所有的前端工作就已经完成了,接下来需要在后端定义一个过滤器,让除了登录接口的所有接口都需要被验证JWT合法性,具体流程是:过滤器解析JWT、取出payload字段(前面存的UUID)、从redis中取出用户认证信息、将该认证信息作为参数创建一个UsernamePasswordAuthenticationToken对象、在SecurityContextHolder中添加该对象的认证、认证成功、过滤器放行、后端接口正常访问

过滤器代码

@Component
public class LoginFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenService jwtTokenService;

    @Resource
    private RedisTemplate<String, User> redisTemplate;

    @Autowired
    private CustomSecurityMetadataSource customSecurityMetadataSource;


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

        String authorization = request.getHeader("Authorization");

        if(request.getRequestURI().equals("/login") || request.getRequestURI().equals("/getPublicKey")){
            filterChain.doFilter(request, response);
        } else if(authorization == null){
            throw new RuntimeException("用户未登录");
        } else{
            RSAKey rsaKey = jwtTokenService.generateRsaKey();
            //验签失败返回false
            //成功返回true
            String payload = "";
            try {
                payload = jwtTokenService.verifyToken(authorization, rsaKey);
            } catch (ParseException | JOSEException e) {
                e.printStackTrace();
            }

            if(payload.equals("")){
                throw new RuntimeException("用户未登录");
            } else {
                //已登录,获取用户信息,进行授权
                User userInfo = redisTemplate.opsForValue().get(payload);  //取缓存
                if(userInfo == null){
                    //用户凭证过期
                    redisTemplate.delete(payload);//删除用户登录信息
                    SecurityContextHolder.clearContext();   //将用户认证信息删除
                } else {
                    UsernamePasswordAuthenticationToken token =
                            new UsernamePasswordAuthenticationToken(userInfo, null, userInfo.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(token);
                    System.out.println("SecurityContextHolder信息:" + SecurityContextHolder.getContext());

                }
                System.out.println("attributes: "+customSecurityMetadataSource.getAllConfigAttributes());
                filterChain.doFilter(request, response);
            }
        }
    }
}

至此,前端已经能通过一个JWT字符串访问后端接口了

RBAC权限模型

接下来就是对接口访问的授权操作了,这里我使用的是RBAC0权限模型,即用户关联角色,角色关联权限,权限关联资源,所有的用户、角色、权限和资源都存放在数据库中。在这里我想说的是,有很多人都使用了基于方法的权限管理,即在方法上通过注解的方式增加权限,我认为这种方式并不灵活,如果想要修改访问某个受保护资源所需要的权限时,就必须要去修改源代码,而使用基于Url的权限管理后,我们能通过直接修改数据库完成权限的修改。

用户的授权都已经在前面的User实体类中给出,下面主要看看怎么对受保护资源添加访问权限,这里我们主要是做两件事:

  • 自定义权限元数据
  • 添加决策器

自定义权限元数据 继承FilterInvocationSecurityMetadataSource接口

权限元数据中存放的就是受保护资源所需要的权限,这里我们先获得当前请求的uri地址,然后与数据库中的受保护资源的地址进行比较,若相同,则将数据库中与之对应的权限信息添加进去

@Component
public class CustomSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    @Autowired
    private UserMapper userMapper;
    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
		//获取URI
        String requestURI = ((FilterInvocation) object).getRequest().getRequestURI();
        List<Resources> allResources = userMapper.getAllResources();
        for (Resources resource : allResources) {
            if(antPathMatcher.match(resource.getUrl(), requestURI)){
                String[] permissions = resource.getPermissions().stream()
                        .map(Permission::getName).toArray(String[]::new);
                //存入资源所需要的权限
                return SecurityConfig.createList(permissions);
            }
        }
        return null;
    }

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

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

决策器,在前后端分离时,这是必须要添加的 继承AccessDecisionManager接口

在自定义权限元数据时,我们已经将访问受保护资源所需要的权限添加到ConfigAttribute中去了,所以在验证权限时,我们只需要将ConfigAttribute中的权限与Authentication中保存的用户权限进行比较即可

@Component
public class CustomAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
		//如果权限元数据为空,则直接放行,即不需要权限就能访问
        if(CollUtil.isEmpty(configAttributes)){
            return;
        }

        for (ConfigAttribute configAttribute : configAttributes) {
            String attribute = configAttribute.getAttribute();
            //取出用户具有的权限
            for (GrantedAuthority authority : authentication.getAuthorities()) {
                if (attribute.trim().equals(authority.getAuthority())){
                    return;
                }
            }
            throw new AccessDeniedException("对不起,你没有权限");
        }

    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

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

以上就是这个项目的大致思路了,详细代码可以参考我的github仓库

项目中可能存在一些槽点,欢迎指正。

你可能感兴趣的:(java,redis,mysql,spring,boot)