第22天-单点登录SSO,JWT实现无状态登录,自定义网关过滤器处理登录验证
Posted zenggeweiss
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第22天-单点登录SSO,JWT实现无状态登录,自定义网关过滤器处理登录验证相关的知识,希望对你有一定的参考价值。
1.单点登录(SSO)
SSO英文全称Single Sign On,单点登录。
SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
1.1. Cookie问题
电商平台通常由多个微服务组成,每个微服务都有独立的域名,而cookie是有作用域的。
查看浏览器控制台:
domain:作用域名
domain参数 | gmall.com | search.gmall.com | item.gmall.com |
---|---|---|---|
gmall.com | √ | √ | √ |
search.gmall.com | × | √ | × |
item.gmall.com | × | × | √ |
domain有两点要注意:
1. domain参数可以设置父域名以及自身,但不能设置其它域名,包括子域名,否则cookie不起作用。
2. cookie的作用域是domain本身以及domain下的所有子域名。
Cookie的路径(Path):
- response.addCookie默认放在当前路径下,访问当前路径下的所有请求都会带
- 设置/标识项目根路径,访问项目任何位置都会携带
1.2. 有状态登录
为了保证客户端cookie的安全性,服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如tomcat中的session。
例如登录:用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
- 服务端保存大量数据,增加服务端压力
- 服务端保存用户状态,无法进行水平扩展
- 客户端请求依赖服务端,多次请求必须访问同一台服务器
即使使用redis保存用户的信息,也会损耗服务器资源。
1.3. 无状态登录
微服务集群中的每个服务,对外提供的都是Rest风格的接口。而Rest风格的一个最重要的规范就是:服务的无状态性,即:
- 服务端不保存任何客户端请求者信息
- 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
- 客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩
- 减小服务端存储压力
1.4. 无状态登录流程
无状态登录的流程:
- 当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
- 认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
- 以后每次请求,客户端都携带认证的token
- 服务端对token进行解密,判断是否有效。
流程图:
整个登录过程中,最关键的点是什么?
token的安全性
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。
采用何种方式加密才是安全可靠的呢?
我们将采用 JWT + RSA非对称加密
2. JWT实现无状态登录
JWT,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权;官网:https://jwt.io
GitHub上jwt的java客户端:https://github.com/jwtk/jjwt
2.1. 数据格式
JWT包含三部分数据:
-
Header:头部,通常头部有两部分信息:
- token类型:JWT
- 加密方式:base64(HS256)
-
Payload:载荷,就是有效数据,一般包含下面信息:
- 用户身份信息(注意,这里因为采用base64编码,可解码,因此不要存放敏感信息)
- 注册声明:如token的签发时间,过期时间,签发人等
这部分也会采用base64编码,得到第二部分数据 -
Signature:签名,是整个数据的认证信息。根据前两步的数据,再加上指定的密钥(secret)(不要泄漏,最好周期性更换),通过base64编码生成。用于验证整个数据完整和可靠性
2.2. JWT交互流程
流程图:
步骤翻译:
- 用户登录
- 服务的认证,通过后根据secret生成token
- 将生成的token返回给浏览器
- 用户每次请求携带token
- 服务端利用公钥解读jwt签名,判断签名有效后,从Payload中获取用户信息
- 处理请求,返回响应结果
因为JWT签发的token中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。
2.3. 非对称加密
加密技术是对信息进行编码和解码的技术,编码是把原来可读信息(又称明文)译成代码形式(又称密文),其逆过程就是解码(解密),加密技术的要点是加密算法,加密算法可以分为三类:
-
对称加密,如AES
- 基本原理:将明文分成N个组,然后使用密钥对各个组进行加密,形成各自的密文,最后把所有的分组密文进行合并,形成最终的密文。
- 优势:算法公开、计算量小、加密速度快、加密效率高
- 缺陷:双方都使用同样密钥,安全性得不到保证
-
非对称加密,如RSA
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
- 私钥加密,持有公钥才可以解密
- 公钥加密,持有私钥才可解密
- 优点:安全,难以破解
- 缺点:算法比较耗时
- 基本原理:同时生成两把密钥:私钥和公钥,私钥隐秘保存,公钥可以下发给信任客户端
-
不可逆加密,如MD5,SHA
- 基本原理:加密过程中不需要使用密钥,输入明文后由系统直接经过加密算法处理成密文,这种加密后的数据是无法被解密的,无法根据密文推算出明文。
RSA算法历史:
1977年,三位数学家Rivest、Shamir 和 Adleman 设计了一种算法,可以实现非对称加密。这种算法用他们三个人的名字缩写:RSA
3.搭建授权中心
用户鉴权:
- 接收用户的登录请求,通过用户中心的接口进行校验,通过后生成JWT
- 使用私钥生成JWT并返回
3.1.JWT工具类
gmall-common工程中已经封装了jwt相关的工具类:
- JwtUtils
- RsaUtils
- CookUtils
并在gmall-common中的pom.xml中引入了jwt相关的依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.10.9</version>
</dependency>
3.2. 配置公钥和私钥
我们需要在授权中心生成真正的公钥和私钥。可以把相关配置内容配置到gmall-auth工程的
application.yml 中或者配置中心:
auth:
jwt:
pubKeyPath: D:\\\\gmall\\\\rsa\\\\rsa.pub
priKeyPath: D:\\\\gmall\\\\rsa\\\\rsa.pri
secret: 30489ouerweljrLROE@#)(@$*343jlsdf
cookieName: GMALL-TOKEN
expire: 180
unick: unick
然后编写属性类读取jwt配置,并从秘钥配置文件中读取出响应的公钥及私钥,
JwtProperties
package com.atguigu.gmall.auth.config;
import com.atguigu.common.utils.RsaUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.annotation.PostConstruct;
import java.io.File;
import java.security.PrivateKey;
import java.security.PublicKey;
/**
* JWT配置 @link JwtProperties
*
* @author zhangwen
* @email: 1466787185@qq.com
*/
@ConfigurationProperties(prefix = "auth.jwt")
@Slf4j
@Data
public class JwtProperties
private String pubKeyPath;
private String priKeyPath;
private String secret;
private String cookieName;
private Integer expire;
private String unick;
private PublicKey publicKey;
private PrivateKey privateKey;
/**
* 该方法在构造方法执行之后执行
*/
@PostConstruct
public void init()
try
File pubFile = new File(pubKeyPath);
File priFile = new File(priKeyPath);
// 如果公钥或者私钥不存在,重新生成公钥和私钥
if (!pubFile.exists() || !priFile.exists())
RsaUtils.generateKey(pubKeyPath, priKeyPath, secret);
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
this.privateKey = RsaUtils.getPrivateKey(priKeyPath);
catch (Exception e)
log.error("生成公钥和私钥出错");
e.printStackTrace();
4.完成登录功能
4.1.跳转登录页
参照京东,当点击登录跳转到登录页面时,如下:
会记录跳转到登录页面前的页面地址,登录成功后要回到原来的页面。
AuthController
/**
* 登录页面-单点登录
* @param returnUrl
* @return
*/
@GetMapping("/login2.html")
public String login2Page(@RequestParam("returnUrl") String returnUrl, Model model)
//把登录前的页面地址,记录到登录页面,以备将来登录成功,回到登录前的页面
model.addAttribute("returnUrl", returnUrl);
return "login2";
在login.html页面会记录returnUrl地址,将来登录成功后重定向到该地址:
在浏览器输入:http://auth.gmall.com/login2.html?returnUrl=http://gmall.com
效果如下:
4.2. 完成登录功能
接下来,我们需要在 gmall-auth 编写登录代码。基本流程如下:
- 客户端携带用户名和密码请求登录 ,并携带登录前页面的路径
- 授权中心调用用户中心接口,根据用户名和密码查询用户信息
- 用户名密码不正确,不能获取用户,登录失败
- 如果校验成功,则生成JWT签名,jwt要防止别人盗取(安全)
- 把jwt放入cookie
- 为了方便页面展示登录用户昵称,向cookie中单独写入昵称(例如京东cookie中的unick)
- 重定向回到登录前的页面
4.2.1. AuthController
编写授权接口,我们接收登录名和密码及登陆前的页面地址,登录成功后重定向到登陆前页面。
-
请求方式:post
-
请求路径:/ssoLogin
-
请求参数:account和password
-
返回结果:无
代码:
/**
* 单点登录
* @param account
* @param password
* @param returnUrl
* @param request
* @param response
* @param redirectAttributes
* @return
*/
@PostMapping("/ssoLogin")
public String ssoLogin(@RequestParam("account") String account,
@RequestParam("password") String password,
@RequestParam("returnUrl") String returnUrl,
HttpServletRequest request,
HttpServletResponse response,
RedirectAttributes redirectAttributes)
UserLoginVO vo = new UserLoginVO();
vo.setAccount(account);
vo.setPassword(password);
// 调用远程接口
R r = memberFeignService.login(vo);
if (r.getCode() != 0)
Map<String, String> errors = new HashMap<>();
String msg = r.getData("msg", new TypeReference<String>() );
errors.put("msg", msg);
redirectAttributes.addFlashAttribute("errors", errors);
// 登录失败,重定向到登录页面
return "redirect:http://auth.gmall.com/login2.html";
// 3. 把用户id及用户名放入载荷
MemberVO memberVO = r.getData("data", new TypeReference<MemberVO>() );
Map<String, Object> map = new HashMap<>();
map.put("userId", memberVO.getId());
map.put("username", memberVO.getNickname());
// 4. 为了防止jwt被别人盗取,载荷中加入用户ip地址
String ipAddress = IpUtils.getIpAddress(request);
map.put("ip", ipAddress);
// 5. 制作jwt类型的token信息
try
String token = JwtUtils.generateToken(map, jwtProperties.getPrivateKey(), jwtProperties.getExpire());
// 6. 把jwt放入cookie中
CookieUtils.setCookie(request, response, jwtProperties.getCookieName(), token, jwtProperties.getExpire() * 60);
// 7.用户昵称放入cookie中,方便页面展示昵称
CookieUtils.setCookie(request, response, jwtProperties.getUnick(), memberVO.getNickname(), jwtProperties.getExpire() * 60);
catch (Exception e)
e.printStackTrace();
return "redirect:" + returnUrl;
4.3. 解决cookie写入问题
解决cookie写入问题,要注意两点:
- cookie中的domain域必须和地址栏(或者是父域名)一致。
- cors跨域满足携带cookie的生效条件
- 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。(网关中已设置)
- 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名。(网关中已设置具体域名)
- 浏览器发起ajax需要指定withCredentials 为true。(前端工程:gmall-admin-vue\\src\\utils\\httpRequest.js文件已经设置)
4.3.1. domain地址变化原因
那么问题来了:为什么我们这里的请求serverName变成了ip地址了呢?
这是因为在地址栏输入域名时,经过了两次转发:
- 我们使用了nginx反向代理,当监听到auth.gmall.com的时候,会自动将请求转发至代理ip地址,即gateway服务器地址。
- 而后请求到达我们的gateway网关,gateway网关就会根据路径匹配,我们的请求是/api/auth,根据规则被转发到了auth服务地址 ,即我们的授权中心。
每次转发都会丢失域名信息。
4.3.2. nginx转发时要携带域名
首先nginx转发请求给网关时,要携带域名信息。需要在nginx配置文件中配置代理头信息:
proxy_set_header Host $host;
修改完成之后,重启nginx容器 docker restart nginx
这样就解决了nginx转发时的域名问题。
4.3.3. 网关转发时要携带域名
在网关转发请求给服务时,要携带地址信息:
spring:
cloud:
gateway:
x-forwarded:
host-enabled: true
4.3.4.再次登录测试
4.4.页头显示用户名
从 cookie 中获取用户昵称
5.网关过滤器验证登录状态
gateway网关过滤器包含两种:
- 全局过滤器
- 局部过滤器
5.1.自定义全局过滤器
自定义全局过滤器非常简单:实现GlobalFilter接口即可,无差别拦截所有微服务的请求
@Component
public class TestGatewayFilter implements GlobalFilter, Ordered
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)
System.out.println("无需配置,拦截所有经过网关的请求!!");
//放行
return chain.filter(exchange);
/**
* 通过实现Orderer接口的getOrder方法控制全局过滤器的执行顺序
* @return
*/
@Override
public int getOrder()
return 0;
5.2. 自定义局部过滤器
自定义局部过滤器稍微麻烦一点:
- 需要编写过滤器工厂类继承AbstractGatewayFilterFactory抽象类
- 在需要过滤的微服务路由中配置该过滤器
可以做到定点拦截。
5.2.1. 过滤器工厂AuthGatewayFilterFactory
@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<Object>
@Override
public GatewayFilter apply(Object config)
//实现GatewaFilter接口
return new GatewayFilter()
@Override
public Mono<Void> filter(ServerWebExchange exchange,GatewayFilterChain chain)
System.out.println("自定义过滤器!");
//放行
return chain.filter(exchange);
;
5.2.2. 在配置文件中使用
现在拿gmall-auth工程尝试使用
- id: gmall_auth_route
uri: lb://gmall-auth
predicates:
- Host=auth.gmall.com
filters:
- Auth
过滤器名称就是 Auth ,即自定义过滤器工厂 AuthGatewayFilterFactory 去掉
GatewayFilterFactory
5.2.3. 读取过滤器配置内容
此时,虽然可以使用这个拦截器了,但是我们的拦截器还是光秃秃的,不能指定内容。
如果像下面一样指定 拦截路径 ,并在过滤器中获取 拦截路径 ,再去判断当前路径是否需要拦截
假设如下:
- id: gmall_auth_route
uri: lb://gmall-auth
predicates:
- Host=auth.gmall.com
filters:
- Auth=/login2.html,/login2
改造AuthGatewayFilterFactory过滤器工厂类如下:
@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.PathConfig>
/**
* 一定要重写构造方法
* 告诉父类,这里使用PathConfĕ g对象接收配置内容
*/
public AuthGatewayFilterFactory()
super(PathConfig.class);
@Override
public GatewayFilter apply(PathConfig config)
//实现GatewaFilter接口
return new GatewayFilter()
@Override
public Mono<Void> filter(ServerWebExchange exchange,GatewayFilterChain chain)
System.out.println("自定义过滤器!" + config);
//放行
return chain.filter(exchange);
;
/**
* 指定字段顺序
* 可以通过不同的字段分别读取:/login2.html,/ssoLogin
* 在这里希望通过一个集合字段读取所有的路径
* @return
*/
@Override
public List<String> shortcutFieldOrder()
return Arrays.asList("authPaths");
/**
* 指定读取字段的结果集类型
* 默认通过map的方式,把配置读取到不同字段
* 例如:/login2.html,/ssoLogin
* 由于只指定了一个字段,只能接收/login2.html
* @return
*/
@Override
public ShortcutType shortcutType()
return ShortcutType.GATHER_LIST;
/**
* 读取配置的内部类
*/
@Data
public static class PathConfig
private List<String> authPaths;
重启网关测试,已经可以拿到配置内容!!!
5.3. 通过自定义局部过滤器完成登录验证
接下来,我们在gmall-gateway编写过滤器,对用户的token进行校验,如果发现未登录,则进行拦截。
思路:
- 判断请求路径在不在拦截名单中,不在直接放行
- 获取请求中的token。同步请求从cookie中获取,异步请求从header中获取(走cookie太重,一个网站往往有很多cookie,如果通过携带cookie的方式传递token,网络传输压力太大)
- 判断token是否为空。为空直接拦截
- 如果不为空,解析jwt获取登录信息
- 判断是否被盗用。通过登录信息中的ip和当前请求的ip比较
- 传递登录信息给后续服务。后续各服务就不用再去解析了
- 放行
5.3.1. 引入jwt相关配置
既然是登录拦截,一定需要公钥解析jwt,我们在 gmall-gateway 中配置
首先在pom.xml中,引入所需要的依赖:
<dependency>
<groupId>com.atguigu.gmall</groupId>
<artifactId>gmall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
然后编写application.yml属性文件,添加如下内容:
auth:
jwt:
pubKeyPath: D:\\\\gmall\\\\rsa\\\\rsa.pub # 公钥地址
cookieName: GMALL-TOKEN
编写属性类,读取公钥:
/**
* JWT属性类 @link JwtProperties
*
* @author zhangwen
* @email: 1466787185@qq.com
*/
@ConfigurationProperties(prefix = "auth.jwt")
@Slf4j
@Data
public class JwtProperties
private String pubKeyPath;
private PublicKey publicKey;
private String cookieName;
@PostConstruct
public void init()
try
//获取公钥
this.publicKey = RsaUtils.getPublicKey(pubKeyPath);
catch (Exception e)
参考技术A
JWT全称“JSON Web Token”,是基于JSON的用户身份认证的令牌。可跨域身份认证,所以JWT很适合做分布式的鉴权,单点登录(SingleSign,SSO)。
jwt有三部分组成用符号"."隔开,
HEADER:token头,描述token是什么类型,加密方式是什么,把json内容转为base64
PAYLOAD:内容,是暴露出来的信息,不可存敏感信息,把json内容转为base64
SIGNATURE:签名,按token头的加密方式把HEADER和PAYLOAD的信息加密生成签名,下面是官网上面的介绍,地址: https://jwt.io/
jwt的token是不可以篡改的,虽然前两部分的内容可以base64解码之后就能看到明文,但由于第三部分签名是把前两部分内容用一个密钥加密的,验证的时候也是把前两部分内容再次加密和原来签名对比是否一致,若内容被篡改了,则两次签名不一致校验不通过。
问题一:同一个公司的系统 ,不如果每个系统都有一套自己的用户名密码,那用户记
得头都大了啊。所以这时产生了一个鉴权中心,全部系统用同一套用户信息,同一个地方登录。
问题二:用同一套用户信息可以了,但如果进每个系统都要输一次账户密码登录还是很麻烦的。所以这里还要一处登录 ,处处登录,登录了其中一个系统,进入其它系统的时候不需要登录
效果如下图所示
用户在sso中心登录后的token在站点A,站点B都能使用,并且站点A,站点B和sso中心不需要通讯也能自己鉴别token是否是有效的。
怎么做到站点和sso串联呢?具体的流程是用户打开站点A,发现未登录 ,那站点A会跳转到sso中心登录并且把自己的url带上,sso中心登录成功后,跳转回站点带过来的url并把token也带上。
那站点A登录成功了,站点B怎么共享这个Token呢,做法是,sso中心登录成功的时候同时存一份Token到cookie(或localstorage等地方),当用户进入站点B的时候,发现没登录,跳转到sso中心带上自己的url,sso中心发现cookie有token了,直接跳转回站点B的url并把token带上,这样站点B就能实现token共享和自动登录了。
新建一个AuthenticationCenter项目
新建一个AuthenticatinController控制器
Login的view视图
其它相关类
上面sso的登录功能就完成了,打开Login页面就能获取到Token了。
新建一个站点A
修改startup.cs文件,在ConfigureServices方法里加上
在Configure方法里加上
新建一个UserController
其它相关类
密钥要和sso中心的保持一致,上面的5000端口是sso的端口,27271端口是站点端口。
以上是关于第22天-单点登录SSO,JWT实现无状态登录,自定义网关过滤器处理登录验证的主要内容,如果未能解决你的问题,请参考以下文章