Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: jwt token nonce and expiration time #3967

Merged
merged 11 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions api/rpc/perms/permissions.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package perms

import (
"crypto/rand"
"encoding/json"
"time"

"github.com/cristalhq/jwt/v5"
"github.com/filecoin-project/go-jsonrpc/auth"
Expand All @@ -19,7 +21,9 @@ var AuthKey = "Authorization"
// JWTPayload is a utility struct for marshaling/unmarshalling
// permissions into for token signing/verifying.
type JWTPayload struct {
Allow []auth.Permission
Allow []auth.Permission
Nonce []byte
ExpiresAt time.Time
}

func (j *JWTPayload) MarshalBinary() (data []byte, err error) {
Expand All @@ -29,8 +33,26 @@ func (j *JWTPayload) MarshalBinary() (data []byte, err error) {
// NewTokenWithPerms generates and signs a new JWT token with the given secret
// and given permissions.
func NewTokenWithPerms(signer jwt.Signer, perms []auth.Permission) ([]byte, error) {
return NewTokenWithTTL(signer, perms, 0)
}

// NewTokenWithTTL generates and signs a new JWT token with the given secret
// and given permissions and TTL.
func NewTokenWithTTL(signer jwt.Signer, perms []auth.Permission, ttl time.Duration) ([]byte, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}

var expiresAt time.Time
if ttl != 0 {
expiresAt = time.Now().UTC().Add(ttl)
}

p := &JWTPayload{
Allow: perms,
Allow: perms,
Nonce: nonce,
ExpiresAt: expiresAt,
}
token, err := jwt.NewBuilder(signer).Build(p)
if err != nil {
Expand Down
36 changes: 36 additions & 0 deletions api/rpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,42 @@ func TestRPCCallsUnderlyingNode(t *testing.T) {
require.Equal(t, expectedBalance, balance)
}

func TestRPCCallsTokenExpired(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

// generate dummy signer and sign admin perms token with it
key := make([]byte, 32)

signer, err := jwt.NewSignerHS(jwt.HS256, key)
require.NoError(t, err)

verifier, err := jwt.NewVerifierHS(jwt.HS256, key)
require.NoError(t, err)

nd, _ := setupNodeWithAuthedRPC(t, signer, verifier)
url := nd.RPCServer.ListenAddr()

adminToken, err := perms.NewTokenWithTTL(signer, perms.AllPerms, time.Millisecond)
require.NoError(t, err)

// we need to run this a few times to prevent the race where the server is not yet started
var rpcClient *client.Client
for i := 0; i < 3; i++ {
time.Sleep(time.Second * 1)
rpcClient, err = client.NewClient(ctx, "http://"+url, string(adminToken))
if err == nil {
t.Cleanup(rpcClient.Close)
break
}
}
require.NotNil(t, rpcClient)
require.NoError(t, err)

_, err = rpcClient.State.Balance(ctx)
require.ErrorContains(t, err, "request failed, http status 401 Unauthorized")
}

// api contains all modules that are made available as the node's
// public API surface
type api struct {
Expand Down
16 changes: 13 additions & 3 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"path/filepath"
"time"

"github.com/cristalhq/jwt/v5"
"github.com/filecoin-project/go-jsonrpc/auth"
Expand All @@ -19,6 +20,8 @@ import (
nodemod "github.com/celestiaorg/celestia-node/nodebuilder/node"
)

var ttlFlagName = "ttl"

func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
cmd := &cobra.Command{
Use: "auth [permission-level (e.g. read || write || admin)]",
Expand All @@ -34,6 +37,11 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
return err
}

ttl, err := cmd.Flags().GetDuration(ttlFlagName)
if err != nil {
return err
}

ks, err := newKeystore(StorePath(cmd.Context()))
if err != nil {
return err
Expand All @@ -50,7 +58,7 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
}
}

token, err := buildJWTToken(key.Body, permissions)
token, err := buildJWTToken(key.Body, permissions, ttl)
if err != nil {
return err
}
Expand All @@ -62,6 +70,8 @@ func AuthCmd(fsets ...*flag.FlagSet) *cobra.Command {
for _, set := range fsets {
cmd.Flags().AddFlagSet(set)
}
cmd.Flags().Duration(ttlFlagName, 0, "Set a Time-to-live (TTL) for the token")

return cmd
}

Expand All @@ -73,12 +83,12 @@ func newKeystore(path string) (keystore.Keystore, error) {
return keystore.NewFSKeystore(filepath.Join(expanded, "keys"), nil)
}

func buildJWTToken(body []byte, permissions []auth.Permission) (string, error) {
func buildJWTToken(body []byte, permissions []auth.Permission, ttl time.Duration) (string, error) {
signer, err := jwt.NewSignerHS(jwt.HS256, body)
if err != nil {
return "", err
}
return authtoken.NewSignedJWT(signer, permissions)
return authtoken.NewSignedJWT(signer, permissions, ttl)
}

func generateNewKey(ks keystore.Keystore) (keystore.PrivKey, error) {
Expand Down
2 changes: 1 addition & 1 deletion cmd/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func getToken(path string) (string, error) {
fmt.Printf("error getting the JWT secret: %v", err)
return "", err
}
return buildJWTToken(key.Body, perms.AllPerms)
return buildJWTToken(key.Body, perms.AllPerms, 0)
}

type rpcClientKey struct{}
Expand Down
26 changes: 22 additions & 4 deletions libs/authtoken/authtoken.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package authtoken

import (
"crypto/rand"
"encoding/json"
"fmt"
"time"

"github.com/cristalhq/jwt/v5"
"github.com/filecoin-project/go-jsonrpc/auth"
Expand All @@ -17,17 +20,32 @@ func ExtractSignedPermissions(verifier jwt.Verifier, token string) ([]auth.Permi
return nil, err
}
p := new(perms.JWTPayload)
err = json.Unmarshal(tk.Claims(), p)
if err != nil {

if err := json.Unmarshal(tk.Claims(), p); err != nil {
return nil, err
}
if !p.ExpiresAt.IsZero() && p.ExpiresAt.Before(time.Now().UTC()) {
return nil, fmt.Errorf("token expired %s ago", time.Since(p.ExpiresAt))
cristaloleg marked this conversation as resolved.
Show resolved Hide resolved
}
return p.Allow, nil
}

// NewSignedJWT returns a signed JWT token with the passed permissions and signer.
func NewSignedJWT(signer jwt.Signer, permissions []auth.Permission) (string, error) {
func NewSignedJWT(signer jwt.Signer, permissions []auth.Permission, ttl time.Duration) (string, error) {
nonce := make([]byte, 32)
if _, err := rand.Read(nonce); err != nil {
return "", err
}

var expiresAt time.Time
if ttl != 0 {
expiresAt = time.Now().UTC().Add(ttl)
}

token, err := jwt.NewBuilder(signer).Build(&perms.JWTPayload{
Allow: permissions,
Allow: permissions,
Nonce: nonce,
ExpiresAt: expiresAt,
})
if err != nil {
return "", err
Expand Down
9 changes: 8 additions & 1 deletion nodebuilder/node/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package node

import (
"context"
"time"

"github.com/cristalhq/jwt/v5"
"github.com/filecoin-project/go-jsonrpc/auth"
Expand Down Expand Up @@ -57,5 +58,11 @@ func (m *module) AuthVerify(_ context.Context, token string) ([]auth.Permission,
}

func (m *module) AuthNew(_ context.Context, permissions []auth.Permission) (string, error) {
return authtoken.NewSignedJWT(m.signer, permissions)
return authtoken.NewSignedJWT(m.signer, permissions, 0)
}

func (m *module) AuthNewWithExpiry(_ context.Context,
permissions []auth.Permission, ttl time.Duration,
) (string, error) {
return authtoken.NewSignedJWT(m.signer, permissions, ttl)
}
12 changes: 9 additions & 3 deletions nodebuilder/node/cmd/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ var authCmd = &cobra.Command{
Use: "set-permissions",
Args: cobra.MinimumNArgs(1),
Short: "Signs and returns a new token with the given permissions.",
RunE: func(c *cobra.Command, args []string) error {
client, err := cmdnode.ParseClientFromCtx(c.Context())
RunE: func(cmd *cobra.Command, args []string) error {
cristaloleg marked this conversation as resolved.
Show resolved Hide resolved
client, err := cmdnode.ParseClientFromCtx(cmd.Context())
if err != nil {
return err
}
Expand All @@ -99,7 +99,13 @@ var authCmd = &cobra.Command{
perms[i] = (auth.Permission)(p)
}

result, err := client.Node.AuthNew(c.Context(), perms)
ttl, _ := cmd.Flags().GetDuration("ttl")
if ttl != 0 {
result, err := client.Node.AuthNewWithExpiry(cmd.Context(), perms, ttl)
return cmdnode.PrintOutput(result, err, nil)
}

result, err := client.Node.AuthNew(cmd.Context(), perms)
return cmdnode.PrintOutput(result, err, nil)
},
}
18 changes: 13 additions & 5 deletions nodebuilder/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package node

import (
"context"
"time"

"github.com/filecoin-project/go-jsonrpc/auth"
)
Expand All @@ -24,17 +25,20 @@ type Module interface {
AuthVerify(ctx context.Context, token string) ([]auth.Permission, error)
// AuthNew signs and returns a new token with the given permissions.
AuthNew(ctx context.Context, perms []auth.Permission) (string, error)
// AuthNewWithExpiry signs and returns a new token with the given permissions and TTL.
AuthNewWithExpiry(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error)
}

var _ Module = (*API)(nil)

type API struct {
Internal struct {
Info func(context.Context) (Info, error) `perm:"admin"`
Ready func(context.Context) (bool, error) `perm:"read"`
LogLevelSet func(ctx context.Context, name, level string) error `perm:"admin"`
AuthVerify func(ctx context.Context, token string) ([]auth.Permission, error) `perm:"admin"`
AuthNew func(ctx context.Context, perms []auth.Permission) (string, error) `perm:"admin"`
Info func(context.Context) (Info, error) `perm:"admin"`
Ready func(context.Context) (bool, error) `perm:"read"`
LogLevelSet func(ctx context.Context, name, level string) error `perm:"admin"`
AuthVerify func(ctx context.Context, token string) ([]auth.Permission, error) `perm:"admin"`
AuthNew func(ctx context.Context, perms []auth.Permission) (string, error) `perm:"admin"`
AuthNewWithExpiry func(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) `perm:"admin"`
}
}

Expand All @@ -57,3 +61,7 @@ func (api *API) AuthVerify(ctx context.Context, token string) ([]auth.Permission
func (api *API) AuthNew(ctx context.Context, perms []auth.Permission) (string, error) {
return api.Internal.AuthNew(ctx, perms)
}

func (api *API) AuthNewWithExpiry(ctx context.Context, perms []auth.Permission, ttl time.Duration) (string, error) {
return api.Internal.AuthNewWithExpiry(ctx, perms, ttl)
}
2 changes: 1 addition & 1 deletion nodebuilder/tests/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func getAdminClient(ctx context.Context, nd *nodebuilder.Node, t *testing.T) *cl
signer := nd.AdminSigner
listenAddr := "ws://" + nd.RPCServer.ListenAddr()

jwt, err := authtoken.NewSignedJWT(signer, []auth.Permission{"public", "read", "write", "admin"})
jwt, err := authtoken.NewSignedJWT(signer, []auth.Permission{"public", "read", "write", "admin"}, time.Minute)
require.NoError(t, err)

client, err := client.NewClient(ctx, listenAddr, jwt)
Expand Down