Shiro整合JWT在SpringBoot中实现认证和权限鉴定

本文章根据以下两篇文章写作而成,感谢

Shiro整合JWT实现认证和权限鉴定(执行流程清晰详细)_jwt+shiro-CSDN博客

手把手教你Shiro整合JWT实现登录认证! - 掘金

一.项目出发点与需求

公司新项目需要一个基于SpringBoot的脚手架,需求是整合JWT和Shiro进行认证和权限鉴定。在搭建的过程中遇到了很多问题,为了不让经验流失在脑海中,就有了这篇文章的诞生。

二.搭建过程与组件解析
1.使用技术
  • SpringBoot
  • Mybatis-Plus
  • Shiro
  • JWT
  • Redis
2.前置知识

本文默认读者已经有SpringBoot的使用经验,如果没有的话请先学习SpringBoot的使用。

JWT:服务端根据用户的登录信息、过期时间以及加密算法经过"揉搓"之后生成一串字符串Token(令牌),该令牌保存在客户端,用户每次向服务端发送请求时都会在请求头中携带令牌,以此来获得需要访问权限的页面的认证。

Shiro:Java的一个安全框架,可以完成:认证、授权、加密、会话管理、与Web集成和缓存等用户登录时把身份信息(用户名/手机号/邮箱地址等)和凭证信息(密码/证书等)封装成一个Token令牌,通过安全管理器中的认证器进行校验,成功则授权以访问系统

Shiro本身是可以实现认证功能的。默认的情况是Shiro在subject.login()之后将认证状态存入全局session中,之后的用户请求都会从这个session中拿取用户的登录状态。但是在本项目中,我们将使用JWT取代session来进行Shiro的认证操作。也就是说,不会使用Shiro的UsernamePasswordToken,而是让JWT来生成Token。登录时不需要subject.login(),直接使用请求头中携带的token进行验证即可。

3.流程图解析

用户注册,将用户名和密码存入数据库中

用户登录流程

校验密码并生成Token存入Redis

访问资源

JWT本质上就是一串加密的字符串,JWT和Shiro的整合就是让JwtFilter截获用户请求头中的token,如果有token就交给Shiro的自定义Realm去判断token是否正确,是否过期等。

4.构建项目

新建一个SpringBoot项目,使用jdk8

新建数据表如下

CREATE TABLE `t_user` (
  `id` bigint NOT NULL COMMENT 'id',
  `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '姓名',
  `age` int DEFAULT NULL COMMENT '年龄',
  `sex` tinyint DEFAULT '0' COMMENT '性别:0-女 1-男',
  `username` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '账号',
  `password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '密码',
  `created_date` datetime DEFAULT NULL COMMENT '创建时间',
  `updated_date` datetime DEFAULT NULL COMMENT '修改时间',
  `is_deleted` int DEFAULT '0' COMMENT '删除标识',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;

 添加依赖

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mybatis plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3</version>
        </dependency>
<!--        druid-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.10</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </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>
        </dependency>
<!--        swagger2-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-boot-starter</artifactId>
            <version>3.0.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-actuator-autoconfigure</artifactId>
        </dependency>

<!--        引入shiro整合springboot依赖-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>1.7.0</version>
        </dependency>

<!--        引入jwt-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.10.3</version>
        </dependency>

<!--        redis-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
<!--        hutools-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.17</version>
        </dependency>
<!--        lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
5.修改配置文件
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/jsbiot?allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai
spring.datasource.username=
spring.datasource.password=
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource

spring.mvc.pathmatch.matching-strategy=ant_path_matcher

spring.redis.host=
spring.redis.port=
spring.redis.password=

mybatis-plus.mapper-locations=classpath:/mappers/*.xml
mybatis-plus.type-aliases-package=com.jsb.jsb_iot_springboot.entity
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
6.配置Redis

@Slf4j
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory){
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(genericJackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(genericJackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}
7.加解密工具类

这里主要使用了hutool的加密方法

public class BcryptUtil {
    //加密
    public static String encode(String password){
        return BCrypt.hashpw(password,BCrypt.gensalt());
    }

    //比较密码
    public static boolean match(String password, String encodePassword){
        return BCrypt.checkpw(password,encodePassword);
    }
}
8.全局异常处理

在过滤请求过程中,对于不合法的请求我们进行拦截并抛出了异常,在前后端分离的项目中,我们应该将请求的处理结果(如无权限等信息)返回给前端,而不是直接进行页面跳转。所以我们需要编写一个异常处理类捕获抛出的异常,并根据不同的异常返回给前端相应的信息,让前端进行页面跳转。

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseResult handler(RuntimeException e){
        log.info("运行时异常:",e.getMessage());
        return ResponseResult.fail(e.getMessage());
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(value = ShiroException.class)
    public ResponseResult handler(ShiroException e) {
        log.error("运行时异常:----------------{}", e);
        return ResponseResult.fail( e.getMessage());
    }
}
9.统一返回结果

@Data
public class ResponseResult<T> implements Serializable {
    private String code;
    private String message;
    private T Data;

    public static ResponseResult success(Object object){
        ResponseResult result = new ResponseResult();
        result.setCode("200");
        result.setData(object);
        result.setMessage("操作成功");
        return  result;
    }

    public static ResponseResult fail(String message){
        ResponseResult result = new ResponseResult();
        result.setCode(HttpStatusEnum.PARAM_ERROR.getCode());
        result.setData(null);
        result.setMessage(message);
        return  result;
    }
}
三.整合JWT与Shiro
1.JWT工具类

作用:生成与验证Token


@ConfigurationProperties(prefix = "jwt")
@Component
@Slf4j
public class JwtUtil {
    // 秘钥

    private static final String secret = "Hayter";
    private static final long TIME_UNIT = 1000;

    // 生成包含用户id的token
    public String createJwtToken(String userId, long expireTime) {
        Date date = new Date(System.currentTimeMillis() + expireTime * TIME_UNIT);
        Algorithm algorithm = Algorithm.HMAC256(secret);

        return JWT.create()
                .withClaim("userId", userId)
                .withExpiresAt(date) // 设置过期时间
                .sign(algorithm);     // 设置签名算法
    }

    // 生成包含自定义信息的token
    public String createJwtToken(Map<String, String> map, long expireTime) {
        JWTCreator.Builder builder = JWT.create();
        if (MapUtils.isNotEmpty(map)) {
            map.forEach((k, v) -> {
                builder.withClaim(k, v);
            });
        }
        Date date = new Date(System.currentTimeMillis() + expireTime * TIME_UNIT);
        Algorithm algorithm = Algorithm.HMAC256(secret);
        return builder
                .withExpiresAt(date) // 设置过期时间
                .sign(algorithm);     // 设置签名算法
    }

    // 校验token,其实就是比较token
    public boolean verifyToken(String token) {
        try {
            JWT.require(Algorithm.HMAC256(secret)).build().verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    // 从token中获取用户id
    public String getUserId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("userId").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    // 从token中获取定义的荷载信息
    public String getTokenClaim(String token, String key) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(key).asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    // 判断 token 是否过期
    public boolean isExpire(String token) {
        DecodedJWT jwt = JWT.decode(token);
        // 如果token的过期时间小于当前时间,则表示已过期,为true
        return jwt.getExpiresAt().getTime() < System.currentTimeMillis();
    }

}
2.JWTToken

作用:取代原生token

在JWT没有和Shiro整合之前,用户的账号密码被封装成了其自带的UsernamePasswordToken对象。UsernamePasswordToken 其实是 AuthenticationToken 的实现类。

比如 subject.login(new UsernamePasswordToken(username,password));

既然要用JWT来取代自带的UsernamePasswordToken实现类,那就要编写一个AuthenticationToken 的实现类JWTtoken。

这个类需要重写getPrincipal()和getCredentials()方法,这两个方法本来是用来获取用户名和密码的,这里直接返回JwtToken就可以了。

/**
 * @Desc:
 * 通过这个类将 string 的 token 转成 AuthenticationToken,shiro 才能接收
 * 由于Shiro不能识别字符串的token,所以需要对其进行一下封装
 */
public class JwtToken implements AuthenticationToken {
    private String token;

    public JwtToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }
    @Override
    public Object getCredentials() {
        return token;
    }
}
3.JWTFilter

目的:

  1. 过滤请求
  2. 封装subject.login()

在Shiro中,ShiroFilter用来过滤所有的请求,一般情况下shiro通过传入用户名和密码生成UsernamePasswordToken后使用subject.login进行登录。

但是这里我们整合了JWT和Shiro,所以要自定义一个过滤器JWTFilter。它的主要作用是拦截请求,判断请求头中是否含有token,如果有就交给Realm进行鉴权处理。

@Slf4j
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter {

    private String errorMsg;

    // 过滤器拦截请求的入口方法
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        // 判断请求头是否带上“Token”
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Authorization");
        // 没有认证意愿(可能是登录行为或者为游客访问),放行
        // 此处放行是因为有些操作不需要权限也可以执行,而对于那些需要权限才能执行的操作自然会因为没有token而在权限鉴定时被拦截
        if (!StringUtils.hasText(token)) {
            return true;
        }
        try {
            // 交给 myRealm
            SecurityUtils.getSubject().login(new JwtToken(token));
            return true;
        } catch (Exception e) {
            errorMsg = e.getMessage();
            e.printStackTrace();
            return false;
        }
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setStatus(400);
        httpServletResponse.setContentType("application/json;charset=utf-8");
        PrintWriter out = httpServletResponse.getWriter();
        out.println(JSONUtil.toJsonStr(ResponseResult.fail(errorMsg)));
        out.flush();
        out.close();
        return false;
    }

    /**
     * 对跨域访问提供支持
     *
     * @param request
     * @param response
     * @return
     * @throws Exception
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域发送一个option请求
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }
}
4.Realm

目的:

  1. 进行具体的用户信息验证
  2. 让Shiro支持JWTToken

在JWTFilter中,对携带了token的请求会直接交给subject.login()方法然后再经由Realm进行token的鉴权处理。

在编写方法的过程中,需要让Shiro支持自定义Token,然后要重写doGetAuthenticationInfo方法用于认证,重写doGetAuthorizationInfo方法用于授权。

如果鉴权不通过的话则会直接抛出异常


@Component
public class MyRealm extends AuthorizingRealm {
    @Autowired
    private RedisUtil redisUtil;
    @Autowired
    private JwtUtil jwtUtil;

    /**
     * 限定这个realm只能处理JwtToken
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 授权(授权部分这里就省略了)
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 获取到用户名,查询用户权限
        return null;
    }

    /**
     * 认证
     * @return
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken){
        // 获取token信息
        String token = (String) authenticationToken.getCredentials();
        // 校验token:未校验通过或者已过期
        if (!jwtUtil.verifyToken(token) || jwtUtil.isExpire(token)) {
            throw new AuthenticationException("token已失效,请重新登录");
        }
        // 用户信息
        User user = (User) redisUtil.get("token_" + token);
        if (null == user) {
            throw new UnknownAccountException("用户不存在");
        }
        SimpleAuthenticationInfo simpleAuthenticationInfo = new SimpleAuthenticationInfo(user, token, this.getName());
        return simpleAuthenticationInfo;
    }
}

5.ShiroConfig

目的:

  1. 添加过滤器和过滤规则
  2. 给默认的安全管理器绑定自定义的Realm并且关闭session
@Configuration
@ComponentScan(value = "com.jsb.jsb_iot_springboot")
public class ShiroConfig {
    // 1.shiroFilter:负责拦截所有请求
    @Bean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager) {

        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 给filter设置安全管理器
        shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);
        // 默认认证界面路径---当认证不通过时跳转
        shiroFilterFactoryBean.setLoginUrl("/login.jsp");

        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", new JwtFilter());
        shiroFilterFactoryBean.setFilters(filterMap);

        // 配置系统受限资源
        Map<String, String> map = new HashMap<String, String>();
        map.put("/index.jsp", "authc");
        map.put("/user/login","anon");
        map.put("/user/register","anon");
        map.put("/login.jsp","anon");
        map.put("/**", "jwt");
        // 所有请求通过我们自己的过滤器
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

        return shiroFilterFactoryBean;
    }

    //2.创建安全管理器
    @Bean
    public DefaultWebSecurityManager getDefaultWebSecurityManager(MyRealm realm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 给安全管理器设置realm
        securityManager.setRealm(realm);
        // 关闭shiro的session(无状态的方式使用shiro)
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }
}

至此,所有配置完成

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值