文章目录
特别声明:整理自慕课网大目师兄的微服务视频,链接: https://coding.imooc.com/learn/list/358.html
1.有状态 vs 无状态
1.1 有状态的做法
- 登录是怎么回事?
客户端和服务器建立连接的时候,客户端会有一个sessionId,这个sessionId是不会变的,服务端就根据这个sessionId唯一确定你是谁了,你如果做了登录这个动作,服务端就会把你的登录信息存在服务端,登录信息和sessionId是一对一的关系,所以你每次请求,服务端都会找找这个sessionId有没有对应的登录信息,如果有,就表示登录了,如果找不到,就没有登录 - 服务端用HttpSession保存登录信息
把登录信息放到HttpSession里是一种很常见的做法,可以设置应用的session过期时间,如果这段时间,用户没有操作,HttpSession过期作废了,用户再来操作就找不到他的登录信息了,所以要重新登录
弊端: 如果启动了多个应用实例,你说你把用户的登录信息保存到哪个应用好呢?,你保存到实例1里,当用户的请求被分配到实例2和实例3处理的时候,实例2和实例3没有存放用户登录信息,就会认为用户没有登录,又让用户登录,这用户不是要蒙圈了?
解决方法:将nginx的转发规则配置为粘性会话,即相同ip的请求,总是转发到同一个实例当中,这样就不会找不到登录信息了。但是这种做法也有弊端,如果用户断网重连,ip地址发生改变,把用户请求发送到其它实例,又会发生找不到登录信息的情况
- 用第三方存储登录信息
如下图,把登录信息存放到Session Store里,可以是Redis,也可以是memberCache,甚至关系型数据库也行啊,开玩笑了,当然是nosql比较好了,redis,membercache都是nosql。
评论:这样不管用户的请求被转发到哪个实例,都能从第三方存储里找到他的登录信息了,如果Session Store没有到达存取瓶颈,这样做是可以的,一般都够用了。
弊端:
1.访问量很大的时候,Session Store也忙不过来了,那么这个方案就还需要改进
2.Session Store挂了,所有微服务都不能用了
3.Session Store迁移了,所有微服务都要改连接地址
1.2 无状态的做法
含义:无状态,就是服务端不保存用户的登录状态,既不把用户登录信息保存在内存里,也不把信息保存在第三方存储。用户做完登录,给用户发一个Token,用户每次请求都带上这个Token,放在Header里或者请求参数里,服务端拿到这个Token做解析,如果合法且未失效,就认为是登录的。Token里可以存放一些不敏感的用户信息,例如用户Id,姓名之类的。不要带手机号,这信息很敏感
图示:
弊端:token发放出去,服务器端就无法控制这个token了,不能让它马上失效,不能让这个用户立刻处于下线状态,毫无掌控力
1.3 优缺点对比
2.微服务的认证方案
2.1 处处安全方案
- 使用协议
OAuth 2.0,大目师兄推荐的介绍文章:http://ifeve.com/oauth2-tutorial-all/,技术性太强了,我没看下去 - 代表实现
Spring Cloud Security、JBoss Keycloak
2.2 网关认证授权,内部裸奔
认证过程:用户的登录授权,Token的解析判断,全网关里实现。后面的微服务不再判断这个请求有没有登录了
优点:逻辑简单,性能好
缺点:后面微服务毫不设防,有风险
2.3 处处校验Token
逻辑:网关不再关心业务,做自己的正事,过滤和转发。Token由专门的认证中心颁发,每个微服务在被请求的时候,都要校验Token的合法性,不用担心影响性能,现在的算法很快,毫秒级别的
优点:微服务分工更明确,网关不插手业务
缺点:每个微服务都要校验Token,秘钥到处用,增加泄露风险
其实可行的方案远不止这3种,不管你怎么实现,反正宗旨是能判断出用户有没有登录就行了
3.无状态的JWT (Json Web Token)
3.1 释义
JWT 的表现形式是长长的一串字符串
- 组成
Signature = Header里的写的签名算法(Base64(Header).Base64(Payload),秘钥)
例如:HS256(“aaa.bbb”,秘钥) - 生成JWT
Token = Base64(Header).Base64(Payload).Base64(Signature)
例如:aaa.bbb.ccc
3.2 user-center引入JWT
3.2.1 新建common项目
由于JWT每个微服务都要用到,干脆新建个工具类项目吧,微服务们依赖这个common项目就好了。项目gitee地址:https://gitee.com/zengchen2016/common
- 新建maven项目
- pom文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zc</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<dependencies>
<!--jwt相关-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
- JWTUtils类
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<String, Object> 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);
}
}
- Base64Utils 类
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 {
/**
* <p>
* BASE64字符串解码为二进制数据
* </p>
*
* @param base64
* @return
* @throws Exception
*/
public static byte[] decode(String base64) throws Exception {
return Base64.getMimeDecoder().decode(base64);
}
/**
* <p>
* 二进制数据编码为BASE64字符串
* </p>
*
* @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());
}
}
}
- install common
3.2.2 user-center依赖common
- user-sever的pom里添加依赖
<dependency>
<groupId>com.zc</groupId>
<artifactId>common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
3.3 测试JWT
- 生成token
秘钥对长度有限制,太短的会报异常,这里token失效时间是1小时
public static void main(String[] args) throws Exception {
Long expirationTimeInSecond = 3600L; // 一个小时
String secret = "aaabbbcccdddeeef1111111111111111111111111111111111111";
Map<String,Object> payloadMap = new HashMap<>();
payloadMap.put("id",1);
payloadMap.put("username","一粒尘埃");
String token = JWTUtils.generateToken(payloadMap,secret,expirationTimeInSecond);
log.info("token: "+token);
}
生成的token分成三段,以 “.” 相连
- 测试token的有效性,header,payload和失效时间
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));
结果,生成时间和失效时间正好间隔1个小时
4.登录发放Token
我是用微信小程序登录的,openId是用户标识,这章代码,读者仅能参
考
4.1 后端和前端代码
- 后端代码 user-center的 LoginController
主要就是第 5 步,发放 token
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;
/**
* <p>
* 前端控制器
* </p>
*
* @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<String,Object> 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<Member>().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<String,Object> 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);
}
}
}
- 前端小程序代码
点击这个按钮,向后台发送login的请求,请求的url是:http://localhost:8040/user-center/login,经过网关访问user-center
4.2 测试登录发放Token
点击获取用户图像昵称,再点击弹窗上面的 “允许”
服务端日志,token已经发放:
5.登录状态验证
5.1 登录状态验证的几种方式
- Servlet过滤器
登录验证可以用过滤器来实现,从请求的Header里取出Token,进而判断这个Token是否有效 - 拦截器
这种方式和过滤器类似,优势是可以操作springmvc相关的api - Spring AOP
自定义一个注解,在需要验证登录状态的方法上加上这个注解,我比较喜欢这种方式
5.2 AOP切面方式的实现
5.2.1 加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
5.2.2 编写代码
- 定义注解@CheckLogin
package com.zengchen.user.auth;
public @interface CheckLogin {
}
- 定义切面CheckLoginAspect.java
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<Member>().eq("id", userId));
log.info("根据userId查询用户信息,{}",member);
return ResponseVO.success(member);
}
}
5.2.3 测试@CheckLogin
http://localhost:8082/member/checkLogin,Header里传 WX-TOKEN,查询用户信息
- 有效登录测试
使用的Token是第4节下发的,有效期一个星期,还可以用
后台日志:
- 无效登录测试
把token最后的s删掉
后台日志:
登录状态检查完成!
6.Feign传递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);
}
}
}
- 配置拦截器
配置项是 requestInterceptors
feign:
sentinel:
# 开启 sentinel 支持
enabled: true
client:
config:
# 全局配置
default:
loggerLevel: full #basic
requestInterceptors:
- com.zengchen.content.feignclient.interceptor.TokenTransferInterceptor
- 测试
测试逻辑:在user-center的方法上加上@CheckLogin注解,content-center使用Feign访问user-center的这个方法,测试token是否传递到了user-center
content-center日志,token有效:
user-center日志,token也有了,说明token已经传递过来了:
7.关于请求安全,Token安全性个人的一点想法
7.1 签名起了什么作用?
所谓签名就是加密,例如:https://www.xxxx.com/s?param1=a¶m2=b¶m3=c&sign=asfdljgljoixcnogn,三个参数param1,param2,param3,再加上一个sign签名,sign就是三个参数+secretKey的加密字符串
- 作用
保证请求参数不会被篡改 - 如何保证?
请求方在发送请求之前,把请求参数按照参数名从小到大(或者从大到小)顺序排列好,再加上secretKey形成一个新的字符串,然后加密,得到sign值,例如
secretKey=""123456
sign = md5(param1=a¶m2=b¶m3=c&secretKey=""123456)
服务端在收到请求之后,也按照相同的拼装顺序凭借参数和secretKey,再次md5得到一个加密字符串,然后比较加密字符串和请求sign是否相同,相同说明请求在传输的过程中,参数没有被篡改,如果对不上,不是参数被改了,就是签名被改了,就是非法请求。
破坏分子改了我的请求参数,他没办法得到一个正确的sign,因为他不知道我的secretKey,知道secretKey的只有请求方和服务方,早商量好的
7.2 一般调用接口为什么要加时间戳?
按理说,上节7.1的签名机制已经可以保证参数的安全性了,为什么还要加上时间戳呢?
因为破坏分子不通过修改你的请求参数了来恶心你了,他可以抓取你整个的请求数据包,原封不动的多次发送请求搞破坏。这叫重放攻击
我看其他的文章里说,破坏分子要完成这个操作,花费的时间要远超过60s,这时候加上时间戳就很有必要了,时间戳也是请求参数之一,也是签名加密的一部分,所以时间戳也不会被修改,这样服务端就可以拿到这个请求的时间戳和当前时间做比较,如果当前时间比时间戳大60s,那说明这个请求不正常,因为一般请求也不会从发出,到接收请求花这么长时间的,所以超过60s的就算非法请求,时间戳的作用就体现出来了。你也可以定义成50s,40s
7.3 加了时间戳为什么又要加随机数?
上节7.2通过时间戳比较来拦截请求,毕竟有个60s的空档,这是一个60s的很大的漏洞,随机数就可以补好这个漏洞,随机数怎么发挥它的作用呢?
每次发送请求,出了时间戳,再带上一个很大的随机数,例如0~10000000,请求一旦发送出去,破坏分子也没法修改这个随机数,随机数也是签名加密的一部分,如果改了,签名就对不上
服务端收到请求之后,先判断时间戳有没有大于60s,大于肯定就拒绝,如果时间戳没问题,再查一下Redis里有没有sign这个签名,如果没有就把sign存放到Redis中,超时时间设置为60s,和时间戳的临界值保持一致,并且给这次请求正常的返回。如果查到Redis已存在sign这个签名,说明这个请求已经在60s内请求一次了,属于非法请求
整个第4节都是个人所思所想,不能作为准则。App,小程序之类的客户端还好,毕竟代码不可见,浏览器端就不行了,js是公开的啊,怎么保密secretKey?而且我觉得只需要对需要登录操作的请求进行签名验证就可以了,公开接口做这个没有意义