【go语言微服务实践】#5-go-micro实现JWT认证

2,877 阅读7分钟

线上答题系统,微服务架构的小小实践,项目代码

一、服务认证概述

  处于安全和保密要求,常常要对API的请求进行鉴权。目前常见的鉴权的方法有JWT、OAuth2.0、OpenID等。我们系统选用的是比较简便的JWT方式。请求端web应用,使用beego框架,服务端是对外提供API的微服务,使用go-micro框架。客户端和服务器端使用RPC(远程过程调用)的方式进行API的调用。因此我们要实现的就是在go-micro框架中,客户端和服务端的RPC请求调用中加上JWT认证授权功能。REST API的请求认证也是类似的思路,可以参考。

二、JWT

2.1 定义

  JWT(JSON Web Tokens) ,一种基于token的json格式web认证方法,目前非常流行应用于鉴权和身份认证中。在常见的业务场景中为了安全,需要对每一个请求进行鉴权,通过才能继续请求资源。如果对每个请求都进行登陆密码验证,会十分低效且引发其他风险。JWT的做法是,在客户端第一次登陆请求后,服务端使用密钥、算法和一些信息(时间戳、用户信息等)给它生成个token返回,客户端收到后存储起来,如存在cookie、LocalStorage中,之后客户端的其他请求都携带该token。服务端收到其他请求后,使用密钥解密以验证携带的token是否正确,称之为鉴权。

2.2 认证流程

  1. 客户端携带用户名和密码发送登录请求
  2. 服务端收到请求后验证用户名密码是否与数据库一致,若通过则根据密钥、信息、算法等生成 token 返回给客户端
  3. 客户端收到后存储起来,如存在cookie、LocalStorage中,之后客户端的其他请求都携带该token,如将token放在请求header中
  4. 服务端收到其他请求后验证请求中携带的 Token是否正确,通过则继续请求资源并返回,否则给客户端返回鉴权失败

2.3 JWT的组成

  JWT 标准的 Token 有三个部分:header、payload、signature。各个部分中间用点分隔开,并且都会使用 Base64 编码,所以真正的 Token 看起来像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc

  1. Header

  Header 一般由两部分组成,一个是 Token 的类型,另一个是使用的算法,比如下面类型就是 JWT,使用的算法是 HS256。

{
  "typ": "JWT",
  "alg": "HS256"
}

上面的内容要用 Base64 的形式编码一下,所以就变成这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

  1. payload

  payload是放置实际有效使用信息的地方。以下是JWT定义的标准字段,使用者也可以添加其他内容

iss:Issuer,发行者
sub:Subject,主题
aud:Audience,接收 JWT Token 的一方
exp:Expiration time,过期时间
nbf:Not before,生效时间
iat:Issued at,发行时间
jti:JWT Token ID

本次使用的payload 内容为:

{ 
    "id": 用户ID, 
    "username": 用户username, 
    "nbf":系统时间, 
    "iat”:系统时间
}

使用 Base64 编码后如下

eyJpYXQiOjE1ODE0NzYzMzcsImlkIjoyLCJuYmYiOjE1ODE0NzYzMzcsInVzZXJuYW1lIjoidXNlciJ9

  1. Signature

  Signature 是 Token 的签名部分,通过如下方式生成:
(1)用 Base64 对 header、payload 的内容编码,然后用.符号连接起来
(2)用 Secret 对编码后的内容进行加密,加密后的内容即为 Signature

var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
HMACSHA256(encodedString, Secret);

  处理后大概是这个样子:

Oh2n9sgIRJ_6DB9YOufu5PifTQBiKRSsKOQbpsVnSJQ

  最后将header、payload、Signature的内容用.连接在一起,就是JWT了:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODE0NzYzMzcsImlkIjoyLCJuYmYiOjE1ODE0NzYzMzcsInVzZXJuYW1lIjoidXNlciJ9.Oh2n9sgIRJ_6DB9YOufu5PifTQBiKRSsKOQbpsVnSJQ

  客户端收到这个 Token 以后把它存储下来,下回向服务端发送请求的时候就带着这个 Token 。服务端收到这个 Token ,然后进行验证,通过以后就会返回给客户端想要的资源。

三、代码编写

  要进行的JWT API 身份认证改造包括三步:

  1. 认证模块签发 token,我们这里的认证模块就是登陆模块
  2. web应用存储token,且之后的每次请求都带上该token
  3. 每个服务对外提供的API

  添加认证中间件,若认证通过则继续操作,否则返回认证失败 结合我们的答题系统进行说明,web应用是客户端,UserManage.Login等其他一些service模块是服务端。web应用使用的框架是beego,服务端使用的框架是go-micro。通过go-micro的wrapper功能,可以实现JWT鉴权的功能。

3.1 认证模块签发 token

  API接口的Secret值存在服务端的conf/config.yaml 配置文件中,用户登录时web应用向UserManage.Login发送请求,UserManage.Login校验用户名和密码,如果用户名密码正确,则生成JWT token并返回,否则返回登陆失败。JWT的生成可以直接使用github.com/dgrijalva/j…这个开源项目,代码如下

import (
   "github.com/dgrijalva/jwt-go"
)

type Context struct{
   ID int64
   Username string
}

func SignJWT(c Context,secret string) (tokenstring string,err error){
   if(secret==""){
      secret = viper.GetString("jwt.secret")
   }

   token :=jwt.NewWithClaims(jwt.SigningMethodHS256,jwt.MapClaims{
      "id" : c.ID,
      "username": c.Username,
      "nbf": time.Now().Unix(),
      "iat":      time.Now().Unix(),
   })
   tokenstring,err = token.SignedString([]byte(secret))

   return tokenstring,err
}

3.2 web应用每次请求都带上该token

  web应用收到UserManage.Login返回的JWT token,并将其保存在session中,之后向各服务的请求头都带上,放在header的 Authorization字段。
  web应用和各个后端的service模块是通过go-micro的gRPC进行交互的,其中web应用是client,各个后端的service模块是server,但可惜go-micro中的http协议高度封装,没有直接提供方法可以设置client请求的header。怎么把token放到header中费了我一番功夫。
  通过go-micro源码的调用关系可以看见,当调用proto中定义的方法时(如UserManage.Login.GetUserById),会调用client模块的Call方法。默认情况下go-micro使用的是RPC调用方式,protobuf编码。

func (c *userManageService) GetUserById(ctx context.Context, in *GetUserByIdReq, opts ...client.CallOption) (*UserMesssage, error) {
   req := c.c.NewRequest(c.name, "UserManage.GetUserById", in)
   out := new(UserMesssage)
   err := c.c.Call(ctx, req, out, opts...)
   if err != nil {
      return nil, err
   }
   return out, nil
}

  client的Call()调用rpc_client.go的call()方法,在这个方法中会读取ctx context.Context中的metadata,并将metadata设置的键值对设置到header中(不过该key值必须是协议的header中定义好的,如Authorization)。接着封装Transport和Codec模块进行protobuf编码以及rpc调用,将请求发给server。
  因此我们可以通过一开始在ctx context.Context中设置metadata.Metadata{"Authorization": token},将token放入web对service模块的请求头中。

//web应用初始化client service的地方
func ServiceRegistryInit(s session.Store,serviceName string) (micro.Service,context.Context){
   //create service
   service := micro.NewService(micro.Name(serviceName),
      micro.Registry(consul.NewRegistry(func(options *registry.Options) {
      options.Addrs = []string{
         beego.AppConfig.String("consulhost")+":"+beego.AppConfig.String("consulport"),
      }
   })))
   service.Init()

   //设置JWT token
   token := ""
   if s != nil {
      //从session获取token
      tokenSession := s.Get("token")
      if tokenSession != nil {
         token = tokenSession.(string)
      }
   }
   md := metadata.Metadata{
      "Authorization": token,
   }
   ctx := metadata.NewContext(context.TODO(), md)

   return service,ctx
}

3.3 每个服务对外提供的API 添加认证中间件

  除UserManage.Login外,各个服务收到请求时都要校验JWT是否正确,正确则继续操作,错误则返回身份认证失败。这是一个通用的认证中间件,可以通过go-micro中的wrapper实现。定义一个认证中间件authWrapper,当服务收到请求时取出请求头header中的“Authorization”,并进行校验。其中token的校验主要是调用go-jwt的jwt.Parse(tokenString string, keyFunc Keyfunc)方法验证

import (
   "context"
   "errors"
   "time"

   "github.com/astaxie/beego/logs"
   "github.com/dgrijalva/jwt-go"
   "github.com/micro/go-micro/server"
   "github.com/spf13/viper"
)

func authWrapper(fn server.HandlerFunc) server.HandlerFunc {
   return func(ctx context.Context, req server.Request, rsp interface{}) error {
      logs.Debug("[authWrapper]", req.Endpoint())
      //登陆不需要验证
      if(req.Endpoint()=="UserManage.Login"){
         return fn(ctx, req, rsp)
      }
      header := req.Header()
      if(header==nil){
         //没有则返回错误
         logs.Error("[JWT auth]","get header wrong")
         return errors.New("get header wrong")
      }

      tokenString := header["Authorization"]
      secret := viper.GetString("jwt.secret")

      if(tokenString==""){
         logs.Error("[JWT auth]","no auth meta-data Authorization found in request")
         return errors.New("no auth meta-data Authorization found in request")
      }

      //token校验
      token, err := jwt.Parse(tokenString, secretFunc(secret))

      //失败
      if err != nil {
         logs.Error("[JWT auth]",err)
         return err
      //token校验成功
      } else if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
         //ctx.ID = uint64(claims["id"].(float64))
         //ctx.Username = claims["username"].(string)
         logs.Info("ID=", int64(claims["id"].(float64))," username=",claims["username"].(string))
         return fn(ctx, req, rsp)
      } else {
         logs.Error("[JWT auth]",err)
         return err
      }
      return fn(ctx, req, rsp)
   }
}

// secretFunc validates the secret format.
func secretFunc(secret string) jwt.Keyfunc {
   return func(token *jwt.Token) (interface{}, error) {
      if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
         return nil, jwt.ErrSignatureInvalid
      }
      return []byte(secret), nil
   }
}