From 85b9051865197cc925bc4dff07344eebe82f1d93 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 10 Apr 2026 17:10:33 +0200 Subject: [PATCH 01/10] test: use iotest.ErrReader --- test/asserts.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/asserts.go b/test/asserts.go index 98d81646..09042c09 100644 --- a/test/asserts.go +++ b/test/asserts.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http/httptest" + "testing/iotest" "github.com/stretchr/testify/assert" ) @@ -27,13 +28,7 @@ func JSONEquals(t assert.TestingT, obj interface{}, expected string) { assert.JSONEq(t, expected, objJSON) } -type unreadableReader struct{} - -func (c unreadableReader) Read([]byte) (int, error) { - return 0, errors.New("this reader cannot be read") -} - // UnreadableReader returns an unreadable reader, used to mock IO issues. func UnreadableReader() io.Reader { - return unreadableReader{} + return iotest.ErrReader(errors.New("this reader cannot be read")) } From 681b9e2d90adb9c99661dd35c422afbbb54e29db Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 10 Apr 2026 17:20:31 +0200 Subject: [PATCH 02/10] fix: unify authentication context This uses a single struct for the authentication. This prevents further re-requesting of the already requested data. --- api/message.go | 2 +- api/message_test.go | 22 ++++++------ api/session_test.go | 3 +- api/stream/stream.go | 2 +- api/stream/stream_test.go | 12 +++---- api/user_test.go | 6 ++-- auth/authentication.go | 7 ++-- auth/util.go | 70 +++++++++++++++++++++++++++------------ auth/util_test.go | 41 +++++++++++++---------- test/auth.go | 4 +-- 10 files changed, 101 insertions(+), 68 deletions(-) diff --git a/api/message.go b/api/message.go index 3225c3d9..de92d275 100644 --- a/api/message.go +++ b/api/message.go @@ -364,7 +364,7 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message := model.MessageExternal{} if err := ctx.Bind(&message); err == nil { - application, err := a.DB.GetApplicationByToken(auth.GetTokenID(ctx)) + application, err := a.DB.GetApplicationByToken(auth.TryGetTokenID(ctx)) if success := successOrAbort(ctx, 500, err); !success { return } diff --git a/api/message_test.go b/api/message_test.go index 358732e2..fcf553ef 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -322,7 +322,7 @@ func (s *MessageSuite) Test_CreateMessage_onJson_allParams() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(7, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": 1}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -344,7 +344,7 @@ func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -365,7 +365,7 @@ func (s *MessageSuite) Test_CreateMessage_WithTitle() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(5, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -382,7 +382,7 @@ func (s *MessageSuite) Test_CreateMessage_WithTitle() { } func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(1, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle"}`)) @@ -398,7 +398,7 @@ func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() { } func (s *MessageSuite) Test_CreateMessage_WithoutTitle() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage"}`)) @@ -415,7 +415,7 @@ func (s *MessageSuite) Test_CreateMessage_WithoutTitle() { } func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": " "}`)) @@ -432,7 +432,7 @@ func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() { } func (s *MessageSuite) Test_CreateMessage_IgnoreID() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "id": 1337}`)) @@ -448,7 +448,7 @@ func (s *MessageSuite) Test_CreateMessage_IgnoreID() { } func (s *MessageSuite) Test_CreateMessage_WithExtras() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") t, _ := time.Parse("2006/01/02", "2017/01/02") @@ -487,7 +487,7 @@ func (s *MessageSuite) Test_CreateMessage_WithExtras() { } func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(8, "app-token") s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": "asd"}`)) @@ -503,7 +503,7 @@ func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() { } func (s *MessageSuite) Test_CreateMessage_onQueryData() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(2, "app-token") t, _ := time.Parse("2006/01/02", "2017/01/02") @@ -526,7 +526,7 @@ func (s *MessageSuite) Test_CreateMessage_onQueryData() { } func (s *MessageSuite) Test_CreateMessage_onFormData() { - auth.RegisterAuthentication(s.ctx, nil, 4, "app-token") + auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) s.db.User(4).AppWithToken(99, "app-token") t, _ := time.Parse("2006/01/02", "2017/01/02") diff --git a/api/session_test.go b/api/session_test.go index dfa29d62..2ca5d7f1 100644 --- a/api/session_test.go +++ b/api/session_test.go @@ -113,8 +113,7 @@ func (s *SessionSuite) Test_Logout_Success() { builder.ClientWithToken(1, "Ctesttoken12345") s.ctx.Request = httptest.NewRequest("POST", "/auth/logout", nil) - test.WithUser(s.ctx, 5) - s.ctx.Set("tokenid", "Ctesttoken12345") + auth.RegisterClient(s.ctx, &model.Client{UserID: 5, Token: "Ctesttoken12345"}) s.a.Logout(s.ctx) diff --git a/api/stream/stream.go b/api/stream/stream.go index c469a812..f784a1bf 100644 --- a/api/stream/stream.go +++ b/api/stream/stream.go @@ -147,7 +147,7 @@ func (a *API) Handle(ctx *gin.Context) { return } - client := newClient(conn, auth.GetUserID(ctx), auth.GetTokenID(ctx), a.remove) + client := newClient(conn, auth.GetUserID(ctx), auth.TryGetTokenID(ctx), a.remove) a.register(client) go client.startReading(a.pongTimeout) go client.startWriteHandler(a.pingPeriod) diff --git a/api/stream/stream_test.go b/api/stream/stream_test.go index 009b10ff..a15fd8d0 100644 --- a/api/stream/stream_test.go +++ b/api/stream/stream_test.go @@ -49,7 +49,7 @@ func TestWriteMessageFails(t *testing.T) { defer leaktest.Check(t)() server, api := bootTestServer(func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, 1, "") + auth.RegisterClient(context, &model.Client{UserID: 1}) }) defer server.Close() defer api.Close() @@ -206,7 +206,7 @@ func TestDeleteMultipleClients(t *testing.T) { tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"} i := 0 server, api := bootTestServer(func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) + auth.RegisterClient(context, &model.Client{UserID: userIDs[i], Token: tokens[i]}) i++ }) defer server.Close() @@ -269,7 +269,7 @@ func TestDeleteUser(t *testing.T) { tokens := []string{"1-1", "1-2", "1-2", "1-3", "2-1", "2-2", "3"} i := 0 server, api := bootTestServer(func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) + auth.RegisterClient(context, &model.Client{UserID: userIDs[i], Token: tokens[i]}) i++ }) defer server.Close() @@ -331,7 +331,7 @@ func TestCollectConnectedClientTokens(t *testing.T) { tokens := []string{"1-1", "1-2", "1-2", "2-1", "2-2"} i := 0 server, api := bootTestServer(func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, userIDs[i], tokens[i]) + auth.RegisterClient(context, &model.Client{UserID: userIDs[i], Token: tokens[i]}) i++ }) defer server.Close() @@ -367,7 +367,7 @@ func TestMultipleClients(t *testing.T) { userIDs := []uint{1, 1, 1, 2, 2, 3} i := 0 server, api := bootTestServer(func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, userIDs[i], "t"+fmt.Sprint(userIDs[i])) + auth.RegisterClient(context, &model.Client{UserID: userIDs[i], Token: "t" + fmt.Sprint(userIDs[i])}) i++ }) defer server.Close() @@ -605,7 +605,7 @@ func wsURL(httpURL string) string { func staticUserID() gin.HandlerFunc { return func(context *gin.Context) { - auth.RegisterAuthentication(context, nil, 1, "customtoken") + auth.RegisterClient(context, &model.Client{UserID: 1, Token: "customtoken"}) } } diff --git a/api/user_test.go b/api/user_test.go index 119ca6d3..f9a8feee 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -426,16 +426,16 @@ func (s *UserSuite) Test_UpdatePassword_EmptyPassword() { func (s *UserSuite) loginAdmin() { s.db.CreateUser(&model.User{ID: 1, Name: "admin", Admin: true}) - auth.RegisterAuthentication(s.ctx, nil, 1, "") + auth.RegisterUser(s.ctx, &model.User{ID: 1, Admin: true}) } func (s *UserSuite) loginUser() { s.db.CreateUser(&model.User{ID: 1, Name: "user", Admin: false}) - auth.RegisterAuthentication(s.ctx, nil, 1, "") + auth.RegisterUser(s.ctx, &model.User{ID: 1}) } func (s *UserSuite) noLogin() { - auth.RegisterAuthentication(s.ctx, nil, 0, "") + // No authentication is registered } func externalOf(user *model.User) *model.UserExternal { diff --git a/auth/authentication.go b/auth/authentication.go index df84f6c5..1dcd67de 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -70,7 +70,6 @@ func (a *Auth) RequireApplicationToken(ctx *gin.Context) { func (a *Auth) Optional(ctx *gin.Context) { if !a.evaluate(ctx, a.user(false), a.client(false)) { - RegisterAuthentication(ctx, nil, 0, "") ctx.Next() } } @@ -116,7 +115,7 @@ func (a *Auth) user(requireAdmin bool) func(ctx *gin.Context) (authState, error) if user, err := a.DB.GetUserByName(name); err != nil { return authStateSkip, err } else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) { - RegisterAuthentication(ctx, user, user.ID, "") + RegisterUser(ctx, user) if requireAdmin && !user.Admin { return authStateForbidden, nil @@ -141,7 +140,7 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro if client == nil { return authStateSkip, nil } - RegisterAuthentication(ctx, nil, client.UserID, client.Token) + RegisterClient(ctx, client) now := time.Now() if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { @@ -177,7 +176,7 @@ func (a *Auth) application(ctx *gin.Context) (authState, error) { if app == nil { return authStateSkip, nil } - RegisterAuthentication(ctx, nil, app.UserID, app.Token) + RegisterApplication(ctx, app) now := time.Now() if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { diff --git a/auth/util.go b/auth/util.go index 6f380e12..6ca614d8 100644 --- a/auth/util.go +++ b/auth/util.go @@ -5,14 +5,37 @@ import ( "github.com/gotify/server/v2/model" ) -// RegisterAuthentication registers the user id, user and or token. -func RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tokenID string) { - ctx.Set("user", user) - ctx.Set("userid", userID) - ctx.Set("tokenid", tokenID) +const authKey = "auth" + +type authentication struct { + client *model.Client + app *model.Application + user *model.User +} + +// RegisterUser stores the authenticated user on the gin context. +func RegisterUser(ctx *gin.Context, user *model.User) { + ctx.Set(authKey, &authentication{user: user}) +} + +// RegisterClient stores the authenticated client on the gin context. +func RegisterClient(ctx *gin.Context, client *model.Client) { + ctx.Set(authKey, &authentication{client: client}) +} + +// RegisterApplication stores the authenticated application on the gin context. +func RegisterApplication(ctx *gin.Context, app *model.Application) { + ctx.Set(authKey, &authentication{app: app}) } -// GetUserID returns the user id which was previously registered by RegisterAuthentication. +func getInfo(ctx *gin.Context) *authentication { + if v, ok := ctx.Get(authKey); ok { + return v.(*authentication) + } + return &authentication{} +} + +// GetUserID returns the user id which was previously registered by one of the Register* functions. func GetUserID(ctx *gin.Context) uint { id := TryGetUserID(ctx) if id == nil { @@ -23,23 +46,28 @@ func GetUserID(ctx *gin.Context) uint { // TryGetUserID returns the user id or nil if one is not set. func TryGetUserID(ctx *gin.Context) *uint { - user := ctx.MustGet("user").(*model.User) - if user == nil { - userID := ctx.MustGet("userid").(uint) - if userID == 0 { - return nil - } - return &userID + info := getInfo(ctx) + switch { + case info.user != nil: + return &info.user.ID + case info.client != nil: + return &info.client.UserID + case info.app != nil: + return &info.app.UserID + default: + return nil } - - return &user.ID -} - -// GetTokenID returns the tokenID. -func GetTokenID(ctx *gin.Context) string { - return ctx.MustGet("tokenid").(string) } +// TryGetTokenID returns the tokenID or an empty string if no token-based +// authentication was registered. func TryGetTokenID(ctx *gin.Context) string { - return ctx.GetString("tokenid") + info := getInfo(ctx) + switch { + case info.client != nil: + return info.client.Token + case info.app != nil: + return info.app.Token + } + return "" } diff --git a/auth/util_test.go b/auth/util_test.go index b42aef16..c09f4625 100644 --- a/auth/util_test.go +++ b/auth/util_test.go @@ -23,32 +23,39 @@ func (s *UtilSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) } -func (s *UtilSuite) Test_getID() { - s.expectUserIDWith(&model.User{ID: 2}, 0, 2) - s.expectUserIDWith(nil, 5, 5) +func (s *UtilSuite) Test_getUserID() { + s.expectUserID(func(ctx *gin.Context) { RegisterUser(ctx, &model.User{ID: 2}) }, 2) + s.expectUserID(func(ctx *gin.Context) { RegisterClient(ctx, &model.Client{UserID: 5}) }, 5) + s.expectUserID(func(ctx *gin.Context) { RegisterApplication(ctx, &model.Application{UserID: 7}) }, 7) + assert.Panics(s.T(), func() { - s.expectUserIDWith(nil, 0, 0) + s.expectUserID(func(ctx *gin.Context) {}, 0) }) - s.expectTryUserIDWith(nil, 0, nil) + + s.expectTryUserID(func(ctx *gin.Context) {}, nil) +} + +func (s *UtilSuite) Test_getTokenID() { + s.expectTokenID(func(ctx *gin.Context) { RegisterClient(ctx, &model.Client{Token: "ctoken"}) }, "ctoken") + s.expectTokenID(func(ctx *gin.Context) { RegisterApplication(ctx, &model.Application{Token: "atoken"}) }, "atoken") + s.expectTokenID(func(ctx *gin.Context) { RegisterUser(ctx, &model.User{ID: 1}) }, "") + s.expectTokenID(func(ctx *gin.Context) {}, "") } -func (s *UtilSuite) Test_getToken() { +func (s *UtilSuite) expectUserID(register func(*gin.Context), expectedID uint) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - RegisterAuthentication(ctx, nil, 1, "asdasda") - actualID := GetTokenID(ctx) - assert.Equal(s.T(), "asdasda", actualID) + register(ctx) + assert.Equal(s.T(), expectedID, GetUserID(ctx)) } -func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) { +func (s *UtilSuite) expectTryUserID(register func(*gin.Context), expectedID *uint) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - RegisterAuthentication(ctx, user, tokenUserID, "") - actualID := GetUserID(ctx) - assert.Equal(s.T(), expectedID, actualID) + register(ctx) + assert.Equal(s.T(), expectedID, TryGetUserID(ctx)) } -func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) { +func (s *UtilSuite) expectTokenID(register func(*gin.Context), expectedToken string) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - RegisterAuthentication(ctx, user, tokenUserID, "") - actualID := TryGetUserID(ctx) - assert.Equal(s.T(), expectedID, actualID) + register(ctx) + assert.Equal(s.T(), expectedToken, TryGetTokenID(ctx)) } diff --git a/test/auth.go b/test/auth.go index fbb2e6ce..d946a72f 100644 --- a/test/auth.go +++ b/test/auth.go @@ -2,11 +2,11 @@ package test import ( "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" "github.com/gotify/server/v2/model" ) // WithUser fake an authentication for testing. func WithUser(ctx *gin.Context, userID uint) { - ctx.Set("user", &model.User{ID: userID}) - ctx.Set("userid", userID) + auth.RegisterUser(ctx, &model.User{ID: userID}) } From 410571dd18e26e72dcddd18d1f5f1f6bcb0759c5 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Fri, 10 Apr 2026 17:30:38 +0200 Subject: [PATCH 03/10] fix: prevent duplicate requests for apps / clients --- api/message.go | 6 +----- api/message_test.go | 33 +++++++++++---------------------- api/session.go | 13 ++----------- api/session_test.go | 5 ++--- api/stream/stream.go | 6 +++++- auth/token_test.go | 5 +++-- auth/util.go | 20 +++++++++----------- auth/util_test.go | 37 ++++++++++++++++++++++++++----------- test/testdb/database.go | 8 +++++++- 9 files changed, 66 insertions(+), 67 deletions(-) diff --git a/api/message.go b/api/message.go index de92d275..3ebf6afd 100644 --- a/api/message.go +++ b/api/message.go @@ -24,7 +24,6 @@ type MessageDatabase interface { DeleteMessagesByUser(userID uint) error DeleteMessagesByApplication(applicationID uint) error CreateMessage(message *model.Message) error - GetApplicationByToken(token string) (*model.Application, error) } var timeNow = time.Now @@ -364,10 +363,7 @@ func (a *MessageAPI) DeleteMessage(ctx *gin.Context) { func (a *MessageAPI) CreateMessage(ctx *gin.Context) { message := model.MessageExternal{} if err := ctx.Bind(&message); err == nil { - application, err := a.DB.GetApplicationByToken(auth.TryGetTokenID(ctx)) - if success := successOrAbort(ctx, 500, err); !success { - return - } + application := auth.GetApplication(ctx) message.ApplicationID = application.ID if strings.TrimSpace(message.Title) == "" { message.Title = application.Name diff --git a/api/message_test.go b/api/message_test.go index fcf553ef..ce9eb6ff 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -322,8 +322,7 @@ func (s *MessageSuite) Test_CreateMessage_onJson_allParams() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(7, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(7, "app-token")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": 1}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -344,8 +343,7 @@ func (s *MessageSuite) Test_CreateMessage_WithDefaultPriority() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithTokenAndDefaultPriority(8, "app-token", 5) + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithTokenAndDefaultPriority(8, "app-token", 5)) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -365,8 +363,7 @@ func (s *MessageSuite) Test_CreateMessage_WithTitle() { timeNow = func() time.Time { return t } defer func() { timeNow = time.Now }() - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(5, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(5, "app-token")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -382,8 +379,7 @@ func (s *MessageSuite) Test_CreateMessage_WithTitle() { } func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(1, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(1, "app-token")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -398,8 +394,7 @@ func (s *MessageSuite) Test_CreateMessage_failWhenNoMessage() { } func (s *MessageSuite) Test_CreateMessage_WithoutTitle() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithTokenAndName(8, "app-token", "Application name")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -415,8 +410,7 @@ func (s *MessageSuite) Test_CreateMessage_WithoutTitle() { } func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithTokenAndName(8, "app-token", "Application name")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "title": " "}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -432,8 +426,7 @@ func (s *MessageSuite) Test_CreateMessage_WithBlankTitle() { } func (s *MessageSuite) Test_CreateMessage_IgnoreID() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithTokenAndName(8, "app-token", "Application name")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"message": "mymessage", "id": 1337}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -448,8 +441,7 @@ func (s *MessageSuite) Test_CreateMessage_IgnoreID() { } func (s *MessageSuite) Test_CreateMessage_WithExtras() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithTokenAndName(8, "app-token", "Application name") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithTokenAndName(8, "app-token", "Application name")) t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } @@ -487,8 +479,7 @@ func (s *MessageSuite) Test_CreateMessage_WithExtras() { } func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(8, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(8, "app-token")) s.ctx.Request = httptest.NewRequest("POST", "/message", strings.NewReader(`{"title": "mytitle", "message": "mymessage", "priority": "asd"}`)) s.ctx.Request.Header.Set("Content-Type", "application/json") @@ -503,8 +494,7 @@ func (s *MessageSuite) Test_CreateMessage_failWhenPriorityNotNumber() { } func (s *MessageSuite) Test_CreateMessage_onQueryData() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(2, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(2, "app-token")) t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } @@ -526,8 +516,7 @@ func (s *MessageSuite) Test_CreateMessage_onQueryData() { } func (s *MessageSuite) Test_CreateMessage_onFormData() { - auth.RegisterApplication(s.ctx, &model.Application{UserID: 4, Token: "app-token"}) - s.db.User(4).AppWithToken(99, "app-token") + auth.RegisterApplication(s.ctx, s.db.User(4).NewAppWithToken(99, "app-token")) t, _ := time.Parse("2006/01/02", "2017/01/02") timeNow = func() time.Time { return t } diff --git a/api/session.go b/api/session.go index 1296fd47..91d2994c 100644 --- a/api/session.go +++ b/api/session.go @@ -118,18 +118,9 @@ func (a *SessionAPI) Login(ctx *gin.Context) { func (a *SessionAPI) Logout(ctx *gin.Context) { auth.SetCookie(ctx.Writer, "", -1, a.SecureCookie) - tokenID := auth.TryGetTokenID(ctx) - if tokenID == "" { - ctx.AbortWithError(400, errors.New("no client auth provided")) - return - } - client, err := a.DB.GetClientByToken(tokenID) - if err != nil { - ctx.AbortWithError(500, err) - return - } + client := auth.GetClient(ctx) if client == nil { - ctx.Status(200) + ctx.AbortWithError(403, errors.New("no client auth provided")) return } diff --git a/api/session_test.go b/api/session_test.go index 2ca5d7f1..e69423cb 100644 --- a/api/session_test.go +++ b/api/session_test.go @@ -109,11 +109,10 @@ func (s *SessionSuite) Test_Login_WrongPassword() { } func (s *SessionSuite) Test_Logout_Success() { - builder := s.db.User(5) - builder.ClientWithToken(1, "Ctesttoken12345") + client := s.db.User(5).NewClientWithToken(1, "Ctesttoken12345") s.ctx.Request = httptest.NewRequest("POST", "/auth/logout", nil) - auth.RegisterClient(s.ctx, &model.Client{UserID: 5, Token: "Ctesttoken12345"}) + auth.RegisterClient(s.ctx, client) s.a.Logout(s.ctx) diff --git a/api/stream/stream.go b/api/stream/stream.go index f784a1bf..384e538b 100644 --- a/api/stream/stream.go +++ b/api/stream/stream.go @@ -147,7 +147,11 @@ func (a *API) Handle(ctx *gin.Context) { return } - client := newClient(conn, auth.GetUserID(ctx), auth.TryGetTokenID(ctx), a.remove) + var token string + if c := auth.GetClient(ctx); c != nil { + token = c.Token + } + client := newClient(conn, auth.GetUserID(ctx), token, a.remove) a.register(client) go client.startReading(a.pongTimeout) go client.startWriteHandler(a.pingPeriod) diff --git a/auth/token_test.go b/auth/token_test.go index 8ad21c7c..2b7c1ccc 100644 --- a/auth/token_test.go +++ b/auth/token_test.go @@ -2,11 +2,12 @@ package auth import ( "crypto/rand" + "errors" "fmt" "strings" "testing" + "testing/iotest" - "github.com/gotify/server/v2/test" "github.com/stretchr/testify/assert" ) @@ -32,7 +33,7 @@ func TestGenerateNotExistingToken(t *testing.T) { func TestBadCryptoReaderPanics(t *testing.T) { assert.Panics(t, func() { - randReader = test.UnreadableReader() + randReader = iotest.ErrReader(errors.New("this reader cannot be read")) defer func() { randReader = rand.Reader }() diff --git a/auth/util.go b/auth/util.go index 6ca614d8..9aa6c543 100644 --- a/auth/util.go +++ b/auth/util.go @@ -59,15 +59,13 @@ func TryGetUserID(ctx *gin.Context) *uint { } } -// TryGetTokenID returns the tokenID or an empty string if no token-based -// authentication was registered. -func TryGetTokenID(ctx *gin.Context) string { - info := getInfo(ctx) - switch { - case info.client != nil: - return info.client.Token - case info.app != nil: - return info.app.Token - } - return "" +// GetApplication returns the authenticated application or nil if no application +// was registered. +func GetApplication(ctx *gin.Context) *model.Application { + return getInfo(ctx).app +} + +// GetClient returns the authenticated client or nil if no client was registered. +func GetClient(ctx *gin.Context) *model.Client { + return getInfo(ctx).client } diff --git a/auth/util_test.go b/auth/util_test.go index c09f4625..12f5a01c 100644 --- a/auth/util_test.go +++ b/auth/util_test.go @@ -35,11 +35,32 @@ func (s *UtilSuite) Test_getUserID() { s.expectTryUserID(func(ctx *gin.Context) {}, nil) } -func (s *UtilSuite) Test_getTokenID() { - s.expectTokenID(func(ctx *gin.Context) { RegisterClient(ctx, &model.Client{Token: "ctoken"}) }, "ctoken") - s.expectTokenID(func(ctx *gin.Context) { RegisterApplication(ctx, &model.Application{Token: "atoken"}) }, "atoken") - s.expectTokenID(func(ctx *gin.Context) { RegisterUser(ctx, &model.User{ID: 1}) }, "") - s.expectTokenID(func(ctx *gin.Context) {}, "") +func (s *UtilSuite) Test_GetApplication() { + app := &model.Application{Token: "atoken"} + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + RegisterApplication(ctx, app) + assert.Same(s.T(), app, GetApplication(ctx)) + + ctx, _ = gin.CreateTestContext(httptest.NewRecorder()) + RegisterUser(ctx, &model.User{ID: 1}) + assert.Nil(s.T(), GetApplication(ctx)) + + ctx, _ = gin.CreateTestContext(httptest.NewRecorder()) + assert.Nil(s.T(), GetApplication(ctx)) +} + +func (s *UtilSuite) Test_GetClient() { + client := &model.Client{Token: "ctoken"} + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + RegisterClient(ctx, client) + assert.Same(s.T(), client, GetClient(ctx)) + + ctx, _ = gin.CreateTestContext(httptest.NewRecorder()) + RegisterUser(ctx, &model.User{ID: 1}) + assert.Nil(s.T(), GetClient(ctx)) + + ctx, _ = gin.CreateTestContext(httptest.NewRecorder()) + assert.Nil(s.T(), GetClient(ctx)) } func (s *UtilSuite) expectUserID(register func(*gin.Context), expectedID uint) { @@ -53,9 +74,3 @@ func (s *UtilSuite) expectTryUserID(register func(*gin.Context), expectedID *uin register(ctx) assert.Equal(s.T(), expectedID, TryGetUserID(ctx)) } - -func (s *UtilSuite) expectTokenID(register func(*gin.Context), expectedToken string) { - ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) - register(ctx) - assert.Equal(s.T(), expectedToken, TryGetTokenID(ctx)) -} diff --git a/test/testdb/database.go b/test/testdb/database.go index 29f8cfdd..f42a8df1 100644 --- a/test/testdb/database.go +++ b/test/testdb/database.go @@ -140,9 +140,15 @@ func (ab *AppClientBuilder) newAppWithTokenAndName(id uint, token, name string, // AppWithTokenAndDefaultPriority creates an application with a token and defaultPriority and returns a message builder. func (ab *AppClientBuilder) AppWithTokenAndDefaultPriority(id uint, token string, defaultPriority int) *MessageBuilder { + ab.NewAppWithTokenAndDefaultPriority(id, token, defaultPriority) + return &MessageBuilder{db: ab.db, appID: id} +} + +// NewAppWithTokenAndDefaultPriority creates an application with a token and defaultPriority and returns the app. +func (ab *AppClientBuilder) NewAppWithTokenAndDefaultPriority(id uint, token string, defaultPriority int) *model.Application { application := &model.Application{ID: id, UserID: ab.userID, Token: token, DefaultPriority: defaultPriority} ab.db.CreateApplication(application) - return &MessageBuilder{db: ab.db, appID: id} + return application } // Client creates a client and returns itself. From 58677b32eff56f6485c3724e5d2761c07dca26a4 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 11 Apr 2026 21:37:10 +0200 Subject: [PATCH 04/10] fix: add client elevatedUntil --- api/client.go | 58 +++++++++++++++++++ api/client_test.go | 74 ++++++++++++++++++++++++ api/oidc.go | 85 ++++++++++++++++++++++++++++ database/client.go | 5 ++ docs/spec.json | 136 +++++++++++++++++++++++++++++++++++++++++++++ model/client.go | 4 ++ model/elevate.go | 17 ++++++ router/router.go | 2 + 8 files changed, 381 insertions(+) create mode 100644 model/elevate.go diff --git a/api/client.go b/api/client.go index 865d74dc..3736f316 100644 --- a/api/client.go +++ b/api/client.go @@ -1,7 +1,9 @@ package api import ( + "errors" "fmt" + "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" @@ -16,6 +18,7 @@ type ClientDatabase interface { GetClientsByUser(userID uint) ([]*model.Client, error) DeleteClientByID(id uint) error UpdateClient(client *model.Client) error + UpdateClientElevatedUntil(id uint, t *time.Time) error } // The ClientAPI provides handlers for managing clients and applications. @@ -235,6 +238,61 @@ func (a *ClientAPI) DeleteClient(ctx *gin.Context) { }) } +// swagger:operation POST /client:elevate client elevateClient +// +// Elevate a client session. +// +// --- +// consumes: [application/json] +// produces: [application/json] +// parameters: +// - name: body +// in: body +// description: the elevation request +// required: true +// schema: +// $ref: "#/definitions/ElevateRequest" +// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] +// responses: +// 204: +// description: Ok +// 400: +// description: Bad Request +// schema: +// $ref: "#/definitions/Error" +// 401: +// description: Unauthorized +// schema: +// $ref: "#/definitions/Error" +// 404: +// description: Not Found +// schema: +// $ref: "#/definitions/Error" +func (a *ClientAPI) ElevateClient(ctx *gin.Context) { + var params model.ElevateRequest + if err := ctx.Bind(¶ms); err != nil { + return + } + + client, err := a.DB.GetClientByID(params.ID) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if client == nil || client.UserID != auth.GetUserID(ctx) { + ctx.AbortWithError(404, errors.New("client not found")) + return + } + + elevatedUntil := time.Now().Add(time.Duration(params.DurationSeconds) * time.Second) + if err := a.DB.UpdateClientElevatedUntil(client.ID, &elevatedUntil); err != nil { + ctx.AbortWithError(500, err) + return + } + + 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 e2b3f28a..0d735155 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -5,6 +5,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/mode" @@ -223,11 +224,84 @@ func (s *ClientSuite) Test_UpdateClient_WithMissingAttributes_expectBadRequest() assert.Equal(s.T(), 400, s.recorder.Code) } +func (s *ClientSuite) Test_ElevateClient_expectSuccess() { + s.db.User(5).Client(8) + + test.WithUser(s.ctx, 5) + s.withJSONBody(`{"id":8,"durationSeconds":900}`) + + before := time.Now() + s.a.ElevateClient(s.ctx) + after := time.Now() + + assert.Equal(s.T(), 204, s.ctx.Writer.Status()) + client, err := s.db.GetClientByID(8) + assert.NoError(s.T(), err) + assert.NotNil(s.T(), client.ElevatedUntil) + assert.WithinRange(s.T(), *client.ElevatedUntil, before.Add(15*time.Minute), after.Add(15*time.Minute)) +} + +func (s *ClientSuite) Test_ElevateClient_expectNotFoundOnMissingClient() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withJSONBody(`{"id":8,"durationSeconds":900}`) + + s.a.ElevateClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *ClientSuite) Test_ElevateClient_expectNotFoundOnCurrentUserIsNotOwner() { + s.db.User(5).Client(8) + s.db.User(2) + + test.WithUser(s.ctx, 2) + s.withJSONBody(`{"id":8,"durationSeconds":900}`) + + s.a.ElevateClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + client, err := s.db.GetClientByID(8) + assert.NoError(s.T(), err) + assert.Nil(s.T(), client.ElevatedUntil) +} + +func (s *ClientSuite) Test_ElevateClient_expectBadRequestOnMissingID() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withJSONBody(`{"durationSeconds":900}`) + + s.a.ElevateClient(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *ClientSuite) Test_ElevateClient_expectBadRequestOnMissingDuration() { + s.db.User(5).Client(8) + + test.WithUser(s.ctx, 5) + s.withJSONBody(`{"id":8}`) + + s.a.ElevateClient(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + client, err := s.db.GetClientByID(8) + assert.NoError(s.T(), err) + assert.Nil(s.T(), client.ElevatedUntil) +} + func (s *ClientSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } +func (s *ClientSuite) withJSONBody(body string) { + s.ctx.Request = httptest.NewRequest("POST", "/client:elevate", strings.NewReader(body)) + s.ctx.Request.Header.Set("Content-Type", "application/json") +} + func withURL(ctx *gin.Context, scheme, host string) { ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) } diff --git a/api/oidc.go b/api/oidc.go index 002fcb16..da2cfd6b 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "log" "net/http" "time" @@ -70,6 +71,7 @@ type pendingOIDCSession struct { RedirectURI string ClientName string CreatedAt time.Time + Elevate *model.ElevateRequest } // OIDCAPI provides handlers for OIDC authentication. @@ -122,6 +124,48 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc { }) } +// swagger:operation GET /auth/oidc/elevate oidc oidcElevate +// +// Start the OIDC flow to elevate an existing client session (browser). +// +// Redirects the user to the OIDC provider's authorization endpoint. After +// successful authentication, the referenced client session is elevated for +// the requested duration. +// +// --- +// parameters: +// - name: id +// in: query +// description: the client id to elevate +// required: true +// type: integer +// format: int64 +// - name: durationSeconds +// in: query +// description: how long the elevation should last, in seconds +// required: true +// type: integer +// responses: +// 302: +// description: Redirect to OIDC provider +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) { + var elevate model.ElevateRequest + if err := ctx.BindQuery(&elevate); err != nil { + return + } + state, err := a.generateState() + if err != nil { + ctx.AbortWithError(http.StatusInternalServerError, err) + return + } + a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{CreatedAt: time.Now(), Elevate: &elevate}) + rp.AuthURLHandler(func() string { return state }, a.Provider)(ctx.Writer, ctx.Request) +} + // swagger:operation GET /auth/oidc/callback oidc oidcCallback // // Handle the OIDC provider callback (browser). @@ -142,6 +186,8 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc { // required: true // type: string // responses: +// 200: +// description: ok // 307: // description: Redirect to UI // default: @@ -160,6 +206,12 @@ func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { http.Error(w, "unknown or expired state", http.StatusBadRequest) return } + + if session.Elevate != nil { + a.handleElevationCallback(w, session.Elevate, user) + return + } + client, err := a.createClient(session.ClientName, user.ID) if err != nil { http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError) @@ -175,6 +227,39 @@ func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider)) } +func (a *OIDCAPI) handleElevationCallback(w http.ResponseWriter, elevate *model.ElevateRequest, user *model.User) { + client, err := a.DB.GetClientByID(elevate.ID) + if err != nil { + http.Error(w, fmt.Sprintf("database error: %v", err), http.StatusInternalServerError) + return + } + if client == nil || client.UserID != user.ID { + http.Error(w, "client not found", http.StatusNotFound) + return + } + elevatedUntil := time.Now().Add(time.Duration(elevate.DurationSeconds) * time.Second) + if err := a.DB.UpdateClientElevatedUntil(client.ID, &elevatedUntil); err != nil { + http.Error(w, fmt.Sprintf("failed to elevate session: %v", err), http.StatusInternalServerError) + return + } + + // The UI rechecks the authentication when the tab is closed. + w.WriteHeader(http.StatusOK) + w.Header().Add("content-type", "text/html") + io.WriteString(w, ` + + + Gotify Session Elevation + + + + +

Gotify session elevation successful. Close this tab to continue.

+ + +`) +} + // swagger:operation POST /auth/oidc/external/authorize oidc externalAuthorize // // Initiate the OIDC authorization flow for a native app. diff --git a/database/client.go b/database/client.go index f85c4948..51946108 100644 --- a/database/client.go +++ b/database/client.go @@ -62,3 +62,8 @@ func (d *GormDatabase) UpdateClient(client *model.Client) error { func (d *GormDatabase) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error { return d.DB.Model(&model.Client{}).Where("token IN (?)", tokens).Update("last_used", t).Error } + +// UpdateClientElevatedUntil updates the elevated_until timestamp of a client by token. +func (d *GormDatabase) UpdateClientElevatedUntil(id uint, t *time.Time) error { + return d.DB.Model(&model.Client{}).Where("id = ?", id).Update("elevated_until", t).Error +} diff --git a/docs/spec.json b/docs/spec.json index 2e6db5a0..b1a24a76 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -702,6 +702,9 @@ } ], "responses": { + "200": { + "description": "ok" + }, "307": { "description": "Redirect to UI" }, @@ -714,6 +717,44 @@ } } }, + "/auth/oidc/elevate": { + "get": { + "description": "Redirects the user to the OIDC provider's authorization endpoint. After\nsuccessful authentication, the referenced client session is elevated for\nthe requested duration.", + "tags": [ + "oidc" + ], + "summary": "Start the OIDC flow to elevate an existing client session (browser).", + "operationId": "oidcElevate", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "the client id to elevate", + "name": "id", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "how long the elevation should last, in seconds", + "name": "durationSeconds", + "in": "query", + "required": true + } + ], + "responses": { + "302": { + "description": "Redirect to OIDC provider" + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/auth/oidc/external/authorize": { "post": { "description": "The app generates a PKCE code_verifier and code_challenge, then calls this\nendpoint. The server forwards the code_challenge to the OIDC provider and\nreturns the authorization URL for the app to open in a browser.", @@ -1086,6 +1127,69 @@ } } }, + "/client:elevate": { + "post": { + "security": [ + { + "clientTokenAuthorizationHeader": [] + }, + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "client" + ], + "summary": "Elevate a client session.", + "operationId": "elevateClient", + "parameters": [ + { + "description": "the elevation request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ElevateRequest" + } + } + ], + "responses": { + "204": { + "description": "Ok" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/current/user": { "get": { "security": [ @@ -2432,6 +2536,13 @@ "name" ], "properties": { + "elevatedUntil": { + "description": "The time until which this client's session is elevated.", + "type": "string", + "format": "date-time", + "x-go-name": "ElevatedUntil", + "readOnly": true + }, "id": { "description": "The client id.", "type": "integer", @@ -2512,6 +2623,31 @@ }, "x-go-package": "github.com/gotify/server/v2/model" }, + "ElevateRequest": { + "type": "object", + "title": "ElevateRequest parameters for client elevation.", + "required": [ + "id", + "durationSeconds" + ], + "properties": { + "durationSeconds": { + "description": "How long the elevation should last, in seconds.", + "type": "integer", + "format": "int64", + "x-go-name": "DurationSeconds", + "example": 900 + }, + "id": { + "description": "The client ID to elevate.", + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "example": 5 + } + }, + "x-go-package": "github.com/gotify/server/v2/model" + }, "Error": { "description": "The Error contains error relevant information.", "type": "object", diff --git a/model/client.go b/model/client.go index 9b96c82a..674be278 100644 --- a/model/client.go +++ b/model/client.go @@ -31,4 +31,8 @@ type Client struct { // read only: true // example: 2019-01-01T00:00:00Z LastUsed *time.Time `json:"lastUsed"` + // The time until which this client's session is elevated. + // + // read only: true + ElevatedUntil *time.Time `json:"elevatedUntil,omitempty"` } diff --git a/model/elevate.go b/model/elevate.go new file mode 100644 index 00000000..8ffd688b --- /dev/null +++ b/model/elevate.go @@ -0,0 +1,17 @@ +package model + +// ElevateRequest parameters for client elevation. +// +// swagger:model ElevateRequest +type ElevateRequest struct { + // The client ID to elevate. + // + // required: true + // example: 5 + ID uint `form:"id" query:"id" json:"id" binding:"required"` + // How long the elevation should last, in seconds. + // + // required: true + // example: 900 + DurationSeconds int `form:"durationSeconds" query:"durationSeconds" json:"durationSeconds" binding:"required"` +} diff --git a/router/router.go b/router/router.go index de05b1cc..f13bc2e3 100644 --- a/router/router.go +++ b/router/router.go @@ -113,6 +113,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co oidcGroup.GET("/callback", oidcHandler.CallbackHandler()) oidcGroup.POST("/external/authorize", oidcHandler.ExternalAuthorizeHandler) oidcGroup.POST("/external/token", oidcHandler.ExternalTokenHandler) + oidcGroup.GET("/elevate", oidcHandler.ElevateHandler) } g.Match([]string{"GET", "HEAD"}, "/health", healthHandler.Health) @@ -214,6 +215,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co client.PUT("/:id", clientHandler.UpdateClient) } + client.POST("/client:elevate", clientHandler.ElevateClient) message := clientAuth.Group("/message") { From c256025b9f98a11aa1c85decb3cbd1c0a787f601 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 11 Apr 2026 21:40:25 +0200 Subject: [PATCH 05/10] fix: return client id and elevatedUntil on /current/user --- api/session.go | 12 ++++++----- api/user.go | 12 ++++++++++- docs/spec.json | 50 ++++++++++++++++++++++++++++++++++++++++++- model/user.go | 33 ++++++++++++++++++++++++++++ ui/src/CurrentUser.ts | 8 +++---- ui/src/types.ts | 5 +++++ 6 files changed, 109 insertions(+), 11 deletions(-) diff --git a/api/session.go b/api/session.go index 91d2994c..432bc005 100644 --- a/api/session.go +++ b/api/session.go @@ -43,7 +43,7 @@ type SessionAPI struct { // 200: // description: Ok // schema: -// $ref: "#/definitions/UserExternal" +// $ref: "#/definitions/CurrentUser" // headers: // Set-Cookie: // type: string @@ -85,10 +85,12 @@ func (a *SessionAPI) Login(ctx *gin.Context) { auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge, a.SecureCookie) - ctx.JSON(200, &model.UserExternal{ - ID: user.ID, - Name: user.Name, - Admin: user.Admin, + ctx.JSON(200, &model.CurrentUserExternal{ + ID: user.ID, + Name: user.Name, + Admin: user.Admin, + ClientID: client.ID, + ElevatedUntil: client.ElevatedUntil, }) } diff --git a/api/user.go b/api/user.go index 02d9aab3..61c0966b 100644 --- a/api/user.go +++ b/api/user.go @@ -126,7 +126,17 @@ func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { if success := successOrAbort(ctx, 500, err); !success { return } - ctx.JSON(200, toExternalUser(user)) + result := &model.CurrentUserExternal{ + ID: user.ID, + Name: user.Name, + Admin: user.Admin, + } + client := auth.GetClient(ctx) + if client != nil { + result.ClientID = client.ID + result.ElevatedUntil = client.ElevatedUntil + } + ctx.JSON(200, result) } // CreateUser create a user. diff --git a/docs/spec.json b/docs/spec.json index b1a24a76..01c30864 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -618,7 +618,7 @@ "200": { "description": "Ok", "schema": { - "$ref": "#/definitions/UserExternal" + "$ref": "#/definitions/CurrentUser" }, "headers": { "Set-Cookie": { @@ -2623,6 +2623,54 @@ }, "x-go-package": "github.com/gotify/server/v2/model" }, + "CurrentUser": { + "description": "CurrentUserExternal Model", + "type": "object", + "required": [ + "id", + "name", + "admin" + ], + "properties": { + "admin": { + "description": "If the user is an administrator.", + "type": "boolean", + "x-go-name": "Admin", + "example": true + }, + "clientId": { + "description": "The client id of the current session.", + "type": "integer", + "format": "int64", + "x-go-name": "ClientID", + "readOnly": true, + "example": 5 + }, + "elevatedUntil": { + "description": "The time until which the session is elevated.", + "type": "string", + "format": "date-time", + "x-go-name": "ElevatedUntil", + "readOnly": true + }, + "id": { + "description": "The user id.", + "type": "integer", + "format": "int64", + "x-go-name": "ID", + "readOnly": true, + "example": 25 + }, + "name": { + "description": "The user name. For login.", + "type": "string", + "x-go-name": "Name", + "example": "unicorn" + } + }, + "x-go-name": "CurrentUserExternal", + "x-go-package": "github.com/gotify/server/v2/model" + }, "ElevateRequest": { "type": "object", "title": "ElevateRequest parameters for client elevation.", diff --git a/model/user.go b/model/user.go index 7593851e..9648b660 100644 --- a/model/user.go +++ b/model/user.go @@ -1,5 +1,7 @@ package model +import "time" + // The User holds information about the credentials of a user and its application and client tokens. type User struct { ID uint `gorm:"primaryKey;autoIncrement"` @@ -80,6 +82,37 @@ type UpdateUserExternal struct { Pass string `json:"pass,omitempty" form:"pass" query:"pass"` } +// CurrentUserExternal Model +// +// swagger:model CurrentUser +type CurrentUserExternal struct { + // The user id. + // + // read only: true + // required: true + // example: 25 + ID uint `json:"id"` + // The user name. For login. + // + // required: true + // example: unicorn + Name string `json:"name"` + // If the user is an administrator. + // + // required: true + // example: true + Admin bool `json:"admin"` + // The client id of the current session. + // + // read only: true + // example: 5 + ClientID uint `json:"clientId,omitempty"` + // The time until which the session is elevated. + // + // read only: true + ElevatedUntil *time.Time `json:"elevatedUntil,omitempty"` +} + // UserExternalPass Model // // The Password for updating the user. diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index e2aa30b4..35de2f5d 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -3,7 +3,7 @@ import * as config from './config'; import {detect} from 'detect-browser'; import {SnackReporter} from './snack/SnackManager'; import {observable, runInAction, action} from 'mobx'; -import {IUser} from './types'; +import {ICurrentUser} from './types'; export class CurrentUser { private reconnectTimeoutId: number | null = null; @@ -11,7 +11,7 @@ export class CurrentUser { @observable accessor loggedIn = false; @observable accessor refreshKey = 0; @observable accessor authenticating = true; - @observable accessor user: IUser = {name: 'unknown', admin: false, id: -1}; + @observable accessor user: ICurrentUser = {name: 'unknown', admin: false, id: -1}; @observable accessor connectionErrorMessage: string | null = null; public constructor(private readonly snack: SnackReporter) {} @@ -58,7 +58,7 @@ export class CurrentUser { headers: {Authorization: 'Basic ' + btoa(username + ':' + password)}, }) .then( - action((resp: AxiosResponse) => { + action((resp: AxiosResponse) => { this.snack(`A client named '${name}' was created for your session.`); this.user = resp.data; this.loggedIn = true; @@ -75,7 +75,7 @@ export class CurrentUser { ); }; - public tryAuthenticate = async (): Promise> => { + public tryAuthenticate = async (): Promise> => { return axios .create() .get(config.get('url') + 'current/user') diff --git a/ui/src/types.ts b/ui/src/types.ts index 40888d18..4f339494 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -62,6 +62,11 @@ export interface IUser { admin: boolean; } +export interface ICurrentUser extends IUser { + clientId?: number; + elevatedUntil?: string; +} + export interface IVersion { version: string; commit: string; From a8744482c668e07a61798573ec8e17bd3f6009f9 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 11 Apr 2026 21:43:59 +0200 Subject: [PATCH 06/10] fix: enforce elevated authentication --- api/application.go | 2 + api/client.go | 4 ++ api/user.go | 14 ++++ auth/authentication.go | 30 ++++++--- auth/authentication_test.go | 124 +++++++++++++++++++++++++++++++++--- docs/spec.json | 11 +++- router/router.go | 31 +++------ 7 files changed, 175 insertions(+), 41 deletions(-) diff --git a/api/application.go b/api/application.go index 2527842d..a984b0f7 100644 --- a/api/application.go +++ b/api/application.go @@ -151,6 +151,8 @@ func (a *ApplicationAPI) GetApplications(ctx *gin.Context) { // // Delete an application. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] diff --git a/api/client.go b/api/client.go index 3736f316..52e78a3b 100644 --- a/api/client.go +++ b/api/client.go @@ -193,6 +193,8 @@ func (a *ClientAPI) GetClients(ctx *gin.Context) { // // Delete a client. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] @@ -242,6 +244,8 @@ func (a *ClientAPI) DeleteClient(ctx *gin.Context) { // // Elevate a client session. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] diff --git a/api/user.go b/api/user.go index 61c0966b..f5272dd6 100644 --- a/api/user.go +++ b/api/user.go @@ -69,6 +69,8 @@ type UserAPI struct { // // Return all users. // +// Requires elevated authentication. +// // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] @@ -105,6 +107,8 @@ func (a *UserAPI) GetUsers(ctx *gin.Context) { // // Return the current user. // +// Requires elevated authentication. +// // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] @@ -147,6 +151,8 @@ func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { // With enabled registration: non admin users can be created without authentication. // With disabled registrations: users can only be created by admin users. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] @@ -233,6 +239,8 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) { // // Get a user. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] @@ -284,6 +292,8 @@ func (a *UserAPI) GetUserByID(ctx *gin.Context) { // // Deletes a user. // +// Requires elevated authentication. +// // --- // produces: [application/json] // security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []] @@ -344,6 +354,8 @@ func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { // // Update the password of the current user. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] @@ -387,6 +399,8 @@ func (a *UserAPI) ChangePassword(ctx *gin.Context) { // // Update a user. // +// Requires elevated authentication. +// // --- // consumes: [application/json] // produces: [application/json] diff --git a/auth/authentication.go b/auth/authentication.go index 1dcd67de..6e7a9b7b 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -15,6 +15,7 @@ type authState int const ( authStateSkip authState = iota authStateForbidden + authStateNotElevated authStateOk ) @@ -23,6 +24,8 @@ const ( cookieName = "gotify-client-token" ) +var timeNow = time.Now + // The Database interface for encapsulating database access. type Database interface { GetApplicationByToken(token string) (*model.Application, error) @@ -39,16 +42,20 @@ type Auth struct { SecureCookie bool } -// RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied -// with the request. Also the authenticated user must be an administrator. +// RequireElevatedAdmin requires an elevated client token or basic auth, the user must be an admin. func (a *Auth) RequireAdmin(ctx *gin.Context) { - a.evaluateOr401(ctx, a.user(true), a.client(true)) + a.evaluateOr401(ctx, a.user(true), a.client(true, true)) } // RequireClient returns a gin middleware which requires a client token or basic authentication header to be supplied // with the request. func (a *Auth) RequireClient(ctx *gin.Context) { - a.evaluateOr401(ctx, a.user(false), a.client(false)) + a.evaluateOr401(ctx, a.user(false), a.client(false, false)) +} + +// RequireElevatedClient requires an elevated client token or basic auth. +func (a *Auth) RequireElevatedClient(ctx *gin.Context) { + a.evaluateOr401(ctx, a.user(false), a.client(false, true)) } // RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. @@ -69,7 +76,7 @@ func (a *Auth) RequireApplicationToken(ctx *gin.Context) { } func (a *Auth) Optional(ctx *gin.Context) { - if !a.evaluate(ctx, a.user(false), a.client(false)) { + if !a.evaluate(ctx, a.user(false), a.client(false, false)) { ctx.Next() } } @@ -85,6 +92,9 @@ func (a *Auth) evaluate(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authS case authStateForbidden: a.abort403(ctx) return true + case authStateNotElevated: + ctx.AbortWithError(403, errors.New("session not elevated, use basic auth or call /client:elevate")) + return true case authStateOk: ctx.Next() return true @@ -127,7 +137,7 @@ func (a *Auth) user(requireAdmin bool) func(ctx *gin.Context) (authState, error) } } -func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, error) { +func (a *Auth) client(requireAdmin, requireElevated bool) func(ctx *gin.Context) (authState, error) { return func(ctx *gin.Context) (authState, error) { token, isCookie := a.readTokenFromRequest(ctx) if token == "" { @@ -142,7 +152,7 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro } RegisterClient(ctx, client) - now := time.Now() + now := timeNow() if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { if err := a.DB.UpdateClientTokensLastUsed([]string{client.Token}, &now); err != nil { return authStateSkip, err @@ -160,6 +170,10 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro } } + if requireElevated && (client.ElevatedUntil == nil || !now.Before(*client.ElevatedUntil)) { + return authStateNotElevated, nil + } + return authStateOk, nil } } @@ -178,7 +192,7 @@ func (a *Auth) application(ctx *gin.Context) (authState, error) { } RegisterApplication(ctx, app) - now := time.Now() + now := timeNow() if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { if err := a.DB.UpdateApplicationTokenLastUsed(app.Token, &now); err != nil { return authStateSkip, err diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 154a67ad..f47fd989 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth/password" @@ -30,12 +31,22 @@ func (s *AuthenticationSuite) SetupSuite() { s.DB = testdb.NewDB(s.T()) s.auth = &Auth{DB: s.DB} + now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + timeNow = func() time.Time { return now } + + elevated := now.Add(time.Hour) + expired := now.Add(-time.Hour) + s.DB.CreateUser(&model.User{ Name: "existing", Pass: password.CreatePassword("pw", 5), Admin: false, Applications: []model.Application{{Token: "apptoken", Name: "backup server1", Description: "irrelevant"}}, - Clients: []model.Client{{Token: "clienttoken", Name: "android phone1"}}, + Clients: []model.Client{ + {Token: "clienttoken", Name: "android phone1"}, + {Token: "clienttoken_elevated", Name: "elevated phone1", ElevatedUntil: &elevated}, + {Token: "clienttoken_expired", Name: "expired phone1", ElevatedUntil: &expired}, + }, }) s.DB.CreateUser(&model.User{ @@ -43,11 +54,15 @@ func (s *AuthenticationSuite) SetupSuite() { Pass: password.CreatePassword("pw", 5), Admin: true, Applications: []model.Application{{Token: "apptoken_admin", Name: "backup server2", Description: "irrelevant"}}, - Clients: []model.Client{{Token: "clienttoken_admin", Name: "android phone2"}}, + Clients: []model.Client{ + {Token: "clienttoken_admin", Name: "android phone2"}, + {Token: "clienttoken_admin_elevated", Name: "elevated phone2", ElevatedUntil: &elevated}, + }, }) } func (s *AuthenticationSuite) TearDownSuite() { + timeNow = time.Now s.DB.Close() } @@ -56,27 +71,53 @@ func (s *AuthenticationSuite) TestQueryToken() { s.assertQueryRequest("token", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "ergerogerg", s.auth.RequireClient, 401) s.assertQueryRequest("token", "ergerogerg", s.auth.RequireAdmin, 401) + s.assertQueryRequest("token", "ergerogerg", s.auth.RequireElevatedClient, 401) // not existing key s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireClient, 401) s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireAdmin, 401) + s.assertQueryRequest("tokenx", "clienttoken", s.auth.RequireElevatedClient, 401) // apptoken s.assertQueryRequest("token", "apptoken", s.auth.RequireApplicationToken, 200) s.assertQueryRequest("token", "apptoken", s.auth.RequireClient, 401) s.assertQueryRequest("token", "apptoken", s.auth.RequireAdmin, 401) + s.assertQueryRequest("token", "apptoken", s.auth.RequireElevatedClient, 401) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireClient, 401) s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireAdmin, 401) + s.assertQueryRequest("token", "apptoken_admin", s.auth.RequireElevatedClient, 401) - // clienttoken + // clienttoken (non-admin, not elevated) s.assertQueryRequest("token", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "clienttoken", s.auth.RequireClient, 200) s.assertQueryRequest("token", "clienttoken", s.auth.RequireAdmin, 403) + s.assertQueryRequest("token", "clienttoken", s.auth.RequireElevatedClient, 403) + + // clienttoken_elevated (non-admin, elevated) + s.assertQueryRequest("token", "clienttoken_elevated", s.auth.RequireApplicationToken, 401) + s.assertQueryRequest("token", "clienttoken_elevated", s.auth.RequireClient, 200) + s.assertQueryRequest("token", "clienttoken_elevated", s.auth.RequireAdmin, 403) + s.assertQueryRequest("token", "clienttoken_elevated", s.auth.RequireElevatedClient, 200) + + // clienttoken_expired (non-admin, elevation expired) + s.assertQueryRequest("token", "clienttoken_expired", s.auth.RequireApplicationToken, 401) + s.assertQueryRequest("token", "clienttoken_expired", s.auth.RequireClient, 200) + s.assertQueryRequest("token", "clienttoken_expired", s.auth.RequireAdmin, 403) + s.assertQueryRequest("token", "clienttoken_expired", s.auth.RequireElevatedClient, 403) + + // clienttoken_admin (not elevated) s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireClient, 200) - s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireAdmin, 200) + s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireAdmin, 403) + s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireElevatedClient, 403) + + // clienttoken_admin_elevated + s.assertQueryRequest("token", "clienttoken_admin_elevated", s.auth.RequireApplicationToken, 401) + s.assertQueryRequest("token", "clienttoken_admin_elevated", s.auth.RequireClient, 200) + s.assertQueryRequest("token", "clienttoken_admin_elevated", s.auth.RequireAdmin, 200) + s.assertQueryRequest("token", "clienttoken_admin_elevated", s.auth.RequireElevatedClient, 200) } func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) { @@ -101,27 +142,47 @@ func (s *AuthenticationSuite) TestHeaderApiKeyToken() { s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.RequireElevatedClient, 401) // not existing key s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("X-Gotify-Keyx", "clienttoken", s.auth.RequireElevatedClient, 401) // apptoken s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("X-Gotify-Key", "apptoken", s.auth.RequireElevatedClient, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireClient, 401) s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("X-Gotify-Key", "apptoken_admin", s.auth.RequireElevatedClient, 401) - // clienttoken + // clienttoken (non-admin, not elevated) s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireClient, 200) s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken", s.auth.RequireElevatedClient, 403) + + // clienttoken_elevated (non-admin, elevated) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_elevated", s.auth.RequireApplicationToken, 401) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_elevated", s.auth.RequireClient, 200) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_elevated", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_elevated", s.auth.RequireElevatedClient, 200) + + // clienttoken_admin (not elevated) s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireClient, 200) - s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireAdmin, 200) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin", s.auth.RequireElevatedClient, 403) + + // clienttoken_admin_elevated + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin_elevated", s.auth.RequireApplicationToken, 401) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin_elevated", s.auth.RequireClient, 200) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin_elevated", s.auth.RequireAdmin, 200) + s.assertHeaderRequest("X-Gotify-Key", "clienttoken_admin_elevated", s.auth.RequireElevatedClient, 200) } func (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() { @@ -129,58 +190,84 @@ func (s *AuthenticationSuite) TestAuthorizationHeaderApiKeyToken() { s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Bearer ergerogerg", s.auth.RequireElevatedClient, 401) // no authentication schema s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "ergerogerg", s.auth.RequireElevatedClient, 401) // wrong authentication schema s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "ApiKeyx clienttoken", s.auth.RequireElevatedClient, 401) // Authorization Bearer apptoken s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Bearer apptoken", s.auth.RequireElevatedClient, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireApplicationToken, 200) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Bearer apptoken_admin", s.auth.RequireElevatedClient, 401) - // Authorization Bearer clienttoken + // Authorization Bearer clienttoken (non-admin, not elevated) s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("Authorization", "Bearer clienttoken", s.auth.RequireElevatedClient, 403) + + // Authorization Bearer clienttoken_elevated (non-admin, elevated) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_elevated", s.auth.RequireApplicationToken, 401) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_elevated", s.auth.RequireClient, 200) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_elevated", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_elevated", s.auth.RequireElevatedClient, 200) + + // Authorization bearer clienttoken_admin (not elevated) s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireClient, 200) - s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireAdmin, 200) + s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("Authorization", "bearer clienttoken_admin", s.auth.RequireElevatedClient, 403) + + // Authorization Bearer clienttoken_admin_elevated + s.assertHeaderRequest("Authorization", "Bearer clienttoken_admin_elevated", s.auth.RequireApplicationToken, 401) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_admin_elevated", s.auth.RequireClient, 200) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_admin_elevated", s.auth.RequireAdmin, 200) + s.assertHeaderRequest("Authorization", "Bearer clienttoken_admin_elevated", s.auth.RequireElevatedClient, 200) } func (s *AuthenticationSuite) TestBasicAuth() { s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Basic ergerogerg", s.auth.RequireElevatedClient, 401) // user existing:pw s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 403) s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 403) + s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.RequireElevatedClient, 200) // user admin:pw s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireApplicationToken, 403) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireClient, 200) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 200) + s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireElevatedClient, 200) // user admin:pwx s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.RequireElevatedClient, 401) // user notexisting:pw s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireApplicationToken, 401) s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireClient, 401) s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireElevatedClient, 401) } func (s *AuthenticationSuite) TestOptionalAuth() { @@ -216,18 +303,35 @@ func (s *AuthenticationSuite) TestCookieToken() { s.assertCookieRequest("ergerogerg", s.auth.RequireApplicationToken, 401) s.assertCookieRequest("ergerogerg", s.auth.RequireClient, 401) s.assertCookieRequest("ergerogerg", s.auth.RequireAdmin, 401) + s.assertCookieRequest("ergerogerg", s.auth.RequireElevatedClient, 401) // apptoken s.assertCookieRequest("apptoken", s.auth.RequireApplicationToken, 200) s.assertCookieRequest("apptoken", s.auth.RequireClient, 401) s.assertCookieRequest("apptoken", s.auth.RequireAdmin, 401) + s.assertCookieRequest("apptoken", s.auth.RequireElevatedClient, 401) - // clienttoken + // clienttoken (non-admin, not elevated) s.assertCookieRequest("clienttoken", s.auth.RequireApplicationToken, 401) s.assertCookieRequest("clienttoken", s.auth.RequireClient, 200) s.assertCookieRequest("clienttoken", s.auth.RequireAdmin, 403) + s.assertCookieRequest("clienttoken", s.auth.RequireElevatedClient, 403) + + // clienttoken_elevated (non-admin, elevated) + s.assertCookieRequest("clienttoken_elevated", s.auth.RequireApplicationToken, 401) + s.assertCookieRequest("clienttoken_elevated", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken_elevated", s.auth.RequireAdmin, 403) + s.assertCookieRequest("clienttoken_elevated", s.auth.RequireElevatedClient, 200) + + // clienttoken_admin (not elevated) s.assertCookieRequest("clienttoken_admin", s.auth.RequireClient, 200) - s.assertCookieRequest("clienttoken_admin", s.auth.RequireAdmin, 200) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireAdmin, 403) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireElevatedClient, 403) + + // clienttoken_admin_elevated + s.assertCookieRequest("clienttoken_admin_elevated", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken_admin_elevated", s.auth.RequireAdmin, 200) + s.assertCookieRequest("clienttoken_admin_elevated", s.auth.RequireElevatedClient, 200) } func (s *AuthenticationSuite) assertCookieRequest(token string, f fMiddleware, code int) (ctx *gin.Context) { diff --git a/docs/spec.json b/docs/spec.json index 01c30864..32ea0349 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -231,6 +231,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -1075,6 +1076,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -1143,6 +1145,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -1206,6 +1209,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "produces": [ "application/json" ], @@ -1252,6 +1256,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -2079,6 +2084,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "produces": [ "application/json" ], @@ -2126,7 +2132,7 @@ "basicAuth": [] } ], - "description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.", + "description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.\n\nRequires elevated authentication.", "consumes": [ "application/json" ], @@ -2193,6 +2199,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -2262,6 +2269,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "consumes": [ "application/json" ], @@ -2340,6 +2348,7 @@ "basicAuth": [] } ], + "description": "Requires elevated authentication.", "produces": [ "application/json" ], diff --git a/router/router.go b/router/router.go index f13bc2e3..2950b421 100644 --- a/router/router.go +++ b/router/router.go @@ -186,21 +186,14 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co app := clientAuth.Group("/application") { app.GET("", applicationHandler.GetApplications) - app.POST("", applicationHandler.CreateApplication) - app.POST("/:id/image", applicationHandler.UploadApplicationImage) - app.DELETE("/:id/image", applicationHandler.RemoveApplicationImage) - app.PUT("/:id", applicationHandler.UpdateApplication) - app.DELETE("/:id", applicationHandler.DeleteApplication) - tokenMessage := app.Group("/:id/message") { tokenMessage.GET("", messageHandler.GetMessagesWithApplication) - tokenMessage.DELETE("", messageHandler.DeleteMessageWithApplication) } } @@ -208,43 +201,37 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co client := clientAuth.Group("/client") { client.GET("", clientHandler.GetClients) - client.POST("", clientHandler.CreateClient) - - client.DELETE("/:id", clientHandler.DeleteClient) - client.PUT("/:id", clientHandler.UpdateClient) } - client.POST("/client:elevate", clientHandler.ElevateClient) message := clientAuth.Group("/message") { message.GET("", messageHandler.GetMessages) - message.DELETE("", messageHandler.DeleteMessages) - message.DELETE("/:id", messageHandler.DeleteMessage) } clientAuth.GET("/stream", streamHandler.Handle) - clientAuth.GET("current/user", userHandler.GetCurrentUser) - - clientAuth.POST("current/user/password", userHandler.ChangePassword) - clientAuth.POST("/auth/logout", sessionHandler.Logout) } + clientElevated := g.Group("") + { + clientElevated.Use(authentication.RequireElevatedClient) + clientElevated.POST("/client:elevate", clientHandler.ElevateClient) + clientElevated.DELETE("/application/:id", applicationHandler.DeleteApplication) + clientElevated.DELETE("/client/:id", clientHandler.DeleteClient) + clientElevated.POST("/current/user/password", userHandler.ChangePassword) + } + authAdmin := g.Group("/user") { authAdmin.Use(authentication.RequireAdmin) - authAdmin.GET("", userHandler.GetUsers) - authAdmin.DELETE("/:id", userHandler.DeleteUserByID) - authAdmin.GET("/:id", userHandler.GetUserByID) - authAdmin.POST("/:id", userHandler.UpdateUserByID) } return g, streamHandler.Close From 530517412ba13088db74369cdf0439f183f6f65c Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 12 Apr 2026 19:51:24 +0200 Subject: [PATCH 07/10] feat: add elevation dialog to protected actions --- ui/src/ElevateStore.ts | 102 ++++++++++++++++++++++++++++ ui/src/application/Applications.tsx | 1 + ui/src/client/Clients.tsx | 1 + ui/src/common/ConfirmDialog.tsx | 46 +++++++++---- ui/src/common/ElevationForm.tsx | 95 ++++++++++++++++++++++++++ ui/src/common/SettingsDialog.tsx | 64 ++++++++++------- ui/src/index.tsx | 3 + ui/src/layout/Layout.tsx | 29 +++++++- ui/src/reactions.ts | 16 +++++ ui/src/stores.tsx | 2 + 10 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 ui/src/ElevateStore.ts create mode 100644 ui/src/common/ElevationForm.tsx diff --git a/ui/src/ElevateStore.ts b/ui/src/ElevateStore.ts new file mode 100644 index 00000000..57b47181 --- /dev/null +++ b/ui/src/ElevateStore.ts @@ -0,0 +1,102 @@ +import axios from 'axios'; +import {action, observable, runInAction} from 'mobx'; +import * as config from './config'; +import {SnackReporter} from './snack/SnackManager'; +import {CurrentUser} from './CurrentUser'; + +export class ElevateStore { + @observable accessor elevated = false; + @observable accessor oidcElevatePending = false; + private oidcPollIntervalId: number | undefined = undefined; + private oidcPopup: Window | null = null; + + public constructor( + private readonly snack: SnackReporter, + private readonly currentUser: CurrentUser + ) {} + + @action + public refreshElevated = (): number => { + const elevatedUntil = this.currentUser.user.elevatedUntil; + if (!elevatedUntil) { + this.elevated = false; + return 0; + } + const ms = new Date(elevatedUntil).getTime() - 30_000 - Date.now(); + if (ms <= 0) { + this.elevated = false; + return 0; + } + this.elevated = true; + return ms; + }; + + public localElevate = async (password: string, durationSeconds: number): Promise => { + await axios.create().request({ + url: config.get('url') + 'client:elevate', + method: 'POST', + data: {id: this.currentUser.user.clientId, durationSeconds}, + headers: { + Authorization: 'Basic ' + btoa(this.currentUser.user.name + ':' + password), + }, + }); + await this.currentUser.tryAuthenticate(); + this.cleanupOidcElevate(); + }; + + public oidcElevate = (durationSeconds: number): void => { + // prevent double execution + if (this.oidcElevatePending) return; + + const url = + config.get('url') + + 'auth/oidc/elevate?id=' + + this.currentUser.user.clientId + + '&durationSeconds=' + + durationSeconds; + + this.oidcPopup = window.open(url, 'gotify-oidc-elevate', 'width=600,height=700'); + if (!this.oidcPopup) { + this.snack('Popup was blocked. Please allow popups for this site and try again.'); + return; + } + + runInAction(() => (this.oidcElevatePending = true)); + + this.oidcPollIntervalId = window.setInterval(this.checkOidcPopup, 500); + }; + + private checkOidcPopup = async () => { + if (this.oidcPopup && !this.oidcPopup.closed) { + // waiting for the popup to close. + return; + } + + window.clearInterval(this.oidcPollIntervalId); + this.oidcPollIntervalId = undefined; + + try { + await this.currentUser.tryAuthenticate(); + } catch { + // errors handled in tryAuthenticate + } + + if (!this.elevated) { + this.snack('OIDC elevation was not completed.'); + } + this.cleanupOidcElevate(); + }; + + public cleanupOidcElevate = () => { + window.clearInterval(this.oidcPollIntervalId); + this.oidcPollIntervalId = undefined; + + if (this.oidcPopup && !this.oidcPopup.closed) { + this.oidcPopup.close(); + } + this.oidcPopup = null; + runInAction(() => { + this.oidcElevatePending = false; + }); + }; +} diff --git a/ui/src/application/Applications.tsx b/ui/src/application/Applications.tsx index 4199b839..570fc932 100644 --- a/ui/src/application/Applications.tsx +++ b/ui/src/application/Applications.tsx @@ -179,6 +179,7 @@ const Applications = observer(() => { text={'Delete ' + toDeleteApp.name + '?'} fClose={() => setToDeleteApp(undefined)} fOnSubmit={() => appStore.remove(toDeleteApp.id)} + requireElevated /> )} {toDeleteImage != null && ( diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index b74feba2..9ff573fb 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -87,6 +87,7 @@ const Clients = observer(() => { text={'Delete ' + toDeleteClient.name + '?'} fClose={() => setToDeleteClient(undefined)} fOnSubmit={() => clientStore.remove(toDeleteClient.id)} + requireElevated /> )} diff --git a/ui/src/common/ConfirmDialog.tsx b/ui/src/common/ConfirmDialog.tsx index 99c506e6..5f76211f 100644 --- a/ui/src/common/ConfirmDialog.tsx +++ b/ui/src/common/ConfirmDialog.tsx @@ -5,42 +5,60 @@ import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogTitle from '@mui/material/DialogTitle'; import React from 'react'; +import {observer} from 'mobx-react-lite'; +import {useStores} from '../stores'; +import ElevationForm from './ElevationForm'; interface IProps { title: string; text: string; fClose: VoidFunction; fOnSubmit: VoidFunction; + requireElevated?: boolean; } -export default function ConfirmDialog({title, text, fClose, fOnSubmit}: IProps) { +const ConfirmDialog = observer(({title, text, fClose, fOnSubmit, requireElevated}: IProps) => { + const {elevateStore} = useStores(); + + const needsElevation = requireElevated && !elevateStore.elevated; + const submitAndClose = () => { fOnSubmit(); fClose(); }; + + const handleClose = () => { + elevateStore.cleanupOidcElevate(); + fClose(); + }; + return ( {title} - {text} + {needsElevation ? : {text}} - - + {!needsElevation && ( + + )} ); -} +}); + +export default ConfirmDialog; diff --git a/ui/src/common/ElevationForm.tsx b/ui/src/common/ElevationForm.tsx new file mode 100644 index 00000000..b18fb6a0 --- /dev/null +++ b/ui/src/common/ElevationForm.tsx @@ -0,0 +1,95 @@ +import React, {useState} from 'react'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import {observer} from 'mobx-react-lite'; +import {useStores} from '../stores'; +import * as config from '../config'; +import CircularProgress from '@mui/material/CircularProgress'; +import {Box, Divider} from '@mui/material'; + +const ElevateDuration = 60 * 60; + +const ElevationForm = observer(() => { + const {elevateStore} = useStores(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const oidcEnabled = config.get('oidc'); + const oidcPending = elevateStore.oidcElevatePending; + + const handleLocalElevate = async () => { + try { + await elevateStore.localElevate(password, ElevateDuration); + } catch { + setError('Elevation failed. Check your password.'); + } + }; + + if (oidcPending) { + return ( + + + Waiting for OIDC sign-in... + + Complete sign-in in the new tab, then close it to continue. + + + + ); + } + + return ( + <> + This action requires re-authentication. +
{ + e.preventDefault(); + handleLocalElevate(); + }}> + { + setPassword(e.target.value); + setError(''); + }} + fullWidth + error={!!error} + helperText={error} + /> + + + + {oidcEnabled && ( + <> + or + + + )} + + ); +}); + +export default ElevationForm; diff --git a/ui/src/common/SettingsDialog.tsx b/ui/src/common/SettingsDialog.tsx index cb7ea472..04125739 100644 --- a/ui/src/common/SettingsDialog.tsx +++ b/ui/src/common/SettingsDialog.tsx @@ -8,6 +8,7 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import {observer} from 'mobx-react-lite'; import {useStores} from '../stores'; +import ElevationForm from './ElevationForm'; interface IProps { fClose: VoidFunction; @@ -15,9 +16,14 @@ interface IProps { const SettingsDialog = observer(({fClose}: IProps) => { const [pass, setPass] = useState(''); - const {currentUser} = useStores(); + const {currentUser, elevateStore} = useStores(); - const submitAndClose = async () => { + const handleClose = () => { + elevateStore.cleanupOidcElevate(); + fClose(); + }; + + const submitAndClose = () => { currentUser.changePassword(pass); fClose(); }; @@ -25,36 +31,42 @@ const SettingsDialog = observer(({fClose}: IProps) => { return ( Change Password - setPass(e.target.value)} - fullWidth - /> + {elevateStore.elevated ? ( + setPass(e.target.value)} + fullWidth + /> + ) : ( + + )} - - -
- -
-
+ + {elevateStore.elevated && ( + +
+ +
+
+ )}
); diff --git a/ui/src/index.tsx b/ui/src/index.tsx index a8dc9d9e..83f24bdb 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -6,6 +6,7 @@ import * as config from './config'; import Layout from './layout/Layout'; import {unregister} from './registerServiceWorker'; import {CurrentUser} from './CurrentUser'; +import {ElevateStore} from './ElevateStore'; import {AppStore} from './application/AppStore'; import {WebSocketStore} from './message/WebSocketStore'; import {SnackManager} from './snack/SnackManager'; @@ -30,6 +31,7 @@ const initStores = (): StoreMapping => { const userStore = new UserStore(snackManager.snack); const messagesStore = new MessagesStore(appStore, snackManager.snack); const currentUser = new CurrentUser(snackManager.snack); + const elevateStore = new ElevateStore(snackManager.snack, currentUser); const clientStore = new ClientStore(snackManager.snack); const wsStore = new WebSocketStore(snackManager.snack, currentUser); const pluginStore = new PluginStore(snackManager.snack); @@ -41,6 +43,7 @@ const initStores = (): StoreMapping => { userStore, messagesStore, currentUser, + elevateStore, clientStore, wsStore, pluginStore, diff --git a/ui/src/layout/Layout.tsx b/ui/src/layout/Layout.tsx index e237219d..ba3fb8bf 100644 --- a/ui/src/layout/Layout.tsx +++ b/ui/src/layout/Layout.tsx @@ -4,6 +4,8 @@ import { StyledEngineProvider, Theme, useMediaQuery, + Paper, + Box, } from '@mui/material'; import {makeStyles} from 'tss-react/mui'; import CssBaseline from '@mui/material/CssBaseline'; @@ -13,6 +15,7 @@ import Header from './Header'; import Navigation from './Navigation'; import ScrollUpButton from '../common/ScrollUpButton'; import SettingsDialog from '../common/SettingsDialog'; +import ElevationForm from '../common/ElevationForm'; import * as config from '../config'; import Applications from '../application/Applications'; import Clients from '../client/Clients'; @@ -26,6 +29,7 @@ import {useStores} from '../stores'; import {SnackbarProvider} from 'notistack'; import LoadingSpinner from '../common/LoadingSpinner'; import {isThemeKey, ThemeKey} from './theme'; +import DefaultPage from '../common/DefaultPage'; const useStyles = makeStyles()((theme: Theme) => ({ content: { @@ -91,6 +95,8 @@ const Layout = observer(() => { ); + const elevated = (children: React.ReactNode) => {children}; + return ( @@ -138,7 +144,10 @@ const Layout = observer(() => { element={authed()} /> )} /> - )} /> + ))} + /> )} /> {children}; }; +export const RequireElevation = observer(({children}: React.PropsWithChildren) => { + const {elevateStore} = useStores(); + + if (elevateStore.elevated) { + return <>{children}; + } + + return ( + + + + + + + + ); +}); + export default Layout; diff --git a/ui/src/reactions.ts b/ui/src/reactions.ts index a51bc209..0f8f6db2 100644 --- a/ui/src/reactions.ts +++ b/ui/src/reactions.ts @@ -45,6 +45,22 @@ export const registerReactions = (stores: StoreMapping) => { } ); + let elevationTimerId: number | undefined = undefined; + reaction( + () => stores.currentUser.user.elevatedUntil, + () => { + window.clearTimeout(elevationTimerId); + const disableAfter = stores.elevateStore.refreshElevated(); + if (disableAfter > 0) { + elevationTimerId = window.setTimeout( + () => stores.elevateStore.refreshElevated(), + disableAfter + ); + } + }, + {fireImmediately: true} + ); + reaction( () => stores.currentUser.connectionErrorMessage, (connectionErrorMessage) => { diff --git a/ui/src/stores.tsx b/ui/src/stores.tsx index 325b90fb..2d56e43e 100644 --- a/ui/src/stores.tsx +++ b/ui/src/stores.tsx @@ -3,6 +3,7 @@ import {UserStore} from './user/UserStore'; import {SnackManager} from './snack/SnackManager'; import {MessagesStore} from './message/MessagesStore'; import {CurrentUser} from './CurrentUser'; +import {ElevateStore} from './ElevateStore'; import {ClientStore} from './client/ClientStore'; import {AppStore} from './application/AppStore'; import {WebSocketStore} from './message/WebSocketStore'; @@ -13,6 +14,7 @@ export interface StoreMapping { snackManager: SnackManager; messagesStore: MessagesStore; currentUser: CurrentUser; + elevateStore: ElevateStore; clientStore: ClientStore; appStore: AppStore; pluginStore: PluginStore; From 624ab65742b37041c59a209e15ddb228bb7538e6 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 12 Apr 2026 20:08:53 +0200 Subject: [PATCH 08/10] fix: elevate session on login --- api/oidc.go | 8 +++++--- api/session.go | 9 ++++++--- model/elevate.go | 4 ++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/api/oidc.go b/api/oidc.go index da2cfd6b..46ca5845 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -417,10 +417,12 @@ 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) client := &model.Client{ - Name: name, - Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), - UserID: userID, + Name: name, + Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + UserID: userID, + ElevatedUntil: &elevatedUntil, } return client, a.DB.CreateClient(client) } diff --git a/api/session.go b/api/session.go index 432bc005..2722f214 100644 --- a/api/session.go +++ b/api/session.go @@ -2,6 +2,7 @@ package api import ( "errors" + "time" "github.com/gin-gonic/gin" "github.com/gotify/server/v2/auth" @@ -74,10 +75,12 @@ func (a *SessionAPI) Login(ctx *gin.Context) { return } + elevatedUntil := time.Now().Add(model.DefaultElevationDuration) client := model.Client{ - Name: clientParams.Name, - Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), - UserID: user.ID, + Name: clientParams.Name, + Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + UserID: user.ID, + ElevatedUntil: &elevatedUntil, } if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { return diff --git a/model/elevate.go b/model/elevate.go index 8ffd688b..c7ff7f47 100644 --- a/model/elevate.go +++ b/model/elevate.go @@ -1,5 +1,7 @@ package model +import "time" + // ElevateRequest parameters for client elevation. // // swagger:model ElevateRequest @@ -15,3 +17,5 @@ type ElevateRequest struct { // example: 900 DurationSeconds int `form:"durationSeconds" query:"durationSeconds" json:"durationSeconds" binding:"required"` } + +var DefaultElevationDuration = time.Hour From 96116dbfe4e0d89ff7d3c0fa83278d2e48de0846 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 12 Apr 2026 20:31:47 +0200 Subject: [PATCH 09/10] feat: display elevation in the clients page --- ui/src/client/ClientStore.ts | 7 +++ ui/src/client/Clients.tsx | 36 +++++++++++- ui/src/client/ElevateClientDialog.tsx | 83 +++++++++++++++++++++++++++ ui/src/tests/client.test.ts | 6 +- ui/src/types.ts | 1 + 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 ui/src/client/ElevateClientDialog.tsx diff --git a/ui/src/client/ClientStore.ts b/ui/src/client/ClientStore.ts index cc63b6e4..4b642d8e 100644 --- a/ui/src/client/ClientStore.ts +++ b/ui/src/client/ClientStore.ts @@ -38,4 +38,11 @@ export class ClientStore extends BaseStore { await this.createNoNotifcation(name); this.snack('Client added'); }; + + @action + public elevate = async (id: number, durationSeconds: number): Promise => { + await axios.post(`${config.get('url')}client:elevate`, {id, durationSeconds}); + await this.refresh(); + this.snack('Client elevated'); + }; } diff --git a/ui/src/client/Clients.tsx b/ui/src/client/Clients.tsx index 9ff573fb..e7e1f1aa 100644 --- a/ui/src/client/Clients.tsx +++ b/ui/src/client/Clients.tsx @@ -9,14 +9,19 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; +import Security from '@mui/icons-material/Security'; import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import TimeAgo from 'react-timeago'; import ConfirmDialog from '../common/ConfirmDialog'; import DefaultPage from '../common/DefaultPage'; import AddClientDialog from './AddClientDialog'; import UpdateClientDialog from './UpdateClientDialog'; +import ElevateClientDialog from './ElevateClientDialog'; import {IClient} from '../types'; import CopyableSecret from '../common/CopyableSecret'; import {LastUsedCell} from '../common/LastUsedCell'; +import {TimeAgoFormatter} from '../common/TimeAgoFormatter'; import {observer} from 'mobx-react-lite'; import {useStores} from '../stores'; @@ -24,6 +29,7 @@ const Clients = observer(() => { const {clientStore} = useStores(); const [toDeleteClient, setToDeleteClient] = useState(); const [toUpdateClient, setToUpdateClient] = useState(); + const [toElevateClient, setToElevateClient] = useState(); const [createDialog, setCreateDialog] = useState(false); const clients = clientStore.getItems(); @@ -32,6 +38,7 @@ const Clients = observer(() => { return ( { Name Token Last Used + Elevation ends + @@ -60,8 +69,10 @@ const Clients = observer(() => { name={client.name} value={client.token} lastUsed={client.lastUsed} + elevatedUntil={client.elevatedUntil} fEdit={() => setToUpdateClient(client)} fDelete={() => setToDeleteClient(client)} + fElevate={() => setToElevateClient(client)} /> ))} @@ -90,6 +101,13 @@ const Clients = observer(() => { requireElevated /> )} + {toElevateClient != null && ( + setToElevateClient(undefined)} + /> + )} ); }); @@ -98,11 +116,13 @@ interface IRowProps { name: string; value: string; lastUsed: string | null; + elevatedUntil?: string; fEdit: VoidFunction; fDelete: VoidFunction; + fElevate: VoidFunction; } -const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => ( +const Row = ({name, value, lastUsed, elevatedUntil, fEdit, fDelete, fElevate}: IRowProps) => ( {name} @@ -114,6 +134,20 @@ const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => ( + + {elevatedUntil && Date.parse(elevatedUntil) > Date.now() ? ( + + ) : ( + '-' + )} + + + + + + + + diff --git a/ui/src/client/ElevateClientDialog.tsx b/ui/src/client/ElevateClientDialog.tsx new file mode 100644 index 00000000..e868a055 --- /dev/null +++ b/ui/src/client/ElevateClientDialog.tsx @@ -0,0 +1,83 @@ +import React, {useState} from 'react'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import {observer} from 'mobx-react-lite'; +import {useStores} from '../stores'; +import ElevationForm from '../common/ElevationForm'; + +interface IProps { + clientName: string; + clientId: number; + fClose: VoidFunction; +} + +const durationOptions = [ + {label: 'Cancel elevation', seconds: -1}, + {label: '1 hour', seconds: 60 * 60}, + {label: '1 day', seconds: 24 * 60 * 60}, + {label: '30 days', seconds: 30 * 24 * 60 * 60}, + {label: '1 year', seconds: 365 * 24 * 60 * 60}, +]; + +const ElevateClientDialog = observer(({clientName, clientId, fClose}: IProps) => { + const {elevateStore, clientStore, currentUser} = useStores(); + const [durationSeconds, setDurationSeconds] = useState(durationOptions[1].seconds); + + const needsElevation = !elevateStore.elevated; + + const handleConfirm = async () => { + await clientStore.elevate(clientId, durationSeconds); + if (clientId === currentUser.user.clientId) { + currentUser.tryAuthenticate(); + } + fClose(); + }; + + const handleClose = () => { + elevateStore.cleanupOidcElevate(); + fClose(); + }; + + return ( + + Elevate Client: {clientName} + + {needsElevation ? ( + + ) : ( + + Duration + + + )} + + + + {!needsElevation && ( + + )} + + + ); +}); + +export default ElevateClientDialog; diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts index 3ae04663..55f940d3 100644 --- a/ui/src/tests/client.test.ts +++ b/ui/src/tests/client.test.ts @@ -19,8 +19,10 @@ enum Col { Name = 1, Token = 2, LastSeen = 3, - Edit = 4, - Delete = 5, + ElevationEnds = 4, + Elevate = 5, + Edit = 6, + Delete = 7, } const waitForClient = diff --git a/ui/src/types.ts b/ui/src/types.ts index 4f339494..0ab2849c 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -15,6 +15,7 @@ export interface IClient { token: string; name: string; lastUsed: string | null; + elevatedUntil?: string; } export interface IPlugin { From be5283ae8dad8428bf247e990fc012bd92118fd9 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sat, 18 Apr 2026 12:31:27 +0200 Subject: [PATCH 10/10] test: elevation --- ui/src/client/ElevateClientDialog.tsx | 16 +++-- ui/src/common/ElevationForm.tsx | 4 ++ ui/src/tests/client.test.ts | 34 ++++------ ui/src/tests/elevation.test.ts | 97 +++++++++++++++++++++++++++ ui/src/tests/utils.ts | 10 +++ 5 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 ui/src/tests/elevation.test.ts diff --git a/ui/src/client/ElevateClientDialog.tsx b/ui/src/client/ElevateClientDialog.tsx index e868a055..fcc35520 100644 --- a/ui/src/client/ElevateClientDialog.tsx +++ b/ui/src/client/ElevateClientDialog.tsx @@ -46,8 +46,8 @@ const ElevateClientDialog = observer(({clientName, clientId, fClose}: IProps) => }; return ( - - Elevate Client: {clientName} + + Elevate Client: {clientName} {needsElevation ? ( @@ -55,6 +55,7 @@ const ElevateClientDialog = observer(({clientName, clientId, fClose}: IProps) => Duration