一、Shiro简介
Shiro是Java领域非常知名的认证( Authentication )与授权 ( Authorization )框架,用以替代JavaEE中的JAAS功能。相 较于其他认证与授权框架,Shiro设计的非常简单,所以广受好 评。任意JavaWeb项目都可以使用Shiro框架,而Spring Security 必须要使用在Spring项目中。所以Shiro的适用性更加广泛。像什么 JFinal 和 Nutz 非Spring框架都可以使用Shiro,而不能使用Spring Security框架。
1.1 什么是认证?
认证就是要核验用户的身份,比如说通过用户名和密码来检验用户的身份。说简单一些,认证就是登陆。登陆之后Shiro要记录用户成功登陆的凭证。
1.2 什么是授权?
授权是比认证更加精细度的划分用户的行为。比如说一个教务管理系统中,学生登陆之后只能查 看信息,不能修改信息。而班主任就可以修改学生的信息。这就是利用授权来限定不同身份用户 的行为。
1.3 Shiro靠什么做认证与授权的?
Shiro可以利用 HttpSession 或者 Redis 存储用户的登陆凭证,以及角色或者身份信息。然后利 用过滤器(Filter),对每个Http请求过滤,检查请求对应的 HttpSession 或者 Redis 中的认证 与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。 也就是说,我们写用户登陆模块的时候,用户登陆成功之后,要调用Shiro保存登陆凭证。然后查 询用户的角色和权限,让Shiro存储起来。将来不管哪个方法需要登陆访问,或者拥有特定的角色跟权限才能访问,我们在方法前设置注解即可,非常简单。
二、JWT简介
JWT(Json Web Token), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标 准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服 务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用 于认证,也可被加密。
三: 实战
3.1 创建JWT工具类
JWT的 Token 要经过加密才能返回给客户端,包括客户端上传的 Token ,后端项目需要验证核 实。于是我们需要一个JWT工具类,用来 加密Token 和 验证Token 的有效性。
3.1.1 导入依赖库
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.5.3</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.11</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
<version>4.4.13</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
3.1.2 定义密钥和过期时间
emos:
jwt:
#密钥
secret: abc123456
#令牌过期时间(天)
expire: 5
#令牌缓存时间(天数)
cache-expire: 10
3.1.3 创建JWT工具类
package com.example.emos.wx.config.shiro;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@Slf4j
public class JwtUtil {
@Value("${emos.jwt.secret}")
private String secret;
@Value("${emos.jwt.expire}")
private int expire;
public String createToken(int userId){
Date date=DateUtil.offset(new Date(), DateField.DAY_OF_YEAR,5);
Algorithm algorithm=Algorithm.HMAC256(secret);
JWTCreator.Builder builder= JWT.create();
String token=builder.withClaim("userId",userId).withExpiresAt(date).sign(algorithm);
return token;
}
public int getUserId(String token){
DecodedJWT jwt=JWT.decode(token);
int userId=jwt.getClaim("userId").asInt();
return userId;
}
public void verifierToken(String token){
Algorithm algorithm=Algorithm.HMAC256(secret);
JWTVerifier verifier=JWT.require(algorithm).build();
verifier.verify(token);
}
}
3.2 把令牌封装成认证对象
我们通过JwtUtil类可以生成 Token ,这个 Token 我们是要返回给客户端的。接下来 我们要把 JWT 和 Shiro框架 对接起来,这样 Shiro框架 就会拦截所有的Http请求,然后验证请求 提交的 Token 是否有效。
package com.example.emos.wx.config.shiro;
import org.apache.shiro.authc.AuthenticationToken;
public class OAuth2Token implements AuthenticationToken {
private String token;
public OAuth2Token(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
3.3 创建OAuth2Realm类
OAuth2Realm 类是 AuthorizingRealm 的实现类,我们要在这个实现类中定义认证和授权的方 法。因为认证与授权模块设计到用户模块和权限模块,现在我们还没有真正的开发业务模块,所 以我们这里先暂时定义空的认证去授权方法,把Shiro和JWT整合起来,在后续章节我们再实现 认证与授权。
package com.example.emos.wx.config.shiro;
import com.example.emos.wx.db.pojo.TbUser;
import com.example.emos.wx.service.UserService;
import org.apache.shiro.authc.*;
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 org.springframework.stereotype.Component;
import java.util.Set;
@Component
public class OAuth2Realm extends AuthorizingRealm {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof OAuth2Token;
}
/**
* 授权(验证权限时调用)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection collection) {
//TODO 查询用户的权限列表
//TODO 把权限列表添加到info对象中
TbUser user= (TbUser) collection.getPrimaryPrincipal();
int userId=user.getId();
Set<String> permsSet=userService.searchUserPermissions(userId);
SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
info.setStringPermissions(permsSet);
return info;
}
/**
* 认证(验证登录时调用)
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//TODO 从令牌中获取userId,然后检测该账户是否被冻结。
String accessToken=(String)token.getPrincipal();
int userId=jwtUtil.getUserId(accessToken);
TbUser user=userService.searchById(userId);
if(user==null){
throw new LockedAccountException("账号已被锁定,请联系管理员");
}
//TODO 往info对象中添加用户信息、Token字符串
SimpleAuthenticationInfo info=new SimpleAuthenticationInfo(user,accessToken,getName());
return info;
}
}
四:刷新令牌应该如何设计?
4.1 为什么要刷新Token的过期时间?
我们在定义JwtUtil工具类的时候,生成的 Token 都有过期时间。那么问题来了,假设 Token 过 期时间为15天,用户在第14天的时候,还可以免登录正常访问系统。但是到了第15天,用户的 Token过期,于是用户需要重新登录系统。
HttpSession 的过期时间比较优雅,默认为15分钟。如果用户连续使用系统,只要间隔时间不超 过15分钟,系统就不会销毁 HttpSession 对象。JWT的令牌过期时间能不能做成 HttpSession 那样超时时间,只要用户间隔操作时间不超过15天,系统就不需要用户重新登录系统。实现这种 效果的方案有两种: 双Token 和 Token缓存 ,这里重点讲一下 Token 缓存方案。
4.2 客户端如何更新令牌?
在我们的方案中,服务端刷新 Token 过期时间,其实就是生成一个新的 Token 给客户端。那么 客户端怎么知道这次响应带回来的Token是更新过的呢?这个问题很容易解决。
只要用户成功登陆系统,当后端服务器更新 Token 的时候,就在响应中添加 Token 。客户端那边判断每次Ajax响应里面是否包含 Token ,如果包含,就把 Token 保存起来就可以了。
4.3 如何在响应中添加令牌?
4.4 创建ThreadLocalToken类
package com.example.emos.wx.config.shiro;
import org.springframework.stereotype.Component;
@Component
public class ThreadLocalToken {
private ThreadLocal<String> local=new ThreadLocal<>();
public void setToken(String token){
local.set(token);
}
public String getToken(){
return local.get();
}
public void clear(){
local.remove();
}
}
4.5 创建OAuth2Filter类
注意事项:
因为在 OAuth2Filter 类中要读写 ThreadLocal 中的数据,所以 OAuth2Filter 类必 须要设置成多例的,否则 ThreadLocal 将无法使用。
package com.example.emos.wx.config.shiro;
import cn.hutool.core.util.StrUtil;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import org.apache.http.HttpStatus;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Scope;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.FilterChain;
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;
import java.util.concurrent.TimeUnit;
@Component
@Scope("prototype")
public class OAuth2Filter extends AuthenticatingFilter {
@Autowired
private ThreadLocalToken threadLocalToken;
@Value("${emos.jwt.cache-expire}")
private int cacheExpire;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private RedisTemplate redisTemplate;
/**
* 拦截请求之后,用于把令牌字符串封装成令牌对象
*/
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req= (HttpServletRequest) request;
String token=getRequestToken(req);
if(StrUtil.isBlank(token)){
return null;
}
return new OAuth2Token(token);
}
/**
* 拦截请求,判断请求是否需要被Shiro处理
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// Ajax提交application/json数据的时候,会先发出Options请求
HttpServletRequest req= (HttpServletRequest) request;
if(req.getMethod().equals(RequestMethod.OPTIONS.name())){
return true;
}
return false;
}
/**
* 该方法用于处理所有应该被Shiro处理的请求
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req= (HttpServletRequest) request;
HttpServletResponse resp= (HttpServletResponse) response;
resp.setContentType("text/html");
resp.setCharacterEncoding("UTF-8");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
threadLocalToken.clear();
//获取请求token,如果token不存在,直接返回401
String token=getRequestToken(req);
if(StrUtil.isBlank(token)){
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("无效的令牌");
return false;
}
try{
jwtUtil.verifierToken(token);
}catch (TokenExpiredException e){
//客户端令牌过期,查询Redis中是否存在令牌,如果存在令牌就重新生成一个令牌给客户端
if(redisTemplate.hasKey(token)){
redisTemplate.delete(token);
int userId=jwtUtil.getUserId(token);
token=jwtUtil.createToken(userId);
redisTemplate.opsForValue().set(token,userId+"",cacheExpire, TimeUnit.DAYS);
//把新令牌绑定到线程
threadLocalToken.setToken(token);
}
else{
//如果Redis不存在令牌,让用户重新登录
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("令牌已过期");
return false;
}
}catch (Exception e){
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("无效的令牌");
return false;
}
boolean bool=executeLogin(request,response);
return bool;
}
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletRequest req= (HttpServletRequest) request;
HttpServletResponse resp= (HttpServletResponse) response;
resp.setContentType("text/html");
resp.setCharacterEncoding("UTF-8");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
try{
resp.getWriter().print(e.getMessage());
}catch (Exception exception){
}
return false;
}
@Override
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
HttpServletRequest req= (HttpServletRequest) request;
HttpServletResponse resp= (HttpServletResponse) response;
resp.setContentType("text/html");
resp.setCharacterEncoding("UTF-8");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
super.doFilterInternal(request, response, chain);
}
/**
* 获取请求头里面的token
*/
private String getRequestToken(HttpServletRequest request){
String token=request.getHeader("token");
if(StrUtil.isBlank(token)){
token=request.getParameter("token");
}
return token;
}
}
4.6 创建ShiroConfig类
我们要创建的 ShiroConfig 类,是用来把 OAuth2Filter 和 OAuth2Realm 配置到Shiro框架,这 样我们辛苦搭建的Shiro+JWT才算生效。
package com.example.emos.wx.config.shiro;
import org.apache.shiro.mgt.SecurityManager;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm realm){
DefaultWebSecurityManager securityManager=new DefaultWebSecurityManager();
securityManager.setRealm(realm);
securityManager.setRememberMeManager(null);
return securityManager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager,OAuth2Filter filter){
ShiroFilterFactoryBean shiroFilter=new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String , Filter> map=new HashMap<>();
map.put("oauth2",filter);
shiroFilter.setFilters(map);
Map<String,String> filterMap=new LinkedHashMap<>();
filterMap.put("/webjars/**", "anon");
filterMap.put("/druid/**", "anon");
filterMap.put("/app/**", "anon");
filterMap.put("/sys/login", "anon");
filterMap.put("/swagger/**", "anon");
filterMap.put("/v2/api-docs", "anon");
filterMap.put("/swagger-ui.html", "anon");
filterMap.put("/swagger-resources/**", "anon");
filterMap.put("/captcha.jpg", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/test/**", "anon");
filterMap.put("/meeting/recieveNotify", "anon");
filterMap.put("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor=new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
4.7 利用AOP,把更新的令牌返回给客户端
我们在写 OAuth2Filter 的时候,把更新后的令牌写到 ThreadLocalToken 里面的 ThreadLocal 。那么这个小节,我们要创建 AOP切面类 ,拦截所有Web方法的返回值,在返回 的 R对象 中添加更新后的令牌。
package com.example.emos.wx.aop;
import com.example.emos.wx.common.util.R;
import com.example.emos.wx.config.shiro.ThreadLocalToken;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TokenAspect {
@Autowired
private ThreadLocalToken threadLocalToken;
@Pointcut("execution(public * com.example.emos.wx.controller.*.*(..))")
public void aspect(){
}
@Around("aspect()")
public Object around(ProceedingJoinPoint point) throws Throwable{
R r=(R)point.proceed();
String token=threadLocalToken.getToken();
if(token!=null){
r.put("token",token);
threadLocalToken.clear();
}
return r;
}
}