【基于Spring Security +Jwt+验证码+Redis实现认证+授权操作(内附详细项目地址)】


前言

考虑到导师的项目后续需要进行权限+授权管理,所以抽空去b站上学习了一下Spring Security。在学习的基础上根据自己的理解写了这个小项目,希望能给需要的人一些启示和后期自己复习使用。

一、SpringSecurity是什么?

SpringSecurity作为Spring家族的一员,在和Spring家族中其他成员如Springboot SpringCloud等进行整合具有其他框架无可比拟的优势同时对OAuth2有良好的支持,再加上SpringCloud对SpringSecurity的不断支持(如推出SpringCloud Security)让SpringSecurity不知不觉成为微服务的首选。

二、系统流程

系统工作图

这张图是学习时的资料图,原谅博主精力有限(懒)不想再重新制作脑图,本篇文章在1处还需携带验证码,在2处还需通过从session中取出存储的验证码和前端传过来的验证码进行校验。用户上传的用户名和密码和验证码必须都正确后才能成功登录。

三、具体实现

1.项目准备工作

由于项目中没有建表语句为了分别读者创建数据库表单为此把所有建表语句贴出:我们需要创建五张表,分别是sys_user,sys_role,sys_menu,sys_role_menu,sys_user_role。其中sys_user表用于认证。其余的四张表用于权限控制。权限控制采用业内常见的RBAC(Role-Based Access Control) 基于角色的权限控制。

CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
  `sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
  `user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
  `update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
  `update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
  `del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';
CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
  `create_by` bigint(200) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(200) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role` (
  `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

系统使用Mybatis-plus对数据层进行控制。虽然权限控制RBAC中有四张表,但是实际上只需要一个实体类就可以实现具体功能了,所以两个实现类分别为Menu和User。由于实体类和工具类都是写死的固定并且并不是核心代码,为节约篇幅就不一一列举,会在文末贴出项目地址,读者可下载项目代码自行查看相关实体类和工具类

2.生成验证码

首先在config包下创建一个KaptchaConfig的配置类,具体代码如下:

package com.ypf.config;

import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

@Configuration
public class KaptchaConfig {
    @Bean
    public Producer kaptcha(){
        Properties properties = new Properties();
        properties.setProperty("kaptcha.image.width", "150");
        properties.setProperty("kaptcha.image.height", "50");
        properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

然后在LoginController类下创建一个请求方法,用来给前端获取验证码,并把验证码存储在Session中,方便登录接口直接从Session中获取。具体代码如下:

package com.ypf.controller;

import com.google.code.kaptcha.Producer;
import org.apache.tomcat.util.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpSession;
import java.awt.image.BufferedImage;
import java.io.IOException;

@RestController
@CrossOrigin
public class LoginController {

    @Autowired
    private Producer producer;

    @GetMapping("/user/vc.jpg")
    public String getVerifyCode(HttpSession session) throws IOException {
        // 1 生成验证码
        String text = producer.createText();
        // 2 放入session 日后可以换成redis 实现
        session.setAttribute("captcha",text);
        // 3 生成图片
        BufferedImage bi = producer.createImage(text);
        FastByteArrayOutputStream fos = new FastByteArrayOutputStream();
        ImageIO.write(bi,"jpg",fos);
        // 4 返回base64
        return Base64.encodeBase64String(fos.toByteArray());
    }
}

由于项目已经导入了SpringSecurity包,所以在默认情况下SpringSecurity会对项目的所有接口进行管理,也就是说所有的接口都必须在登录后进行访问,然而我们实际项目中,我们希望获取验证码的接口和真正的登录接口是不需要被限制的,所以我们需要编写一个SecurityConfig的一个配置类去继承WebSecurityConfigurerAdapter这个类,然后重写configure这个方法实现对这两个接口的放行,为了在其他地方能够调用这个配置,我们还需要重写authenticationManagerBean()方法。具体代码如下:

package com.ypf.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                // 设置不会创建一个session对象  Spring Security will never create an HttpSession and it will never use it to obtain the SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/user/login").anonymous()  // 前后端分离的接口使用anonymous 不分离用permitAll
                .antMatchers("/user/vc.jpg").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }

    @Bean  // 必须要加@Bean 注解才能从容器中获取到
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

经过Postman测试获取验证码接口已经成功被放行:
验证码接口返回结果

由于后端相应的结果是base64编码的结果所以我们需要通过解码软件才能看到相应的图片,我们通过如下base64图片转换工具网站进行解析,在解析前或者在前端显示的时候需要在返回的结果前面拼接:data:image/png;base64, 选择Base64还原图片即可获得后端响应的验证码:
在这里插入图片描述

3.更改SpringSecurity的用户数据源

更改数据源的方式有很多,我的实现方式是通过重写UserDetailService接口中loadUserByUsername来实现更改数据源为数据库。具体代码如下:

package com.ypf.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ypf.domain.LoginUser;
import com.ypf.domain.User;
import com.ypf.mapper.MenuMapper;
import com.ypf.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;
    

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //根据用户名查询用户信息
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(wrapper);
//        System.out.println("user = " + user);

        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或密码错误");
        }

        //TODO 根据用户查询权限信息 添加到LoginUser中
        List<String> list = new ArrayList<>(Arrays.asList("test","admin"));
        
        //封装成UserDetails对象返回
        return new LoginUser(user,list);
        
    }
}

对于用户的密码的加密方法有很多我们使用SpringSecurity默认的Bcrypt加密方式:在SecurityConfig类中增加如下代码:

// 创建 BCryptPasswordEncoder 注入容器  指定密码使用哪种加密算法
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

在指定编码后 我们需要将数据库中的明文存储的密码更改为加密后的密码。这样在接下来的登录接口中,后端才能对前端传入明文密码,进行正确判断。我们可以创建一个测试类来将明文密码转换为加密,将输出的结果存储到数据库中。

 @Test
    public void TestBCryptPasswordEncoder(){
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        String encode = bCryptPasswordEncoder.encode("123");
        System.out.println("encode = " + encode);
    }

由于loadUserByUsername的方法需要返回一个UserDetail类型的对象,所以我们需要封装一个LoginUser去实现UserDetail这个方法,该类中有两个成员变量一个是自己创建的用户实体类,一个是用于权限校验的List集合。具体代码如下:

package com.ypf.domain;

import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permissions;

    @JSONField(serialize = false)  // 如果把这个序列化到redis中 会报错 所以不需要序列化
    private List<SimpleGrantedAuthority> authorities;  // 定义为成员变量 只在第一次的时候进行加载


    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }



    // 鉴权需要重写这个方法的
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        // 把permission 中String 类型的权限信息封装成 SimpleGrantedAuthority对象

        /**
         * 方法1
         */
//        List<GrantedAuthority> newList = new ArrayList<>();
//        for (String permission : permissions) {
//            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
//            newList.add(simpleGrantedAuthority);
//        }

        /**
         * 方法2
         */
        if (authorities!=null){
            return authorities;
        }

        authorities = permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());

        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

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

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

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

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

更改完以上配置之后就已经实现了对数据源进行更改,项目启动时已经不会再出现默认的密码了。

4.登录接口实现

在登录时需要获取三个参数用户名,密码,验证码。所以我单独封装了一个登录用的LoginVo类,该类中有三个成员变量userName,password,captcha;具体代码如下:

package com.ypf.domain.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginVo {
    private String userName;
    private String password;
    private String captcha;
}

登录接口的controller层,:

@PostMapping("/user/login")
    public ResponseResult login(@RequestBody LoginVo loginVo, HttpSession session){

        String captcha = (String) session.getAttribute("captcha");

        return  loginService.login(loginVo,captcha);
    }

登录接口的sercice层,具体思想是首先校验前端传来的验证码,验证通过后,再调用SpringSecurity中的认证器进行验证,如果认证通过了 使用userid 生成一个jwt jwt 存入ResponseResult 返回,最后把完整的用户信息存入redis userid 作为key 具体代码如下:

package com.ypf.service.impl;

import com.ypf.domain.LoginUser;
import com.ypf.domain.ResponseResult;
import com.ypf.domain.vo.LoginVo;
import com.ypf.service.LoginService;
import com.ypf.utils.JwtUtil;
import com.ypf.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Objects;

@Service
public class LoginServiceImpl implements LoginService {

    // 将Security 中配置的东西拿出来
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(LoginVo loginVo, String captcha) {
        String userName = loginVo.getUserName();
        String uploadCaptcha = loginVo.getCaptcha();
        String password = loginVo.getPassword();

        if (!uploadCaptcha.equals(captcha)){
            return new ResponseResult(HttpStatus.BAD_REQUEST.value(),"验证码错误");
        }


        // AuthenticationManager authenticate 进行用户认证
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userName,password);
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);

        // 如果没认证给出对应的提示
        if (Objects.isNull(authenticate)){
            throw new RuntimeException("登录失败");
        }

        // 如果认证通过了 使用userid 生成一个jwt jwt 存入ResponseResult 返回
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();

        String jwt = JwtUtil.createJWT(userId);
        HashMap<String,String> map = new HashMap<>();
        map.put("token",jwt);

        // 把完整的用户信息存入redis userid 作为key
        redisCache.setCacheObject("login:"+userId,loginUser);

        return new ResponseResult(200,"登录成功",map);

    }


}

使用postman测试,可以看到用户登录成功后返回了一个token,前端只需把这个token存储下来,再下次访问时在请求头中带上这个token进行访问就可以不用再登录了。测试结果如下:
用户登录测试结果

在redis中也可以清楚的看见存储了用户的详细信息
redis中的用户信息

5.Token过滤器

之前已经通过配置对获取验证码接口和用户登录接口进行了放行,但是对于其他接口我们只能通过判断请求头中是否含有token来决定是否用户的状态为已登录状态,从而决定是放行进行下一步的权限校验,还是直接拒绝访问,所以我们要自定义一个token过滤器来实现这个功能。具体代码如下:

package com.ypf.filter;

import com.ypf.domain.LoginUser;
import com.ypf.utils.JwtUtil;
import com.ypf.utils.RedisCache;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
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;
import java.util.Objects;

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {



    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)){
         // 放行  后面还有别的过滤器进行验证 所以这里如果请求头里没有 直接放行就行了
            System.out.println(1);
         filterChain.doFilter(request,response);
         return;
        }

        String userID;
        // 解析token
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userID = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        // 从redis中获取用户信息
        String redisKey = "login:"+userID;



        LoginUser loginUser = redisCache.getCacheObject(redisKey);
        if (Objects.isNull(loginUser)){
            throw new RuntimeException("用户未登录");
        }

        // 存入SecurityContextHolder
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

        // 放行
        filterChain.doFilter(request,response);
    }


}

我们还需要将自己编写的token过滤器添加到SecurityConig中才能生效,我们在configure方法中添加,由于我们希望如果用户携带了token就无需验证用户名和密码了,所以 把token过滤器添加到 验证用户名和密码的过滤器前面。具体实现如下:

package com.ypf.config;

import com.ypf.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    // 创建 BCryptPasswordEncoder 注入容器  指定密码使用哪种加密算法
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                // 设置不会创建一个session对象  Spring Security will never create an HttpSession and it will never use it to obtain the SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/user/login").anonymous()  // 前后端分离的接口使用anonymous 不分离用permitAll
                .antMatchers("/user/vc.jpg").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

        // 添加过滤器
        // 把token校验过滤器添加到过滤器链中  把token过滤器添加到 验证用户名和密码的前面
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean  // 必须要加@Bean 注解才能从容器中获取到
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

用户登录成功后,携带返回的token对测试接口进行访问,可以看到用户无效再进行登录即可访问受限资源。

6.权限校验

在实际项目中,我们的系统不仅需要对用户进行登录验证,我们还需要对用户进行权限控制,因为我们并不希望一些用户访问一些特定的资源。所以我们需要进行权限控制,对于数据库表来说,我采用了比较常见的RBAC(Role-Based Access Control) 基于角色的权限控制方案进行建表。一共有五个表sys_user,sys_role,sys_menu,sys_role_menu,sys_user_role。RBBC简单来说就是 哪个用户属于哪个角色,哪个角色用于哪些权限。然后用户与角色表通过一张user_role表进行联系,角色和权限通过一张role_menu表进行联系。具体关系如下图所示:
各个表对应关系
在各个数据表中我们添加如下数据:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

用于我们只建立了两个实体类,所以我们在Menu这个类进行权限查询时,用mybatis-plus自带的方法肯定是不能满足的,因为涉及到跨表查询,所以我们需要自定义一个MenuMapper中自定义一个selectPermsByUserId() 通过用户id查询权限的方法。具体代码如下:

package com.ypf.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ypf.domain.Menu;

import java.util.List;

public interface MenuMapper extends BaseMapper<Menu> {
    List<String> selectPermsByUserId(Long id);
}

在resource包下的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.ypf.mapper.MenuMapper">


    <select id="selectPermsByUserId" resultType="java.lang.String">
        SELECT
            DISTINCT m.`perms`
        FROM
            sys_user_role ur
                LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
                LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
                LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
        WHERE
            user_id = #{userid}
          AND r.`status` = 0
          AND m.`status` = 0
    </select>
</mapper>

然后我们去改写之前的UserDetailsServiceImpl中加载用户权限的方法,之前传入的死数据。现在要从数据库中动态查询。具体代码如下:

package com.ypf.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ypf.domain.LoginUser;
import com.ypf.domain.User;


import com.ypf.mapper.MenuMapper;
import com.ypf.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MenuMapper menuMapper;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        //根据用户名查询用户信息
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(wrapper);
//        System.out.println("user = " + user);

        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或密码错误");
        }

        // 根据用户从数据库中查询权限信息 添加到LoginUser中
        List<String> list = menuMapper.selectPermsByUserId(user.getId());

        //封装成UserDetails对象返回
        return new LoginUser(user,list);

    }
}

在SecurityConfig中添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,对于权限控制其实可以使用官方默认的权限控制器进行校验,但是在实际项目中,我们大多采用的是自定义权限校验器进行验证,这样更加方便和灵活,可以更好的满足我们的需求。
所以我新建一个expression包 在包中创建一个YPFExpressionRoot类来实现权限控制。在JwtAuthenticationTokenFilte中我们已经把认证通过的用户的从数据库中读取到的权限信息存储在了SecurityContextHolder中,所以我们只需要判断用户所具有的权限信息中是否包含该接口的访问权限,如果包含就返回true 不包含就返回false,具体代码如下:

package com.ypf.expression;

import com.ypf.domain.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.List;

@Component("ex")
public class YPFExpressionRoot {
    public boolean hasAuthority(String authority){
        // 获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();

        List<String> permissions = loginUser.getPermissions();
        // 判断用户权限集合中 是否存在 authority
        return permissions.contains(authority);
    }
}

我们在test方法上加上所需的访问权限,其中@ex.hasAuthority(‘system:dept:list’) 表示使用自定义的权限校验器 并且所需的权限为system:dept:list

package com.ypf.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@PreAuthorize("@ex.hasAuthority('system:dept:list')")
public class TestController {
    @GetMapping("/test")
    public String test(){
        System.out.println("test ok!");
        return "test ok!";
    }
}

使用postman测试可以看出当我们角色具有这个权限时接口访问是正常的
在这里插入图片描述
当我们角色具有这个权限时接口访问得到的是返回403状态码,也就是禁止访问,表示我们的权限控制正常。
在这里插入图片描述

7.系统的完善

在实际项目中,当我们用户认证失败或者权限不足时,我们在前后端分离的项目中,后端通常会返回自定义的Json格式的字符串,所以这个时候我们还需要创建两个处理器:AccessDeniedHandlerImpl和AuthenticationEntryPointImpl,AccessDeniedHandlerImpl是权限不足处理器,AuthenticationEntryPointImpl是认证失败处理器。具体代码如下:

package com.ypf.handler;

import com.alibaba.fastjson.JSON;
import com.ypf.domain.ResponseResult;
import com.ypf.utils.WebUtils;
import org.springframework.http.HttpStatus;
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 AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(),"用户认证失败请重新登录");
        String json = JSON.toJSONString(result);
        // 处理异常
        WebUtils.renderString(response,json);
    }
}

package com.ypf.handler;

import com.alibaba.fastjson.JSON;
import com.ypf.domain.ResponseResult;
import com.ypf.utils.WebUtils;
import org.springframework.http.HttpStatus;
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 AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(),"您的权限不足");
        String json = JSON.toJSONString(result);
        // 处理异常
        WebUtils.renderString(response,json);
    }
}

然后再SecurityConfig中进行配置 ,修改后的SecurityConfig配置类如下:

package com.ypf.config;

import com.ypf.filter.JwtAuthenticationTokenFilter;
import com.ypf.handler.AccessDeniedHandlerImpl;
import com.ypf.handler.AuthenticationEntryPointImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {


    // 创建 BCryptPasswordEncoder 注入容器  指定密码使用哪种加密算法
    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPointImpl authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandlerImpl accessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                // 设置不会创建一个session对象  Spring Security will never create an HttpSession and it will never use it to obtain the SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/user/login").anonymous()  // 前后端分离的接口使用anonymous 不分离用permitAll
                .antMatchers("/user/vc.jpg").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();

        // 添加过滤器
        // 把token校验过滤器添加到过滤器链中  把token过滤器添加到 验证用户名和密码的前面
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //  配置异常处理器
        http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);

        // 允许跨域
        http.cors();
    }

    @Bean  // 必须要加@Bean 注解才能从容器中获取到
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

使用postman测试可以看出当我们角色没有这个权限时接口访问返回的是自定义的Json类型的提示。
在这里插入图片描述
使用postman测试可以看出当我们角色未登录时返回的是自定义的Json类型的提示。
在这里插入图片描述

8 项目地址

点击跳转

总结

这个项目只实现了基础的功能,在实际项目中,还需要再对细节进行打磨和完善,SpringSecurity最大的优势是在于微服务架构下对于权限的控制。其实SpringSecurity的强大远不止于此,我目前只学会了皮毛,只能应对一些简单的项目需求。ps:(内心独白,我知道这篇文章不会有多少人看的,所以感慨一下人生)所谓万丈高楼平地起,我还很菜,但是我相信有一天我会变得无比强大,2022年的3月和4月,开心过,迷茫过。想不清楚一些问题,很多东西并不是一定要拥有的。学会放开,23岁的我,研一在读,正直青春,正应努力。别在奋斗的年级选择堕落。加油吧,无论最后选择去互联网,还是研究所,还是读博,都应该全力以赴的过好每一天。

  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值