diff --git a/server/api.go b/server/api.go index 7fd432c9..2e2eb0eb 100644 --- a/server/api.go +++ b/server/api.go @@ -3,12 +3,17 @@ package main import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" + "net/url" "os" "path/filepath" + "strings" "sync" + "time" + "github.com/cristalhq/jwt/v2" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/plugin" @@ -36,6 +41,12 @@ type StartMeetingFromAction struct { } `json:"context"` } +type CallbackValidation struct { + UserID string + ChannelID string + room string +} + func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { switch path := r.URL.Path; path { case "/api/v1/meetings/enrich": @@ -46,6 +57,8 @@ func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req p.handleConfig(w, r) case "/jitsi_meet_external_api.js": p.handleExternalAPIjs(w, r) + case "/auth-callback": + p.handleCallback(w, r) default: http.NotFound(w, r) } @@ -303,3 +316,76 @@ func (p *Plugin) handleEnrichMeetingJwt(w http.ResponseWriter, r *http.Request) mlog.Warn("Unable to write response body", mlog.String("handler", "handleEnrichMeetingJwt"), mlog.Err(err)) } } + +func (p *Plugin) handleCallback(w http.ResponseWriter, r *http.Request) { + if err := p.getConfiguration().IsValid(); err != nil { + mlog.Error("Invalid plugin configuration", mlog.Err(err)) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + room := r.Header.Get("room") + redirectURL := p.handleRedirectURL(room) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +func (p *Plugin) handleRedirectURL(room string) string { + jitsiURL := strings.TrimSpace(p.getConfiguration().GetJitsiURL()) + jitsiURL = strings.TrimRight(jitsiURL, "/") + redirectURL := jitsiURL + "/" + room + "?jwt=" + + validJwtToken, err1 := p.createJwtToken(room) + if err1 != nil { + mlog.Error("Error Create JWT context", mlog.Err(err1)) + return redirectURL + "invalidjwttoken" + } + + if p.callback.room == room { + return redirectURL + validJwtToken + } + return *p.API.GetConfig().ServiceSettings.SiteURL + +} + +func (p *Plugin) createJwtToken(room string) (string, error) { + // Error check is done in configuration.IsValid() + jURL, _ := url.Parse(p.getConfiguration().GetJitsiURL()) + + user, err := p.API.GetUser(p.callback.UserID) + if err != nil { + mlog.Error("Error Get User", mlog.Err(err)) + return "", err + } + + var meetingLinkValidUntil = time.Time{} + meetingLinkValidUntil = time.Now().Add(time.Duration(p.getConfiguration().JitsiLinkValidTime) * time.Minute) + + claims := Claims{} + claims.Issuer = p.getConfiguration().JitsiAppID + claims.Audience = []string{p.getConfiguration().JitsiAppID} + claims.ExpiresAt = jwt.NewNumericDate(meetingLinkValidUntil) + claims.Subject = jURL.Hostname() + claims.Room = room + + sanitizedUser := user.DeepCopy() + config := p.API.GetConfig() + if config.PrivacySettings.ShowFullName == nil || !*config.PrivacySettings.ShowFullName { + sanitizedUser.FirstName = "" + sanitizedUser.LastName = "" + } + if config.PrivacySettings.ShowEmailAddress == nil || !*config.PrivacySettings.ShowEmailAddress { + sanitizedUser.Email = "" + } + + newContext := Context{ + User: User{ + Avatar: fmt.Sprintf("%s/api/v4/users/%s/image?_=%d", *config.ServiceSettings.SiteURL, sanitizedUser.Id, sanitizedUser.LastPictureUpdate), + Name: sanitizedUser.GetDisplayName(model.SHOW_NICKNAME_FULLNAME), + Email: sanitizedUser.Email, + ID: sanitizedUser.Id, + }, + Group: claims.Context.Group, + } + claims.Context = newContext + + return signClaims(p.getConfiguration().JitsiAppSecret, &claims) +} diff --git a/server/api_test.go b/server/api_test.go new file mode 100644 index 00000000..6989011e --- /dev/null +++ b/server/api_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin/plugintest" + "github.com/stretchr/testify/require" +) + +func TestRedirect(t *testing.T) { + p := Plugin{ + configuration: &configuration{ + JitsiURL: "http://test", + JitsiEmbedded: false, + JitsiNamingScheme: "mattermost", + JitsiAppSecret: "test-secret", + JitsiAppID: "test-appidjitsi", + }, + botID: "test-bot-id", + } + site := "http://mattermostserver" + servicesetting := model.ServiceSettings{SiteURL: &site} + + t.Run("Redirect with Valid JWT", func(t *testing.T) { + apiMock := plugintest.API{} + defer apiMock.AssertExpectations(t) + p.SetAPI(&apiMock) + p.callback = CallbackValidation{ + room: "validroom", + UserID: "test-user"} + + apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user", Locale: "en"}, nil) + apiMock.On("GetConfig").Return(&model.Config{ServiceSettings: servicesetting}) + + result := p.handleRedirectURL("validroom") + require.True(t, strings.Contains(result, "validroom"), "Check Path should have room name") + require.True(t, strings.Contains(result, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "url has JWT header") + }) + + t.Run("Redirect to site url for login authentication", func(t *testing.T) { + apiMock := plugintest.API{} + defer apiMock.AssertExpectations(t) + p.SetAPI(&apiMock) + p.callback = CallbackValidation{ + room: "validroom", + UserID: "test-user"} + + apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user", Locale: "en"}, nil) + apiMock.On("GetConfig").Return(&model.Config{ServiceSettings: servicesetting}) + + result := p.handleRedirectURL("invalidroom") + require.Equal(t, site, result) + }) + + t.Run("Redirect with invalid JWT, error internal", func(t *testing.T) { + apiMock := plugintest.API{} + defer apiMock.AssertExpectations(t) + p.SetAPI(&apiMock) + p.callback = CallbackValidation{ + room: "validroom", + UserID: "test-user"} + apiMock.On("GetUser", "test-user").Return(&model.User{Id: "test-user", Locale: "en"}, nil) + apiMock.On("GetConfig").Return(&model.Config{ServiceSettings: servicesetting}) + + p.configuration.JitsiAppSecret = "" // error if app secret is empty string + + result := p.handleRedirectURL("validroom") + require.True(t, strings.Contains(result, "invalidjwttoken"), "error internal, should send invalid jwt token") + }) +} diff --git a/server/plugin.go b/server/plugin.go index a0ece18a..f60db510 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -35,6 +35,7 @@ type Plugin struct { telemetryClient telemetry.Client tracker telemetry.Tracker + callback CallbackValidation // configurationLock synchronizes access to the configuration. configurationLock sync.RWMutex @@ -355,6 +356,13 @@ func (p *Plugin) startMeeting(user *model.User, channel *model.Channel, meetingI }) + "\n\n" + meetingUntil, } + callbackValidation := CallbackValidation{ + UserID: user.Id, + ChannelID: channel.Id, + room: meetingID, + } + p.callback = callbackValidation + post := &model.Post{ UserId: user.Id, ChannelId: channel.Id,