欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

使用Shiro和JWT自定义Spring Boot启动器指南

最编程 2024-07-28 10:43:34
...

Shiro 是什么 ?

不多说了,一个java 安全认证框架 ,类似于Spring-security,两者异同网上多了去,个人比较喜欢shiro ,简单易懂。

JWT 是什么 ?

json web tokens 的简称

为什么要 shiro + JWT ?

现在微服务架构,前后端分离架构,综合 PC、 APP 、小程序、微信等,采用一个令牌作为登录用户身份的象征,不再用原来的浏览器Session作为登录凭证。
小程序 微信 等应该没有 cookie 、session概念。
采用统一 jwt 一劳永逸解决所有前端过来的请求身份认证问题,减轻服务器内存存储压力。

JWT 的利弊网上分析的够透彻了,这里不多说了。

SpringBoot 自定义starter是什么 ?

springboot 精髓之一,不多介绍。如果还不明白springboot的精髓,请务必查看下面大神的微信文章:
https://mp.weixin.qq.com/s/SY7H7EjLN5CpE33k1QKLfA

为什么要自定义一个starter ?

减轻新项目的配置复杂度,统一配置管理。不会自定义starter的,强烈建议查看下面大神的博客:
https://www.jianshu.com/p/4735fe7ae921

开始搞起来

建一个maven工程

工程名称叫做 shirojwt-spring-boot-starter,先贴出 pom.xml文件内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!--================ Spring Boot ===================== -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>org.guzt</groupId>
    <artifactId>shirojwt-spring-boot-starter</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <java.version>1.8</java.version>

        <!-- Shiro JWT Begin -->
        <java.jwt.version>3.10.2</java.jwt.version>
        <shiro.spring.version>1.5.2</shiro.spring.version>
        <!-- Shiro JWT End -->
    </properties>


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

        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>

        <!-- 用于使用Spring AOP和AspectJ实现面向切面编程 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
        </dependency>

        <!-- shiro -->
        <!-- https://mvnrepository.com/artifact/org.apache.shiro/shiro-spring -->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring</artifactId>
            <version>${shiro.spring.version}</version>
        </dependency>

        <!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>${java.jwt.version}</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>${java.version}</source>
                    <target>${java.version}</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <!-- 生成sources源码包的插件 -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-source-plugin</artifactId>
                <configuration>
                    <attach>true</attach>
                </configuration>
                <executions>
                    <execution>
                        <id>attach-sources</id>
                        <!--意思是在什么阶段打包源文件-->
                        <phase>package</phase>
                        <goals>
                            <goal>jar-no-fork</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>install-sources</id>
                        <!--意思是在什么阶段打包源文件-->
                        <phase>install</phase>
                        <goals>
                            <goal>jar-no-fork</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

上面都是要用到的jar,其实starter最重要的两个jar依赖:
spring-boot-starter 和 spring-boot-configuration-processor (用于配置文件属性封装)

spring-boot-starter-aop 其实可以不用引入,但如果你在项目中要使用shiro的权限控制注解时,请务必保证你的项目里面有 spring-boot-starter-aop 这个依赖。

spring-web 依赖主要是这个自定义的starter里面用到了一些Request相关内容,不是主要依赖。

shiro-spring 和 java-jwt 主角不用说了

maven工程总体包目录结构

在这里插入图片描述
红框的文件包是主要的

总体设计思路

这里说一下总体设计思路,代码全部贴出太多了,文章最后放上 github地址,里面有使用说明 README.md

  • 相关的属性部分有默认值,可以在application.yml里面修改
  • 认证,授权两个方法必须让引入starter的开发者根据自己业务重写
  • 应该有一个当认证失败时,供开发者调用的方法,开发者在这个方法里面实现具体认证失败时跳转还是输出错误信息
  • 必须注入自定义的过滤器
  • 必须可以自定义路径过滤规则
  • 有基于注解认证的 和 基于角色权限URL认证方式的

这里不用多说了,可以查看github上的源码,另外附上使用说明和测试用例的github地址

shirojwt-spring-boot-starter 源码地址


https://github.com/dwhgygzt/shirojwt-spring-boot-starter


测试工程源码地址


https://github.com/dwhgygzt/myshirojwt-test


使用方式

代码下载本地,mvn clean install 之后

配置:

pom.xml 文件引入如下配置

<dependency>
    <groupId>org.guzt</groupId>
    <artifactId>shirojwt-spring-boot-starter</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

引入配置后,其实 application.yml不用配置任何信息即可启用 shiro jwt,
当然你可以根据下面的常用默认值决定是否配置

  1. 配置文件默认登录路径 /api/login
  2. 配置文件默认退出路径 /api/logout
  3. 默认Header里jwt的名称 Authorization
  4. 其他默认值例如token超时时限,刷新时限请查看源码,默认1个小时

如果需要配置不同信息,yml文件配置也十分简单:

shirojwt:
  login-url: /api/login
  logout-url: /api/logout
  jwtIssuer: yourIssuerName
  token-header-key: Authorization

用法:

1. 用户登录后生成 token方法

下面是一个简单的测试类

@RestController
@RequestMapping("/api")
public class UserInfoController {

    // 用于查询用户信息的 service
    @Resource
    private UserInfoService userInfoService;

    @PostMapping("login")
    public Map<String, String> login(String userName, String password) {
        // 你的登录代码验证逻辑
        Map<String, String> loginInfo = userInfoService.login(userName, password);
        if (loginInfo == null || loginInfo.isEmpty()) {
            BusinessException.create("用户名或密码错误");
        }
        // 登录验证通过后 生成token给前端
        assert loginInfo != null;
        loginInfo.put("token", JwtUtil.sign(userName, 
                        loginInfo.get(UserInfoService.passwordKey), 
                        loginInfo.get(UserInfoService.saltKey)));
        return loginInfo;
    }

    @GetMapping("logout")
    public String logout() {
        Subject subject = SecurityUtils.getSubject();
        if (subject != null) {
            subject.logout();
        }
        return "退出成功";
    }
}

2. 前端访问后台接口,http请求中 HEADER 必须带有token

KEY VALUE
Authorization 登录接口获得的token值

3. shiro验证token合法性

重写 JwtBussinessService 类即可,覆盖里面几个方法,

java 代码中使用如下:

@Service
public class MyJwtBussinessService extends JwtBussinessService {

    private Logger logger = LoggerFactory.getLogger(this.getClass());


    public MyJwtBussinessService() {
        logger.info("MyJwtBussinessService 初始化");
    }

    @Override
    public AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals, String realmName) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        logger.debug("进入 授权 doGetAuthorizationInfo");
        logger.debug("the toke is {}", principals.toString());
        String userName = JwtUtil.getUserName(principals.toString());
        // 模拟从数据库中根据用户名查询出用户
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        String spit = ",";
        // 该用户具有哪些权限
        for (String permission : user.get(UserInfoService.permissionsKey).split(spit)) {
            authorizationInfo.addStringPermission(permission);
        }
        // 该用户具有哪些角色
        for (String role : user.get(UserInfoService.rolesKey).split(spit)) {
            authorizationInfo.addRole(role);
        }

        return authorizationInfo;
    }

    @Override
    public AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth, String realmName) throws AuthenticationException {
        String token = (String) auth.getCredentials();
        logger.debug("进入 认证 doGetAuthenticationInfo");
        logger.debug("the toke is {}", token);
        // token是否过期
        Date expiresDate = JwtUtil.getExpiresAt(token);
        if (expiresDate == null) {
            throw new IncorrectCredentialsException("token 不正确");
        } else if (expiresDate.before(new Date())) {
            throw new ExpiredCredentialsException("token 过期了");
        }
        // 验证 token是否有效
        String userName = JwtUtil.getUserName(token);
        if (userName == null) {
            throw new IncorrectCredentialsException("token 不正确");
        }
        // 验证用户是否存在
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        if (user == null) {
            throw new UnknownAccountException("用户不存在");
        }
        // 用户最终认证
        String password = user.get(UserInfoService.passwordKey);
        String salt = user.get(UserInfoService.saltKey);
        return new SimpleAuthenticationInfo(token, password, ByteSource.Util.bytes(salt), realmName);
    }

    @Override
    public void onAccessDenied(HttpServletRequest request, HttpServletResponse response, boolean isTokenExists, ShiroException ex) throws IOException {
        // 这里的 ShiroException 分为两类 一类认证异常 一类权限检查不通过异常
        // AuthenticationException 认证异常
        // AuthorizationException 权限检查不通过异常
        defaultPrintJson(response, "{\"code\":\"-1\",\"data\":{\"bussinessCode\":\"401\"},\"message\":\"" + ex.getLocalizedMessage() + "\"}");
    }

    @Override
    public String refreshOldToken(String oldToken) {
        // 刷新 token
        String userName = JwtUtil.getUserName(oldToken);
        Map<String, String> user = UserInfoService.MYSQL_USER_TABLE.get(userName);
        return JwtUtil.sign(userName, user.get(UserInfoService.passwordKey), user.get(UserInfoService.saltKey));
    }
}

默认已经对swagger进行的过滤,可直接访问swagger页面
如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilterRule所在的配置类会被提前初始化

@Component
public class MyExtraFilterRule extends ExtraFilterRule {
    
    // 请务必使用懒加载方式注入bean
    // yourBusinessBean 例如为菜单查询类,查询出所有按钮权限菜单
    @Lazy
    @Service
    private YourBusinessBean yourBusinessBean;

    @Override
    public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
        // 不检查某些路径
        filterRuleMap.put("/api/init", "noSessionCreation,anon");
       // 添加自定义过滤器配置 myTestFilter 就是自己的过滤器
        filterRuleMap.put("/api/selectUserInfoByUserName", "noSessionCreation,myTestFilter,jwt,jwtPerms[dd]");
    }
}

5. 添加自定义过滤器

如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilter所在的配置类会被提前初始化

@Component
public class MyExtraFilter extends ExtraFilter {
    @Override
    public void setExtraFilter(LinkedHashMap<String, Filter> filterMap) {
        filterMap.put("myTestFilter", new MyTestFilter());
    }
}

/** * 自定义过滤器, 请勿使用 @Bean 或 @Service * * admin */
public class MyTestFilter extends AuthorizationFilter {

    protected Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        logger.info("没有别的事情,就是表示进过了过滤器 MyTestFilter");
        return Boolean.TRUE;
    }
}

6. 默认已经添加的过滤器配置

名称 作用
jwt jwt认证
myCorsFilter 支持跨域,默认支持
jwtPerms URL 上的权限认证
jwtRoles URL 上的角色认证

7. 基于URL的权限认证

一般情况下针对基于URL的权限认证,说白了就是按钮权限认证,也即对后台某个Controller方法的权限认证。
所谓权限认证,就是你是否有相应的权限或角色标识才可调用该controller里面的某个方法。

这里做法一般两种, 1. 基于权限注解 2. 基于URL过滤器配置

基于注解

用法如下:

/** * 测试 shirojwt * * @author admin */
@RestController
@RequestMapping("/api")
public class UserInfoController {
 
    // 需要 权限 admin:update 才可访问这个方法
    @RequiresPermissions("admin:update")
    @PutMapping("updateUser")
    public String updateUser(@RequestBody Map<String, String> user) {
        userInfoService.updateUser(user);
        return "success";
    }
 
    // 需要 admin或user角色才能访问这个方法
    @RequiresRoles(value = {"admin","user"})
    @GetMapping("getUserInfoByUserName")
    public Map<String, String> getUserInfoByUserName(String userName) {
        return userInfoService.getUserByUserName(userName);
    }

}

当用户访问 上面controller层里面任意一个方法时,shiro会调用上文中 doGetAuthorizationInfo
方法,该方法作用就是从数据库或缓存中根据 JWT 取出用户具有的角色和权限,然后Shiro框架会自动判定用户是否具有
访问该方法的权限,如果没有将抛出 UnauthorizedException 异常, 用户可使用全局异常进行捕获反馈给前端。

这里说明一下 在此之前用户已经进过JWT 认证了,如果认证不通过不会到这一步的。

基于URL过滤器配置

上文已经提过,本starter已经默认注册了 权限角色验证的过滤器,且支持自定义URL过滤配置

名称 作用
jwtPerms URL 上的权限认证
jwtRoles URL 上的角色认证

重复上面的文章 覆写ExtraFilterRule类即可。

默认已经对swagger进行的过滤,可直接访问swagger页面
如果要引入其他Bean 请务必使用懒加载方式,防止自定义的AOP失效,因为ExtraFilterRule所在的配置类会被提前初始化

@Component
public class MyExtraFilterRule extends ExtraFilterRule {
    
    // 请务必使用懒加载方式注入bean
    // MenuRoleService 角色菜单权限关系处理service
    @Lazy
    @Service
    private MenuRoleService menuRoleService;

    @Override
    public void setExtraFilterRule(LinkedHashMap<String, String> filterRuleMap) {
        List<Menu> buttons = menuRoleService.listAllButtonMenu();
        for( Menu item : buttons ){
            // item.getPathUrl() 是按钮对应的后端路径
            // item.getPerm() 是按钮应的权限标识,表示这个URL需要该权限标识才可访问
            filterRuleMap.put(item.getPathUrl(), "noSessionCreation,jwt,jwtPerms["+ item.getPerm() +"]");
        }
    }
}

这里如果用户权限认证不通过时候,会调用上文中 MyJwtBussinessService 里面的 onAccessDenied 方法。
此时 ShiroException 为 UnauthorizedException,你可以根据具体的异常类别做出打印或跳转信息给前端。

这里列出 ShiroException 的具体常用的几种子类,以便你做出具体的业务逻辑处理。

类别 说明
NoTokenAuthenticationException 【jwt验证】 header里面未携带jwt
ProgramErrorAuthenticationException 【jwt验证】jwt验证程序500错误
ExpiredCredentialsException 【jwt验证】jwt过期,这个需要你自己认证方法里面抛出
ExpiredCredentialsException 【jwt验证】jwt过期,这个需要你自己认证方法里面抛出
IncorrectCredentialsException 【jwt验证】jwt格式错误,这个需要你自己认证方法里面抛出
UnauthorizedException 【权限验证】 权限认证不通过统一抛出该异常

8. 基于URL的动态权限认证

所谓动态 就是可以在管理系统里面随意添加一条或删除一条URL 认证记录,这里暂不建议这样做,
这里非要做其实是要刷新Shiro里面缓存的URL 拦截配置,说穿了就是将里面的一个LinkHashMap清空重新
填充数据。

  • 不建议原因1 现在都是分布式部署,你要刷新全部的机器上的应用
  • 不建议原因2 一般都是有新功能上线才会有这样的事情,建议滚动发布即可,挨个重启服务测试
  • 不建议原因3 现在很多的微服务认证都转向API网关层认证,当然网关认证也可结合shirojwt,网关一般也是多台部署
    一般滚动发布即可。

9. 关于缓存管理

这里建议开发自行 在认证 和 授权两个方法里面通过redis缓存进行自定义逻辑处理。
例如简单的获取用户是否存在验证逻辑:

@Service
public class CurrentUserServiceImpl implements CurrentUserService {

    public CurrentUserVO getCurrentUserFromCacheAndDb(String authToken) {
        if (StrUtil.isEmpty(authToken)) {
            logger.debug("authToken is null");
            BusinessException.create(CommonBusinessCode.AUTHTOKEN_NOTFOUND);
        }
        CurrentUserVO vo = null;
       // 先从缓存里面取 token
        RBucket<String> tokenBucket = redissonClient.getBucket(
                applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_TOKEN_KEY, SecureUtil.md5(authToken)));
        if (!tokenBucket.isExists()) {
            logger.debug("authToken {} not in redis", authToken);
            BusinessException.create(CommonBusinessCode.AUTHTOKEN_INVALID);
        }
       // 然后根据token 取用户
        RBucket<CurrentUserVO> userBucket = redissonClient.getBucket(
                applicationName + StrUtil.format(SysConstants.REDIS_LOGIN_USER_KEY, JwtUtil.getUserName(authToken)));
        if (userBucket.isExists()) {
            vo = userBucket.get();
            CurrentUserContext.remove();
            CurrentUserContext.setCurrentUser(vo);
        }
        // 缓存不存在,从数据库中加载用户信息
        if (ObjectUtil.isEmpty(vo)) {
            logger.debug("currentUser({}) 获取不到信息 从数据库中查询该用户", JwtUtil.getUserName(authToken));
            CurrentUserContext.remove();
            sysUserAggregateService.getCurrentUserFromDb(JwtUtil.getUserName(authToken), ExtendNetUtil.getSpringContextRequestIp(), SecureUtil.md5(authToken));
            vo = CurrentUserContext.getCurrentUser();
            // 放入缓存中
            userBucket.set(vo, shiroJwtProperties.getTokenExpireSeconds(), TimeUnit.SECONDS);
        }

        if (ObjectUtil.isEmpty(vo)) {
            logger.debug("currentUser({}) 缓存和数据库中都获取不到用户信息", JwtUtil.getUserName(authToken));
            BusinessException.create(CommonBusinessCode.CURRENT_USER_NOTFOUND);
        }

        return vo;
    }

}