consistent auth token for validator apis (#13747)

* wip

* fixing tests

* adding more tests especially to handle legacy

* fixing linting

* fixing deepsource issues and flags

* fixing some deepsource issues,pathing issues, and logs

* some review items

* adding additional review feedback

* updating to follow updates from https://github.com/ethereum/keymanager-APIs/pull/74

* adjusting functions to match changes in keymanagers PR

* Update validator/rpc/auth_token.go

Co-authored-by: Radosław Kapka <rkapka@wp.pl>

* Update validator/rpc/auth_token.go

Co-authored-by: Radosław Kapka <rkapka@wp.pl>

* Update validator/rpc/auth_token.go

Co-authored-by: Radosław Kapka <rkapka@wp.pl>

* review feedback

---------

Co-authored-by: Radosław Kapka <rkapka@wp.pl>
This commit is contained in:
james-prysm
2024-04-18 11:26:49 -05:00
committed by GitHub
parent 219301339c
commit feb16ae4aa
21 changed files with 378 additions and 250 deletions

View File

@@ -38,7 +38,6 @@ go_library(
"//consensus-types/primitives:go_default_library",
"//consensus-types/validator:go_default_library",
"//crypto/bls:go_default_library",
"//crypto/rand:go_default_library",
"//encoding/bytesutil:go_default_library",
"//io/file:go_default_library",
"//io/logs:go_default_library",
@@ -148,6 +147,7 @@ go_test(
"@com_github_gorilla_mux//:go_default_library",
"@com_github_grpc_ecosystem_grpc_gateway_v2//runtime:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_sirupsen_logrus//hooks/test:go_default_library",
"@com_github_tyler_smith_go_bip39//:go_default_library",
"@com_github_wealdtech_go_eth2_wallet_encryptor_keystorev4//:go_default_library",
"@org_golang_google_grpc//:go_default_library",

View File

@@ -15,32 +15,24 @@ import (
"github.com/fsnotify/fsnotify"
"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v5/crypto/rand"
"github.com/prysmaticlabs/prysm/v5/api"
"github.com/prysmaticlabs/prysm/v5/encoding/bytesutil"
"github.com/prysmaticlabs/prysm/v5/io/file"
)
const (
AuthTokenFileName = "auth-token"
)
// CreateAuthToken generates a new jwt key, token and writes them
// to a file in the specified directory. Also, it logs out a prepared URL
// for the user to navigate to and authenticate with the Prysm web interface.
func CreateAuthToken(walletDirPath, validatorWebAddr string) error {
jwtKey, err := createRandomJWTSecret()
func CreateAuthToken(authPath, validatorWebAddr string) error {
token, err := api.GenerateRandomHexString()
if err != nil {
return err
}
token, err := createTokenString(jwtKey)
if err != nil {
log.Infof("Generating auth token and saving it to %s", authPath)
if err := saveAuthToken(authPath, token); err != nil {
return err
}
authTokenPath := filepath.Join(walletDirPath, AuthTokenFileName)
log.Infof("Generating auth token and saving it to %s", authTokenPath)
if err := saveAuthToken(walletDirPath, jwtKey, token); err != nil {
return err
}
logValidatorWebAuth(validatorWebAddr, token, authTokenPath)
logValidatorWebAuth(validatorWebAddr, token, authPath)
return nil
}
@@ -49,18 +41,18 @@ func CreateAuthToken(walletDirPath, validatorWebAddr string) error {
// user via stdout and the validator client should then attempt to open the default
// browser. The web interface authenticates by looking for this token in the query parameters
// of the URL. This token is then used as the bearer token for jwt auth.
func (s *Server) initializeAuthToken(walletDir string) (string, error) {
authTokenFile := filepath.Join(walletDir, AuthTokenFileName)
exists, err := file.Exists(authTokenFile, file.Regular)
if err != nil {
return "", errors.Wrapf(err, "could not check if file exists: %s", authTokenFile)
func (s *Server) initializeAuthToken() error {
if s.authTokenPath == "" {
return errors.New("auth token path is empty")
}
exists, err := file.Exists(s.authTokenPath, file.Regular)
if err != nil {
return errors.Wrapf(err, "could not check if file %s exists", s.authTokenPath)
}
if exists {
// #nosec G304
f, err := os.Open(authTokenFile)
f, err := os.Open(filepath.Clean(s.authTokenPath))
if err != nil {
return "", err
return err
}
defer func() {
if err := f.Close(); err != nil {
@@ -69,24 +61,18 @@ func (s *Server) initializeAuthToken(walletDir string) (string, error) {
}()
secret, token, err := readAuthTokenFile(f)
if err != nil {
return "", err
return err
}
s.jwtSecret = secret
return token, nil
s.authToken = token
return nil
}
jwtKey, err := createRandomJWTSecret()
token, err := api.GenerateRandomHexString()
if err != nil {
return "", err
return err
}
s.jwtSecret = jwtKey
token, err := createTokenString(s.jwtSecret)
if err != nil {
return "", err
}
if err := saveAuthToken(walletDir, jwtKey, token); err != nil {
return "", err
}
return token, nil
s.authToken = token
return saveAuthToken(s.authTokenPath, token)
}
func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenPath string) {
@@ -106,16 +92,20 @@ func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenP
}
for {
select {
case <-watcher.Events:
case event := <-watcher.Events:
if event.Op.String() == "REMOVE" {
log.Error("Auth Token was removed! Restart the validator client to regenerate a token")
s.authToken = ""
continue
}
// If a file was modified, we attempt to read that file
// and parse it into our accounts store.
token, err := s.initializeAuthToken(s.walletDir)
if err != nil {
if err := s.initializeAuthToken(); err != nil {
log.WithError(err).Errorf("Could not watch for file changes for: %s", authTokenPath)
continue
}
validatorWebAddr := fmt.Sprintf("%s:%d", s.validatorGatewayHost, s.validatorGatewayPort)
logValidatorWebAuth(validatorWebAddr, token, authTokenPath)
logValidatorWebAuth(validatorWebAddr, s.authToken, authTokenPath)
case err := <-watcher.Errors:
log.WithError(err).Errorf("Could not watch for file changes for: %s", authTokenPath)
case <-ctx.Done():
@@ -124,7 +114,7 @@ func (s *Server) refreshAuthTokenFromFileChanges(ctx context.Context, authTokenP
}
}
func logValidatorWebAuth(validatorWebAddr, token string, tokenPath string) {
func logValidatorWebAuth(validatorWebAddr, token, tokenPath string) {
webAuthURLTemplate := "http://%s/initialize?token=%s"
webAuthURL := fmt.Sprintf(
webAuthURLTemplate,
@@ -136,18 +126,11 @@ func logValidatorWebAuth(validatorWebAddr, token string, tokenPath string) {
"the Prysm web interface",
)
log.Info(webAuthURL)
log.Infof("Validator CLient JWT for RPC and REST authentication set at:%s", tokenPath)
log.Infof("Validator Client auth token for gRPC and REST authentication set at %s", tokenPath)
}
func saveAuthToken(walletDirPath string, jwtKey []byte, token string) error {
hashFilePath := filepath.Join(walletDirPath, AuthTokenFileName)
func saveAuthToken(tokenPath string, token string) error {
bytesBuf := new(bytes.Buffer)
if _, err := bytesBuf.WriteString(fmt.Sprintf("%x", jwtKey)); err != nil {
return err
}
if _, err := bytesBuf.WriteString("\n"); err != nil {
return err
}
if _, err := bytesBuf.WriteString(token); err != nil {
return err
}
@@ -155,34 +138,61 @@ func saveAuthToken(walletDirPath string, jwtKey []byte, token string) error {
return err
}
if err := file.MkdirAll(walletDirPath); err != nil {
return errors.Wrapf(err, "could not create directory %s", walletDirPath)
if err := file.MkdirAll(filepath.Dir(tokenPath)); err != nil {
return errors.Wrapf(err, "could not create directory %s", filepath.Dir(tokenPath))
}
if err := file.WriteFile(hashFilePath, bytesBuf.Bytes()); err != nil {
return errors.Wrapf(err, "could not write to file %s", hashFilePath)
if err := file.WriteFile(tokenPath, bytesBuf.Bytes()); err != nil {
return errors.Wrapf(err, "could not write to file %s", tokenPath)
}
return nil
}
func readAuthTokenFile(r io.Reader) (secret []byte, token string, err error) {
br := bufio.NewReader(r)
var jwtKeyHex string
jwtKeyHex, err = br.ReadString('\n')
if err != nil {
return
func readAuthTokenFile(r io.Reader) ([]byte, string, error) {
scanner := bufio.NewScanner(r)
var lines []string
var secret []byte
var token string
// Scan the file and collect lines, excluding empty lines
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
lines = append(lines, line)
}
}
secret, err = hex.DecodeString(strings.TrimSpace(jwtKeyHex))
if err != nil {
return
// Check for scanning errors
if err := scanner.Err(); err != nil {
return nil, "", err
}
tokenBytes, _, err := br.ReadLine()
if err != nil {
return
// Process based on the number of lines, excluding empty ones
switch len(lines) {
case 1:
// If there is only one line, interpret it as the token
token = strings.TrimSpace(lines[0])
case 2:
// TODO: Deprecate after a few releases
// For legacy files
// If there are two lines, the first is the jwt key and the second is the token
jwtKeyHex := strings.TrimSpace(lines[0])
s, err := hex.DecodeString(jwtKeyHex)
if err != nil {
return nil, "", errors.Wrapf(err, "could not decode JWT secret")
}
secret = bytesutil.SafeCopyBytes(s)
token = strings.TrimSpace(lines[1])
log.Warn("Auth token is a legacy file and should be regenerated.")
default:
return nil, "", errors.New("Auth token file format has multiple lines, please update the auth token to a single line that is a 256 bit hex string")
}
token = strings.TrimSpace(string(tokenBytes))
return
if err := api.ValidateAuthToken(token); err != nil {
log.WithError(err).Warn("Auth token does not follow our standards and should be regenerated either \n" +
"1. by removing the current token file and restarting \n" +
"2. using the `validator web generate-auth-token` command. \n" +
"Tokens can be generated through the `validator web generate-auth-token` command")
}
return secret, token, nil
}
// Creates a JWT token string using the JWT key.
@@ -195,16 +205,3 @@ func createTokenString(jwtKey []byte) (string, error) {
}
return tokenString, nil
}
func createRandomJWTSecret() ([]byte, error) {
r := rand.NewGenerator()
jwtKey := make([]byte, 32)
n, err := r.Read(jwtKey)
if err != nil {
return nil, err
}
if n != len(jwtKey) {
return nil, errors.New("could not create appropriately sized random JWT secret")
}
return jwtKey, nil
}

View File

@@ -1,16 +1,22 @@
package rpc
import (
"bufio"
"bytes"
"context"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/golang-jwt/jwt/v4"
"github.com/prysmaticlabs/prysm/v5/api"
"github.com/prysmaticlabs/prysm/v5/io/file"
"github.com/prysmaticlabs/prysm/v5/testing/require"
logTest "github.com/sirupsen/logrus/hooks/test"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
@@ -24,11 +30,14 @@ func setupWalletDir(t testing.TB) string {
func TestServer_AuthenticateUsingExistingToken(t *testing.T) {
// Initializing for the first time, there is no auth token file in
// the wallet directory, so we generate a jwt token and secret from scratch.
srv := &Server{}
walletDir := setupWalletDir(t)
token, err := srv.initializeAuthToken(walletDir)
authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName)
srv := &Server{
authTokenPath: authTokenPath,
}
err := srv.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, true, len(srv.jwtSecret) > 0)
unaryInfo := &grpc.UnaryServerInfo{
FullMethod: "Proto.CreateWallet",
@@ -37,78 +46,173 @@ func TestServer_AuthenticateUsingExistingToken(t *testing.T) {
return nil, nil
}
ctxMD := map[string][]string{
"authorization": {"Bearer " + token},
"authorization": {"Bearer " + srv.authToken},
}
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, ctxMD)
_, err = srv.JWTInterceptor()(ctx, "xyz", unaryInfo, unaryHandler)
_, err = srv.AuthTokenInterceptor()(ctx, "xyz", unaryInfo, unaryHandler)
require.NoError(t, err)
// Next up, we make the same request but reinitialize the server and we should still
// pass with the same auth token.
srv = &Server{}
_, err = srv.initializeAuthToken(walletDir)
srv = &Server{
authTokenPath: authTokenPath,
}
err = srv.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, true, len(srv.jwtSecret) > 0)
_, err = srv.JWTInterceptor()(ctx, "xyz", unaryInfo, unaryHandler)
_, err = srv.AuthTokenInterceptor()(ctx, "xyz", unaryInfo, unaryHandler)
require.NoError(t, err)
}
func TestServer_RefreshJWTSecretOnFileChange(t *testing.T) {
func TestServer_RefreshAuthTokenOnFileChange(t *testing.T) {
// Initializing for the first time, there is no auth token file in
// the wallet directory, so we generate a jwt token and secret from scratch.
srv := &Server{}
walletDir := setupWalletDir(t)
_, err := srv.initializeAuthToken(walletDir)
require.NoError(t, err)
currentSecret := srv.jwtSecret
require.Equal(t, true, len(currentSecret) > 0)
authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName)
srv := &Server{
authTokenPath: authTokenPath,
}
authTokenPath := filepath.Join(walletDir, AuthTokenFileName)
err := srv.initializeAuthToken()
require.NoError(t, err)
currentToken := srv.authToken
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go srv.refreshAuthTokenFromFileChanges(ctx, authTokenPath)
go srv.refreshAuthTokenFromFileChanges(ctx, srv.authTokenPath)
// Wait for service to be ready.
time.Sleep(time.Millisecond * 250)
// Update the auth token file with a new secret.
require.NoError(t, CreateAuthToken(walletDir, "localhost:7500"))
require.NoError(t, CreateAuthToken(srv.authTokenPath, "localhost:7500"))
// The service should have picked up the file change and set the jwt secret to the new one.
time.Sleep(time.Millisecond * 500)
newSecret := srv.jwtSecret
require.Equal(t, true, len(newSecret) > 0)
require.Equal(t, true, !bytes.Equal(currentSecret, newSecret))
err = os.Remove(AuthTokenFileName)
newToken := srv.authToken
require.Equal(t, true, currentToken != newToken)
err = os.Remove(srv.authTokenPath)
require.NoError(t, err)
}
// TODO: remove this test when legacy files are removed
func TestServer_LegacyTokensStillWork(t *testing.T) {
hook := logTest.NewGlobal()
// Initializing for the first time, there is no auth token file in
// the wallet directory, so we generate a jwt token and secret from scratch.
walletDir := setupWalletDir(t)
authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName)
bytesBuf := new(bytes.Buffer)
_, err := bytesBuf.WriteString("b5bbbaf533b625a93741978857f13d7adeca58445a1fb00ecf3373420b92776c")
require.NoError(t, err)
_, err = bytesBuf.WriteString("\n")
require.NoError(t, err)
_, err = bytesBuf.WriteString("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg")
require.NoError(t, err)
_, err = bytesBuf.WriteString("\n")
require.NoError(t, err)
err = file.MkdirAll(walletDir)
require.NoError(t, err)
err = file.WriteFile(authTokenPath, bytesBuf.Bytes())
require.NoError(t, err)
srv := &Server{
authTokenPath: authTokenPath,
}
err = srv.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, hexutil.Encode(srv.jwtSecret), "0xb5bbbaf533b625a93741978857f13d7adeca58445a1fb00ecf3373420b92776c")
require.Equal(t, srv.authToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg")
f, err := os.Open(filepath.Clean(srv.authTokenPath))
require.NoError(t, err)
scanner := bufio.NewScanner(f)
var lines []string
// Scan the file and collect lines, excluding empty lines
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
lines = append(lines, line)
}
}
require.Equal(t, len(lines), 2)
require.LogsContain(t, hook, "Auth token does not follow our standards and should be regenerated")
// Check for scanning errors
err = scanner.Err()
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
err = os.Remove(srv.authTokenPath)
require.NoError(t, err)
}
// TODO: remove this test when legacy files are removed
func TestServer_LegacyTokensBadSecret(t *testing.T) {
// Initializing for the first time, there is no auth token file in
// the wallet directory, so we generate a jwt token and secret from scratch.
walletDir := setupWalletDir(t)
authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName)
bytesBuf := new(bytes.Buffer)
_, err := bytesBuf.WriteString("----------------")
require.NoError(t, err)
_, err = bytesBuf.WriteString("\n")
require.NoError(t, err)
_, err = bytesBuf.WriteString("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.MxwOozSH-TLbW_XKepjyYDHm2IT8Ki0tD3AHuajfNMg")
require.NoError(t, err)
_, err = bytesBuf.WriteString("\n")
require.NoError(t, err)
err = file.MkdirAll(walletDir)
require.NoError(t, err)
err = file.WriteFile(authTokenPath, bytesBuf.Bytes())
require.NoError(t, err)
srv := &Server{
authTokenPath: authTokenPath,
}
err = srv.initializeAuthToken()
require.ErrorContains(t, "could not decode JWT secret", err)
}
func Test_initializeAuthToken(t *testing.T) {
// Initializing for the first time, there is no auth token file in
// the wallet directory, so we generate a jwt token and secret from scratch.
srv := &Server{}
walletDir := setupWalletDir(t)
token, err := srv.initializeAuthToken(walletDir)
authTokenPath := filepath.Join(walletDir, api.AuthTokenFileName)
srv := &Server{
authTokenPath: authTokenPath,
}
err := srv.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, true, len(srv.jwtSecret) > 0)
// Initializing second time, we generate something from the initial file.
srv2 := &Server{}
token2, err := srv2.initializeAuthToken(walletDir)
srv2 := &Server{
authTokenPath: authTokenPath,
}
err = srv2.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, true, bytes.Equal(srv.jwtSecret, srv2.jwtSecret))
require.Equal(t, token, token2)
require.Equal(t, srv.authToken, srv2.authToken)
// Deleting the auth token and re-initializing means we create a jwt token
// and secret from scratch again.
srv3 := &Server{}
walletDir = setupWalletDir(t)
token3, err := srv3.initializeAuthToken(walletDir)
authTokenPath = filepath.Join(walletDir, api.AuthTokenFileName)
srv3 := &Server{
authTokenPath: authTokenPath,
}
err = srv3.initializeAuthToken()
require.NoError(t, err)
require.Equal(t, true, len(srv.jwtSecret) > 0)
require.NotEqual(t, token, token3)
require.NotEqual(t, srv.authToken, srv3.authToken)
}
// "createTokenString" now uses jwt.RegisteredClaims instead of jwt.StandardClaims (deprecated),

View File

@@ -2,7 +2,6 @@ package rpc
import (
"net/http"
"path/filepath"
"github.com/pkg/errors"
"github.com/prysmaticlabs/prysm/v5/io/file"
@@ -20,8 +19,7 @@ func (s *Server) Initialize(w http.ResponseWriter, r *http.Request) {
httputil.HandleError(w, errors.Wrap(err, "Could not check if wallet exists").Error(), http.StatusInternalServerError)
return
}
authTokenPath := filepath.Join(s.walletDir, AuthTokenFileName)
exists, err := file.Exists(authTokenPath, file.Regular)
exists, err := file.Exists(s.authTokenPath, file.Regular)
if err != nil {
httputil.HandleError(w, errors.Wrap(err, "Could not check if auth token exists").Error(), http.StatusInternalServerError)
return

View File

@@ -9,6 +9,7 @@ import (
"path/filepath"
"testing"
"github.com/prysmaticlabs/prysm/v5/api"
"github.com/prysmaticlabs/prysm/v5/testing/require"
"github.com/prysmaticlabs/prysm/v5/validator/accounts"
"github.com/prysmaticlabs/prysm/v5/validator/keymanager"
@@ -19,7 +20,7 @@ func TestInitialize(t *testing.T) {
localWalletDir := setupWalletDir(t)
// Step 2: Optionally create a temporary 'auth-token' file
authTokenPath := filepath.Join(localWalletDir, AuthTokenFileName)
authTokenPath := filepath.Join(localWalletDir, api.AuthTokenFileName)
_, err := os.Create(authTokenPath)
require.NoError(t, err)
@@ -34,7 +35,7 @@ func TestInitialize(t *testing.T) {
require.NoError(t, err)
_, err = acc.WalletCreate(context.Background())
require.NoError(t, err)
server := &Server{walletDir: localWalletDir}
server := &Server{walletDir: localWalletDir, authTokenPath: authTokenPath}
// Step 4: Create an HTTP request and response recorder
req := httptest.NewRequest(http.MethodGet, "/initialize", nil)
@@ -43,6 +44,8 @@ func TestInitialize(t *testing.T) {
// Step 5: Call the Initialize function
server.Initialize(w, req)
require.Equal(t, w.Code, http.StatusOK)
// Step 6: Assert expectations
result := w.Result()
defer func() {

View File

@@ -2,11 +2,9 @@ package rpc
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v4"
"github.com/prysmaticlabs/prysm/v5/api"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
@@ -15,8 +13,8 @@ import (
"google.golang.org/grpc/status"
)
// JWTInterceptor is a gRPC unary interceptor to authorize incoming requests.
func (s *Server) JWTInterceptor() grpc.UnaryServerInterceptor {
// AuthTokenInterceptor is a gRPC unary interceptor to authorize incoming requests.
func (s *Server) AuthTokenInterceptor() grpc.UnaryServerInterceptor {
return func(
ctx context.Context,
req interface{},
@@ -35,8 +33,8 @@ func (s *Server) JWTInterceptor() grpc.UnaryServerInterceptor {
}
}
// JwtHttpInterceptor is an HTTP handler to authorize a route.
func (s *Server) JwtHttpInterceptor(next http.Handler) http.Handler {
// AuthTokenHandler is an HTTP handler to authorize a route.
func (s *Server) AuthTokenHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// if it's not initialize or has a web prefix
if strings.Contains(r.URL.Path, api.WebApiUrlPrefix) || strings.Contains(r.URL.Path, api.KeymanagerApiPrefix) {
@@ -53,9 +51,8 @@ func (s *Server) JwtHttpInterceptor(next http.Handler) http.Handler {
}
token := tokenParts[1]
_, err := jwt.Parse(token, s.validateJWT)
if err != nil {
http.Error(w, fmt.Errorf("forbidden: could not parse JWT token: %v", err).Error(), http.StatusForbidden)
if strings.TrimSpace(token) != s.authToken || strings.TrimSpace(s.authToken) == "" {
http.Error(w, "Forbidden: token value is invalid", http.StatusForbidden)
return
}
}
@@ -78,16 +75,8 @@ func (s *Server) authorize(ctx context.Context) error {
return status.Error(codes.Unauthenticated, "Invalid auth header, needs Bearer {token}")
}
token := strings.Split(authHeader[0], "Bearer ")[1]
_, err := jwt.Parse(token, s.validateJWT)
if err != nil {
return status.Errorf(codes.Unauthenticated, "Could not parse JWT token: %v", err)
if strings.TrimSpace(token) != s.authToken || strings.TrimSpace(s.authToken) == "" {
return status.Errorf(codes.Unauthenticated, "Forbidden: token value is invalid")
}
return nil
}
func (s *Server) validateJWT(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected JWT signing method: %v", token.Header["alg"])
}
return s.jwtSecret, nil
}

View File

@@ -6,18 +6,18 @@ import (
"net/http/httptest"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/prysmaticlabs/prysm/v5/api"
"github.com/prysmaticlabs/prysm/v5/testing/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
func TestServer_JWTInterceptor_Verify(t *testing.T) {
func TestServer_AuthTokenInterceptor_Verify(t *testing.T) {
token := "cool-token"
s := Server{
jwtSecret: []byte("testKey"),
authToken: token,
}
interceptor := s.JWTInterceptor()
interceptor := s.AuthTokenInterceptor()
unaryInfo := &grpc.UnaryServerInfo{
FullMethod: "Proto.CreateWallet",
@@ -25,22 +25,20 @@ func TestServer_JWTInterceptor_Verify(t *testing.T) {
unaryHandler := func(ctx context.Context, req interface{}) (interface{}, error) {
return nil, nil
}
token, err := createTokenString(s.jwtSecret)
require.NoError(t, err)
ctxMD := map[string][]string{
"authorization": {"Bearer " + token},
}
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, ctxMD)
_, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler)
_, err := interceptor(ctx, "xyz", unaryInfo, unaryHandler)
require.NoError(t, err)
}
func TestServer_JWTInterceptor_BadToken(t *testing.T) {
func TestServer_AuthTokenInterceptor_BadToken(t *testing.T) {
s := Server{
jwtSecret: []byte("testKey"),
authToken: "cool-token",
}
interceptor := s.JWTInterceptor()
interceptor := s.AuthTokenInterceptor()
unaryInfo := &grpc.UnaryServerInfo{
FullMethod: "Proto.CreateWallet",
@@ -49,111 +47,65 @@ func TestServer_JWTInterceptor_BadToken(t *testing.T) {
return nil, nil
}
badServer := Server{
jwtSecret: []byte("badTestKey"),
}
token, err := createTokenString(badServer.jwtSecret)
require.NoError(t, err)
ctxMD := map[string][]string{
"authorization": {"Bearer " + token},
"authorization": {"Bearer bad-token"},
}
ctx := context.Background()
ctx = metadata.NewIncomingContext(ctx, ctxMD)
_, err = interceptor(ctx, "xyz", unaryInfo, unaryHandler)
require.ErrorContains(t, "signature is invalid", err)
_, err := interceptor(ctx, "xyz", unaryInfo, unaryHandler)
require.ErrorContains(t, "token value is invalid", err)
}
func TestServer_JWTInterceptor_InvalidSigningType(t *testing.T) {
ss := &Server{jwtSecret: make([]byte, 32)}
// Use a different signing type than the expected, HMAC.
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{})
_, err := ss.validateJWT(token)
require.ErrorContains(t, "unexpected JWT signing method", err)
}
func TestServer_AuthTokenHandler(t *testing.T) {
token := "cool-token"
func TestServer_JwtHttpInterceptor(t *testing.T) {
jwtKey, err := createRandomJWTSecret()
require.NoError(t, err)
s := &Server{jwtSecret: jwtKey}
testHandler := s.JwtHttpInterceptor(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := &Server{authToken: token}
testHandler := s.AuthTokenHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Your test handler logic here
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("Test Response"))
require.NoError(t, err)
}))
t.Run("no jwt was sent", func(t *testing.T) {
t.Run("no auth token was sent", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody)
require.NoError(t, err)
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code)
})
t.Run("wrong jwt was sent", func(t *testing.T) {
t.Run("wrong auth token was sent", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer YOUR_JWT_TOKEN") // Replace with a valid JWT token
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
})
t.Run("jwt was sent", func(t *testing.T) {
t.Run("good auth token was sent", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
require.NoError(t, err)
token, err := createTokenString(jwtKey)
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", http.NoBody)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token) // Replace with a valid JWT token
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
})
t.Run("wrong jwt format was sent", func(t *testing.T) {
t.Run("web endpoint needs auth token", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
require.NoError(t, err)
token, err := createTokenString(jwtKey)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer"+token) // no space was added // Replace with a valid JWT token
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("wrong jwt no bearer format was sent", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
require.NoError(t, err)
token, err := createTokenString(jwtKey)
require.NoError(t, err)
req.Header.Set("Authorization", token) // Replace with a valid JWT token
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("broken jwt token format was sent", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/eth/v1/keystores", nil)
require.NoError(t, err)
token, err := createTokenString(jwtKey)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token[0:2]+" "+token[2:]) // Replace with a valid JWT token
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusForbidden, rr.Code)
})
t.Run("web endpoint needs jwt token", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, "/api/v2/validator/beacon/status", nil)
req, err := http.NewRequest(http.MethodGet, "/api/v2/validator/beacon/status", http.NoBody)
require.NoError(t, err)
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusUnauthorized, rr.Code)
})
t.Run("initialize does not need jwt", func(t *testing.T) {
t.Run("initialize does not need auth", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"initialize", nil)
req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"initialize", http.NoBody)
require.NoError(t, err)
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
})
t.Run("health does not need jwt", func(t *testing.T) {
t.Run("health does not need auth", func(t *testing.T) {
rr := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"health/logs", nil)
req, err := http.NewRequest(http.MethodGet, api.WebUrlPrefix+"health/logs", http.NoBody)
require.NoError(t, err)
testHandler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)

View File

@@ -47,6 +47,7 @@ type Config struct {
CertFlag string
KeyFlag string
ValDB db.Database
AuthTokenPath string
WalletDir string
ValidatorService *client.ValidatorService
SyncChecker client.SyncChecker
@@ -87,6 +88,8 @@ type Server struct {
validatorService *client.ValidatorService
syncChecker client.SyncChecker
genesisFetcher client.GenesisFetcher
authTokenPath string
authToken string
walletDir string
wallet *wallet.Wallet
walletInitializedFeed *event.Feed
@@ -123,6 +126,7 @@ func NewServer(ctx context.Context, cfg *Config) *Server {
validatorService: cfg.ValidatorService,
syncChecker: cfg.SyncChecker,
genesisFetcher: cfg.GenesisFetcher,
authTokenPath: cfg.AuthTokenPath,
walletDir: cfg.WalletDir,
walletInitializedFeed: cfg.WalletInitializedFeed,
walletInitialized: cfg.Wallet != nil,
@@ -136,6 +140,19 @@ func NewServer(ctx context.Context, cfg *Config) *Server {
beaconApiEndpoint: cfg.BeaconApiEndpoint,
router: cfg.Router,
}
if server.authTokenPath == "" && server.walletDir != "" {
server.authTokenPath = filepath.Join(server.walletDir, api.AuthTokenFileName)
}
if server.authTokenPath != "" {
if err := server.initializeAuthToken(); err != nil {
log.WithError(err).Error("Could not initialize web auth token")
}
validatorWebAddr := fmt.Sprintf("%s:%d", server.validatorGatewayHost, server.validatorGatewayPort)
logValidatorWebAuth(validatorWebAddr, server.authToken, server.authTokenPath)
go server.refreshAuthTokenFromFileChanges(server.ctx, server.authTokenPath)
}
// immediately register routes to override any catchalls
if err := server.InitializeRoutes(); err != nil {
log.WithError(err).Fatal("Could not initialize routes")
@@ -146,7 +163,7 @@ func NewServer(ctx context.Context, cfg *Config) *Server {
// Start the gRPC server.
func (s *Server) Start() {
// Setup the gRPC server options and TLS configuration.
address := fmt.Sprintf("%s:%s", s.host, s.port)
address := net.JoinHostPort(s.host, s.port)
lis, err := net.Listen("tcp", address)
if err != nil {
log.WithError(err).Errorf("Could not listen to port in Start() %s", address)
@@ -163,7 +180,7 @@ func (s *Server) Start() {
),
grpcprometheus.UnaryServerInterceptor,
grpcopentracing.UnaryServerInterceptor(),
s.JWTInterceptor(),
s.AuthTokenInterceptor(),
)),
}
grpcprometheus.EnableHandlingTimeHistogram()
@@ -198,17 +215,6 @@ func (s *Server) Start() {
}()
log.WithField("address", address).Info("gRPC server listening on address")
if s.walletDir != "" {
token, err := s.initializeAuthToken(s.walletDir)
if err != nil {
log.WithError(err).Error("Could not initialize web auth token")
return
}
validatorWebAddr := fmt.Sprintf("%s:%d", s.validatorGatewayHost, s.validatorGatewayPort)
authTokenPath := filepath.Join(s.walletDir, AuthTokenFileName)
logValidatorWebAuth(validatorWebAddr, token, authTokenPath)
go s.refreshAuthTokenFromFileChanges(s.ctx, authTokenPath)
}
}
// InitializeRoutes initializes pure HTTP REST endpoints for the validator client.
@@ -218,7 +224,7 @@ func (s *Server) InitializeRoutes() error {
return errors.New("no router found on server")
}
// Adding Auth Interceptor for the routes below
s.router.Use(s.JwtHttpInterceptor)
s.router.Use(s.AuthTokenHandler)
// Register all services, HandleFunc calls, etc.
// ...
s.router.HandleFunc("/eth/v1/keystores", s.ListKeystores).Methods(http.MethodGet)