一 JWT+RSA 无状态SSO原理
1.1.有状态登陆
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
- 服务端保存大量数据,增加服务端压力
- 服务端保存用户状态,无法进行水平扩展
- 客户端请求依赖服务端,多次请求必须访问同一台服务器
1.2.无状态登陆
微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服 务的无状态性,即:服务端不保存任何客户端请求者信息,客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
- 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩
- 减小服务端存储压力
1.3 RSA加密
RSA称为非对称加密,加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密 文)其逆过程就是解码(解密),加密技术的要点是加密算法,RSA会根据你给的盐值生成私钥和公钥:
- 私钥:通过私钥加密的数据使用私钥或者公钥来解密。
- 公钥:通过公钥加密的数据只能使用私钥来解密。
- 优点:安全,难以破解
- 缺点:算法比较耗时
1.4 JWT
JWT,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的 Web应用授权。看名知意,其实就是一种加密方式,分为三部分:
Header(头部):一般只包含两部分信息:
声明加密的算法,这里使用的是HS256的加密算法。声明Token的类型,这里使用的是JWT的风格类型。
Payload(载荷):存放一些有效数据比如用户ID、用户名称,解密之后可以获取载荷中的用户信息,因为采用Base64编码格式,所以可以被解码,不要放敏感信息,例如登录密码之类的。
Signature(签名):Signature由header和payload经过 base64 编码后加 盐值得到的。生成Signature的算法如下
var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
HMACSHA256(encodedString, '!Q@#$%^%&');
JjwtUtil.java
public class JjwtUtil {
// jti:jwt的唯一身份标识
public static final String JWT_ID = UUID.randomUUID().toString();
// 加密密文,私钥
public static final String JWT_SECRET = "jiamimiwen";
// 过期时间,单位毫秒
public static final int EXPIRE_TIME = 60 * 60 * 1000; // 一个小时
// public static final long EXPIRE_TIME = 7 * 24 * 3600 * 1000; // 一个星期
// 由字符串生成加密key
public static SecretKey generalKey() {
// 本地的密码解码
byte[] encodedKey = Base64.decodeBase64(JWT_SECRET);
// 根据给定的字节数组使用AES加密算法构造一个密钥
SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
return key;
}
// 创建jwt
public static String createJWT(String issuer, String audience, String subject) throws Exception {
// 设置头部信息
Map<String, Object> header = new HashMap<String, Object>();
header.put("typ", "JWT");
header.put("alg", "HS256");
// 或
// 指定header那部分签名的时候使用的签名算法,jjwt已经将这部分内容封装好了,只有{"alg":"HS256"}
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 创建payload的私有声明(根据特定的业务需要添加,如果要拿这个做验证,一般是需要和jwt的接收方提前沟通好验证的方式)
Map<String, Object> claims = new HashMap<>();
claims.put("username", "admin");
claims.put("password", "010203");
// jti用户id,例如:20da39f8-b74e-4a9b-9a0f-a39f1f73fe64
String jwtId = JWT_ID;
// 生成JWT的时间
long nowTime = System.currentTimeMillis();
Date issuedAt = new Date(nowTime);
// 生成签名的时候使用的秘钥secret,切记这个秘钥不能外露,是你服务端的私钥,在任何场景都不应该流露出去,一旦客户端得知这个secret,那就意味着客户端是可以自我签发jwt的
SecretKey key = generalKey();
// 为payload添加各种标准声明和私有声明
JwtBuilder builder = Jwts.builder() // 表示new一个JwtBuilder,设置jwt的body
.setHeader(header) // 设置头部信息
.setClaims(claims) // 如果有私有声明,一定要先设置自己创建的这个私有声明,这是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明
.setId(jwtId) // jti(JWT ID):jwt的唯一身份标识,根据业务需要,可以设置为一个不重复的值,主要用来作为一次性token,从而回避重放攻击
.setIssuedAt(issuedAt) // iat(issuedAt):jwt的签发时间
.setIssuer(issuer) // iss(issuer):jwt签发者
.setSubject(subject) // sub(subject):jwt所面向的用户,放登录的用户名,一个json格式的字符串,可存放userid,roldid之类,作为用户的唯一标志
.signWith(signatureAlgorithm, key); // 设置签名,使用的是签名算法和签名使用的秘钥
// 设置过期时间
long expTime = EXPIRE_TIME;
if (expTime >= 0) {
long exp = nowTime + expTime;
builder.setExpiration(new Date(exp));
}
// 设置jwt接收者
if (audience == null || "".equals(audience)) {
builder.setAudience("Tom");
} else {
builder.setAudience(audience);
}
return builder.compact();
}
// 解密jwt
public static Claims parseJWT(String jwt) throws Exception {
SecretKey key = generalKey(); // 签名秘钥,和生成的签名的秘钥一模一样
Claims claims = Jwts.parser() // 得到DefaultJwtParser
.setSigningKey(key) // 设置签名的秘钥
.parseClaimsJws(jwt).getBody(); // 设置需要解析的jwt
return claims;
}
}
JWTTest.java
public static void main(String[] args) {
User user = new User();
user.setAge(1);
user.setName("张三");
// jwt所面向的用户,放登录的用户名等
String subject = JSON.toJSONString(user);
try {
// "Jack"是jwt签发者,"李四"是jwt接收者
String jwt = JjwtUtil.createJWT("Jack", "李四", subject);
System.out.println("JWT:" + jwt);
System.out.println("JWT长度:" + jwt.length());
System.out.println("\njwt三个组成部分中间payload部分的解密:");
Claims c = JjwtUtil.parseJWT(jwt);
System.out.println("jti用户id:" + c.getId());
System.out.println("iat登录时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(c.getIssuedAt()));
System.out.println("iss签发者:" + c.getIssuer());
System.out.println("sub用户信息列表:" + c.getSubject());
System.out.println("exp过期时间:" + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(c.getExpiration()));
System.out.println("aud接收者:" + c.getAudience());
System.out.println("登录的用户名:" + c.get("username"));
// 或
System.out.println("登录的用户名:" + c.get("username", String.class));
System.out.println("登录的密码:" + c.get("password", String.class));
} catch (Exception e) {
e.printStackTrace();
}
}
JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJhZ2VcIjoxLFwibmFtZVwiOlwi5byg5LiJXCJ9IiwiYXVkIjoi5p2O5ZubIiwicGFzc3dvcmQiOiIwMTAyMDMiLCJpc3MiOiJKYWNrIiwiZXhwIjoxNjgzNjc3MTUwLCJpYXQiOjE2ODM2NzM1NTAsImp0aSI6ImYwNGEwZTkyLThkY2YtNGFjOC1iNzJmLWYwMGIzYzc1ZThhYSIsInVzZXJuYW1lIjoiYWRtaW4ifQ.htyiHnR3zPbufvF33Z8qT1c1UJOeMmZD_kVFAl_I2YQ
JWT长度:331
jwt三个组成部分中间payload部分的解密:
jti用户id:f04a0e92-8dcf-4ac8-b72f-f00b3c75e8aa
iat登录时间:2023-05-10 07:05:50
iss签发者:Jack
sub用户信息列表:{"age":1,"name":"张三"}
exp过期时间:2023-05-10 08:05:50
aud接收者:李四
登录的用户名:admin
登录的用户名:admin
登录的密码:010203
二 分布式下JWT SSO的过程
1 访问网关,查看是否有token,如果没有教跳到登陆页面
2 当用户登录的时候,访问网关再调用授权中心进行授权,通过JWT生成token放入网关的redis中或者cookies中,然后把token返回给页面,再根据记录下来的请求地址,跳转到对应的页面.
3 用户再访问其他系统的时候,调用网关,然后网关获取token,如果不存在token就直接调跳转登陆页面,如果token存在,就调用授权中心进行验证,如果验证token存在,返回用户信息给到网关。如果token不存在就跳转登陆页面。
三 JWT+RSA SSO的过程
3.1 RSA的意义
可以发现,每次鉴权都需要访问鉴权中心,系统间的网络请求频率过高,效率略差,鉴权中心的压力较大。这时就用到了前面说的RSA,我们可以把私钥留在授权中心,把公钥给网关或者其他微服务,那么就可以在网关或其他微服务当中直接解密JWT了,这样做的好处是减少了授权中心的压力。
3.2 jwt+rsa 登录过程
1 如上访问网关未登录就跳到登陆页面。
2登录的时候同上访问授权中心授权,授权中心验证完用户信息以后,通过私钥对生成jwt的token信息。然后将token信息写入redis或者cookie带回页面。
3 如上所示,客户端再访问其他资源的时候,就不需要再去调用授权服务器了,而是获取token信息,直接通过公钥进行解密就好了。当然如果没有token的话还是跳转到登陆页面。
四 无状态登录实现
4.1 设计图
4.1.1 交互流程
4.1.2 交互平面
4.2 核心代码片段
4.2.1 risk-sso 服务
/**
* <p>
* 服务实现类
* </p>
*
* @since 2021-04-09
*/
@Service
public class WxbMemeberServiceImpl extends ServiceImpl<WxbMemeberMapper, WxbMemeber> implements IWxbMemeberService {
@Autowired
private WxbMemeberMapper wxbMemeberMapper;
@Resource
private RedisTemplate redisTemplate;
@Autowired
private RestTemplate restTemplate;
@Override
public Result login(WxbMemeber memeber) {
String name = memeber.getName();
String password = memeber.getPassword();
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
LambdaQueryWrapper<WxbMemeber> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(WxbMemeber::getName, name);
WxbMemeber wxbMemeber = wxbMemeberMapper.selectOne(lambdaQueryWrapper);
if (wxbMemeber == null) {
return Result.error(1008, "用户不存在,请注册!", null);
}
String dbPassWord = wxbMemeber.getPassword();
if (!bCryptPasswordEncoder.matches(password, dbPassWord)) {
return Result.error(1008, "用户名 密码输入错误!", null);
}
//获取私钥
String token = getLoginToken(wxbMemeber);
return Result.ok(token, "success");
}
/**
* 获取登录用户的token
*
* @param wxbMemeber
* @return
*/
public String getLoginToken(WxbMemeber wxbMemeber) {
PrivateKey privateKey = getPrivateKey();
wxbMemeber.setPassword(""); //新密设置null
String token = JwtUtils.generateTokenExpireInMinutes(wxbMemeber, privateKey, 60);
String redisKey = getLoginRedisKey(wxbMemeber);
redisTemplate.opsForValue().set(redisKey, token, 30, TimeUnit.MINUTES);
return token;
}
@Override
public Result wxLogin(HttpServletRequest request, HttpServletResponse response) {
//获取回调地址中的code
String code = request.getParameter("code");
//拼接url
String url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=" + WxConstant.APPID + "&secret="
+ WxConstant.APPSECRET + "&code=" + code + "&grant_type=authorization_code";
JSONObject jsonObject = restTemplate.getForObject(url, JSONObject.class);
//1.获取微信用户的openid
String openid = jsonObject.getString("openid");
//2.获取获取access_token
String access_token = jsonObject.getString("access_token");
String infoUrl = "https://api.weixin.qq.com/sns/userinfo?access_token=" + access_token + "&openid=" + openid
+ "&lang=zh_CN";
//3.获取微信用户信息
WxUser userInfo = restTemplate.getForObject(infoUrl, WxUser.class);
//至此拿到了微信用户的所有信息,剩下的就是业务逻辑处理部分了
//保存openid和access_token到session
request.getSession().setAttribute("openid", openid);
request.getSession().setAttribute("access_token", access_token);
LambdaQueryWrapper<WxbMemeber> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(WxbMemeber::getOpenId, openid);
List<WxbMemeber> wxbMemebers = wxbMemeberMapper.selectList(lambdaQueryWrapper);
if (CollectionUtils.isEmpty(wxbMemebers)) {
/**
* 没有查到跳转到注册页面
*/
return Result.error(500, "用户不存在,请重新注册!", null);
}
WxbMemeber wxbMemeber = wxbMemebers.get(0);
BeanUtils.copyProperties(userInfo, wxbMemeber);
this.saveOrUpdate(wxbMemeber);
wxbMemeber.setPassword("");
String token = getLoginToken(wxbMemeber);
return Result.ok(token, "success");
}
/**
* 获取登录用户名 密码
*
* @param wxbMemeber
* @return
*/
public String getLoginRedisKey(WxbMemeber wxbMemeber) {
String pattern = "l_g:%s:%s";
String memeberId = wxbMemeber.getMemeberId();
String dateDay = DateFormatUtils.format(new Date(), "yyyyMMdd");
return String.format(pattern, dateDay, memeberId);
}
private PrivateKey getPrivateKey() {
PrivateKey privateKey = null;
try {
privateKey = RsaUtils.getPrivateKey(ResourceUtils.getFile("classpath:rsa").getPath());
} catch (Exception e) {
e.printStackTrace();
}
return privateKey;
}
}
4.2.2 risk-gateway
@Component
@Slf4j
@RefreshScope
public class GlobleAuthFilter implements GlobalFilter, Ordered {
@Resource
private RedisTemplate redisTemplate;
@Value("${filter.url.pre:/login}")
private String filterUrl;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
URI uri = request.getURI();
String path = uri.getPath();
if (!checkIsNeedLogin(path)) {
return chain.filter(exchange);
}
//1 获取token
HttpHeaders headers = request.getHeaders();
String token = headers.getFirst("token");
if (StringUtils.isBlank(token)) {
return response(response, R.failed("无效token!"));
}
PublicKey publicKey = getPublicKey();
//2 对token进行公钥解密
WxbMemeber wxbMemeber = (WxbMemeber) JwtUtils.getInfoFromToken(token, publicKey, WxbMemeber.class);
//3 解密以后获取用户的id
String loginRedisKey = getLoginRedisKey(wxbMemeber);
String tokens = (String) redisTemplate.opsForValue().get(loginRedisKey);
if (StringUtils.isBlank(tokens)) {
return response(response, R.failed("登录超时,请重新登录!"));
}
if (!token.equals(tokens)) {
return response(response, R.failed("异地登录,请重新登录!"));
}
//4 蓄一下超时时间
redisTemplate.opsForValue().set(loginRedisKey, token, 30, TimeUnit.MINUTES);
//5 把解密后的用户信息放入请求头继续往下传递
ServerHttpRequest.Builder mutate = request.mutate();
mutate.header("token", JSONObject.toJSONString(wxbMemeber));
ServerWebExchange.Builder webexcahnge = exchange.mutate();
ServerWebExchange newServerWebExchange = webexcahnge.request(mutate.build()).build();
return chain.filter(newServerWebExchange);
}
public boolean checkIsNeedLogin(String path) {
String[] split = filterUrl.split(",");
if (split.length > 0) {
for (String sp : split) {
if (path.contains(sp)) {
return false;
}
}
}
return true;
}
public String getLoginRedisKey(WxbMemeber wxbMemeber) {
String pattern = "l_g:%s:%s";
String memeberId = wxbMemeber.getMemeberId();
String dateDay = DateFormatUtils.format(new Date(), "yyyyMMdd");
return String.format(pattern, dateDay, memeberId);
}
private PublicKey getPublicKey() {
PublicKey privateKey = null;
try {
privateKey = RsaUtils.getPublicKey(ResourceUtils.getFile("classpath:rsa.pub").getPath());
} catch (Exception e) {
e.printStackTrace();
}
return privateKey;
}
private Mono<Void> response(ServerHttpResponse response, R res) {
//不能放行,直接返回,返回json信息
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
ObjectMapper objectMapper = new ObjectMapper();
String jsonStr = null;
try {
jsonStr = objectMapper.writeValueAsString(res);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
DataBuffer dataBuffer = response.bufferFactory().wrap(jsonStr.getBytes());
return response.writeWith(Flux.just(dataBuffer));//响应json数据
}
@Override
public int getOrder() { //作为第一个全局过滤器
return 0;
}
}
/**
* gateway跨域配置
*/
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedMethod("*");
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
4.2.3 risk-order
package worn.xiao.order.interceptor;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import worn.xiao.entity.WxbMemeber;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
@Slf4j
public class UserInfoInterceptor implements HandlerInterceptor {
private static final ThreadLocal<WxbMemeber> loginUser=new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("token");
if(StringUtils.isBlank(token)){
log.error("preHandle token StringUtils.isBlank(token)");
return false;
}
WxbMemeber wxbMemebers = JSONObject.parseObject(token, WxbMemeber.class);
loginUser.set(wxbMemebers);
return HandlerInterceptor.super.preHandle(request, response, handler);
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
loginUser.remove();
}
public static WxbMemeber getLoginUser(){
return loginUser.get();
}
}