mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-18 08:16:39 +00:00
Add proxy <-> management authentication
This commit is contained in:
137
management/server/types/proxy_access_token.go
Normal file
137
management/server/types/proxy_access_token.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
b "github.com/hashicorp/go-secure-stdlib/base62"
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/base62"
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProxyTokenPrefix is the globally used prefix for proxy access tokens
|
||||
ProxyTokenPrefix = "nbx_"
|
||||
// ProxyTokenSecretLength is the number of characters used for the secret
|
||||
ProxyTokenSecretLength = 30
|
||||
// ProxyTokenChecksumLength is the number of characters used for the encoded checksum
|
||||
ProxyTokenChecksumLength = 6
|
||||
// ProxyTokenLength is the total number of characters used for the token
|
||||
ProxyTokenLength = 40
|
||||
)
|
||||
|
||||
// HashedProxyToken is a SHA-256 hash of a plain proxy token, base64-encoded.
|
||||
type HashedProxyToken string
|
||||
|
||||
// PlainProxyToken is the raw token string displayed once at creation time.
|
||||
type PlainProxyToken string
|
||||
|
||||
// ProxyAccessToken holds information about a proxy access token including a hashed version for verification
|
||||
type ProxyAccessToken struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Name string
|
||||
HashedToken HashedProxyToken `gorm:"uniqueIndex"`
|
||||
// AccountID is nil for management-wide tokens, set for account-scoped tokens
|
||||
AccountID *string `gorm:"index"`
|
||||
ExpiresAt *time.Time
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
LastUsed *time.Time
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
// IsExpired returns true if the token has expired
|
||||
func (t *ProxyAccessToken) IsExpired() bool {
|
||||
if t.ExpiresAt == nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().After(*t.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid returns true if the token is not revoked and not expired
|
||||
func (t *ProxyAccessToken) IsValid() bool {
|
||||
return !t.Revoked && !t.IsExpired()
|
||||
}
|
||||
|
||||
// ProxyAccessTokenGenerated holds the new token and the plain text version
|
||||
type ProxyAccessTokenGenerated struct {
|
||||
PlainToken PlainProxyToken
|
||||
ProxyAccessToken
|
||||
}
|
||||
|
||||
// CreateNewProxyAccessToken generates a new proxy access token.
|
||||
// Returns the token with hashed value stored and plain token for one-time display.
|
||||
func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) {
|
||||
hashedToken, plainToken, err := generateProxyToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currentTime := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if expiresIn > 0 {
|
||||
expiresAt = util.ToPtr(currentTime.Add(expiresIn))
|
||||
}
|
||||
|
||||
return &ProxyAccessTokenGenerated{
|
||||
ProxyAccessToken: ProxyAccessToken{
|
||||
ID: xid.New().String(),
|
||||
Name: name,
|
||||
HashedToken: hashedToken,
|
||||
AccountID: accountID,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: currentTime,
|
||||
Revoked: false,
|
||||
},
|
||||
PlainToken: plainToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) {
|
||||
secret, err := b.Random(ProxyTokenSecretLength)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
checksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
encodedChecksum := base62.Encode(checksum)
|
||||
paddedChecksum := fmt.Sprintf("%06s", encodedChecksum)
|
||||
plainToken := PlainProxyToken(ProxyTokenPrefix + secret + paddedChecksum)
|
||||
return plainToken.Hash(), plainToken, nil
|
||||
}
|
||||
|
||||
// Hash returns the SHA-256 hash of the plain token, base64-encoded.
|
||||
func (t PlainProxyToken) Hash() HashedProxyToken {
|
||||
h := sha256.Sum256([]byte(t))
|
||||
return HashedProxyToken(base64.StdEncoding.EncodeToString(h[:]))
|
||||
}
|
||||
|
||||
// Validate checks the format of a proxy token without checking the database.
|
||||
func (t PlainProxyToken) Validate() error {
|
||||
if !strings.HasPrefix(string(t), ProxyTokenPrefix) {
|
||||
return fmt.Errorf("invalid token prefix")
|
||||
}
|
||||
|
||||
if len(t) != ProxyTokenLength {
|
||||
return fmt.Errorf("invalid token length")
|
||||
}
|
||||
|
||||
secret := t[len(ProxyTokenPrefix) : len(t)-ProxyTokenChecksumLength]
|
||||
checksumStr := t[len(t)-ProxyTokenChecksumLength:]
|
||||
|
||||
expectedChecksum := crc32.ChecksumIEEE([]byte(secret))
|
||||
expectedChecksumStr := fmt.Sprintf("%06s", base62.Encode(expectedChecksum))
|
||||
|
||||
if string(checksumStr) != expectedChecksumStr {
|
||||
return fmt.Errorf("invalid token checksum")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
155
management/server/types/proxy_access_token_test.go
Normal file
155
management/server/types/proxy_access_token_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPlainProxyToken_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token PlainProxyToken
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
token: "", // will be generated
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "wrong prefix",
|
||||
token: "xyz_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM",
|
||||
wantErr: true,
|
||||
errMsg: "invalid token prefix",
|
||||
},
|
||||
{
|
||||
name: "too short",
|
||||
token: "nbx_short",
|
||||
wantErr: true,
|
||||
errMsg: "invalid token length",
|
||||
},
|
||||
{
|
||||
name: "too long",
|
||||
token: "nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNMextra",
|
||||
wantErr: true,
|
||||
errMsg: "invalid token length",
|
||||
},
|
||||
{
|
||||
name: "correct length but invalid checksum",
|
||||
token: "nbx_invalidtoken123456789012345678901234", // exactly 40 chars, invalid checksum
|
||||
wantErr: true,
|
||||
errMsg: "invalid token checksum",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
token: "",
|
||||
wantErr: true,
|
||||
errMsg: "invalid token prefix",
|
||||
},
|
||||
{
|
||||
name: "only prefix",
|
||||
token: "nbx_",
|
||||
wantErr: true,
|
||||
errMsg: "invalid token length",
|
||||
},
|
||||
}
|
||||
|
||||
// Generate a valid token for the first test
|
||||
generated, err := CreateNewProxyAccessToken("test", 0, nil, "test")
|
||||
require.NoError(t, err)
|
||||
tests[0].token = generated.PlainToken
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.token.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainProxyToken_Hash(t *testing.T) {
|
||||
token1 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM")
|
||||
token2 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM")
|
||||
token3 := PlainProxyToken("nbx_differenttoken1234567890123456789X")
|
||||
|
||||
hash1 := token1.Hash()
|
||||
hash2 := token2.Hash()
|
||||
hash3 := token3.Hash()
|
||||
|
||||
assert.Equal(t, hash1, hash2, "same token should produce same hash")
|
||||
assert.NotEqual(t, hash1, hash3, "different tokens should produce different hashes")
|
||||
assert.NotEmpty(t, hash1)
|
||||
}
|
||||
|
||||
func TestCreateNewProxyAccessToken(t *testing.T) {
|
||||
t.Run("creates valid token", func(t *testing.T) {
|
||||
generated, err := CreateNewProxyAccessToken("test-token", 0, nil, "test-user")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, generated.ID)
|
||||
assert.Equal(t, "test-token", generated.Name)
|
||||
assert.Equal(t, "test-user", generated.CreatedBy)
|
||||
assert.NotEmpty(t, generated.HashedToken)
|
||||
assert.NotEmpty(t, generated.PlainToken)
|
||||
assert.Nil(t, generated.ExpiresAt)
|
||||
assert.False(t, generated.Revoked)
|
||||
|
||||
assert.NoError(t, generated.PlainToken.Validate())
|
||||
assert.Equal(t, ProxyTokenLength, len(generated.PlainToken))
|
||||
assert.Equal(t, ProxyTokenPrefix, string(generated.PlainToken[:len(ProxyTokenPrefix)]))
|
||||
})
|
||||
|
||||
t.Run("tokens are unique", func(t *testing.T) {
|
||||
gen1, err := CreateNewProxyAccessToken("test1", 0, nil, "user")
|
||||
require.NoError(t, err)
|
||||
|
||||
gen2, err := CreateNewProxyAccessToken("test2", 0, nil, "user")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, gen1.PlainToken, gen2.PlainToken)
|
||||
assert.NotEqual(t, gen1.HashedToken, gen2.HashedToken)
|
||||
assert.NotEqual(t, gen1.ID, gen2.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyAccessToken_IsExpired(t *testing.T) {
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
|
||||
t.Run("expired token", func(t *testing.T) {
|
||||
token := &ProxyAccessToken{ExpiresAt: &past}
|
||||
assert.True(t, token.IsExpired())
|
||||
})
|
||||
|
||||
t.Run("not expired token", func(t *testing.T) {
|
||||
token := &ProxyAccessToken{ExpiresAt: &future}
|
||||
assert.False(t, token.IsExpired())
|
||||
})
|
||||
|
||||
t.Run("no expiration", func(t *testing.T) {
|
||||
token := &ProxyAccessToken{ExpiresAt: nil}
|
||||
assert.False(t, token.IsExpired())
|
||||
})
|
||||
}
|
||||
|
||||
func TestProxyAccessToken_IsValid(t *testing.T) {
|
||||
token := &ProxyAccessToken{
|
||||
Revoked: false,
|
||||
}
|
||||
|
||||
assert.True(t, token.IsValid())
|
||||
|
||||
token.Revoked = true
|
||||
assert.False(t, token.IsValid())
|
||||
}
|
||||
Reference in New Issue
Block a user