chore(webhook): reveal-later signing secret flow

Generate webhook signing secrets server-side and let users reveal them on
demand, replacing the create-dialog secret controls that surfaced internal
mask state (Status / Generate & Copy / Clear / Pending) to users.

- Add owner-gated GetUserWebhookSigningSecret RPC — the only path that
  returns the secret; list/create/update responses still omit it.
- Generate the secret server-side on create (webhook.GenerateSigningSecret),
  so validity no longer depends on the client.
- Rename UserWebhook.has_signing_secret -> signing_secret_set for parity
  with the existing api_key_set field.
- Create dialog drops the secret section to a one-line note; the generated
  secret is shown once right after create and revealable from Edit later.
This commit is contained in:
boojack
2026-06-26 09:03:24 +08:00
parent c6d65bcf3f
commit eb826455b6
15 changed files with 901 additions and 406 deletions
+8
View File
@@ -269,6 +269,14 @@ func (s *ConnectServiceHandler) DeleteUserWebhook(ctx context.Context, req *conn
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) GetUserWebhookSigningSecret(ctx context.Context, req *connect.Request[v1pb.GetUserWebhookSigningSecretRequest]) (*connect.Response[v1pb.GetUserWebhookSigningSecretResponse], error) {
resp, err := s.APIV1Service.GetUserWebhookSigningSecret(ctx, req.Msg)
if err != nil {
return nil, convertGRPCError(err)
}
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) ListUserNotifications(ctx context.Context, req *connect.Request[v1pb.ListUserNotificationsRequest]) (*connect.Response[v1pb.ListUserNotificationsResponse], error) {
resp, err := s.APIV1Service.ListUserNotifications(ctx, req.Msg)
if err != nil {
+41 -4
View File
@@ -1095,7 +1095,16 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
if err := webhook.ValidateURL(strings.TrimSpace(request.Webhook.Url)); err != nil {
return nil, err
}
if err := webhook.ValidateSigningSecret(strings.TrimSpace(request.Webhook.SigningSecret)); err != nil {
// The signing secret is generated server-side so it always meets the Standard
// Webhooks length requirement. A client-supplied secret is still accepted (and
// validated) for backward compatibility.
signingSecret := strings.TrimSpace(request.Webhook.SigningSecret)
if signingSecret == "" {
signingSecret, err = webhook.GenerateSigningSecret()
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate signing secret: %v", err)
}
} else if err := webhook.ValidateSigningSecret(signingSecret); err != nil {
return nil, err
}
@@ -1104,7 +1113,7 @@ func (s *APIV1Service) CreateUserWebhook(ctx context.Context, request *v1pb.Crea
Id: webhookID,
Title: request.Webhook.DisplayName,
Url: strings.TrimSpace(request.Webhook.Url),
SigningSecret: strings.TrimSpace(request.Webhook.SigningSecret),
SigningSecret: signingSecret,
}
err = s.Store.AddUserWebhook(ctx, userID, webhook)
@@ -1259,6 +1268,34 @@ func (s *APIV1Service) DeleteUserWebhook(ctx context.Context, request *v1pb.Dele
return &emptypb.Empty{}, nil
}
// GetUserWebhookSigningSecret reveals the signing secret for a single webhook.
// This is the only endpoint that returns the secret value; it is gated to the
// webhook owner (or an admin) and the secret is never included in list/create/update responses.
func (s *APIV1Service) GetUserWebhookSigningSecret(ctx context.Context, request *v1pb.GetUserWebhookSigningSecretRequest) (*v1pb.GetUserWebhookSigningSecretResponse, error) {
user, webhookID, err := s.resolveUserAndWebhookIDFromName(ctx, request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid webhook name: %v", err)
}
userID := user.ID
if _, err := s.authorizeUserResourceAccess(ctx, userID, true); err != nil {
return nil, err
}
webhooks, err := s.Store.GetUserWebhooks(ctx, userID)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user webhooks: %v", err)
}
for _, webhook := range webhooks {
if webhook.Id == webhookID {
return &v1pb.GetUserWebhookSigningSecretResponse{SigningSecret: webhook.SigningSecret}, nil
}
}
return nil, status.Errorf(codes.NotFound, "webhook not found")
}
// Helper functions for webhook operations
// generateUserWebhookID generates a unique ID for user webhooks.
@@ -1274,7 +1311,7 @@ func convertUserWebhookFromUserSetting(webhook *storepb.WebhooksUserSetting_Webh
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
HasSigningSecret: webhook.SigningSecret != "",
SigningSecretSet: webhook.SigningSecret != "",
// Note: create_time and update_time are not available in the user setting webhook structure
// This is a limitation of storing webhooks in user settings vs the dedicated webhook table
}
@@ -1474,7 +1511,7 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, user *store.
Name: fmt.Sprintf("%s/webhooks/%s", BuildUserName(user.Username), webhook.Id),
Url: webhook.Url,
DisplayName: webhook.Title,
HasSigningSecret: webhook.SigningSecret != "",
SigningSecretSet: webhook.SigningSecret != "",
}
apiWebhooks = append(apiWebhooks, apiWebhook)
}
+44 -1
View File
@@ -1,10 +1,15 @@
package v1
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
@@ -26,5 +31,43 @@ func TestConvertUserWebhookFromUserSettingOmitsSigningSecret(t *testing.T) {
require.Equal(t, "My Webhook", apiWebhook.DisplayName)
require.Equal(t, "https://example.com/postreceive", apiWebhook.Url)
require.Empty(t, apiWebhook.SigningSecret, "signing secret must never be returned in API responses")
require.True(t, apiWebhook.HasSigningSecret, "has_signing_secret must be true when secret is configured")
require.True(t, apiWebhook.SigningSecretSet, "signing_secret_set must be true when secret is configured")
}
// TestUserWebhookSigningSecretLifecycle covers the reveal-later flow: a webhook
// created without a secret is auto-assigned one server-side (exposed only via the
// signing_secret_set flag), the owner can reveal the value, and a non-owner cannot.
func TestUserWebhookSigningSecretLifecycle(t *testing.T) {
svc := newIntegrationService(t)
ctx := context.Background()
// First user initializes the store as host/admin; the test actors are regular users.
_, err := svc.Store.CreateUser(ctx, &store.User{Username: "host", Role: store.RoleAdmin, Email: "host@example.com"})
require.NoError(t, err)
owner, err := svc.Store.CreateUser(ctx, &store.User{Username: "owner", Role: store.RoleUser, Email: "owner@example.com"})
require.NoError(t, err)
other, err := svc.Store.CreateUser(ctx, &store.User{Username: "other", Role: store.RoleUser, Email: "other@example.com"})
require.NoError(t, err)
ownerCtx := userCtx(ctx, owner.ID)
otherCtx := userCtx(ctx, other.ID)
// Create without supplying a secret -> the server generates one.
created, err := svc.CreateUserWebhook(ownerCtx, &v1pb.CreateUserWebhookRequest{
Parent: "users/owner",
Webhook: &v1pb.UserWebhook{DisplayName: "deploy", Url: "https://example.com/postreceive"},
})
require.NoError(t, err)
require.True(t, created.SigningSecretSet, "create must auto-generate a signing secret")
require.Empty(t, created.SigningSecret, "create response must never carry the secret value")
// The owner can reveal the generated value.
revealed, err := svc.GetUserWebhookSigningSecret(ownerCtx, &v1pb.GetUserWebhookSigningSecretRequest{Name: created.Name})
require.NoError(t, err)
require.True(t, strings.HasPrefix(revealed.SigningSecret, "whsec_"), "revealed secret must be in whsec_ form")
// A non-owner, non-admin user cannot reveal it.
_, err = svc.GetUserWebhookSigningSecret(otherCtx, &v1pb.GetUserWebhookSigningSecretRequest{Name: created.Name})
require.Error(t, err)
require.Equal(t, codes.PermissionDenied, status.Code(err), "non-owner must be denied")
}