1、什么是Shiro
Shiro是Java领域非常知名的认证( Authentication )与授权 ( Authorization )框架,用以替代JavaEE中的JAAS功能。相较于其他认证与授权框架,Shiro设计的非常简单,所以广受好评。任意JavaWeb项目都可以使用Shiro框架,而Spring Security 必须要使用在Spring项目中。所以Shiro的适用性更加广泛。像什 么 JFinal 和 Nutz 非Spring框架都可以使用Shiro,而不能使用Spring Security框架。
- 两大功能: 认证 与 授权
- 适用于JavaWeb项目
- 不是Spring框架下的产品
1.1 什么是认证和授权
- 认证:认证就是要核验用户的身份,比如说通过用户名和密码来检验用户的身份。说简单一些,认证就
是登陆。登陆之后Shiro要记录用户成功登陆的凭证。
- 授权:授权是比认证更加精细度的划分用户的行为。比如说在我们开发的系统中,对于数据库普通的开发人员可能只有读写权限,而数据库管理员可以有读写删等更高级的权限。这就是利用授权来限定不同身份用户 的行为。
1.2 shrio怎么上述两个功能?
Shiro可以利用 HttpSession 或者 Redis 存储用户的登陆凭证,以及角色或者身份信息。然后利用过滤器(Filter),对每个Http请求过滤,检查请求对应的HttpSession 或者 Redis 中的认证与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。 也就是说,我们写用户登陆模块的时候,用户登陆成功之后,要调用Shiro保存登陆凭证。然后查询用户的角色和权限,让Shiro存储起来。将来不管哪个方法需要登陆访问,或者拥有特定的角色 跟权限才能访问,我们在方法前设置注解即可,非常简单。
2、什么是JWT
JWT(Json Web Token), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标 准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
- JWT可以用在单点登录的系统中
如果用户的登陆凭证经过加密( Token )保存在客户端,客户端每次提交请求的时候,把 Token 上传给后端服务器节点。即便后端项目使用了负载均衡,每个后端节点接收到客户端上传 的Token之后,经过检测,是有效的 Token ,于是就断定用户已经成功登陆,接下来就可以提供后端服务了
- JWT兼容更多的客户端
传统的 HttpSession 依靠浏览器的 Cookie 存放 SessionId ,所以要求客户端必须是浏览器。现在的JavaWeb系统,客户端可以是浏览器、APP、小程序,以及物联网设备。为了让这些设备都能访问到JavaWeb项目,就必须要引入JWT技术。JWT的 Token 是纯字符串,至于客户端怎么保存,没有具体要求。只要客户端发起请求的时候,附带上 Token 即可。所以像物联网设备,我 们可以用 SQLite 存储 Token 数据。
3、JWT的实战
通过上面的介绍,我们已经了解了JWT的相关信息。说白了,JWT就是对我们的token进行加密,保存在客户端,每一个客户端的请求过来我们就对这个token进行校验,来判断用户的信息
3.1 创建JWT工具类
用于对Token进行加密,解密,生成...
- 导入依赖
<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>
- 为了维护的方便,我们可以给token在配置文件中定义好过期时间,秘钥,缓存时间。后面通过属性注入即可
- 创建JWT工具类
@Component
@Slf4j
public class JWTUtil {
@Value("${emos.jwt.secret}")
private String secret;
@Value("${emos.jwt.expire}")
private int expire;
/**
* 通过UserId,创建一个token
* @param userId
* @return
*/
public String createToken(int userId){
DateTime offset = DateUtil.offset(new Date(), DateField.DAY_OF_YEAR, expire);
Algorithm algorithm = Algorithm.HMAC256(secret);//使用算法加密secret密钥
JWTCreator.Builder builder = JWT.create();
String token = builder.withClaim("userId", userId)
.withExpiresAt(offset)
.sign(algorithm);
return token;
}
/**
* 通过token获取userId
* @param token
* @return
*/
public int getUserId(String token){
DecodedJWT decodedJWT = JWT.decode(token);
int userId = decodedJWT.getClaim("userId").asInt();
return userId;
}
/**
*验证密钥
* @param token
*/
public void verifierToken(String token){
Algorithm algorithm =Algorithm.HMAC256(secret);//对密钥进行加密算法处理
JWTVerifier verifier = JWT.require(algorithm).build();//创建一个jwt的verifier对象
verifier.verify(token);//验证密钥和token(加密后的密钥部分)是否一致---sign
}
}
4、对接JWT和Shiro框架
现在我们可以用过JWTUtil工具类生成token并对其进行一些操作,而token是不能直接交给Shiro的,按照Shiro框架,我们还需要对token进行封装。
4.1 封装token对象
public class OAuth2Token implements AuthenticationToken {
private String token;
/**
* 创建生成token对象的构造器
*
* @param token
*/
public OAuth2Token(String token){
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
4.2 创建Realm类
根据上面实现shiro框架的步骤,我们需要创建OAuth2Realm类,这个类需要继承AuthorizingRealm
OAuth2Realm 类是 AuthorizingRealm 的实现类,我们要在这个实现类中定义认证和授权的方法。因为认证与授权模块设计到用户模块和权限模块,现在我们还没有真正的开发业务模块,所 以我们这里先暂时定义空的认证去授权方法,把Shiro和JWT整合起来,在后续章节我们再实现 认证与授权。
@Component
public class OAuth2Realm extends AuthorizingRealm {
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof OAuth2Token;
}
/**
* 授权(验证权限时调用)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
//TODO 查询用户的权限列表
//TODO 把权限列表添加到info对象中
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
throws AuthenticationException {
//TODO 从令牌中获取userId,然后检测该账户是否被冻结。
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo();
//TODO 往info对象中添加用户信息、Token字符串
return info;
}
}
4.3 如何设计token(令牌)的过期时间
我们在定义JwtUtil工具类的时候,生成的 Token 都有过期时间。那么问题来了,假设 Token 过 期时间为15天,用户在第14天的时候,还可以免登录正常访问系统。但是到了第15天,用户的 Token过期,于是用户需要重新登录系统。
HttpSession 的过期时间比较优雅,默认为15分钟。如果用户连续使用系统,只要间隔时间不超 过15分钟,系统就不会销毁 HttpSession 对象。
JWT的令牌过期时间能不能做成 HttpSession 那样超时时间,只要用户间隔操作时间不超过15天,系统就不需要用户重新登录系统。实现这种 效果的方案有两种: 双Token 和 Token缓存 ,这里重点讲一下 Token 缓存方案。
简单的来说,就是当我们第一次登录的时候,此时我们有效的登录时间是15天,如果我在第15天的时候登录,那么此时还不应该需要重新登录,只有说在第15天过后,也就是我们15天内没有操作,且再过15天之后过期,这就是为什么我们缓存的时间是过期时间的1倍。
Token缓存方案是把 Token 缓存到Redis,然后设置Redis里面缓存的 Token 过期时间为正常 Token 的1倍,然后根据情况刷新 Token 的过期时间。
Token失效,缓存也不存在的情况:当第15天,用户的 Token 失效以后,我们让Shiro程序到Redis查看是否存在缓存的 Token ,如果这个 Token 不存在于Redis里面,就说明用户的操作间隔了15天,需要重新登录。
Token失效,但是缓存还存在的情况 如果Redis中存在缓存的 Token ,说明当前 Token 失效后,间隔时间还没有超过15天,不应该让用户重新登录。所以要生成新的 Token 返回给客户端,并且把这个 Token 缓存到Redis里面,这种操作称为刷新 Token 过期时间。
我们定义 OAuth2Filter 类拦截所有的HTTP请求:
一方面它会把请求中的 Token 字符串提取出 来,封装成对象交给Shiro框架;
另一方面,它会检查 Token 的有效性。如果 Token 过期,那么 会生成新的 Token ,分别存储在 ThreadLocalToken 和 Redis 中。
之所以要把 新令牌 保存到 ThreadLocalToken 里面,是因为要向 AOP切面类 传递这个 新令牌 。 虽然 OAuth2Filter 中有 doFilterInternal() 方法,我们可以得到响应并且写入 新令牌 。但是 这个做非常麻烦,首先我们要通过IO流读取响应中的数据,然后还要把数据解析成JSON对象, 最后再放入这个新令牌。如果我们定义了 AOP切面类 ,拦截所有Web方法返回的 R对象 ,然后 在 R对象 里面添加 新令牌 ,这多简单啊。但是 OAuth2Filter 和 AOP 切面类之间没有调用关 系,所以我们很难把 新令牌 传给 AOP切面类 。 这里我想到了 ThreadLocal ,只要是同一个线程,往 ThreadLocal 里面写入数据和读取数据是 完全相同的。在Web项目中,从 OAuth2Filter 到 AOP切面类 ,都是由同一个线程来执行的,中途不会更换线程。所以我们可以放心的把新令牌保存都在 ThreadLocal 里面, AOP切面类 可以成 功的取出新令牌,然后往 R对象 里面添加新令牌即可。 ThreadLocalToken 是我自定义的类,里面包含了 ThreadLocal 类型的变量,可以用来保存线程 安全的数据,而且避免了使用线程锁
4.4 设计ThreadLocalToken类
@Component
public class ThreadLocalToken {
private ThreadLocal<String> threadLocal;
public void setToken(String token){
threadLocal.set(token);
}
public String getToken(){
return threadLocal.get();
}
public void clear(){
threadLocal.remove();
}
}
4.5 创建OAuth2Filter类
@Component
@Scope("prototype")//在spring的IOC容器中使用多例模式创建实例
public class OAuth2Filter extends AuthenticatingFilter {
@Autowired
private RedisTemplate redisTemplate;//将token保存到redis
@Autowired
private ThreadLocalToken threadLocalToken;//保存token的封装类
@Value("${emos.jwt.cache-expire}")
private int expireDate;//令牌过期时间
@Autowired
private JWTUtil jwtUtil; //用于令牌加密的工具类
/**
* 生成token
* @param servletRequest
* @param servletResponse
* @return
* @throws Exception
*/
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest,
ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//从请求中获取token并进行判断
String tokenStr = getTokenByRequest(request);
if(StrUtil.isBlank(tokenStr)){
return null;
}
return new OAuth2Token(tokenStr);
}
/**
* 是否允许过滤
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response,
Object mappedValue) {
// 判断是否是Option请求
HttpServletRequest req = (HttpServletRequest) request;
if(req.getMethod().equals(HttpMethod.OPTIONS.name())){
return true;//放过OPTIONS请求
}
return false;
}
/**
* 处理所有应该有shiro处理的请求
* 也就是不被放过的请求
* @param servletRequest
* @param servletResponse
* @return
* @throws Exception
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse)
throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
response.setHeader("Content-Type","text/html;charset=UTF-8");
//允许跨域请求
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", response.getHeader("Origin"));
//清空当前ThreadLocalToken中的token,因为我们后面可能要新生成token
threadLocalToken.clear();
//从请求中获取令牌
String tokenStr = getTokenByRequest(request);
//如果令牌为空,在响应中返回前端相关信息
if(StrUtil.isBlank(tokenStr)){
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("no useful token");
return false;
}
//如果令牌存在,刷新令牌
try{
//校验令牌
jwtUtil.verifierToken(tokenStr);
}catch (TokenExpiredException e) { //发现令牌过期
//去redis中找
if (redisTemplate.hasKey("token")) {
//redis存在,重新为客户生成一个新的token
redisTemplate.delete("token");
int userId = jwtUtil.getUserId(tokenStr);
String token = jwtUtil.createToken(userId);
redisTemplate.opsForValue().set(token, userId + "", expireDate, TimeUnit.DAYS);
threadLocalToken.setToken(token);
}
//redis令牌不存在,需要重新登录
else {
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("令牌已经过期");
return false;
}
}catch (JWTDecodeException e){
response.setStatus(HttpStatus.SC_UNAUTHORIZED);
response.getWriter().print("无效的令牌");
return false;
}
boolean bool = executeLogin(request, response);
return bool;
}
/**
* 处理登录失败的情况
* @param token
* @param e
* @param request
* @param response
* @return
*/
@Override
protected boolean onLoginFailure(AuthenticationToken token,
AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.setContentType("application/json;charset=utf-8");
resp.setHeader("Access-Control-Allow-Credentials", "true");
resp.setHeader("Access-Control-Allow-Origin", req.getHeader("Origin"));
try {
resp.getWriter().print(e.getMessage());
} catch (IOException exception) {
}
return false;
}
private String getTokenByRequest(HttpServletRequest httpServletRequest){
String token = httpServletRequest.getHeader("token");
if(StrUtil.isBlank(token)){
//如果请求头中不存在就去请求体中获取
token = httpServletRequest.getParameter("token");
}
return token;
}
}
4.6 创建ShiroConfig类
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm);
securityManager.setRememberMeManager(null);
return securityManager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
OAuth2Filter oAuth2Filter) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
HashMap<String, Filter> filters = new HashMap<>();
filters.put("oauth2", oAuth2Filter);
shiroFilter.setFilters(filters);
//因为LinkedHashMap可以保证存取得有序性
LinkedHashMap<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("/**", "oauth2");
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor(){
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
4.7 利用AOP,把更新的令牌返回给客户端
@Aspect
@Component
public class tokenAop {
@Autowired
private ThreadLocalToken localToken;
@Pointcut("execution(public * com.example.emos.wx.controller.*.*(..)))")
public void aspect(){
}
@Around("aspect()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable{
R r = (R) proceedingJoinPoint.proceed();
String token = localToken.getToken();
if(token != null){
r.put("token",token);
localToken.clear();
}
return r;
}
}
这样就完成了我们Jwt+Shiro和我们项目的集成!!