跟我一起学Go系列:Go gRPC 安全认证方式-Token和自定义认证
Go gRPC 系列:
跟我一起学Go系列:gRPC安全认证机制-SSL/TLS认证
接上一篇继续讲 gRPC 认证,本篇内容主要是 Token 认证和自定义认证方式的使用。
说 Token 认证就不得不提 Session。做 Web 端开发的同学应该都了解 Session 和 Token 机制。
Token 校验
基于 Session 的身份校验机制
Session 一般由服务端存储,用户通过用户名和密码登录之后服务端会在服务器开辟一块 Session 内存空间存入用户信息,同时服务器会在 cookie 信息中写入一个 Session_id 值,用于标识这一块内存空间。下次用户再来请求的时候会由 cookie 中带过来这个 Session_id,服务端拿着这个 Session_id 去寻找对应的 Session,如果能找到说明用户已经登录过,不用重新走授权的逻辑。
使用 Session 存在问题在哪里:
- 服务端存储压力过大,当用户量大的时候,所有用户都会在内存中保存 Session 信息,可想而知需要很大的内存空间。
- 分布式应用下 Session 共享问题会耗费更多的存储。
- Session 机制是基于 cookie 的,cookie 如果被截取用户很容易受到 CSFR(跨站伪造请求攻击)。
- 另外使用 cookie 的另一个弊端就是不支持跨域,当然对于跨域的处理现在已经不是什么问题。
基于 Token 的身份校验机制
再来说说 Token 机制。token 即令牌的意思,令牌的生成规则是我们自定义的,用户第一次登录后服务端生成一个令牌返回给客户端,以后客户端在令牌过期内只需要带上这个令牌以及生成令牌必要的参数,服务端通过生成规则能生成一样的令牌即表示校验通过。
原理很简单但是带来的效果却是翻倍提升的:
- 采用生成规则校验,服务端无需存储 token 数据,没有内存压力,同样服务端做负载均衡的时候也无需像 session 那样需要考虑分布式存储问题。
- 支持跨域,将 token 置于请求头中即可。
- 对于移动端这种不支持 cookie 的应用场景来说 token 是更有效的验证手段。
Token 形式多种多样,其中,JSON Web Token 是一种比较受欢迎的 Token 规范,其实就是规范 token 该怎么生成的方式。
JWT 中的 Token 分为 3 部分,Header、Payload 与 Signature,例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJsb3JhLWFwcC1zZXJ2ZXIiLCJleHAiOjE2MjUzMjgyOTYsImlzcyI6ImxvcmEtYXBwLXNlcnZlciIsIm5iZiI6MTYyNTMyNDY5Niwic3ViIjoidXNlciIsInVzZXJuYW1lIjoieGlhb21pbmcifQ.vAeStAhbRa15rhKsTET3_nphRaxr2yVMLd2fGXHnDwY
两个.
字符隔开三部分,即:
Header.Payload.Signature
含义上,Header表示 Token 相关的基本元信息,如 Token 类型、加密方式(算法)等,具体如下(alg
是必填的,其余都可选):
typ
:Token typecty
:Content typealg
:Message authentication code algorithm
Payload表示 Token 携带的数据及其它 Token 元信息,规范定义的标准字段如下:
iss
:Issuer,签发方sub
:Subject,Token 信息主题(Sub identifies the party that this JWT carries information about)aud
:Audience,接收方exp
:Expiration Time,过期时间nbf
:Not (valid) Before,生效时间iat
:Issued at,生成时间jti
:JWT ID,唯一标识
这些字段都是可选的,Payload 只要是合法 JSON 即可。生成之后的三部分又做了一次加密处理:
Base64编码的Header.Base64编码的Payload.对前两部分按指定算法加密的结果
关于 JWT 规范的 Token 生成暂时就先说这么多,我们接下来就看看在 gRPC 中如何应用 Token 机制。gRPC 本身不提供 Token 认证机制,而是通过插件机制支持第三方认证,本示例使用了第三方 jwt 包:
github.com/dgrijalva/jwt-go
通过该包我们就不用自己去写 jwt 规范下的这一套加密方式。
基于 JWT 规范的 Token 认证机制代码位于 Gitbub仓库,大家自行查看。
首先我们创建一个新的测试 API,token.proto:
syntax = "proto3";
option go_package = "/";
package test.grpcTest.tokenTls;
service TokenService {
rpc Login (LoginRequest) returns (LoginResp) {}
rpc SayHello(PingMessage) returns (PingMessage) {}
}
message LoginRequest{
string username = 1;
string password = 2;
}
message LoginResp{
string status = 1;
string token = 2;
}
message PingMessage {
string greeting = 1;
}
一个方法是登录的时候获取服务端 token,一个方法模拟拿到服务端 token 之后是否能用 token 通过校验。
接下来我们定义 token 的生成方式以及校验方式,这里使用了 第三方 JWT 组件:
package __
import (
"context"
"fmt"
"time"
"github.com/dgrijalva/jwt-go"
"google.golang.org/grpc/metadata"
)
func CreateToken(userName string) (tokenString string) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "lora-app-server",
"aud": "lora-app-server",
"nbf": time.Now().Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"sub": "user",
"username": userName,
})
tokenString, err := token.SignedString([]byte("verysecret"))
if err != nil {
panic(err)
}
return tokenString
}
// AuthToken 自定义认证
type AuthToken struct {
Token string
}
func (c AuthToken) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"authorization": c.Token,
}, nil
}
func (c AuthToken) RequireTransportSecurity() bool {
return false
}
// Claims defines the struct containing the token claims.
type Claims struct {
jwt.StandardClaims
// Username defines the identity of the user.
Username string `json:"username"`
}
// Step1. 从 context 的 metadata 中,取出 token
func getTokenFromContext(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", fmt.Errorf("ErrNoMetadataInContext")
}
// md 的类型是 type MD map[string][]string
token, ok := md["authorization"]
if !ok || len(token) == 0 {
return "", fmt.Errorf("ErrNoAuthorizationInMetadata")
}
// 因此,token 是一个字符串数组,我们只用了 token[0]
return token[0], nil
}
func CheckAuth(ctx context.Context) (username string) {
tokenStr, err := getTokenFromContext(ctx)
if err != nil {
panic("get token from context error")
}
var clientClaims Claims
token, err := jwt.ParseWithClaims(tokenStr, &clientClaims, func(token *jwt.Token) (interface{}, error) {
if token.Header["alg"] != "HS256" {
panic("ErrInvalidAlgorithm")
}
return []byte("verysecret"), nil
})
if err != nil {
panic("jwt parse error")
}
if !token.Valid {
panic("ErrInvalidToken")
}
return clientClaims.Username
}
CreateToken 方法调用了 jwt 生成 token 的规范,包括 token 的过期时间设置。
另一个重要的点是 AuthToken,它实现了 PerRPCCredentials 接口。gRPC 可以为每个方法的调用进行认证,从而对不同的用户 token 进行不同的访问权限控制,首先需要实现 grpc.PerRPCCredentials 接口:
type PerRPCCredentials interface {
GetRequestMetadata(ctx context.Context, uri ...string) (
map[string]string, error,
)// 返回认证需要的信息
RequireTransportSecurity() bool // 是否要求底层使用安全连接
}
可以认为 PerRPCCredentials 接口就是 gRPC 提供的自定义认证方式的入口。注意到我在 GetRequestMetadata 方法中 set进去一个 authorization 字段,用来存储 token。
JWT 认证方式实现完毕,我们可以写服务端和客户端的代码:
服务端:
package __
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
"google.golang.org/grpc/reflection"
"net"
"testing"
)
//拦截器 - 打印日志
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
fmt.Printf("gRPC method: %s, %v", info.FullMethod, req)
resp, err := handler(ctx, req)
fmt.Printf("gRPC method: %s, %v", info.FullMethod, resp)
return resp, err
}
type login struct {
}
func (login *login) Login(ctx context.Context, request *LoginRequest) (resp *LoginResp, err error) {
if request.Username == "xiaoming" && request.Password == "123456" {
token := CreateToken(request.Username)
return &LoginResp{Status: "200", Token: token}, nil
}
return &LoginResp{Status: "401", Token: ""}, nil
}
func (login *login) SayHello(ctx context.Context, request *PingMessage) (resp *PingMessage, err error) {
auth := CheckAuth(ctx)
return &PingMessage{Greeting: auth}, nil
}
func TestGrpcServer(t *testing.T) {
// 监听本地的8972端口
lis, err := net.Listen("tcp", ":8972")
if err != nil {
fmt.Printf("failed to listen: %v", err)
return
}
// TLS认证
creds, err := credentials.NewServerTLSFromFile("/Users/rickiyang/server.crt", "/Users/rickiyang/server.key")
if err != nil {
grpclog.Fatalf("Failed to generate credentials %v", err)
}
//开启TLS认证 注册拦截器
s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(LoggingInterceptor)) // 创建gRPC服务器
RegisterTokenServiceServer(s, &login{}) // 在gRPC服务端注册服务
reflection.Register(s) //在给定的gRPC服务器上注册服务器反射服务
// Serve方法在list上接受传入连接,为每个连接创建一个ServerTransport和server的goroutine。
// 该goroutine读取gRPC请求,然后调用已注册的处理程序来响应它们。
err = s.Serve(lis)
if err != nil {
fmt.Printf("failed to serve: %v", err)
return
}
}
服务端在 Login 方法中要为用户生成 token,所以调用了 CreateToken 方法。同样 SayHello 方法中就要去校验客户端提供的 token 是否有效。
继续看客户端逻辑:
package __
import (
"fmt"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
"testing"
"golang.org/x/net/context"
"google.golang.org/grpc"
)
func TestGrpcClient(t *testing.T) {
var err error
var opts []grpc.DialOption
// TLS连接
creds, err := credentials.NewClientTLSFromFile("/Users/rickiyang/ca.crt", "www.rickiyang.com")
if err != nil {
grpclog.Fatalf("Failed to create TLS credentials %v", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
//连接服务端
conn, err := grpc.Dial(":8972", opts...)
if err != nil {
fmt.Printf("faild to connect: %v", err)
}
defer conn.Close()
c := NewTokenServiceClient(conn)
// 调用服务端的SayHello
r, err := c.Login(context.Background(), &LoginRequest{Username: "xiaoming", Password: "123456"})
if err != nil {
fmt.Printf("could not greet: %v", err)
}
requestToken := new(AuthToken)
requestToken.Token = r.Token
//连接服务端
conn, err = grpc.Dial(":8972", grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(requestToken))
if err != nil {
fmt.Printf("faild to connect: %v", err)
}
defer conn.Close()
c = NewTokenServiceClient(conn)
hello, err := c.SayHello(context.Background(), &PingMessage{Greeting: "hahah"})
if err != nil {
fmt.Printf("could not greet: %v", err)
}
fmt.Printf("Greeting: %s, %s !\n", r.Token, hello)
}
客户端校验分为两个部分,Login 方法调用之前我们是不能加 token 校验的,因为此刻还没有拿到 token。
调用 Login 获取到 Token 之后将 token set 进 metadata,重新建立连接,此后的调用就使用 token 来进行校验。大家可以运行示例实验一下。
自定义校验方式
上面 Token 校验方式中说过,实现自定义校验方式要实现 PerRPCCredentials 接口。Token 校验本身就是一折特殊的自定义校验方式,我们再来举个示例:
先来看客户端代码:
package normal
import (
"fmt"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
"testing"
"golang.org/x/net/context"
"google.golang.org/grpc"
pb "gorm-demo/models/pb"
)
const (
// OpenTLS 是否开启TLS认证
OpenTLS = true
)
// customCredential 自定义认证
type customCredential struct{}
// GetRequestMetadata 实现自定义认证接口
func (c customCredential) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"appId": "110",
"appKey": "token",
}, nil
}
// RequireTransportSecurity 自定义认证是否开启TLS
func (c customCredential) RequireTransportSecurity() bool {
return OpenTLS
}
func TestGrpcClient(t *testing.T) {
var err error
var opts []grpc.DialOption
if OpenTLS {
// TLS连接
creds, err := credentials.NewClientTLSFromFile("/Users/yangyue2/ca.crt", "www.yangyue.com")
if err != nil {
grpclog.Fatalf("Failed to create TLS credentials %v", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithInsecure())
}
// 使用自定义认证
opts = append(opts, grpc.WithPerRPCCredentials(new(customCredential)))
//连接服务端
conn, err := grpc.Dial(":8972", opts...)
if err != nil {
fmt.Printf("faild to connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)
// 调用服务端的SayHello
r, err := c.SayHello(context.Background(), &pb.HelloRequest{Name: "CN"})
if err != nil {
fmt.Printf("could not greet: %v", err)
}
fmt.Printf("Greeting: %s !\n", r.Message)
}
自定义了一个 根据 appid 和 appkey 来校验权限的功能,服务端检查存在即放过。服务端代码:
package normal
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection"
pb "gorm-demo/models/pb"
"net"
"testing"
)
//拦截器 - 打印日志
func LoggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
fmt.Printf("gRPC method: %s, %v", info.FullMethod, req)
resp, err := handler(ctx, req)
fmt.Printf("gRPC method: %s, %v", info.FullMethod, resp)
return resp, err
}
// 定义helloService并实现约定的接口
type helloService struct{}
// SayHello 实现Hello服务接口
func (h helloService) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
// 解析metadata中的信息并验证
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, grpc.Errorf(codes.Unauthenticated, "无Token认证信息")
}
var (
appId string
appKey string
)
if val, ok := md["appid"]; ok {
appId = val[0]
}
if val, ok := md["appkey"]; ok {
appKey = val[0]
}
if appId != "110" || appKey != "token" {
return nil, grpc.Errorf(codes.Unauthenticated, "Token认证信息无效: appid=%s, appkey=%s", appId, appKey)
}
resp := new(pb.HelloReply)
resp.Message = fmt.Sprintf("Hello %s.\nToken info: appid=%s,appkey=%s", in.Name, appId, appKey)
return resp, nil
}
func TestGrpcServer(t *testing.T) {
// 监听本地的8972端口
lis, err := net.Listen("tcp", ":8972")
if err != nil {
fmt.Printf("failed to listen: %v", err)
return
}
// TLS认证
creds, err := credentials.NewServerTLSFromFile("/Users/yangyue2/server.crt", "/Users/yangyue2/server.key")
if err != nil {
grpclog.Fatalf("Failed to generate credentials %v", err)
}
//开启TLS认证 注册拦截器
s := grpc.NewServer(grpc.Creds(creds), grpc.UnaryInterceptor(LoggingInterceptor)) // 创建gRPC服务器
pb.RegisterGreeterServer(s, &helloService{}) // 在gRPC服务端注册服务
reflection.Register(s) //在给定的gRPC服务器上注册服务器反射服务
// Serve方法在lis上接受传入连接,为每个连接创建一个ServerTransport和server的goroutine。
// 该goroutine读取gRPC请求,然后调用已注册的处理程序来响应它们。
err = s.Serve(lis)
if err != nil {
fmt.Printf("failed to serve: %v", err)
return
}
}
服务端校验是否存在对应的 appid 和 appkey即可。了解了如何使用之后写起来就很简单。
关于 gRPC 安全认证方式就到这里,两篇内容分别讲了基于 TLS 的认证和自定义认证的逻辑。大家根据业务场景自行决定使用哪种校验即可。