单点登录

2472 字
12 分钟
单点登录

功能介绍#

问题的引出#

在微服务架构中,我们的整个应用被分解为登录的用户才能访问这些接口,比如查询用户的订单,查看用户的购物车,或者用户个人资料等等接口。但是还有另外一些服务的另外一些接口的访问,是不需要登录的,比如获取电商网站首页的商品类目,查看商品详情等等接口。

我们先来简单回顾下,单体架构中的登录,以及登录身份认证过程

在微服务架构中,我们按照相同的方式,再来分析下登录,以及登录身份认证过程:

假设用户在没有登录的情况下,发起查看购物车的请求:

  • 当请求转发给订服务的时候,订单服务,订单服务发现用户的请求中没有包含JsessionId的Cookie,认为用户没登录,于是给前端返回需要登录的响应码,前端自动跳转到登录页面。
  • 在登录界面,用户提交用户名和密码,向登录服务发起请求,登录服务验证用户发送的用户名和密码无误后,创建session,并返回包含JsessionId的Cookie
  • 登录成功后,用户再次发起查看订单的请求,此时虽然携带了包含JsessionId的Cookie,但是这个JsessionId对应却并不是订单服务中的某一个Session的id,即在订单服务中找不到与此JssionId对应的Session,于是订单服务还是认为用户未登录。

经过以上分析,我们发现,在微服务架构中,适用于单体应用的登录以及登录验证逻辑,在微服务架构中不适用了。所以,在微服务架构中,我们就需要解决单点登录问题——Single Sign On,简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

解决思路#

要解决单点登录问题,思路不止一种,在我们的项目中,我们采用共享会话的方案来解决。

这个思路的核心要点,当用户登录成功之后,将用户的会话信息,存储在一个共享的地方(比如Redis),而不是某个服务的内存中,这样一来就可以通过访问共享会话来判断用户的登录状态。

对于用户服务而言:

  • 一旦判断用户登录成功,就将用户信息存储到Redis,这就相当于将用户登录之后的会话,存储到Redis中。
  • 同时,随机生成Token,作为Redis中,会话信息的key,通过该Token获取Redis中对应的共享会话信息
  • 该Token还需要返回给前端。

对于前端而言,只要用户登陆成功,就会接收到Redis中,登录会话对应的Token,前端就会在请求Cookie中携带该Token,发起请求的时候,会在请求中携带该Cookie。

对于服务而言:

  • 因为所有的请求都是经过网关的,所以登录身份认证的工作,没有必要在每个服务中完成,而是统一在网关中完成即可
  • 所以网关会拦截那些需要做登录身份认证的请求,并根据请求中携带是否携带Cookie,以及通过Cookie中的Token是否能在Redis中获取登录会话,来判断用户是否登录过。

登录实现#

在项目中引入用户服务

![](/assets/firefly-docs/microservice/microservice-20-sso/introduce user-3.png)

在登录页面输入用户名和密码后,点击登录提交登录请求,在用户服务中,需要实现如下Controller方法

@RestController
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private RedissonClient redissonClient;
/**
* 登录
*/
@PostMapping("/user/login")
public Result login(@RequestBody UserInfoParam userInfo, HttpServletRequest request) {
// 生成uuid字符串,作为Redis存储登录会话的key
String token = UUID.randomUUID().toString().replaceAll("-", "");
// 从当前请求中,获取请求发起的IP地址
String ipAddress = IpUtil.getIpAddress(request);
UserLoginDTO loginInfo = userService.login(userInfo, ipAddress, token);
if (loginInfo != null) {
// 登录匹配成功
return Result.ok(loginInfo);
}
return Result.build(null, UserCodeEnum.USER_LOGIN_CHECK_FAIL);
}
}

还有实现登录的业务接口

public interface UserService {
/**
* 登录方法
*/
UserLoginDTO login(UserInfoParam userInfo, String ip, String token);
}
@Override
public UserLoginDTO login(UserInfoParam userInfo, String ip, String token) {
// select * from userInfo where userName = ? and passwd = ?
// 注意密码是MD5密文存储
String passwd = userInfo.getPasswd(); //123
String newPasswd = DigestUtils.md5DigestAsHex(passwd.getBytes());
LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfo::getLoginName, userInfo.getLoginName());
queryWrapper.eq(UserInfo::getPasswd, newPasswd);
UserInfo info = userInfoMapper.selectOne(queryWrapper);
if (info == null) {
// 未查询到,匹配登录失败
return null;
}
// 登录匹配成功,则向redis保存用户登录信息
UserLoginInfoDTO userLoginInfo = new UserLoginInfoDTO();
// 登录用户id
userLoginInfo.setUserId(info.getId().toString());
// 登录用户id
userLoginInfo.setIp(ip);
// 获取存储登录会话的桶
RBucket<UserLoginInfoDTO> bucket = redissonClient.getBucket(UserConstants.USER_LOGIN_KEY_PREFIX + token);
// 向桶中存储用户id和ip地址,相当于保存登录会话
bucket.set(userLoginInfo, UserConstants.USERKEY_TIMEOUT, TimeUnit.SECONDS);
// 返回用户昵称和登录会话token
return new UserLoginDTO(info.getNickName(), token);
}

登录验证逻辑实现#

首先,gateway工程中需要引入user-api依赖

<dependency>
<groupId>com.cskaoyan.mall</groupId>
<artifactId>user-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
/**
* 全局过滤器
* 对所有的请求进行拦截(在这个商城中,不是所有的请求都需要登录,只有部分业务的接口才需要登录)
* 如果已经登录,那么放行
* 如果没有登录,那么拦截
*
*/
@Component
public class AuthFilter implements GlobalFilter {
@Value("${authUrls.url}")
String authUrl; // /*/auth/**
// 创建一个路径匹配工具
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Autowired
RedissonClient redissonClient;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取当前请求的路径
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
// 获取path
String path = request.getURI().getPath();
// 如果path是内部的接口 /api/*/inner/** , 要拦截
// TODO
// 2. 根据请求的路径判断是否需要登录
boolean isNeedLogin = judgeLogin(path);
// 3. 判断当前用户是否已经登录
String userId = hasLogin(request);
// 如果ip被盗用了,那么拦截
if ("-1".equals(userId)) {
return out(response, ResultCodeEnum.ILLEGAL_REQUEST);
}
// 4. 如果当前路径需要登录且用户未登录,拦截
if (isNeedLogin && StringUtils.isEmpty(userId)) {
return out(response, ResultCodeEnum.LOGIN_AUTH);
}
// 5. 对于其他的情况,放行
// 当前路径不需要登录
// 当前路径需要登录且用户已经登录
ServerHttpRequest.Builder builder = request.mutate(); // 复制一个新的请求头
if (!StringUtils.isEmpty(userId)) {
// 把用户id放到请求头中去,方便后续服务直接从请求头中获取用户id
// 因为在此已经获取过一次用户id了,如果不做任何操作,直接放行,
// 那么后续各个服务收到请求之后,还是需要根据请求中的token查询Redis
builder.header("userId", userId);
}
// 为了方便购物车的业务逻辑,可以也统一的把临时用户id放入请求头
String userTempId = this.getUserTempId(request);
// 把临时用户id放入请求头
if (!StringUtils.isEmpty(userTempId)) {
builder.header("userTempId", userTempId);
}
ServerHttpRequest newRequest = builder.build();
// 把newRequest放入exchange对象
ServerWebExchange newExChange = exchange.mutate().request(newRequest).build();
return chain.filter(newExChange);
}
// 用户临时用户id
private String getUserTempId(ServerHttpRequest request) {
String userTempId = request.getHeaders().getFirst("userTempId");
if (StringUtils.isEmpty(userTempId)) {
// 从请求的cookie中获取token
HttpCookie httpCookie = request.getCookies().getFirst("userTempId");
if (httpCookie != null) {
userTempId = httpCookie.getValue();
}
}
return userTempId;
}
/**
* 获取用户信息
* @param request
* @return
* null: 说明用户未登录
* “-1”: 说明 ip被盗用了
* userId:说明用户已登录
*/
private String hasLogin(ServerHttpRequest request) {
// 1. 获取令牌
// 从请求头中获取 token
String token = request.getHeaders().getFirst("token");
if (StringUtils.isEmpty(token)) {
// 从请求的cookie中获取token
HttpCookie httpCookie = request.getCookies().getFirst("token");
if (httpCookie != null) {
token = httpCookie.getValue();
}
}
// 2. 如果没有令牌,那么返回null,表示用户未登录
if (StringUtils.isEmpty(token)) {
return null;
}
// 3. 如果有令牌,返回通过令牌查询Redis, 获取用户信息
String key = UserConstants.USER_LOGIN_KEY_PREFIX + token;
RBucket<UserLoginInfoDTO> bucket = redissonClient.getBucket(key);
UserLoginInfoDTO userLoginInfoDTO = bucket.get();
// 4. 如果用户信息获取失败,那么说明这个令牌无效,返回null,表示用户未登录
if (userLoginInfoDTO == null) {
return null;
}
// 5. 因为在登录的时候,记录了用户的ip
// 那么在此处进行拦截,验证用户是否已经登录,也可以获取当前客户端的ip
// 如果redis中的ip和当前请求的ip是一致的,说明是来自同一台主机的请求(登录主机和现在发请求的是同一台主机)
// 如果不一致,说明登录的主机和当前发请求的主机,不同同一台主机(有可能是token被盗用了)
String ip = userLoginInfoDTO.getIp();
String currentIp = IpUtil.getGatwayIpAddress(request);
if (!ip.equals(currentIp)) {
return "-1";
}
// 6. 如果这个令牌查到了用户信息,那么说明用户已经登录,那么返回用户id
String userId = userLoginInfoDTO.getUserId();
return userId;
}
/**
* 判断当前路径的请求是否需要登录
* @param path
* @return
*
* /index
* /order/auth/getOrder
*/
private boolean judgeLogin(String path) {
boolean ret = antPathMatcher.match(authUrl, path);
return ret;
}
// 接口鉴权失败返回数据
private Mono<Void> out(ServerHttpResponse response, ResultCodeEnum resultCodeEnum) {
// 返回用户没有权限登录
Result<Object> result = Result.build(null, resultCodeEnum);
// 将result对象转化为json字符串,并将字符串转化为字节数据
byte[] bits = JSONObject.toJSONString(result).getBytes(StandardCharsets.UTF_8);
// 封装一个字节数据为一个DataBuffer,消息体数据
DataBuffer wrap = response.bufferFactory().wrap(bits);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
// 输出到页面
return response.writeWith(Mono.just(wrap));
}
public static void main(String[] args) {
/**
* SpringMVC用来做路径匹配的工具
*/
AntPathMatcher antPathMatcher = new AntPathMatcher();
String path = "/order/auth/getOrder";
boolean ret = antPathMatcher.match("/*/auth/**", path);
System.out.println(ret);
}
}

退出登录#

退出登录的思路就很简单了,我们只需要在Redis中删除,用户对应的登录会话即可,这样一来,下次访问需要登录的接口时,就会因为获取不到用户在Redis中获取到登录会话,而被要求重新登录了

@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private RedissonClient redissonClient;
/**
* 退出登录
* @param request
* @return
*/
@GetMapping("logout")
public Result logout(HttpServletRequest request){
RBucket<String> token = redissonClient.getBucket(UserConstants.USER_LOGIN_KEY_PREFIX + request.getHeader("token"));
token.delete();
return Result.ok();
}
}

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

单点登录
https://firefly-mu-weld.vercel.app/posts/microservice-20-sso/
作者
Daisy
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录