详细解析下gRPC examples-RBAC authenication-权限组管理-基于自定义Token
什么是RABC认证?
RBAC (Role-Based Access Control) 授权策略是一种用于控制系统或应用程序中用户或实体对资源的访问权限的方法。在 RBAC 中,访问控制是基于角色的,而不是基于个体用户的。以下是 RBAC 授权策略的核心概念:
-
角色(Roles):角色代表了一组用户或实体,这些用户或实体在系统中具有相似的权限需求或角色职责。例如,一个系统可以定义角色如管理员、编辑、普通用户等。
-
权限(Permissions):权限是指用户或实体可以执行的操作或访问的资源。每个角色都被赋予一组权限,这些权限决定了该角色能够进行的操作。
-
用户分配角色:每个用户或实体被分配到一个或多个角色,这些角色决定了他们在系统中的权限。用户与角色之间的关系可以是多对多的。
简单来说,我们把用户抽象成组别,比方说管理员组,普通用户组,管理员组的用户可以访问后台管理的接口,而普通用户组的用户则没有这个权利
RBAC 授权
此示例使用 google.golang.org/grpc/authz
包中的 StaticInterceptor
。它使用基于标头的 RBAC 策略来将每个 gRPC 方法与所需角色匹配。为简单起见,上下文中注入了模拟元数据,其中包括所需的角色,但这应该根据经过身份验证的上下文从适当的服务中获取。
服务端思维导图
服务端实现流程
首先创立token包,在其中实现对token结构的定义和解密加密方法
// Token 是模拟token用于在grpc客户端发送时带到RPC头部
// 并被服务端用预先制定好的策略进行校验
type Token struct {
// Secret 被服务端用与校验用户
Secret string `json:"secret"`
// Username 被服务端用于在授权的元数据中分配角色。
Username string `json:"username"`
}
// Encode 返回经过 Base64 编码的JSON令牌 。
// returns a base64 encoded version of the JSON representation of token.
func (t *Token) Encode() (string, error) {
barr, err := json.Marshal(t)
if err != nil {
return "", err
}
s := base64.StdEncoding.EncodeToString(barr)
return s, nil
}
// Decode 使用基于base64来更新Token的状态
// 将令牌以json形式表示
func (t *Token) Decode(s string) error {
barr, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return err
}
return json.Unmarshal(barr, t)
}
接下来要做的是在服务端中定义角色与其对应的权限
const (
unaryEchoWriterRole = "UNARY_ECHO:W"
streamEchoReadWriterRole = "STREAM_ECHO:RW"
authzPolicy = `
{
"name": "authz",
"allow_rules": [
{
"name": "allow_UnaryEcho",
"request": {
"paths": ["/grpc.examples.echo.Echo/UnaryEcho"],
"headers": [
{
"key": "UNARY_ECHO:W",
"values": ["true"]
}
]
}
},
{
"name": "allow_BidirectionalStreamingEcho",
"request": {
"paths": ["/grpc.examples.echo.Echo/BidirectionalStreamingEcho"],
"headers": [
{
"key": "STREAM_ECHO:RW",
"values": ["true"]
}
]
}
}
],
"deny_rules": []
}
`
)
让我来详细解析下这个权限的定义
//- `unaryEchoWriterRole` 表示一种角色,
// 允许客户端进行一元(单向)RPC并拥有写入权限。
//"UNARY_ECHO:W" 是这个角色的名字,表示它可以执行写入操作。
unaryEchoWriterRole = "UNARY_ECHO:W"
// streamEchoReadWriterRole 表示另一种角色,
// 允许客户端进行双向流式(双向通信)RPC并拥有读写权限。
// "STREAM_ECHO:RW" 是这个角色的名字,表示它可以执行读取和写入操作。
streamEchoReadWriterRole = "STREAM_ECHO:RW"
这些角色可以在服务器端用于授权决策。例如,如果客户端具有 unaryEchoWriterRole
角色,服务器将允许它执行一元RPC的写入操作。如果客户端具有 streamEchoReadWriterRole
角色,服务器将允许它执行双向流式RPC的读取和写入操作。
接下来来解释下authzPolicy这段代码定义了一个名为 authzPolicy
的授权策略,它用于控制哪些操作可以由客户端执行以及哪些角色可以执行这些操作。让我来详细解释:
-
name
: 这是授权策略的名称,通常用于标识和引用这个策略。 -
allow_rules
: 这是一个允许规则的列表,它指定了哪些操作是允许的。-
allow_UnaryEcho
: 这是一个名为allow_UnaryEcho
的允许规则,它指定了客户端可以执行一元RPC(单向通信)的操作。-
request
: 这个部分指定了在什么条件下允许执行这个操作。-
paths
: 这里定义了允许的操作路径。在这个例子中,它限定了只有/grpc.examples.echo.Echo/UnaryEcho
这个路径上的操作可以被执行。 -
headers
: 这是一个标头(header)的列表,它定义了在请求中需要满足的标头条件。-
key
: 这个标头的名称是 “UNARY_ECHO:W”,它与前面定义的角色unaryEchoWriterRole
匹配。 -
values
: 在这个情况下,它指定了 “true”,表示只有当客户端拥有unaryEchoWriterRole
角色时才允许执行这个操作。
-
-
-
-
allow_BidirectionalStreamingEcho
: 这是一个名为allow_BidirectionalStreamingEcho
的允许规则,它指定了客户端可以执行双向流式RPC(双向通信)的操作。它的结构与allow_UnaryEcho
类似,但是适用于不同的操作路径和角色。
-
-
deny_rules
: 这是一个拒绝规则的列表,用于指定哪些操作是被拒绝的。在这个示例中,没有定义任何拒绝规则,因此所有操作都被默认允许。 -
总之,
authzPolicy
定义了一个授权策略,该策略允许一元RPC和双向流式RPC的执行,但要求客户端具有特定的角色(在标头中指定)才能执行这些操作。接下来我们应当在服务端中加载证书并实现静态的拦截器
// 创建基于TLS通信的加密端. creds, err := credentials.NewServerTLSFromFile(data.Path("x509/server_cert.pem"), data.Path("x509/server_key.pem")) if err != nil { log.Fatalf("Loading credentials: %v", err) } // 创建一个基于静态策略的验证拦截器 staticInteceptor, err := authz.NewStatic(authzPolicy) if err != nil { log.Fatalf("Creating a static authz interceptor: %v", err) }
我们通过"google.golang.org/grpc/authz" authz中的NewStatic传入我们先前写好的authzPolicy,它会成功注册基于我们写好的策略的静态拦截器
接下来我们应当实现对于令牌头的校验和验证,让我们看下main函数中
// grpc.ChainUnaryInterceptor 是 gRPC 框架提供的函数,
//'它用于创建一组一元拦截器(Unary Interceptors)的链。
//一元拦截器是 gRPC 中用于拦截一元 RPC 调用的拦截器,这些拦截器可以在请求到达服务器之前或响应返回给客户端之前执行一些额外的逻辑。
// unaryInts 是一个变量名,用于存储创建的一元拦截器链。
// authUnaryInterceptor 是一个自定义的一元拦截器函数
// 它被传递给 grpc.ChainUnaryInterceptor 作为第一个参数。这个拦截器的作用是在每个一元 RPC 调用到达服务器之前,
// 验证客户端的授权令牌,并为该调用创建一个带有用户名的新上下文。
// staticInteceptor.UnaryInterceptor 是另一个拦截器,这是从 staticInteceptor 中提取的一元拦截器。
// staticInteceptor 是一个authz.NewStatic(authzPolicy)创建的授权拦截器,它用于检查是否允许特定的 RPC 调用。这个拦截器会在 authUnaryInterceptor 之后执行。
unaryInts := grpc.ChainUnaryInterceptor(authUnaryInterceptor, staticInteceptor.UnaryInterceptor)
streamInts := grpc.ChainStreamInterceptor(authStreamInterceptor, staticInteceptor.StreamInterceptor)
让我们看下authUnaryInterceptor的代码
// authUnaryInterceptor 从传入的RPC context中查找验证头
// 解析username 并创建一个新的context传给解析函数调用
func authUnaryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMissingMetadata
}
username, err := isAuthenticated(md["authorization"])
if err != nil {
return nil, status.Error(codes.Unauthenticated, err.Error())
}
//handler 是一个 gRPC 的一元处理函数(UnaryHandler)。它代表了实际的 gRPC 服务端处理逻辑,即在执行拦截器之后要调用的函数,处理客户端的 gRPC 请求。
//
//newContextWithRoles(ctx, username) 是一个自定义函数,用于创建一个新的上下文(context)对象,其中包含了用户的角色信息。
//这个函数在拦截器中的作用是,根据客户端提供的用户名(username)将用户的角色信息添加到上下文中。
//
//req 是客户端发送的 gRPC 请求的参数。在这个上下文中,handler 将使用包含角色信息的新上下文来处理请求。
return handler(newContextWithRoles(ctx, username), req)
}
按照调用的逻辑让我们里一下它做了什么
-
username, err := isAuthenticated(md["authorization"]) // md["authorization"] 的目的是从 gRPC 请求的元数据中提取授权头部的值。
-
我们首先调用isAuthenticated用于验证授权头部的值是否有效,并解析出其中的用户名。函数返回两个值:
username和
err。如果验证和解析成功,
username将包含用户名,
err将为
nil;如果发生错误,
err将包含一个描述错误的消息,而
username` 将为空字符串。 -
在头部值验证通过的情况下,在执行了拦截器后调用newContextWithRoles,用于创建一个新的上下文(context)对象,其中包含了用户的角色信息。这个函数在拦截器中的作用是,根据客户端提供的用户名(
username
)将用户的角色信息添加到上下文中。
然后我们注册写好的这些服务
s := grpc.NewServer(grpc.Creds(creds), unaryInts, streamInts)
// 在这个服务中注册EchoServer
pb.RegisterEchoServer(s, &server{})
客户端通信
除去复杂的函数实现,我们直接来看main函数的流程和结果
func main() {
flag.Parse()
// 创建基于 TLS 的凭证。
creds, err := credentials.NewClientTLSFromFile(data.Path("x509/ca_cert.pem"), "x.test.example.com")
if err != nil {
log.Fatalf("加载凭证失败:%v", err)
}
// 建立与服务器的连接。
conn, err := grpc.Dial(*addr, grpc.WithTransportCredentials(creds))
if err != nil {
log.Fatalf("grpc.Dial(%q) 失败:%v", *addr, err)
}
defer conn.Close()
// 创建一个回声客户端并发送 RPC 请求。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := ecpb.NewEchoClient(conn)
// 以授权用户的身份进行 RPC 请求,期望它们成功完成。
authorizedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "super-user", Secret: "super-secret"})
if err := callUnaryEcho(ctx, client, "hello world", authorizedUserTokenCallOption); err != nil {
log.Fatalf("已授权用户的一元 RPC 失败:%v", err)
}
if err := callBidiStreamingEcho(ctx, client, authorizedUserTokenCallOption); err != nil {
log.Fatalf("已授权用户的双向 RPC 失败:%v", err)
}
// 以未经授权的用户身份进行 RPC 请求,期望它们失败并返回 PermissionDenied 状态码。
unauthorizedUserTokenCallOption := newCredentialsCallOption(token.Token{Username: "bad-actor", Secret: "super-secret"})
if err := callUnaryEcho(ctx, client, "hello world", unauthorizedUserTokenCallOption); err != nil {
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("未经授权用户的一元 RPC 失败,如预期:%v", err)
default:
log.Fatalf("未经授权用户的一元 RPC 失败,但出现意外错误:%v,%v", c, err)
}
}
if err := callBidiStreamingEcho(ctx, client, unauthorizedUserTokenCallOption); err != nil {
switch c := status.Code(err); c {
case codes.PermissionDenied:
log.Printf("未经授权用户的双向 RPC 失败,如预期:%v", err)
default:
log.Fatalf("未经授权用户的双向 RPC 失败,但出现意外错误:%v", err)
}
}
}
客户端通信结果
UnaryEcho: hello world
BidiStreaming Echo: Request 1
BidiStreaming Echo: Request 2
BidiStreaming Echo: Request 3
BidiStreaming Echo: Request 4
BidiStreaming Echo: Request 5
2023/09/25 19:49:10 未经授权用户的一元 RPC 失败,如预期:rpc error: code = PermissionDenied desc = UnaryEcho RPC failed: rpc error: code = PermissionDenied desc = unauthorized RPC request rejected
2023/09/25 19:49:10 未经授权用户的双向 RPC 失败,如预期:rpc error: code = PermissionDenied desc = receiving StreamingEcho message: rpc error: code = PermissionDenied desc = unauthorized RPC request rejected
思维导图
项目结构
.
├── client
│ └── main.go
├── README.md
├── server
│ └── main.go
└── token
└── token.go
试用一下
服务器要求经过身份验证的用户具有以下角色才能授权使用这些方法:
UnaryEcho
需要角色UNARY_ECHO:W
BidirectionalStreamingEcho
需要角色STREAM_ECHO:RW
在接收到请求后,服务器首先检查是否提供了令牌,然后解码令牌并检查是否正确设置了密钥(为简单起见,这里将密钥硬编码为 super-secret
,在生产环境中应使用正确的身份验证提供程序)。
如果上述步骤成功,它会使用令牌中的用户名来设置适当的角色(为简单起见,如果用户名与 super-user
匹配,这些角色将被硬编码为上述的 2 个所需角色,但这些角色也应该从外部提供)。
使用以下命令启动服务器:
go run server/main.go
客户端实现演示了如何使用有效的令牌(设置用户名和密钥)与每个端点都将成功返回。它还说明了如何使用错误的令牌将导致服务返回 codes.PermissionDenied
。
使用以下命令启动客户端:
go run client/main.go
认证
在 gRPC 中,认证被抽象为 credentials.PerRPCCredentials
。通常,它还包括授权。用户可以在每个连接或每个调用的基础上进行配置。
目前,认证的示例包括使用 OAuth2 与 gRPC 的示例。
尝试
go run server/main.go
go run client/main.go
OAuth2
OAuth 2.0 协议是目前广泛使用的身份验证和授权机制。gRPC 提供了方便的 API 来配置 OAuth 以与 gRPC 一起使用。请参考 godoc:https://godoc.org/google.golang.org/grpc/credentials/oauth 了解详细信息。
DialOption [
WithPerRPCCredentials](https://godoc.org/google.golang.org/grpc#WithPerRPCCredentials)。或者,如果用户希望为每个调用应用 OAuth 令牌,然后使用 grpc RPC 调用配置
CallOption [
PerRPCCredentials`](https://godoc.org/google.golang.org/grpc#PerRPCCredentials)。
请注意,OAuth 要求底层传输层是安全的(例如,TLS 等)。
在 gRPC 内部,提供的令牌前缀为令牌类型和一个空格,然后附加到带有键 “authorization” 的元数据中。
客户端实例
/*
*
* Copyright 2018 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// 该客户端演示了如何为每个RPC提供OAuth2令牌。
package main
import (
"context"
"flag"
"fmt"
"log"
"time"
"golang.org/x/oauth2"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/oauth"
"google.golang.org/grpc/examples/data"
ecpb "google.golang.org/grpc/examples/features/proto/echo"
)
var addr = flag.String("addr", "localhost:50051", "the address to connect to")
// callUnaryEcho 调用一元 RPC 函数并处理响应。
func callUnaryEcho(client ecpb.EchoClient, message string) {
// 创建一个带有超时的上下文(Context),以便在超过指定的时间后自动取消操作。
// context.Background() 创建一个没有任何父上下文的根上下文。
// 10*time.Second 表示超时时间为10秒。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// 使用 defer 关键字,以确保在函数结束时调用 cancel 函数来取消上下文。
defer cancel()
// 使用客户端对象 client 调用一元 RPC 函数 UnaryEcho,并传递上下文 ctx 和请求参数。
// 在此示例中,我们要发送一条消息给服务器,消息内容为 message 变量的值。
resp, err := client.UnaryEcho(ctx, &ecpb.EchoRequest{Message: message})
// 检查是否发生了错误。
if err != nil {
// 如果发生错误,使用 log.Fatalf 函数记录错误信息并退出程序。
log.Fatalf("client.UnaryEcho(_) = _, %v: ", err)
}
// 如果没有发生错误,打印从服务器返回的响应消息。
fmt.Println("UnaryEcho: ", resp.Message)
}
func main() {
flag.Parse()
// 设置连接的凭证。
perRPC := oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(fetchToken())}
creds, err := credentials.NewClientTLSFromFile(data.Path("x509/ca_cert.pem"), "x.test.example.com")
if err != nil {
log.Fatalf("failed to load credentials: %v", err)
}
opts := []grpc.DialOption{
// 除了下面的 grpc.DialOption,调用者还可以在 RPC 调用中使用 grpc.CallOption grpc.PerRPCCredentials。
// 参见:https://godoc.org/google.golang.org/grpc#PerRPCCredentials
grpc.WithPerRPCCredentials(perRPC),
// oauth.TokenSource 需要配置传输凭证。
grpc.WithTransportCredentials(creds),
}
conn, err := grpc.Dial(*addr, opts...)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
rgc := ecpb.NewEchoClient(conn)
callUnaryEcho(rgc, "hello world")
}
// fetchToken 模拟令牌查找并省略正确令牌获取的详细信息。
// 有关获取OAuth2令牌的示例,请参见:
// https://godoc.org/golang.org/x/oauth2
func fetchToken() *oauth2.Token {
return &oauth2.Token{
AccessToken: "some-secret-token",
}
}
在服务器端,用户通常在拦截器内部获取令牌并进行验证。要获取令牌,请调用 metadata.FromIncomingContext
并传入给定的上下文。它将返回元数据映射。接下来,使用键 “authorization” 获取相应的值,该值是字符串切片。对于 OAuth,切片应只包含一个元素,该元素是格式为 <令牌类型> + " " + <令牌> 的字符串。用户可以通过解析字符串轻松获取令牌,然后验证其有效性。
如果令牌无效,则返回带有错误代码 codes.Unauthenticated
的错误。
如果令牌有效,则调用方法处理程序以开始处理 RPC。
服务端代码实例
/*
* Copyright 2018 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
//