diff --git a/go.mod b/go.mod index fab3d2b3e..c5594dc8c 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( ) require ( + codeberg.org/ac/base62 v0.0.0-20210305150220-e793b546833a fyne.io/fyne/v2 v2.1.4 github.com/c-robinson/iplib v1.0.3 github.com/coreos/go-iptables v0.6.0 @@ -37,6 +38,7 @@ require ( github.com/gliderlabs/ssh v0.3.4 github.com/godbus/dbus/v5 v5.1.0 github.com/google/nftables v0.0.0-20220808154552-2eca00135732 + github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 github.com/hashicorp/go-version v1.6.0 github.com/libp2p/go-netroute v0.2.0 github.com/magiconair/properties v1.8.5 @@ -87,6 +89,7 @@ require ( github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/gopacket v1.1.19 // indirect + github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/native v0.0.0-20200817173448-b6b71def0850 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect diff --git a/go.sum b/go.sum index dd6b6a3fd..e50c4a033 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +codeberg.org/ac/base62 v0.0.0-20210305150220-e793b546833a h1:U6cY/g6VSiy59vuvnBU6J/eSir0qVg4BeTnCDLaX+20= +codeberg.org/ac/base62 v0.0.0-20210305150220-e793b546833a/go.mod h1:ykEpkLT4JtH3I4Rb4gwkDsNLfgUg803qRDeIX88t3e8= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= fyne.io/fyne/v2 v2.1.4 h1:bt1+28++kAzRzPB0GM2EuSV4cnl8rXNX4cjfd8G06Rc= fyne.io/fyne/v2 v2.1.4/go.mod h1:p+E/Dh+wPW8JwR2DVcsZ9iXgR9ZKde80+Y+40Is54AQ= @@ -281,6 +283,10 @@ github.com/gopherjs/gopherjs v0.0.0-20220410123724-9e86199038b0 h1:fWY+zXdWhvWnd github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.2 h1:ET4pqyjiGmY09R5y+rSd70J2w45CtbWDNvGqWp/R3Ng= +github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PUsDV+UD1D5IRU4ActiaWGwt0Yw= +github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= diff --git a/management/server/account_test.go b/management/server/account_test.go index fad5bf536..e40d5e5b8 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -12,10 +12,11 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/route" - "github.com/netbirdio/netbird/management/server/jwtclaims" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/management/server/jwtclaims" ) func verifyCanAddPeerToAccount(t *testing.T, manager AccountManager, account *Account, userID string) { @@ -1207,6 +1208,17 @@ func TestAccount_Copy(t *testing.T) { Id: "user1", Role: UserRoleAdmin, AutoGroups: []string{"group1"}, + PATs: []PersonalAccessToken{ + { + ID: "pat1", + Description: "First PAT", + HashedToken: "SoMeHaShEdToKeN", + ExpirationDate: time.Now().AddDate(0, 0, 7), + CreatedBy: "user1", + CreatedAt: time.Now(), + LastUsed: time.Now(), + }, + }, }, }, Groups: map[string]*Group{ diff --git a/management/server/personal_access_token.go b/management/server/personal_access_token.go new file mode 100644 index 000000000..e7ee05dad --- /dev/null +++ b/management/server/personal_access_token.go @@ -0,0 +1,63 @@ +package server + +import ( + "crypto/sha256" + "fmt" + "hash/crc32" + "time" + + "codeberg.org/ac/base62" + b "github.com/hashicorp/go-secure-stdlib/base62" + "github.com/rs/xid" +) + +const ( + // PATPrefix is the globally used, 4 char prefix for personal access tokens + PATPrefix = "nbp_" + secretLength = 30 +) + +// PersonalAccessToken holds all information about a PAT including a hashed version of it for verification +type PersonalAccessToken struct { + ID string + Description string + HashedToken string + ExpirationDate time.Time + // scope could be added in future + CreatedBy string + CreatedAt time.Time + LastUsed time.Time +} + +// CreateNewPAT will generate a new PersonalAccessToken that can be assigned to a User. +// Additionally, it will return the token in plain text once, to give to the user and only save a hashed version +func CreateNewPAT(description string, expirationInDays int, createdBy string) (*PersonalAccessToken, string, error) { + hashedToken, plainToken, err := generateNewToken() + if err != nil { + return nil, "", err + } + currentTime := time.Now().UTC() + return &PersonalAccessToken{ + ID: xid.New().String(), + Description: description, + HashedToken: hashedToken, + ExpirationDate: currentTime.AddDate(0, 0, expirationInDays), + CreatedBy: createdBy, + CreatedAt: currentTime, + LastUsed: currentTime, + }, plainToken, nil +} + +func generateNewToken() (string, string, error) { + secret, err := b.Random(secretLength) + if err != nil { + return "", "", err + } + + checksum := crc32.ChecksumIEEE([]byte(secret)) + encodedChecksum := base62.Encode(checksum) + paddedChecksum := fmt.Sprintf("%06s", encodedChecksum) + plainToken := PATPrefix + secret + paddedChecksum + hashedToken := sha256.Sum256([]byte(plainToken)) + return string(hashedToken[:]), plainToken, nil +} diff --git a/management/server/personal_access_token_test.go b/management/server/personal_access_token_test.go new file mode 100644 index 000000000..a4e02f750 --- /dev/null +++ b/management/server/personal_access_token_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "crypto/sha256" + "hash/crc32" + "strings" + "testing" + + "codeberg.org/ac/base62" + "github.com/stretchr/testify/assert" +) + +func TestPAT_GenerateToken_Hashing(t *testing.T) { + hashedToken, plainToken, _ := generateNewToken() + expectedToken := sha256.Sum256([]byte(plainToken)) + assert.Equal(t, hashedToken, string(expectedToken[:])) +} + +func TestPAT_GenerateToken_Prefix(t *testing.T) { + _, plainToken, _ := generateNewToken() + fourCharPrefix := plainToken[:4] + assert.Equal(t, PATPrefix, fourCharPrefix) +} + +func TestPAT_GenerateToken_Checksum(t *testing.T) { + _, plainToken, _ := generateNewToken() + tokenWithoutPrefix := strings.Split(plainToken, "_")[1] + if len(tokenWithoutPrefix) != 36 { + t.Fatal("Token has wrong length") + } + secret := tokenWithoutPrefix[:len(tokenWithoutPrefix)-6] + tokenCheckSum := tokenWithoutPrefix[len(tokenWithoutPrefix)-6:] + + expectedChecksum := crc32.ChecksumIEEE([]byte(secret)) + actualChecksum, err := base62.Decode(tokenCheckSum) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, expectedChecksum, actualChecksum) +} diff --git a/management/server/user.go b/management/server/user.go index 767d39df2..a9aaa1b61 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -2,12 +2,14 @@ package server import ( "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/jwtclaims" "github.com/netbirdio/netbird/management/server/status" - log "github.com/sirupsen/logrus" - "strings" ) const ( @@ -44,6 +46,7 @@ type User struct { Role UserRole // AutoGroups is a list of Group IDs to auto-assign to peers registered by this user AutoGroups []string + PATs []PersonalAccessToken } // IsAdmin returns true if user is an admin, false otherwise @@ -89,12 +92,15 @@ func (u *User) toUserInfo(userData *idp.UserData) (*UserInfo, error) { // Copy the user func (u *User) Copy() *User { - autoGroups := make([]string, 0) - autoGroups = append(autoGroups, u.AutoGroups...) + autoGroups := make([]string, len(u.AutoGroups)) + copy(autoGroups, u.AutoGroups) + pats := make([]PersonalAccessToken, len(u.PATs)) + copy(pats, u.PATs) return &User{ Id: u.Id, Role: u.Role, AutoGroups: autoGroups, + PATs: pats, } }