概述

现在有个需求,需要在grpc的拦截器中,通过token进行身份认证。如果认证不通过则返回错误; 认证通过,则放行;

TLS是用作加密通讯通道的,与该需求无关。

Token认证

具体流程

  • 客户端通过账号密码登录服务器,登录成功服务器返回一个token给客户端。
  • 客户端携带token信息往服务器请求业务逻辑的接口,,服务器收到请求,解析验证token,验证通过,则放行。

协议文件

proto
//hello.proto
syntax = "proto3";
option go_package = "./pb;pb";
import "google/protobuf/empty.proto";

package hello;

message Login{
  string login = 1;
  string password = 2;
}

message TokenInfo {
  string token = 1;
}

message HelloWorld {
  string msg = 1;
}

service Hello {
  rpc CreateToken(Login) returns(TokenInfo) {}
  rpc SayHello(google.protobuf.Empty) returns(HelloWorld) {}
}

生成*.pb.go文件

$ protoc -I. --go_out=.  --go-grpc_out=. ./*proto

使用的是jwt-go包。来帮助服务端创建&解析token

golang
//jwt.go
package jwt

import (
	"context"
	"fmt"
	"pro2d/conf"
	"pro2d/utils"
	"time"

	jwt "github.com/dgrijalva/jwt-go"
	"google.golang.org/grpc/metadata"
)

func CreateToken(uid string) (tokenString string) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iss":      "pro2d-app-server",
		"aud":      "pro2d-app-server",
		"nbf":      time.Now().Unix(),
		"exp":      time.Now().Add(time.Hour).Unix(),
		"sub":      "pro2d",
		"uid": uid,
	})
	tokenString, err := token.SignedString([]byte(utils.Pro2DTokenSignedString))
	if err != nil {
		panic(err)
	}
	return tokenString
}

func ParseToken(tokenStr string) string {
	var clientClaims Claims
	token, err := jwt.ParseWithClaims(tokenStr, &clientClaims, func(token *jwt.Token) (interface{}, error) {
		if token.Header["alg"] != "HS256" {
			//panic("ErrInvalidAlgorithm")
			utils.Sugar.Error("ErrInvalidAlgorithm")
			return nil, nil
		}
		return []byte(utils.Pro2DTokenSignedString), nil
	})
	if err != nil {
		utils.Sugar.Error("jwt parse error")
		return ""
	}

	if !token.Valid {
		utils.Sugar.Error("ErrInvalidToken")
		return ""
	}
	return clientClaims.Uid
}


// Claims defines the struct containing the token claims.
type Claims struct {
	jwt.StandardClaims
	Uid string
}

// 从 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) string {
	tokenStr, err := getTokenFromContext(ctx)
	if err != nil {
		utils.Sugar.Errorf("get token from context error")
		return ""
	}
	return ParseToken(tokenStr)
}

// 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 true
}

server端

golang
package main

import (
	"context"
	"fmt"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/protobuf/types/known/emptypb"
	"log"
	"net"
	"pro2d/components/jwt"
	"pro2d/protos/pb"
)

type Server struct {
	pb.UnimplementedHelloServer
}

func (s *Server) CreateToken(ctx context.Context, in *pb.Login) (*pb.TokenInfo, error)  {
	if in.Login == "login" && in.Password == "123456" {
		return &pb.TokenInfo{Token: jwt.CreateToken(in.Login)}, nil
	}
	return nil, fmt.Errorf("login error")
}

func (s *Server) SayHello(ctx context.Context, empty *emptypb.Empty) (*pb.HelloWorld, error)  {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, status.Errorf(codes.Unauthenticated,"ErrNoMetadataInContext")
	}
	// md 的类型是 type MD map[string][]string
	token, ok := md["authorization"]
	if !ok || len(token) == 0 {
		return nil, status.Errorf(codes.Unauthenticated,"ErrNoAuthorizationInMetadata")
	}
	login := jwt.ParseToken(token[0])
	return &pb.HelloWorld{Msg: "Hello world: " + login}, nil
}

func main() {
	// 监听本地端口
	listener, err := net.Listen("tcp", ":8948")
	if err != nil {
		log.Fatalf("net.Listen err: %v", err)
	}
	var opts []grpc.ServerOption
	// 从输入证书文件和密钥文件为服务端构造TLS凭证
	creds, err := credentials.NewServerTLSFromFile("keys/server.pem", "keys/server.key")
	if err != nil {
		log.Fatalf("Failed to generate credentials %v", err)
	}
	opts = append(opts, grpc.Creds(creds))
	// 新建gRPC服务器实例,并开启TLS认证
	grpcServer := grpc.NewServer(opts...)

	// 在gRPC服务器注册我们的服务
	pb.RegisterHelloServer(grpcServer, &Server{})
	log.Println(" net.Listing whth TLS")
	//用服务器 Serve() 方法以及我们的端口信息区实现阻塞等待,直到进程被杀死或者 Stop() 被调用
	err = grpcServer.Serve(listener)
	if err != nil {
		log.Fatalf("grpcServer.Serve err: %v", err)
	}
}

client端

golang
package main

import (
	"context"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/protobuf/types/known/emptypb"
	"log"
	"pro2d/components/jwt"
	_ "pro2d/conf"
	"pro2d/protos/pb"
	"pro2d/utils"
)

func main() {
	var opts []grpc.DialOption
	creds, err := credentials.NewClientTLSFromFile("keys/server.pem", "pro2d")
	if err != nil {
		log.Fatal(err)
		return
	}
	opts = append(opts, grpc.WithTransportCredentials(creds))
	conn, err := grpc.Dial("localhost:8948", opts...)

	helloClient := pb.NewHelloClient(conn)
	token, err := helloClient.CreateToken(context.TODO(), &pb.Login{
		Login:    "login",
		Password: "123456",
	})
	if err != nil {
		log.Fatal(err)
		return
	}
	utils.Sugar.Debugf("token: %s", token.Token)

	opts = append(opts, grpc.WithPerRPCCredentials(&jwt.AuthToken{Token: token.Token}))
	conn2, err := grpc.Dial("localhost:8948",opts...)
	if err != nil {
		log.Fatal(err)
		return
	}

	helloClient2 := pb.NewHelloClient(conn2)
	rsp, err := helloClient2.SayHello(context.TODO(), &emptypb.Empty{})
	if err != nil {
		log.Fatal(err)
	}

	log.Printf("sayhello rsp: %v", rsp)
}

  • 客户端调用CreateToken, 获得token

  • 客户端通过grpc.WithPerRPCCredentials, 携带token信息,重新发起grpc请求,再调用SayHello,接口,结果返回成功

  • 服务器在收到SayHello请求的时候,从上下文ctx取出token信息,再解析token获得login的信息。

自定义信息验证

上面的token认证是通过客户端传递token信息到服务器,服务器从进行解析来做的。 还有一种是客户端和服务器约定固定信息,当客户顿请求接口时,把信息放在请求头部。

server端, 修改SayHello函数

func (s *Server) SayHello(ctx context.Context, empty *emptypb.Empty) (*pb.HelloWorld, error)  {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, status.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 != "100" || appKey != "token" {
		return nil, status.Errorf(codes.Unauthenticated, "Token认证信息无效: appid=%s, appkey=%s", appId, appKey)
	}

	return &pb.HelloWorld{Msg: "Hello world"}, nil
}

客户端需要改的地方是把 opts = append(opts, grpc.WithPerRPCCredentials(&jwt.AuthToken{Token: token.Token})) 改为 opts = append(opts, grpc.WithPerRPCCredentials(&CustomToken{}))

type CustomToken struct {
}

func (c CustomToken) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
	return map[string]string{
		"appId": "100",
		"appKey": "token",
	}, nil
}

func (c CustomToken) RequireTransportSecurity() bool {
	return true
}

SAN证书生成参考: SAN

关于grpc和认证相关的一些想法

我们在开发过程中往往更复杂, 下面是优化过程

  1. grpc + tls + token验证 + 自定义认证
  • tls: 目的是为了加密协议传输通道
  • token认证:是为了能从token中获取用户信息,保障用户信息安全。服务器不用存储会话信息,减少内存。但是每次接口请求都增加了token解析操作。
  • 自定义认证:客户端服务器约定固定的参数, 客户端每次请求都会带上,服务器做验证(有token认证后,自定义认证没有必要了,个人观点)
  1. 在拦截器中统一身份token验证, 这样做的目的是减少代码冗余,但是这样没法传递信息给接口,直到我发现了context.WithValue()
  • 服务器收到信息,解析token, 得到用户的唯一标识uid(这是客户端在创建token的时候加进去的)
  • 然后使用context.WithValue("uid", uid), 把uid放到上下文中,传递给SayHello(ctx context.Context, empty *emptypb.Empty) (*pb.HelloWorld, error) 可以通过 ctx.Value("uid").(string) 取出来。这样做逻辑处理的时候就知道是哪位用户在处理。

--完--