diff --git a/components/gitpod-db/go/dbtest/personal_access_token.go b/components/gitpod-db/go/dbtest/personal_access_token.go index 6db7113a385f6f..6de53128ae4e44 100644 --- a/components/gitpod-db/go/dbtest/personal_access_token.go +++ b/components/gitpod-db/go/dbtest/personal_access_token.go @@ -82,7 +82,7 @@ func CreatePersonalAccessTokenRecords(t *testing.T, conn *gorm.DB, entries ...db records = append(records, record) ids = append(ids, record.ID.String()) - _, err := db.CreateToken(context.Background(), conn, tokenEntry) + _, err := db.CreatePersonalAccessToken(context.Background(), conn, tokenEntry) require.NoError(t, err) } diff --git a/components/gitpod-db/go/personal_access_token.go b/components/gitpod-db/go/personal_access_token.go index 9520bb01ba60bc..6df7410c2f7fda 100644 --- a/components/gitpod-db/go/personal_access_token.go +++ b/components/gitpod-db/go/personal_access_token.go @@ -49,7 +49,7 @@ func GetToken(ctx context.Context, conn *gorm.DB, id uuid.UUID) (PersonalAccessT return token, nil } -func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (PersonalAccessToken, error) { +func CreatePersonalAccessToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (PersonalAccessToken, error) { if req.UserID == uuid.Nil { return PersonalAccessToken{}, fmt.Errorf("Invalid or empty userID") } @@ -63,6 +63,7 @@ func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (P return PersonalAccessToken{}, fmt.Errorf("Expiration time required") } + now := time.Now().UTC() token := PersonalAccessToken{ ID: req.ID, UserID: req.UserID, @@ -71,13 +72,13 @@ func CreateToken(ctx context.Context, conn *gorm.DB, req PersonalAccessToken) (P Description: req.Description, Scopes: req.Scopes, ExpirationTime: req.ExpirationTime, - CreatedAt: time.Now().UTC(), - LastModified: time.Now().UTC(), + CreatedAt: now, + LastModified: now, } tx := conn.WithContext(ctx).Create(req) if tx.Error != nil { - return PersonalAccessToken{}, fmt.Errorf("Failed to create token for user %s", req.UserID) + return PersonalAccessToken{}, fmt.Errorf("Failed to create personal access token for user %s", req.UserID) } return token, nil diff --git a/components/gitpod-db/go/personal_access_token_test.go b/components/gitpod-db/go/personal_access_token_test.go index 38a855b4bd433c..0ef84e888812e7 100644 --- a/components/gitpod-db/go/personal_access_token_test.go +++ b/components/gitpod-db/go/personal_access_token_test.go @@ -54,7 +54,7 @@ func TestPersonalAccessToken_Create(t *testing.T) { LastModified: time.Now(), } - result, err := db.CreateToken(context.Background(), conn, request) + result, err := db.CreatePersonalAccessToken(context.Background(), conn, request) require.NoError(t, err) require.Equal(t, request.ID, result.ID) diff --git a/components/public-api-server/go.mod b/components/public-api-server/go.mod index 44dcd89debf37b..855210cc02bdd3 100644 --- a/components/public-api-server/go.mod +++ b/components/public-api-server/go.mod @@ -23,6 +23,7 @@ require ( github.com/spf13/cobra v1.4.0 github.com/stretchr/testify v1.8.1 github.com/stripe/stripe-go/v72 v72.122.0 + golang.org/x/crypto v0.0.0-20220214200702-86341886e292 google.golang.org/grpc v1.50.1 google.golang.org/protobuf v1.28.1 gorm.io/gorm v1.24.1 diff --git a/components/public-api-server/pkg/apiv1/tokens.go b/components/public-api-server/pkg/apiv1/tokens.go index 41ca60e2665155..c58bc9210a3b2d 100644 --- a/components/public-api-server/pkg/apiv1/tokens.go +++ b/components/public-api-server/pkg/apiv1/tokens.go @@ -43,17 +43,63 @@ type TokensService struct { } func (s *TokensService) CreatePersonalAccessToken(ctx context.Context, req *connect.Request[v1.CreatePersonalAccessTokenRequest]) (*connect.Response[v1.CreatePersonalAccessTokenResponse], error) { + tokenReq := req.Msg.GetToken() + + name := strings.TrimSpace(tokenReq.GetName()) + if name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Token Name is a required parameter.")) + } + + description := strings.TrimSpace(tokenReq.GetDescription()) + + expiry := tokenReq.GetExpirationTime() + if !expiry.IsValid() { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("Received invalid Expiration Time, it is a required parameter.")) + } + + // TODO: Parse and validate scopes before storing + // Until we do that, we store empty scopes. + var scopes []string + conn, err := getConnection(ctx, s.connectionPool) if err != nil { return nil, err } - _, err = s.getUser(ctx, conn) + _, userID, err := s.getUser(ctx, conn) if err != nil { return nil, err } - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitpod.experimental.v1.TokensService.CreatePersonalAccessToken is not implemented")) + pat, err := auth.GeneratePersonalAccessToken(s.signer) + if err != nil { + log.WithError(err).Errorf("Failed to generate personal access token for user %s", userID.String()) + return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to generate personal access token.")) + } + + hash, err := pat.ValueHash() + if err != nil { + log.WithError(err).Errorf("Failed to generate personal access token value hash for user %s", userID.String()) + return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to compute personal access token hash.")) + } + + token, err := db.CreatePersonalAccessToken(ctx, s.dbConn, db.PersonalAccessToken{ + ID: uuid.New(), + UserID: userID, + Hash: hash, + Name: name, + Description: description, + Scopes: scopes, + ExpirationTime: expiry.AsTime().UTC(), + }) + if err != nil { + log.WithError(err).Errorf("Failed to store personal access token for user %s", userID.String()) + return nil, connect.NewError(connect.CodeInternal, errors.New("Failed to store personal access token.")) + } + + return connect.NewResponse(&v1.CreatePersonalAccessTokenResponse{ + Token: personalAccessTokenToAPI(token, pat.String()), + }), nil } func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect.Request[v1.GetPersonalAccessTokenRequest]) (*connect.Response[v1.GetPersonalAccessTokenResponse], error) { @@ -67,7 +113,7 @@ func (s *TokensService) GetPersonalAccessToken(ctx context.Context, req *connect return nil, err } - _, err = s.getUser(ctx, conn) + _, _, err = s.getUser(ctx, conn) if err != nil { return nil, err } @@ -82,16 +128,11 @@ func (s *TokensService) ListPersonalAccessTokens(ctx context.Context, req *conne return nil, err } - user, err := s.getUser(ctx, conn) + _, userID, err := s.getUser(ctx, conn) if err != nil { return nil, err } - userID, err := uuid.Parse(user.ID) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to parse user ID as UUID")) - } - result, err := db.ListPersonalAccessTokensForUser(ctx, s.dbConn, userID, paginationToDB(req.Msg.GetPagination())) if err != nil { return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Failed to list Personal Access Tokens for User %s", userID.String())) @@ -114,7 +155,7 @@ func (s *TokensService) RegeneratePersonalAccessToken(ctx context.Context, req * return nil, err } - _, err = s.getUser(ctx, conn) + _, _, err = s.getUser(ctx, conn) if err != nil { return nil, err } @@ -134,7 +175,7 @@ func (s *TokensService) UpdatePersonalAccessToken(ctx context.Context, req *conn return nil, err } - _, err = s.getUser(ctx, conn) + _, _, err = s.getUser(ctx, conn) if err != nil { return nil, err } @@ -154,7 +195,7 @@ func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *conn return nil, err } - _, err = s.getUser(ctx, conn) + _, _, err = s.getUser(ctx, conn) if err != nil { return nil, err } @@ -163,17 +204,22 @@ func (s *TokensService) DeletePersonalAccessToken(ctx context.Context, req *conn return nil, connect.NewError(connect.CodeUnimplemented, errors.New("gitpod.experimental.v1.TokensService.DeletePersonalAccessToken is not implemented")) } -func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, error) { +func (s *TokensService) getUser(ctx context.Context, conn protocol.APIInterface) (*protocol.User, uuid.UUID, error) { user, err := conn.GetLoggedInUser(ctx) if err != nil { - return nil, proxy.ConvertError(err) + return nil, uuid.Nil, proxy.ConvertError(err) } if !experiments.IsPersonalAccessTokensEnabled(ctx, s.expClient, experiments.Attributes{UserID: user.ID}) { - return nil, connect.NewError(connect.CodePermissionDenied, errors.New("This feature is currently in beta. If you would like to be part of the beta, please contact us.")) + return nil, uuid.Nil, connect.NewError(connect.CodePermissionDenied, errors.New("This feature is currently in beta. If you would like to be part of the beta, please contact us.")) + } + + userID, err := uuid.Parse(user.ID) + if err != nil { + return nil, uuid.Nil, connect.NewError(connect.CodeInternal, errors.New("Failed to parse user ID as UUID. Please contact support.")) } - return user, nil + return user, userID, nil } func getConnection(ctx context.Context, pool proxy.ServerConnectionPool) (protocol.APIInterface, error) { diff --git a/components/public-api-server/pkg/apiv1/tokens_test.go b/components/public-api-server/pkg/apiv1/tokens_test.go index be958454d5ef2f..b8a17d56bfa34c 100644 --- a/components/public-api-server/pkg/apiv1/tokens_test.go +++ b/components/public-api-server/pkg/apiv1/tokens_test.go @@ -23,6 +23,7 @@ import ( "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" "gorm.io/gorm" ) @@ -37,6 +38,8 @@ var ( return experiment == experiments.PersonalAccessTokensEnabledFlag }, } + + signer = auth.NewHS256Signer([]byte("my-secret")) ) func TestTokensService_CreatePersonalAccessTokenWithoutFeatureFlag(t *testing.T) { @@ -47,19 +50,86 @@ func TestTokensService_CreatePersonalAccessTokenWithoutFeatureFlag(t *testing.T) serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil) - _, err := client.CreatePersonalAccessToken(context.Background(), &connect.Request[v1.CreatePersonalAccessTokenRequest]{}) + _, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{ + Token: &v1.PersonalAccessToken{ + Name: "my-token", + ExpirationTime: timestamppb.Now(), + }, + })) require.Error(t, err, "This feature is currently in beta. If you would like to be part of the beta, please contact us.") require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) }) - t.Run("unimplemented when feature flag enabled", func(t *testing.T) { - serverMock, _, client := setupTokensService(t, withTokenFeatureEnabled) + t.Run("invalid argument when name is not specified", func(t *testing.T) { + _, _, client := setupTokensService(t, withTokenFeatureDisabled) + + _, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{ + Token: &v1.PersonalAccessToken{}, + })) + require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + }) + + t.Run("invalid argument when expiration time is unspecified", func(t *testing.T) { + _, _, client := setupTokensService(t, withTokenFeatureDisabled) + + _, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{ + Token: &v1.PersonalAccessToken{ + Name: "my-token", + }, + })) + require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + }) + + t.Run("invalid argument when expiration time is invalid", func(t *testing.T) { + _, _, client := setupTokensService(t, withTokenFeatureDisabled) + + _, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{ + Token: &v1.PersonalAccessToken{ + Name: "my-token", + ExpirationTime: ×tamppb.Timestamp{ + Seconds: 253402300799 + 1, + }, + }, + })) + require.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + }) + + t.Run("crates personal access token", func(t *testing.T) { + serverMock, dbConn, client := setupTokensService(t, withTokenFeatureEnabled) serverMock.EXPECT().GetLoggedInUser(gomock.Any()).Return(user, nil) - _, err := client.CreatePersonalAccessToken(context.Background(), &connect.Request[v1.CreatePersonalAccessTokenRequest]{}) - require.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err)) + token := &v1.PersonalAccessToken{ + Name: "my-token", + Description: "my description", + ExpirationTime: timestamppb.Now(), + } + + response, err := client.CreatePersonalAccessToken(context.Background(), connect.NewRequest(&v1.CreatePersonalAccessTokenRequest{ + Token: token, + })) + require.NoError(t, err) + + created := response.Msg.GetToken() + t.Cleanup(func() { + require.NoError(t, dbConn.Where("id = ?", created.GetId()).Delete(&db.PersonalAccessToken{}).Error) + }) + + require.NotEmpty(t, created.GetId()) + require.Equal(t, token.Name, created.GetName()) + require.Equal(t, token.Description, created.GetDescription()) + require.Equal(t, token.Scopes, created.GetScopes()) + requireEqualProto(t, token.GetExpirationTime(), created.GetExpirationTime()) + + // Returned token must be parseable + _, err = auth.ParsePersonalAccessToken(created.GetValue(), signer) + require.NoError(t, err) + + // token must exist in the DB, with the User ID of the requestor + storedInDB, err := db.GetToken(context.Background(), dbConn, uuid.MustParse(created.GetId())) + require.NoError(t, err) + require.Equal(t, user.ID, storedInDB.UserID.String()) }) } @@ -370,7 +440,6 @@ func setupTokensService(t *testing.T, expClient experiments.Client) (*protocol.M t.Helper() dbConn := dbtest.ConnectForTests(t) - signer := auth.NewHS256Signer([]byte("my-secret")) ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) diff --git a/components/public-api-server/pkg/auth/personal_access_token.go b/components/public-api-server/pkg/auth/personal_access_token.go index 1d7ec02ccb080b..6eaef65a8a53a9 100644 --- a/components/public-api-server/pkg/auth/personal_access_token.go +++ b/components/public-api-server/pkg/auth/personal_access_token.go @@ -12,6 +12,8 @@ import ( "fmt" "math/big" "strings" + + "golang.org/x/crypto/bcrypt" ) const PersonalAccessTokenPrefix = "gitpod_pat_" @@ -42,6 +44,16 @@ func (t *PersonalAccessToken) Value() string { return t.value } +func (t *PersonalAccessToken) ValueHash() (string, error) { + const bcryptCost = 16 + hash, err := bcrypt.GenerateFromPassword([]byte(t.value), bcryptCost) + if err != nil { + return "", fmt.Errorf("failed to generate personal access token value hash: %w", err) + } + + return string(hash), nil +} + func GeneratePersonalAccessToken(signer Signer) (PersonalAccessToken, error) { if signer == nil { return PersonalAccessToken{}, errors.New("no personal access token signer available")