So much complexity in software comes from trying to make one thing do two things.

And the rest of the complexity comes from making two things do one thing.

-- Enix Jin

上一篇:从零开始的Microservices服务搭建(一)

基于微服务架构为软件开发带来了许多好处,包括小型开发团队、更短的开发周期、语言选择的灵活性、服务可扩展性等。

然而,不幸的是,微服务还引入了分布式系统的许多复杂问题。其中第一个挑战就是如何在微服务架构中实现灵活、安全、有效的身份验证(Authentication)和授权(Authorization)方案。


名词解释:

  • 验证(Authentication): 你是谁(Who you are),一般采用用户名+密码方式
  • 授权(Authorization): 你能做什么(What you can do),比如你能否对某个资源做删除操作

传统应用中的Authentication和Authorization

我们先看下在传统应用中,这个问题一般是怎么解决的:

  • 当用户登录时,应用程序的安全模块验证用户的身份
  • 在验证用户是合法的之后,为用户创建Session,并且将唯一的Session ID与Session相关联。Session中会存储登录用户信息,例如用户名、角色
  • 服务器将Session ID返回给客户端
  • 客户端将Session ID记录为cookie,并在后续请求中将其发送到服务端
  • 服务可以使用Session ID来验证用户的身份、权限,而无需每次都输入用户名和密码进行身份验证

大致流程如下图:


Microservices中的Authentication和Authorization

在微服务架构下,应用程序被分成多个微服务,每个微服务器只实现一个模块的业务逻辑。 在拆分之后,需要对每个微服务的访问请求进行身份验证和授权。如果您参考传统应用的实现,你将遇到以下问题:

  • 验证和授权需要在每个微服务中重复实现。(虽然我们可以使用代码库来重用部分代码,但这反过来会导致所有微服务依赖于特定的代码库及其版本,从而影响微服务语言/框架选择的灵活性)
  • 微服务应遵循单一责任原则。 微服务只处理单个业务逻辑。 身份验证和授权的全局逻辑不应放在微服务实现中。
  • HTTP是无状态协议。对于服务器,每次用户的HTTP请求是独立的。无状态意味着服务器可以根据需要将客户端请求发送到集群中的任何节点。HTTP的无状态设计对负载平衡有明显的好处。由于没有状态,用户请求可以分发到任何服务器。对于不需要身份验证的服务,例如浏览新闻页面,没有问题。但是,许多服务(例如在线购物和企业管理系统)是需要验证用户身份的。因此,需要以基于HTTP协议的方式保存用户的登录状态,以防止用户需要对每个请求执行验证。传统方式是使用服务器端的Session来保存用户状态。由于服务器是有状态的,无法水平扩展。

解决方案?

基于保留服务器端Session(不推荐)的解决大致有如下几个:

  • 1.负载均衡确保同一用户每次都访问同一个集群节点 (负载均衡会做的比较复杂,而且万一处于某种原因需要切换节点,会造成session丢失)
  • 2.Session复制,把session完整同步到每个节点上(服务器越多,对内存和带宽的消耗越大)
  • 3.独立的Session服务器,所有服务的Session存在一个共享的存储上(比如Redis)

但是,既然我们最初的设计就是基于REST的微服务,我们决定抛弃Session,采用Token的方式:

Token和Session之间的主要区别在于存储的位置不同。Session集中存储在服务器中,而Token由用户自己持有,并且通常以localStorage的形式存储在浏览器中。Token编码用户的身份信息,并且每次将请求发送到服务器时,服务器因此可以确定访问者的身份并确定其是否可以访问所请求的资源。

在上一篇里,我们把Gateway做为外部请求的唯一入口。这意味着所有请求都通过Gateway,有效地隐藏了微服务。我们能不能在Gateway上来做Authentication和Authorization呢?当然是可以的。我们甚至能更进一步,把Token机制的一个缺点一起解决掉。

我们知道为了保证服务器的无状态,我们一般把token放在HTTP的头里面,每次发送时都由服务器解析这个token,从而获取里面的payload、有效期等信息(比如JWT)。Token机制的一个问题是Token一旦发出去了,在有效期内是无法撤消的。但是现在有了Gateway这个统一入口,我们可以把JWT Token在Gateway转换成不透明的外部令牌。每次请求时,Gateway负责把这个外部令牌转换成内部微服务使用的JWT Token。这样在保持微服务无状态的情况下,我们顺便解决了Token无法撤消的问题,如下图:

Step2的source code在这里

Gateway暴露login和logout的方法,注意为了简化代码,我们没有把Token->JWT的内容放到Redis里面,而是直接在内存里的一个Map。我们也假设了所有的微服务使用了同一个JWT key(虽然稍微做扩展就能区分)。

@Post("/login")
async login(req) {
    let authService = global.microservices.get("/api/auth");
    if (authService && authService.nodes.filter(node => node.active === 1).length > 0) {
        ...
        let uuid = uuidv4();
        global.token.set(uuid, resp.body.jwt);
        return {token: uuid};
    } else {
        throw new serviceException("no active auth service!", 400, "auth service fail!");
    }
}

@Delete("/logout")
async logout(req) {
    if (req.get("token")) {
        global.token.delete(req.get("token"));
    }
    return {success: true};
}

Gateway的proxy部分也稍作修改,如果能根据Token获取到JWT,就把它写在header里面发送给微服务。(参加代码

最后,为了测试权限系统,我们对message微服务稍做修改:

@Get("")
async list() {
    return {success: true, nodePort: global.config.servicePort};
}

@Post({
    url: "",
    callbacks: [async (req, res, next) => {
        logger.debug(req.get("jwt"));
        let user = await encryption.getAuthentication(req);
        if (user.username === "enixjin") {
            next();
        } else {
            res.status(403).jsonp({error: `your are:${user.username}, only enixjin could access this api`});
        }
    }]
})
async sendMessage(req) {
    return {success: true, body: req.body, nodePort: global.config.servicePort};
}

可以看到GET方法对所有登录用户开放,而POST方法只对JWT的payload里解出的username为enixjin的用户开放。

简单的测试下:

登录:

然后我们用这个token去访问message微服务

如果尝试用不同用户的token去发布一个message时候,服务器正确的返回了403:

总结&Next

Step2的代码实现了下面的目标:

  • 由Gateway实现了登录
  • token->jwt的转换、存储使得系统更安全,并且注销成为可能
  • 最大程度的使microservice独立运行,只需要考虑自己的业务逻辑

后面的计划:微服务之间的互相调用问题、熔断、恢复、隔离等