github.com/romnn/go-service

Utilities for building gRPC and HTTP services in Go


Keywords
authentication, golang, grpc, http, microservice, protobuf, reflection
License
MIT
Install
go get github.com/romnn/go-service

Documentation

go-service

Build Status GitHub GoDoc Test Coverage

Service utilities for building gRPC and HTTP services in Go.

Some features:

  • composable authentication using JWT
  • gRPC interceptors for method reflection

Example: Authentication

// examples/auth/auth.proto

syntax = "proto3";
package auth;

import "google/protobuf/timestamp.proto";

service Auth {
  rpc Login(LoginRequest) returns (AuthToken) {}
  rpc Validate(ValidationRequest) returns (ValidationResult) {}
}

message LoginRequest {
  string email = 1;
  string password = 2;
}

message ValidationRequest { string token = 1; }

message ValidationResult { bool valid = 1; }

message AuthToken {
  string token = 1;
  string email = 2;
  google.protobuf.Timestamp expires = 10;
}
// examples/auth/server.go

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"net"
	"os"
	"os/signal"
	"syscall"

	"github.com/golang-jwt/jwt/v4"
	pb "github.com/romnn/go-service/examples/auth/gen"
	"github.com/romnn/go-service/pkg/auth"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/timestamppb"
)

// User represents a user
type User struct {
	Email          string
	HashedPassword string
}

// UserDatabase is a mock user database
type UserDatabase interface {
	GetUserByEmail(email string) (*User, error)
	AddUser(user *User)
	RemoveUserByEmail(email string) (*User, error)
}

type userDatabase struct {
	users map[string]*User
}

// AddUser adds a user to the database
func (db *userDatabase) AddUser(user *User) {
	db.users[user.Email] = user
}

// RemoveUserByEmail removes a user
func (db *userDatabase) RemoveUserByEmail(email string) (*User, error) {
	if user, ok := db.users[email]; ok {
		delete(db.users, email)
		return user, nil
	}
	return nil, fmt.Errorf("no user with email %q", email)
}

// GetUserByEmail gets a user
func (db *userDatabase) GetUserByEmail(email string) (*User, error) {
	if user, ok := db.users[email]; ok {
		return user, nil
	}
	return nil, fmt.Errorf("no user with email %q", email)
}

// AuthService ...
type AuthService struct {
	pb.UnimplementedAuthServer
	Authenticator *auth.Authenticator
	Database      UserDatabase
}

// Claims encode the JWT token claims
type Claims struct {
	UserEmail string `json:"user-email"`
	jwt.RegisteredClaims
}

// GetRegisteredClaims returns the standard claims that will be set automatically
func (claims *Claims) GetRegisteredClaims() *jwt.RegisteredClaims {
	// MUST return pointer to registered claims of this struct
	return &claims.RegisteredClaims
}

// Validate validates a token
func (s *AuthService) Validate(ctx context.Context, in *pb.ValidationRequest) (*pb.ValidationResult, error) {
	valid, token, err := s.Authenticator.Validate(in.GetToken(), &Claims{})
	if err != nil {
		log.Println(err)
		return &pb.ValidationResult{Valid: false}, status.Error(codes.Internal, "Failed to validate token")
	}
	if claims, ok := token.Claims.(*Claims); ok && valid {
		log.Printf("valid authentication claims: %v", claims)
		return &pb.ValidationResult{Valid: true}, nil
	}
	return &pb.ValidationResult{Valid: false}, nil
}

// Login logs in a user
func (s *AuthService) Login(ctx context.Context, in *pb.LoginRequest) (*pb.AuthToken, error) {
	user, err := s.Database.GetUserByEmail(in.GetEmail())
	if err != nil {
		log.Println(err)
		return nil, status.Error(codes.NotFound, "no such user")
	}
	if !auth.CheckPasswordHash(in.GetPassword(), user.HashedPassword) {
		return nil, status.Error(codes.Unauthenticated, "unauthorized")
	}

	// authenticated
	token, err := s.Authenticator.SignJwtClaims(&Claims{
		UserEmail: user.Email,
	})
	if err != nil {
		log.Println(err)
		return nil, status.Error(codes.Internal, "error while signing token")
	}

	expirationTime := time.Now().Add(s.Authenticator.ExpiresAfter)
	return &pb.AuthToken{
		Token:   token,
		Email:   user.Email,
		Expires: timestamppb.New(expirationTime),
	}, nil
}

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	authenticator := auth.Authenticator{
		ExpiresAfter: 100 * time.Second,
		Issuer:       "issuer@example.org",
		Audience:     "example.org",
	}

	keyConfig := auth.KeyConfig{Generate: true}
	if err := authenticator.SetupKeys(&keyConfig); err != nil {
		log.Fatalf("failed to setup keys: %v", err)
	}

	service := AuthService{
		Authenticator: &authenticator,
		Database: &userDatabase{
			users: make(map[string]*User),
		},
	}

	server := grpc.NewServer()
	pb.RegisterAuthServer(server, &service)

	shutdown := make(chan os.Signal, 1)
	signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-shutdown
		log.Println("shutdown ...")
		server.GracefulStop()
		listener.Close()
	}()

	log.Printf("listening on: %v", listener.Addr())
	if err := server.Serve(listener); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

Example: Reflection

// examples/reflect/reflect.proto

syntax = "proto3";
package reflect;

import "google/protobuf/descriptor.proto";

// we define custom options
extend google.protobuf.MethodOptions {
  bool bool_value = 51234;
  string string_value = 51235;
  int32 int_value = 51236;
}

message Empty {}

message Annotations {
  bool bool_value = 1;
  string string_value = 2;
  int32 int_value = 3;
}

service Reflect {
  // we will read the options of this method using reflection
  rpc GetNoAnnotations(Empty) returns (Annotations) {}

  // we will read the options of this method using reflection
  rpc GetAnnotations(Empty) returns (Annotations) {
    option (bool_value) = true;
    option (string_value) = "Hello World";
    option (int_value) = 42;
  }
}
// examples/reflect/server.go

package main

import (
	"context"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	pb "github.com/romnn/go-service/examples/reflect/gen"
	"github.com/romnn/go-service/pkg/grpc/reflect"
	"google.golang.org/grpc"
	"google.golang.org/protobuf/proto"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// ReflectService implements the reflect service
type ReflectService struct {
	pb.UnimplementedReflectServer
}

func (s *ReflectService) getAnnotations(ctx context.Context) (*pb.Annotations, error) {
	info, ok := reflect.GetMethodInfo(ctx)
	if !ok {
		return nil, status.Error(codes.Internal, "failed to get grpc method info")
	}
	var annotations pb.Annotations
	methodOptions := info.Method().Options()
	if boolValue, ok := proto.GetExtension(methodOptions, pb.E_BoolValue).(bool); ok {
		annotations.BoolValue = boolValue
	}
	if stringValue, ok := proto.GetExtension(methodOptions, pb.E_StringValue).(string); ok {
		annotations.StringValue = stringValue
	}
	if intValue, ok := proto.GetExtension(methodOptions, pb.E_IntValue).(int32); ok {
		annotations.IntValue = intValue
	}
	return &annotations, nil
}

// GetNoAnnotations returns the options of this GRPC method
func (s *ReflectService) GetNoAnnotations(ctx context.Context, req *pb.Empty) (*pb.Annotations, error) {
	return s.getAnnotations(ctx)
}

// GetAnnotations returns the options of this GRPC method
func (s *ReflectService) GetAnnotations(ctx context.Context, req *pb.Empty) (*pb.Annotations, error) {
	return s.getAnnotations(ctx)
}

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	service := ReflectService{}
	registry := reflect.NewRegistry()
	server := grpc.NewServer(
		grpc.ChainUnaryInterceptor(reflect.UnaryServerInterceptor(registry)),
		grpc.ChainStreamInterceptor(reflect.StreamServerInterceptor(registry)),
	)
	pb.RegisterReflectServer(server, &service)
	registry.Load(server)

	shutdown := make(chan os.Signal, 1)
	signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-shutdown
		log.Println("shutdown ...")
		server.GracefulStop()
		listener.Close()
	}()

	log.Printf("listening on: %v", listener.Addr())
	if err := server.Serve(listener); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

For more examples, see examples/.

Development

Tooling

Before you get started, make sure you have installed the following tools:

$ python3 -m pip install pre-commit bump2version invoke
$ go install golang.org/x/tools/cmd/goimports
$ go install golang.org/x/lint/golint
$ go install github.com/fzipp/gocyclo

It is advised to install the git commit hooks to enforce code checks:

inv install-hooks

To check if all checks pass:

inv pre-commit
Compiling proto files

If you want to (re-)compile the sample grpc .proto services, you will need protoc, protoc-gen-go and protoc-gen-go-grpc.

apt install -y protobuf-compiler
brew install protobuf

go get -u google.golang.org/protobuf/cmd/protoc-gen-go
go install google.golang.org/protobuf/cmd/protoc-gen-go

go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc

To compile, you can use the provided utility:

inv compile-protos