本章介绍 gorpc 认证鉴权的实现,本章主要介绍原理和部分代码实现,全部代码可以参考:auth

在实现 gorpc 认证鉴权之前,我们需要了解一些认证鉴权方面的知识。

一、单体模式下的认证鉴权

在单体模式下,整个应用是一个进程,应用一般只需要一个统一的安全认证模块来实现用户认证鉴权。例如用户登陆时,安全模块验证用户名和密码的合法性。假如合法,为用户生成一个唯一的 Session。将 SessionId 返回给客户端,客户端一般将 SessionId 以 Cookie 的形式记录下来,并在后续请求中传递 Cookie 给服务端来验证身份。为了避免 Session Id被第三者截取和盗用,客户端和应用之前应使用 TLS 加密通信,session 也会设置有过期时间。

客户端访问服务端时,服务端一般会用一个拦截器拦截请求,取出 session id,假如 id 合法,则可判断客户端登陆。然后查询用户的权限表,判断用户是否具有执行某次操作的权限。

二、微服务模式下的认证鉴权

在微服务模式下,一个整体的应用可能被拆分为多个微服务,之前只有一个服务端,现在会存在多个服务端。对于客户端的单个请求,为保证安全,需要跟每个微服务都要重复上面的过程。这种模式每个微服务都要去实现相同的校验逻辑,肯定是非常冗余的。

1、用户身份认证

为了避免每个服务端都进行重复认证,采用一个服务进行统一认证。所以考虑一个单点登录的方案,用户只需要登录一次,就可以访问所有微服务。一般在 api 的 gateway 层提供对外服务的入口,所以可以在 api gateway 层提供统一的用户认证。

2、用户状态保持

由于 http 是一个无状态的协议,前面说到了单体模式下通过 cookie 保存用户状态, cookie 一般存储于浏览器中,用来保存用户的信息。但是 cookie 是有状态的。客户端和服务端在一次会话期间都需要维护 cookie 或者 sessionId,在微服务环境下,我们期望服务的认证是无状态的。所以我们一般采用 token 认证的方式,而非 cookie。

token 由服务端用自己的密钥加密生成,在客户端登录或者完成信息校验时返回给客户端,客户端认证成功后每次向服务端发送请求带上 token,服务端根据密钥进行解密,从而校验 token 的合法,假如合法则认证通过。token 这种方式的校验不需要服务端保存会话状态,方便服务扩展。

三、实现思路

由于业内比较通用的认证和鉴权方案比较类似,都是通过 tls 进行数据加密,通过 oauth2 进行权限校验。所以这里我们也是使用 tls + oauth2 的方式进行认证鉴权实现。这里不对 tls 和 oauth2 进行详细介绍,假如有不清楚的可以参考阮一峰老师的教程,介绍得比较清楚:

tls :www.ruanyifeng.com/blog/2014/0…

oauth2 :www.ruanyifeng.com/blog/2019/0…

这里需要补充介绍下 tls 认证的两种方式:

单向认证:只有一个对象校验对端的证书合法性,通常是 client 校验 server 的证书合法性,例如:浏览器

双向认证:两端都相互校验证书合法性。client 校验 server 证书,server 也校验 client 证书。一般用于银行、金融等对安全级别要求比较高的网站或者客户端

框架默认支持单向认证。即 client 校验 server 证书。

接下来介绍下实现思路:

要支持 tls,需要 server 端提供证书(生产环境中一般需要 CA 签发),客户端在握手时根据 CA 的公钥来验证 server 端证书的合法性。使用 oauth2 进行权限控制,需要 client 在请求时带上 token,然后 server 去校验 token,验证 token 的正确性。这里 client 请求 token 可以在请求参数中透传,server 端对于 token 的处理,可以通过拦截器的方式进行实现。

证书生成:

第一步:服务端生成私钥

openssl ecparam -genkey -name secp384r1 -out server.key

第二步:服务端使用私钥生成证书

openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650

这里需要填写一些信息(Common Name 需要填写服务名)

Country Name (2 letter code) []:
State or Province Name (full name) []:
Locality Name (eg, city) []:
Organization Name (eg, company) []:
Organizational Unit Name (eg, section) []:
Common Name (eg, fully qualified host name) []:testAuth
Email Address []:

上面生成了 server.crt 和 server.key 两个文件,我们将这两个文件放到 testdata 目录下

四、tls 认证实现

1、接口定义

tls 认证鉴权是在传输层握手的时候进行认证,所以我们定义一个 TransportAuth 接口,这个接口包括了 client 和 server 握手的两个函数

// TransportAuth defines a common interface for client and server handshakes
type TransportAuth interface {

   // ClientHandshake defines a common interface for client handshakes
   ClientHandshake(context.Context, string, net.Conn) (net.Conn, AuthInfo, error)

   // ServerHandshake defines a common interface for server handshakes
   ServerHandshake(conn net.Conn) (net.Conn, AuthInfo, error)

}

由于 go 官方包 “crypto/tls” 已经支持了 tls ,所以我们这里直接复用 “crypto/tls” 包的一些特性,主要是 tls 的配置 Config 和 连接状态 ConnectionState,

// tlsAuth defines the implementation of TLS authentication
// and implements TransportAuth, PerRPCAuth, AuthInfo
type tlsAuth struct {
   config *tls.Config
   state tls.ConnectionState
}

这里的 Config 信息需要通过传入的证书来获取,如下:

// NewClientTLSAuthFromFile instantiates client-side authentication information
// with certificates and service names
func NewClientTLSAuthFromFile(certFile, serverName string) (TransportAuth, error) {
   cert , err := ioutil.ReadFile(certFile)
   if err != nil {
      return nil, err
   }
   cp := x509.NewCertPool()
   if !cp.AppendCertsFromPEM(cert) {
      return nil, codes.ClientCertFailError
   }
   conf := &tls.Config {
      ServerName: serverName,
      RootCAs: cp,
   }
   return &tlsAuth{config : conf}, nil
}

2、实现客户端握手

这里主要思路为,先从 tls 的配置信息 Config 中获取认证信息,然后 tls.Client 方法会返回一个带有认证信息的连接 conn,然后使用这个连接 conn 进行握手,而不是原来的连接。如下:

// ClientHandshake implements the client's handshake
func (t *tlsAuth) ClientHandshake(ctx context.Context, authority string, rawConn net.Conn) (net.Conn, AuthInfo, error) {
   // 防止使用不同的 endpoints 时 ServerName 被污染
   cfg := cloneTLSConfig(t.config)
   if cfg.ServerName == "" {
      colonPos := strings.LastIndex(authority, ":")
      if colonPos == -1 {
         colonPos = len(authority)
      }
      cfg.ServerName = authority[:colonPos]
   }
   conn := tls.Client(rawConn, cfg)
   errChan := make(chan error, 1)

   go func() {
      errChan <- conn.Handshake()
   }()
   select {
   case err := <- errChan :
      if err != nil {
         return nil, nil, err
      }
   case <- ctx.Done() :
      return nil, nil, ctx.Err()
   }

   return WrapConn(rawConn,conn) , &tlsAuth{state : conn.ConnectionState()}, nil
}

3、server 端握手实现

server 端握手实现和 client 端握手实现的思路类似,先从 tls 的配置信息 Config 中获取认证信息,然后调用 tls.Server 方法获取一个带有认证信息的连接 conn,使用这个新的 conn 进行握手。

// the ServerHandshake implements the server handshake
func (t *tlsAuth) ServerHandshake(rawConn net.Conn) (net.Conn, AuthInfo, error) {
   conn := tls.Server(rawConn, t.config)
   if err := conn.Handshake(); err != nil {
      return nil, nil, err
   }
   return WrapConn(rawConn,conn), &tlsAuth{state : conn.ConnectionState()}, nil
}

4、测试 tls 握手

测试 tls 握手的过程,这里不进行赘述,详情请参考代码:auth_test

五、oauth2 鉴权实现

oauth2 鉴权的实现也是主要用到了 “golang.org/x/oauth2” 这个包,之前上面说到了鉴权的思路:client 请求 token 可以在请求参数中透传,server 端对于 token 的处理,可以通过拦截器的方式进行实现。

1、接口定义

我们定义一个接口 PerRPCAuth 来表示每次进行 rpc 请求都需要进行 token 认证,这里有一个 GetMetadata 方法,主要用来定义获取 token,可以继续看下面 oauth2 是怎么获取的。

// PerRPCAuth defines a common interface for single RPC call authentication
type PerRPCAuth interface {

   // GetMetadata fetch custom metadata from the context
   GetMetadata(ctx context.Context, uri ... string) (map[string]string, error)

}

2、oauth2 实现

oauth2 的实现主要是用到了 “golang.org/x/oauth2” 包的 Token 结构

type oAuth2 struct {
   token *oauth2.Token
}

oauth2.Token 的结构不妨也贴一下:

// Token represents the credentials used to authorize
// the requests to access protected resources on the OAuth 2.0
// provider's backend.
//
// Most users of this package should not access fields of Token
// directly. They're exported mostly for use by related packages
// implementing derivative OAuth2 flows.
type Token struct {
   // AccessToken is the token that authorizes and authenticates
   // the requests.
   AccessToken string `json:"access_token"`

   // TokenType is the type of token.
   // The Type method returns either this or "Bearer", the default.
   TokenType string `json:"token_type,omitempty"`

   // RefreshToken is a token that's used by the application
   // (as opposed to the user) to refresh the access token
   // if it expires.
   RefreshToken string `json:"refresh_token,omitempty"`

   // Expiry is the optional expiration time of the access token.
   //
   // If zero, TokenSource implementations will reuse the same
   // token forever and RefreshToken or equivalent
   // mechanisms for that TokenSource will not be used.
   Expiry time.Time `json:"expiry,omitempty"`

   // raw optionally contains extra metadata from the server
   // when updating a token.
   raw interface{}
}

实现了 GetMetadata 方法来进行 Token 的获取,如下:

func (o *oAuth2) GetMetadata(ctx context.Context, uri ... string) (map[string]string, error) {

   if o.token == nil {
      return nil, codes.ClientCertFailError
   }

   return map[string]string{
      "authorization": o.token.Type() + " " + o.token.AccessToken,
   }, nil
}

3、token 的透传

token 如何从 client 透传到 server 呢?这里主要通过 metadata,metadata 本质是一个 k-v 键值对

type clientMetadata map[string][]byte

type serverMetadata map[string][]byte

我们在 gorpc 协议里面 Request 和 Response 都支持了这种键值对的透传,如下:

message Request {
    string  service_path = 2;          // 请求服务路径
    map<string, bytes> metadata = 3;  // 透传的数据
    bytes  payload = 4;               // 请求体
}

message Response {
    uint32 ret_code = 1;               // 返回码 0-正常 非0-错误
    string ret_msg = 2;                 // 返回消息,OK-正常,错误会提示详情
    map<string, bytes> metadata = 3;   // 透传的数据
    bytes payload = 4;                 // 返回体
}

所以,我们只需要通过 oauth2 的 GetMetadata 获取 token 的 k-v 键值对,然后在 client 端发送请求的时候,塞到 Request 中,server 端收到请求,从 Request 取出 metadata,设置到 context 中,同时定义一个 authFunc,通过 authFunc 构造拦截器,从拦截器中取出 metadata,获取到 token 信息,然后校验 token 是否合法即可。如下:

通过 BuildAuthInterceptor,支持传入 AuthFunc 来构造一个 server 端拦截器。

// AuthFunc verifies that the token is valid or not
type AuthFunc func(ctx context.Context) (context.Context, error)


// BuildAuthFilter constructs a client interceptor with an AuthFunc
func BuildAuthInterceptor(af AuthFunc) interceptor.ServerInterceptor {

   return func(ctx context.Context, req interface{}, handler interceptor.Handler) (interface{}, error) {
      newCtx, err := af(ctx)

      if err != nil {
         return nil, codes.NewFrameworkError(codes.ClientCertFail, err.Error())
      }

      return handler(newCtx, req)
   }
}

4、全流程解析

server 端

业务需要定义一个 AuthFunc,这里面需要完成对 token 的校验。然后通过 gorpc.WithInterceptor(auth.BuildAuthInterceptor(af)) 来构造一个 server 端拦截器,对 rpc 请求进行拦截。

AuthFunc 中,先通过 md := metadata.ServerMetadata(ctx) 取出 metadata,然后从 metadata 中取出 token 信息,校验 token 信息是否合法。

func main() {

   af := func(ctx context.Context) (context.Context, error){
      md := metadata.ServerMetadata(ctx)

      if len(md) == 0 {
         return ctx, errors.New("token nil")
      }
      v := md["authorization"]
      log.Debug("token : ", string(v))
      if string(v) != "Bearer testToken" {
         return ctx, errors.New("token invalid")
      }
      return ctx, nil
   }

   opts := []gorpc.ServerOption{
      gorpc.WithAddress("127.0.0.1:8003"),
      gorpc.WithNetwork("tcp"),
      gorpc.WithSerializationType("msgpack"),
      gorpc.WithTimeout(time.Millisecond * 2000000),
      gorpc.WithInterceptor(auth.BuildAuthInterceptor(af)),
   }
   s := gorpc.NewServer(opts ...)
   if err := s.RegisterService("/helloworld.Greeter", new(testdata.Service)); err != nil {
      panic(err)
   }
   s.Serve()
}

client 端

通过 client.WithPerRPCAuth 方法,传入一个 PerRPCAuth,这里通过调用 auth.NewOAuth2ByToken(“testToken”) 方法生成一个 token

func main() {
   opts := []client.Option {
      client.WithTarget("127.0.0.1:8003"),
      client.WithNetwork("tcp"),
      client.WithTimeout(2000000 * time.Millisecond),
      client.WithSerializationType("msgpack"),
      client.WithPerRPCAuth(auth.NewOAuth2ByToken("testToken")),
   }
   c := client.DefaultClient
   req := &testdata.HelloRequest{
      Msg: "hello",
   }
   rsp := &testdata.HelloReply{}
   err := c.Call(context.Background(), "/helloworld.Greeter/SayHello", req, rsp, opts ...)
   fmt.Println(rsp.Msg, err)
}

我们看一下 NewOAuth2ByToken 这个方法,其实就是我们的 oauth2 包下的方法,它返回了一个 oAuth2 的对象,由于 oAuth2 实现了 PerRPCAuth 接口,所以可以通过 client.WithPerRPCAuth 直接设置每次 rpc 拦截的 token。

// NewOAuth2ByToken supports the generation of an oauth2 based on a string token
func NewOAuth2ByToken(token string) *oAuth2 {
   return &oAuth2{
      token : &oauth2.Token{
         AccessToken: token,
      },
   }
}

这里还差了一步,需要将 PerRPCAuth 中的 token 数据,塞到 client 的 Request 里面。这一步在 client 构造 Request 的时候完成,如下:

func addReqHeader(ctx context.Context, client *defaultClient, payload []byte) *protocol.Request {
   clientStream := stream.GetClientStream(ctx)

   servicePath := fmt.Sprintf("/%s/%s", clientStream.ServiceName, clientStream.Method)
   md := metadata.ClientMetadata(ctx)

   // fill the authentication information
   for _, pra := range client.opts.perRPCAuth {
      authMd, _ := pra.GetMetadata(ctx)
      for k, v := range authMd {
         md[k] = []byte(v)
      }
   }

   request := &protocol.Request{
      ServicePath: servicePath,
      Payload: payload,
      Metadata: md,
   }

   return request
}

至此,我们就完成了 client 到 server token 的透传、流转和校验。

具体代码可以参考我们的 example auth

小结

本章节主要介绍了认证鉴权的一些基础知识、模式和常用实现。并且使用 tls 和 oauth2 实现了 gorpc 框架的认证鉴权。需要读者自行了解一些认证鉴权的基础,否则可能有些吃力。