含义:无状态,就是服务端不保存用户的登录状态,既不把用户登录信息保存在内存里,也不把信息保存在第三方存储。用户做完登录,给用户发一个Token,用户每次请求都带上这个Token,放在Header里或者请求参数里,服务端拿到这个Token做解析,如果合法且未失效,就认为是登录的。Token里可以存放一些不敏感的用户信息,例如用户Id,姓名之类的。不要带手机号,这信息很敏感
图示:
弊端:token发放出去,服务器端就无法控制这个token了,不能让它马上失效,不能让这个用户立刻处于下线状态,毫无掌控力
认证过程:用户的登录授权,Token的解析判断,全网关里实现。后面的微服务不再判断这个请求有没有登录了
优点:逻辑简单,性能好
缺点:后面微服务毫不设防,有风险
逻辑:网关不再关心业务,做自己的正事,过滤和转发。Token由专门的认证中心颁发,每个微服务在被请求的时候,都要校验Token的合法性,不用担心影响性能,现在的算法很快,毫秒级别的
优点:微服务分工更明确,网关不插手业务
缺点:每个微服务都要校验Token,秘钥到处用,增加泄露风险
其实可行的方案远不止这3种,不管你怎么实现,反正宗旨是能判断出用户有没有登录就行了
JWT 的表现形式是长长的一串字符串
由于JWT每个微服务都要用到,干脆新建个工具类项目吧,微服务们依赖这个common项目就好了。项目gitee地址:https://gitee.com/zengchen2016/common
4.0.0
com.zc
common
0.0.1-SNAPSHOT
io.jsonwebtoken
jjwt-api
0.10.7
io.jsonwebtoken
jjwt-impl
0.10.7
runtime
io.jsonwebtoken
jjwt-jackson
0.10.7
runtime
org.apache.maven.plugins
maven-compiler-plugin
8
package com.zengchen.common.utils;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
public class JWTUtils {
/**
* 从token中获取claim
*
* @param token token
* @param secret secret 密钥
* @return claim
*/
public static Claims getClaimsFromToken(String token, String secret) {
try {
return Jwts.parser()
.setSigningKey(secret.getBytes())
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
throw new IllegalArgumentException("Token invalided.");
}
}
/**
* 获取token的过期时间
*
* @param token token
* @return 过期时间
*/
public static Date getExpirationDateFromToken(String token, String secret) {
return getClaimsFromToken(token, secret)
.getExpiration();
}
/**
* 判断token是否过期
*
* @param token token
* @return 已过期返回true,未过期返回false
*/
private static Boolean isTokenExpired(String token, String secret) {
Date expiration = getExpirationDateFromToken(token, secret);
return expiration.before(new Date());
}
/**
* 计算token的过期时间
*
* @return 过期时间
*/
private static Date getExpirationTime(Long expirationTimeInSecond) {
return new Date(System.currentTimeMillis() + expirationTimeInSecond * 1000);
}
/**
* 为指定用户生成token
*
* @param claims 用户信息
* @return token
*/
public static String generateToken(Map claims, String secret, Long expirationTimeInSecond) {
Date createdTime = new Date();
Date expirationTime = getExpirationTime(expirationTimeInSecond);
byte[] keyBytes = secret.getBytes();
SecretKey key = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(createdTime)
.setExpiration(expirationTime)
// 你也可以改用你喜欢的算法
// 支持的算法详见:https://github.com/jwtk/jjwt#features
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 判断token是否非法
*
* @param token token
* @return 未过期返回true,否则返回false
*/
public static Boolean validateToken(String token, String secret) {
try {
return !isTokenExpired(token, secret);
}catch (Exception e){
return false;
}
}
/**
* 获取header或者payload
* @param encodedString
* @return
* @throws Exception
*/
public static String getInfo(String encodedString) throws Exception {
byte[] info = Base64Utils.decode(encodedString);
return new String(info);
}
}
package com.zengchen.common.utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class Base64Utils {
/**
*
* BASE64字符串解码为二进制数据
*
*
* @param base64
* @return
* @throws Exception
*/
public static byte[] decode(String base64) throws Exception {
return Base64.getMimeDecoder().decode(base64);
}
/**
*
* 二进制数据编码为BASE64字符串
*
*
* @param bytes
* @return
* @throws Exception
*/
public static String encode(byte[] bytes) throws Exception {
return Base64.getMimeEncoder().encodeToString(bytes);
}
/**
* 将字符串进行压缩并转换成base64字符
*
* @param data (非空字符串)
* @return 压缩将转换成base64的字符串
*/
public static String zipBase64(String data) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bos);
gzip.write(data.getBytes());
gzip.finish();
return Base64.getEncoder().encodeToString(bos.toByteArray());
}
/**
* 将字符串进行base64解码并进行解压
*
* @param data 被压缩并转换成base64的字符串(非空)
* @return
*/
public static String base64Unzip(String data) throws IOException {
ByteArrayInputStream bis = new ByteArrayInputStream(Base64.getDecoder().decode(data));
GZIPInputStream gzip = new GZIPInputStream(bis);
byte[] buf = new byte[16384];
int num = -1;
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
while ((num = gzip.read(buf, 0, buf.length)) != -1) {
bos.write(buf, 0, num);
}
bos.flush();
return new String(bos.toByteArray());
}
}
}
com.zc
common
0.0.1-SNAPSHOT
public static void main(String[] args) throws Exception {
Long expirationTimeInSecond = 3600L; // 一个小时
String secret = "aaabbbcccdddeeef1111111111111111111111111111111111111";
Map payloadMap = new HashMap<>();
payloadMap.put("id",1);
payloadMap.put("username","一粒尘埃");
String token = JWTUtils.generateToken(payloadMap,secret,expirationTimeInSecond);
log.info("token: "+token);
}
String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiLkuIDnspLlsJjln4MiLCJpYXQiOjE1NjcyNjI1NDUsImV4cCI6MTU2NzI2NjE0NX0.MyUgnwEc9SUapRxB7I7rM_1c4oqRnq98XNaXCEp8plU";
log.info("jwt 有效性: " + JWTUtils.validateToken(token,secret));
String header = JWTUtils.getInfo(token.split("\\.")[0]);
log.info("jwt header: " + header);
String payload = JWTUtils.getInfo(token.split("\\.")[1]);
log.info("jwt payload: " + payload);
LocalDateTime createDateTime =LocalDateTime.ofEpochSecond(1567262545,0, ZoneOffset.ofHours(8));
LocalDateTime expireDateTime =LocalDateTime.ofEpochSecond(1567266145,0, ZoneOffset.ofHours(8));
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
log.info("创建时间:"+ dtf.format(createDateTime));
log.info("失效时间:"+ dtf.format(expireDateTime));
我是用微信小程序登录的,openId是用户标识,这章代码,读者仅能参
考
package com.zengchen.user.controller;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.zengchen.common.bean.ResponseVO;
import com.zengchen.common.enums.ResponseCode;
import com.zengchen.common.utils.JWTUtils;
import com.zengchen.common.utils.OkhttpUtil;
import com.zengchen.user.entity.Member;
import com.zengchen.user.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
*
* 前端控制器
*
*
* @author zengchen123
* @since 2019-09-04
*/
@RestController
@Slf4j
public class LoginController {
@Value("${wx.appid}")
private String appid;
@Value("${wx.secret}")
private String secret;
@Value("${wx.code2Session_url}")
private String code2Session_url;
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expirationTimeInSecond}")
private String jwtExpirationTimeInSecond;
@Autowired
private MemberService memberService;
@PostMapping("login")
public Object login(@RequestBody String requestBody) {
Map responseData = new HashMap<>();
try {
log.info("login with requestBody={}", requestBody);
// 1,获取请求参数
JSONObject requestOjt = JSON.parseObject(requestBody);
String tempCode = requestOjt.getString("code");
JSONObject userInfo = requestOjt.getJSONObject("userInfo");
// 2,检查请求参数
if (StringUtils.isBlank(tempCode) || StringUtils.isBlank(userInfo.getString("gender"))) {
return ResponseVO.fail(ResponseCode.ERROR_PARAMS);
}
// 3.调用auth.code2Session,获取openid
String targetUrl = code2Session_url.replace("APPID", appid)
.replace("SECRET", secret).replace("JSCODE", tempCode);
log.info("auth.code2Session targetUrl = {}", targetUrl);
String response = OkhttpUtil.get(targetUrl);
log.info("auth.code2Session response = {}", response);
JSONObject responseOjt = JSON.parseObject(response);
String openid = responseOjt.getString("openid");
if (StringUtils.isBlank(openid)) {
String errMsg = responseOjt.getString("errmsg");
return ResponseVO.fail(ResponseCode.ERROR_LOGIN.getCode(), errMsg);
}
// 4.检查用户是否已经注册,未注册的先注册
log.info("检查是否注册,openid = {}",openid);
Member member = memberService.getOne(new QueryWrapper().eq("wx_id", openid));
if(null == member){
log.info("用户未注册");
member = new Member();
member.setWxId(openid);
member.setSex(userInfo.getString("gender"));
member.setHeadUrl(userInfo.getString("avatarUrl"));
member.setNickname(userInfo.getString("nickName"));
member.setCreateTime(new Date());
member.setUpdateTime(member.getCreateTime());
memberService.save(member);
}
// 5.发放 Token
Map claims = new HashMap<>();
claims.put("userId",member.getId());
claims.put("userName",member.getNickname());
claims.put("headUrl",member.getHeadUrl());
String token = JWTUtils.generateToken(claims,jwtSecret,Long.valueOf(jwtExpirationTimeInSecond));
log.info("发放 token = {}",token);
responseData.put("token",token);
return ResponseVO.success(responseData);
} catch (Exception e) {
log.error("登录异常", e);
return ResponseVO.fail(ResponseCode.ERROR_LOGIN);
}
}
}
点击获取用户图像昵称,再点击弹窗上面的 “允许”
服务端日志,token已经发放:
org.springframework.boot
spring-boot-starter-aop
package com.zengchen.user.auth;
public @interface CheckLogin {
}
package com.zengchen.user.auth;
import com.zengchen.common.bean.ServerException;
import com.zengchen.common.utils.JWTUtils;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
@Aspect
@Component
@Slf4j
public class CheckLoginAspect {
@Value("${jwt.secret}")
private String jwtSecret;
@Before("@annotation(com.zengchen.user.auth.CheckLogin)")
public void checkLogin(){
log.info("checkLogin start.......");
// 1.从请求的Header里取出token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("WX-TOKEN");
log.info("checkLogin token = {}",token);
// 2.检查token是否有效
Boolean isValid = JWTUtils.validateToken(token, jwtSecret);
if (!isValid) {
log.info("checkLogin token 无效,未登录!");
throw new ServerException("0001");
}
log.info("checkLogin token 有效,已登录!");
// 3.将用户id存放到 Attribute 里
Claims claimsFromToken = JWTUtils.getClaimsFromToken(token, jwtSecret);
request.setAttribute("userId", claimsFromToken.get("userId"));
}
}
@RestController
@Slf4j
@RequestMapping("/member")
public class MemberController {
@Autowired
private MemberService memberService;
@GetMapping("/checkLogin")
@CheckLogin
public Object getMember(HttpServletRequest request) {
Integer userId = (Integer)request.getAttribute("userId");
log.info("从attribute里取出 userId = {}",userId.intValue());
Member member = memberService.getOne(new QueryWrapper().eq("id", userId));
log.info("根据userId查询用户信息,{}",member);
return ResponseVO.success(member);
}
}
http://localhost:8082/member/checkLogin,Header里传 WX-TOKEN,查询用户信息
我们选择的认证方式是每个微服务都对Token进行验证,如果Feign调用的时候,不带上Token,下一个微服务用什么去验证呢?下面使用Feign的RequestInterceptor拦截器实现这个功能
public class TokenTransferInterceptor implements RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
* 将 WX-TOKEN 传递到下一个请求里
* @param template
*/
@Override
public void apply(RequestTemplate template) {
// 1.从请求的Header里取出token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes attributes = (ServletRequestAttributes) requestAttributes;
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("WX-TOKEN");
// 2.存入token
if(StringUtils.isNotBlank(token)){
template.header("WX-TOKEN",token);
}
}
}
feign:
sentinel:
# 开启 sentinel 支持
enabled: true
client:
config:
# 全局配置
default:
loggerLevel: full #basic
requestInterceptors:
- com.zengchen.content.feignclient.interceptor.TokenTransferInterceptor
所谓签名就是加密,例如:https://www.xxxx.com/s?param1=a¶m2=b¶m3=c&sign=asfdljgljoixcnogn,三个参数param1,param2,param3,再加上一个sign签名,sign就是三个参数+secretKey的加密字符串
secretKey=""123456
sign = md5(param1=a¶m2=b¶m3=c&secretKey=""123456)
服务端在收到请求之后,也按照相同的拼装顺序凭借参数和secretKey,再次md5得到一个加密字符串,然后比较加密字符串和请求sign是否相同,相同说明请求在传输的过程中,参数没有被篡改,如果对不上,不是参数被改了,就是签名被改了,就是非法请求。
破坏分子改了我的请求参数,他没办法得到一个正确的sign,因为他不知道我的secretKey,知道secretKey的只有请求方和服务方,早商量好的
按理说,上节7.1的签名机制已经可以保证参数的安全性了,为什么还要加上时间戳呢?
因为破坏分子不通过修改你的请求参数了来恶心你了,他可以抓取你整个的请求数据包,原封不动的多次发送请求搞破坏。这叫重放攻击
我看其他的文章里说,破坏分子要完成这个操作,花费的时间要远超过60s,这时候加上时间戳就很有必要了,时间戳也是请求参数之一,也是签名加密的一部分,所以时间戳也不会被修改,这样服务端就可以拿到这个请求的时间戳和当前时间做比较,如果当前时间比时间戳大60s,那说明这个请求不正常,因为一般请求也不会从发出,到接收请求花这么长时间的,所以超过60s的就算非法请求,时间戳的作用就体现出来了。你也可以定义成50s,40s
上节7.2通过时间戳比较来拦截请求,毕竟有个60s的空档,这是一个60s的很大的漏洞,随机数就可以补好这个漏洞,随机数怎么发挥它的作用呢?
每次发送请求,出了时间戳,再带上一个很大的随机数,例如0~10000000,请求一旦发送出去,破坏分子也没法修改这个随机数,随机数也是签名加密的一部分,如果改了,签名就对不上
服务端收到请求之后,先判断时间戳有没有大于60s,大于肯定就拒绝,如果时间戳没问题,再查一下Redis里有没有sign这个签名,如果没有就把sign存放到Redis中,超时时间设置为60s,和时间戳的临界值保持一致,并且给这次请求正常的返回。如果查到Redis已存在sign这个签名,说明这个请求已经在60s内请求一次了,属于非法请求
整个第4节都是个人所思所想,不能作为准则。App,小程序之类的客户端还好,毕竟代码不可见,浏览器端就不行了,js是公开的啊,怎么保密secretKey?而且我觉得只需要对需要登录操作的请求进行签名验证就可以了,公开接口做这个没有意义