From 131aa218a7ce70cb496490228400c9f54a19d272 Mon Sep 17 00:00:00 2001 From: Yumechi Date: Tue, 2 Jun 2026 19:25:40 +0800 Subject: [PATCH] feat: EdDSA token for database leakage/index mitigation --- api/application.go | 9 +- api/application_test.go | 128 ++++++------ api/client.go | 9 +- api/client_test.go | 70 ++++--- api/message.go | 2 +- api/message_test.go | 10 +- api/oidc.go | 9 +- api/oidc_test.go | 11 +- api/session.go | 10 +- api/session_test.go | 8 +- api/stream/client.go | 2 +- api/stream/stream_test.go | 4 +- api/tokens_test.go | 11 +- api/user.go | 2 +- auth/authentication.go | 18 +- auth/token.go | 183 +++++++++++++++--- auth/token_test.go | 51 +++-- database/database.go | 2 +- database/user.go | 2 +- model/message.go | 2 +- plugin/compat/instance.go | 14 +- plugin/compat/v1.go | 4 +- plugin/compat/v1_test.go | 4 +- plugin/compat/wrap_test.go | 1 - plugin/compat/wrap_test_norace.go | 1 - plugin/compat/wrap_test_race.go | 1 - plugin/example/echo/echo.go | 6 +- plugin/manager.go | 15 +- plugin/manager_test.go | 5 +- plugin/manager_test_norace.go | 1 - plugin/manager_test_race.go | 1 - .../broken/malformedconstructor/main.go | 2 +- plugin/testing/mock/mock.go | 13 +- router/router_test.go | 2 + runner/runner.go | 4 +- test/asserts.go | 4 +- test/asserts_test.go | 2 +- test/token.go | 16 -- test/token_test.go | 15 -- 39 files changed, 381 insertions(+), 273 deletions(-) delete mode 100644 test/token.go delete mode 100644 test/token_test.go diff --git a/api/application.go b/api/application.go index a984b0f7f..d6c987c43 100644 --- a/api/application.go +++ b/api/application.go @@ -92,12 +92,13 @@ type ApplicationParams struct { func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { + tokenPublic, tokenPrivate := generateApplicationToken() app := model.Application{ Name: applicationParams.Name, Description: applicationParams.Description, DefaultPriority: applicationParams.DefaultPriority, SortKey: applicationParams.SortKey, - Token: auth.GenerateNotExistingToken(generateApplicationToken, a.applicationExists), + Token: tokenPublic, UserID: auth.GetUserID(ctx), Internal: false, } @@ -106,6 +107,7 @@ func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { handleApplicationError(ctx, err) return } + app.Token = tokenPrivate ctx.JSON(200, withResolvedImage(&app)) } } @@ -452,11 +454,6 @@ func withResolvedImage(app *model.Application) *model.Application { return app } -func (a *ApplicationAPI) applicationExists(token string) bool { - app, _ := a.DB.GetApplicationByToken(token) - return app != nil -} - func exist(path string) bool { if _, err := os.Stat(path); os.IsNotExist(err) { return false diff --git a/api/application_test.go b/api/application_test.go index c0263c683..2fa7a1d34 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" @@ -22,12 +23,6 @@ import ( "github.com/stretchr/testify/suite" ) -var ( - firstApplicationToken = "Aaaaaaaaaaaaaaa" - secondApplicationToken = "Abbbbbbbbbbbbbb" - thirdApplicationToken = "Acccccccccccccc" -) - func TestApplicationSuite(t *testing.T) { suite.Run(t, new(ApplicationSuite)) } @@ -37,30 +32,23 @@ type ApplicationSuite struct { db *testdb.Database a *ApplicationAPI ctx *gin.Context + imageDir *test.TmpDir recorder *httptest.ResponseRecorder } -var ( - originalGenerateApplicationToken func() string - originalGenerateImageName func() string -) - func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { - originalGenerateApplicationToken = generateApplicationToken - originalGenerateImageName = generateImageName - generateApplicationToken = test.Tokens(firstApplicationToken, secondApplicationToken, thirdApplicationToken) - generateImageName = test.Tokens(firstApplicationToken[1:], secondApplicationToken[1:], thirdApplicationToken[1:]) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) + tmpDir := test.NewTmpDir("gotify_applicationsuite") + s.imageDir = &tmpDir withURL(s.ctx, "http", "example.com") - s.a = &ApplicationAPI{DB: s.db} + s.a = &ApplicationAPI{DB: s.db, ImageDir: s.imageDir.Path() + "/"} } func (s *ApplicationSuite) AfterTest(suiteName, testName string) { - generateApplicationToken = originalGenerateApplicationToken - generateImageName = originalGenerateImageName + s.imageDir.Clean() s.db.Close() } @@ -73,7 +61,6 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { expected := &model.Application{ ID: 1, - Token: firstApplicationToken, UserID: 5, Name: "custom_name", Description: "description_text", @@ -82,6 +69,7 @@ func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { } assert.Equal(s.T(), 200, s.recorder.Code) if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + expected.Token = app.Token assert.Equal(s.T(), expected, app) } } @@ -133,7 +121,6 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar expected := &model.Application{ ID: 1, - Token: firstApplicationToken, Name: "name", Description: "description", Internal: false, @@ -143,7 +130,17 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar } assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) + } } func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { @@ -167,10 +164,18 @@ func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() { s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} + expected := &model.Application{ID: 1, Name: "custom_name", SortKey: "a0", CreatedAt: testdb.Now, Image: "static/defaultapp.png"} assert.Equal(s.T(), 200, s.recorder.Code) - if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), app, expected) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) } } @@ -184,29 +189,39 @@ func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { expected := &model.Application{ ID: 1, - Token: firstApplicationToken, Name: "custom_name", Image: "static/defaultapp.png", SortKey: "a0", CreatedAt: testdb.Now, } assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Application + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), app.Token, tokenParsed.PublicForm()) + } } func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.db.User(5) - s.db.User(6).AppWithToken(1, firstApplicationToken) + s.db.User(6).App(1) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 2, Token: secondApplicationToken, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} + expected := &model.Application{ID: 2, Name: "custom_name", UserID: 5, SortKey: "a0", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), app, expected) + if app, err := s.db.GetApplicationByID(2); assert.NoError(s.T(), err) { + expected.Token = app.Token + assert.Equal(s.T(), expected, app) } } @@ -288,7 +303,7 @@ func (s *ApplicationSuite) Test_DeleteApplication_internal_expectBadRequest() { s.db.User(5).InternalApp(10) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "10"}} s.a.DeleteApplication(s.ctx) @@ -300,7 +315,7 @@ func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "4"}} s.a.DeleteApplication(s.ctx) @@ -312,7 +327,7 @@ func (s *ApplicationSuite) Test_DeleteApplication() { s.db.User(5).App(1) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.DeleteApplication(s.ctx) @@ -371,7 +386,7 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { imgName := app.Image assert.Equal(s.T(), 200, s.recorder.Code) - _, err = os.Stat(imgName) + _, err = os.Stat(s.imageDir.Path(imgName)) assert.Nil(s.T(), err) s.a.DeleteApplication(s.ctx) @@ -381,12 +396,11 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { } } -func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() { - existingImageName := "2lHMAel6BDHLL-HrwphcviX-l.png" - firstGeneratedImageName := firstApplicationToken[1:] + ".png" - secondGeneratedImageName := secondApplicationToken[1:] + ".png" +func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImage() { + existingImageName := "existing.png" s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: existingImageName}) + fakeImage(s.T(), s.imageDir.Path(existingImageName)) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) assert.Nil(s.T(), err) @@ -394,42 +408,14 @@ func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageA s.ctx.Request.Header.Set("Content-Type", cType) test.WithUser(s.ctx, 5) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} - fakeImage(s.T(), existingImageName) - fakeImage(s.T(), firstGeneratedImageName) s.a.UploadApplicationImage(s.ctx) assert.Equal(s.T(), 200, s.recorder.Code) - _, err = os.Stat(existingImageName) - assert.True(s.T(), os.IsNotExist(err)) - - _, err = os.Stat(secondGeneratedImageName) - assert.Nil(s.T(), err) - assert.Nil(s.T(), os.Remove(secondGeneratedImageName)) - assert.Nil(s.T(), os.Remove(firstGeneratedImageName)) -} - -func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { - s.db.User(5) - s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"}) - - fakeImage(s.T(), "existing.png") - cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) + listing, err := os.ReadDir(s.imageDir.Path()) assert.Nil(s.T(), err) - s.ctx.Request = httptest.NewRequest("POST", "/irrelevant", &buffer) - s.ctx.Request.Header.Set("Content-Type", cType) - test.WithUser(s.ctx, 5) - s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} - - s.a.UploadApplicationImage(s.ctx) - - assert.Equal(s.T(), 200, s.recorder.Code) - - _, err = os.Stat("existing.png") - assert.True(s.T(), os.IsNotExist(err)) - - os.Remove(firstApplicationToken[1:] + ".png") + assert.Len(s.T(), listing, 1) } func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { @@ -504,14 +490,14 @@ func (s *ApplicationSuite) Test_RemoveAppImage_expectSuccess() { imageFile := "existing.png" s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: imageFile}) - fakeImage(s.T(), imageFile) + fakeImage(s.T(), s.imageDir.Path(imageFile)) test.WithUser(s.ctx, 5) s.ctx.Request = httptest.NewRequest("DELETE", "/irrelevant", nil) s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} s.a.RemoveApplicationImage(s.ctx) - _, err := os.Stat(imageFile) + _, err := os.Stat(s.imageDir.Path(imageFile)) assert.True(s.T(), os.IsNotExist(err)) assert.Equal(s.T(), 200, s.recorder.Code) @@ -672,7 +658,7 @@ func (s *ApplicationSuite) withFormData(formData string) { s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } -func (s *ApplicationSuite) withJSON(value interface{}) { +func (s *ApplicationSuite) withJSON(value any) { jsonVal, _ := json.Marshal(value) s.ctx.Request = httptest.NewRequest("POST", "/application", bytes.NewBuffer(jsonVal)) s.ctx.Request.Header.Set("Content-Type", "application/json") diff --git a/api/client.go b/api/client.go index bef5901eb..f81535fb9 100644 --- a/api/client.go +++ b/api/client.go @@ -150,9 +150,10 @@ func (a *ClientAPI) UpdateClient(ctx *gin.Context) { func (a *ClientAPI) CreateClient(ctx *gin.Context) { clientParams := ClientParams{} if err := ctx.Bind(&clientParams); err == nil { + tokenPublic, tokenPrivate := generateClientToken() client := model.Client{ Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + Token: tokenPublic, UserID: auth.GetUserID(ctx), } if clientParams.ExpiresAfterInactivitySeconds != nil { @@ -162,6 +163,7 @@ func (a *ClientAPI) CreateClient(ctx *gin.Context) { if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return } + client.Token = tokenPrivate ctx.JSON(200, client) } } @@ -321,8 +323,3 @@ func (a *ClientAPI) ElevateClient(ctx *gin.Context) { ctx.Status(204) }) } - -func (a *ClientAPI) clientExists(token string) bool { - client, _ := a.DB.GetClientByToken(token) - return client != nil -} diff --git a/api/client_test.go b/api/client_test.go index 40422e14e..a55998cef 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -1,7 +1,9 @@ package api import ( + "encoding/json" "fmt" + "io" "net/http/httptest" "net/url" "strings" @@ -9,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" "github.com/gotify/server/v2/test" @@ -17,11 +20,6 @@ import ( "github.com/stretchr/testify/suite" ) -var ( - firstClientToken = "Caaaaaaaaaaaaaa" - secondClientToken = "Cbbbbbbbbbbbbbb" -) - func TestClientSuite(t *testing.T) { suite.Run(t, new(ClientSuite)) } @@ -35,11 +33,7 @@ type ClientSuite struct { notified bool } -var originalGenerateClientToken func() string - func (s *ClientSuite) BeforeTest(suiteName, testName string) { - originalGenerateClientToken = generateClientToken - generateClientToken = test.Tokens(firstClientToken, secondClientToken) mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.db = testdb.NewDB(s.T()) @@ -54,7 +48,6 @@ func (s *ClientSuite) notify(uint, string) { } func (s *ClientSuite) AfterTest(suiteName, testName string) { - generateClientToken = originalGenerateClientToken s.db.Close() } @@ -71,10 +64,11 @@ func (s *ClientSuite) Test_CreateClient_mapAllParameters() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, UserID: 5, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), clients, expected) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + expected.Token = client.Token + assert.Equal(s.T(), expected, client) } } @@ -85,11 +79,12 @@ func (s *ClientSuite) Test_CreateClient_ignoresReadOnlyPropertiesInParams() { s.withFormData("name=myclient&ID=45&Token=12341234&UserID=333") s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, UserID: 5, Token: firstClientToken, Name: "myclient", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, UserID: 5, Name: "myclient", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - if clients, err := s.db.GetClientsByUser(5); assert.NoError(s.T(), err) { - assert.Contains(s.T(), clients, expected) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + expected.Token = client.Token + assert.Equal(s.T(), expected, client) } } @@ -129,22 +124,44 @@ func (s *ClientSuite) Test_CreateClient_returnsClientWithID() { s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 1, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Client + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if client, err := s.db.GetClientByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), client.Token, tokenParsed.PublicForm()) + } } func (s *ClientSuite) Test_CreateClient_withExistingToken() { - s.db.User(5).ClientWithToken(1, firstClientToken) + firstClientTokenPublic, _ := generateClientToken() + s.db.User(5).ClientWithToken(1, firstClientTokenPublic) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name") s.a.CreateClient(s.ctx) - expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", CreatedAt: testdb.Now} + expected := &model.Client{ID: 2, Name: "custom_name", CreatedAt: testdb.Now} assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) + bodyBytes, err := io.ReadAll(s.recorder.Body) + assert.Nil(s.T(), err) + var got model.Client + assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got)) + expected.Token = got.Token + assert.Equal(s.T(), expected, &got) + tokenParsed, err := auth.ParseEnhancedToken(got.Token) + assert.Nil(s.T(), err) + if client, err := s.db.GetClientByID(2); assert.NoError(s.T(), err) { + assert.Equal(s.T(), client.Token, tokenParsed.PublicForm()) + assert.NotEqual(s.T(), tokenParsed.PublicForm(), firstClientTokenPublic) + } } func (s *ClientSuite) Test_GetClients() { @@ -165,7 +182,7 @@ func (s *ClientSuite) Test_DeleteClient_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.AddParam("id", "8") s.a.DeleteClient(s.ctx) @@ -177,7 +194,7 @@ func (s *ClientSuite) Test_DeleteClient() { s.db.User(5).Client(8) test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/", nil) s.ctx.AddParam("id", "8") assert.False(s.T(), s.notified) @@ -204,7 +221,7 @@ func (s *ClientSuite) Test_CreateClient_acceptsExpiresAfterInactivitySeconds() { } func (s *ClientSuite) Test_UpdateClient_updatesExpiresAfterInactivitySeconds() { - s.db.User(5).NewClientWithToken(1, firstClientToken) + s.db.User(5).Client(1) test.WithUser(s.ctx, 5) s.withFormData("name=firefox&expiresAfterInactivitySeconds=7200") @@ -218,7 +235,8 @@ func (s *ClientSuite) Test_UpdateClient_updatesExpiresAfterInactivitySeconds() { } func (s *ClientSuite) Test_UpdateClient_expectSuccess() { - s.db.User(5).NewClientWithToken(1, firstClientToken) + firstClientTokenPublic, _ := generateClientToken() + s.db.User(5).ClientWithToken(1, firstClientTokenPublic) test.WithUser(s.ctx, 5) s.withFormData("name=firefox") @@ -227,7 +245,7 @@ func (s *ClientSuite) Test_UpdateClient_expectSuccess() { expected := &model.Client{ ID: 1, - Token: firstClientToken, + Token: firstClientTokenPublic, UserID: 5, Name: "firefox", CreatedAt: testdb.Now, diff --git a/api/message.go b/api/message.go index e6a2199e0..185e31e9d 100644 --- a/api/message.go +++ b/api/message.go @@ -433,7 +433,7 @@ func toExternalMessage(msg *model.Message) *model.MessageExternal { Date: msg.Date, } if len(msg.Extras) != 0 { - res.Extras = make(map[string]interface{}) + res.Extras = make(map[string]any) json.Unmarshal(msg.Extras, &res.Extras) } return res diff --git a/api/message_test.go b/api/message_test.go index d2561b81f..2a1d4a2b9 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -53,9 +53,9 @@ func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() { actual := &model.PagedMessages{ Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"}, - Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]interface{}{ + Messages: []*model.MessageExternal{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: intPtr(4), Extras: map[string]any{ "test::string": "string", - "test::array": []interface{}{1, 2, 3}, + "test::array": []any{1, 2, 3}, "test::int": 1, "test::float": 0.5, }}}, @@ -461,10 +461,10 @@ func (s *MessageSuite) Test_CreateMessage_WithExtras() { Title: "msg with extras", Date: t, Priority: intPtr(0), - Extras: map[string]interface{}{ - "gotify::test": map[string]interface{}{ + Extras: map[string]any{ + "gotify::test": map[string]any{ "string": "test", - "array": []interface{}{float64(1), float64(2), float64(3)}, + "array": []any{float64(1), float64(2), float64(3)}, "int": float64(1), "float": float64(0.5), }, diff --git a/api/oidc.go b/api/oidc.go index 706d318f6..e1b02815a 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -423,14 +423,19 @@ func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) { func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) { elevatedUntil := time.Now().Add(model.DefaultElevationDuration) + tokenPublic, tokenPrivate := generateClientToken() client := &model.Client{ Name: name, - Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + Token: tokenPublic, UserID: userID, ElevatedUntil: &elevatedUntil, ExpiresAfterInactivitySeconds: auth.CookieMaxAge, } - return client, a.DB.CreateClient(client) + if err := a.DB.CreateClient(client); err != nil { + return nil, err + } + client.Token = tokenPrivate + return client, nil } func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) { diff --git a/api/oidc_test.go b/api/oidc_test.go index 473e57f61..837704c05 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -10,15 +10,12 @@ import ( "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/decaymap" "github.com/gotify/server/v2/mode" - "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/zitadel/oidc/v3/pkg/oidc" ) -var origGenClientToken = generateClientToken - func TestOIDCSuite(t *testing.T) { suite.Run(t, new(OIDCSuite)) } @@ -144,19 +141,17 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { // --- createClient --- func (s *OIDCSuite) Test_CreateClient() { - generateClientToken = test.Tokens("Ctesttoken00001") - defer func() { generateClientToken = origGenClientToken }() - s.db.NewUser(1) client, err := s.a.createClient("MyPhone", 1) assert.NoError(s.T(), err) assert.Equal(s.T(), "MyPhone", client.Name) - assert.Equal(s.T(), "Ctesttoken00001", client.Token) + tokenParsed, err := auth.ParseEnhancedToken(client.Token) + assert.NoError(s.T(), err) assert.Equal(s.T(), uint(1), client.UserID) assert.Equal(s.T(), uint(auth.CookieMaxAge), client.ExpiresAfterInactivitySeconds) - dbClient, err := s.db.GetClientByToken("Ctesttoken00001") + dbClient, err := s.db.GetClientByToken(tokenParsed.PublicForm()) assert.NoError(s.T(), err) assert.NotNil(s.T(), dbClient) } diff --git a/api/session.go b/api/session.go index c72b8f551..924245351 100644 --- a/api/session.go +++ b/api/session.go @@ -76,9 +76,10 @@ func (a *SessionAPI) Login(ctx *gin.Context) { } elevatedUntil := time.Now().Add(model.DefaultElevationDuration) + tokenPublic, tokenPrivate := generateClientToken() client := model.Client{ Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + Token: tokenPublic, UserID: user.ID, ElevatedUntil: &elevatedUntil, ExpiresAfterInactivitySeconds: auth.CookieMaxAge, @@ -87,7 +88,7 @@ func (a *SessionAPI) Login(ctx *gin.Context) { return } - auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge, a.SecureCookie) + auth.SetCookie(ctx.Writer, tokenPrivate, auth.CookieMaxAge, a.SecureCookie) ctx.JSON(200, &model.CurrentUserExternal{ ID: user.ID, @@ -138,8 +139,3 @@ func (a *SessionAPI) Logout(ctx *gin.Context) { ctx.Status(200) } - -func (a *SessionAPI) clientExists(token string) bool { - client, _ := a.DB.GetClientByToken(token) - return client != nil -} diff --git a/api/session_test.go b/api/session_test.go index ebe3b6f33..fea0bbd6c 100644 --- a/api/session_test.go +++ b/api/session_test.go @@ -12,7 +12,6 @@ import ( "github.com/gotify/server/v2/auth/password" "github.com/gotify/server/v2/mode" "github.com/gotify/server/v2/model" - "github.com/gotify/server/v2/test" "github.com/gotify/server/v2/test/testdb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -55,10 +54,6 @@ func (s *SessionSuite) AfterTest(suiteName, testName string) { } func (s *SessionSuite) Test_Login_Success() { - originalGenerateClientToken := generateClientToken - defer func() { generateClientToken = originalGenerateClientToken }() - generateClientToken = test.Tokens("Ctesttoken12345") - s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))) @@ -77,7 +72,8 @@ func (s *SessionSuite) Test_Login_Success() { } } assert.NotNil(s.T(), sessionCookie) - assert.Equal(s.T(), "Ctesttoken12345", sessionCookie.Value) + _, err := auth.ParseEnhancedToken(sessionCookie.Value) + assert.NoError(s.T(), err) assert.True(s.T(), sessionCookie.HttpOnly) assert.Equal(s.T(), "/", sessionCookie.Path) assert.Equal(s.T(), http.SameSiteStrictMode, sessionCookie.SameSite) diff --git a/api/stream/client.go b/api/stream/client.go index 1d7f4ba0d..fbb8b13b6 100644 --- a/api/stream/client.go +++ b/api/stream/client.go @@ -16,7 +16,7 @@ var ping = func(conn *websocket.Conn) error { return conn.WriteMessage(websocket.PingMessage, nil) } -var writeJSON = func(conn *websocket.Conn, v interface{}) error { +var writeJSON = func(conn *websocket.Conn, v any) error { return conn.WriteJSON(v) } diff --git a/api/stream/stream_test.go b/api/stream/stream_test.go index a15fd8d01..9b644325d 100644 --- a/api/stream/stream_test.go +++ b/api/stream/stream_test.go @@ -40,7 +40,7 @@ func TestWriteMessageFails(t *testing.T) { mode.Set(mode.TestDev) oldWrite := writeJSON // try emulate an write error, mostly this should kill the ReadMessage goroutine first but you'll never know. - writeJSON = func(conn *websocket.Conn, v interface{}) error { + writeJSON = func(conn *websocket.Conn, v any) error { return errors.New("asd") } defer func() { @@ -610,7 +610,7 @@ func staticUserID() gin.HandlerFunc { } func waitForConnectedClients(api *API, count int) { - for i := 0; i < 10; i++ { + for range 10 { if countClients(api) == count { // ok return diff --git a/api/tokens_test.go b/api/tokens_test.go index 66289e751..41c671095 100644 --- a/api/tokens_test.go +++ b/api/tokens_test.go @@ -8,7 +8,12 @@ import ( ) func TestTokenGeneration(t *testing.T) { - assert.Regexp(t, regexp.MustCompile("^C(.+)$"), generateClientToken()) - assert.Regexp(t, regexp.MustCompile("^A(.+)$"), generateApplicationToken()) - assert.Regexp(t, regexp.MustCompile("^(.+)$"), generateImageName()) + clientPub, clientPriv := generateClientToken() + assert.Regexp(t, regexp.MustCompile(`^gtfy_client\.(.+)$`), clientPub) + assert.Regexp(t, regexp.MustCompile(`^gtfy_client\.(.+)$`), clientPriv) + applicationPub, applicationPriv := generateApplicationToken() + assert.Regexp(t, regexp.MustCompile(`^gtfy_app\.(.+)$`), applicationPub) + assert.Regexp(t, regexp.MustCompile(`^gtfy_app\.(.+)$`), applicationPriv) + imageName := generateImageName() + assert.Regexp(t, regexp.MustCompile(`^(.+)$`), imageName) } diff --git a/api/user.go b/api/user.go index e24714e63..e1d393a4e 100644 --- a/api/user.go +++ b/api/user.go @@ -20,7 +20,7 @@ type UserDatabase interface { DeleteUserByID(id uint) error UpdateUser(user *model.User) error CreateUser(user *model.User) error - CountUser(condition ...interface{}) (int64, error) + CountUser(condition ...any) (int64, error) } // UserChangeNotifier notifies listeners for user changes. diff --git a/auth/authentication.go b/auth/authentication.go index 2d52e2146..532cd18a8 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -151,6 +151,13 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu if token == "" { return authStateSkip, nil } + if strings.HasPrefix(token, enhancedTokenPrefix) { + complexToken, err := ParseEnhancedToken(token) + if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { + return authStateSkip, err + } + token = complexToken.PublicForm() + } client, err := a.DB.GetClientByToken(token) if err != nil { return authStateSkip, err @@ -166,7 +173,7 @@ func (a *Auth) handleClient(checks ...func(*model.Client) (authState, error)) fu return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, client.Token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) } } @@ -185,6 +192,13 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { if token == "" { return authStateSkip, nil } + if strings.HasPrefix(token, enhancedTokenPrefix) { + complexToken, err := ParseEnhancedToken(token) + if err != nil || !complexToken.ValidateTimestamp(timeNow().Unix()) { + return authStateSkip, err + } + token = complexToken.PublicForm() + } app, err := a.DB.GetApplicationByToken(token) if err != nil { return authStateSkip, err @@ -200,7 +214,7 @@ func (a *Auth) handleApplication(ctx *gin.Context) (authState, error) { return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, app.Token, CookieMaxAge, a.SecureCookie) + SetCookie(ctx.Writer, token, CookieMaxAge, a.SecureCookie) } } diff --git a/auth/token.go b/auth/token.go index a0f9e0b5f..3d9ca9b30 100644 --- a/auth/token.go +++ b/auth/token.go @@ -1,20 +1,167 @@ package auth import ( + "crypto" + "crypto/ed25519" "crypto/rand" + "crypto/sha512" + "encoding/base64" + "errors" + "fmt" "math/big" + "strconv" + "strings" +) + +const ( + maxTimestampDiffSeconds = 15 * 60 + randomTokenLength = 22 // ~2^132 keyspace ) var ( - tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") - randomTokenLength = 22 // ~2^132.5 keyspace (7.65e+39) - applicationPrefix = "A" - clientPrefix = "C" - pluginPrefix = "P" + errInvalidToken = errors.New("invalid token") + errNoPrivateKey = errors.New("no private key") + tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + pluginPrefix = "P" + enhancedTokenPrefix = "gtfy_" randReader = rand.Reader ) +type EnhancedToken struct { + ident string // a shared identifier, in formats like A12 for application ID 12 + pubOrPrivKey []byte // public key + timestamp int64 + signature []byte +} + +// PublicForm returns the a canonicalized representation of the public key. +func (c *EnhancedToken) PublicForm() string { + if c.timestamp != 0 || len(c.signature) != 0 { + return enhancedTokenPrefix + c.ident + "." + base64.RawURLEncoding.EncodeToString(c.pubOrPrivKey) + } + privKey := ed25519.NewKeyFromSeed(c.pubOrPrivKey) + return enhancedTokenPrefix + c.ident + "." + base64.RawURLEncoding.EncodeToString(privKey.Public().(ed25519.PublicKey)) +} + +// Sign signs the timestamp with the private key and returns a new EnhancedToken. +func (c *EnhancedToken) Sign(timestamp int64) (*EnhancedToken, error) { + if c.timestamp != 0 || len(c.signature) != 0 || len(c.pubOrPrivKey) != ed25519.SeedSize { + return nil, errNoPrivateKey + } + privKey := ed25519.NewKeyFromSeed(c.pubOrPrivKey) + sha512 := sha512.New() + sha512.Write([]byte("iss=")) + fmt.Fprintf(sha512, "%d", timestamp) + sign, err := privKey.Sign(nil, sha512.Sum(nil), crypto.SHA512) + if err != nil { + return nil, err + } + return &EnhancedToken{ + ident: c.ident, + pubOrPrivKey: privKey.Public().(ed25519.PublicKey), + timestamp: timestamp, + signature: sign, + }, nil +} + +func (c *EnhancedToken) ValidateTimestamp(now int64) bool { + if c.timestamp == 0 && len(c.signature) == 0 { + return true + } + if c.timestamp < now-maxTimestampDiffSeconds { + return false + } + if c.timestamp > now+maxTimestampDiffSeconds { + return false + } + return true +} + +// String marshals the token into a string. +func (c *EnhancedToken) String() string { + var b strings.Builder + b.WriteString(enhancedTokenPrefix) + b.WriteString(c.ident) + b.WriteByte('.') + b.WriteString(base64.RawURLEncoding.EncodeToString(c.pubOrPrivKey)) + if c.timestamp != 0 || len(c.signature) != 0 { + fmt.Fprintf(&b, ".%d.", c.timestamp) + b.WriteString(base64.RawURLEncoding.EncodeToString(c.signature)) + } + return b.String() +} + +// NewEnhancedToken creates a new EnhancedToken. +func NewEnhancedToken(ident string) *EnhancedToken { + ident = strings.ReplaceAll(ident, ".", "_") + var seed [ed25519.SeedSize]byte + _, err := rand.Read(seed[:]) + if err != nil { + panic("unreachable: random source should never return an error") + } + return &EnhancedToken{ident: ident, pubOrPrivKey: seed[:]} +} + +// ParseEnhancedToken parses a string into an EnhancedToken. +func ParseEnhancedToken(token string) (*EnhancedToken, error) { + token, found := strings.CutPrefix(token, enhancedTokenPrefix) + if !found { + return nil, errInvalidToken + } + + // count number of dots, one dot -> ident then private key, three dots -> ident, public key, challenge then signature + fields := strings.SplitN(token, ".", 4) + if len(fields) != 2 && len(fields) != 4 { + return nil, errInvalidToken + } + ident := fields[0] + pkOrPubkeyB64 := fields[1] + pkOrPubkeyBytesLen := base64.RawURLEncoding.DecodedLen(len(pkOrPubkeyB64)) + pkOrPubkey, err := base64.RawURLEncoding.DecodeString(pkOrPubkeyB64) + if err != nil { + return nil, errInvalidToken + } + if len(fields) == 2 { + if pkOrPubkeyBytesLen != ed25519.SeedSize { + return nil, errInvalidToken + } + return &EnhancedToken{ + ident: ident, + pubOrPrivKey: pkOrPubkey, + }, nil + } + if pkOrPubkeyBytesLen != ed25519.PublicKeySize { + return nil, errInvalidToken + } + timestampStr := fields[2] + timestamp, err := strconv.ParseInt(timestampStr, 10, 64) + if err != nil { + return nil, errInvalidToken + } + signatureB64 := fields[3] + signatureBytesLen := base64.RawURLEncoding.DecodedLen(len(signatureB64)) + if signatureBytesLen != ed25519.SignatureSize { + return nil, errInvalidToken + } + signature, err := base64.RawURLEncoding.DecodeString(signatureB64) + if err != nil { + return nil, errInvalidToken + } + sha512 := sha512.New() + sha512.Write([]byte("iss=")) // query-like encoding to give us some semantic headroom should we need more fields in the future + fmt.Fprintf(sha512, "%d", timestamp) + if err := ed25519.VerifyWithOptions(pkOrPubkey, sha512.Sum(nil), signature, &ed25519.Options{Hash: crypto.SHA512}); err != nil { + return nil, errInvalidToken + } + return &EnhancedToken{ + ident: ident, + pubOrPrivKey: pkOrPubkey, + timestamp: timestamp, + signature: signature, + }, nil +} + func randIntn(n int) int { max := big.NewInt(int64(n)) res, err := rand.Int(randReader, max) @@ -24,29 +171,21 @@ func randIntn(n int) int { return int(res.Int64()) } -// GenerateNotExistingToken receives a token generation func and a func to check whether the token exists, returns a unique token. -func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { - for { - token := generateToken() - if !tokenExists(token) { - return token - } - } -} - // GenerateApplicationToken generates an application token. -func GenerateApplicationToken() string { - return generateRandomToken(applicationPrefix) +func GenerateApplicationToken() (publicForm, privateForm string) { + token := NewEnhancedToken("app") + return token.PublicForm(), token.String() } // GenerateClientToken generates a client token. -func GenerateClientToken() string { - return generateRandomToken(clientPrefix) +func GenerateClientToken() (publicForm, privateForm string) { + token := NewEnhancedToken("client") + return token.PublicForm(), token.String() } // GeneratePluginToken generates a plugin token. func GeneratePluginToken() string { - return generateRandomToken(pluginPrefix) + return pluginPrefix + generateRandomString(randomTokenLength) } // GenerateImageName generates an image name. @@ -54,10 +193,6 @@ func GenerateImageName() string { return generateRandomString(25) } -func generateRandomToken(prefix string) string { - return prefix + generateRandomString(randomTokenLength) -} - func generateRandomString(length int) string { res := make([]byte, length) for i := range res { diff --git a/auth/token_test.go b/auth/token_test.go index 2b7c1ccca..c88ce168d 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -3,32 +3,45 @@ package auth import ( "crypto/rand" "errors" - "fmt" - "strings" + "log" "testing" "testing/iotest" "github.com/stretchr/testify/assert" ) -func TestTokenHavePrefix(t *testing.T) { - for i := 0; i < 50; i++ { - assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) - assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) - assert.True(t, strings.HasPrefix(GeneratePluginToken(), "P")) - assert.NotEmpty(t, GenerateImageName()) - } -} +func TestNewComplexToken(t *testing.T) { + token := NewEnhancedToken("A12") + log.Printf("token: %s", token.String()) + canonicalizedExpected := token.PublicForm() + tokenParsed, err := ParseEnhancedToken(token.String()) + assert.NoError(t, err) + tokenSigned, err := tokenParsed.Sign(12345) + assert.NoError(t, err) + tokenSignedStr := tokenSigned.String() + assert.True(t, tokenSigned.ValidateTimestamp(12345+1)) + assert.False(t, tokenSigned.ValidateTimestamp(12345+maxTimestampDiffSeconds+1)) + canonicalizedActual := tokenSigned.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) -func TestGenerateNotExistingToken(t *testing.T) { - count := 5 - token := GenerateNotExistingToken(func() string { - return fmt.Sprint(count) - }, func(token string) bool { - count-- - return token != "0" - }) - assert.Equal(t, "0", token) + tokenParsed, err = ParseEnhancedToken(tokenSignedStr) + assert.NoError(t, err) + canonicalizedActual = tokenParsed.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) + _, err = tokenSigned.Sign(12345) + assert.ErrorIs(t, err, errNoPrivateKey) + tokenParsed, err = ParseEnhancedToken(tokenSignedStr) + assert.NoError(t, err) + canonicalizedActual = tokenParsed.PublicForm() + assert.Equal(t, canonicalizedExpected, canonicalizedActual) + tokenSignedStrMutated := tokenSignedStr[1:] + if tokenSignedStr[0] != 'A' { + tokenSignedStrMutated = "A" + tokenSignedStrMutated + } else { + tokenSignedStrMutated = "B" + tokenSignedStrMutated + } + _, err = ParseEnhancedToken(tokenSignedStrMutated) + assert.ErrorIs(t, err, errInvalidToken) } func TestBadCryptoReaderPanics(t *testing.T) { diff --git a/database/database.go b/database/database.go index 802f56d3a..205489ac7 100644 --- a/database/database.go +++ b/database/database.go @@ -23,7 +23,7 @@ import ( // gormLogWriter routes gorm logger output through zerolog. type gormLogWriter struct{} -func (gormLogWriter) Printf(format string, args ...interface{}) { +func (gormLogWriter) Printf(format string, args ...any) { log.Warn().Str("component", "gorm").Msgf(format, args...) } diff --git a/database/user.go b/database/user.go index 8e6bab358..d625db692 100644 --- a/database/user.go +++ b/database/user.go @@ -32,7 +32,7 @@ func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) { } // CountUser returns the user count which satisfies the given condition. -func (d *GormDatabase) CountUser(condition ...interface{}) (int64, error) { +func (d *GormDatabase) CountUser(condition ...any) (int64, error) { c := int64(-1) handle := d.DB.Model(new(model.User)) if len(condition) == 1 { diff --git a/model/message.go b/model/message.go index e00545a21..57a6c9f9f 100644 --- a/model/message.go +++ b/model/message.go @@ -56,7 +56,7 @@ type MessageExternal struct { // These namespaces are reserved and might be used in the official clients: gotify android ios web server client. Do not use them for other purposes. // // example: {"home::appliances::thermostat::change_temperature":{"temperature":23},"home::appliances::lighting::on":{"brightness":15}} - Extras map[string]interface{} `form:"-" query:"-" json:"extras,omitempty"` + Extras map[string]any `form:"-" query:"-" json:"extras,omitempty"` // The date the message was created. // // read only: true diff --git a/plugin/compat/instance.go b/plugin/compat/instance.go index c3e71ce3b..d310216a2 100644 --- a/plugin/compat/instance.go +++ b/plugin/compat/instance.go @@ -2,6 +2,7 @@ package compat import ( "net/url" + "slices" "github.com/gin-gonic/gin" ) @@ -31,9 +32,9 @@ type PluginInstance interface { GetDisplay(location *url.URL) string // DefaultConfig see Configurer - DefaultConfig() interface{} + DefaultConfig() any // ValidateAndSetConfig see Configurer - ValidateAndSetConfig(c interface{}) error + ValidateAndSetConfig(c any) error // SetMessageHandler see Messenger#SetMessageHandler SetMessageHandler(h MessageHandler) @@ -50,12 +51,7 @@ type PluginInstance interface { // HasSupport tests a PluginInstance for a capability. func HasSupport(p PluginInstance, toCheck Capability) bool { - for _, module := range p.Supports() { - if module == toCheck { - return true - } - } - return false + return slices.Contains(p.Supports(), toCheck) } // Capabilities is a slice of module. @@ -87,5 +83,5 @@ type Message struct { Message string Title string Priority int - Extras map[string]interface{} + Extras map[string]any } diff --git a/plugin/compat/v1.go b/plugin/compat/v1.go index 86832c3c6..1b3e99ad2 100644 --- a/plugin/compat/v1.go +++ b/plugin/compat/v1.go @@ -77,7 +77,7 @@ type PluginV1Instance struct { } // DefaultConfig see papiv1.Configurer. -func (c *PluginV1Instance) DefaultConfig() interface{} { +func (c *PluginV1Instance) DefaultConfig() any { if c.configurer != nil { return c.configurer.DefaultConfig() } @@ -85,7 +85,7 @@ func (c *PluginV1Instance) DefaultConfig() interface{} { } // ValidateAndSetConfig see papiv1.Configurer. -func (c *PluginV1Instance) ValidateAndSetConfig(config interface{}) error { +func (c *PluginV1Instance) ValidateAndSetConfig(config any) error { if c.configurer != nil { return c.configurer.ValidateAndSetConfig(config) } diff --git a/plugin/compat/v1_test.go b/plugin/compat/v1_test.go index a516e0341..7e5db7aec 100644 --- a/plugin/compat/v1_test.go +++ b/plugin/compat/v1_test.go @@ -118,7 +118,7 @@ func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, } @@ -127,7 +127,7 @@ func (s *V1WrapperSuite) TestMessenger_sendMessageWithExtras() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, }, handler.msgSent) diff --git a/plugin/compat/wrap_test.go b/plugin/compat/wrap_test.go index b3a4433fc..944277a22 100644 --- a/plugin/compat/wrap_test.go +++ b/plugin/compat/wrap_test.go @@ -1,5 +1,4 @@ //go:build linux || darwin -// +build linux darwin package compat diff --git a/plugin/compat/wrap_test_norace.go b/plugin/compat/wrap_test_norace.go index c3ee4a53a..62963f0de 100644 --- a/plugin/compat/wrap_test_norace.go +++ b/plugin/compat/wrap_test_norace.go @@ -1,5 +1,4 @@ //go:build !race -// +build !race package compat diff --git a/plugin/compat/wrap_test_race.go b/plugin/compat/wrap_test_race.go index b5668b90b..c2cfceb8b 100644 --- a/plugin/compat/wrap_test_race.go +++ b/plugin/compat/wrap_test_race.go @@ -1,5 +1,4 @@ //go:build race -// +build race package compat diff --git a/plugin/example/echo/echo.go b/plugin/example/echo/echo.go index b8b8407d2..db1784fab 100644 --- a/plugin/example/echo/echo.go +++ b/plugin/example/echo/echo.go @@ -47,14 +47,14 @@ type Config struct { } // DefaultConfig implements plugin.Configurer -func (c *EchoPlugin) DefaultConfig() interface{} { +func (c *EchoPlugin) DefaultConfig() any { return &Config{ MagicString: "hello world", } } // ValidateAndSetConfig implements plugin.Configurer -func (c *EchoPlugin) ValidateAndSetConfig(config interface{}) error { +func (c *EchoPlugin) ValidateAndSetConfig(config any) error { c.config = config.(*Config) return nil } @@ -86,7 +86,7 @@ func (c *EchoPlugin) RegisterWebhook(baseURL string, g *gin.RouterGroup) { Title: "Hello received", Message: fmt.Sprintf("echo server received a hello message %d times", conf.CalledTimes), Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "plugin::name": "echo", }, }) diff --git a/plugin/manager.go b/plugin/manager.go index 9c9436dcf..5048f2328 100644 --- a/plugin/manager.go +++ b/plugin/manager.go @@ -103,16 +103,6 @@ func NewManager(db Database, directory string, mux *gin.RouterGroup, notifier No // ErrAlreadyEnabledOrDisabled is returned on SetPluginEnabled call when a plugin is already enabled or disabled. var ErrAlreadyEnabledOrDisabled = errors.New("config is already enabled/disabled") -func (m *Manager) applicationExists(token string) bool { - app, _ := m.db.GetApplicationByToken(token) - return app != nil -} - -func (m *Manager) pluginConfExists(token string) bool { - pluginConf, _ := m.db.GetPluginConfByToken(token) - return pluginConf != nil -} - // SetPluginEnabled sets the plugins enabled state. func (m *Manager) SetPluginEnabled(pluginID uint, enabled bool) error { instance, err := m.Instance(pluginID) @@ -403,14 +393,15 @@ func (m *Manager) createPluginConf(instance compat.PluginInstance, info compat.I pluginConf := &model.PluginConf{ UserID: userID, ModulePath: info.ModulePath, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, m.pluginConfExists), + Token: auth.GeneratePluginToken(), } if compat.HasSupport(instance, compat.Configurer) { pluginConf.Config, _ = yaml.Marshal(instance.DefaultConfig()) } if compat.HasSupport(instance, compat.Messenger) { + tokenPublic, _ := auth.GenerateApplicationToken() app := &model.Application{ - Token: auth.GenerateNotExistingToken(auth.GenerateApplicationToken, m.applicationExists), + Token: tokenPublic, Name: info.String(), UserID: userID, Internal: true, diff --git a/plugin/manager_test.go b/plugin/manager_test.go index 8d698966e..63811cffa 100644 --- a/plugin/manager_test.go +++ b/plugin/manager_test.go @@ -1,5 +1,4 @@ //go:build linux || darwin -// +build linux darwin package plugin @@ -306,13 +305,13 @@ func (s *ManagerSuite) TestRemoveUser_danglingConf_expectSuccess() { ModulePath: mockPluginPath, Enabled: true, UserID: 9, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + Token: auth.GeneratePluginToken(), }) s.db.CreatePluginConf(&model.PluginConf{ ModulePath: examplePluginPath, Enabled: true, UserID: 9, - Token: auth.GenerateNotExistingToken(auth.GeneratePluginToken, s.manager.pluginConfExists), + Token: auth.GeneratePluginToken(), }) assert.Nil(s.T(), s.manager.RemoveUser(9)) } diff --git a/plugin/manager_test_norace.go b/plugin/manager_test_norace.go index 5af219b62..3d92abd1f 100644 --- a/plugin/manager_test_norace.go +++ b/plugin/manager_test_norace.go @@ -1,5 +1,4 @@ //go:build !race -// +build !race package plugin diff --git a/plugin/manager_test_race.go b/plugin/manager_test_race.go index ff558d764..49d0a66ee 100644 --- a/plugin/manager_test_race.go +++ b/plugin/manager_test_race.go @@ -1,5 +1,4 @@ //go:build race -// +build race package plugin diff --git a/plugin/testing/broken/malformedconstructor/main.go b/plugin/testing/broken/malformedconstructor/main.go index c021cf377..f5f073e6f 100644 --- a/plugin/testing/broken/malformedconstructor/main.go +++ b/plugin/testing/broken/malformedconstructor/main.go @@ -25,7 +25,7 @@ func (c *Plugin) Disable() error { } // NewGotifyPluginInstance creates a plugin instance for a user context. -func NewGotifyPluginInstance(ctx plugin.UserContext) interface{} { +func NewGotifyPluginInstance(ctx plugin.UserContext) any { return &Plugin{} } diff --git a/plugin/testing/mock/mock.go b/plugin/testing/mock/mock.go index 10c748491..51c15d969 100644 --- a/plugin/testing/mock/mock.go +++ b/plugin/testing/mock/mock.go @@ -3,6 +3,7 @@ package mock import ( "errors" "net/url" + "slices" "github.com/gin-gonic/gin" @@ -119,10 +120,8 @@ func (c *PluginInstance) RegisterWebhook(basePath string, mux *gin.RouterGroup) // SetCapability changes the capability of this plugin func (c *PluginInstance) SetCapability(p compat.Capability, enable bool) { if enable { - for _, cap := range c.capabilities { - if cap == p { - return - } + if slices.Contains(c.capabilities, p) { + return } c.capabilities = append(c.capabilities, p) } else { @@ -143,7 +142,7 @@ func (c *PluginInstance) Supports() compat.Capabilities { } // DefaultConfig implements compat.Configuror -func (c *PluginInstance) DefaultConfig() interface{} { +func (c *PluginInstance) DefaultConfig() any { return &PluginConfig{ TestKey: "default", IsNotValid: false, @@ -151,7 +150,7 @@ func (c *PluginInstance) DefaultConfig() interface{} { } // ValidateAndSetConfig implements compat.Configuror -func (c *PluginInstance) ValidateAndSetConfig(config interface{}) error { +func (c *PluginInstance) ValidateAndSetConfig(config any) error { if (config.(*PluginConfig)).IsNotValid { return errors.New("conf is not valid") } @@ -170,7 +169,7 @@ func (c *PluginInstance) TriggerMessage() { Title: "test message", Message: "test", Priority: 2, - Extras: map[string]interface{}{ + Extras: map[string]any{ "test::string": "test", }, }) diff --git a/router/router_test.go b/router/router_test.go index 4ac7c1232..bf5d67dbb 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "log" "net/http" "net/http/httptest" "strings" @@ -332,6 +333,7 @@ func (s *IntegrationSuite) TestSendMessage() { token := &model.Application{} json.NewDecoder(res.Body).Decode(token) assert.Equal(s.T(), "backup-server", token.Name) + log.Printf("token: %s", token.Token) req = s.newRequest("POST", "message", `{"message": "backup done", "title": "backup done"}`) req.Header.Add("X-Gotify-Key", token.Token) diff --git a/runner/runner.go b/runner/runner.go index bee93dc87..32f90c77f 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -95,8 +95,8 @@ func startListening(connectionType, listenAddr string, port, keepAlive int) (net } func getNetworkAndAddr(listenAddr string, port int) (string, string) { - if strings.HasPrefix(listenAddr, "unix:") { - return "unix", strings.TrimPrefix(listenAddr, "unix:") + if after, ok := strings.CutPrefix(listenAddr, "unix:"); ok { + return "unix", after } return "tcp", fmt.Sprintf("%s:%d", listenAddr, port) } diff --git a/test/asserts.go b/test/asserts.go index 09042c090..1cb3b81de 100644 --- a/test/asserts.go +++ b/test/asserts.go @@ -11,7 +11,7 @@ import ( ) // BodyEquals asserts the content from the response recorder with the encoded json of the provided instance. -func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseRecorder) { +func BodyEquals(t assert.TestingT, obj any, recorder *httptest.ResponseRecorder) { bytes, err := io.ReadAll(recorder.Body) assert.Nil(t, err) actual := string(bytes) @@ -20,7 +20,7 @@ func BodyEquals(t assert.TestingT, obj interface{}, recorder *httptest.ResponseR } // JSONEquals asserts the content of the string with the encoded json of the provided instance. -func JSONEquals(t assert.TestingT, obj interface{}, expected string) { +func JSONEquals(t assert.TestingT, obj any, expected string) { bytes, err := json.Marshal(obj) assert.Nil(t, err) objJSON := string(bytes) diff --git a/test/asserts_test.go b/test/asserts_test.go index a018ab968..ec90f38ea 100644 --- a/test/asserts_test.go +++ b/test/asserts_test.go @@ -18,7 +18,7 @@ type fakeTesting struct { hasErrors bool } -func (t *fakeTesting) Errorf(format string, args ...interface{}) { +func (t *fakeTesting) Errorf(format string, args ...any) { t.hasErrors = true } diff --git a/test/token.go b/test/token.go deleted file mode 100644 index 64370f472..000000000 --- a/test/token.go +++ /dev/null @@ -1,16 +0,0 @@ -package test - -import "sync" - -// Tokens returns a token generation function with takes a series of tokens and output them in order. -func Tokens(tokens ...string) func() string { - var i int - lock := sync.Mutex{} - return func() string { - lock.Lock() - defer lock.Unlock() - res := tokens[i%len(tokens)] - i++ - return res - } -} diff --git a/test/token_test.go b/test/token_test.go deleted file mode 100644 index 000be3d5e..000000000 --- a/test/token_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package test - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTokenGeneration(t *testing.T) { - mockTokenFunc := Tokens("a", "b", "c") - - for _, expected := range []string{"a", "b", "c", "a", "b", "c"} { - assert.Equal(t, expected, mockTokenFunc()) - } -}