Microservices in Golang - Part4
内容提要:微服务的认证
原作者:Ewan Valentine
原文链接:https://ewanvalentine.io/microservices-in-golang-part-4/
引言
上文中,我们创建了user服务,并且开始将用户信息存储到数据库中。现在我们需要一种更安全的保存密码的方式,并且需要在微服务中引入密钥分发和用户鉴权的机制。
注意,现在我把我们的服务拆分成了多个仓库,这确实更容易部署一些,其实一开始我确实是想把所有的代码都放到一起的,但是后来我发现这样很难管理Go项目的依赖,总是引起很多冲突(译者注:你知道我踩了多少坑吗!),之后我也会开始说下该怎么独立的运行和测试微服务了。
UPDATE 2020/05/04:
- 优化目录结构
- 添加运行截图
- 程序结构图
不幸的是,现在我们也没法用 docker-compose
了,但是其实也还好(影响不大),如果你有什么好的建议,发过来!
译者注:
其实在所有微服务项目的父目录中写
docker-compose
也是可以的。
在开始之前,先来看看整个程序的运行流程:
consignment-cli
将货运订单发送给consignment-service
,并由后者对下订单的用户进行认证,并将订单持久化到数据库中。本节中我们主要做的就是完善这个认证功能,用户注册之后获得一个token
,唯一标识用户的身份,在下订单的过程中,用户需要提供他的token
,token
由consignment-service
转发到user-service
进行解析,获取用户的信息并鉴权,符合条件的用户才能下订单。
另外需要做的一件事是手动运行数据库容器:
1 | $ docker run -d -p 5432:5432 \ |
新的代码仓库在这里:
- https://github.com/EwanValentine/shippy-consignment-service
- https://github.com/EwanValentine/shippy-user-service
- https://github.com/EwanValentine/shippy-vessel-service
- https://github.com/EwanValentine/shippy-user-cli
- https://github.com/EwanValentine/shippy-consignment-cli
首先我们要做的就是把 handler 中的密码进行哈希,这非常有必要,永远都不要使用明文保存密码!可能有人会说 “这不是废话吗?”,这当然不是废话,因为真的有项目这么干!
密码哈希
现在我们更新 user-service/handler.go
中的内容:
1 | // user-service/handler.go |
这里没什么大的改动,仅加入了对密码进行哈希加密的函数,我们把哈希之后的值作为实际的密码进行保存,并且在服务鉴权时,我们也是用哈希值进行比对的。
现在我们可以非常安全的将用户信息与数据库中的信息进行比对了。在这里还需要一套机制以便可以在用户界面和各个服务中使用这个功能。有很多方法可以实现这个,但是我能想到的最简单的方法还是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 | package main |
这部分的内容比较简单,就是
Decode()
接受一个 token 字符串,将其解析为 token 对象并进行认证,认证成功则返回 claim,claim 中包含的用户元数据帮助我们对用户进行认证。
Encode()
方法则正好相反,它将你的用户数据进行哈希,并返回一个新的 JWT token 字符串。
注意到我们在开头设置了一个新变量 key
,这是一个安全密钥,生产环境中还需要用一个更安全的方式。
token 生成
有了认证服务,接下来更新一下 user-cli
吧,这次我把 user-cli
精简成了一个脚本(因为前面的代码有问题),之后会改,但是目前这个脚本足够用来测试我们的微服务了:
1 | // user-cli/cli.go |
代码中我们将一些值写“死”了,根据你的情况替换掉这些值,使用 $ make build && make run
来运行,你应该可以看到返回一个很长的 Token 字符串,之后会用到它!
译者注:
由于数据库容器是手动起的,方便起见,直接手动运行
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 | // consignment-cli/cli.go |
再来更新一下 consignment-service
来检查请求是否有权限,并将请求传给 user-service
:
1 | // consignment-service/main.go |
$ cd consignment-cli
到路径下,重新使用 $ make build
来构建 docker 镜像,并运行:
1 | $ make build |
注意到这里我们用到了 --net='host'
这个标签,这告诉 Docker 将Docker 容器运行在宿主网络 (如 127.0.0.1
或者 localhost
),而不是在 Docker 内部的网络中。这里不再需要使用像 -p 8080:8080
这样的端口映射,你可以直接加上 -p 8080
,关于 Docker 网络,可以阅读这个.
当你运行的时候,可以看到一个新的 consignment 被创建出来,试着删掉 Token 的一部分字符,Token 就会失效,你可以看到会产生报错。
gRPC 实现
好了,我们终于创建了一个 JWT 服务以及一个用于认证 JWT 秘钥的中间层来认证我们的用户。如果你不想使用 go-micro,而是使用原生的 grpc, 你需要将你的中间件改成下面的样子:
1 | func main() { |
开关自如
这个设置可能无法在本地运行,但是我们不是总是需要在本地运行每个微服务。我们需要创建独立的微服务,并且可以在隔离的环境中测试。在我们的例子中,如果你只想要测试 consignment-service
,你应该不会想把 auth-service
也一并跑起来吧。一个小技巧就是设置一个开启或关闭其他服务的开关。
比如我在 consignment-service
中对认证进行了一个封装:
1 | // user-service/main.go |
别忘了在 Makefile 中加入新的内容:
1 | // consignment-service/Makefile |
这个方法使本地运行微服务的部分子集更加容易,当然也有一些其他的方法,但我感觉这个是最简单的。虽然在方向上有些小小的改动,但是我希望你认为这是有用的。同时,如果你对如何让一个单一仓库在本地运行有任何建议,请务必告诉我,不胜感激!
任何漏洞,错误或者反馈,欢迎你通过邮件[告诉我](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
182020/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.go
的GetAll()
方法中出现引用空指针的错误,错误信息:1
2
3
4
5
6
7
8
9
10
11
12
13
14panic: 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
3cur, 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