1.新建一个springboot项目
2.引入相关maven依赖
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.13</version>
</dependency>
<!--swagger2-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
<!--validation-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.0</version>
</dependency>
<!--shiro、JWT-->
<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.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.yml配置
server:
tomcat:
uri-encoding: UTF-8
threads:
max: 200
min-spare: 30
connection-timeout: 5000ms
port: 8080
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/springboot_shiro_jwt?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: mima
initial-size: 8 #连接池初始连接数
max-active: 16 #连接池最大连接数
min-idle: 8 #连接池最小连接数
max-wait: 60000 #最大等待时间 单位:毫秒
test-while-idle: true
test-on-borrow: false
test-on-return: false
#redis配置
redis:
database: 0
host: localhost
port: 6379
password:
jedis:
pool:
max-active: 1000 #redis连接池连接数量上限
max-wait: -1ms
max-idle: 16 #最大空闲连接数
min-idle: 8 #最小空闲连接数
mybatis:
mapper-locations: classpath*:mapper/*.xml
type-aliases-package: com.hds.shiro_jwt_swagger2.pojo
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
emos:
jwt:
secret: abc123456 #密钥
expire: 5 #令牌过期时间
cache-expire: 10 #令牌缓存时间
4.自定义异常
①定义统一返回对象R
public class R extends HashMap<String, Object> {
public R() {
put("code", HttpStatus.SC_OK);
put("msg", "success");
}
public R put(String key, Object value) {
super.put(key, value);
return this;
}
public static R ok() {
return new R();
}
public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}
public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}
public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}
public static R error(String msg) {
return R.error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}
public static R error() {
return R.error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员!");
}
}
②定义ResponseEnum
@Getter
public enum ResponseEnum {
ERROR(-1, "服务端错误"),
NOHANDLERFOUND(404, "404"),
SUCCESS(0, "成功"),
PASSWORD_ERROR(1, "密码错误"),
USERNAME_EXIST(2, "用户名已存在"),
PARAM_ERROR(3, "参数错误"),
EMAIL_EXIST(4, "邮箱已存在"),
CODE_OUT(8, "验证码过期"),
CODE_ERROR(9, "验证码错误"),
NEED_LOGIN(10, "用户未登录, 请先登录"),
USERNAME_OR_PASSWORD_ERROR(11, "用户名或密码错误"),
NO_PERMISSION(12, "你不具备相关权限"),
;
Integer code;
String desc;
ResponseEnum(Integer code, String desc) {
this.code = code;
this.desc = desc;
}
}
③定义MyException继承自RuntimeException
/**
* RunTimeException:运行时异常,又称不受检查异常,不受检查!
* 因为不受检查,所以在代码中可能会有RunTimeException时Java编译检查时不会告诉你有这个异常,但是在实际运行代码时则会暴露出来,比如经典的1/0,空指针等。如果不处理也会被Java自己处理。
*/
@Data
public class MyException extends RuntimeException {
private int code = 500;
private String msg;
public MyException(String msg) {
super(msg);
this.msg = msg;
}
public MyException(String msg, int code) {
super(msg);
this.msg = msg;
this.code = code;
}
}
④定义ExceptionAdvice捕获全局异常
@RestControllerAdvice
public class ExceptionAdvice {
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)/*出现异常,返回的状态码500*/
@ExceptionHandler(Exception.class)
public R exceptionHandler(Exception e){
if(e instanceof MethodArgumentNotValidException){/*参数异常*/
MethodArgumentNotValidException exception= (MethodArgumentNotValidException) e;
return R.error(ResponseEnum.PARAM_ERROR.getCode(),exception.getBindingResult().getFieldError().getDefaultMessage());
}
else if(e instanceof MyException){
MyException exception= (MyException) e;
return R.error(exception.getCode(),exception.getMsg());
}
else if(e instanceof UnauthorizedException){
return R.error(ResponseEnum.NO_PERMISSION.getCode(),ResponseEnum.NO_PERMISSION.getDesc());
}
else{
return R.error(ResponseEnum.ERROR.getCode(),ResponseEnum.ERROR.getDesc());
}
}
}
5.集成swagger
创建SwaggerConfig对象
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket createRestApi() {
Docket docket = new Docket(DocumentationType.SWAGGER_2);//指定api类型为swagger2
ApiInfoBuilder builder = new ApiInfoBuilder();
builder.title("shiro_jwt_swagger2"); // 文档页标题
ApiInfo info = builder.build();
docket.apiInfo(info);
ApiSelectorBuilder selectorBuilder = docket.select();
selectorBuilder.paths(PathSelectors.any());
selectorBuilder.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class));
docket = selectorBuilder.build();
//全站统一header设置 请求头的token
ApiKey apiKey = new ApiKey("token", "token", "header");
List<ApiKey> apiKeyList = new ArrayList<>();
apiKeyList.add(apiKey);
docket.securitySchemes(apiKeyList);
//全局Authorization参数
AuthorizationScope scope = new AuthorizationScope("global", "accessEverything");
AuthorizationScope[] scopes = {scope};
SecurityReference reference = new SecurityReference("token", scopes);
List refList = new ArrayList();
refList.add(reference);
SecurityContext context = SecurityContext.builder().securityReferences(refList).build();
List cxtList = new ArrayList();
cxtList.add(context);
docket.securityContexts(cxtList);
return docket;
}
}
6.配置后端验证
生成实体类,dao层,idea配置database,通过mybatis-generator插件自动生成代码
7.整合jwt+shiro
shiro:认证(登录核验身份)+授权(划分用户行为)框架。
shiro是利用过滤器(Filter),对每个Http请求进项过滤从而完成认证与授权的
①创建JwtUtil类,用来生成token以及验证token
/**
* JSON Web Token由三部分组成,它们之间用圆点(.)连接。这三部分分别是:
* xxxxx.yyyyy.zzzzz
* Header
* Payload
* Signature
*/
@Component
public class JwtUtil {
@Value("${conf.jwt.secret}")
private String secret;
@Value("${conf.jwt.expire}")
private int expire;
/**
* 创建token
*
* @param userId
* @return
*/
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;
}
/**
* 通过token获取userid
*
* @param token
* @return
*/
public int getUserId(String token) {
DecodedJWT jwt = JWT.decode(token);
int userId = jwt.getClaim("userId").asInt();
return userId;
}
/**
* 验证token
*
* @param token
*/
public void verifierToken(String token) {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm).build();
verifier.verify(token);
}
}
②创建OAuth2Token类
客户端提交的token字符串不能直接交给shiro框架,需要先封装成AuthenticationToken对象交给shiro。
/**
* 认证对象
* 把token字符串封装成shiro所需要的认证对象
*/
public class OAuth2Token implements AuthenticationToken {
private String token;
private OAuth2Token(String token){
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
③创建OAuth2Realm类
OAuth2Realm类是AuthorizingRealm的实现类,主要完成认证和授权的功能。
/**
* Realm类 定义shiro认证和授权的方法
*/
@Component
public class OAuth2Realm extends AuthorizingRealm {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserService userService;
/**
*
* @param token
* @return
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof OAuth2Token;
}
/**
* 授权(验证权限时调用)
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection collection) {
//得到用户信息
TbUser user= (TbUser) collection.getPrimaryPrincipal();
int userId=user.getId();
Set<String> permsSet=userService.searchUserPermissions(userId);
SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();//授权对象
//查询用户的权限列表
//把权限列表添加到Info对象中
info.setStringPermissions(permsSet);
return info;//返回授权对象
}
/**
* 认证(验证是否登录时调用)
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//从令牌获取userId,然后检测该账户是否被冻结
String accessToken=(String)token.getPrincipal();
int userId=jwtUtil.getUserId(accessToken);
TbUser user=userService.searchById(userId);
if(user==null){
throw new LockedAccountException("账号已被锁定,请联系管理员");
}
//向info对象添加用户信息、token字符串
//SimpleAuthenticationInfo(用户信息,令牌字符串,Realm的名字)
SimpleAuthenticationInfo info=new SimpleAuthenticationInfo(user,accessToken,getName());
return info;
}
}
8.设计令牌续期机制
缓存令牌机制,将令牌缓存在Redis上面,缓存的令牌过期时间是客户端令牌过期时间的一倍,如果客户端令牌过期,缓存令牌没有过期,就生成新的令牌返回给客户端,并且服务端缓存新的令牌。如果客户端令牌过期,缓存令牌也过期了,则用户必须重新登录。客户端需要检测响应里面是否含有token,如果有token,就覆盖token。
如何在响应添加令牌?
定义OAuth2Filter类拦截所有HTTP请求,一方面它会把请求中的Token字符串提取出来,封装成对象交给shiro框架;另一方面,它会检查Token的有效性。如果Token过期,就会生成新的Token,分别存储在ThreadLocalToken和Redis中。
之所以要把新令牌保存到ThreadLocalToken里面,是因为要向AOP切面类传递这个新令牌。AOP切面类判断ThreadLocalToken里面是否有值,有值就会向返回R对象添加token,然后客户端需要检测响应里面是否含有token,如果有token,就覆盖token。
①创建ThreadLocalToken类
@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();
}
}
②创建OAuth2Filter类
因为OAuth2Filter类中要读写ThreadLocal中的数据,所以OAuth2Filter类必须设置成多例的!
@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;
/**
* 拦截请求之后,用于把令牌字符串封装成令牌对象
* 该令牌对象会交给Shiro使用
* @param request
* @param response
* @return
* @throws Exception
*/
@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处理
* @param request
* @param response
* @param mappedValue
* @return
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
HttpServletRequest req= (HttpServletRequest) request;
//ajax提交application/json请求的时候,会先发出option请求
//这里要放行option请求,不需要shiro处理
if(req.getMethod().equals(RequestMethod.OPTIONS.name())){
//放行
return true;
}
//除了option请求之外,所有请求都要被Shiro处理
return false;
}
/**
* isAccessAllowed执行后,判断需要被shiro处理的才会执行该方法
* 判断token是真过期还是假过期
*
* @param request
* @param response
* @return
* @throws Exception
*/
@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"));
//清空threadLocal
threadLocalToken.clear();
String token=getRequestToken(req);
if(StrUtil.isBlank(token)){
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("无效的令牌");
return false;
}
try{
//验证token是否有效,如果验证有问题(1.令牌内容有问题、2.令牌过期)就会抛出异常
jwtUtil.verifierToken(token);
}catch (TokenExpiredException e){//令牌过期异常
//判断redis是否还有令牌
if(redisTemplate.hasKey(token)){
//redis里面存在令牌,说明客户端的令牌过期,但是redis令牌还未过期
//删除redis的 旧令牌
//获取userid并生成新的令牌,并存进redis和threadLocalToken
redisTemplate.delete(token);
int userId=jwtUtil.getUserId(token);
token=jwtUtil.createToken(userId);
redisTemplate.opsForValue().set(token,userId+"",cacheExpire, TimeUnit.DAYS);
threadLocalToken.setToken(token);
}
else{//客户端令牌过期了,并且服务端的令牌也过期了,此时需要重新登陆
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("令牌已过期");
return false;
}
}catch (Exception e){
resp.setStatus(HttpStatus.SC_UNAUTHORIZED);
resp.getWriter().print("无效的令牌");
return false;
}
//验证通过
//执行Realm类
boolean bool=executeLogin(request,response);
return bool;
}
/**
* shiro执行Realm类里面的认证(只有认证失败才进来)方法后,判断用户未登录或者登录失败,则会执行该方法。
* 该方法往客户端返回一个错误消息即可
* @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.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;
}
/**
* 管理拦截和响应的Filter
* @param request
* @param response
* @param chain
* @throws ServletException
* @throws IOException
*/
@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
* @param request
* @return
*/
private String getRequestToken(HttpServletRequest request){
//从请求头获取token
String token=request.getHeader("token");
if(StrUtil.isBlank(token)){
//从请求体获取token
token=request.getParameter("token");
}
return token;
}
}
③创建ShiroConfig类,把Filter和Realm添加到Shiro框架
@Configuration
public class ShiroConfig {
/**
* 用于封装Realm对象
*
* @param realm
* @return
*/
@Bean("securityManager")
public SecurityManager securityManager(OAuth2Realm realm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(realm);
securityManager.setRememberMeManager(null);
return securityManager;
}
/**
* 用于封装Filter对象,设置Filter拦截路径
* @param securityManager
* @param filter
* @return
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, OAuth2Filter filter) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
//oauth过滤
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");//对应上面的map的oauth2
shiroFilter.setFilterChainDefinitionMap(filterMap);
return shiroFilter;
}
/**
* 管理shiro对象生命周期
* @return
*/
@Bean("lifecycleBeanPostProcessor")
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* AOP切面类 Web方法执行前,验证权限
* 用于调用前判断 必须满足某角色某权限
* @param securityManager
* @return
*/
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
}
④利用aop切面向客户端返回新的令牌
拦截web方法返回的R对象,然后向R对象添加Token。
创建TokenAspect类
@Aspect
@Component
public class TokenAspect {
@Autowired
private ThreadLocalToken threadLocalToken;
/**
* 切点,需要被拦截的点
*/
@Pointcut("execution(public * com.hds.shiro_jwt_swagger2.controller.*.*(..))")
public void aspect() {
}
/**
* 针对切点定义环绕事件
* 获得拦截方法换回的R对象,向R对象put token
*
* @param point
* @return
* @throws Throwable
*/
@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;
}
}