微服务统一认证与授权的 Go 语言实现

KelsenBard 发布于3月前

今天笔者要写得是 Go 微服务相关的组件实践,笔者在好几年前就接触 Go 语言,去年开始从事 Go 微服务相关的开发,在过程中也和小伙伴联合编写了一本 《Go 高并发与微服务实战》书籍,即将出版上市。本文是截取其中的抢先版阅览,介绍微服务统一认证与授权的 Go 语言实现。

1 前言

统一认证与授权是微服务架构的基础功能,微服务架构不同于单体应用的架构,认证和授权非常集中。当服务拆分之后,对各个微服务认证与授权变得非常分散,所以在微服务架构中,将集成统一认证与授权的功能,作为横切关注点。

2 常见的认证与授权方案

常见的认证与授权方案有 OAuth、分布式 Session、OpenID 和 JWT 等,下面我们将分别介绍这四种方案。

2.1 OAuth

OAuth2 相关理论的介绍主要来自于OAuth2官方文档,相关地址为 https://tools.ietf.org/html/rfc6749 。

OAuth 协议的目的是为了为用户资源的授权提供一个安全的、开放而简易的标准。官网中的介绍如下:

An open protocol to allow secure API authorization in a simple and standard method from web, mobile and desktop applications.

OAuth1 由于不被 OAuth2 兼容,且签名逻辑过于复杂和授权流程的过于单一,在此不过多谈论,以下重点关注OAuth2认证流程,它是当前Web应用中的主流授权流程。

OAuth2是当前授权的行业标准,其重点在于为Web应用程序、桌面应用程序、移动设备以及室内设备的授权流程提供简单的客户端开发方式。它为第三方应用提供对HTTP服务的有限访问,既可以是资源拥有者通过授权允许第三方应用获取HTTP服务,也可以是第三方以自己的名义获取访问权限。

角色

OAuth2 中主要分为了4种角色

  • resource owner 资源所有者,是能够对受保护的资源授予访问权限的实体,可以是一个用户,这时会被称为end-user。
  • resource server 资源服务器,持有受保护的资源,允许持有访问令牌(access token)的请求访问受保护资源。
  • client 客户端,持有资源所有者的授权,代表资源所有者对受保护资源进行访问。
  • authorization server 授权服务器,对资源所有者的授权进行认证,成功后向客户端发送访问令牌。

在很多时候,资源服务器和授权服务器是合二为一的,在授权交互的时候是授权服务器,在请求资源交互是资源服务器。但是授权服务器是单独的实体,它可以发出被多个资源服务器接受的访问令牌。

协议流程

首先看一张来自官方提供的流程图:

+--------+                               +---------------+
 |        |--(1)- Authorization Request ->|   Resource    |
 |        |                               |     Owner     |
 |        |<-(2)-- Authorization Grant ---|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(3)-- Authorization Grant -->| Authorization |
 | Client |                               |     Server    |
 |        |<-(4)----- Access Token -------|               |
 |        |                               +---------------+
 |        |
 |        |                               +---------------+
 |        |--(5)----- Access Token ------>|    Resource   |
 |        |                               |     Server    |
 |        |<-(6)--- Protected Resource ---|               |
 +--------+                               +---------------+
复制代码

这是一张关于OAuth2角色的抽象交互流程图,主要包含以下的6个步骤:

  1. 客户端请求资源所有者的授权;
  2. 资源所有者同意授权,返回授权许可(Authorization Grant),这代表了资源所有者的授权凭证;
  3. 客户端携带授权许可要求授权服务器进行认证,请求访问令牌;
  4. 授权服务器对客户端进行身份验证,并认证授权许可,如果有效,返回访问令牌;
  5. 客户端携带访问许可向资源服务器请求受保护资源的访问;
  6. 资源服务器验证访问令牌,如果有效,接受访问请求,返回受保护资源。 客户端授权类型

为了获取访问令牌,客户端必须获取到资源所有者的授权许可。OAuth2默认定了四种授权类型,当然也提供了用于定义额外的授权类型的扩展机制。默认的四种授权类型为:

  • authorization code 授权码类型
  • implicit 简化类型(也称为隐式类型)
  • resource owner password credentials 密码类型
  • client credential 客户端类型

下面对常用的授权码类型和密码类型进行详细的介绍。

授权码类型

授权码类型(authorization code)通过重定向的方式让资源所有者直接与授权服务器进行交互来进行授权,避免了资源所有者信息泄漏给客户端,是功能最完整、流程最严密的授权类型,但是需要客户端必须能与资源所有者的代理(通常是Web浏览器)进行交互,和可从授权服务器中接受请求(重定向给予授权码),授权流程如下:

+----------+
 | Resource |
 |   Owner  |
 |          |
 +----------+
      ^
      |
     (2)
 +----|-----+          Client Identifier      +---------------+
 |         -+----(1)-- & Redirection URI ---->|               |
 |  User-   |                                 | Authorization |
 |  Agent  -+----(2)-- User authenticates --->|     Server    |
 |          |                                 |               |
 |         -+----(3)-- Authorization Code ---<|               |
 +-|----|---+                                 +---------------+
   |    |                                         ^      v
  (1)  (3)                                        |      |
   |    |                                         |      |
   ^    v                                         |      |
 +---------+                                      |      |
 |         |>---(4)-- Authorization Code ---------'      |
 |  Client |          & Redirection URI                  |
 |         |                                             |
 |         |<---(5)----- Access Token -------------------'
 +---------+       (w/ Optional Refresh Token)
复制代码
  1. 客户端引导资源所有者的用户代理到授权服务器的endpoint,一般通过重定向的方式。客户端提交的信息应包含客户端标识(client identifier)、请求范围(requested scope)、本地状态(local state)和用于返回授权码的重定向地址(redirection URI);
  2. 授权服务器认证资源所有者(通过用户代理),并确认资源所有者允许还是拒绝客户端的访问请求;
  3. 如果资源所有者授予客户端访问权限,授权服务器通过重定向用户代理的方式回调客户端提供的重定向地址,并在重定向地址中添加授权码和客户端先前提供的任何本地状态;
  4. 客户端携带上一步获得的授权码向授权服务器请求访问令牌。在这一步中授权码和客户端都要被授权服务器进行认证。客户端需要提交用于获取授权码的重定向地址;
  5. 授权服务器对客户端进行身份验证,和认证授权码,确保接收到的重定向地址与第三步中用于的获取授权码的重定向地址相匹配。如果有效,返回访问令牌,以及可能返回的刷新令牌(Refresh Token)。

密码类型

密码类型(resource owner password credentials)需要资源所有者将密码凭证交予客户端,客户端通过自己持有的信息直接向授权服务器获取授权。在这种情况下,需要资源所有者对客户端高度可信任,同时客户端不允许保存密码凭证。这种授权类型适用于能够获取资源所有者的凭证(credentials)(如用户名和密码)的客户端。授权流程如下:

+----------+
 | Resource |
 |  Owner   |
 |          |
 +----------+
      v
      |    Resource Owner
     (1) Password Credentials
      |
      v
 +---------+                                  +---------------+
 |         |>--(2)---- Resource Owner ------->|               |
 |         |         Password Credentials     | Authorization |
 | Client  |                                  |     Server    |
 |         |<--(3)---- Access Token ---------<|               |
 |         |    (w/ Optional Refresh Token)   |               |
 +---------+                                  +---------------+
复制代码
  1. 资源所有者向客户端提供其用户名和密码等凭证;
  2. 客户端携带资源所有者的凭证(用户名和密码),向授权服务器请求访问令牌;
  3. 授权服务器认证客户端并且验证资源所有者的凭证,如果有效,返回访问令牌,以及可能返回的刷新令牌(Refresh Token)。

令牌刷新

客户端从授权服务器中获取的访问令牌(access token)一般是具备失效性的,在访问令牌过期的情况下,持有有效用户凭证的客户端可以再次向授权服务器请求访问令牌,但是如果不持有用户凭证的客户端可以通过和上次访问令牌一同返回的刷新令牌(refresh token)向授权服务器获取新的访问令牌。

2.2 分布式 Session

2.2.1 什么是 Session,什么是 Cookie?

HTTP 协议是无状态的协议。一旦数据交换完毕,客户端与服务器端的连接就会关闭,再次交换数据需要建立新的连接。这就意味着服务器无法从连接上跟踪会话。

会话,指用户登录网站后的一系列动作,比如浏览商品添加到购物车并购买。会话(Session)跟踪是 Web 程序中常用的技术,用来跟踪用户的整个会话。常用的会话跟踪技术是 Cookie 与 Session。

Cookie 实际上是一小段的文本信息。客户端请求服务器,如果服务器需要记录该用户状态,就使用 response 向客户端浏览器颁发一个 Cookie。客户端会把 Cookie 保存起来。

当浏览器再请求该网站时,浏览器把请求的网址连同该 Cookie 一同提交给服务器。服务器检查该 Cookie,以此来辨认用户状态。服务器还可以根据需要修改 Cookie 的内容。

Session 是另一种记录客户状态的机制,不同的是 Cookie 保存在客户端浏览器中,而 Session 保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录

在服务器上。这就是 Session。客户端浏览器再次访问时只需要从该 Session 中查找该客户的状态就可以了。

每个用户访问服务器都会建立一个 session,那服务器是怎么标识用户的唯一身份呢?事实上,用户与服务器建立连接的同时,服务器会自动为其分配一个 SessionId。

简单来说,Cookie 通过在客户端记录信息确定用户身份,Session通过在服务器端记录信息确定用户身份。

2.3 OpenID

某些站点看到允许以 OpenID 的方式登陆,如使用 Facebook 账号或者 Google 账号登陆站点。

OpenID 和 OAuth 很像。但本质上来说它们是截然不同的两个东西:

  • OpenID: 只用于 身份认证(Authentication),允许你以 同一个账户 在 多个网站登陆。它仅仅是为你的 合法身份 背书,当你以 Facebook 账号登陆某个站点之后,该站点 无权访问 你的在 Facebook 上的 数据。
  • OAuth: 用于 授权(Authorisation),允许 被授权方 访问 授权方 的 用户数据。

2.4 JWT

JWT,JSON Web Token,作为一个开放的标准,通过紧凑(compact,快速传输,体积小)或者自包含(self-contained,payload中将包含用户所需的所有的信息,避免了对数据库的多次查询)的方式,定义了用于在各方之间发送的安全JSON对象。

为什么要介绍JWT,因为JWT可以很好的充当在上一节介绍的访问令牌(access token)和刷新令牌(refresh token)的载体,这是Web双方之间进行安全传输信息的良好方式。当只有授权服务器持有签发和验证JWT的secret,那么就只有授权服务器能验证JWT的有效性以及发送带有签名的JWT,这就唯一保证了以JWT为载体的token的有效性和安全性。

JWT的组成

JWT格式一般如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiY2FuZyB3dSIsImV4cCI6MTUxODA1MTE1NywidXNlcklkIjoiMTIzNDU2In0.IV4XZ0y0nMpmMX9orv0gqsEMOxXXNQOE680CKkkPQcs

它由三部分组成,每部分通过 . 分隔开,分别是:

  • Header 头部
  • Payload 有效负荷
  • Signature 签名

接着我们对每一部分进行详细的介绍。

Header

头部通常由两部分组成:

  • typ 类型,一般为jwt。
  • alg 加密算法,通常是HMAC SHA256或者RSA。

一个简单的头部例子如下:

{
	"alg": "HS256"
	"typ": "JWT"
}
复制代码

然后这部分JSON会被Base64Url编码用于构成JWT的第一部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Playload

有效负载是JWT的第二部分,是用来携带有效信息的载体,主要是关于用户实体和附加元数据的声明,由以下三部分组成:

  • Registered claims 注册声明,这是一组预定的声明,但并不强制要求,提供了一套有用的、能共同使用的声明。主要有iss(JWT签发者),exp(JWT过期时间),sub(JWT面向的用户),aud(接受JWT的一方)等。
  • Public claims 公开声明 公开声明中可以添加任何信息,一般是用户信息或者业务扩展信息等。
  • Private claims 私有声明 被JWT提供者和消费者共同定义的声明,既不属于注册声明也不属于公开声明。

一般不建议在payload中添加任何的敏感信息,因为Base64是对称解密的,这意味着payload中的信息的是可见的。

一个简单的有效负荷例子:

{
 	"name": "cang wu",
 	"exp": 1518051157,
 	"userId": "123456"
}
复制代码

这部分JSON会被Base64Url编码用于构成JWT的第二部分:

eyJuYW1lIjoiY2FuZyB3dSIsImV4cCI6MTUxODA1MTE1NywidXNlcklkIjoiMTIzNDU2In0

Signature

要创建签名,必须需要被编码后的头部、被编码后的有效负荷、一个secret,最后通过在头部的定义的加密算法alg加密生成签名,生成签名的伪代码如下:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)
复制代码

用到的加密算法为 HMACSHA256

secret 是保存在服务端用于验证JWT以及签发JWT,所以必须只由服务端持有,不该流露出去。

一个简单的签名如下:

IV4XZ0y0nMpmMX9orv0gqsEMOxXXNQOE680CKkkPQcs

这将成为JWT的第三部分。

最后这三部分通过.分割,组成最终的JWT,如下所示:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiY2FuZyB3dSIsImV4cCI6MTUxODA1MTE1NywidXNlcklkIjoiMTIzNDU2In0.IV4XZ0y0nMpmMX9orv0gqsEMOxXXNQOE680CKkkPQcs

3 授权服务器

3.1 整体架构

经过以上的简单介绍,我们已经了解了目前常见的统一认证与鉴权的方案,接下来我们将基于 OAuth2 协议和 JWT 实现一套简单的认证和授权系统。系统主要由两个服务组成,授权服务器和资源服务器,它们之间的交互图 11-4 所示:

微服务统一认证与授权的 Go 语言实现

客户端想要访问资源服务器中用户持有的资源信息,首先需要携带用户凭证向授权服务器请求访问令牌。授权服务器在验证过客户端和用户凭证的有效性后,它将返回生成的访问令牌给客户端。接着客户端携带访问令牌向资源服务器请求对应的用户资源,在资源服务器通过授权服务器验证过访问令牌有效后,将返回对应的用户资源。

很多时候,授权服务器和资源服务器是合二为一,即可以颁发访问令牌,也对用户资源受限访问;也可以将它们的职责划分得更加详细,授权服务器主要负责令牌的颁发和令牌的验证,而资源服务器负责对用户资源进行保护,仅允许持有有效访问令牌的请求访问受限资源。

授权服务器的主要职责有颁发访问令牌和验证访问令牌,对此我们需要对外提供两个接口:

  • /oauth/token 用于客户端携带用户凭证请求访问令牌
  • /oauth/check_token 用于验证访问令牌的有效性,返回访问令牌对应的客户端和用户信息。

一般来讲,每一个客户端都可以为用户申请访问令牌,因此一个有效的访问令牌是和客户端、用户绑定的,这表示某一用户授予某一个客户端访问资源的权限。

我们接下来实现的授权服务器主要包含以下模块,如图 11-5 所示:

微服务统一认证与授权的 Go 语言实现

  • ClientDetailsService,用于提供获取客户端信息;
  • UserDetailsService,用于获取用户信息;
  • TokenGrant,用于根据授权类型进行不同的验证流程,并使用 TokenService 生成访问令牌;
  • TokenService,生成并管理令牌,使用 TokenStore 存储令牌;
  • TokenStore,负责令牌的存储工作。

鉴于篇幅所限,我们的授权服务器仅提供密码类型获取访问令牌,但是提供了简便的可扩展的机制,读者可以根据自己的需要进行扩展实现。

3.2 用户服务和客户端服务

用户服务和客户端服务的作用类型,都是根据对应的唯一标识加载用户和客户端信息,用于接下来的用户信息和客户端信息的校验。我们定义的用户信息和客户端信息结构体如下:

type UserDetails struct {
	// 用户标识
	UserId int
	// 用户名 唯一
	Username string
	// 用户密码
	Password string
	// 用户具有的权限
	Authorities []string
}
// 验证用户名和密码是否匹配
func (userDetails *UserDetails)IsMatch(username string, password string) bool {
	return userDetails.Password == password && userDetails.Username == username
}


type ClientDetails struct {
	// client 的标识
	ClientId string
	// client 的密钥
	ClientSecret string
	// 访问令牌有效时间,秒
	AccessTokenValiditySeconds int
	// 刷新令牌有效时间,秒
	RefreshTokenValiditySeconds int
	// 重定向地址,授权码类型中使用
	RegisteredRedirectUri string
	// 可以使用的授权类型
	AuthorizedGrantTypes []string
}

// 验证 clientId 和 ClientSecret 是否匹配
func (clientDetails *ClientDetails) IsMatch(clientId string, clientSecret string) bool {
	return clientId == clientDetails.ClientId && clientSecret == clientDetails.ClientSecret
}
复制代码

除了它们具备的基本信息,还提供了 #IsMatch 方法用于验证账号信息和密码是否匹配的 方法。由于我们的信息都是明文存储的,所以直接比较信息是否相等即可,也可以根据项目的需求,在其中使用一些加密算法,避免敏感信息明文存储。

UserDetailsService 和 ClientDetailService 服务都仅提供一个方法,用于根据对应的标识加载信息,接口定义如下所示:

type UserDetailsService interface {
	// 根据用户名加载用户信息
	GetUserDetailByUsername(username string)(*UserDetails, error)
}

type ClientDetailService interface {
	// 根据 clientId 加载客户端信息
	GetClientDetailByClientId(clientId string) (*ClientDetails, error)
}
复制代码

用户信息和客户端信息可以来源多处,我们可以从数据库中、缓存中甚至通过 RPC 的方式从其他用户微服务中加载。

查看原文: 微服务统一认证与授权的 Go 语言实现

  • purplekoala
  • blackrabbit
  • PullanWoodrow
  • JonsonTammy
  • AlyCoral