mirror of
https://github.com/OffchainLabs/prysm.git
synced 2026-01-09 15:37:56 -05:00
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:
@@ -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
94
validator/rpc/auth.go
Normal 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
|
||||
}
|
||||
62
validator/rpc/auth_test.go
Normal file
62
validator/rpc/auth_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
70
validator/rpc/intercepter.go
Normal file
70
validator/rpc/intercepter.go
Normal 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
|
||||
}
|
||||
71
validator/rpc/intercepter_test.go
Normal file
71
validator/rpc/intercepter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
7
validator/rpc/server_test.go
Normal file
7
validator/rpc/server_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
|
||||
)
|
||||
|
||||
var _ = pb.AuthServer(&Server{})
|
||||
Reference in New Issue
Block a user