diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..4d9fa7b2 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,13 @@ +name: "CodeQL config" + +query-filters: + - exclude: + problem.severity: + - warning + - recommendation + - exclude: + id: go/log-injection + +paths-ignore: + - '**/*_test.go' + - '**/*.test.*' diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 00000000..c929fd9b --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,18 @@ +name: cd +on: + workflow_run: + workflows: ["ci"] + branches-ignore: ["*"] + types: + - completed + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + plugin-cd: + uses: mattermost/actions-workflows/.github/workflows/plugin-cd.yml@main + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3c05ae63 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: ci +on: + schedule: + - cron: "0 0 * * *" + push: + branches: + - master + tags: + - "v*" + pull_request: + +permissions: + contents: read + +jobs: + plugin-ci: + uses: mattermost/actions-workflows/.github/workflows/plugin-ci.yml@main + secrets: inherit diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3e5aea68..d928202d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,15 +8,16 @@ on: branches: [ master ] schedule: - cron: '30 0 * * 0' + +permissions: + contents: read jobs: analyze: + permissions: + security-events: write # for github/codeql-action/autobuild to send a status report name: Analyze runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write strategy: fail-fast: false @@ -25,18 +26,20 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) + debug: false + config-file: ./.github/codeql/codeql-config.yml + + # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 + # Perform Analysis - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.golangci.yml b/.golangci.yml index 2afba7a7..021e8960 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,6 +15,8 @@ linters-settings: govet: check-shadowing: true enable-all: true + disable: + - fieldalignment misspell: locale: US diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..049c407a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14.21.1 diff --git a/docs/.gitbook/assets/mattermost_webhook_secret.png b/docs/.gitbook/assets/mattermost_webhook_secret.png new file mode 100644 index 00000000..9ca615b3 Binary files /dev/null and b/docs/.gitbook/assets/mattermost_webhook_secret.png differ diff --git a/docs/.gitbook/assets/webhook_url.png b/docs/.gitbook/assets/webhook_url.png new file mode 100644 index 00000000..fd0d3999 Binary files /dev/null and b/docs/.gitbook/assets/webhook_url.png differ diff --git a/docs/.gitbook/assets/zoom_webhook_secret.png b/docs/.gitbook/assets/zoom_webhook_secret.png new file mode 100644 index 00000000..2ddbab72 Binary files /dev/null and b/docs/.gitbook/assets/zoom_webhook_secret.png differ diff --git a/docs/installation/zoom-configuration/webhook-configuration.md b/docs/installation/zoom-configuration/webhook-configuration.md index 73f2b25f..01938196 100644 --- a/docs/installation/zoom-configuration/webhook-configuration.md +++ b/docs/installation/zoom-configuration/webhook-configuration.md @@ -4,18 +4,32 @@ When a Zoom meeting ends, the original link shared in the channel can be changed to indicate the meeting has ended and how long it lasted. To enable this functionality, we need to create a webhook subscription in Zoom that tells the Mattermost server every time a meeting ends. The Mattermost server then updates the original Zoom message. -1. Click on **Feature**. -2. Enable **Event Subscriptions**. -3. Click **Add New Event Subscription** and give it a name \(e.g. "Meeting Ended"\). -4. Enter a valid **Event notification endpoint URL** \(`https://SITEURL/plugins/zoom/webhook?secret=WEBHOOKSECRET`\). - * `SITEURL` should be your Mattermost server URL. - * `WEBHOOKSECRET` is generated during [Mattermost Setup](../mattermost-setup.md). +Select **Feature** in the left sidebar to begin setting up the webhook subscription. -![Feature screen](../../.gitbook/assets/screenshot-from-2020-06-05-19-51-56%20%284%29.png) +### Configuring webhook authentication -* Click **Add events** and select the **End Meeting** event. +1. Copy the "Secret Token" value from Zoom's form. +2. Paste this value into the Zoom plugin's settings in the Mattermost system console for the `Zoom Webhook Secret` field. -![Event types screen](../../.gitbook/assets/screenshot-from-2020-06-05-20-43-04%20%282%29%20%281%29.png) +![Mattermost Webhook Secret](../../.gitbook/assets/mattermost_webhook_secret.png) +![Zoom Webhook Secret](../../.gitbook/assets/zoom_webhook_secret.png) -* Click **Done** and then save your app. +3. In the Mattermost system console, generate and copy the `Webhook Secret`. We'll use this value in the next section. +Zoom's webhook secret authentication system has been made required for all webhooks created or modified after the new system was rolled out. In order to maintain backwards compatibility with existing installations of the Zoom plugin, we still support (and also require) the original webhook secret system used by the plugin. So any new webhooks created will need to be configured with both secrets as mentioned in the steps above. + +### Configuring webhook events + +1. Enable **Event Subscriptions**. +2. Select **Add New Event Subscription** and give it a name \(e.g. "Meeting Ended"\). +3. Construct the following URL, where `SITEURL` is your Mattermost server URL, and `WEBHOOKSECRET` is the value in the system console labeled `Webhook Secret`. +4. Enter in the **Event notification endpoint URL** field the constructed URL: + +`https://SITEURL/plugins/zoom/webhook?secret=WEBHOOKSECRET` + +![Webhook URL](../../.gitbook/assets/webhook_url.png) + +5. Select **Add events** and select the **End Meeting** event. +6. Select **Done** and to save your app. + +![Event types screen](../../.gitbook/assets/event_types.png) diff --git a/go.mod b/go.mod index 38b11dde..beb1df1b 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,5 @@ require ( github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.6.1 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 + golang.org/x/sys v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 9dfa3771..53256d2c 100644 --- a/go.sum +++ b/go.sum @@ -1004,8 +1004,9 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200817155316-9781c653f443 h1:X18bCaipMcoJGm27Nv7zr4XYPKGUy92GtqboKC2Hxaw= golang.org/x/sys v0.0.0-20200817155316-9781c653f443/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/plugin.json b/plugin.json index 7b6ec081..bdca0d51 100644 --- a/plugin.json +++ b/plugin.json @@ -1,12 +1,12 @@ { "id": "zoom", "name": "Zoom", - "description": "Zoom audio and video conferencing plugin for Mattermost 5.2+.", + "description": "Zoom audio and video conferencing plugin", "homepage_url": "https://github.com/mattermost/mattermost-plugin-zoom", "support_url": "https://github.com/mattermost/mattermost-plugin-zoom/issues", - "release_notes_url": "https://github.com/mattermost/mattermost-plugin-zoom/releases/tag/v1.6.1", + "release_notes_url": "https://github.com/mattermost/mattermost-plugin-zoom/releases/tag/v1.6.2", "icon_path": "assets/profile.svg", - "version": "1.6.1", + "version": "1.6.2", "min_server_version": "5.12.0", "server": { "executables": { @@ -52,7 +52,7 @@ "key": "AccountLevelApp", "display_name": "OAuth by Account Level App (Beta):", "type": "bool", - "help_text": "When true, only an account administrator has to log in. The rest of the users will use their email to log in.", + "help_text": "When true, only an account administrator has to log in. The rest of the users will automatically use their Mattermost email to authenticate when starting meetings.", "placeholder": "", "default": false }, @@ -107,6 +107,15 @@ "regenerate_help_text": "Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing Zoom plugin.", "placeholder": "", "default": null + }, + { + "key": "ZoomWebhookSecret", + "display_name": "Zoom Webhook Secret:", + "type": "text", + "help_text": "`Secret Token` taken from Zoom's webhook configuration page", + "regenerate_help_text": "", + "placeholder": "", + "default": null } ] } diff --git a/server/configuration.go b/server/configuration.go index cf6b236f..b3c3467b 100644 --- a/server/configuration.go +++ b/server/configuration.go @@ -38,9 +38,13 @@ type configuration struct { AccountLevelApp bool OAuthClientID string OAuthClientSecret string - OAuthRedirectURL string EncryptionKey string - WebhookSecret string + + // WebhookSecret is generated in the Mattermost system console + WebhookSecret string + + // ZoomWebhookSecret is the `Secret Token` taken from Zoom's webhook configuration page + ZoomWebhookSecret string } // Clone shallow copies the configuration. Your implementation may require a deep copy if diff --git a/server/http.go b/server/http.go index 6cd7d8ea..5437ce5a 100644 --- a/server/http.go +++ b/server/http.go @@ -6,11 +6,8 @@ package main import ( "bytes" "context" - "crypto/subtle" "encoding/json" "fmt" - "io/ioutil" - "math" "net/http" "strings" "time" @@ -194,109 +191,6 @@ func (p *Plugin) completeUserOAuthToZoom(w http.ResponseWriter, r *http.Request) } } -func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { - if !p.verifyWebhookSecret(r) { - p.API.LogWarn("Could not verify webhook secreet") - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - - if !strings.Contains(r.Header.Get("Content-Type"), "application/json") { - res := fmt.Sprintf("Expected Content-Type 'application/json' for webhook request, received '%s'.", r.Header.Get("Content-Type")) - p.API.LogWarn(res) - http.Error(w, res, http.StatusBadRequest) - return - } - - b, err := ioutil.ReadAll(r.Body) - if err != nil { - p.API.LogWarn("Cannot read body from Webhook") - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - var webhook zoom.Webhook - if err = json.Unmarshal(b, &webhook); err != nil { - p.API.LogError("Error unmarshaling webhook", "err", err.Error()) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if webhook.Event != zoom.EventTypeMeetingEnded { - w.WriteHeader(http.StatusNotImplemented) - return - } - - var meetingWebhook zoom.MeetingWebhook - if err = json.Unmarshal(b, &meetingWebhook); err != nil { - p.API.LogError("Error unmarshaling meeting webhook", "err", err.Error()) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - p.handleMeetingEnded(w, r, &meetingWebhook) -} - -func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, r *http.Request, webhook *zoom.MeetingWebhook) { - meetingPostID := webhook.Payload.Object.ID - postID, appErr := p.fetchMeetingPostID(meetingPostID) - if appErr != nil { - http.Error(w, appErr.Error(), appErr.StatusCode) - return - } - - post, appErr := p.API.GetPost(postID) - if appErr != nil { - p.API.LogWarn("Could not get meeting post by id", "err", appErr) - http.Error(w, appErr.Error(), appErr.StatusCode) - return - } - - start := time.Unix(0, post.CreateAt*int64(time.Millisecond)) - length := int(math.Ceil(float64((model.GetMillis()-post.CreateAt)/1000) / 60)) - startText := start.Format("Mon Jan 2 15:04:05 -0700 MST 2006") - topic, ok := post.Props["meeting_topic"].(string) - if !ok { - topic = defaultMeetingTopic - } - - meetingID, ok := post.Props["meeting_id"].(float64) - if !ok { - meetingID = 0 - } - - slackAttachment := model.SlackAttachment{ - Fallback: fmt.Sprintf("Meeting %s has ended: started at %s, length: %d minute(s).", post.Props["meeting_id"], startText, length), - Title: topic, - Text: fmt.Sprintf( - "Personal Meeting ID (PMI) : %d\n\n##### Meeting Summary\n\nDate: %s\n\nMeeting Length: %d minute(s)", - int(meetingID), - startText, - length, - ), - } - - post.Message = "The meeting has ended." - post.Props["meeting_status"] = zoom.WebhookStatusEnded - post.Props["attachments"] = []*model.SlackAttachment{&slackAttachment} - - _, appErr = p.API.UpdatePost(post) - if appErr != nil { - p.API.LogWarn("Could not update the post", "err", appErr) - http.Error(w, appErr.Error(), appErr.StatusCode) - return - } - - if appErr = p.deleteMeetingPostID(meetingPostID); appErr != nil { - p.API.LogWarn("failed to delete db entry", "error", appErr.Error()) - return - } - - _, err := w.Write([]byte(post.ToJson())) - if err != nil { - p.API.LogWarn("failed to write response", "error", err.Error()) - } -} - func (p *Plugin) postMeeting(creator *model.User, meetingID int, channelID string, topic string) error { meetingURL := p.getMeetingURL(creator, meetingID) @@ -521,7 +415,7 @@ func getString(key string, props model.StringInterface) string { } func (p *Plugin) deauthorizeUser(w http.ResponseWriter, r *http.Request) { - if !p.verifyWebhookSecret(r) { + if !p.verifyMattermostWebhookSecret(r) { http.Error(w, "Not authorized", http.StatusUnauthorized) return } @@ -555,11 +449,6 @@ func (p *Plugin) deauthorizeUser(w http.ResponseWriter, r *http.Request) { } } -func (p *Plugin) verifyWebhookSecret(r *http.Request) bool { - config := p.getConfiguration() - return subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(config.WebhookSecret)) == 1 -} - func (p *Plugin) completeCompliance(payload zoom.DeauthorizationPayload) error { data := zoom.ComplianceRequest{ ClientID: payload.ClientID, diff --git a/server/manifest.go b/server/manifest.go index 4cee1dc3..67e2e221 100644 --- a/server/manifest.go +++ b/server/manifest.go @@ -7,5 +7,5 @@ var manifest = struct { Version string }{ ID: "zoom", - Version: "1.6.1", + Version: "1.6.2", } diff --git a/server/plugin_test.go b/server/plugin_test.go index 26b8ac81..aa402af2 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -78,7 +78,7 @@ func TestPlugin(t *testing.T) { }, "ValidStartedWebhookRequest": { Request: validStartedWebhookRequest, - ExpectedStatusCode: http.StatusNotImplemented, + ExpectedStatusCode: http.StatusOK, HasPermissionToChannel: true, }, "NoSecretWebhookRequest": { @@ -97,6 +97,12 @@ func TestPlugin(t *testing.T) { api := &plugintest.API{} + api.On("GetLicense").Return(nil) + api.On("GetServerVersion").Return("6.2.0") + + api.On("KVGet", "mmi_botid").Return([]byte(botUserID), nil) + api.On("PatchBot", botUserID, mock.AnythingOfType("*model.BotPatch")).Return(nil, nil) + api.On("GetUser", "theuserid").Return(&model.User{ Id: "theuserid", Email: "theuseremail", diff --git a/server/webhook.go b/server/webhook.go new file mode 100644 index 00000000..1df5cfe7 --- /dev/null +++ b/server/webhook.go @@ -0,0 +1,213 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "math" + "net/http" + "strings" + "time" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-plugin-zoom/server/zoom" +) + +func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { + if !p.verifyMattermostWebhookSecret(r) { + p.API.LogWarn("Could not verify Mattermost webhook secret") + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + if !strings.Contains(r.Header.Get("Content-Type"), "application/json") { + res := fmt.Sprintf("Expected Content-Type 'application/json' for webhook request, received '%s'.", r.Header.Get("Content-Type")) + p.API.LogWarn(res) + http.Error(w, res, http.StatusBadRequest) + return + } + + b, err := ioutil.ReadAll(r.Body) + if err != nil { + p.API.LogWarn("Cannot read body from Webhook") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var webhook zoom.Webhook + if err = json.Unmarshal(b, &webhook); err != nil { + p.API.LogError("Error unmarshaling webhook", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if webhook.Event != zoom.EventTypeValidateWebhook { + err = p.verifyZoomWebhookSignature(r, b) + if err != nil { + p.API.LogWarn("Could not verify webhook signature: " + err.Error()) + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + } + + switch webhook.Event { + case zoom.EventTypeMeetingEnded: + p.handleMeetingEnded(w, r, b) + case zoom.EventTypeValidateWebhook: + p.handleValidateZoomWebhook(w, r, b) + default: + w.WriteHeader(http.StatusOK) + } +} + +func (p *Plugin) handleMeetingEnded(w http.ResponseWriter, r *http.Request, body []byte) { + var webhook zoom.MeetingWebhook + if err := json.Unmarshal(body, &webhook); err != nil { + p.API.LogError("Error unmarshaling meeting webhook", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + meetingPostID := webhook.Payload.Object.ID + postID, appErr := p.fetchMeetingPostID(meetingPostID) + if appErr != nil { + http.Error(w, appErr.Error(), appErr.StatusCode) + return + } + + post, appErr := p.API.GetPost(postID) + if appErr != nil { + p.API.LogWarn("Could not get meeting post by id", "err", appErr) + http.Error(w, appErr.Error(), appErr.StatusCode) + return + } + + start := time.Unix(0, post.CreateAt*int64(time.Millisecond)) + length := int(math.Ceil(float64((model.GetMillis()-post.CreateAt)/1000) / 60)) + startText := start.Format("Mon Jan 2 15:04:05 -0700 MST 2006") + topic, ok := post.Props["meeting_topic"].(string) + if !ok { + topic = defaultMeetingTopic + } + + meetingID, ok := post.Props["meeting_id"].(float64) + if !ok { + meetingID = 0 + } + + slackAttachment := model.SlackAttachment{ + Fallback: fmt.Sprintf("Meeting %s has ended: started at %s, length: %d minute(s).", post.Props["meeting_id"], startText, length), + Title: topic, + Text: fmt.Sprintf( + "Meeting ID: %d\n\n##### Meeting Summary\n\nDate: %s\n\nMeeting Length: %d minute(s)", + int(meetingID), + startText, + length, + ), + } + + post.Message = "The meeting has ended." + post.Props["meeting_status"] = zoom.WebhookStatusEnded + post.Props["attachments"] = []*model.SlackAttachment{&slackAttachment} + + _, appErr = p.API.UpdatePost(post) + if appErr != nil { + p.API.LogWarn("Could not update the post", "err", appErr) + http.Error(w, appErr.Error(), appErr.StatusCode) + return + } + + if appErr = p.deleteMeetingPostID(meetingPostID); appErr != nil { + p.API.LogWarn("failed to delete db entry", "error", appErr.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(post); err != nil { + p.API.LogWarn("failed to write response", "error", err.Error()) + } +} + +func (p *Plugin) verifyMattermostWebhookSecret(r *http.Request) bool { + config := p.getConfiguration() + return subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(config.WebhookSecret)) == 1 +} + +func (p *Plugin) verifyZoomWebhookSignature(r *http.Request, body []byte) error { + config := p.getConfiguration() + if config.ZoomWebhookSecret == "" { + return nil + } + + var webhook zoom.Webhook + err := json.Unmarshal(body, &webhook) + if err != nil { + return errors.Wrap(err, "error unmarshaling webhook payload") + } + + ts := r.Header.Get("x-zm-request-timestamp") + + msg := fmt.Sprintf("v0:%s:%s", ts, string(body)) + hash, err := createWebhookSignatureHash(config.ZoomWebhookSecret, msg) + if err != nil { + return err + } + + computedSignature := fmt.Sprintf("v0=%s", hash) + providedSignature := r.Header.Get("x-zm-signature") + if computedSignature != providedSignature { + return errors.New("provided signature does not match") + } + + return nil +} + +func (p *Plugin) handleValidateZoomWebhook(w http.ResponseWriter, r *http.Request, body []byte) { + config := p.getConfiguration() + if config.ZoomWebhookSecret == "" { + p.API.LogWarn("Failed to validate Zoom webhook: Zoom webhook secret not set") + w.WriteHeader(http.StatusBadRequest) + return + } + + var webhook zoom.ValidationWebhook + err := json.Unmarshal(body, &webhook) + if err != nil { + p.API.LogWarn("Failed to validate Zoom webhook: " + err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + hash, err := createWebhookSignatureHash(config.ZoomWebhookSecret, webhook.Payload.PlainToken) + if err != nil { + p.API.LogWarn("Failed to validate Zoom webhook: " + err.Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + out := zoom.ValidationWebhookResponse{ + PlainToken: webhook.Payload.PlainToken, + EncryptedToken: hash, + } + + w.Header().Add("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(out); err != nil { + p.API.LogWarn("failed to write response", "error", err.Error()) + } +} + +func createWebhookSignatureHash(secret, data string) (string, error) { + h := hmac.New(sha256.New, []byte(secret)) + _, err := h.Write([]byte(data)) + if err != nil { + return "", errors.Wrap(err, "failed to create webhook signature hash") + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/server/webhook_test.go b/server/webhook_test.go new file mode 100644 index 00000000..c8d6b5e3 --- /dev/null +++ b/server/webhook_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin" + "github.com/mattermost/mattermost-server/v5/plugin/plugintest" + + "github.com/mattermost/mattermost-plugin-zoom/server/zoom" +) + +var testConfig = &configuration{ + EnableOAuth: true, + OAuthClientID: "clientid", + OAuthClientSecret: "clientsecret", + EncryptionKey: "encryptionkey", + WebhookSecret: "webhooksecret", + ZoomWebhookSecret: "zoomwebhooksecret", +} + +func TestWebhookValidate(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + p.setConfiguration(testConfig) + + api.On("GetLicense").Return(nil) + p.SetAPI(api) + + requestBody := `{"payload":{"plainToken":"Kn5a3Wv7SP6YP5b4BWfZpg"},"event":"endpoint.url_validation"}` + + w := httptest.NewRecorder() + reqBody := ioutil.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + + p.ServeHTTP(&plugin.Context{}, w, request) + body, _ := ioutil.ReadAll(w.Result().Body) + t.Log(string(body)) + + require.Equal(t, 200, w.Result().StatusCode) + + out := zoom.ValidationWebhookResponse{} + err := json.Unmarshal(body, &out) + require.NoError(t, err) + + require.Equal(t, "Kn5a3Wv7SP6YP5b4BWfZpg", out.PlainToken) + require.Equal(t, "2a41c3138d2187a756c51428f78d192e9b88dcf44dd62d1b081ace4ec2241e0a", out.EncryptedToken) +} + +func TestWebhookVerifySignature(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + p.setConfiguration(testConfig) + + api.On("GetLicense").Return(nil) + api.On("KVGet", "post_meeting_123").Return(nil, &model.AppError{StatusCode: 200}) + api.On("LogDebug", "Could not get meeting post from KVStore", "err", ": , ") + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` + + ts := "1660149894817" + signature := "v0=7fe2f9e66d133961eff4746eda161096cebe8d677319d66546281d88ea147189" + + w := httptest.NewRecorder() + reqBody := ioutil.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + body, _ := ioutil.ReadAll(w.Result().Body) + t.Log(string(body)) + + require.Equal(t, 200, w.Result().StatusCode) +} + +func TestWebhookVerifySignatureInvalid(t *testing.T) { + api := &plugintest.API{} + p := Plugin{} + p.setConfiguration(testConfig) + + api.On("GetLicense").Return(nil) + api.On("LogWarn", "Could not verify webhook signature: provided signature does not match") + p.SetAPI(api) + + requestBody := `{"payload":{"object": {"id": "123"}},"event":"meeting.ended"}` + + ts := "1660149894817" + signature := "v0=7fe2f9e66d133961eff4746eda161096cebe8d677319d66546281d88ea147190" + + w := httptest.NewRecorder() + reqBody := ioutil.NopCloser(bytes.NewBufferString(requestBody)) + request := httptest.NewRequest("POST", "/webhook?secret=webhooksecret", reqBody) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("x-zm-signature", signature) + request.Header.Add("x-zm-request-timestamp", ts) + + p.ServeHTTP(&plugin.Context{}, w, request) + body, _ := ioutil.ReadAll(w.Result().Body) + t.Log(string(body)) +} diff --git a/server/zoom/client.go b/server/zoom/client.go index df96a264..0fbf46de 100644 --- a/server/zoom/client.go +++ b/server/zoom/client.go @@ -4,7 +4,7 @@ package zoom import ( - "encoding/json" + "fmt" "github.com/mattermost/mattermost-server/v5/model" "github.com/pkg/errors" @@ -18,8 +18,15 @@ type AuthError struct { } func (err *AuthError) Error() string { - msg, _ := json.Marshal(err) - return string(msg) + out := "" + if err.Message != "" { + out += fmt.Sprintf("message: %s. ", err.Message) + } + if err.Err != nil { + out += fmt.Sprintf("error: %s. ", err.Err.Error()) + } + + return out } var errNotFound = errors.New("not found") diff --git a/server/zoom/webhook.go b/server/zoom/webhook.go index a1859a38..307c4c29 100644 --- a/server/zoom/webhook.go +++ b/server/zoom/webhook.go @@ -15,8 +15,9 @@ const ( RecordingWebhookTypeComplete = "RECORDING_MEETING_COMPLETED" RecentlyCreated = "RECENTLY_CREATED" - EventTypeMeetingStarted EventType = "meeting.started" - EventTypeMeetingEnded EventType = "meeting.ended" + EventTypeMeetingStarted EventType = "meeting.started" + EventTypeMeetingEnded EventType = "meeting.ended" + EventTypeValidateWebhook EventType = "endpoint.url_validation" ) type MeetingWebhookObject struct { @@ -41,9 +42,24 @@ type MeetingWebhook struct { Payload MeetingWebhookPayload `json:"payload"` } +type ValidationWebhookPayload struct { + PlainToken string `json:"plainToken"` +} + +type ValidationWebhook struct { + Event EventType `json:"event"` + Payload ValidationWebhookPayload `json:"payload"` +} + +type ValidationWebhookResponse struct { + PlainToken string `json:"plainToken"` + EncryptedToken string `json:"encryptedToken"` +} + type Webhook struct { - Event EventType `json:"event"` - Payload interface{} `json:"payload"` + Event EventType `json:"event"` + EventTime int `json:"event_ts"` + Payload interface{} `json:"payload"` } type RecordingWebhook struct { diff --git a/webapp/src/manifest.js b/webapp/src/manifest.js index 3b02e693..9d7d74c1 100644 --- a/webapp/src/manifest.js +++ b/webapp/src/manifest.js @@ -1,4 +1,4 @@ // This file is automatically generated. Do not modify it manually. export const id = 'zoom'; -export const version = '1.6.1'; +export const version = '1.6.2';