SpringBoot+SpringSecurity+Redis+jwt实现前后端分离携带验证码(实战)

5,583 阅读20分钟

写在前面的话

  • 小白记录第一次整合Springboot+Springsecurity+hutool+redis+jwt。
  • 本文章使用了大量hutool的工具类,请悉知。
  • 本文章中心是携带验证码+账号密码请求后端验证,使用Redis存储验证码。
  • 默认在阅读本篇文章的朋友们对Springboot、SpringSecurity、redis、jwt已有认知。
  • 因为本项目属于本人的一个练手项目,所以包含的一些pom依赖如不需要请自行剥离,仅需SpringSecurity、redis、jwt即可。
  • 如有疑问请评论区友好交流指点。

后续1(2021-12-05)

在写本项目的时候,我发现退出登录后,虽然SpringSecurity已经删除了凭证,但是jwt还是可以凭借token访问,这显然是不太完美的。结合百度,我想到了使用redis来存储token,实现退出登录后及时删除token。请滑到文档底部查看最新改动代码。

后续2(2021-12-07)

看到评论区朋友问有没有git地址,刚才上线脱敏了下配置,这就把地址开源出来了。
后端:e.coding.net/yueranzs/vu…
这个练手项目叫做新冠物资管理系统,b站看了视频,结合自己技术把前端后端都做了大变动。
前端:e.coding.net/yueranzs/vu…

后端目前图片存储使用到的技术是阿里云OSS(自封装OSSUtil),大家可以自行复制。有疑问或补充请评论区友好交流。
因为本身自己是个后端程序猿所以前端很多注释,应该是易懂的。

orz,不过都还没有做完,正在一步步学习中,前端架构是拜托了一个大佬教我学习更改的,更容易扩展,大家如想学习,可以down下来运行试试看。前端我可能没做脱敏数据,目前也在学习怎么把vue部署在自己的群晖NAS虚拟机上,最终结果出来我将直接公开域名,供大家访问。

后续3(2022-01-21)

今天逛掘金时发现有朋友说链接404了,刚才火速去更到gitee上了,麻烦看到的朋友访问下面链接: gitee.com/yueranzs/xi…
另外在这里说一下哈,orz,在完成本篇基础设置后,闲逛掘金发现了一个非常优秀的权限框架sa-token。 用了这款框架我才发现它是多么的轻便可插拔,甚至学习成本极低。

相信大家在使用SpringSecurity时会发现很郁闷的事情,SpringSecurity默认只能在拦截器里写登录接口,而且还是formdata类型的数据,包括需要传验证码校验时,都需要层层自定义实现类。

麻烦,确实麻烦。

sa-token不同。

1.完全可以自定义登录接口,最后仅需要一行代码StpUtil.login(user.getId());实现登录

简直碉堡了好吗,当时看到这行我立马就新开了一个项目,剔除掉Spring Security,时隔几月,已经迭代了很多,包括底层一些自认为写的七七八八了。

2.@SaCheckRole@SaCheckPermission两个注解同时存在时,解决既需要某角色,又需要某角色拥有某权限的问题。

当然也可以用@SaCheckPermission里的or,一行代码搞定拥有某角色或者某权限访问该接口的问题。(@SaCheckPermission(value = "权限标识",orRole = "角色标识"))

3.源码很简洁,使用也很简洁,全中文,调试不困难。

用一句话来形容,sa-token很灵活,松耦合,甚至是完全可以短时间剥离掉换成另一款框架,而SpringSecurity则需要费点时间。

秉持着要做事有始有终的原则,我会在写完sa-token版本的项目之后,再补上SpringSecurity版本的。

引入相关依赖

<properties>
    <java.version>1.8</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <spring-boot.version>2.3.7.RELEASE</spring-boot.version>
    <druid.version>1.2.4</druid.version>
    <hutool.version>5.7.16</hutool.version>
    <poi.version>5.1.0</poi.version>
    <mybatis-plus.version>3.4.1</mybatis-plus.version>
    <mybatis-plus-velocity.version>2.2</mybatis-plus-velocity.version>
    <jwt.version>0.11.2</jwt.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>org.junit.vintage</groupId>
                <artifactId>junit-vintage-engine</artifactId>
            </exclusion>
        </exclusions>
    </dependency>

    <!--springboot web-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--springboot2.3.1级更高,springboot不再内置验证-->
    <!--<dependency>
       <groupId>jakarta.validation</groupId>
       <artifactId>jakarta.validation-api</artifactId>
       <version>${jakarta.version}</version>
   </dependency>-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <!--spring aop 面向切面  自定义注解需要-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>


    <!--druid-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid-spring-boot-starter</artifactId>
        <version>${druid.version}</version>
    </dependency>


    <!--hutool-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>${hutool.version}</version>
    </dependency>
    <!-- poi -->
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi</artifactId>
        <version>${poi.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.poi</groupId>
        <artifactId>poi-ooxml</artifactId>
        <version>${poi.version}</version>
    </dependency>

    <!--mybatis-plus-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>${mybatis-plus.version}</version>
    </dependency>
    <!--mybatis-plus代码生成器-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>${mybatis-plus.version}</version>
    </dependency>
    <!--mybatis-plus模板引擎 默认-->
    <dependency>
        <groupId>org.apache.velocity</groupId>
        <artifactId>velocity-engine-core</artifactId>
        <version>${mybatis-plus-velocity.version}</version>
    </dependency>

    <!--redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!--spring-security-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!--jwt所需jar包-->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>${jwt.version}</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>${jwt.version}</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>${jwt.version}</version>
        <scope>runtime</scope>
    </dependency>

</dependencies>

验证码存在redis中,并返回base64到前端

  • 本文章使用的是redis存储验证码,具体组成为key:code_xxx,value:英文数字(四位)
  • 验证码图返回的base64使用的是hutool工具类(强烈推荐)

LoginController

//randomCode是一个时间戳,由前端生成后请求后端,具体是防止redis中的key重复
@ApiOperation(value = "验证码",notes = "获取验证码")
@GetMapping("/getRandomCode")
public Result getRandomCode(@RequestParam String randomCode){
    if (ObjectUtil.isEmpty(randomCode)) {
        return Result.error(500,"请输入验证码!");
    }
    return Result.successData(loginService.getRandomCode(randomCode));
}

LoginServiceImpl

/**
 * 获取验证码Base64
 *
 * @param randomCode
 * @return
 */
@Transactional(propagation = Propagation.SUPPORTS,rollbackFor = Exception.class)
@Override
public String getRandomCode(String randomCode) {
    //定义图形验证码的长、宽、验证码字符数、干扰元素个数
    ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(90, 34, 4, 3);
    //设置背景颜色
    captcha.setBackground(Color.WHITE);
    //验证图形验证码的有效性,返回boolean值
    captcha.verify("60");
    //将字符长存入redis,并判断redis中是否存在
    //RedisUtil,我一会贴在下面
    //TimeUnit是个枚举类,我这里选择是以秒计时,如60秒后过期清除当前验证码
    boolean redisCode = RedisUtil.set("code_" + randomCode, captcha.getCode(), 过期时长, TimeUnit.SECONDS);
    //如果存入redis中失败,抛出异常
    //这里是自定义异常类,可以自行处理,不影响
    if (!redisCode) {
        new BusinessException(状态码, 返回提示信息);
    }
    //3.这里只返回Base64字符串用来展示
    return captcha.getImageBase64Data();
}

RedisUtil工具类

/**
 * 掘金里无法导入整个redis工具类,我这里挑了几个需要的方法,仅供参考
 * redis工具类
 * @author yueranzs
 * @date 2021-03-04 10:08
 */
public class RedisUtil {
    //因为普通类无法直接使用RedisTemplate,这里用hutool中的SpringUtil来获取bean
    //如没引入hutool的可以百度下springboot中java普通类怎么调用mapper或service中的接口
    //关键注解@Component、@PostConstruct
    private static final RedisTemplate redisTemplate = SpringUtil.getBean("redisTemplate");
    
    /**
     * 普通缓存放入
     *
     * @param key   键
     * @param value 值
     * @return true成功 false失败
     */
    public static boolean set(String key, Object value) {
        try {
            redisTemplate.opsForValue().set(key, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }

    }
    
    /**
     * 删除缓存
     *
     * @param key 可以传一个值 或多个
     */
    @SuppressWarnings("unchecked")
    public static void del(String... key) {
        if (key != null && key.length > 0) {
            if (key.length == 1) {
                redisTemplate.delete(key[0]);
            } else {
                redisTemplate.delete(CollectionUtils.arrayToList(key));
            }
        }
    }
}

异常类

/**
 * @author yueranzs
 * @date 2021-11-03 17:55
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class BusinessException extends RuntimeException{

    @ApiModelProperty(value = "状态码")
    private Integer code;

    @ApiModelProperty(value = "错误信息")
    private String errMsg;
    
}

全局异常处理

/**
 * 全局异常处理
 * @author yueranzs
 * @date 2021-11-01 11:55
 */
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {


    /**
    * 这里的意思是,只要捕获到BusinessException异常,那么就执行此方法
    */
    @ExceptionHandler(BusinessException.class)
    @ResponseBody
    public Result error(BusinessException exception){
        log.error(exception.getErrMsg());
        return Result.error(exception.getCode(), exception.getErrMsg());
    }



}

封装返回类

/**
 * 封装返回类
 * @author yueranzs
 * @date 2021-11-01 10:51
 */
@Data
public class Result {

    @ApiModelProperty(value = "是否成功")
    private Boolean success;

    @ApiModelProperty(value = "响应码")
    private Integer code;

    @ApiModelProperty(value = "提示信息")
    private String message;

    @ApiModelProperty(value = "返回数据")
    private Object data;

    /**
     * 构造方法私有化,里面的方法都是静态方法
     * 达到保护属性的作用
     */
    private Result(){

    }

    /**
     * 这里使用链式编程
     * @return
     */
    public static Result ok(){
        Result result = new Result();
        result.setSuccess(true);
        result.setCode(ResultCode.SUCCESS.getCode());
        result.setMessage(ResultCode.SUCCESS.getMessage());
        return result;
    }
    public static Result ok(Integer code,String message){
        Result result = new Result();
        result.setSuccess(true);
        result.setCode(code);
        result.setMessage(message);
        return result;
    }
    public static Result error(){
        Result result = new Result();
        result.setSuccess(false);
        //失败code
        result.setCode(ResultCode.COMMON_FAIL.getCode());
        //失败message
        result.setMessage(ResultCode.COMMON_FAIL.getMessage());
        return result;
    }
    public static Result error(Integer code,String message){
        Result result = new Result();
        result.setSuccess(false);
        result.setCode(code);
        result.setMessage(message);
        return result;
    }
    public static Result successData(Object data){
        Result result = new Result();
        result.setSuccess(true);
        //成功code
        result.setCode(ResultCode.SUCCESS.getCode());
        //成功message
        result.setMessage(ResultCode.SUCCESS.getMessage());
        result.setData(data);
        return result;
    }
    public static Result errorData(Object data){
        Result result = new Result();
        result.setSuccess(false);
        //失败code
        result.setCode(ResultCode.COMMON_FAIL.getCode());
        //失败message
        result.setMessage(ResultCode.COMMON_FAIL.getMessage());
        result.setData(data);
        return result;
    }

    /**
     * 自定义
     * @param success
     * @return
     */
    public Result success(Boolean success){
        this.setSuccess(success);
        return this;
    }
    public Result message(String message){
        this.setMessage(message);
        return this;
    }
    public Result code(Integer code){
        this.setCode(code);
        return this;
    }
    public Result data(Object data){
        this.setData(data);
        return this;
    }

}

访问获取验证码接口

postman请求返回的数据和结构

image.png

前端页面展示情况

image.png

redis中存储情况

image.png

编写SecurityConfig配置类

关于EnableGlobalMethodSecurity

  • 当我们想要开启spring方法级安全时,只需要在任何 @Configuration 实例上使用 @EnableGlobalMethodSecurity 注解就能达到此目的。同时这个注解为我们提供了prePostEnabled 、securedEnabled 和 jsr250Enabled 三种不同的机制来实现同一种功能。
  • 具体请访问链接,有详细解释:blog.csdn.net/chihaihai/a…
/**
 * @author yueranzs
 * @date 2021/11/22 13:56
 */
@Configuration
//开启springsecurity
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private LoginAuthenticationProvider loginAuthenticationProvider;
    @Autowired
    private LoginUserDetails loginUserDetails;
    @Autowired
    private AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource;

    /**
     * SpringSecurity5.X要求必须指定密码加密方式,否则会在请求认证的时候报错
     * 同样的,如果指定了加密方式,就必须您的密码在数据库中存储的是加密后的,才能比对成功
     * @return
     */
    @Bean
    protected BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    /**
    * 注入自定义jwttoken过滤器
    */
    @Bean
    protected JwtAuthenticationTokenFilter authenticationTokenFilter() throws Exception{
        return new JwtAuthenticationTokenFilter();
    }

    /**
     * 角色继承,比如在一个系统中admin属于最高角色"超级管理员",那么他将拥有其他角色所有的权限
     * 以>来设置
     * admin > user > normal > ......
     * @return
     */
    @Bean
    RoleHierarchy roleHierarchy(){
        RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
        hierarchy.setHierarchy("ROLE_admin > ROLE_user");
        return hierarchy;
    }

    /**
    * 静态资源放行
    */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
    }
    
    /**
    * Springsecurity默认不携带验证码进行验证,所以这里我们需要重写相关配置类,一会请看代码
    */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //将自定义的Provider装配到Builder
        auth.authenticationProvider(loginAuthenticationProvider);
        //将自定义的loginserviceimpl装配到builder
        auth.userDetailsService(loginUserDetails).passwordEncoder(new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return encodedPassword.equals(rawPassword.toString());
            }
        });
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //Springsecurity放行规则,permitAll是针对所有方法。
        //目使用了swagger,所以需要将swagger相关的url放行。
        //SpringseCurity的放行规则由上往下,如果前者已被拦截,
        //不再执行,所以这就是为什么.anyRequest().authenticated()需要放在最后的原因。
        http.authorizeRequests()
            .antMatchers("/webjars/**").permitAll()
            .antMatchers("/swagger-resources/**").permitAll()
            .antMatchers("/v2/*").permitAll()
            .antMatchers("/login/**").permitAll()
            //剩下方法拦截
            .anyRequest().authenticated()
            .and()
            .formLogin()
            //登录页
            .loginPage("/login.html")
            //登录请求接口,如果url为空也会默认将loginPage的值赋值给url
            //可能习惯性会认为需要自己写一个/login/loginUser的接口,
            //但其实这里是交给SpringSecurity自己去检验的,默认情况下只需要携带form-data类型的账号密码提交即可。
            //本项目将会对默认请求进行重写,使用存在redis中的验证码验证
            .loginProcessingUrl("/login/loginUser")
            //设置登录参数别名
            //SpringSecurity默认情况下账号和密码的属性名为username、password。
            //当然也可以跟我一样重新设置别名。(虽然设置的是一样的,orz)
            .usernameParameter("username")
            .passwordParameter("password")
            //登录成功后的回调,我看其他博客写的是自定义返回类,因为我并没做其他操作,就简单一点吧,看后面代码。
            //为什么是HttpResponseResult::loginSuccess而其他的却是->?
            //因为登录成功接口我的形参和该方法形参一致,所以可以这样写
            .successHandler(HttpResponseResult::loginSuccess)
            //登录失败回调
            .failureHandler((req, resp, e) -> HttpResponseResult.loginError(resp,e))
            //权限不足回调
            .accessDeniedHandler(HttpResponseResult::insufficientPermissions)
            //自定义authenticationDetailsSource,目的是为了获取请求的验证码等信息
            .authenticationDetailsSource(authenticationDetailsSource)
            .permitAll()
            .and()
            .csrf().disable()
            .exceptionHandling()
            .authenticationEntryPoint((req, resp, auth) -> HttpResponseResult.noLogin(resp))
            .and()
            //设置无状态的连接,即不创建session
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            //退出登录
            .logout()
            .logoutUrl("/login/logout")
            .logoutSuccessHandler((req,resp,auth) ->HttpResponseResult.logout(resp))
            .permitAll()
            .and()
        ;

//使用自定义的jwttoken过滤器来进行验证               
http.addFilterBefore(authenticationTokenFilter(),UsernamePasswordAuthenticationFilter.class);
        //禁止页面缓存
        http.headers().cacheControl();
    }

}

编写SpringSecurity的回调返回类

/**
 * 针对返回响应的封装
 * @author yueranzs
 * @date 2021/11/22 14:13
 */
@Data
public class HttpResponseResult {


    /**
     * 基础返回
     * @param resp
     * @param jsonObject
     * @throws IOException
     */
    public static void base(HttpServletResponse resp,JSONObject jsonObject) throws IOException {
        resp.setContentType("application/json;charset=utf-8");
        PrintWriter out = resp.getWriter();
        out.println(jsonObject);
        out.flush();
        out.close();
    }
    /**
     * 响应返回封装
     * @param resp
     * @param resultCode
     * @return
     */
    public static void data(HttpServletResponse resp,CustomizeResultCode resultCode) throws IOException {
        JSONObject result = new JSONObject();
        result.set("code",resultCode.getCode());
        result.set("message",resultCode.getMessage());
        base(resp,result);
    }
    
    /**
     * 暂无凭证或是认证失败
     * @param resp
     */
    public static void noProof(HttpServletResponse resp) throws IOException {
        data(resp,UserResultCode.USER_NOT_PROOF);
    }
    
    /**
     * 登录失败
     * @param resp
     * @param exception security的认证异常
     */
    public static void loginError(HttpServletResponse resp,AuthenticationException exception) throws IOException {
        if (exception instanceof LockedException) {
            //账户锁定
            data(resp,UserResultCode.USER_ACCOUNT_LOCKED);
        } else if (exception instanceof CredentialsExpiredException) {
            //密码过期
            data(resp,UserResultCode.USER_CREDENTIALS_EXPIRED);
        } else if (exception instanceof AccountExpiredException) {
            //账户过期
            data(resp,UserResultCode.USER_ACCOUNT_EXPIRED);
        } else if (exception instanceof DisabledException) {
            //账户禁用
            data(resp,UserResultCode.USER_ACCOUNT_DISABLE);
        } else if (exception instanceof BadCredentialsException) {
            //用户名或者密码输入错误
            data(resp,UserResultCode.USER_LOGIN_ERROR_NO);
        }else if (exception instanceof InternalAuthenticationServiceException){
            //用户不存在
            data(resp,UserResultCode.USER_ACCOUNT_NOT_EXIST);
        }
    }

    /**
     * 退出
     * @param resp
     */
    public static void logout(HttpServletResponse resp) throws IOException {
        data(resp,UserResultCode.USER_LOGOUT_SUCCESS);
    }

    /**
     * 登录成功
     * @param req
     * @param resp
     * @param auth
     */
    public static void loginSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication auth) throws IOException {
        //生成token
        JwtUtil jwtUtil = new JwtUtil();
        Map<String, Object> user = new HashMap<>();
        user.put("username",auth.getName());
        //token我只包含了username,因为在下面自定义jwttoken过滤器里面会查询角色等信息
        String token = jwtUtil.create(user);
        base(resp,new JSONObject().set("code",200).set("data",token));
    }
    
    /**
     * 权限不足
     * @param req
     * @param resp
     * @param e
     */
    public static void insufficientPermissions(HttpServletRequest req, HttpServletResponse resp, AccessDeniedException e) throws IOException {
        data(resp,UserResultCode.USER_INSUFFICIENT_PERMISSIONS);
    }
}

User类(pojo)

注意,下面用户表是mybatis-plus生成,如果想通过SpringSecurity验证,需要实现UserDetails

/**
 * <p>
 * 用户表
 * </p>
 *
 * @author yueranzs
 * @since 2021-11-04
 */
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("tb_user")
@ApiModel(value="User对象", description="用户表")
public class User implements Serializable, UserDetails {

    private static final long serialVersionUID = 1L;

    @ApiModelProperty(value = "用户ID")
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @ApiModelProperty(value = "用户名")
    private String username;

    @ApiModelProperty(value = "昵称")
    private String nickname;

    @ApiModelProperty(value = "邮箱")
    private String email;

    @ApiModelProperty(value = "头像")
    private String avatar;
    @ApiModelProperty(value = "头像临时签名")
    @TableField(exist = false)
    private String avatarUrl;

    @ApiModelProperty(value = "联系电话")
    private String phoneNumber;

    @ApiModelProperty(value = "状态 0锁定 1有效")
    private Integer status;

    @ApiModelProperty(value = "创建时间")
    private Date createTime;

    @ApiModelProperty(value = "修改时间")
    private Date modifiedTime;

    @ApiModelProperty(value = "性别 0男 1女 2保密")
    private Integer sex;

    @ApiModelProperty(value = "盐")
    private String salt;

    @ApiModelProperty(value = "0:超级管理员,1:系统用户")
    private Integer type;

    @ApiModelProperty(value = "密码")
    private String password;

    @ApiModelProperty(value = "生日")
    private Date birth;

    @ApiModelProperty(value = "部门id")
    private Long departmentId;

    @ApiModelProperty(value = "逻辑删除")
    private Integer deleted;

    @ApiModelProperty(value = "角色信息")
    //mybatis-plus中的注解,即在对数据库操作时忽略本字段
    @TableField(exist = false)
    private Set<? extends GrantedAuthority> authorities;

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

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

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

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

编写LoginAuthenticationDetailsSource类

/**
 * 描述:自定义AuthenticationDetailsSource,将HttpServletRequest注入到AuthenticationDetails,使其能获取到请求中的验证码等其他信息
 * @author yueranzs
 * @date 2021/12/1 9:42
 */
@Component
public class LoginAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
    @Override
    public WebAuthenticationDetails buildDetails(HttpServletRequest request) {
        return new LoginWebAuthenticationDetails(request);
    }
}

编写LoginWebAuthenticationDetails类

/**
 * 描述:自定义WebAuthenticationDetails,将验证码和用户名、密码一同带入AuthenticationProvider中
 * @author yueranzs
 * @date 2021/12/1 9:38
 */
public class LoginWebAuthenticationDetails extends WebAuthenticationDetails {
    private static final long serialVersionUID = 6975601077710753878L;
    /*验证码value*/
    private final String code;
    /*验证码key*/
    private final String randomCode;
    public LoginWebAuthenticationDetails(HttpServletRequest request) {
        super(request);
        //这里的code是指验证码真实code,即redis中的验证码value,可自行修改成自己项目的属性名
        code = request.getParameter("code");
        //redis中的验证码key,可自行修改成自己项目的属性名
        randomCode = request.getParameter("randomCode");
    }

    public String getRandomCode() {
        return randomCode;
    }
    public String getCode() {
        return code;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(super.toString()).append("; code: ").append(this.getCode());
        sb.append(super.toString()).append("; randomCode: ").append(this.getRandomCode());
        return sb.toString();
    }
}

编写LoginUserDetails类

/**
 * @author yueranzs
 * @date 2021/12/1 11:38
 */
@Component
public class LoginUserDetails implements UserDetailsService {

    @Autowired
    private UserService userService;

    /**
    * 这里是根据username(账号)去查询数据库,然后进行检验
    */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //mybatis-plus的语句,意思是查询单个的用户根据用户名(username)和伪删除(delflag)来查
        User user = userService.getOne(new QueryWrapper<User>().lambda().select(User::getId,User::getUsername, User::getPassword)
                .eq(User::getUsername, username)
                .eq(User::getDeleted, ResultCode.NODELETE.getCode()));
        if (ObjectUtil.isNull(user)) {
            //用户不存在,抛出SpringSecurity异常
            throw new InternalAuthenticationServiceException(UserResultCode.USER_ACCOUNT_NOT_EXIST.getMessage());
        }
        //查询角色
        List<Role> roles = userService.getRolesByUserId(user.getId());
        Set authorities = new HashSet();
        //注意:SpringSecurity授权分两种:角色和权限
        //角色授权:在授权时,前缀必须加上"ROLE_",一般使用AuthorityUtils.commaSeparatedStringToAuthorityList(字符串,用逗号添加多个role)
        //AuthorityUtils.commaSeparatedStringToAuthorityList就不需要自己加"ROLE_"了
        //权限授权:不需要加前缀
        
        //后面的hasRole和hasAuthority千万不要搞错了,Role是角色,Authority是权限,我当初就是看错了,找了很久的问题,后面看代码
        roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleName())));
        
        //千万要记得查询到角色信息后记得设置
        user.setAuthorities(authorities);
        
        //返回
        return user;
    }
}

编写LoginAuthenticationProvider类

/**
 * 描述:自定义SpringSecurity的认证器
 * @author yueranzs
 * @date 2021/12/1 9:44
 */
@Component
public class LoginAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    @Autowired
    private LoginUserDetails loginUserDetails;

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {

    }

    @Override
    protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        return null;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        //用户名
        String username = authentication.getName();
        //密码
        String password = authentication.getCredentials().toString();
        LoginWebAuthenticationDetails loginWebAuthenticationDetails= (LoginWebAuthenticationDetails)authentication.getDetails();
        //验证码key
        String randomCode = loginWebAuthenticationDetails.getRandomCode();
        //验证码value
        String code = loginWebAuthenticationDetails.getCode();
        //验证码是否为空
        if (ObjectUtil.isEmpty(randomCode) || ObjectUtil.isEmpty(code)) {
            throw new NullPointerException("请输入验证码");
        }
        //检验验证码是否正确
        if (!validateVerifyRandomCode(randomCode,code)) {
            throw new BusinessException(UserResultCode.REDIS_CODE.getCode(), UserResultCode.REDIS_CODE.getMessage());
        }
        User user = (User) loginUserDetails.loadUserByUsername(username);
        //密码是否一致
        if (!user.getPassword().equals(SecureUtil.md5(password))) {
        //密码错误,不过因为安全性的问题所以返回此异常,意思是用户名或者密码错误
            throw new BadCredentialsException(UserResultCode.USER_CREDENTIALS_ERROR.getMessage());
        }
        //删除redis的验证码
        RedisUtil.del("code_" + randomCode);

        return this.createSuccessAuthentication(user,authentication,user);
    }

    /**
     * 验证用户输入的验证码
     * @param randomCode 验证码key
     * @param code 验证码value
     * @return
     */
    public boolean validateVerifyRandomCode(String randomCode,String code){
        //验证码是否一致
        Object redisCode = RedisUtil.get("code_" + randomCode);
        return ObjectUtil.equals(code, redisCode);
    }
}

编写Jwt配置类

/**
 * jwt配置类
 * @author yueranzs
 * @date 2021/12/4 9:57
 */
@Data
@ToString
@Configuration
//与配置文件中的数据关联起来(这个注解会默认自动匹配jwt开头的配置)
@ConfigurationProperties(prefix = "jwt")
public class JwtConfig {

    /*request Headers : Authorization*/
    private String header;

    /*Base64对该令牌进行编码*/
    private String base64Secret;

    /*令牌过期时间 此处单位/毫秒 */
    private Long tokenValidityInSeconds;

}

JwtUtil工具类

注意,本工具类建立在hutool工具类的基础上,仅供参考,部分属性值请视自己情况定

/**
 * jwt工具类
 * @author yueranzs
 * @date 2021/11/25 15:55
 */
@Component
public class JwtUtil {

    private static JwtConfig jwtConfig;

    @Autowired
    private void setJwtConfig(JwtConfig jwtConfig){
        JwtUtil.jwtConfig = jwtConfig;
    }

    /**
     * 生成jwt
     * @param payload 数据主体
     * @return
     */
    public String create(Map<String,Object> payload){
        //每个jwt都默认生成一个到期时间
        payload.put("expire_time", DateUtil.current() + jwtConfig.getTokenValidityInSeconds());
        //生成私钥
        JWTSigner jwtSigner = JWTSignerUtil.hs256(jwtConfig.getBase64Secret().getBytes(StandardCharsets.UTF_8));
        //生成token
        return JWTUtil.createToken(payload,jwtSigner);
    }

    /**
     * 解析jwt
     * @param token
     * @return
     */
    public JSONObject parse(String token){
        return JWTUtil.parseToken(token).getPayload().getClaimsJson();
    }

    /**
     * 校验token是否正确
     * @param token
     * @return
     */
    public boolean verifyToken(String token){
        //先判断是否到期,再判断是否正确
        if (expiredToken(token)) {
            return JWTUtil.verify(token,jwtConfig.getBase64Secret().getBytes(StandardCharsets.UTF_8));
        }
        return false;
    }

    /**
     * 校验token是否过期
     * @param token
     * @return
     */
    public boolean expiredToken(String token){
        return DateUtil.current() < getExpiredToken(token);
    }

    /**
     * 获取token过期时间
     * @param token
     * @return
     */
    public long getExpiredToken(String token){
        return Long.parseLong(parse(token).get("expire_time").toString());
    }

    /**
     * 获取登录人账号
     * @param token
     * @return
     */
    public String getUserNameToken(String token){
        return parse(token).get("username").toString();
    }

    /**
     * 获取登录人角色集合
     * @param token
     * @return
     */
    public Set<GrantedAuthority> getRolesToken(String token){
        return (Set<GrantedAuthority>) parse(token).get("authorities");
    }

}

applicaiton.yml中进行追加jwt信息

jwt:
  # 请求头,就是在header中携带的令牌名称,任意名字都可以
  header: Authorization
  # 盐值
  base64-secret: jwt加密的密钥,任意填写
  # 过期时间 ,单位/毫秒
  token-validity-in-seconds: 过期时间

编写JwtAuthenticationTokenFilter过滤类

/**
 * jwttokenfilter
 * @author yueranzs
 * @date 2021/12/4 10:14
 */
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private UserDetailsService userDetailsService;

    @Resource
    private JwtUtil jwtUtil;

    @Resource
    private JwtConfig jwtConfig;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestUrl = request.getRequestURI();
        String authToken = request.getHeader(jwtConfig.getHeader());
        String userName = null;
        if (ObjectUtil.isNotEmpty(authToken)) {
            userName = jwtUtil.getUserNameToken(authToken);
        }

        log.info("进入jwt自定义token过滤器");
        log.info("自定义token过滤器获得用户名为:" + userName);

        //当userName不为空时进行校验token是否为有效token
        //ObjectUtil.isNotEmpty()和ObjectUtil.isNull()是hutool中的方法。
        /*
            前者意思是指对象是否不为空,和isNotNull()不同。
            比如"",isNotNull()会返回true而isNotEmpty()会返回false。
            userName是字符串所以使用isNotEmpty(),该方法也很适合集合判空
        */
        /*
            getAuthentication()使用isNull()原因是:
            通过前面几个代码块的代码,可以看出是存储授权信息的
            这里的意思是如果用户名不为空并且授权信息又有值,那么就直接跳过,反之就是进入下面的if内部
        */
        if (ObjectUtil.isNotEmpty(userName) && ObjectUtil.isNull(SecurityContextHolder.getContext().getAuthentication())) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userName);
            User user = (User) userDetails;
            //检验token
            if (!jwtUtil.verifyToken(authToken)) {
                throw new BusinessException(500,"token已过期");
            }else if (StrUtil.equals(userName,user.getUsername())){
                /**
                 * UsernamePasswordAuthenticationToken继承AbstractAuthenticationToken实现Authentication
                 * 所以当在页面中输入用户名和密码之后首先会进入到UsernamePasswordAuthenticationToken验证(Authentication),
                 * 然后生成的Authentication会被交由AuthenticationManager来进行管理
                 * 而AuthenticationManager管理一系列的AuthenticationProvider,
                 * 而每一个Provider都会通UserDetailsService和UserDetail来返回一个
                 * 以UsernamePasswordAuthenticationToken实现的带用户名和密码以及权限的Authentication
                 */
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                //将authentication放入SecurityContextHolder中
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request,response);
    }
}

hasRole、hasAuthority

关于这两只的区别可以看链接:Spring Security 中的 hasRole 和 hasAuthority 有区别吗? - 云+社区 - 腾讯云 (tencent.com)

/**
 * 前面代码块中我说过这两个注解千万不要混淆,虽然在使用上,都并不需要加前缀
 * 但我之前没注意清楚,在给用户授权时我写了ROLE_admin,但是使用的是hasAuthority
 * 也就导致我怎么都访问不了这个方法,后面半信半疑hasAuthority('ROLE_admin')才能访问
 * 再后来发现是自己用错方法了,换上hasRole('admin')就没问题
 *
 * @PreAuthorize可以看我第一个分享的链接
 * hashRole和hasAuthority在springsecurity4的时候才有了ROLE_前缀区分,早期几乎是一模一样的
 * @return
 */
@PreAuthorize("hasAuthority('admin')")
@ApiOperation(value = "测试一下",notes = "测试一下")
@GetMapping("/test")
public Result test(){
    return Result.successData("hahah");
}

运行效果

登录成功

image.png

登录失败

image.png

token过期

image.png

暂无权限

image.png

ps:其他的一些状态码暂未测试,目前这些也已足以,后续如有其他需要补充的我会再来添代码。就先这样吧。谢谢阅读。

后续1-改造代码(2021-12-05)

调整SpringSecurity的回调返回类中loginSuccess()

//加上下面注解,因为需要获取jwtConfig的过期时间
@Component
public class HttpResponseResult {

    //关于jwtConfig的都是需要新增的代码
    private static JwtConfig jwtConfig;

    @Autowired
    private void setJwtConfig(JwtConfig jwtConfig){
        HttpResponseResult.jwtConfig = jwtConfig;
    }

    /**
     * 登录成功
     * @param req
     * @param resp
     * @param auth
     */
    public static void loginSuccess(HttpServletRequest req, HttpServletResponse resp, Authentication auth) throws IOException {
        //生成token
        JwtUtil jwtUtil = new JwtUtil();
        Map<String, Object> user = new HashMap<>();
        user.put("username",auth.getName());
        String token = jwtUtil.create(user);

        //将token存入redis
        //这里的key结构为:"token_" + userName
        //token失效时间和jwt失效时间保持一致
        //jwtConfig.getTokenValidityInSeconds():获取jwt失效时间
        RedisUtil.set("token_" + auth.getName(), token, jwtConfig.getTokenValidityInSeconds(), TimeUnit.SECONDS);

        base(resp,new JSONObject().set("code",200).set("data",token));
    }

}

调整SecurityConfig部分代码

将关于退出登录的security方法全部删除,为什么?
本来是想在退出方法里进行删除redis的token,但是因为存储token的key一部分是获取当前userName,而只要访问了logout()就默认进入了security的过滤器,如果想改变的话会比较麻烦,所以我打算自行实现,很方便。


只需注释"//退出登录,这里划重点,我全部注释了"下面代码即可。

@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/webjars/**").permitAll()
            .antMatchers("/swagger-resources/**").permitAll()
            .antMatchers("/v2/*").permitAll()
            .antMatchers("/login/getRandomCode","/login/getUserAvatar").permitAll()
//            .antMatchers("/admin/**").hasRole("admin")
//            .antMatchers("/user/**").hasRole("user")
            .anyRequest().authenticated()
            .and()
            .formLogin()
            //登录页
            .loginPage("/login.html")
            //登录请求接口,如果url为空也会默认将loginPage的值赋值给url
            .loginProcessingUrl("/login/loginUser")
            //设置登录参数别名
            .usernameParameter("username")
            .passwordParameter("password")
            .successHandler(HttpResponseResult::loginSuccess)
            .failureHandler((req, resp, e) -> HttpResponseResult.loginError(resp,e))
            .authenticationDetailsSource(authenticationDetailsSource)
            .permitAll()
            .and()
            .csrf().disable()
            .exceptionHandling()
            .authenticationEntryPoint((req, resp, auth) -> HttpResponseResult.noProof(resp))
            .accessDeniedHandler(HttpResponseResult::insufficientPermissions)
            .and()
            //设置无状态的连接,即不创建session
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            //退出登录,这里划重点,我全部注释了
            /*.logout()
            .logoutUrl("/login/logout")
            .logoutSuccessHandler(HttpResponseResult::logout)
            .permitAll()
            .and()*/
        ;

        http.addFilterBefore(authenticationTokenFilter(),UsernamePasswordAuthenticationFilter.class);
        //禁止页面缓存
        http.headers().cacheControl();
    }

新增UserUtil工具类

方便获取当前登陆人信息

/**
 * @author yueranzs
 * @date 2021/12/5 12:24
 */
public class UserUtil {


    /**
     * 获取当前登陆人信息
     * @return
     */
    public static User getUser(){
        return (User)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    }
    /**
     * 获取当前登录人账号
     * @return
     */
    public static String getUserName(){
        return getUser().getUsername();
    }
    /**
     * 获取当前登录人编号
     * @return
     */
    public static Long getUserId(){
        return getUser().getId();
    }

    /**
     * 获取当前登录人角色信息
     * @return
     */
    public static Set<? extends GrantedAuthority> getUserRole(){
        return getUser().getAuthorities();
    }

}

LoginController中新增logout()退出登录方法

@ApiOperation(value = "退出登录",notes = "退出登录")
@GetMapping("/logout")
public JSONObject logout(){
    return loginService.logout();
}

LoginService实现类

/**
 * 退出登录
 *
 * @return
 */
@Override
public JSONObject logout() {
    //仅作返回退出登录的结果
    JSONObject object = new JSONObject();
    //查找redis中是否存在此token,如果不为空(isNotEmpty的用法上面说过)就删除该token
    if (ObjectUtil.isNotEmpty(RedisUtil.get("token_" + UserUtil.getUserName()))) {
        //清除token,注意顺序不要弄反了
        //要先清除redis的token才能清除认证对象,不然是无法通过UserUtil获取到userName的
        RedisUtil.del("token_" + UserUtil.getUserName());
        //只需要清除认证对象,因为在springsecurity中并没有设置session,所以不需要清空
        SecurityContextHolder.getContext().setAuthentication(null);
       
       //退出登录成功code
        object.set("code",UserResultCode.USER_LOGOUT_SUCCESS.getCode());
       
    //退出登录成功message
        object.set("message",UserResultCode.USER_LOGOUT_SUCCESS.getMessage());
        return object;
    }
    //退出登录失败code
    object.set("code",UserResultCode.USER_LOGOUT_ERROR.getCode());
   
   //退出登录失败message
   object.set("message",UserResultCode.USER_LOGOUT_ERROR.getMessage());
    return object;
}

调整JwtAuthenticationTokenFilter

/**
 * jwttokenfilter
 * @author yueranzs
 * @date 2021/12/4 10:14
 */
@Slf4j
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private UserDetailsService userDetailsService;

    @Resource
    private JwtUtil jwtUtil;

    @Resource
    private JwtConfig jwtConfig;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestUrl = request.getRequestURI();
        String authToken = request.getHeader(jwtConfig.getHeader());
        String userName = null;
        if (ObjectUtil.isNotEmpty(authToken)) {
            userName = jwtUtil.getUserNameToken(authToken);
        }

        log.info("进入jwt自定义token过滤器");
        log.info("自定义token过滤器获得用户名为:" + userName);

        //当userName不为空时进行校验token是否为有效token
        if (ObjectUtil.isNotEmpty(userName) && ObjectUtil.isNull(SecurityContextHolder.getContext().getAuthentication())) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(userName);
            User user = (User) userDetails;
            //检验token,新增下面的if判断
            if(ObjectUtil.isEmpty(RedisUtil.get("token_" + userName))){
                throw new BusinessException(500,"token已被清除");
            } else if (!jwtUtil.verifyToken(authToken)) {
                throw new BusinessException(500,"token已过期");
            }else if (StrUtil.equals(userName,user.getUsername())) {
                /**
                 * UsernamePasswordAuthenticationToken继承AbstractAuthenticationToken实现Authentication
                 * 所以当在页面中输入用户名和密码之后首先会进入到UsernamePasswordAuthenticationToken验证(Authentication),
                 * 然后生成的Authentication会被交由AuthenticationManager来进行管理
                 * 而AuthenticationManager管理一系列的AuthenticationProvider,
                 * 而每一个Provider都会通UserDetailsService和UserDetail来返回一个
                 * 以UsernamePasswordAuthenticationToken实现的带用户名和密码以及权限的Authentication
                 */
                //清除密码
                user.setPassword(null);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                //将authentication放入SecurityContextHolder中
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request,response);
    }
}

运行结果

image.png

image.png

后面的话

那么就先补充到这里吧,等后续还缺漏什么的我会继续更新,谢谢观看。