github.com/prysmaticlabs/prysm@v1.4.4/validator/rpc/auth.go (about)

     1  package rpc
     2  
     3  import (
     4  	"context"
     5  	"path/filepath"
     6  	"time"
     7  
     8  	"github.com/form3tech-oss/jwt-go"
     9  	"github.com/golang/protobuf/ptypes/empty"
    10  	"github.com/pkg/errors"
    11  	pb "github.com/prysmaticlabs/prysm/proto/validator/accounts/v2"
    12  	"github.com/prysmaticlabs/prysm/shared/fileutil"
    13  	"github.com/prysmaticlabs/prysm/shared/promptutil"
    14  	"github.com/prysmaticlabs/prysm/shared/timeutils"
    15  	"github.com/prysmaticlabs/prysm/validator/accounts/wallet"
    16  	"golang.org/x/crypto/bcrypt"
    17  	"google.golang.org/grpc/codes"
    18  	"google.golang.org/grpc/status"
    19  )
    20  
    21  var (
    22  	tokenExpiryLength = time.Hour
    23  	hashCost          = 8
    24  )
    25  
    26  const (
    27  	// HashedRPCPassword for the validator RPC access.
    28  	HashedRPCPassword       = "rpc-password-hash"
    29  	checkUserSignupInterval = time.Second * 30
    30  )
    31  
    32  // Signup to authenticate access to the validator RPC API using bcrypt and
    33  // a sufficiently strong password check.
    34  func (s *Server) Signup(ctx context.Context, req *pb.AuthRequest) (*pb.AuthResponse, error) {
    35  	walletDir := s.walletDir
    36  	if req.Password != req.PasswordConfirmation {
    37  		return nil, status.Error(codes.InvalidArgument, "Password confirmation does not match")
    38  	}
    39  	// First, we check if the validator already has a password. In this case,
    40  	// the user should be logged in as normal.
    41  	if fileutil.FileExists(filepath.Join(walletDir, HashedRPCPassword)) {
    42  		return s.Login(ctx, req)
    43  	}
    44  	// We check the strength of the password to ensure it is high-entropy,
    45  	// has the required character count, and contains only unicode characters.
    46  	if err := promptutil.ValidatePasswordInput(req.Password); err != nil {
    47  		return nil, status.Errorf(codes.InvalidArgument, "Could not validate RPC password input: %v", err)
    48  	}
    49  	hasDir, err := fileutil.HasDir(walletDir)
    50  	if err != nil {
    51  		return nil, status.Error(codes.FailedPrecondition, "Could not check if wallet directory exists")
    52  	}
    53  	if !hasDir {
    54  		if err := fileutil.MkdirAll(walletDir); err != nil {
    55  			return nil, status.Errorf(codes.Internal, "could not write directory %s to disk: %v", walletDir, err)
    56  		}
    57  	}
    58  	// Write the password hash to disk.
    59  	if err := s.SaveHashedPassword(req.Password); err != nil {
    60  		return nil, status.Errorf(codes.Internal, "could not write hashed password to disk: %v", err)
    61  	}
    62  	return s.sendAuthResponse()
    63  }
    64  
    65  // Login to authenticate with the validator RPC API using a password.
    66  func (s *Server) Login(ctx context.Context, req *pb.AuthRequest) (*pb.AuthResponse, error) {
    67  	walletDir := s.walletDir
    68  	// We check the strength of the password to ensure it is high-entropy,
    69  	// has the required character count, and contains only unicode characters.
    70  	if err := promptutil.ValidatePasswordInput(req.Password); err != nil {
    71  		return nil, status.Errorf(codes.InvalidArgument, "Could not validate RPC password input: %v", err)
    72  	}
    73  	hashedPasswordPath := filepath.Join(walletDir, HashedRPCPassword)
    74  	if !fileutil.FileExists(hashedPasswordPath) {
    75  		return nil, status.Error(codes.Internal, "Could not find hashed password on disk")
    76  	}
    77  	hashedPassword, err := fileutil.ReadFileAsBytes(hashedPasswordPath)
    78  	if err != nil {
    79  		return nil, status.Error(codes.Internal, "Could not retrieve hashed password from disk")
    80  	}
    81  	// Compare the stored hashed password, with the hashed version of the password that was received.
    82  	if err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(req.Password)); err != nil {
    83  		return nil, status.Error(codes.Unauthenticated, "Incorrect validator RPC password")
    84  	}
    85  	return s.sendAuthResponse()
    86  }
    87  
    88  // HasUsedWeb checks if the user has authenticated via the web interface.
    89  func (s *Server) HasUsedWeb(ctx context.Context, _ *empty.Empty) (*pb.HasUsedWebResponse, error) {
    90  	walletExists, err := wallet.Exists(s.walletDir)
    91  	if err != nil {
    92  		return nil, status.Error(codes.Internal, "Could not check if wallet exists")
    93  	}
    94  	hashedPasswordPath := filepath.Join(s.walletDir, HashedRPCPassword)
    95  	return &pb.HasUsedWebResponse{
    96  		HasSignedUp: fileutil.FileExists(hashedPasswordPath),
    97  		HasWallet:   walletExists,
    98  	}, nil
    99  }
   100  
   101  // Logout a user by invalidating their JWT key.
   102  func (s *Server) Logout(ctx context.Context, _ *empty.Empty) (*empty.Empty, error) {
   103  	// Invalidate the old JWT key, making all requests done with its token fail.
   104  	jwtKey, err := createRandomJWTKey()
   105  	if err != nil {
   106  		return nil, status.Error(codes.Internal, "Could not invalidate JWT key")
   107  	}
   108  	s.jwtKey = jwtKey
   109  	return &empty.Empty{}, nil
   110  }
   111  
   112  // Sends an auth response via gRPC containing a new JWT token.
   113  func (s *Server) sendAuthResponse() (*pb.AuthResponse, error) {
   114  	// If everything is fine here, construct the auth token.
   115  	tokenString, expirationTime, err := s.createTokenString()
   116  	if err != nil {
   117  		return nil, status.Error(codes.Internal, "Could not create jwt token string")
   118  	}
   119  	return &pb.AuthResponse{
   120  		Token:           tokenString,
   121  		TokenExpiration: expirationTime,
   122  	}, nil
   123  }
   124  
   125  // ChangePassword allows changing the RPC password via the API as an authenticated method.
   126  func (s *Server) ChangePassword(ctx context.Context, req *pb.ChangePasswordRequest) (*empty.Empty, error) {
   127  	if req.CurrentPassword == "" {
   128  		return nil, status.Error(codes.InvalidArgument, "Current password cannot be empty")
   129  	}
   130  	hashedPasswordPath := filepath.Join(s.walletDir, HashedRPCPassword)
   131  	if !fileutil.FileExists(hashedPasswordPath) {
   132  		return nil, status.Error(codes.FailedPrecondition, "Could not compare password from disk")
   133  	}
   134  	hashedPassword, err := fileutil.ReadFileAsBytes(hashedPasswordPath)
   135  	if err != nil {
   136  		return nil, status.Error(codes.FailedPrecondition, "Could not retrieve hashed password from disk")
   137  	}
   138  	if err := bcrypt.CompareHashAndPassword(hashedPassword, []byte(req.CurrentPassword)); err != nil {
   139  		return nil, status.Error(codes.Unauthenticated, "Incorrect password")
   140  	}
   141  	if req.Password != req.PasswordConfirmation {
   142  		return nil, status.Error(codes.InvalidArgument, "Password does not match confirmation")
   143  	}
   144  	if err := promptutil.ValidatePasswordInput(req.Password); err != nil {
   145  		return nil, status.Errorf(codes.InvalidArgument, "Could not validate password input: %v", err)
   146  	}
   147  	// Write the new password hash to disk.
   148  	if err := s.SaveHashedPassword(req.Password); err != nil {
   149  		return nil, status.Errorf(codes.Internal, "could not write hashed password to disk: %v", err)
   150  	}
   151  	return &empty.Empty{}, nil
   152  }
   153  
   154  // SaveHashedPassword to disk for the validator RPC.
   155  func (s *Server) SaveHashedPassword(password string) error {
   156  	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), hashCost)
   157  	if err != nil {
   158  		return errors.Wrap(err, "could not generate hashed password")
   159  	}
   160  	hashFilePath := filepath.Join(s.walletDir, HashedRPCPassword)
   161  	return fileutil.WriteFile(hashFilePath, hashedPassword)
   162  }
   163  
   164  // Interval in which we should check if a user has not yet used the RPC Signup endpoint
   165  // which means they are using the --web flag and someone could come in and signup for them
   166  // if they have their web host:port exposed to the Internet.
   167  func (s *Server) checkUserSignup(ctx context.Context) {
   168  	ticker := time.NewTicker(checkUserSignupInterval)
   169  	defer ticker.Stop()
   170  	for {
   171  		select {
   172  		case <-ticker.C:
   173  			hashedPasswordPath := filepath.Join(s.walletDir, HashedRPCPassword)
   174  			if fileutil.FileExists(hashedPasswordPath) {
   175  				return
   176  			}
   177  			log.Warnf(
   178  				"You are using the --web option but have not yet signed via a browser. "+
   179  					"If your web host and port are exposed to the Internet, someone else can attempt to sign up "+
   180  					"for you! You can visit http://%s:%d to view the Prysm web interface",
   181  				s.validatorGatewayHost,
   182  				s.validatorGatewayPort,
   183  			)
   184  		case <-s.ctx.Done():
   185  			return
   186  		}
   187  	}
   188  }
   189  
   190  // Creates a JWT token string using the JWT key with an expiration timestamp.
   191  func (s *Server) createTokenString() (string, uint64, error) {
   192  	// Create a new token object, specifying signing method and the claims
   193  	// you would like it to contain.
   194  	expirationTime := timeutils.Now().Add(tokenExpiryLength)
   195  	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
   196  		ExpiresAt: expirationTime.Unix(),
   197  	})
   198  	// Sign and get the complete encoded token as a string using the secret
   199  	tokenString, err := token.SignedString(s.jwtKey)
   200  	if err != nil {
   201  		return "", 0, err
   202  	}
   203  	return tokenString, uint64(expirationTime.Unix()), nil
   204  }