JWT: https://jwt.io/introduction/
1. 什么是JWT
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
目前,jwt一般用于跨域的用户认证,是一种基于JSON的开发标准,由于数据是可以经过签名加密的,比较安全可靠。
2. 传统的session认证 与 JWT
传统的session认证
由于Http协议是一种无状态的协议,服务器无法识别是哪个用户发出的请求。这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行。为了识别是哪个用户发出的请求,我们只能在服务器用session存储一份用户登录的信息,对应的sessionId会在响应时传递给浏览器写到cookie中。用户以后的请求,都会携带cookie发送到服务器。服务器根据cookie中保存的sessionId,找到对应session中存储的用户信息,这样就能识别请求来自哪个用户了。这就是传统的基于session认证。
基于session认证所显露的问题
Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性: 对于分布式架构的支持以及扩展性不是很好。而且session是保存在内存中,单台服务器部署如果登陆用户过多占用服务器资源也多,做集群必须得实现session共享的话,集群数量又不易太多,否则服务器之间频繁同步session也会非常耗性能。
CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
JWT认证
-
用户使用用户名跟密码请求登录;
-
服务端收到请求,去验证用户名与密码,验证成功后,服务器使用私钥创建一个jwt;
-
服务器把这个 JWT发送给客户端;
-
客户端每次向服务端请求资源的时候都会在请求头中带着该JWT;
-
服务端收到请求,然后去验证客户端请求里面带着的 Token;
-
如果验证成功,就向客户端返回请求的数据
JWT 优点
- 简洁(Compact): 可以通过
URL
,POST
参数或者在HTTP header
发送,因为数据量小,传输速度也很快 - 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
- 因为
Token
是以JSON
加密的形式保存在客户端的,所以JWT
是跨语言的,原则上任何web形式都支持。 - 不需要在服务端保存会话信息,特别适用于分布式微服务。
传统token方式和jwt在认证方面的差异
-
传统token方式
用户登录成功后,服务端生成一个随机token给用户,并且在服务端(数据库或缓存)中保存一份token,以后用户再来访问时需携带token,服务端接收到token之后,去数据库或缓存中进行校验token的是否超时、是否合法。
-
jwt方式
用户登录成功后,服务端通过jwt生成一个随机token给用户(服务端无需保留token),以后用户再来访问时需携带token,服务端接收到token之后,通过jwt对token进行校验是否超时、是否合法。
3. JWT 结构
jwt的生成token格式如下,即:由 .
连接的三部分组成,分别是Header,Payload,Signature。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header
Header包含算法(例如 HMAC SHA256 or RSA)和token类型,这里就是JWT。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
然后,这个JSON被编码为Base64Url,就是JWT的第一部分。
Payload
Payload是JWT主体,是有关实体(例如,用户)和其他数据的声明。
标准中注册的声明 :(建议但不强制使用)
iss
: jwt签发者
sub
: 面向的用户(jwt所面向的用户)
aud
: 接收jwt的一方
exp
: 过期时间戳(jwt的过期时间,这个过期时间必须要大于签发时间)
nbf
: 定义在什么时间之前,该jwt都是不可用的.
iat
: jwt的签发时间
jti
: jwt的唯一身份标识,主要用来作为一次性token
,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64
是对称解密的,意味着该部分信息可以归类为明文信息。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后,经过Base64Url编码,就是JWT的第二部分。
Signature
Signature 是 把 Header 和 Payload(编码后的)的通过.
拼接起来,然后使用secret和Header中的加密算法进行加密,成为JWT的第三部分。将加密结果作为signature连接在最后。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
signature用于验证消息在此过程中没有更改,并且对于使用私钥进行签名的令牌,它还可以验证JWT的发送者是它所说的真实身份。
最后将三段字符串通过 .
拼接起来就生成了JWT的token。
base64url加密:先做base64加密,然后再将
-
替代+
,_
替代/
,删掉=
。因为这个三个字符在 URL 中有特殊含义。
4. JWT的验证
-
用户提交用户名,密码进行登录;
-
服务端验证通过,服务器端生成Token字符串,返回到客户端。
-
客户端保存Token,下一次请求资源时,附带上Token信息
-
服务器端获得Token之后,会按照以下步骤进行校验:
- 将token分割成
header_segment
、payload_segment
、crypto_segment
三部分 - 对第一部分
header_segment
进行base64url解密,得到header
- 对第二部分
payload_segment
进行base64url解密,得到payload
- 对第三部分
crypto_segment
进行base64url解密,得到signature
- 对第三部分
signature
部分数据进行合法性校验- 拼接前两段密文,即:
signing_input
- 从第一段明文中获取加密算法,默认:
HS256
- 使用 算法+盐 对
signing_input
进行加密,将得到的结果和signature
密文进行比较。如果相等,表示token未被修改过。(认证通过)
- 拼接前两段密文,即:
- 将token分割成
5. JWT应用
1.导入依赖
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.8.2</version>
</dependency>
2.定义注解
需要登录才能进行操作的注解WxLoginToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface WxLoginToken {
boolean required() default true;
}
用来跳过验证的PassToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
boolean required() default true;
}
3.生成Token
/**
* 生成微信小程序 token
*
* @param openId wx openId
* @param secret 小程序token密钥
* @return token
*/
public static String signWx(String openId, String secret, Integer wxUserId) {
try {
Map<String, Object> map = new HashMap<>();
map.put("alg", "HS256");
map.put("typ", "JWT");
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create()
.withHeader(map) // header
.withClaim("wxOpenId", openId) // payload
.withSubject(String.valueOf(wxUserId)) //jwt所面向的用户
// .withExpiresAt(date) // expire time
.sign(algorithm); // signature
} catch (Exception e) {
log.error("error:{}", e);
return null;
}
}
没有直接用JWT的过期时间,而是将其存入了redis。
4.接下来需要写一个拦截器去获取Token并验证Token
public class WxAuthenticationInterceptor implements HandlerInterceptor {
@Autowired
private IWxUserService wxUserService;
@Resource
private RedisUtil redisUtil;
@Autowired
private UofferProperties properties;
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws UofferTokenException {
String encryptToken = httpServletRequest.getHeader("WXAuthorization");// 从 http 请求头中取出 token
// token 解密
String token = UofferUtil.decryptToken(encryptToken);
String ip = IPUtil.getIpAddr(httpServletRequest);
// 如果不是映射到方法直接通过
if (!(object instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) object;
Method method = handlerMethod.getMethod();
//检查是否有passtoken注释,有则跳过认证
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
//检查有没有需要用户权限的注解
if (method.isAnnotationPresent(WxLoginToken.class)) {
WxLoginToken wxLoginToken = method.getAnnotation(WxLoginToken.class);
if (wxLoginToken.required()) {
// 执行认证
if (encryptToken == null) {
throw new UofferTokenException("未认证,请在前端系统进行认证");
}
// 获取 token 中的 userId
String openId = JWTUtil.getWxOpenId(token);
WxUser wxUser = wxUserService.selectByOpenId(openId);
if (wxUser == null) {
throw new UofferTokenException("用户不存在,请重新登录");
}
// 验证 token
boolean verify = JWTUtil.verifyWx(token, wxUser.getOpenId(), Constant.RM_WX_SECRET);
if (!verify) {
throw new UofferTokenException("验证token失败");
} else {
// 验证redis中的token
if (redisUtil.hasKey(Constant.RM_WX_TOKEN_CACHE + openId)) {
String redisToken = redisUtil.get(Constant.RM_WX_TOKEN_CACHE + openId);
if (!redisToken.equalsIgnoreCase(encryptToken)) {
throw new UofferTokenException("验证token失败");
}
// 刷新redis中的缓存
redisUtil.set(Constant.RM_WX_TOKEN_CACHE + openId, encryptToken, properties.getShiro().getWxTokenTimeOut());
return true;
}
}
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
5.配置拦截器
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authenticationInterceptor())
.addPathPatterns("/wxApi/**");// 拦截所有请求,通过判断是否有 @LoginRequired 注解 决定是否需要登录
}
@Bean
public HandlerInterceptor authenticationInterceptor() {
return new WxAuthenticationInterceptor();
}
}
6.登陆
@RestController
@RequestMapping("/wxApi/auth")
@Slf4j
@Api(tags = "wx 登录相关接口")
public class WxAuthController {
@Value("${wx.appId}")
private String appId;
@Value("${wx.appSecret}")
private String appSecret;
@Autowired
private RedisUtil redisUtil;
@Autowired
private UofferProperties properties;
@Autowired
private IWxUserService wxUserService;
@PostMapping("/doLogin")
@ApiOperation(value = "登录")
public Object loginByWeixin(@RequestBody WxLoginInfo wxLoginInfo, HttpServletRequest request) {
String code = wxLoginInfo.getCode();
UserInfo userInfo = wxLoginInfo.getUserInfo();
// 校验参数是否为空
if (code == null || userInfo == null) {
return ResponseUtil.badArgument();
}
String sessionKey = null;
String openid = null;
String data = "appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code";
Map<String, Object> map = new HashMap<String, Object>();
String res = HttpRequestUtil.sendGet("https://api.weixin.qq.com/sns/jscode2session", data);
JSONObject json = JSONObject.parseObject(res);
log.info(json.toJSONString());
sessionKey = json.getString("session_key");
openid = json.getString("openid");
if (sessionKey == null || openid == null) {
return ResponseUtil.fail();
}
// 根据openId查询是否有该用户
WxUser user = new WxUser();
Date now = new Date();
WxUser user1 = wxUserService.selectByOpenId(openid);
JWTToken jwtToken = null;
LocalDateTime expireTime = LocalDateTime.now().plusSeconds(properties.getShiro().getJwtTimeOut());
if (user1 == null) {
log.info("openid:" + openid);
user.setOpenId(openid);
user.setAvatar(userInfo.getAvatarUrl());
user.setNickName(userInfo.getNickName());
user.setGender(userInfo.getGender());
user.setLastLoginTime(now);
user.setLastLoginIp(IPUtil.getIpAddr(request));
user.setCreateTime(now);
// 将用户信息存入数据库中
wxUserService.save(user);
String sign = JWTUtil.signWx(openid, Constant.RM_WX_SECRET, user.getId());
String token = UofferUtil.encryptToken(sign);
String expireTimeStr = DateUtil.formatFullTime(expireTime);
jwtToken = new JWTToken(token, expireTimeStr);
// token 存入redis
redisUtil.set(Constant.RM_WX_TOKEN_CACHE + openid, jwtToken.getToken(), properties.getShiro().getWxTokenTimeOut());
} else {
// 已经登录过的用户修改上次登录时间和上次登录Ip
user1.setLastLoginTime(now);
user1.setLastLoginIp(IPUtil.getIpAddr(request));
wxUserService.updateById(user1);
// token
String sign = JWTUtil.signWx(openid, Constant.RM_WX_SECRET, user1.getId());
String token = UofferUtil.encryptToken(sign);
String expireTimeStr = DateUtil.formatFullTime(expireTime);
jwtToken = new JWTToken(token, expireTimeStr);
// token 存入redis
redisUtil.set(Constant.RM_WX_TOKEN_CACHE + openid, jwtToken.getToken(), properties.getShiro().getWxTokenTimeOut());
}
Map<Object, Object> result = new HashMap<Object, Object>();
result.put("token", jwtToken.getToken());
result.put("tokenExpire", jwtToken.getExipreAt());
result.put("userInfo", userInfo);
result.put("openId", openid);
return ResponseUtil.ok(result);
}
}
不加注解的话默认不验证,登录接口一般是不验证的。在getMyResumeList()
中我加上了登录注解,说明该接口必须登录获取token
后,在请求头中加上token
并通过验证才可以访问
@GetMapping("/resume")
@WxLoginToken
@ApiOperation(value = "查询我的简历列表")
public ResultVo getMyResumeList(HttpServletRequest request) {
6.JWT在web应用中的缺陷
缺点一: 无法满足注销场景
传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但 jwt 的方案就比较难办了,因为 jwt 是无状态的,服务端通过计算来校验有效性。没有存储起来,所以即使客户端删除了 jwt,但是该 jwt 还是在有效期内,只不过处于一个游离状态。
缺点二: 无法满足修改密码场景
修改密码则略微有些不同,假设号被到了,修改密码(是用户密码,不是 jwt 的 secret)之后,盗号者在原 jwt 有效期之内依旧可以继续访问系统,所以仅仅清空 cookie 自然是不够的,这时,需要强制性的修改 secret。
缺点二: 无法满足token续签场景
我们知道微信只要你每天使用是不需要重新登录的,因为有token续签,因为传统的 cookie 续签方案一般都是框架自带的,session 有效期 30 分钟,30 分钟内如果有访问,session 有效期被刷新至 30 分钟。但是 jwt 本身的 payload 之中也有一个 exp 过期时间参数,来代表一个 jwt 的时效性,而 jwt 想延期这个 exp 就有点身不由己了,因为 payload 是参与签名的,一旦过期时间被修改,整个 jwt 串就变了,jwt 的特性天然不支持续签!
引用:
https://www.jianshu.com/p/e88d3f8151db