Skip to content

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 登录实现 ​

在项目中引入用户服务

![](单点登录.assets/introduce user-3.png)

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

java
@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);
    }

}

还有实现登录的业务接口

java
public interface UserService {

    /**
     * 登录方法
     */
    UserLoginDTO login(UserInfoParam userInfo, String ip, String token);

}
java
    @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 登录验证逻辑实现 &#8203;

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

xml
        <dependency>
            <groupId>com.cskaoyan.mall</groupId>
            <artifactId>user-api</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
java
@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 退出登录 &#8203;

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

java
@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();
    }
}