Microservices in Golang - Part4

内容提要:微服务的认证

原作者:Ewan Valentine

原文链接:https://ewanvalentine.io/microservices-in-golang-part-4/

引言

上文中,我们创建了user服务,并且开始将用户信息存储到数据库中。现在我们需要一种更安全的保存密码的方式,并且需要在微服务中引入密钥分发和用户鉴权的机制。

注意,现在我把我们的服务拆分成了多个仓库,这确实更容易部署一些,其实一开始我确实是想把所有的代码都放到一起的,但是后来我发现这样很难管理Go项目的依赖,总是引起很多冲突(译者注:你知道我踩了多少坑吗!),之后我也会开始说下该怎么独立的运行和测试微服务了。

UPDATE 2020/05/04:

  • 优化目录结构
  • 添加运行截图
  • 程序结构图

不幸的是,现在我们也没法用 docker-compose了,但是其实也还好(影响不大),如果你有什么好的建议,发过来

译者注:

其实在所有微服务项目的父目录中写 docker-compose 也是可以的。

在开始之前,先来看看整个程序的运行流程:

Shippy Infrastructure

consignment-cli 将货运订单发送给 consignment-service ,并由后者对下订单的用户进行认证,并将订单持久化到数据库中。本节中我们主要做的就是完善这个认证功能,用户注册之后获得一个 token,唯一标识用户的身份,在下订单的过程中,用户需要提供他的 tokentokenconsignment-service 转发到 user-service 进行解析,获取用户的信息并鉴权,符合条件的用户才能下订单。

另外需要做的一件事是手动运行数据库容器:

1
2
3
4
5
6
$ docker run -d -p 5432:5432 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=postgres \
postgres
$ docker run -d -p 27017:27017 mongo

新的代码仓库在这里:

首先我们要做的就是把 handler 中的密码进行哈希,这非常有必要,永远都不要使用明文保存密码!可能有人会说 “这不是废话吗?”,这当然不是废话,因为真的有项目这么干!


密码哈希

现在我们更新 user-service/handler.go 中的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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
}

// 将用户密码与数据库中的值进行比对
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 {

// 对用户输入的密码进行哈希
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
}

这里没什么大的改动,仅加入了对密码进行哈希加密的函数,我们把哈希之后的值作为实际的密码进行保存,并且在服务鉴权时,我们也是用哈希值进行比对的。


现在我们可以非常安全的将用户信息与数据库中的信息进行比对了。在这里还需要一套机制以便可以在用户界面和各个服务中使用这个功能。有很多方法可以实现这个,但是我能想到的最简单的方法还是JWT.

在继续看接下来的内容之前,你最好先检查下代码库拆分之后,Dockerfile 和 Makefile是不是正确的,新的git仓库可供参考。


JWT

JWT 即 JSON Web Tokens,这是一个分布式安全协议,与 OAuth相似,它的原理非常简单,你可以使用一个算法针对每个用户产生唯一的哈希值,该值可以用来比较或者做认证。不仅如此,哈希值还包含了用户的元信息 metadata,这个也能作为加密字符串的一部分,如下:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

这个 Token 以 . 为间隔分为三个部分 header.payload.signature ,每个部分都有其特殊含义。

header

包括用于描述 Token 自身的元数据,像是 Token 的类型、生成 Token 所用的算法等,客户端就可以利用这段信息来对 Token 进行解码。如:

1
2
3
4
{
"typ": "JWT",
"alg": "HS256"
}

payload

存放 metadata 的地方,可以是用户的详细信息、令牌的过期时间或者其他你想要包含的信息。

signature

将 header、payload 及密钥共同加密后获取,用于客户端做数据校验,保证 token 在传输过程中没有被更改。

当然使用 JWT 也有一些不好的地方以及一些风险,这篇文章关于这点写得很好,建议你先读读这篇文章,以保证安全性上的最佳实践。

有一点我尤其推荐你去做的,就是在生成 Token 时加入用户的IP地址,这可以保证任何人都不能偷走你的令牌,在其他设备上伪装成你。确保你使用的是https,可以有效的避免你的令牌被“中间人攻击”。

生成 JWT 的哈希算法有很多种,大致分为两类:对称加密和非对称加密。对称加密是我们即将使用的,加密解密使用的都是同一把“密钥”;非对称加密使用私钥和公钥来分别进行加密和解密,非常适合多个服务之间的认证。

一些更多的参考资源:


JWT 加密解密

现在我们已经了解了 JWT 的基本原理,让我们来更新一下我们的 token_service.go ,这里用到一个非常棒的库:github.com/dgrijalva/jwt-go ,里面包含了一些非常好的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
"time"

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

var (
// 定义哈希时所用的盐
// 仅作参考,实际使用时应该用随机生成的md5值或者其他
key = []byte("fusidicsSuperSecretKey")
)

// CustomClaims 作为元数据,在被哈希之后作为第二段数据被发送给JWT
type CustomClaims struct {
User *pb.User
jwt.StandardClaims
}

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

// TokenService ...
type TokenService struct {
repo Repository
}

// Decode 将token字符串解码为token对象
func (srv *TokenService) Decode(token string) (*CustomClaims, error) {

// 解析token
tokenType, err := jwt.ParseWithClaims(string(key), &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
return key, nil
})

// 验证token并返回custom claims
if claims, ok := tokenType.Claims.(*CustomClaims); ok && tokenType.Valid {
return claims, nil
}
return nil, err
}

// Encode 将claim编码为JWT
func (srv *TokenService) Encode(user *pb.User) (string, error) {

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

// 创建Claims
claims := CustomClaims{
user,
jwt.StandardClaims{
ExpiresAt: expireToken,
Issuer: "user",
},
}

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

// 注册token并返回
return token.SignedString(key)
}

这部分的内容比较简单,就是 Decode() 接受一个 token 字符串,将其解析为 token 对象并进行认证,认证成功则返回 claim,claim 中包含的用户元数据帮助我们对用户进行认证。

Encode() 方法则正好相反,它将你的用户数据进行哈希,并返回一个新的 JWT token 字符串。

注意到我们在开头设置了一个新变量 key,这是一个安全密钥,生产环境中还需要用一个更安全的方式。

token 生成

有了认证服务,接下来更新一下 user-cli 吧,这次我把 user-cli 精简成了一个脚本(因为前面的代码有问题),之后会改,但是目前这个脚本足够用来测试我们的微服务了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 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 字符串,之后会用到它!consignment_1

译者注:

由于数据库容器是手动起的,方便起见,直接手动运行 user-service,需要注意的是,user-service 运行需要指定 POSTGRES 相关环境变量:

1
$ docker run --net="host" -e MICRO_REGISTRY=mdns -e MICRO_ADDRESS=:50053 -e DB_HOST=localhost -e DB_USER=postgres -e DB_NAME=postgres -e DB_PASSWORD=postgres user-service

这一步也是为了将用户的信息写入到 postgres 数据库中,以便之后 consignment-service 验证 token.

使用以下指令可以查看数据库中的内容:

1
2
3
4
5
6
7
8
$ docker exec -it 1a1 /bin/bash

$ root@1a16e40cf390:/# su postgres
postgres@1a16e40cf390:/$ psql
psql (12.2 (Debian 12.2-2.pgdg100+1))
Type "help" for help.

$ postgres=# select * from users;

token 验证

现在我们需要更新 consignment-cli ,用上这个 Token,并把它传到 consignment-service 的上下文中去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// consignment-cli/cli.go
...
func main() {

cmd.Init()

// 创建客户端
client := pb.NewShippingServiceClient("consignment", microclient.DefaultClient)

// 连接服务器并输出返回值
file := defaultFilename
var token string
log.Println(os.Args)

if len(os.Args) < 3 {
log.Fatal(errors.New("Not enough arguments, expecting 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)
}

// 创建包含 token 的 context
// context 同时会被作为调用传到 consignment-service 中
ctx := metadata.NewContext(context.Background(), map[string]string{
"token": token,
})

// 第一次调用,包含 token
// 将货物存储到指定用户的仓库里
r, err := client.CreateConsignment(ctx, consignment)
if err != nil {
log.Fatalf("Could not create %v", err)
}
log.Printf("Created: %t", r.Created)

// 第二次调用
// 列出目前所有托运的货物
getAll, err := client.GetConsignments(ctx, &pb.GetRequest{})
if err != nil {
log.Fatalf("Could not list consignments: %v", err)
}
for i, v := range getAll.Consignments {
log.Printf("consignment_%d %v \n", i, v)
}
}

再来更新一下 consignment-service 来检查请求是否有权限,并将请求传给 user-service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// consignment-service/main.go
func main() {
...
// 创建一个新的服务 包括了一些 micro options
srv := micro.NewService(

// Name 必须要和 consignment.proto 中的包名保持一致
micro.Name("go.micro.srv.consignment"),
micro.Version("latest"),
// 认证服务的中间件
micro.WrapHandler(AuthWrapper),
)
...
}

...

// AuthWrapper 是一个高阶参数,传入一个 HandlerFunc 并返回一个函数。
// 返回的函数以 context,request,response 作为接口参数
// token 从 consignment-cli 的上下文中获取,并被传到 user-service 中进行认证
// 认证通过,返回的函数继续执行;认证不通过,报错并返回
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")
}

// 注意这里是用的大写 (我也不是很清楚为什么要这样写)
token := meta["Token"]
log.Println("Authenticating with token: ", token)

// 认证
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
}
}

$ cd consignment-cli 到路径下,重新使用 $ make build 来构建 docker 镜像,并运行:

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

注意到这里我们用到了 --net='host' 这个标签,这告诉 Docker 将Docker 容器运行在宿主网络 (如 127.0.0.1 或者 localhost ),而不是在 Docker 内部的网络中。这里不再需要使用像 -p 8080:8080 这样的端口映射,你可以直接加上 -p 8080,关于 Docker 网络,可以阅读这个.

consignment_2

当你运行的时候,可以看到一个新的 consignment 被创建出来,试着删掉 Token 的一部分字符,Token 就会失效,你可以看到会产生报错。


gRPC 实现

好了,我们终于创建了一个 JWT 服务以及一个用于认证 JWT 秘钥的中间层来认证我们的用户。如果你不想使用 go-micro,而是使用原生的 grpc, 你需要将你的中间件改成下面的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 中对认证进行了一个封装:

1
2
3
4
5
6
7
8
9
10
11
// user-service/main.go
...
func AuthWrapper(fn server.HandlerFunc) server.HandlerFunc {
return func(ctx context.Context, req server.Request, resp interface{}) error {
// 通过环境变量 DISABLE_AUTH 确认是否需要跳过认证服务
if os.Getenv("DISABLE_AUTH") == "true" {
return fn(ctx, req, resp)
}
...
}
}

别忘了在 Makefile 中加入新的内容:

1
2
3
4
5
6
7
8
9
// consignment-service/Makefile
...
run:
docker run -d --net="host" \
-p 50051 \
-e MICRO_SERVER_ADDRESS=:50051 \
-e MICRO_REGISTRY=mdns \
-e DISABLE_AUTH=true \
consignment-service

这个方法使本地运行微服务的部分子集更加容易,当然也有一些其他的方法,但我感觉这个是最简单的。虽然在方向上有些小小的改动,但是我希望你认为这是有用的。同时,如果你对如何让一个单一仓库在本地运行有任何建议,请务必告诉我,不胜感激!

任何漏洞,错误或者反馈,欢迎你通过邮件[告诉我](mailto: ewan.valentine89@gmail.com)。


觉得这系列文章对您有帮助,可以请原作者喝杯咖啡,Cheers! https://monzo.me/ewanvalentine.

或者通过 Patreon 来支持原作者。


Toubleshoot

原文中有些小错误已经直接在译文中修改了,以下为运行过程中可能遇到的问题做了一些说明。

  • 运行 consignment-cli 时,user-service panic 并报错

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    2020/05/05 16:38:08 log.go:18: Transport [http] Listening on [::]:39885       
    2020/05/05 16:38:08 log.go:18: Broker [http] Connected to [::]:44651
    2020/05/05 16:38:08 log.go:18: Registry [mdns] Registering
    node: user-d8819fec-f072-4ed1-aa40-92cfb84acad5
    panic: runtime error: invalid memory address or nil pointer dereference
    [signal SIGSEGV: segmentation violation code=0x1 addr=0x30 pc=0x12d0b0d]

    goroutine 101 [running]:
    main.(*TokenService).Decode(0xc000701010, 0xc0006ae1a0, 0x18b, 0xc0000ce7e0, 0x1700c00, 0xc0000ce7e0)
    /root/workspace/go/user-service/token_service.go:42 +0xcd
    main.(*service).ValidateToken(0xc00032dfe0, 0x1700c00, 0xc0000ce7e0, 0xc000220500, 0xc000221950, 0x7f2c810c82f0, 0x0)
    /root/workspace/go/user-service/handler.go:88 +0x4e
    github.com/fusidic/user-service/proto/user.(*UserService).ValidateToken(0xc000701610, 0x1700c00, 0xc0000ce7e0, 0xc000220500, 0xc000221950, 0x0, 0x0)
    /root/workspace/go/user-service/proto/user/user.pb.go:444 +0x5b
    reflect.Value.call(0xc000246980, 0xc00047a650, 0x13, 0x1560137, 0x4, 0xc0006adc80, 0x4, 0x4, 0x16, 0x14e33c0, ...)
    /usr/local/go/src/reflect/value.go:460 +0x8ab
    reflect.Value.Call(0xc000246980, 0xc00047a650, 0x13, 0xc0006adc80, 0x4, 0x4, 0x8d33a6, 0xc0002219a0, 0x50)
    /usr/local/go/src/reflect/value.go:321 +0xb4
  • 解决方法: 修改 user-service/token_service.go

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    ...
    // Decode 将token字符串解码为token对象
    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
    }
    }
    ...
  • 上述修改之后,再次运行 consignment-cli ,会在 consignment-service/repository.goGetAll() 方法中出现引用空指针的错误,错误信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    panic: runtime error: invalid memory address or nil pointer dereference
    [signal SIGSEGV: segmentation violation code=0x1 addr=0x40 pc=0xf97476]

    goroutine 109 [running]:
    go.mongodb.org/mongo-driver/mongo.(*Cursor).next(0x0, 0x150a200, 0xc000356cc0, 0x0, 0x0)
    /go/pkg/mod/go.mongodb.org/mongo-driver@v1.3.2/mongo/cursor.go:95 +0x26
    go.mongodb.org/mongo-driver/mongo.(*Cursor).Next(...)
    /go/pkg/mod/go.mongodb.org/mongo-driver@v1.3.2/mongo/cursor.go:74
    main.(*MongoRepository).GetAll(0xc000182688, 0x150a200, 0xc000356cc0, 0x150a200, 0xc000356cc0, 0xc00031a890, 0x4ba2fc, 0x1203fa0)
    /app/repository.go:120 +0x107
    main.(*handler).GetConsignments(0xc00000c860, 0x150a200, 0xc000356cc0, 0xc00000c560, 0xc0001a3630, 0x7f2bd40e4080, 0x0)
    /app/handler.go:49 +0x4b
    github.com/fusidic/consignment-service/proto/consignment.(*ShippingService).GetConsignments(0xc00003bff0, 0x150a200, 0xc000356cc0, 0xc00000c560, 0xc0001a3630, 0x0, 0x0)
    /app/proto/consignment/consignment.pb.go:361 +0x5b

    参见 stackoverflow 上的一个回答:

    1
    2
    3
    cur, err := repository.collection.Find(ctx, nil, nil)
    // 修改为
    cur, err := repository.collection.Find(ctx, bson.D{}, nil)

    具体可以参见 mongo-driver 的范例

  • 只是测试环境下,docker run 或者 直接运行 都是可以的,唯一需要注意的就是环境变量

    1
    2
    3
    4
    5
    6
    7
    $ docker run --net="host" -e MICRO_REGISTRY=mdns -e MICRO_ADDRESS=:50052 -e DB_HOST=mongodb://localhost:27017 vessel-service

    $ docker run --net="host" -e MICRO_ADDRESS=:50051 -e MICRO_REGISTRY=mdns -e DB_HOST=mongodb://localhost:27017 consignment-service

    $ docker run --net="host" -e MICRO_REGISTRY=mdns -e MICRO_ADDRESS=:50053 -e DB_HOST=localhost -e DB_USER=postgres -e DB_NAME=postgres -e DB_PASSWORD=postgres user-service

    $ docker run --net="host" -e MICRO_REGISTRY=mdns consignment-cli consignment.json eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVc2VyIjp7ImlkIjoiMTRmZDVjMmUtMTc0OS00MTEwLThkZjctZjc5YjM1NGIxODhmIiwibmFtZSI6IkV3YW4gVmFsZW50aW5lIiwiY29tcGFueSI6IkJCQyIsImVtYWlsIjoiZXdhbi52YWxlbnRpbmU4OUBnbWFpbC5jb20iLCJwYXNzd29yZCI6IiQyYSQxMCR3Wmw0dmFNVlBNZzVZL0tkc3BkdWVPSU53SU1GNFR4ZG1MbjA4U1JRQ1A3eGEzTXhQUXlXeSJ9LCJleHAiOjE1ODg5MjIxNTQsImlzcyI6InVzZXIifQ.SptniIf0hIs3_5og5BGLSxC7vK57MO2nQYphi9RGCBI