diff --git a/api/message.go b/api/message.go index 3ebf6afd..e6a2199e 100644 --- a/api/message.go +++ b/api/message.go @@ -325,17 +325,20 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { }) } -// CreateMessage creates a message, authentication via application-token is required. +// CreateMessage creates a message, authentication via application token, client token, or basic auth is required. // swagger:operation POST /message message createMessage // // Create a message. // -// __NOTE__: This API ONLY accepts an application token as authentication. +// __NOTE__: When authenticating with a client token or basic auth, the request body +// must include "appid" referencing an application owned by the authenticated user. +// When authenticating with an application token, the application is derived from the +// token and any "appid" in the body is ignored. // // --- // consumes: [application/json] // produces: [application/json] -// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: []] +// security: [appTokenAuthorizationHeader: [], appTokenHeader: [], appTokenQuery: [], clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] // parameters: // - name: body // in: body @@ -362,26 +365,44 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { // $ref: "#/definitions/Error" func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message := model.MessageExternal{} - if err := ctx.Bind(&message); err == nil { - application := auth.GetApplication(ctx) - message.ApplicationID = application.ID - if strings.TrimSpace(message.Title) == "" { - message.Title = application.Name - } + if err := ctx.Bind(&message); err != nil { + return + } - if message.Priority == nil { - message.Priority = &application.DefaultPriority + app := auth.GetApplication(ctx) + if app == nil { + if message.ApplicationID == 0 { + ctx.AbortWithError(400, errors.New("appid is required when not authenticating with an application token")) + return } - - message.Date = timeNow() - message.ID = 0 - msgInternal := toInternalMessage(&message) - if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { + fetchedApp, err := a.DB.GetApplicationByID(message.ApplicationID) + if success := successOrAbort(ctx, 500, err); !success { + return + } + if fetchedApp == nil || fetchedApp.UserID != auth.GetUserID(ctx) { + ctx.AbortWithError(400, errors.New("appid not found")) return } - a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) - ctx.JSON(200, toExternalMessage(msgInternal)) + app = fetchedApp + } + + message.ApplicationID = app.ID + if strings.TrimSpace(message.Title) == "" { + message.Title = app.Name + } + + if message.Priority == nil { + message.Priority = &app.DefaultPriority + } + + message.Date = timeNow() + message.ID = 0 + msgInternal := toInternalMessage(&message) + if success := successOrAbort(ctx, 500, a.DB.CreateMessage(msgInternal)); !success { + return } + a.Notifier.Notify(auth.GetUserID(ctx), toExternalMessage(msgInternal)) + ctx.JSON(200, toExternalMessage(msgInternal)) } func toInternalMessage(msg *model.MessageExternal) *model.Message { diff --git a/api/message_test.go b/api/message_test.go index ce9eb6ff..d2561b81 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -536,6 +536,119 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() { assert.Equal(s.T(), uint(1), s.notifiedMessage.ID) } +func (s *MessageSuite) Test_CreateMessage_clientToken_usesBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + user := s.db.User(4) + user.NewAppWithToken(7, "app-token") + auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(7) + assert.NoError(s.T(), err) + expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} + assert.Len(s.T(), msgs, 1) + assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_missingAppId_400() { + user := s.db.User(4) + user.NewAppWithToken(7, "app-token") + auth.RegisterClient(s.ctx, user.NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) + if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs) + } +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_appNotOwned_400() { + s.db.User(5).NewAppWithToken(7, "other-app-token") + auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) + if msgs, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs) + } +} + +func (s *MessageSuite) Test_CreateMessage_clientToken_unknownAppId_400() { + auth.RegisterClient(s.ctx, s.db.User(4).NewClientWithToken(1, "client-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 999, "message": "mymessage"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Nil(s.T(), s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_basicAuth_usesBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + s.db.User(4).NewAppWithToken(7, "app-token") + test.WithUser(s.ctx, 4) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(7) + assert.NoError(s.T(), err) + expected := &model.MessageExternal{ID: 1, ApplicationID: 7, Title: "mytitle", Message: "mymessage", Priority: intPtr(1), Date: t} + assert.Len(s.T(), msgs, 1) + assert.Equal(s.T(), expected, toExternalMessage(msgs[0])) + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.notifiedMessage) +} + +func (s *MessageSuite) Test_CreateMessage_appToken_ignoresBodyAppId() { + t, _ := time.Parse("2006/01/02", "2017/01/02") + timeNow = func() time.Time { return t } + defer func() { timeNow = time.Now }() + + user := s.db.User(4) + user.NewAppWithToken(7, "other-app-token") + auth.RegisterApplication(s.ctx, user.NewAppWithToken(8, "app-token")) + + s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"appid": 7, "title": "mytitle", "message": "mymessage", "priority": 1}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateMessage(s.ctx) + + msgs, err := s.db.GetMessagesByApplication(8) + assert.NoError(s.T(), err) + assert.Len(s.T(), msgs, 1) + if msgs7, err := s.db.GetMessagesByApplication(7); assert.NoError(s.T(), err) { + assert.Empty(s.T(), msgs7) + } + assert.Equal(s.T(), 200, s.recorder.Code) +} + func (s *MessageSuite) withURL(scheme, host, path, query string) { s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query} s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) diff --git a/auth/authentication.go b/auth/authentication.go index 4b4310a8..2d52e214 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -75,6 +75,11 @@ func (a *Auth) RequireApplicationToken(ctx *gin.Context) { a.abort401(ctx) } +// RequireAny requires client, application, or basic auth. +func (a *Auth) RequireApplicationOrClient(ctx *gin.Context) { + a.evaluateOr401(ctx, a.handleApplication, a.handleClient(), a.handleUser()) +} + func (a *Auth) Optional(ctx *gin.Context) { if !a.evaluate(ctx, a.handleUser(), a.handleClient()) { ctx.Next() diff --git a/docs/spec.json b/docs/spec.json index 96780c2d..04be9646 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -1438,9 +1438,21 @@ }, { "appTokenQuery": [] + }, + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] } ], - "description": "__NOTE__: This API ONLY accepts an application token as authentication.", + "description": "__NOTE__: When authenticating with a client token or basic auth, the request body\nmust include \"appid\" referencing an application owned by the authenticated user.\nWhen authenticating with an application token, the application is derived from the\ntoken and any \"appid\" in the body is ignored.", "consumes": [ "application/json" ], diff --git a/router/router.go b/router/router.go index a57ca1fe..c7c1f24c 100644 --- a/router/router.go +++ b/router/router.go @@ -188,7 +188,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration}) }) - g.Group("/").Use(authentication.RequireApplicationToken).POST("/message", messageHandler.CreateMessage) + g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage) clientAuth := g.Group("") { diff --git a/router/router_test.go b/router/router_test.go index cb2a5833..4ac7c123 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -385,7 +385,7 @@ func (s *IntegrationSuite) TestAuthentication() { req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup"}`) req.SetBasicAuth("normal", "secret") - doRequestAndExpect(s.T(), req, 403, forbiddenJSON) + doRequestAndExpect(s.T(), req, 400, `{"error":"Bad Request", "errorCode":400, "errorDescription":"appid is required when not authenticating with an application token"}`) req = s.newRequest("GET", "current/user", "") req.SetBasicAuth("normal", "secret") diff --git a/ui/src/message/MessagesStore.ts b/ui/src/message/MessagesStore.ts index e0be4070..e72ac558 100644 --- a/ui/src/message/MessagesStore.ts +++ b/ui/src/message/MessagesStore.ts @@ -141,15 +141,14 @@ export class MessagesStore { priority: number ): Promise => { const app = this.appStore.getByID(appId); - const payload: Pick = { + const payload: Pick = { + appid: appId, message, priority, title, }; - await axios.post(`${config.get('url')}message`, payload, { - headers: {'X-Gotify-Key': app.token}, - }); + await axios.post(`${config.get('url')}message`, payload); this.snack(`Message sent to ${app.name}`); };