本文章根据以下两篇文章写作而成,感谢
Shiro整合JWT实现认证和权限鉴定(执行流程清晰详细)_jwt+shiro-CSDN博客
一.项目出发点与需求
公司新项目需要一个基于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
目的:
- 过滤请求
- 封装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
目的:
- 进行具体的用户信息验证
- 让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
目的:
- 添加过滤器和过滤规则
- 给默认的安全管理器绑定自定义的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;
}
}
至此,所有配置完成