单点登录
功能介绍

问题的引出
在微服务架构中,我们的整个应用被分解为登录的用户才能访问这些接口,比如查询用户的订单,查看用户的购物车,或者用户个人资料等等接口。但是还有另外一些服务的另外一些接口的访问,是不需要登录的,比如获取电商网站首页的商品类目,查看商品详情等等接口。
我们先来简单回顾下,单体架构中的登录,以及登录身份认证过程

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

假设用户在没有登录的情况下,发起查看购物车的请求:
- 当请求转发给订服务的时候,订单服务,订单服务发现用户的请求中没有包含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中获取登录会话,来判断用户是否登录过。
登录实现

在项目中引入用户服务

在登录页面输入用户名和密码后,点击登录提交登录请求,在用户服务中,需要实现如下Controller方法
@RestControllerpublic 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>/** * 全局过滤器 * 对所有的请求进行拦截(在这个商城中,不是所有的请求都需要登录,只有部分业务的接口才需要登录) * 如果已经登录,那么放行 * 如果没有登录,那么拦截 * */@Componentpublic 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(); }}文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!