Golang 中的微服务-第 4 部分-使用 JWT 做认证

ArisAries · 2018-03-03 20:17:08 · 7129 次点击 · 预计阅读时间 14 分钟 · 大约8小时之前 开始浏览    
这是一个创建于 2018-03-03 20:17:08 的文章,其中的信息可能已经有所发展或是发生改变。

在之前的文章里,我们创建了一个用户服务,保存了一些用户。现在我们看下如何在用户服务中安全的保存用户密码,同时,通过微服务写几个功能,来验证用户,发布安全令牌。

注意,我现在已经把我们的服务拆分到几个不同的仓库里。 我觉得这样部署起来容易些。 最开始我打算做成一个单独的仓库,但是发现用 Go 的 dep 管理有点麻烦,有很多冲突。我也会说明下如何独立地运行和测试微服务。

遗憾的是,用这种方法我们就不能用 docker-compose 了。 不过目前用起来还不错。如果你在这方面有什么建议,可以给我发邮件

现在你要手动启动数据库:

$ docker run -d -p 5432:5432 postgres
$ docker run -d -p 27017:27017 mongo

最新的仓库地址在下面:

首先,我们要更新下 handler 文件,做密码哈希,这是非常必要的。绝对不能,也坚决不要使用明文密码。你们可能会想 ‘废话,那还用说么’ ,但我还要坚持强调下!

// shippy-user-service/handler.go
... 
func (srv *service) Auth(ctx context.Context, req *pb.User, res *pb.Token) error {
    log.Println("Logging in with:", req.Email, req.Password)
    user, err := srv.repo.GetByEmail(req.Email)
    log.Println(user)
    if err != nil {
        return err
    }

    // Compares our given password against the hashed password
    // stored in the database
    if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
        return err
    }

    token, err := srv.tokenService.Encode(user)
    if err != nil {
        return err
    }
    res.Token = token
    return nil
}

func (srv *service) Create(ctx context.Context, req *pb.User, res *pb.Response) error {

    // Generates a hashed version of our password
    hashedPass, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return err
    }
    req.Password = string(hashedPass)
    if err := srv.repo.Create(req); err != nil {
        return err
    }
    res.User = req
    return nil
}

这里没做太多改动,除了增加了密码哈希功能,我们在保存新用户之前把哈希后的内容作为新的密码。同样的,在认证部分,我们会校验密码的哈希值。

现在我们能够安全的认证数据库里的用户信息,我们需要一个机制,能使用接口和分布式服务来做认证。实现这样的功能有许多方法,但是我发现,能通过服务和 web 使用的最简单的认证方法是用 JWT

不过在我们继续下面的内容之前,请查看下我在每个服务中的 Dockfiles 和 Makefiles 做的修改。为了匹配最新的 git 仓库,我也修改了 imports 。

JWT

JWT 是 JSON web tokens 的缩写,是一个分布式的安全协议。类似 OAuth。 概念很简单,用算法给用户生成一个唯一的哈希,这个哈希值可以用来比较和校验。不仅如此,token 自身也会包含用户的元数据 (metadata)。也就是说,用户数据本身也可以是 token 的一部分。 我们看一个 JWT 的实例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

token 被 '.' 分成三部分。每个部分都有各自的含义。第一部分是 token 自身的元数据,像 token 的类型,创建 token 使用的算法等。让客户端能知道怎么给 token 解码。第二部分是用户自定义的元数据。可以是你的用户详情,过期时间,任何你想填的内容。最后一个部分是认证签名,包括用什么方法,什么数据给 token 做哈希这类信息。

当然用 JWT 也有缺点和使用风险,这些缺点在 这篇文章 中总结的很好。同时,我推荐大家读 这篇文章_Cheat_Sheet_for_Java),这里有安全层面上的最佳实践方法。

有一点我想要大家特别注意下,就是获取用户的源 IP,并用它作为令牌声明的一部分。这样可以确保没有人能盗取你的令牌,并在另一台机器上伪装成你。确保使用 https,减少这种类型的攻击,因为使用 https 能保护你的 token,免受中间人攻击。

用来做 JWT 哈希的算法有很多,大体分为两类。对称加密和非对称加密。对称加密类似我们现在用的方式,使用共享盐值 (salt)。非对称加密会在客户端和服务端分别使用公钥和私钥。用来在多个服务之间做认证是极好的。

更多资源:

现在我们知道 JWT 的基本概念了。我们更新下 token_service.go 实现这些操作。我们可以用这个 Go 的库 github.com/dgrijalva/jwt-go ,这个库写的很棒,有很多不错的实例。

// shippy-user-service/token_service.go
package main

import (
    "time"

    pb "github.com/EwanValentine/shippy-user-service/proto/user"
    "github.com/dgrijalva/jwt-go"
)

var (

    // Define a secure key string used
    // as a salt when hashing our tokens.
    // Please make your own way more secure than this,
    // use a randomly generated md5 hash or something.
    key = []byte("mySuperSecretKeyLol")
)

// CustomClaims is our custom metadata, which will be hashed
// and sent as the second segment in our JWT
type CustomClaims struct {
    User *pb.User
    jwt.StandardClaims
}

type Authable interface {
    Decode(token string) (*CustomClaims, error)
    Encode(user *pb.User) (string, error)
}

type TokenService struct {
    repo Repository
}

// Decode a token string into a token object
func (srv *TokenService) Decode(tokenString string) (*CustomClaims, error) {

    // Parse the token
    token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
        return key, nil
    })

    // Validate the token and return the custom claims
    if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid {
        return claims, nil
    } else {
        return nil, err
    }
}

// Encode a claim into a JWT
func (srv *TokenService) Encode(user *pb.User) (string, error) {

    expireToken := time.Now().Add(time.Hour * 72).Unix()

    // Create the Claims
    claims := CustomClaims{
        user,
        jwt.StandardClaims{
            ExpiresAt: expireToken,
            Issuer:    "go.micro.srv.user",
        },
    }

    // Create token
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

    // Sign token and return
    return token.SignedString(key)
}

照常,我写了一些注释解释了部分细节,不过这里的导言写的比较简单。Decode 方法,是将一个字符串 token 解析成一个 token 对象,验证下,如果有效,则返回一个 CustomClaims 对象。这样我们能通过这个 CustomClaims 对象获取到用户的元数据来确认该用户是否有效。

Encode 方法,做的正好相反,将你的自定义的元数据,哈希成一个新的 JWT ,并返回。

注意我们在上面设置了一个 'key' 变量,这个 key 是一个安全盐值(salt),在生产环境下请用一个比这个更安全的盐值。

现在我们有了一个验证 token 的服务。我们更新下 user-cli,我现在已经把这部分简化成一个脚本,因为这之前的 cli 部分的代码有点问题,我以后再来说这个问题,这个工具就是为了测试用:

// shippy-user-cli/cli.go
package main

import (
    "log"
    "os"

    pb "github.com/EwanValentine/shippy-user-service/proto/user"
    micro "github.com/micro/go-micro"
    microclient "github.com/micro/go-micro/client"
    "golang.org/x/net/context"
)

func main() {

    srv := micro.NewService(

        micro.Name("go.micro.srv.user-cli"),
        micro.Version("latest"),
    )

    // Init will parse the command line flags.
    srv.Init()

    client := pb.NewUserServiceClient("go.micro.srv.user", microclient.DefaultClient)

    name := "Ewan Valentine"
    email := "ewan.valentine89@gmail.com"
    password := "test123"
    company := "BBC"

    r, err := client.Create(context.TODO(), &pb.User{
        Name:     name,
        Email:    email,
        Password: password,
        Company:  company,
    })
    if err != nil {
        log.Fatalf("Could not create: %v", err)
    }
    log.Printf("Created: %s", r.User.Id)

    getAll, err := client.GetAll(context.Background(), &pb.Request{})
    if err != nil {
        log.Fatalf("Could not list users: %v", err)
    }
    for _, v := range getAll.Users {
        log.Println(v)
    }

    authResponse, err := client.Auth(context.TODO(), &pb.User{
        Email:    email,
        Password: password,
    })

    if err != nil {
        log.Fatalf("Could not authenticate user: %s error: %v\n", email, err)
    }

    log.Printf("Your access token is: %s \n", authResponse.Token)

    // let's just exit because
    os.Exit(0)
}

目前我们有一些硬编码的值,替换下这些值,然后用 $make build && make run 执行脚本。你可以看到返回了一个 token。把这个长长的 token 值拷贝下,你马上就会用到!

现在我们更新下我们的 consignment-cli,生成一个 token 字符串,传给 consignment-service:

// shippy-consignment-cli/cli.go
...
func main() {

    cmd.Init()

    // Create new greeter client
    client := pb.NewShippingServiceClient("go.micro.srv.consignment", microclient.DefaultClient)

    // Contact the server and print out its response.
    file := defaultFilename
    var token string
    log.Println(os.Args)

    if len(os.Args) < 3 {
        log.Fatal(errors.New("Not enough arguments, expecing file and token."))
    }

    file = os.Args[1]
    token = os.Args[2]

    consignment, err := parseFile(file)

    if err != nil {
        log.Fatalf("Could not parse file: %v", err)
    }

    // Create a new context which contains our given token.
    // This same context will be passed into both the calls we make
    // to our consignment-service.
    ctx := metadata.NewContext(context.Background(), map[string]string{
        "token": token,
    })

    // First call using our tokenised context
    r, err := client.CreateConsignment(ctx, consignment)
    if err != nil {
        log.Fatalf("Could not create: %v", err)
    }
    log.Printf("Created: %t", r.Created)

    // Second call
    getAll, err := client.GetConsignments(ctx, &pb.GetRequest{})
    if err != nil {
        log.Fatalf("Could not list consignments: %v", err)
    }
    for _, v := range getAll.Consignments {
        log.Println(v)
    }
}

现在我们更新 consignment-service 来核对一下获取 token 的请求,并传给 user-service :

// shippy-consignment-service/main.go
func main() {
    ... 
    // Create a new service. Optionally include some options here.
    srv := micro.NewService(

        // This name must match the package name given in your protobuf definition
        micro.Name("go.micro.srv.consignment"),
        micro.Version("latest"),
        // Our auth middleware
        micro.WrapHandler(AuthWrapper),
    )
    ...
}

... 

// AuthWrapper is a high-order function which takes a HandlerFunc
// and returns a function, which takes a context, request and response interface.
// The token is extracted from the context set in our consignment-cli, that
// token is then sent over to the user service to be validated.
// If valid, the call is passed along to the handler. If not,
// an error is returned.
func AuthWrapper(fn server.HandlerFunc) server.HandlerFunc {
    return func(ctx context.Context, req server.Request, resp interface{}) error {
        meta, ok := metadata.FromContext(ctx)
        if !ok {
            return errors.New("no auth meta-data found in request")
        }

        // Note this is now uppercase (not entirely sure why this is...)
        token := meta["Token"]
        log.Println("Authenticating with token: ", token)

        // Auth here
        authClient := userService.NewUserServiceClient("go.micro.srv.user", client.DefaultClient)
        _, err := authClient.ValidateToken(context.Background(), &userService.Token{
            Token: token,
        })
        if err != nil {
            return err
        }
        err = fn(ctx, req, resp)
        return err
    }
}

然后我们执行 consignment-cli工具,cd 到新的 shippy-consignment-cli 仓库,执行 $make build 来创建新的 docker 镜像, 现在运行:

$ make build
$ docker run --net="host" \
      -e MICRO_REGISTRY=mdns \
      consignment-cli consignment.json \
      <TOKEN_HERE>

注意下我们在运行 docker 容器的时候使用了 --net="host" 标记。用来告诉 Docker 在本地局域网来运行我们的容器,如127.0.0.1 或 localhost,而不是 Docker 的内网。注意,用这个方法不需要使用端口转发。因此只需要用 -p 8080 代替 -p 8080:8080 就可以了。 更多关于 Docker 网络的内容请看这里

到这一步,你就能看到新的 consignment 被创建了。试着从 token 里删除几个字母,token 就会变无效。会出现错误。

好了,现在我们创建了一个 JWT token 的服务,和一个用来验证 JWT token 的中间件,而 JWT token 用来验证用户。

如果你不想用 go-micro,而是 vanilla grpc,你的中间件是类似这样的:

func main() {
    myServer := grpc.NewServer(
    grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(AuthInterceptor),
    )
}

func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {

    // Set up a connection to the server.
    conn, err := grpc.Dial(authAddress, grpc.WithInsecure())
    if err != nil {
    log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewAuthClient(conn)
    r, err := c.ValidateToken(ctx, &pb.ValidateToken{Token: token})

    if err != nil {
        log.Fatalf("could not authenticate: %v", err)
    }

    return handler(ctx, req)
}

这个设置在本地运行有点不灵活。但是我们并不总在本地运行每一个服务。我们创建的服务应该互相独立,能单独测试。现在的情况下,如果我们只需要测试 consignment-service,就不需要启动 auth-service。因此我在这加了一个开关,打开或关闭其他的服务。

我更新 consignment-service 里的 auth 封装:

// shippy-user-service/main.go
func AuthWrapper(fn server.HandlerFunc) server.HandlerFunc {
    return func(ctx context.Context, req server.Request, resp interface{}) error {
    // This skips our auth check if DISABLE_AUTH is set to true
        if os.Getenv("DISABLE_AUTH") == "true" {
            return fn(ctx, req, resp)
        }
    }
}

然后在 Makefile 里加个开关:

// shippy-user-service/Makefile
run:
    docker run -d --net="host" \
        -p 50052 \
        -e MICRO_SERVER_ADDRESS=:50052 \
        -e MICRO_REGISTRY=mdns \
        -e DISABLE_AUTH=true \
        consignment-service

用这个方式,在本地运行微服务中的某个部分,会更简单,解决这个问题还有几个不同的方法,但是我认为这个最简单。希望你觉得有点帮助,尽管在方向上有小改动。同时,如果你对使用单个仓库运行微服务这方面有什么建议,非常欢迎告知我,那么这部分内容就简单多了。

如果你发现这篇文章里有任何 bug,错误,或者你有对这篇文章有任何反馈或者有帮助的建议,请给我发邮件

如果你觉得这个系列文章有用,而且你用了广告过滤(俺没怪你)。请考虑打赏下我的辛苦劳动噢。非常感谢!。 https://monzo.me/ewanvalentine


via: https://ewanvalentine.io/microservices-in-golang-part-4

作者:André Carvalho  译者:ArisAries  校对:polaris1119

本文由 GCTT 原创编译,Go语言中文网 荣誉推出


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

7129 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传