JWT
jwt(json web token)是一种网络请求安全标准用来登录授权,对比session、cookie更快更安全,而且支持手机app和其他客户端使用。jwt的核心思路是当用户登录时服务端判断用户的登录信息是否合法,如何登录信息正确就在服务端生成一个token并把他返回给客户端。在客户端进行其他请求时带上token,服务端可以从上传的token提取用户的个人信息进行验证判断是否时合法登录和是否有访问权限。jwt的常用概念有创建token、token失效、刷新token。
传统Cookie+Session与JWT对比
- 在传统的用户登录认证中,因为http是无状态的,所以都是采用session方式。用户登录成功,服务端会保证一个session,当然会给客户端一个sessionId,客户端会把sessionId保存在cookie中,每次请求都会携带这个sessionId。
- cookie+session这种模式通常是保存在内存中,而且服务从单服务到多服务会面临的session共享问题,随着用户量的增多,开销就会越大。而JWT不是这样的,只需要服务端生成token,客户端保存这个token,每次请求携带这个token,服务端认证解析就可。
- JWT方式校验方式更加简单便捷化,无需通过redis缓存,而是直接根据token取出保存的用户信息,以及对token可用性校验,单点登录,验证token更为简单。
JWT优缺点
优点:
- 可扩展性好
应用程序分布式部署的情况下,Session需要做多机数据共享,通常可以存在数据库或者Redis里面。而JWT不需要。 - 无状态
JWT不在服务端存储任何状态。RESTful API的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外JWT的载荷中可以存储一些常用信息,用于交换信息,有效地使用 JWT,可以降低服务器查询数据库的次数。
缺点:
1.安全性:由于JWT的payload是使用Base64编码的,并没有加密,因此JWT中不能存储敏感数据。而Session的信息是存在服务端的,相对来说更安全。
2.性能:JWT太长。由于是无状态使用JWT,所有的数据都被放到JWT里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致JWT非常长,Cookie的限制大小一般是4k,cookie很可能放不下,所以JWT一般放在LocalStorage里面。并且用户在系统中的每一次Http请求都会把JWT携带在Header里面,Http请求的Header可能比Body还要大。而SessionId只是很短的一个字符串,因此使用JWT的Http请求比使用Session的开销大得多。
3.一次性:无状态是JWT的特点,但也导致了这个问题,JWT是一次性的。想修改里面的内容,就必须签发一个新的JWT。即缺陷是一旦下发,服务后台无法拒绝携带该jwt的请求(如踢除用户)
4.无法废弃:通过JWT的验证机制可以看出来,一旦签发一个JWT,在到期之前就会始终有效,无法中途废弃。例如你在payload中存储了一些信息,当信息需要更新时,则重新签发一个JWT,但是由于旧的jwt还没过期,拿着这个旧的JWT依旧可以登录,那登录后服务端从JWT中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的JWT,那么旧的就加入黑名单(比如存到redis里面),避免被再次使用。
5.续签:如果你使用jwt做会话管理,传统的Cookie续签方案一般都是框架自带的,Session有效期30分钟,30分钟内如果有访问,有效期被刷新至30分钟。一样的道理,要改变JWT的有效时间,就要签发新的JWT。最简单的一种方式是每次请求刷新JWT,即每个HTTP请求都返回一个新的JWT。这个方法不仅暴力不优雅,而且每次请求都要做JWT的加密解密,会带来性能问题。另一种方法是在Redis中单独为每个JWT设置过期时间,每次访问时刷新JWT的过期时间。可以看出想要破解JWT一次性的特性,就需要在服务端存储jwt的状态。但是引入 redis 之后,就把无状态的jwt硬生生变成了有状态了,违背了JWT的初衷。而且这个方案和Session都差不多了。
SpringBoot整合JWT
- 添加依赖
<!-- JWT依赖 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.7.0</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency>
- 配置
server: port: 8080 spring: application: name: springboot-jwt config: jwt: # 加密密钥 secret: abcdefg1234567 # token有效时长 expire: 3600 # header 名称 header: token
- 编写JwtConfig
package com.example.config; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import java.util.Date; /** * JWT的token,区分大小写 */ @ConfigurationProperties(prefix = "config.jwt") @Component public class JwtConfig { private String secret; private long expire; private String header; /** * 生成token * @param subject * @return */ public String createToken (String subject){ Date nowDate = new Date(); Date expireDate = new Date(nowDate.getTime() + expire * 1000);//过期时间 return Jwts.builder() .setHeaderParam("typ", "JWT") .setSubject(subject) .setIssuedAt(nowDate) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } /** * 获取token中注册信息 * @param token * @return */ public Claims getTokenClaim (String token) { try { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); }catch (Exception e){ // e.printStackTrace(); return null; /* catch (ExpiredJwtException e){ return e.getClaims(); //防止jwt过期解析报错 } */ } } /** * 验证token是否过期失效 * @param expirationTime * @return */ public boolean isTokenExpired (Date expirationTime) { return expirationTime.before(new Date()); } /** * 获取token失效时间 * @param token * @return */ public Date getExpirationDateFromToken(String token) { return getTokenClaim(token).getExpiration(); } /** * 获取用户名从token中 */ public String getUsernameFromToken(String token) { return getTokenClaim(token).getSubject(); } /** * 获取jwt发布时间 */ public Date getIssuedAtDateFromToken(String token) { return getTokenClaim(token).getIssuedAt(); } // --------------------- getter & setter --------------------- public String getSecret() { return secret; } public void setSecret(String secret) { this.secret = secret; } public long getExpire() { return expire; } public void setExpire(long expire) { this.expire = expire; } public String getHeader() { return header; } public void setHeader(String header) { this.header = header; } }
- 配置拦截器
package com.example.interceptor; import com.example.config.JwtConfig; import io.jsonwebtoken.Claims; import io.jsonwebtoken.SignatureException; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @Component public class TokenInterceptor extends HandlerInterceptorAdapter { @Resource private JwtConfig jwtConfig ; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws SignatureException { /** 地址过滤 */ String uri = request.getRequestURI() ; if (uri.contains("/login")){ return true ; } /** Token 验证 */ String token = request.getHeader(jwtConfig.getHeader()); if(StringUtils.isEmpty(token)){ token = request.getParameter(jwtConfig.getHeader()); } if(StringUtils.isEmpty(token)){ throw new SignatureException(jwtConfig.getHeader()+ "不能为空"); } Claims claims = null; try{ claims = jwtConfig.getTokenClaim(token); if(claims == null || jwtConfig.isTokenExpired(claims.getExpiration())){ throw new SignatureException(jwtConfig.getHeader() + "失效,请重新登录。"); } }catch (Exception e){ throw new SignatureException(jwtConfig.getHeader() + "失效,请重新登录。"); } /** 设置 identityId 用户身份ID */ request.setAttribute("identityId", claims.getSubject()); return true; } }
- 注册拦截器
package com.example.config; import com.example.interceptor.TokenInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import javax.annotation.Resource; @Configuration public class WebConfig implements WebMvcConfigurer { @Resource private TokenInterceptor tokenInterceptor ; public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor).addPathPatterns("/**"); } }
- 编写统一异常处理类
package com.example.config; import io.jsonwebtoken.SignatureException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.yuyi.full.handler.exception.ExceptionInfoBO; import org.yuyi.full.handler.exception.ResultBO; import org.yuyi.full.handler.exception.ResultTool; @RestControllerAdvice public class PermissionHandler { @ExceptionHandler(value = { SignatureException.class }) @ResponseBody public ResultBO<?> authorizationException(SignatureException e){ return ResultTool.error(new ExceptionInfoBO(1008,e.getMessage())); } }
- 编写测试接口
package com.example.controller; import com.alibaba.fastjson.JSONObject; import com.example.config.JwtConfig; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.yuyi.full.handler.exception.ResultBO; import org.yuyi.full.handler.exception.ResultTool; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @RestController public class TokenController { @Resource private JwtConfig jwtConfig ; /** * 用户登录接口 * @param userName * @param passWord * @return */ @PostMapping("/login") public ResultBO<?> login (@RequestParam("userName") String userName, @RequestParam("passWord") String passWord){ JSONObject json = new JSONObject(); /** 验证userName,passWord和数据库中是否一致,如不一致,直接return ResultTool.errer(); 【这里省略该步骤】*/ // 这里模拟通过用户名和密码,从数据库查询userId // 这里把userId转为String类型,实际开发中如果subject需要存userId,则可以JwtConfig的createToken方法的参数设置为Long类型 String userId = 5 + ""; String token = jwtConfig.createToken(userId) ; if (!StringUtils.isEmpty(token)) { json.put("token",token) ; } return ResultTool.success(json) ; } /** * 需要 Token 验证的接口 */ @PostMapping("/info") public ResultBO<?> info (){ return ResultTool.success("info") ; } /** * 根据请求头的token获取userId * @param request * @return */ @GetMapping("/getUserInfo") public ResultBO<?> getUserInfo(HttpServletRequest request){ String usernameFromToken = jwtConfig.getUsernameFromToken(request.getHeader("token")); return ResultTool.success(usernameFromToken) ; } /* 为什么项目重启后,带着之前的token还可以访问到需要info等需要token验证的接口? 答案:只要不过期,会一直存在,类似于redis */ }
自定义实现JWT
通过以上java-jwt实现的jwt可以发现一下几个问题。
1.token一旦生成服务端无法吊销,这就造成一旦出现token泄露服务端无法实现对token的控制造成安全漏洞。
2.无法做到对方法层面的权限控制,代码中仅实现了token的验证没有把token和用户角色属性乡关联。
3.开发不方便,没法用注解的方式控制每个方法的角色权限划分。
接下来我们用自定义的形式实现类似jwt的效果解决以上痛点。
- 创建用户角色枚举对象
package com.yfapi.api.model; import com.baomidou.mybatisplus.annotation.EnumValue; import com.fasterxml.jackson.annotation.JsonValue; public enum EnumUserRole { SYSTEM_ADMIN(11,"系统管理员"), ORDER_ADMIN(12,"业务管理员"), TOURIST(1,"游客"); @EnumValue private Integer role; @JsonValue private String roleName; EnumUserRole(Integer role,String roleName){ this.role=role; this.roleName=roleName; } public Integer value(){ return this.role; } public String roleName(){ return this.roleName; } public static EnumUserRole ofValue(Integer i) { for(EnumUserRole dt : EnumUserRole.values()) { if(dt.role == i) { return dt; } } throw new IllegalArgumentException("no EnumUserRole with " + i + " exists"); } }
- 自定义方法方法权限注解
package com.yfapi.api.model; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 方法权限限制注解 * 只有没注解或者在注解中的角色才可访问方法 * 默认游客角色 * Target标识注解到方法上 * Retention标识为运行时可访问 */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AuthRole { EnumUserRole[] value() default EnumUserRole.TOURIST; }
- 定义用户登录获取token的controller接口
package com.yfapi.api.controller; import com.alibaba.fastjson.JSON; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.yfapi.api.entity.Adminuser; import com.yfapi.api.mapper.AdminuserMapper; import com.yfapi.api.model.AccountLoginInput; import com.yfapi.api.model.ReturnNode; import com.yfapi.api.model.TokenInfo; import com.yfapi.api.model.EnumUserRole; import com.yfapi.api.util.RedisUtils; import com.yfapi.api.util.YFCommon; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.UUID; @SuppressWarnings("all") @RestController @Api(tags = "登录接口") public class AuthenticateController extends BaseController { @Autowired private AdminuserMapper adminuserMapper; @Autowired private RedisUtils redisUtils; @ApiOperation(value = "管理员登录", httpMethod = "POST") @RequestMapping("/api/authenticate/adminLogin") public ReturnNode GetAccessTokenByPass(@RequestBody AccountLoginInput input){ if(str_IsEmpty(input.getAccount())|| str_IsEmpty(input.getPass())) return ReturnNode.ReturnError("参数不能为空"); QueryWrapper<Adminuser> qw=new QueryWrapper<>(); qw.and(i->i.eq("account",input.getAccount()).eq("pass",YFCommon.MD5(input.getPass()))); Adminuser usr = adminuserMapper.selectOne(qw); if(usr==null) return ReturnNode.ReturnError("登录失败"); EnumUserRole role= EnumUserRole.ofValue(usr.getRole()); TokenInfo info=new TokenInfo(); info.setUserId(usr.getId()); info.setUserName(usr.getUserName()); info.setRole(role); info.setRoleStr(role.roleName()); String json = JSON.toJSONString(info); consoleLog(json); String uuid = (UUID.randomUUID()).toString().replaceAll("-",""); consoleLog(uuid); //把uuid作为key登录信息json作为值存入redis redisUtils.set(uuid,json,86400);//1天 //uuid就是token info.setToken(uuid); return ReturnNode.ReturnSuccess(info); } }
- 定义拦截器
package com.yfapi.api.config; import com.alibaba.fastjson.JSON; import com.yfapi.api.model.TokenInfo; import com.yfapi.api.model.AuthRole; import com.yfapi.api.util.RedisUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; import java.util.Arrays; /** * 自定义拦截器 */ @Component public class MyTokenAuthInterceptor implements HandlerInterceptor { @Autowired private RedisUtils redisUtils; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { System.out.println("执行拦截器"); String token = request.getHeader("token"); if(str_IsEmpty(token)) { throw new Exception("token错误!!!"); } String json = redisUtils.get(token); if(str_IsEmpty(json)) { throw new Exception("登录信息已失效!!!"); } //生成登录信息对象 TokenInfo info = JSON.parseObject(json,TokenInfo.class); // 判断该方法的访问权限,该角色权限等级是否能访问 HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); AuthRole role = method.getAnnotation(AuthRole.class); //判断用户角色是否在方法注解的角色组里 if(role!=null&&Arrays.asList(role.value()).contains(info.getRole())==false){ //抛出异常接口访问结束 throw new Exception("没有访问权限"); } //权限判断通过,将tokeninfo信息写入request请求中 request.setAttribute("tokeninfo",info); return HandlerInterceptor.super.preHandle(request, response, handler); } private boolean str_IsEmpty(String str) { if (str==null||str.isEmpty() || str.length() == 0) return true; else return false; } }
- 注册拦截器
package com.yfapi.api.config; import com.google.common.collect.Lists; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * 自定义拦截器注册类 */ @Configuration public class MyTokenAuthInterceptorConfig implements WebMvcConfigurer { @Autowired private MyTokenAuthInterceptor myTokenAuthInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { /** * 注册拦截器,除了common和authenticate接口其他的都要拦截判断token */ String[] excludePath = { "/api/authenticate/**", "/api/common/**" }; registry.addInterceptor(myTokenAuthInterceptor) .addPathPatterns("/api/**") .excludePathPatterns(Lists.newArrayList(excludePath)); WebMvcConfigurer.super.addInterceptors(registry); } }
- 其他业务型controller接口
@ApiOperation(value = "获取个人信息",httpMethod = "POST") @RequestMapping("/getUserInfo") @AuthRole({EnumUserRole.SYSTEM_ADMIN})//可以访问的角色注解 public ReturnNode getUserInfo() throws Exception { var tokenInfo = getAuthTokenInfo(); var userId=tokenInfo.getUserId(); return ReturnNode.ReturnSuccess(userId); }
可以看到通过注册拦截器把 /api/authenticate/… 和 /api/common/… 之外的接口全部进行拦截进行权限验证,token存在redis中可以在服务端灵活控制token是否失效,而在业务代码开发中通过自定义注解@AuthRole可以非常灵活的设置1个或多个或全部角色访问方法。以上代码是实现了系统中固定的角色的权限限制,而大部分业务的角色是动态分配的,这就可以在拦截器中使用数据库查询的方法判断登录的角色对某个方法是否有访问权限,角色和方法的权限关系配置在数据库中。