一、Shiro介绍
Shiro是Java领域非常知名的认证( Authentication)与授权( Authorization)框架,用以替代JavaEE中的JAAS功能。任意JavaWeb项目都可以使用Shiro框架,而Spring Security必须要使用在Spring项目中。所以Shiro的适用性更加广泛。
认证?
认证就是要核验用户的身份,比如说通过用户名和密码来检验用户的身份。说简单一些,认证就是登陆。登陆之后Shiro要记录用户成功登陆的凭证。
授权?
授权是比认证更加精细度的划分用户的行为。比如说一个教务管理系统中,学生登陆之后只能查看信息,不能修改信息。而班主任就可以修改学生的信息。这就是利用授权来限定不同身份用户的行为。
shiro如何认证与授权?
Shiro可以利用HttpSession或者Redis存储用户的登陆凭证,以及角色或者身份信息。然后利用过滤器(Filter) ,对每个HTTP请求过滤,检查请求对应的httppsession或者Redis中的认证与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。
二、JWT简介
JWT (Json Web Token),是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
JWT兼容更多的客户端
传统的HttpSession依靠浏览器的Cookie存放sessionId,所以要求客户端必须是浏览器。现在的JavaWeb系统,客户端可以是浏览器、APP、小程序,以及物联网设备。为了让这些设备都能访问到JavaWeb项目,就必须要引入JWT技术。JWT的 Token是纯字符串,至于客户端怎么保存,没有具体要求。只要客户端发起请求的时候,附带上Token即可。
三、创建JWT工具类
JWT的Token要经过加密才能返回给客户端,包括客户端上传的Token后端项目需要验证核实。于是我们需要一个JWT工具类,用来加密Token和验证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>
- 定义密钥和过期时间
将密钥和过期时间定义到spring boot配置文件中,通过注入到JavaBean中,维护更方便。
emos:
jwt:
#密钥
secret: abc123456
#令牌过期时间(天)
expire: 5
#令牌缓存时间(天数)
cache-expire: 10
- 创建JWT工具类
package com.example.emos.wx.config.shiro;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateTime;
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, expire).toJdkDate();
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);
return jwt.getClaim("userId").asInt();
}
//验证令牌
public void verifierToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
}
}
四、将令牌封装为认证对象
客户端提交的token无法直接交给shiro框架,需要先封装为AuthenticationToken类型的对象。
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;
}
}
五、创建OAuth2Realm类
OAuth2Realm类是AuthorizingRealm的实现类,我们要在这个实现类中定义认证和授权的方法。
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;
}
/**
* 授权
*
* @param collection
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection collection) {
TbUser user = (TbUser) collection.getPrimaryPrincipal();
Integer id = user.getId();
//用户权限列表
Set<String> permsSet = userService.searchUserPermissions(id);
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.setStringPermissions(permsSet);
return info;
}
/**
* 认证
*
* @param token
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String accessToken = (String) token.getPrincipal();
int userId = jwtUtil.getUserId(accessToken);
//查询用户信息
TbUser user = userService.searchById(userId);
if (user == null) {
throw new LockedAccountException("账户已被锁定,请联系管理员");
}
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, accessToken, getName());
return info;
}
}
六、创建ThreadLocalToken类
该类包含ThreadLocal类型的变量,可以用来保存线程安全的数据,且避免了使用线程锁。
package com.example.emos.wx.config.shiro;
import org.springframework.stereotype.Component;
@Component
public class ThreadLocalToken {
private ThreadLocal local=new ThreadLocal();
public void setToken(String token){
local.set(token);
}
public String getToken(){
return (String) local.get();
}
public void clear(){
local.remove();
}
}
七、创建OAuth2Filter类
在配置文件中,添加JWT需要用到的密钥、过期时间和缓存过期时间。
emos:
jwt:
#密钥
secret: abc123456
#令牌过期时间(天)
expire: 5
#令牌缓存时间(天数)
cache-expire: 10
package com.example.emos.wx.config.shiro;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import org.apache.commons.lang3.StringUtils;
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 {
//获取请求token
String token = getRequestToken((HttpServletRequest) request);
if (StringUtils.isBlank(token)) {
return null;
}
return new OAuth2Token(token);
}
/**
* 拦截请求,判断请求是否需要被Shiro处理
*/
@Override
protected boolean isAccessAllowed(ServletRequest request,
ServletResponse response, Object mappedValue) {
HttpServletRequest req = (HttpServletRequest) request;
// Ajax提交application/json数据的时候,会先发出Options请求
// 这里要放行Options请求,不需要Shiro处理
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
return true;
}
// 除了Options请求之外,所有请求都要被Shiro处理
return false;
}
/**
* 该方法用于处理所有应该被Shiro处理的请求
*/
@Override
protected boolean onAccessDenied(ServletRequest request,
ServletResponse response) throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
resp.setHeader("Content-Type", "text/html;charset=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((HttpServletRequest) request);
if (StringUtils.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); //生成新的令牌
//把新的令牌保存到Redis中
redisTemplate.opsForValue().set(token, userId + "", cacheExpire, TimeUnit.DAYS);
//把新令牌绑定到线程
threadLocalToken.setToken(token);
} else {
//如果Redis不存在令牌,让用户重新登录
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("令牌已经过期");
return false;
}
} catch (JWTDecodeException 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.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;
}
/**
* 获取请求头里面的token
*/
private String getRequestToken(HttpServletRequest httpRequest) {
//从header中获取token
String token = httpRequest.getHeader("token");
//如果header中不存在token,则从参数中获取token
if (StringUtils.isBlank(token)) {
token = httpRequest.getParameter("token");
}
return token;
}
@Override
public void doFilterInternal(ServletRequest request,
ServletResponse response, FilterChain chain) throws ServletException, IOException {
super.doFilterInternal(request, response, chain);
}
}
八、创建ShrioConfig类
把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 oAuth2Realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(oAuth2Realm);
securityManager.setRememberMeManager(null);
return securityManager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager,OAuth2Filter oAuth2Filter) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
//oauth过滤
Map<String, Filter> filters = new HashMap<>();
filters.put("oauth2", oAuth2Filter);
shiroFilter.setFilters(filters);
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("/**", "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;
}
}
九、利用AOP,把更新的令牌返回
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();
//如果ThreadLocal中存在Token,说明是更新的Token
if (token != null) {
r.put("token", token); //往响应中放置Token
threadLocalToken.clear();
}
return r;
}
}
精简返回给客户端的异常内容
package com.example.emos.wx.config;
import com.example.emos.wx.exception.EmosException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public String validExceptionHandler(Exception e) {
log.error("执行异常",e);
if (e instanceof MethodArgumentNotValidException) {
MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
//将错误信息返回给前台
return exception.getBindingResult().getFieldError().getDefaultMessage();
}
else if(e instanceof EmosException){
EmosException exception=(EmosException)e;
return exception.getMsg();
}
else if(e instanceof UnauthorizedException){
return "你不具有相关权限";
}
else {
return "后端执行异常";
}
}
}