项目介绍

编程区新手,未来继续创造新项目

github仓库:github.com/neutron123a…

github主页:github.com/neutron123a…

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

思路

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

​ 前端先进行注册操作,为了数据传输安全,这儿会先对前端输入的暗码进行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的过滤器链中即可

数据库表规划

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

​ 因为后边还要运用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<Permission>,而权限相关资源却是在资源实体类中运用List<Permission>,变成了“资源相关权限”。其实,这并不是任意而为的,在后边的授权操作中,咱们需求为一切的受维护资源别离相关上一切能够拜访它们的权限,所以,运用“资源相关权限”这种办法能够便利后边的操作,在给受维护资源增加拜访权限时,咱们只需求运用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<Role> getRolesByUserId(@Param("user_id") Integer user_id);
	//获取资源
    List<Resources> getAllResources();
}

mapper.xml

<?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.neutron.login_backend.mapper.UserMapper">
    <!--Integer addUser(@Param("username") String username, @Param("password") String password);-->
    <insert id="addUser">
        INSERT INTO security.user(username, password, enabled, accountNonExpired, accountNonLocked, credentialNonExpired) VALUES(#{username}, #{password}, 1, 1, 1, 1);
    </insert>
    <!--User queryUserByUsername(String username);-->
    <select id="queryUserByUsername" resultType="com.neutron.login_backend.entity.User">
        SELECT *
        FROM security.user
        WHERE username = #{username}
    </select>
    <!--List<Role> getRolesByUserId(@Param("user_id") Integer user_id);-->
    <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>
    <!--List<Permission> getAllPermissions();-->
    <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;
    }
}

最后还要将决策器和元数据都注册到SpringSecurity的过滤器链中:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private UserService userService;
    @Autowired
    private LoginFilter loginFilter;
    @Autowired
    private CustomSecurityMetadataSource customSecurityMetadataSource;
    @Autowired
    private CustomAccessDecisionManager customAccessDecisionManager;
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
        return http.apply(new UrlAuthorizationConfigurer<>(applicationContext))
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(customSecurityMetadataSource);
                        object.setAccessDecisionManager(customAccessDecisionManager);
                        return object;
                    }
                })
                .and()
                .authorizeRequests()
                .antMatchers("/login", "/signUp", "/getPublicKey").permitAll()
                .anyRequest().authenticated()
                .and()
                .cors()
                .configurationSource(corsConfigurationSource())
                .and()
                .userDetailsService(userService)
                .formLogin()
                .loginPage("http://127.0.0.1:5173/")
                .and()
                .addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class)
                .csrf().disable()
                .build();
    }
    @Bean
    SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize) -> authorize
                .anyRequest().authenticated());
        return http.build();
    }
    //跨域
    @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;
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider dao = new DaoAuthenticationProvider();
        dao.setUserDetailsService(userService);
        dao.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(dao);
    }
}

以上便是这个项目的大致思路了,具体代码能够参考我的github仓库

项目中或许存在一些槽点,欢迎纠正。