shiro框架是如今web开发做权限控制的重要选择之一,除了这个之外选择spring security的也很多。两者都有各自的优点。shiro更加简单,而spring security本来就是spring家族的,api整合起来更加友好。博主在实际项目中只用过shiro,相比spring security,shiro简单一些。但是这些安全框架都不是特别容易理解。
在项目中权限控制的思路是:首先用户可以凭借手机号+密码登录,也可以凭借手机号+验证码登录。如果登陆成功将颁发给用户一个token,token由jwt制作。往后token过期之前,用户访问的凭借都是token。如果token过期或者被篡改,则要求用户重新登陆。在token有效时,用户访问需要权限的接口都要经过shiro的两个核心注解RequiresRoles以及RequiresPermissions的验证。
下面将一点点带大家一步步看项目如何实现权限控制的。
本来是准备采取完全的ddd方式来构建项目,但是考虑到理解shiro可能已经有些费力。就不写得那么ddd了(你现在大概在心里喷博主是不会ddd的,hhh)。项目的四层的含义稍微解释一下
interfaces:用户接口层,这里面的facade里面放着大家熟悉的controller,其余都是用来转换
前后端的对象的,例如DTO。
infrastructure:基础设施层,这里面存放的是一些配置信息、工具类以及shiro相关的一些类,
你可以理解为此层是为了给整个项目提供通用支持。
appication:应用服务层,进入微服务的入口。此层组织多个业务领域完成来自facade的业务
需求,除此以外,一些spring的事件、定时任务、消息中间件的东西都会放在这里。
domain层:领域模型层,ddd领域驱动模型最重要但是最难理解的一层。这里面会存在多个聚合,
每一个聚合都是独立的,这种独立体现在业务上。例如用户周围的一些概念,用户+角色+权限可
以形成一个聚合,系统日志和用户操作日志可以形成一个聚合。这一层为了代码的简易没有按照
ddd严格划分领域,感兴趣的伙伴可以去看一些书籍了解。
本来不打算将sql语句写在这里,但是如果不想去gitee看源码的小伙伴就拿着这里的sql用吧。
第一张 用户表
CREATE TABLE `sys_user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'user表的id字段',
`userId` varchar(255) NOT NULL COMMENT '用户id 作为表主键 用于关联',
`userName` varchar(25) DEFAULT NULL COMMENT '用户登录帐号',
`password` varchar(255) DEFAULT NULL COMMENT '用户登录密码',
`userRemarks` varchar(255) DEFAULT NULL COMMENT '备注,预留字段',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
CREATE TABLE `sys_role` (
`roleId` varchar(32) NOT NULL COMMENT ' 角色id 作为表主键 用于关联',
`roleName` varchar(32) DEFAULT NULL COMMENT '角色名',
`roleRemarks` varchar(255) DEFAULT NULL COMMENT '备注,预留字段',
PRIMARY KEY (`roleId`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
CREATE TABLE `user_role` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '表主键id',
`userId` varchar(255) DEFAULT NULL COMMENT '帐号表的主键id',
`roleId` varchar(32) DEFAULT NULL COMMENT '角色表的主键id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
CREATE TABLE `sys_permissions` (
`perId` varchar(32) NOT NULL COMMENT '权限表id 作为表主键 用于关联',
`permissionsName` varchar(32) DEFAULT NULL COMMENT '权限名称',
`perRemarks` varchar(255) DEFAULT NULL COMMENT '备注,预留字段',
PRIMARY KEY (`perId`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
CREATE TABLE `role_per` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '表主键id',
`roleId` varchar(32) DEFAULT NULL COMMENT '角色表的主键id',
`perId` varchar(32) DEFAULT NULL COMMENT '权限表的主键id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC
以上就是表的结构和所有创建表的语句,你只需要建立一个mysql的数据库将这些语句都执行一下即可。如果你连数据都不想插入,最后我会给你在gitee的项目地址,项目里面有sql脚本,直接拿去用就好。五张表中sys_user代表的就是用户的账号,主要的字段就是手机号和密码字段。然后每个用户都会有一个角色,用户一旦创建,就会往user_role中添加关于这个用户的相关角色。然后角色和权限的关系是一对多的,由此实现用户的权限控制。
springboot选择了较高版本的2.2.6。shiro选择1.3.2。Jwt选择3.4.0。属于springboot的依赖遵循夫版本,剩余依赖各自的版本即可。
<?xml version="1.0" encoding="UTF-8"?>
://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">
>4.0.0 >
>com.cmdc >
>shiro-common >
>1.0-SNAPSHOT >
>
>org.springframework.boot >
>spring-boot-starter-parent >
>2.2.6.RELEASE >
> <!-- lookup parent from repository -->
>
<!--web项目-->
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
org.springframework.boot
spring-boot-starter-data-redis
mysql
mysql-connector-java
runtime
org.projectlombok
lombok
1.18.10
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.0.1
org.apache.shiro
shiro-spring
1.3.2
javax.persistence
persistence-api
1.0
com.auth0
java-jwt
3.4.0
com.google.guava
guava
28.1-jre
junit
junit
test
>
>
配置文件如下
server:
port: 8888
spring:
application:
name: my-shiro
#指定数据库相关信息
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/shiro-end-starter?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8
username: root
password: root
mybatis:
mapper-locations: classpath*:com.cmdc.domain.mapper/*.xml
type-aliases-package: com.cmdc.domain.entity
configuration:
map-underscore-to-camel-case: true
启动类------最重要的MapperScan注解不要忘记
package com.cmdc;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
*
* @author : wuwensheng
* @date : 9:17 2020/7/1
*/
@SpringBootApplication
//扫描下面的接口生成代理实现类
@MapperScan("com.cmdc.domain.mapper")
public class ShiroApplication {
public static void main(String[] args) {
SpringApplication.run(ShiroApplication.class,args);
}
}
因为只想展示权限的控制,需要建立实体类的表就一个user
package com.cmdc.domain.entity;
import lombok.*;
import lombok.experimental.Accessors;
import javax.persistence.Table;
import javax.validation.constraints.NotEmpty;
/**
* @author : wuwensheng
* @date : 10:37 2020/7/1
*/
@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false,of = "userId")
@Builder
@Table(name = "sys_user")
@Accessors(chain = true)
public class User {
private Integer id;
private String userId;
private String userName;
private String password;
private String userRemarks;
public static User getUser(String userId, String userName, String encryptPassword, String remark) {
return User.builder().userId(userId).userName(userName).password(encryptPassword).userRemarks(remark).build();
}
}
建立关于user的资源库接口
package com.cmdc.domain.mapper;
import com.cmdc.domain.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.LinkedHashMap;
/**
* @author : wuwensheng
* @date : 9:22 2020/7/1
*/
@Mapper
public interface UserMapper {
User selectById(@Param("userId") String userId);
LinkedHashMap<String, Object> selectUserPermissionById(@Param("userId") String userId);
User selectByName(@Param("userName") String userName);
LinkedHashMap<String, Object> selectUserPermissionByName(@Param("userName") String userId);
void insertUser(@Param("user") User user);
}
建立用户角色中间表的资源库接口
package com.cmdc.domain.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* @author : wuwensheng
* @date : 23:33 2020/7/1
*/
@Mapper
public interface UserRoleMapper {
void insert(@Param("userId") String userId, @Param("roleId") Integer roleId);
}
给定相关配置文件
userMapper
<mapper namespace="com.cmdc.domain.mapper.UserMapper">
<select id="selectById" parameterType="String" resultType="com.cmdc.domain.entity.User">
select u.userId,u.userName,u.password,u.userRemarks from sys_user as u where u.userId=#{userId}
select>
<select id="selectByName" parameterType="String" resultType="com.cmdc.domain.entity.User">
select u.userId,u.userName,u.password,u.userRemarks from sys_user as u where u.userName=#{userName}
select>
<select id="selectUserPermissionById" parameterType="String" resultType="java.util.LinkedHashMap">
SELECT u.userId,u.userName,u.password,u.userRemarks,
r.roleName,
r.roleRemarks,
GROUP_CONCAT(p.permissionsName)permissionsNameList,
GROUP_CONCAT(p.perRemarks)perRemarksList
FROM
sys_user u,sys_role r,sys_permissions p,user_role ur,role_per rp
WHERE
u.userId=ur.userId AND
ur.roleId=r.roleId AND
r.roleId=rp.roleId AND
rp.perId=p.perId AND
u.userId=#{userId}
GROUP BY u.id
select>
<select id="selectUserPermissionByName" parameterType="String" resultType="java.util.LinkedHashMap">
SELECT u.userId,u.userName,u.password,u.userRemarks,
r.roleName,
r.roleRemarks,
GROUP_CONCAT(p.permissionsName)permissionsNameList,
GROUP_CONCAT(p.perRemarks)perRemarksList
FROM
sys_user u,sys_role r,sys_permissions p,user_role ur,role_per rp
WHERE
u.userId=ur.userId AND
ur.roleId=r.roleId AND
r.roleId=rp.roleId AND
rp.perId=p.perId AND
u.userName=#{userName}
GROUP BY u.id
select>
<insert id="insertUser" parameterType="com.cmdc.domain.entity.User">
INSERT into sys_user
(userId,userName,password,userRemarks)
VALUES
(#{user.userId},#{user.userName},#{user.password},#{user.userRemarks})
insert>
mapper>
userRoleMapper
<mapper namespace="com.cmdc.domain.mapper.UserRoleMapper">
<insert id="insert">
INSERT INTO user_role(userId,roleId) VALUES (#{userId},#{roleId})
insert>
mapper>
注意一点,这边的资源库接口和相关的mapper文件的相对路径是一致的,都是com.cmdc.domain.mapper,如果你不想让他们的路径保持一致,别忘记更改下配置文件中mapper-locations的值。
这边因为需要查询用户权限,还要在用户登录的时候比较用户输入的密码和数据库中保存的用户密码,所以基本的mapper中的方法给了按照userId查询user,按照userId查询权限。其余的按照名称查询的备用。在你写完之后可以测试一下,写一点测一点还是个好习惯!
最后仔细看一眼domain层的结构
然后对应的mappper映射文件
这里的domainService稍微解释一下,真正的逻辑代码不会放在application层的service里面,因为application层的service是组织多个领域的领域服务去共同完成业务的。而domainService才是去书写每个领域的具体逻辑代码的。
接下来开始从业务出发,由上层的用户接口往下一步步完成用户的注册,登录,权限校验。
用户注册比较简单,只需要让用户输入用户名、密码、手机号等这些基本信息,然后如果用户输入的信息合法,将此用户添加到user表中,为其分配一个默认的权限即可。
用户注册时前端封装的DTO
package com.cmdc.interfaces.dto.request;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* @author : wuwensheng
* @date : 23:10 2020/7/1
*/
@Getter
@Setter
@ToString
public class RegisterDTO {
//用户手机号
@NotNull(message = "用户输入的手机号不可为空")
@Size(max = 11,min = 11,message = "手机号必须为11位")
private String userId;
//用户输入的密码
@NotBlank(message = "用户输入的注册密码不可为空")
@Size(max = 8,min = 6,message = "用户输入的密码长度必须在6-8之间")
private String password;
//用户名称
private String userName;
//用户
private String remark;
}
用户注册时的接口
//此为shiro开放端口
@PostMapping(value = "/user/register",name = "用户注册")
public JsonResult userRegister(@RequestBody @Valid RegisterDTO registerDTO){
JsonResult jsonResult;
try {
userService.register(registerDTO.getUserId(),registerDTO.getUserName(),registerDTO.getPassword(),registerDTO.getRemark());
jsonResult=new JsonResult();
}catch (CmdcException e){
throw e;
}
catch (Exception e){
throw new CmdcException(ErrorEnum.SERVER_ERROR);
}
return jsonResult;
}
其中jsonResult是通用的返回格式,CmdcException是自定义的异常,如果是自己扔出的自定义异常那么继续抛出,其余的异常不在预料之中则提示前端后台服务器报错。
这边调用了应用服务完成用户注册,应用服务中的具体代码是
应用层服务类接口
package com.cmdc.application.service;
import com.cmdc.interfaces.dto.JsonResult;
import org.springframework.transaction.annotation.Transactional;
/**
* @author : wuwensheng
* @date : 13:26 2020/7/1
*/
public interface UserService {
String passWordLogin(String userId,String passWord);
@Transactional
void register( String userId, String userName,String password, String remark);
void sendVerificationCode(String userId);
String verificationCodeLogin(String userId, String code);
}
应用层服务类的实现类
package com.cmdc.application.service.impl;
import com.cmdc.application.service.UserService;
import com.cmdc.domain.service.UserDomainService;
import com.cmdc.infrastructure.common.Constant;
import com.cmdc.infrastructure.exception.CmdcException;
import com.cmdc.infrastructure.exception.ErrorEnum;
import com.cmdc.infrastructure.util.CommonsUtils;
import com.cmdc.infrastructure.util.RedisUtil;
import com.cmdc.interfaces.dto.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* @author : wuwensheng
* @date : 13:29 2020/7/1
*/
@Service
@Slf4j
public class UserServiceImpl implements UserService{
@Autowired
private UserDomainService userDomainService;
@Autowired
private RedisUtil redisUtil;
@Override
public String passWordLogin(String userId, String passWord) {
return userDomainService.passwordLogin(userId,passWord);
}
@Override
public void register(String userId, String userName, String password, String remark) {
//现在开始用户注册
userDomainService.register(userId,userName,password,remark);
}
@Override
public void sendVerificationCode(String userId) {
userDomainService.sendVerificationCode(userId);
}
@Override
public String verificationCodeLogin(String userId, String code) {
return userDomainService.verificationCodeLogin(userId,code);
}
}
和之前说的一致,应用层的服务类不会直接去处理各种业务,而是组织各个核心的逻辑,具体的实现交给领域服务完成
用户的领域服务类
package com.cmdc.domain.service;
import com.cmdc.domain.entity.User;
import com.cmdc.domain.mapper.UserMapper;
import com.cmdc.domain.mapper.UserRoleMapper;
import com.cmdc.infrastructure.common.Constant;
import com.cmdc.infrastructure.enums.LoginEnum;
import com.cmdc.infrastructure.exception.CmdcException;
import com.cmdc.infrastructure.exception.ErrorEnum;
import com.cmdc.infrastructure.shirotoken.CustomizedToken;
import com.cmdc.infrastructure.util.CommonsUtils;
import com.cmdc.infrastructure.util.JwtUtil;
import com.cmdc.infrastructure.util.RedisUtil;
import com.cmdc.interfaces.dto.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* @author : wuwensheng
* @date : 13:33 2020/7/1
*/
@Service
@Slf4j
public class UserDomainService {
@Autowired
private UserMapper userMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Autowired
private RedisUtil redisUtil;
public void register(String userId, String userName, String password, String remark) {
// 首先检查此用户是否在数据库
if (this.selectById(userId) != null) throw new CmdcException(500, "该手机号已经注册");
// 制作用户密码,然后将用户插入user表中
String encryptPassword = CommonsUtils.encryptPassword(password, String.valueOf(userId));
log.info("加密之后的用户密码是:{}", encryptPassword);
this.insetUser(User.getUser(userId, userName, encryptPassword, remark));
// 增加用户角色中间表,注册最基本角色
userRoleMapper.insert(userId, 200);
}
public void insetUser(User user) {
userMapper.insertUser(user);
}
}
由于userDomainService这个领域服务代码稍多,用哪些方法我们就粘贴哪个。
这边注册时候对用户密码的处理是进行了MD5加密,使用用户的唯一标识手机号做盐值,做1024次hash迭代。工具类的代码如下:
package com.cmdc.infrastructure.util;
import com.cmdc.infrastructure.common.Constant;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.crypto.hash.SimpleHash;
import javax.servlet.http.HttpServletRequest;
/**
* @author lixiao
* @date 2019/7/31 17:11
*/
@Slf4j
public class CommonsUtils {
/**
* 手机号正则校验
* @param phone 手机号
* @return 校验是否成功
*/
public static boolean phoneRegexCheck(String phone){
if(phone.length()!=11){
return false;
}
return true;
}
/**
* 获取六位数验证码
* @return 验证码
*/
public static int getCode(){
return (int)((Math.random()*9+1)*100000);
}
/**
* 使用md5加密
* @param password 需要加密的密码
* @param phoneNumber 手机号
* @return 返回加密后的密码
*/
public static String encryptPassword(String password, String phoneNumber){ //userId作为盐值
return String.valueOf(new SimpleHash("MD5", password, phoneNumber, 1024));
}
/**
* 获取请求域中的UserId
*/
public static Integer getUserId(HttpServletRequest request){
return Integer.parseInt(request.getAttribute(Constant.USER_ID).toString());
}
}
插入用户之后需要在用户角色表中也插入一行记录,此处分配的角色为普通用户。
现在利用postman测试一下
返回了200,再看一眼数据库
都是ok的。
用户登录的整体思路是这样的,登录的时候要求用户输入手机号+密码登录,登录之后首先检查有无此用户,没有提示用户不存在,有的话继续检查用户输入的密码是否正确,正确为用户返回jwt制作的token,错误进行友情提示。
下面直接动手
首先用户接口层
@PostMapping(value = "/user/passwordLogin",name = "用户密码登录")
public JsonResult passwordLogin(@RequestBody @Valid PassWordLoginDTO passWordLoginDTO){
log.info("传递的请求参数:{}",passWordLoginDTO);
JsonResult jsonResult;
try {
jsonResult=new JsonResult<>( userService.passWordLogin(passWordLoginDTO.getUserId(),passWordLoginDTO.getPassword()));
}catch (CmdcException e){
throw e;
}catch (Exception e){
log.info("错误信息:{}",e);
jsonResult=new JsonResult<>(ErrorEnum.SERVER_ERROR);
}
return jsonResult;
}
传递的PassWordLoginDTO类
package com.cmdc.interfaces.dto.request;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author : wuwensheng
* @date : 13:27 2020/7/1
*/
@Getter
@Setter
@ToString
public class PassWordLoginDTO {
@NotNull(message = "用户密码登录传递的id不能为空")
private String userId;
@NotBlank(message = "用户密码登录传递的密码名称不能为空")
private String password;
}
应用服务层将用户的密码和手机号继续向下传递
最后看领域服务中怎么进行的处理
package com.cmdc.domain.service;
import com.cmdc.domain.entity.User;
import com.cmdc.domain.mapper.UserMapper;
import com.cmdc.domain.mapper.UserRoleMapper;
import com.cmdc.infrastructure.common.Constant;
import com.cmdc.infrastructure.enums.LoginEnum;
import com.cmdc.infrastructure.exception.CmdcException;
import com.cmdc.infrastructure.exception.ErrorEnum;
import com.cmdc.infrastructure.shirotoken.CustomizedToken;
import com.cmdc.infrastructure.util.CommonsUtils;
import com.cmdc.infrastructure.util.JwtUtil;
import com.cmdc.infrastructure.util.RedisUtil;
import com.cmdc.interfaces.dto.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
/**
* @author : wuwensheng
* @date : 13:33 2020/7/1
*/
@Service
@Slf4j
public class UserDomainService {
@Autowired
private UserMapper userMapper;
//todo 其实这里是需要发送事件的
@Autowired
private UserRoleMapper userRoleMapper;
@Autowired
private RedisUtil redisUtil;
public User selectByName(String userName) {
return userMapper.selectByName(userName);
}
public User selectById(String userId) {
return userMapper.selectById(userId);
}
public String passwordLogin(String userId, String passWord) {
// 获取Subject
Subject subject = SecurityUtils.getSubject();
// 校验userId是否为空
if (StringUtils.isEmpty(userId)) throw new CmdcException(ErrorEnum.ACCOUNT_UNUSUAL);
// 校验数据库中此user是否存在
User user = this.selectById(userId);
if (user == null) throw new CmdcException(ErrorEnum.ACCOUNT_UNUSUAL);
// 制作CustomizedToken执行登录
CustomizedToken customizedToken = new CustomizedToken(userId, passWord, LoginEnum.BY_PASSWORD.getLoginType());
subject.login(customizedToken);
// 若登陆成功返回相关token
return JwtUtil.sign(user.getUserId(), Constant.TOKEN_SECRET);
}
}
这边的具体逻辑解释一下,首先subject对象是shiro依赖中的,你可以理解为这是用户的身份验证和权限鉴别的入口,可以称之为主体对象,在这里由它发起登录。登录的时候需要传递的形参是继承自shiro的AuthenticationToken,源码如下:
此处由于之后可能还要加入验证码登录,为了使得登录的逻辑更为通用,选择自定义token。这个token中userName和password是必须的,父类自带,除此之外扩展了一个loginType用来标识登录的类型
package com.cmdc.infrastructure.shirotoken;
import org.apache.shiro.authc.UsernamePasswordToken;
/**
* @author wuwensheng
* @date 2019/12/31 20:54
*/
public class CustomizedToken extends UsernamePasswordToken {
/**
* 登录类型
*/
public String loginType;
public CustomizedToken(final String username, final String password, final String loginType) {
super(username, password);
this.loginType = loginType;
}
public String getLoginType() {
return loginType;
}
public void setLoginType(String loginType) {
this.loginType = loginType;
}
@Override
public String toString(){
return "loginType="+ loginType +",username=" + super.getUsername()+",password="+ String.valueOf(super.getPassword());
}
}
此时你就想问了,subject.login()发起了登录,那么具体验证用户输入的信息对不对由谁进行呢?稍安勿躁,接下来就要开始配置shiro了,一点一点来
首先,配置一个最迫切需要的关于手机号+密码登录的realm,将其配置在基础设施包下的shirorealm包下
具体的代码如下
package com.cmdc.infrastructure.shirorealm;
import com.cmdc.domain.entity.User;
import com.cmdc.domain.mapper.UserMapper;
import com.cmdc.infrastructure.shirotoken.CustomizedToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.factory.annotation.Autowired;
/**
* 不需要关注PasswordRealm的授权
* 用户登录之后的权限校验全部发生在token层面,由jwtRealm进行
* PasswordRealm要做的就是用户的认证
* @author : wuwensheng
* @date : 10:57 2020/7/1
*/
@Slf4j
public class PasswordRealm extends AuthorizingRealm {
//认为realm所做的身份认证、权限校验都属于最底部的进入接口前的准备 尽量让其贴近领域层
@Autowired
private UserMapper userMapper;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof CustomizedToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
log.info("PasswordRealm权限认证开始,传递的token:{}",authenticationToken);
//找出数据库中的对象 给定用户输入的对象做出对比
CustomizedToken token = (CustomizedToken) authenticationToken;
log.info("PasswordRealm转换的自定义token是:{}",token);
// 根据userId查询用户
User user=userMapper.selectById(token.getUsername());
if (user == null) {
// 抛出账号不存在异常
throw new UnknownAccountException();
}
Object credentials = user.getPassword();
//param1:数据库用户 param2:密码 param3:加密所用盐值 param4:当前realm的名称
return new SimpleAuthenticationInfo(user, credentials, ByteSource.Util.bytes(user.getUserId()),getName());
}
}
首先,心里清楚,传递过来的AuthenticationToken一定要转换为自定义的CustomizedToken,因为制作的时候我们就是这么制作的。之后先从CustomizedToken拿出用户名查询此用户,查询不到抛出账户不存在异常。查询到了接着比较密码即可,若通过逻辑向下执行,否则逻辑终止。
解释下校验用户密码的这一行核心代码
realm是shiro的核心概念,不同的realm用于不同情形下用户的身份认证和权限校验,写一个realm 需要继承shiro的AuthorizingRealm 。你需要完成的需重写的两个核心方是:doGetAuthenticationInfodoGetAuthorizationInfo。分别负责刚刚提到的身份认证和权限校验。当存在多种认证的token的时候,你需要重写这里的supports方法(不再详细解释,看一眼代码就理解了,起到的是一个适配的作用)
登录代码的最后一行JwtUtil.sign(user.getUserId(), Constant.TOKEN_SECRET)。目的是成功之后为用户返回一个token。往后用户访问的身份验证和权限校验工作都交给token。这个token怎么制作的呢,点金JwtUtil这个工具类看一下。
package com.cmdc.infrastructure.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cmdc.infrastructure.common.Constant;
import lombok.extern.slf4j.Slf4j;
import java.util.Date;
/**
*需要注意的是 这边校验的方式并非非对称加密
* 可以改造为非对称加密: 公钥加密,私钥解密 私钥生成签名,公钥验证签名
*/
@Slf4j
public class JwtUtil {
/**
* 校验token是否正确
*
* @param token 密钥
* @param secret 用户的密码
* @return 是否正确
*/
public static boolean verify(String token, String secret) {
try {
//根据密码生成JWT效验器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim(Constant.TOKEN_CLAIM, getUserId(token))
.build();
// 效验TOKEN
verifier.verify(token);
return true;
} catch (JWTVerificationException exception) {
return false;
}
}
/**
* 获得token中的信息无需secret解密也能获得
* @return token中包含的用户名
*/
public static String getUserId(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(Constant.TOKEN_CLAIM).asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
*
* @param userId 用户id
* @param secret 制作此token的签名依据
* @return 加密的token
*/
public static String sign(String userId, String secret) {
Date date = new Date(System.currentTimeMillis() + Constant.TOKEN_EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create()
.withClaim(Constant.TOKEN_CLAIM, userId)
.withExpiresAt(date)
.sign(algorithm);
}
}
制作jwtToken签名的加密方式采用HMAC256,加密所需字符串写一个固定值。放在jwt载荷中的就一个userId,往后需要用户别的信息从token中取出来然后再去数据库里面查询。
用于密码校验的realm完成,但是问题又出现了
问题一:你只是定义了一个realm,却没有告诉shiro。
问题二:即使你告诉shiro这个realm的存在,shiro也不知道选取在subject.login()发动的时候,要去选择哪个realm,因为
我们没告诉shiro选择realm的规则。
问题的解决
问题一:编写shiro的配置,将我们定义的realm加入shiro的管理中。
问题二:重写shiro负责管理realm选取的类,编写自己的校验规则。
shiro的核心配置类源码
package com.cmdc.infrastructure.config;
import com.cmdc.infrastructure.filter.JwtFilter;
import com.cmdc.infrastructure.shirorealm.CodeRealm;
import com.cmdc.infrastructure.shirorealm.JwtRealm;
import com.cmdc.infrastructure.shirorealm.PasswordRealm;
import com.cmdc.infrastructure.shirorealm.UserModularRealmAuthenticator;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authc.pam.AtLeastOneSuccessfulStrategy;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.*;
/**
* @author lixiao
* @date 2019/10/3 15:39
*/
@Configuration
public class ShiroConfig {
/**
* 密码登录时指定匹配器,暂时不弄
* @return HashedCredentialsMatcher
*/
@Bean("hashedCredentialsMatcher")
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
// 设置哈希算法名称
matcher.setHashAlgorithmName("MD5");
// 设置哈希迭代次数
matcher.setHashIterations(1024);
// 设置存储凭证十六进制编码
matcher.setStoredCredentialsHexEncoded(true);
return matcher;
}
/**
* 如果需要密码匹配器则需要进行指定
* 密码登录Realm
* @return PasswordRealm
*/
@Bean
public PasswordRealm passwordRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher) {
PasswordRealm passwordRealm = new PasswordRealm();
passwordRealm.setCredentialsMatcher(matcher);
return passwordRealm;
}
@Bean
public CodeRealm codeRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher){
CodeRealm codeRealm=new CodeRealm();
codeRealm.setCredentialsMatcher(matcher);
return codeRealm;
}
/**
* jwtRealm
*
* @return JwtRealm
*/
@Bean
public JwtRealm jwtRealm() {
return new JwtRealm();
}
/**
* Shiro内置过滤器,可以实现拦截器相关的拦截器
* 常用的过滤器:
* anon:无需认证(登录)可以访问
* authc:必须认证才可以访问
* user:如果使用rememberMe的功能可以直接访问
* perms:该资源必须得到资源权限才可以访问
* role:该资源必须得到角色权限才可以访问
**/
@Bean
public ShiroFilterFactoryBean shiroFilter(@Qualifier("securityManager") SecurityManager securityManager) {
ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
// 设置 SecurityManager
bean.setSecurityManager(securityManager);
// 设置未登录跳转url
bean.setUnauthorizedUrl("/user/unLogin");
Map<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/user/passwordLogin", "anon");
filterMap.put("/user/verificationCodeLogin", "anon");
filterMap.put("/user/register", "anon");
bean.setFilterChainDefinitionMap(filterMap);
Map<String, Filter> filter = new HashMap<>(1);
filter.put("jwt", new JwtFilter());
bean.setFilters(filter);
filterMap.put("/**", "jwt");
bean.setFilterChainDefinitionMap(filterMap);
return bean;
}
@Bean
public UserModularRealmAuthenticator userModularRealmAuthenticator() {
//自己重写的ModularRealmAuthenticator
UserModularRealmAuthenticator modularRealmAuthenticator = new UserModularRealmAuthenticator();
modularRealmAuthenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy());
return modularRealmAuthenticator;
}
/**
* SecurityManager 是 Shiro 架构的核心,通过它来链接Realm和用户(文档中称之为Subject.)
*/
@Bean
public SecurityManager securityManager(
@Qualifier("codeRealm")CodeRealm codeRealm,
@Qualifier("passwordRealm") PasswordRealm passwordRealm,
@Qualifier("jwtRealm") JwtRealm jwtRealm,
@Qualifier("userModularRealmAuthenticator") UserModularRealmAuthenticator userModularRealmAuthenticator) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm
securityManager.setAuthenticator(userModularRealmAuthenticator);
List<Realm> realms = new ArrayList<>();
// 添加多个realm
realms.add(passwordRealm);
realms.add(jwtRealm);
realms.add(codeRealm);
securityManager.setRealms(realms);
/*
* 关闭shiro自带的session,详情见文档
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
}
/**
* 以下Bean开启shiro权限注解
*
* @return DefaultAdvisorAutoProxyCreator
*/
@Bean
public static DefaultAdvisorAutoProxyCreator creator() {
DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
creator.setProxyTargetClass(true);
return creator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
@Bean(name = "lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
}
这些配置非常重要,是将项目的环境和shiro联系起来的关键,下面详细解释ShiroConfig类中注入的各个bean的作用
密码匹配器
将realm交给shiro
shiro内置的请求url过滤器
realm选择器
shiro的内置管理器
开启shiro对注解的支持
如此问题一得到了解决,下面解决第二个问题,当在subject.login()发动的时候,要去选择哪个realm。这个类ShiroConfig中已经配置,叫做UserModularRealmAuthenticator,来看一眼代码
package com.cmdc.infrastructure.shirorealm;
import com.cmdc.infrastructure.shirotoken.CustomizedToken;
import com.cmdc.infrastructure.shirotoken.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.pam.ModularRealmAuthenticator;
import org.apache.shiro.realm.Realm;
import java.util.ArrayList;
import java.util.Collection;
/**
* @author lixiao
* @date 2019/7/31 20:48
*/
@Slf4j
public class UserModularRealmAuthenticator extends ModularRealmAuthenticator {
//当subject.login()方法执行,下面的代码即将执行
@Override
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
log.info("UserModularRealmAuthenticator:method doAuthenticate() 执行 ");
// 判断getRealms()是否返回为空
assertRealmsConfigured();
// 所有Realm
Collection<Realm> realms = getRealms();
// 盛放登录类型对应的所有Realm集合
Collection<Realm> typeRealms = new ArrayList<>();
// 强制转换回自定义的Token
try{
log.info("进入了UserModularRealmAuthenticator类...得到的authenticationToken是:{}",authenticationToken);
JwtToken jwtToken = (JwtToken) authenticationToken;
for(Realm realm : realms){
if (realm.getName().contains("Jwt")){
typeRealms.add(realm);
}
}
return doSingleRealmAuthentication(typeRealms.iterator().next(), jwtToken);
}catch (ClassCastException e){
typeRealms.clear();
// 这个类型转换的警告不需要再关注 如果token错误 后面将会抛出异常信息
CustomizedToken customizedToken = (CustomizedToken) authenticationToken;
// 登录类型
String loginType = customizedToken.getLoginType();
for (Realm realm : realms) {
log.info("正在遍历的realm是:{}",realm.getName());
if (realm.getName().contains(loginType)){
log.info("当前realm:{}被注入:",realm.getName());
typeRealms.add(realm);
}
}
// 判断是单Realm还是多Realm
if(typeRealms.size() == 1){
log.info("一个realm");
return doSingleRealmAuthentication(typeRealms.iterator().next(), customizedToken);
}else {
log.info("多个realm");
return doMultiRealmAuthentication(typeRealms, customizedToken);
}
}
}
}
当subject.login()执行,传递的token将来到重写了ModularRealmAuthenticator类的类里面,在这里也就是来到了 UserModularRealmAuthenticator ,选取realm的规则代码中写的非常清楚。简单再说一下
首先尝试将token强转为jwtToken,因为不知道此时是用户在密码登录、验证码登录或者是jwt登录。所以只能尝试转换了。转换成功选择JwtRealm。强转失败,就只剩下两个选择,要么密码登录要么验证码登录,这两种登录传递的token都是CustomizedToken,此时根据CustomizedToken中的loginType字段选取realm即可。
呼~终于是完成密码登录的配置了,接下来赶紧测试一下!就拿着15698756214这个刚注册的用户测试
如果输入的用户根本不存在
如果用户存在但密码错误
用户存在且密码正确
一切按照预期进行的,成功返回了jwt的token,往后可以拿着这个token无所欲为了。
当用户输入了正确的用户名和密码,为用户返回了一个jwt制作的token。这个token的载荷中存储了用户的手机号,利用固定字符串做的签名,还为它设置了过期的时间。
当token在有效期之内的时候,用户访问某个接口,这个接口需要固定的角色和权限才能访问,我们就利用用户所携带的token中的userId到数据库中查询此用户,告诉shiro这个用户的角色和权限,shiro会拿着这些去和接口限定的角色和权限做对比。
首先,在接口上规定角色和权限
那么规定了角色和权限的时候在那里执行呢?当然也是需要写一个realm去触发的,那么这个触发realm的地方在哪里呢?还记得之前ShiroConfig中的ShiroFilterBean的配置吗,拿出来再看一眼
问题现在变得明确了,触发权限校验的realm写在这个JwtFilter中,具体校验的realm需要重新写一个(因为本次是关于校验用户携带的token的),取名叫做JwtRealm。
JwtFilter中的代码如下
package com.cmdc.infrastructure.filter;
import com.cmdc.infrastructure.common.Constant;
import com.cmdc.infrastructure.shirotoken.JwtToken;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 这个类最主要的目的是:当请求需要校验权限,token是否具有权限时,构造出主体subject执行login()
*/
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 执行登录认证
* @param request ServletRequest
* @param response ServletResponse
* @param mappedValue mappedValue
* @return 是否成功
*/
@Override
//这个方法叫做 尝试进行登录的操作,如果token存在,那么进行提交登录,如果不存在说明可能是正在进行登录或者做其它的事情 直接放过即可
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 执行登录
*/
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws ServletException, IOException {
log.info("进入JwtFilter类中...");
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader(Constant.TOKEN_HEADER_NAME);
log.info("获取到的token是:{}",token);
// 判断token是否存在
if (token == null) {
return false;
}
JwtToken jwtToken = new JwtToken(token);
try{
log.info("提交UserModularRealmAuthenticator决定由哪个realm执行操作...");
getSubject(request, response).login(jwtToken);
} catch (AuthenticationException e){
log.info("捕获到身份认证异常");
return false;
}
return true;
}
/**
* 对跨域提供支持
*/
@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请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
将token强转为了JwtToken,然后由subject执行登录。想都不用想,一定会经过之前咱们重写的UserModularRealmAuthenticator
当来到JwtRealm中,思考一下这一端代码怎么写
首先,身份验证的地方,只需要校验此token的签名是否正确以及此token是否过期,签名正确也没过期那么认为这是一个有效token。可以直接放行了。
其次,权限校验的地方,根据token中的userId去数据库中查询到此用户的角色和权限并交给shiro。shiro会对比此处提供的角色和权限以及接口处注解声明的角色和权限。
JwtRealm的代码如下
package com.cmdc.infrastructure.shirorealm;
import com.cmdc.domain.entity.User;
import com.cmdc.domain.mapper.UserMapper;
import com.cmdc.infrastructure.common.Constant;
import com.cmdc.infrastructure.exception.ErrorEnum;
import com.cmdc.infrastructure.shirotoken.JwtToken;
import com.cmdc.infrastructure.util.JwtUtil;
import com.google.common.base.Splitter;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.LinkedHashMap;
import java.util.List;
/**
* @author : wuwensheng
* @date : 10:59 2020/7/1
*/
@Slf4j
public class JwtRealm extends AuthorizingRealm {
@Autowired
private UserMapper userMapper;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String token=principalCollection.toString();
String userId = JwtUtil.getUserId(token);
log.info("JwtRealm身份认证开始,获取到的token是:{}",token);
// 这里的空指针异常不需要处理 无论此处抛出什么异常 shiro均认为身份有问题
LinkedHashMap<String, Object> stringObjectLinkedHashMap = userMapper.selectUserPermissionById(userId);
//获取所有的角色
String roleName = String.valueOf(stringObjectLinkedHashMap.get("roleName"));
//获取所有的权限
List<String> permissionsNameList =
Splitter.on(",").splitToList(String.valueOf(stringObjectLinkedHashMap.get("permissionsNameList")));
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 添加角色
authorizationInfo.addRole(roleName);
//添加权限
authorizationInfo.addStringPermissions(permissionsNameList);
return authorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
log.info("jwtRealm中关于身份验证的方法执行...传递的token是:{}",authenticationToken);
String token = (String) authenticationToken.getCredentials();
// 解密获得phone,用于和数据库进行对比
String userId = JwtUtil.getUserId(token);
if (userId == null) {
throw new AuthenticationException(ErrorEnum.TOKEN_EXCEPTION.getMsg());
}
User user = userMapper.selectById(userId);
//校验用户是否存在
if(user == null){
throw new AuthenticationException(ErrorEnum.ACCOUNT_UNUSUAL.getMsg());
}
//操作时校验的是非对称加密是否成立.
if (!JwtUtil.verify(token,Constant.TOKEN_SECRET)) {
log.info("token校验无效...");
throw new AuthenticationException(ErrorEnum.TOKEN_EXCEPTION.getMsg());
}
log.info("进行身份验证时,用户提供的token有效");
return new SimpleAuthenticationInfo(token, token, getName());
}
}
这一段的注释写的非常详细,就不再多说。我们测试一下权限控制是否好用
。就测试昨天的普通用户15698756214这个账号。
测试/v1.0/shiro/getUserPermission这个接口
返回的结果正确,此接口允许访问
到这里我们就整合完毕了,代码中还添加了验证码登录的功能,有兴趣大家可以也看一看。建议大家还是将源码下载下来。结合文章走一遍!最后祝大家工作顺利,看的开心的话关注一下博主,谢谢!
代码gitee地址:springboot-shiro-jwt整合