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 865d74dc..52e78a3b 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.
@@ -190,6 +193,8 @@ func (a *ClientAPI) GetClients(ctx *gin.Context) {
//
// Delete a client.
//
+// Requires elevated authentication.
+//
// ---
// consumes: [application/json]
// produces: [application/json]
@@ -235,6 +240,63 @@ func (a *ClientAPI) DeleteClient(ctx *gin.Context) {
})
}
+// swagger:operation POST /client:elevate client elevateClient
+//
+// Elevate a client session.
+//
+// Requires elevated authentication.
+//
+// ---
+// 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/message.go b/api/message.go
index 3225c3d9..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.GetTokenID(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 358732e2..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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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.RegisterAuthentication(s.ctx, nil, 4, "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/oidc.go b/api/oidc.go
index 002fcb16..46ca5845 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.
@@ -332,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 1296fd47..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"
@@ -43,7 +44,7 @@ type SessionAPI struct {
// 200:
// description: Ok
// schema:
-// $ref: "#/definitions/UserExternal"
+// $ref: "#/definitions/CurrentUser"
// headers:
// Set-Cookie:
// type: string
@@ -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
@@ -85,10 +88,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,
})
}
@@ -118,18 +123,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 dfa29d62..e69423cb 100644
--- a/api/session_test.go
+++ b/api/session_test.go
@@ -109,12 +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)
- test.WithUser(s.ctx, 5)
- s.ctx.Set("tokenid", "Ctesttoken12345")
+ auth.RegisterClient(s.ctx, client)
s.a.Logout(s.ctx)
diff --git a/api/stream/stream.go b/api/stream/stream.go
index c469a812..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.GetTokenID(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/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.go b/api/user.go
index 02d9aab3..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: []]
@@ -126,7 +130,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.
@@ -137,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]
@@ -223,6 +239,8 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) {
//
// Get a user.
//
+// Requires elevated authentication.
+//
// ---
// consumes: [application/json]
// produces: [application/json]
@@ -274,6 +292,8 @@ func (a *UserAPI) GetUserByID(ctx *gin.Context) {
//
// Deletes a user.
//
+// Requires elevated authentication.
+//
// ---
// produces: [application/json]
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
@@ -334,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]
@@ -377,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/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..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,8 +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)) {
- RegisterAuthentication(ctx, nil, 0, "")
+ if !a.evaluate(ctx, a.user(false), a.client(false, false)) {
ctx.Next()
}
}
@@ -86,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
@@ -116,7 +125,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
@@ -128,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 == "" {
@@ -141,9 +150,9 @@ 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()
+ 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
@@ -161,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
}
}
@@ -177,9 +190,9 @@ 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()
+ 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/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 6f380e12..9aa6c543 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})
}
-// GetUserID returns the user id which was previously registered by RegisterAuthentication.
+// 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})
+}
+
+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,26 @@ 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)
+// GetApplication returns the authenticated application or nil if no application
+// was registered.
+func GetApplication(ctx *gin.Context) *model.Application {
+ return getInfo(ctx).app
}
-func TryGetTokenID(ctx *gin.Context) string {
- return ctx.GetString("tokenid")
+// 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 b42aef16..12f5a01c 100644
--- a/auth/util_test.go
+++ b/auth/util_test.go
@@ -23,32 +23,54 @@ 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_getToken() {
+func (s *UtilSuite) Test_GetApplication() {
+ app := &model.Application{Token: "atoken"}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
- RegisterAuthentication(ctx, nil, 1, "asdasda")
- actualID := GetTokenID(ctx)
- assert.Equal(s.T(), "asdasda", actualID)
+ 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) expectUserIDWith(user *model.User, tokenUserID, expectedID uint) {
+func (s *UtilSuite) expectUserID(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, GetUserID(ctx))
}
-func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) {
+func (s *UtilSuite) expectTryUserID(register func(*gin.Context), expectedID *uint) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
- RegisterAuthentication(ctx, user, tokenUserID, "")
- actualID := TryGetUserID(ctx)
- assert.Equal(s.T(), expectedID, actualID)
+ register(ctx)
+ assert.Equal(s.T(), expectedID, TryGetUserID(ctx))
}
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..32ea0349 100644
--- a/docs/spec.json
+++ b/docs/spec.json
@@ -231,6 +231,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"consumes": [
"application/json"
],
@@ -618,7 +619,7 @@
"200": {
"description": "Ok",
"schema": {
- "$ref": "#/definitions/UserExternal"
+ "$ref": "#/definitions/CurrentUser"
},
"headers": {
"Set-Cookie": {
@@ -702,6 +703,9 @@
}
],
"responses": {
+ "200": {
+ "description": "ok"
+ },
"307": {
"description": "Redirect to UI"
},
@@ -714,6 +718,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.",
@@ -1034,6 +1076,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"consumes": [
"application/json"
],
@@ -1086,6 +1129,70 @@
}
}
},
+ "/client:elevate": {
+ "post": {
+ "security": [
+ {
+ "clientTokenAuthorizationHeader": []
+ },
+ {
+ "clientTokenHeader": []
+ },
+ {
+ "clientTokenQuery": []
+ },
+ {
+ "basicAuth": []
+ }
+ ],
+ "description": "Requires elevated authentication.",
+ "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": [
@@ -1102,6 +1209,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"produces": [
"application/json"
],
@@ -1148,6 +1256,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"consumes": [
"application/json"
],
@@ -1975,6 +2084,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"produces": [
"application/json"
],
@@ -2022,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"
],
@@ -2089,6 +2199,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"consumes": [
"application/json"
],
@@ -2158,6 +2269,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"consumes": [
"application/json"
],
@@ -2236,6 +2348,7 @@
"basicAuth": []
}
],
+ "description": "Requires elevated authentication.",
"produces": [
"application/json"
],
@@ -2432,6 +2545,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 +2632,79 @@
},
"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.",
+ "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..c7ff7f47
--- /dev/null
+++ b/model/elevate.go
@@ -0,0 +1,21 @@
+package model
+
+import "time"
+
+// 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"`
+}
+
+var DefaultElevationDuration = time.Hour
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/router/router.go b/router/router.go
index de05b1cc..2950b421 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)
@@ -185,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)
}
}
@@ -207,42 +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)
}
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
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"))
}
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})
}
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.
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/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/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 b74feba2..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)}
/>
))}
@@ -87,6 +98,14 @@ const Clients = observer(() => {
text={'Delete ' + toDeleteClient.name + '?'}
fClose={() => setToDeleteClient(undefined)}
fOnSubmit={() => clientStore.remove(toDeleteClient.id)}
+ requireElevated
+ />
+ )}
+ {toElevateClient != null && (
+ setToElevateClient(undefined)}
/>
)}
@@ -97,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}
@@ -113,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..fcc35520
--- /dev/null
+++ b/ui/src/client/ElevateClientDialog.tsx
@@ -0,0 +1,91 @@
+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 (
+
+ );
+});
+
+export default ElevateClientDialog;
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 (
);
-}
+});
+
+export default ConfirmDialog;
diff --git a/ui/src/common/ElevationForm.tsx b/ui/src/common/ElevationForm.tsx
new file mode 100644
index 00000000..487a27f8
--- /dev/null
+++ b/ui/src/common/ElevationForm.tsx
@@ -0,0 +1,99 @@
+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.
+
+ elevateStore.cleanupOidcElevate()}>
+ Cancel OIDC Login
+
+
+ );
+ }
+
+ return (
+ <>
+ This action requires re-authentication.
+
+
+ {oidcEnabled && (
+ <>
+ or
+ elevateStore.oidcElevate(ElevateDuration)}>
+ Elevate via OIDC
+
+ >
+ )}
+ >
+ );
+});
+
+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 (
);
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;
diff --git a/ui/src/tests/client.test.ts b/ui/src/tests/client.test.ts
index 3ae04663..643b52b5 100644
--- a/ui/src/tests/client.test.ts
+++ b/ui/src/tests/client.test.ts
@@ -1,6 +1,6 @@
import {Page} from 'puppeteer';
import {newTest, GotifyTest} from './setup';
-import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
+import {count, innerText, waitForExists, waitToDisappear, clearField, ClientCol} from './utils';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
import * as auth from './authentication';
@@ -15,24 +15,16 @@ beforeAll(async () => {
afterAll(async () => await gotify.close());
-enum Col {
- Name = 1,
- Token = 2,
- LastSeen = 3,
- Edit = 4,
- Delete = 5,
-}
-
const waitForClient =
(name: string, row: number): (() => Promise) =>
async () => {
- await waitForExists(page, $table.cell(row, Col.Name), name);
+ await waitForExists(page, $table.cell(row, ClientCol.Name), name);
};
const updateClient =
(id: number, data: {name?: string}): (() => Promise) =>
async () => {
- await page.click($table.cell(id, Col.Edit, '.edit'));
+ await page.click($table.cell(id, ClientCol.Edit, '.edit'));
await page.waitForSelector($dialog.selector());
if (data.name) {
const nameSelector = $dialog.input('.name');
@@ -77,21 +69,23 @@ describe('Client', () => {
expect(await count(page, $table.rows())).toBe(3);
- expect(await innerText(page, $table.cell(1, Col.Name))).toContain('chrome');
- expect(await innerText(page, $table.cell(2, Col.Name))).toBe('phone');
- expect(await innerText(page, $table.cell(3, Col.Name))).toBe('desktop app');
+ expect(await innerText(page, $table.cell(1, ClientCol.Name))).toContain('chrome');
+ expect(await innerText(page, $table.cell(2, ClientCol.Name))).toBe('phone');
+ expect(await innerText(page, $table.cell(3, ClientCol.Name))).toBe('desktop app');
});
it('updates client', updateClient(1, {name: 'firefox'}));
it('has updated client name', waitForClient('firefox', 1));
it('shows token', async () => {
- await page.click($table.cell(3, Col.Token, '.toggle-visibility'));
- expect((await innerText(page, $table.cell(3, Col.Token))).startsWith('C')).toBeTruthy();
+ await page.click($table.cell(3, ClientCol.Token, '.toggle-visibility'));
+ expect(
+ (await innerText(page, $table.cell(3, ClientCol.Token))).startsWith('C')
+ ).toBeTruthy();
});
it('shows last seen', async () => {
- expect(await innerText(page, $table.cell(3, Col.LastSeen))).toBeTruthy();
+ expect(await innerText(page, $table.cell(3, ClientCol.LastSeen))).toBeTruthy();
});
it('deletes client', async () => {
- await page.click($table.cell(2, Col.Delete, '.delete'));
+ await page.click($table.cell(2, ClientCol.Delete, '.delete'));
await page.waitForSelector(selector.$confirmDialog.selector());
await page.click(selector.$confirmDialog.button('.confirm'));
@@ -102,7 +96,7 @@ describe('Client', () => {
expect(await count(page, $table.rows())).toBe(2);
});
it('deletes own client', async () => {
- await page.click($table.cell(1, Col.Delete, '.delete'));
+ await page.click($table.cell(1, ClientCol.Delete, '.delete'));
// confirm delete
await page.waitForSelector(selector.$confirmDialog.selector());
diff --git a/ui/src/tests/elevation.test.ts b/ui/src/tests/elevation.test.ts
new file mode 100644
index 00000000..2c58e06a
--- /dev/null
+++ b/ui/src/tests/elevation.test.ts
@@ -0,0 +1,97 @@
+import {Page} from 'puppeteer';
+import {newTest, GotifyTest} from './setup';
+import {clickByText, ClientCol, count, waitForExists, waitToDisappear} from './utils';
+import {afterAll, beforeAll, describe, expect, it} from 'vitest';
+import * as auth from './authentication';
+import * as selector from './selector';
+
+let page: Page;
+let gotify: GotifyTest;
+beforeAll(async () => {
+ gotify = await newTest();
+ page = gotify.page;
+});
+
+afterAll(async () => await gotify.close());
+
+const $clientTable = selector.table('#client-table');
+const $clientDialog = selector.form('#client-dialog');
+
+// This expects the session to be already elevated.
+const cancelElevationViaUI = async (row: number) => {
+ await page.goto(gotify.url + '/#/clients');
+ await waitForExists(page, selector.heading(), 'Clients');
+
+ await page.click($clientTable.cell(row, ClientCol.Elevate, '.elevate'));
+ await page.waitForSelector('.elevate-client-dialog');
+
+ await page.click('.elevate-client-dialog .elevate-duration [role=combobox]');
+ await clickByText(page, '[role="option"]', 'Cancel elevation');
+ await waitToDisappear(page, '[role="listbox"]');
+
+ await page.click('.elevate-client-dialog .elevate-confirm');
+ await waitToDisappear(page, '.elevate-client-dialog');
+};
+
+const elevateViaForm = async (password: string) => {
+ const passwordInput = '.elevation-password input';
+ await page.waitForSelector(passwordInput);
+ await page.type(passwordInput, password);
+ await page.click('.elevation-submit');
+};
+
+describe('Elevation', () => {
+ it('does login', async () => await auth.login(page));
+
+ describe('setup', () => {
+ it('navigates to clients', async () => {
+ await page.click('#navigate-clients');
+ await waitForExists(page, selector.heading(), 'Clients');
+ });
+ it('creates a test client', async () => {
+ await page.click('#create-client');
+ await page.waitForSelector($clientDialog.selector());
+ await page.type($clientDialog.input('.name'), 'test-client');
+ await page.click($clientDialog.button('.create'));
+ await waitToDisappear(page, $clientDialog.selector());
+ await page.waitForSelector($clientTable.row(2));
+ expect(await count(page, $clientTable.rows())).toBe(2);
+ });
+ });
+
+ describe('Users page requires elevation', () => {
+ it('de-elevates the current client via UI', () => cancelElevationViaUI(1));
+ it('navigates to users and sees elevation form', async () => {
+ await page.goto(gotify.url + '/#/users');
+ await waitForExists(page, selector.heading(), 'Authentication Required');
+ await page.waitForSelector('.elevation-password input');
+ });
+ it('elevates via password and sees users page', async () => {
+ await elevateViaForm('admin');
+ await waitForExists(page, selector.heading(), 'Users');
+ expect(page.url()).toContain('/users');
+ });
+ });
+
+ describe('Client delete requires elevation', () => {
+ it('de-elevates the current client via UI', () => cancelElevationViaUI(1));
+ it('navigates to clients', async () => {
+ await page.goto(gotify.url + '/#/clients');
+ await waitForExists(page, selector.heading(), 'Clients');
+ });
+ it('clicks delete and sees elevation form in dialog', async () => {
+ await page.click($clientTable.cell(2, ClientCol.Delete, '.delete'));
+ await page.waitForSelector(selector.$confirmDialog.selector());
+ await page.waitForSelector('.confirm-dialog .elevation-password input');
+ });
+ it('elevates', () => elevateViaForm('admin'));
+ it('confirms deletion', async () => {
+ await page.waitForSelector(selector.$confirmDialog.button('.confirm'));
+ await page.click(selector.$confirmDialog.button('.confirm'));
+ });
+ it('has deleted the client', async () => {
+ await waitToDisappear(page, $clientTable.row(2));
+ expect(await count(page, $clientTable.rows())).toBe(1);
+ });
+ });
+});
diff --git a/ui/src/tests/utils.ts b/ui/src/tests/utils.ts
index 8330cf91..a8aa861a 100644
--- a/ui/src/tests/utils.ts
+++ b/ui/src/tests/utils.ts
@@ -67,3 +67,13 @@ export const clearField = async (element: ElementHandle | Page, selector: string
await elementHandle.click({clickCount: 3});
await elementHandle.press('Backspace');
};
+
+export enum ClientCol {
+ Name = 1,
+ Token = 2,
+ LastSeen = 3,
+ ElevationEnds = 4,
+ Elevate = 5,
+ Edit = 6,
+ Delete = 7,
+}
diff --git a/ui/src/types.ts b/ui/src/types.ts
index 40888d18..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 {
@@ -62,6 +63,11 @@ export interface IUser {
admin: boolean;
}
+export interface ICurrentUser extends IUser {
+ clientId?: number;
+ elevatedUntil?: string;
+}
+
export interface IVersion {
version: string;
commit: string;