Add Authentication Functions to Validator RPC (#6968)

* define auth endpoints
* add intercepter with tests
* auth functions
* fix up the auth functions
* add functions for storing and saving the hashed password from the validator db
* validate strong password input and simplify jwt claims
* tests for db funcs
* comments for db funcs
* wrap up the authentication tests
* register auth srv
* use proper db iface package and check if existing password
* fix broken tests and add new test to check if password already exists
* use roughtime
* rlock to check the auth paths
* Merge refs/heads/master into auth-rpc
* Merge refs/heads/master into auth-rpc
* Merge refs/heads/master into auth-rpc
* leave out the stream interceptor
* resolve confs
* Merge branch 'master' into auth-rpc
* confs
* Merge branch 'auth-rpc' of github.com:prysmaticlabs/prysm into auth-rpc
* Merge refs/heads/master into auth-rpc
* Merge refs/heads/master into auth-rpc
* Merge refs/heads/master into auth-rpc
* Merge refs/heads/master into auth-rpc
This commit is contained in:
Raul Jordan
2020-08-13 15:27:42 -05:00
committed by GitHub
parent 6ed0539723
commit 16c34b627f
19 changed files with 1159 additions and 394 deletions

View File

@@ -4,7 +4,9 @@ load("@prysm//tools/go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = [
"auth.go",
"health.go",
"intercepter.go",
"server.go",
],
importpath = "github.com/prysmaticlabs/prysm/validator/rpc",
@@ -12,25 +14,48 @@ go_library(
deps = [
"//proto/validator/accounts/v2:go_default_library",
"//shared/bytesutil:go_default_library",
"//shared/promptutil:go_default_library",
"//shared/rand:go_default_library",
"//shared/roughtime:go_default_library",
"//shared/traceutil:go_default_library",
"//validator/client:go_default_library",
"//validator/db:go_default_library",
"@com_github_dgrijalva_jwt_go//:go_default_library",
"@com_github_grpc_ecosystem_go_grpc_middleware//:go_default_library",
"@com_github_grpc_ecosystem_go_grpc_middleware//recovery:go_default_library",
"@com_github_grpc_ecosystem_go_grpc_middleware//tracing/opentracing:go_default_library",
"@com_github_grpc_ecosystem_go_grpc_prometheus//:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@io_opencensus_go//plugin/ocgrpc:go_default_library",
"@org_golang_google_grpc//:go_default_library",
"@org_golang_google_grpc//codes:go_default_library",
"@org_golang_google_grpc//credentials:go_default_library",
"@org_golang_google_grpc//metadata:go_default_library",
"@org_golang_google_grpc//reflection:go_default_library",
"@org_golang_google_grpc//status:go_default_library",
"@org_golang_x_crypto//bcrypt:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = ["health_test.go"],
srcs = [
"auth_test.go",
"health_test.go",
"intercepter_test.go",
"server_test.go",
],
embed = [":go_default_library"],
deps = [
"//proto/validator/accounts/v2:go_default_library",
"//shared/bytesutil:go_default_library",
"//shared/testutil/assert:go_default_library",
"//shared/testutil/require:go_default_library",
"//validator/client:go_default_library",
"//validator/db/testing:go_default_library",
"@com_github_prysmaticlabs_ethereumapis//eth/v1alpha1:go_default_library",
"@org_golang_google_grpc//:go_default_library",
"@org_golang_google_grpc//metadata:go_default_library",
],
)

94
validator/rpc/auth.go Normal file
View File

@@ -0,0 +1,94 @@
package rpc
import (
"context"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/pkg/errors"
pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
"github.com/prysmaticlabs/prysm/shared/promptutil"
"github.com/prysmaticlabs/prysm/shared/roughtime"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
tokenExpiryLength = 20 * time.Minute
hashCost = 8
)
// Signup to authenticate access to the validator RPC API using bcrypt and
// a sufficiently strong password check.
func (s *Server) Signup(ctx context.Context, req *pb.AuthRequest) (*pb.AuthResponse, error) {
// First, we check if the validator already has a password. In this case,
// the user should NOT be able to signup and the function will return an error.
existingPassword, err := s.valDB.HashedPasswordForAPI(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Could not retrieve hashed password from database")
}
if len(existingPassword) != 0 {
return nil, status.Error(codes.PermissionDenied, "Validator already has a password set, cannot signup")
}
// We check the strength of the password to ensure it is high-entropy,
// has the required character count, and contains only unicode characters.
if err := promptutil.ValidatePasswordInput(req.Password); err != nil {
return nil, status.Error(codes.InvalidArgument, "Could not validate password input")
}
// Salt and hash the password using the bcrypt algorithm
// The second argument is the cost of hashing, which we arbitrarily set as 8
// (this value can be more or less, depending on the computing power you wish to utilize)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), hashCost)
if err != nil {
return nil, status.Error(codes.Internal, "Could not generate hashed password")
}
// We store the hashed password to disk.
if err := s.valDB.SaveHashedPasswordForAPI(ctx, hashedPassword); err != nil {
return nil, status.Error(codes.Internal, "Could not save hashed password to database")
}
return s.sendAuthResponse()
}
// Login to authenticate with the validator RPC API using a password.
func (s *Server) Login(ctx context.Context, req *pb.AuthRequest) (*pb.AuthResponse, error) {
// We retrieve the hashed password for the validator API from disk.
hashedPassword, err := s.valDB.HashedPasswordForAPI(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Could not retrieve hashed password from database")
}
// Compare the stored hashed password, with the hashed version of the password that was received.
if err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(req.Password)); err != nil {
return nil, status.Error(codes.Unauthenticated, "Incorrect password")
}
return s.sendAuthResponse()
}
// Sends an auth response via gRPC containing a new JWT token.
func (s *Server) sendAuthResponse() (*pb.AuthResponse, error) {
// If everything is fine here, construct the auth token.
tokenString, expirationTime, err := s.createTokenString()
if err != nil {
return nil, status.Error(codes.Internal, "Could not create jwt token string")
}
return &pb.AuthResponse{
Token: []byte(tokenString),
TokenExpiration: expirationTime,
}, nil
}
// Creates a JWT token string using the JWT key with an expiration timestamp.
func (s *Server) createTokenString() (string, uint64, error) {
expirationTime := roughtime.Now().Add(tokenExpiryLength)
claims := &jwt.StandardClaims{
// In JWT, the expiry time is expressed as unix milliseconds.
ExpiresAt: expirationTime.Unix(),
}
// Declare the token with the algorithm used for signing, and the claims.
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(s.jwtKey)
if err != nil {
return "", 0, errors.Wrap(err, "could not sign token")
}
return tokenString, uint64(claims.ExpiresAt), nil
}

View File

@@ -0,0 +1,62 @@
package rpc
import (
"context"
"testing"
pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
"github.com/prysmaticlabs/prysm/shared/testutil/assert"
"github.com/prysmaticlabs/prysm/shared/testutil/require"
dbtest "github.com/prysmaticlabs/prysm/validator/db/testing"
)
func TestServer_Signup_PasswordAlreadyExists(t *testing.T) {
valDB := dbtest.SetupDB(t, [][48]byte{})
ctx := context.Background()
ss := &Server{
valDB: valDB,
}
// Save a hash password pre-emptively to the database.
hashedPassword := []byte("2093402934902839489238492")
require.NoError(t, valDB.SaveHashedPasswordForAPI(ctx, hashedPassword))
// Attempt to signup despite already having a hashed password in the DB
// which should immediately fail.
strongPass := "29384283xasjasd32%%&*@*#*"
_, err := ss.Signup(ctx, &pb.AuthRequest{
Password: strongPass,
})
require.ErrorContains(t, "Validator already has a password set, cannot signup", err)
}
func TestServer_SignupAndLogin_RoundTrip(t *testing.T) {
valDB := dbtest.SetupDB(t, [][48]byte{})
ctx := context.Background()
ss := &Server{
valDB: valDB,
}
weakPass := "password"
_, err := ss.Signup(ctx, &pb.AuthRequest{
Password: weakPass,
})
require.ErrorContains(t, "Could not validate password input", err)
// We assert we are able to signup with a strong password.
strongPass := "29384283xasjasd32%%&*@*#*"
_, err = ss.Signup(ctx, &pb.AuthRequest{
Password: strongPass,
})
require.NoError(t, err)
// Assert we stored the hashed password.
hashedPass, err := valDB.HashedPasswordForAPI(ctx)
require.NoError(t, err)
assert.NotEqual(t, 0, len(hashedPass))
// We assert we are able to login.
_, err = ss.Login(ctx, &pb.AuthRequest{
Password: strongPass,
})
require.NoError(t, err)
}

View File

@@ -170,20 +170,20 @@ func setupFakeClient() *client.FakeValidator {
6: {'f'},
},
PubkeyToIndexMap: map[[48]byte]uint64{
[48]byte{'a'}: 1,
[48]byte{'b'}: 2,
[48]byte{'c'}: 3,
[48]byte{'d'}: 4,
[48]byte{'e'}: 5,
[48]byte{'f'}: 6,
{'a'}: 1,
{'b'}: 2,
{'c'}: 3,
{'d'}: 4,
{'e'}: 5,
{'f'}: 6,
},
Balances: map[[48]byte]uint64{
[48]byte{'a'}: 11,
[48]byte{'b'}: 12,
[48]byte{'c'}: 13,
[48]byte{'d'}: 14,
[48]byte{'e'}: 15,
[48]byte{'f'}: 16,
{'a'}: 11,
{'b'}: 12,
{'c'}: 13,
{'d'}: 14,
{'e'}: 15,
{'f'}: 16,
},
PubkeysToStatusesMap: map[[48]byte]ethpb.ValidatorStatus{
[48]byte{'a'}: 0,

View File

@@ -0,0 +1,70 @@
package rpc
import (
"context"
"sync"
"github.com/dgrijalva/jwt-go"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// noAuthPaths keeps track of the paths which do not require
// authentication from our API.
var (
noAuthPaths = map[string]bool{
"/proto/Login": true,
"/proto/Signup": true,
}
authLock sync.RWMutex
)
// JWTInterceptor is a gRPC unary interceptor to authorize incoming requests
// for methods that are NOT in the noAuthPaths configuration map.
func (s *Server) JWTInterceptor() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// Skip authorize when the path doesn't require auth.
authLock.RLock()
shouldAuthenticate := !noAuthPaths[info.FullMethod]
authLock.RUnlock()
if shouldAuthenticate {
if err := s.authorize(ctx); err != nil {
return nil, err
}
}
h, err := handler(ctx, req)
log.Debugf("Request - Method: %s, Error: %v\n", info.FullMethod, err)
return h, err
}
}
// Authorize the token received is valid.
func (s *Server) authorize(ctx context.Context) error {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Errorf(codes.InvalidArgument, "retrieving metadata failed")
}
authHeader, ok := md["authorization"]
if !ok {
return status.Errorf(codes.Unauthenticated, "authorization token could not be found")
}
checkParsedKey := func(*jwt.Token) (interface{}, error) {
return s.jwtKey, nil
}
token := authHeader[0]
_, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, checkParsedKey)
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,71 @@
package rpc
import (
"context"
"strings"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
func TestServer_JWTInterceptor_Verify(t *testing.T) {
s := Server{
jwtKey: []byte("testKey"),
}
interceptor := s.JWTInterceptor()
unaryInfo := &grpc.UnaryServerInfo{
FullMethod: "Proto.CreateWallet",
}
unaryHandler := func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, nil
}
token, _, err := s.createTokenString()
if err != nil {
t.Fatal(err)
}
ctxMD := map[string][]string{
"authorization": {token},
}
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, ctxMD)
_, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler)
if err != nil {
t.Fatal(err)
}
}
func TestServer_JWTInterceptor_BadToken(t *testing.T) {
s := Server{
jwtKey: []byte("testKey"),
}
interceptor := s.JWTInterceptor()
unaryInfo := &grpc.UnaryServerInfo{
FullMethod: "Proto.CreateWallet",
}
unaryHandler := func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, nil
}
badServer := Server{
jwtKey: []byte("badTestKey"),
}
token, _, err := badServer.createTokenString()
if err != nil {
t.Fatal(err)
}
ctxMD := map[string][]string{
"authorization": {token},
}
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, ctxMD)
_, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler)
if err == nil {
t.Fatalf("Unexpected success processing token %v", err)
}
if !strings.Contains(err.Error(), "signature is invalid") {
t.Fatalf("Expected error validating signature, received %v", err)
}
}

View File

@@ -5,8 +5,17 @@ import (
"fmt"
"net"
middleware "github.com/grpc-ecosystem/go-grpc-middleware"
recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
grpc_opentracing "github.com/grpc-ecosystem/go-grpc-middleware/tracing/opentracing"
grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
"github.com/prysmaticlabs/prysm/shared/rand"
"github.com/prysmaticlabs/prysm/shared/traceutil"
"github.com/prysmaticlabs/prysm/validator/client"
"github.com/prysmaticlabs/prysm/validator/db"
"github.com/sirupsen/logrus"
"go.opencensus.io/plugin/ocgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/reflection"
@@ -24,11 +33,13 @@ type Config struct {
Port string
CertFlag string
KeyFlag string
ValDB db.Database
ValidatorService *client.ValidatorService
}
// Server defining a gRPC server for the remote signer API.
type Server struct {
valDB db.Database
ctx context.Context
cancel context.CancelFunc
host string
@@ -38,6 +49,7 @@ type Server struct {
withKey string
credentialError error
grpcServer *grpc.Server
jwtKey []byte
validatorService *client.ValidatorService
}
@@ -51,6 +63,7 @@ func NewServer(ctx context.Context, cfg *Config) *Server {
port: cfg.Port,
withCert: cfg.CertFlag,
withKey: cfg.KeyFlag,
valDB: cfg.ValDB,
validatorService: cfg.ValidatorService,
}
}
@@ -65,7 +78,21 @@ func (s *Server) Start() {
}
s.listener = lis
opts := make([]grpc.ServerOption, 0)
// Register interceptors for metrics gathering as well as our
// own, custom JWT unary interceptor.
opts := []grpc.ServerOption{
grpc.StatsHandler(&ocgrpc.ServerHandler{}),
grpc.UnaryInterceptor(middleware.ChainUnaryServer(
recovery.UnaryServerInterceptor(
recovery.WithRecoveryHandlerContext(traceutil.RecoveryHandlerFunc),
),
grpc_prometheus.UnaryServerInterceptor,
grpc_opentracing.UnaryServerInterceptor(),
s.JWTInterceptor(),
)),
}
grpc_prometheus.EnableHandlingTimeHistogram()
if s.withCert != "" && s.withKey != "" {
creds, err := credentials.NewServerTLSFromFile(s.withCert, s.withKey)
if err != nil {
@@ -80,8 +107,21 @@ func (s *Server) Start() {
}
s.grpcServer = grpc.NewServer(opts...)
// We create a new, random JWT key upon validator startup.
r := rand.NewGenerator()
jwtKey := make([]byte, 32)
n, err := r.Read(jwtKey)
if err != nil {
log.WithError(err).Fatal("Could not initialize validator jwt key")
}
if n != len(jwtKey) {
log.WithError(err).Fatal("Could not create random jwt key for validator")
}
s.jwtKey = jwtKey
// Register services available for the gRPC server.
reflection.Register(s.grpcServer)
pb.RegisterAuthServer(s.grpcServer, s)
go func() {
if s.listener != nil {

View File

@@ -0,0 +1,7 @@
package rpc
import (
pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
)
var _ = pb.AuthServer(&Server{})