1. 单点登录 ​

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

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

假设用户在没有登录的情况下,发起查看购物车的请求:
- 当请求转发给订服务的时候,订单服务,订单服务发现用户的请求中没有包含JsessionId的Cookie,认为用户没登录,于是给前端返回需要登录的响应码,前端自动跳转到登录页面。
- 在登录界面,用户提交用户名和密码,向登录服务发起请求,登录服务验证用户发送的用户名和密码无误后,创建session,并返回包含JsessionId的Cookie
- 登录成功后,用户再次发起查看订单的请求,此时虽然携带了包含JsessionId的Cookie,但是这个JsessionId对应却并不是订单服务中的某一个Session的id,即在订单服务中找不到与此JssionId对应的Session,于是订单服务还是认为用户未登录。
经过以上分析,我们发现,在微服务架构中,适用于单体应用的登录以及登录验证逻辑,在微服务架构中不适用了。所以,在微服务架构中,我们就需要解决单点登录问题——Single Sign On,简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
1.2 解决思路 ​
要解决单点登录问题,思路不止一种,在我们的项目中,我们采用共享会话的方案来解决。
这个思路的核心要点,当用户登录成功之后,将用户的会话信息,存储在一个共享的地方(比如Redis),而不是某个服务的内存中,这样一来就可以通过访问共享会话来判断用户的登录状态。

对于用户服务而言:
- 一旦判断用户登录成功,就将用户信息存储到Redis,这就相当于将用户登录之后的会话,存储到Redis中。
- 同时,随机生成Token,作为Redis中,会话信息的key,通过该Token获取Redis中对应的共享会话信息
- 该Token还需要返回给前端。
对于前端而言,只要用户登陆成功,就会接收到Redis中,登录会话对应的Token,前端就会在请求Cookie中携带该Token,发起请求的时候,会在请求中携带该Cookie。
对于服务而言:
- 因为所有的请求都是经过网关的,所以登录身份认证的工作,没有必要在每个服务中完成,而是统一在网关中完成即可
- 所以网关会拦截那些需要做登录身份认证的请求,并根据请求中携带是否携带Cookie,以及通过Cookie中的Token是否能在Redis中获取登录会话,来判断用户是否登录过。
1.3 登录实现 ​

在项目中引入用户服务

在登录页面输入用户名和密码后,点击登录提交登录请求,在用户服务中,需要实现如下Controller方法
@RestController
@RequestMapping("/user")
public class LoginController {
@Autowired
private UserService userService;
@Autowired
private RedissonClient redissonClient;
/**
* 登录
*/
@PostMapping("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);
}1.4 登录验证逻辑实现 ​

首先,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 {
// /*/a
@Value("${authUrls.url}")
String authUrl;
@Autowired
RedissonClient redissonClient;
// SpringMVC 提供的路径匹配器
AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取当前请求path
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String path = request.getURI().getPath();
// 2. 判断是否是 内部接口,如果是内部接口,那么直接拦截
boolean isInnerPath = judgeInnerPath(path);
if (isInnerPath) {
return out(response,ResultCodeEnum.PERMISSION);
}
// 3. 判断当前请求path是否需要登录
boolean isNeedLogin = judgeLoginPath(path);
// 4. 判断当前是否已经登录
String userId = hasLogin(request);
if ("-1".equals(userId)) {
// ip被盗用
return out(response, ResultCodeEnum.ILLEGAL_REQUEST);
}
// 5. 如果需要登录,且当前 未登录,那么拦截
if (isNeedLogin && StringUtils.isEmpty(userId)) {
return out(response,ResultCodeEnum.LOGIN_AUTH);
}
// 6. 代码走到这里,那么说明【不需要登录】 或者是 【需要登录且当前已经登录】
// 7. 把用户id或者是临时用户id 放入请求头
// 获取临时用户id
String userTempId = getUserTempId(request);
ServerHttpRequest.Builder requestBuilder = request.mutate();
if (!StringUtils.isEmpty(userId)) {
requestBuilder.header("userId",userId);
}
if (!StringUtils.isEmpty(userTempId)) {
requestBuilder.header("userTempId",userTempId);
}
ServerHttpRequest newRequest = requestBuilder.build();
ServerWebExchange newExchange = exchange.mutate().request(newRequest).build();
// 8. 放行
return chain.filter(newExchange);
}
// 获取临时用户id
private String getUserTempId(ServerHttpRequest request) {
String userTempId = request.getHeaders().getFirst("userTempId");
if (StringUtils.isEmpty(userTempId)) {
HttpCookie cookie = request.getCookies().getFirst("userTempId");
if (cookie != null) {
userTempId = cookie.getValue();
}
}
return userTempId;
}
// 判断当前是否已经登录
private String hasLogin(ServerHttpRequest request) {
String token = request.getHeaders().getFirst("token");
if (StringUtils.isEmpty(token)) {
HttpCookie cookie = request.getCookies().getFirst("token");
if (cookie != null) {
token = cookie.getValue();
}
}
if (StringUtils.isEmpty(token)) {
return null;
}
// 从Redis中获取用户数据
RBucket<UserLoginInfoDTO> bucket = redissonClient.getBucket(UserConstants.USER_LOGIN_KEY_PREFIX + token);
UserLoginInfoDTO userLoginInfoDTO = bucket.get();
if (userLoginInfoDTO == null) {
return null;
}
String userId = userLoginInfoDTO.getUserId();
String ip = userLoginInfoDTO.getIp();
// 判断ip是否被盗用
String nowIp = IpUtil.getGatwayIpAddress(request);
if (!nowIp.equals(ip)) {
return "-1";
}
return userId;
}
// 判断当前 路径是否需要登录
private boolean judgeLoginPath(String path) {
String[] split = authUrl.split(",");
for (String url : split) {
boolean ret = antPathMatcher.match(url, path);
if (ret) {
return true;
}
}
return false;
}
// 判断是否是内部接口
private boolean judgeInnerPath(String path) {
boolean ret = antPathMatcher.match("/**/inner/**", 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));
}
}1.5 退出登录 ​
退出登录的思路就很简单了,我们只需要在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();
}
}