From 86df116960fd538270086ca9ef793d19d68cf078 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 13:02:06 +0100 Subject: [PATCH 01/13] fix: simplify authentication handling --- auth/authentication.go | 253 ++++++++++++++++++++---------------- auth/authentication_test.go | 8 +- router/router.go | 12 +- 3 files changed, 148 insertions(+), 125 deletions(-) diff --git a/auth/authentication.go b/auth/authentication.go index 295d67b6..560f75d7 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -10,15 +10,20 @@ import ( "github.com/gotify/server/v2/model" ) +type authState int + const ( - headerName = "X-Gotify-Key" + authStateSkip authState = iota + authStateForbidden + authStateOk ) +const headerName = "X-Gotify-Key" + // The Database interface for encapsulating database access. type Database interface { GetApplicationByToken(token string) (*model.Application, error) GetClientByToken(token string) (*model.Client, error) - GetPluginConfByToken(token string) (*model.PluginConf, error) GetUserByName(name string) (*model.User, error) GetUserByID(id uint) (*model.User, error) UpdateClientTokensLastUsed(tokens []string, t *time.Time) error @@ -30,72 +35,154 @@ type Auth struct { DB Database } -type authenticate func(tokenID string, user *model.User) (authenticated, success bool, userId uint, err error) - // 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. -func (a *Auth) RequireAdmin() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, user.Admin, user.ID, nil - } - if token, err := a.DB.GetClientByToken(tokenID); err != nil { - return false, false, 0, err - } else if token != nil { - user, err := a.DB.GetUserByID(token.UserID) - if err != nil { - return false, false, token.UserID, err - } - return true, user.Admin, token.UserID, nil - } - return false, false, 0, nil - }) +func (a *Auth) RequireAdmin(ctx *gin.Context) { + a.evaluateOr401(ctx, a.user(true), a.client(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() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, true, user.ID, nil +func (a *Auth) RequireClient(ctx *gin.Context) { + a.evaluateOr401(ctx, a.user(false), a.client(false)) +} + +// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. +func (a *Auth) RequireApplicationToken(ctx *gin.Context) { + if a.evaluate(ctx, a.application) { + return + } + state, err := a.user(false)(ctx) + if err != nil { + ctx.AbortWithError(500, err) + } + if state != authStateSkip { + // Return to the user that it's valid authentication, but we don't allow user auth for application endpoints. + a.abort403(ctx) + return + } + a.abort401(ctx) +} + +func (a *Auth) Optional(ctx *gin.Context) { + if !a.evaluate(ctx, a.user(false), a.client(false)) { + RegisterAuthentication(ctx, nil, 0, "") + ctx.Next() + } +} + +func (a *Auth) evaluate(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) bool { + for _, fn := range funcs { + state, err := fn(ctx) + if err != nil { + ctx.AbortWithError(500, err) + return true } - if client, err := a.DB.GetClientByToken(tokenID); err != nil { - return false, false, 0, err - } else if client != nil { - now := time.Now() - if client.LastUsed == nil || client.LastUsed.Add(5*time.Minute).Before(now) { - if err := a.DB.UpdateClientTokensLastUsed([]string{tokenID}, &now); err != nil { - return false, false, 0, err + switch state { + case authStateForbidden: + a.abort403(ctx) + return true + case authStateOk: + ctx.Next() + return true + case authStateSkip: + continue + } + } + return false +} + +func (a *Auth) evaluateOr401(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authState, error)) { + if !a.evaluate(ctx, funcs...) { + a.abort401(ctx) + } +} + +func (a *Auth) abort401(ctx *gin.Context) { + ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api")) +} + +func (a *Auth) abort403(ctx *gin.Context) { + ctx.AbortWithError(403, errors.New("you are not allowed to access this api")) +} + +func (a *Auth) user(requireAdmin bool) func(ctx *gin.Context) (authState, error) { + return func(ctx *gin.Context) (authState, error) { + if name, pass, ok := ctx.Request.BasicAuth(); ok { + 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, "") + + if requireAdmin && !user.Admin { + return authStateForbidden, nil } + return authStateOk, nil } - return true, true, client.UserID, nil } - return false, false, 0, nil - }) + return authStateSkip, nil + } } -// RequireApplicationToken returns a gin middleware which requires an application token to be supplied with the request. -func (a *Auth) RequireApplicationToken() gin.HandlerFunc { - return a.requireToken(func(tokenID string, user *model.User) (bool, bool, uint, error) { - if user != nil { - return true, false, 0, nil +func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, error) { + return func(ctx *gin.Context) (authState, error) { + token := a.readTokenFromRequest(ctx) + if token == "" { + return authStateSkip, nil } - if app, err := a.DB.GetApplicationByToken(tokenID); err != nil { - return false, false, 0, err - } else if app != nil { - now := time.Now() - if app.LastUsed == nil || app.LastUsed.Add(5*time.Minute).Before(now) { - if err := a.DB.UpdateApplicationTokenLastUsed(tokenID, &now); err != nil { - return false, false, 0, err - } + client, err := a.DB.GetClientByToken(token) + if err != nil { + return authStateSkip, err + } + if client == nil { + return authStateSkip, nil + } + RegisterAuthentication(ctx, nil, client.UserID, client.Token) + + now := time.Now() + 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 } - return true, true, app.UserID, nil } - return false, false, 0, nil - }) + + if requireAdmin { + if user, err := a.DB.GetUserByID(client.UserID); err != nil { + return authStateSkip, err + } else if !user.Admin { + return authStateForbidden, nil + } + } + + return authStateOk, nil + } +} + +func (a *Auth) application(ctx *gin.Context) (authState, error) { + token := a.readTokenFromRequest(ctx) + if token == "" { + return authStateSkip, nil + } + app, err := a.DB.GetApplicationByToken(token) + if err != nil { + return authStateSkip, err + } + if app == nil { + return authStateSkip, nil + } + RegisterAuthentication(ctx, nil, app.UserID, app.Token) + + now := time.Now() + 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 + } + } + + return authStateOk, nil } -func (a *Auth) tokenFromQueryOrHeader(ctx *gin.Context) string { +func (a *Auth) readTokenFromRequest(ctx *gin.Context) string { if token := a.tokenFromQuery(ctx); token != "" { return token } else if token := a.tokenFromXGotifyHeader(ctx); token != "" { @@ -128,67 +215,3 @@ func (a *Auth) tokenFromAuthorizationHeader(ctx *gin.Context) string { return authHeader[len(prefix):] } - -func (a *Auth) userFromBasicAuth(ctx *gin.Context) (*model.User, error) { - if name, pass, ok := ctx.Request.BasicAuth(); ok { - if user, err := a.DB.GetUserByName(name); err != nil { - return nil, err - } else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) { - return user, nil - } - } - return nil, nil -} - -func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc { - return func(ctx *gin.Context) { - token := a.tokenFromQueryOrHeader(ctx) - user, err := a.userFromBasicAuth(ctx) - if err != nil { - ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) - return - } - - if user != nil || token != "" { - authenticated, ok, userID, err := auth(token, user) - if err != nil { - ctx.AbortWithError(500, errors.New("an error occurred while authenticating user")) - return - } else if ok { - RegisterAuthentication(ctx, user, userID, token) - ctx.Next() - return - } else if authenticated { - ctx.AbortWithError(403, errors.New("you are not allowed to access this api")) - return - } - } - ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api")) - } -} - -func (a *Auth) Optional() gin.HandlerFunc { - return func(ctx *gin.Context) { - token := a.tokenFromQueryOrHeader(ctx) - user, err := a.userFromBasicAuth(ctx) - if err != nil { - RegisterAuthentication(ctx, nil, 0, "") - ctx.Next() - return - } - - if user != nil { - RegisterAuthentication(ctx, user, user.ID, token) - ctx.Next() - return - } else if token != "" { - if tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil { - RegisterAuthentication(ctx, user, tokenClient.UserID, token) - ctx.Next() - return - } - } - RegisterAuthentication(ctx, nil, 0, "") - ctx.Next() - } -} diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 92efff43..092ef98f 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -82,7 +82,7 @@ func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddlewar recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/?%s=%s", key, value), nil) - f()(ctx) + f(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } @@ -91,7 +91,7 @@ func (s *AuthenticationSuite) TestNothingProvided() { recorder := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) - s.auth.RequireApplicationToken()(ctx) + s.auth.RequireApplicationToken(ctx) assert.Equal(s.T(), 401, recorder.Code) } @@ -215,9 +215,9 @@ func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddlewa ctx, _ = gin.CreateTestContext(recorder) ctx.Request = httptest.NewRequest("GET", "/", nil) ctx.Request.Header.Set(key, value) - f()(ctx) + f(ctx) assert.Equal(s.T(), code, recorder.Code) return ctx } -type fMiddleware func() gin.HandlerFunc +type fMiddleware gin.HandlerFunc diff --git a/router/router.go b/router/router.go index 8b191373..2c166b33 100644 --- a/router/router.go +++ b/router/router.go @@ -120,8 +120,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co g.Use(cors.New(auth.CorsConfig(conf))) { - g.GET("/plugin", authentication.RequireClient(), pluginHandler.GetPlugins) - pluginRoute := g.Group("/plugin/", authentication.RequireClient()) + g.GET("/plugin", authentication.RequireClient, pluginHandler.GetPlugins) + pluginRoute := g.Group("/plugin/", authentication.RequireClient) { pluginRoute.GET("/:id/config", pluginHandler.GetConfig) pluginRoute.POST("/:id/config", pluginHandler.UpdateConfig) @@ -131,7 +131,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } } - g.Group("/user").Use(authentication.Optional()).POST("", userHandler.CreateUser) + g.Group("/user").Use(authentication.Optional).POST("", userHandler.CreateUser) g.OPTIONS("/*any") @@ -150,11 +150,11 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co ctx.JSON(200, vInfo) }) - g.Group("/").Use(authentication.RequireApplicationToken()).POST("/message", messageHandler.CreateMessage) + g.Group("/").Use(authentication.RequireApplicationToken).POST("/message", messageHandler.CreateMessage) clientAuth := g.Group("") { - clientAuth.Use(authentication.RequireClient()) + clientAuth.Use(authentication.RequireClient) app := clientAuth.Group("/application") { app.GET("", applicationHandler.GetApplications) @@ -206,7 +206,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co authAdmin := g.Group("/user") { - authAdmin.Use(authentication.RequireAdmin()) + authAdmin.Use(authentication.RequireAdmin) authAdmin.GET("", userHandler.GetUsers) From e7234fdc1f5f3fecfe6528e648552b2a9bc637eb Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 11:34:04 +0100 Subject: [PATCH 02/13] fix: docs indention --- docs/package.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/package.go b/docs/package.go index 9597adfa..64589359 100644 --- a/docs/package.go +++ b/docs/package.go @@ -35,21 +35,21 @@ // type: apiKey // name: token // in: query -// appTokenHeader: +// appTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header -// clientTokenHeader: +// clientTokenHeader: // type: apiKey // name: X-Gotify-Key // in: header -// appTokenAuthorizationHeader: +// appTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header // description: >- // Enter an application token with the `Bearer` prefix, e.g. `Bearer Axxxxxxxxxx`. -// clientTokenAuthorizationHeader: +// clientTokenAuthorizationHeader: // type: apiKey // name: Authorization // in: header From 0efef4968344e35d4a48bcad2d64b7c72f2e1d08 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 11:31:23 +0100 Subject: [PATCH 03/13] fix: read token from cookie --- auth/authentication.go | 15 ++++++++++++++- auth/authentication_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/auth/authentication.go b/auth/authentication.go index 560f75d7..37c9e31a 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -18,7 +18,10 @@ const ( authStateOk ) -const headerName = "X-Gotify-Key" +const ( + headerName = "X-Gotify-Key" + cookieName = "gotify-client-token" +) // The Database interface for encapsulating database access. type Database interface { @@ -189,10 +192,20 @@ func (a *Auth) readTokenFromRequest(ctx *gin.Context) string { return token } else if token := a.tokenFromAuthorizationHeader(ctx); token != "" { return token + } else if token := a.tokenFromCookie(ctx); token != "" { + return token } return "" } +func (a *Auth) tokenFromCookie(ctx *gin.Context) string { + token, err := ctx.Cookie(cookieName) + if err != nil { + return "" + } + return token +} + func (a *Auth) tokenFromQuery(ctx *gin.Context) string { return ctx.Request.URL.Query().Get("token") } diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 092ef98f..3aab3fb2 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "net/http" "net/http/httptest" "testing" @@ -210,6 +211,35 @@ func (s *AuthenticationSuite) TestOptionalAuth() { assert.Equal(s.T(), uint(2), *TryGetUserID(ctx)) } +func (s *AuthenticationSuite) TestCookieToken() { + // not existing token + s.assertCookieRequest("ergerogerg", s.auth.RequireApplicationToken, 401) + s.assertCookieRequest("ergerogerg", s.auth.RequireClient, 401) + s.assertCookieRequest("ergerogerg", s.auth.RequireAdmin, 401) + + // apptoken + s.assertCookieRequest("apptoken", s.auth.RequireApplicationToken, 200) + s.assertCookieRequest("apptoken", s.auth.RequireClient, 401) + s.assertCookieRequest("apptoken", s.auth.RequireAdmin, 401) + + // clienttoken + s.assertCookieRequest("clienttoken", s.auth.RequireApplicationToken, 401) + s.assertCookieRequest("clienttoken", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken", s.auth.RequireAdmin, 403) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireClient, 200) + s.assertCookieRequest("clienttoken_admin", s.auth.RequireAdmin, 200) +} + +func (s *AuthenticationSuite) assertCookieRequest(token string, f fMiddleware, code int) (ctx *gin.Context) { + recorder := httptest.NewRecorder() + ctx, _ = gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + f(ctx) + assert.Equal(s.T(), code, recorder.Code) + return ctx +} + func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) { recorder := httptest.NewRecorder() ctx, _ = gin.CreateTestContext(recorder) From 1774781223ab90c5e9680899540050076623e305 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 13:12:53 +0100 Subject: [PATCH 04/13] feat: implement /auth/local/{login,logout} --- api/session.go | 97 +++++++++++++++++++++++++++++ api/session_test.go | 137 +++++++++++++++++++++++++++++++++++++++++ auth/authentication.go | 22 ++++--- auth/cookie.go | 22 +++++++ auth/util.go | 4 ++ router/router.go | 5 ++ 6 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 api/session.go create mode 100644 api/session_test.go create mode 100644 auth/cookie.go diff --git a/api/session.go b/api/session.go new file mode 100644 index 00000000..23dde4b0 --- /dev/null +++ b/api/session.go @@ -0,0 +1,97 @@ +package api + +import ( + "errors" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/auth/password" + "github.com/gotify/server/v2/model" +) + +// SessionDatabase is the interface for session-related database access. +type SessionDatabase interface { + GetUserByName(name string) (*model.User, error) + CreateClient(client *model.Client) error + GetClientByToken(token string) (*model.Client, error) + DeleteClientByID(id uint) error +} + +// SessionAPI provides handlers for cookie-based session authentication. +type SessionAPI struct { + DB SessionDatabase + NotifyDeleted func(uint, string) +} + +// Login authenticates via basic auth, creates a client, sets an HttpOnly cookie, and returns user info. +func (a *SessionAPI) Login(ctx *gin.Context) { + name, pass, ok := ctx.Request.BasicAuth() + if !ok { + ctx.AbortWithError(401, errors.New("basic auth required")) + return + } + + user, err := a.DB.GetUserByName(name) + if err != nil { + ctx.AbortWithError(500, err) + return + } + if user == nil || !password.ComparePassword(user.Pass, []byte(pass)) { + ctx.AbortWithError(401, errors.New("invalid credentials")) + return + } + + clientParams := ClientParams{} + if err := ctx.Bind(&clientParams); err != nil { + return + } + + client := model.Client{ + Name: clientParams.Name, + Token: auth.GenerateNotExistingToken(generateClientToken, a.clientExists), + UserID: user.ID, + } + if success := successOrAbort(ctx, 500, a.DB.CreateClient(&client)); !success { + return + } + + auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge) + + ctx.JSON(200, &model.UserExternal{ + ID: user.ID, + Name: user.Name, + Admin: user.Admin, + }) +} + +// Logout deletes the client for the current session and clears the cookie. +func (a *SessionAPI) Logout(ctx *gin.Context) { + auth.SetCookie(ctx.Writer, "", -1) + + 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 + } + if client == nil { + ctx.Status(200) + return + } + + a.NotifyDeleted(client.UserID, client.Token) + if success := successOrAbort(ctx, 500, a.DB.DeleteClientByID(client.ID)); !success { + return + } + + ctx.Status(200) +} + +func (a *SessionAPI) clientExists(token string) bool { + client, _ := a.DB.GetClientByToken(token) + return client != nil +} diff --git a/api/session_test.go b/api/session_test.go new file mode 100644 index 00000000..d4afcad2 --- /dev/null +++ b/api/session_test.go @@ -0,0 +1,137 @@ +package api + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/auth/password" + "github.com/gotify/server/v2/mode" + "github.com/gotify/server/v2/model" + "github.com/gotify/server/v2/test" + "github.com/gotify/server/v2/test/testdb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func TestSessionSuite(t *testing.T) { + suite.Run(t, new(SessionSuite)) +} + +type SessionSuite struct { + suite.Suite + db *testdb.Database + a *SessionAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + notified bool +} + +func (s *SessionSuite) BeforeTest(suiteName, testName string) { + mode.Set(mode.TestDev) + s.recorder = httptest.NewRecorder() + s.db = testdb.NewDB(s.T()) + s.ctx, _ = gin.CreateTestContext(s.recorder) + withURL(s.ctx, "http", "example.com") + s.notified = false + s.a = &SessionAPI{DB: s.db, NotifyDeleted: s.notify} + + s.db.CreateUser(&model.User{ + Name: "testuser", + Pass: password.CreatePassword("testpass", 5), + }) +} + +func (s *SessionSuite) notify(uint, string) { + s.notified = true +} + +func (s *SessionSuite) AfterTest(suiteName, testName string) { + s.db.Close() +} + +func (s *SessionSuite) Test_Login_Success() { + originalGenerateClientToken := generateClientToken + defer func() { generateClientToken = originalGenerateClientToken }() + generateClientToken = test.Tokens("Ctesttoken12345") + + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))) + + s.a.Login(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + + // Verify HttpOnly cookie is set + cookies := s.recorder.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.CookieName { + sessionCookie = c + break + } + } + assert.NotNil(s.T(), sessionCookie) + assert.Equal(s.T(), "Ctesttoken12345", sessionCookie.Value) + assert.True(s.T(), sessionCookie.HttpOnly) + assert.Equal(s.T(), "/", sessionCookie.Path) + assert.Equal(s.T(), http.SameSiteStrictMode, sessionCookie.SameSite) + + body := s.recorder.Body.String() + assert.Contains(s.T(), body, "testuser") + assert.NotContains(s.T(), body, "Ctesttoken12345") + + clients, err := s.db.GetClientsByUser(1) + assert.NoError(s.T(), err) + assert.Len(s.T(), clients, 1) + assert.Equal(s.T(), "test-browser", clients[0].Name) +} + +func (s *SessionSuite) Test_Login_WrongPassword() { + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/login", strings.NewReader("name=test-browser")) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.ctx.Request.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("testuser:wrongpass"))) + + s.a.Login(s.ctx) + + assert.Equal(s.T(), 401, s.recorder.Code) + + // No cookie should be set + cookies := s.recorder.Result().Cookies() + for _, c := range cookies { + assert.NotEqual(s.T(), auth.CookieName, c.Name) + } +} + +func (s *SessionSuite) Test_Logout_Success() { + builder := s.db.User(5) + builder.ClientWithToken(1, "Ctesttoken12345") + + s.ctx.Request = httptest.NewRequest("POST", "/auth/local/logout", nil) + test.WithUser(s.ctx, 5) + s.ctx.Set("tokenid", "Ctesttoken12345") + + s.a.Logout(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.True(s.T(), s.notified) + + cookies := s.recorder.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.CookieName { + sessionCookie = c + break + } + } + assert.NotNil(s.T(), sessionCookie) + assert.Equal(s.T(), "", sessionCookie.Value) + assert.True(s.T(), sessionCookie.MaxAge < 0) + + s.db.AssertClientNotExist(1) +} diff --git a/auth/authentication.go b/auth/authentication.go index 37c9e31a..94ace207 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -129,7 +129,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) { return func(ctx *gin.Context) (authState, error) { - token := a.readTokenFromRequest(ctx) + token, isCookie := a.readTokenFromRequest(ctx) if token == "" { return authStateSkip, nil } @@ -147,6 +147,9 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro if err := a.DB.UpdateClientTokensLastUsed([]string{client.Token}, &now); err != nil { return authStateSkip, err } + if isCookie { + SetCookie(ctx.Writer, client.Token, CookieMaxAge) + } } if requireAdmin { @@ -162,7 +165,7 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro } func (a *Auth) application(ctx *gin.Context) (authState, error) { - token := a.readTokenFromRequest(ctx) + token, isCookie := a.readTokenFromRequest(ctx) if token == "" { return authStateSkip, nil } @@ -180,22 +183,25 @@ func (a *Auth) application(ctx *gin.Context) (authState, error) { if err := a.DB.UpdateApplicationTokenLastUsed(app.Token, &now); err != nil { return authStateSkip, err } + if isCookie { + SetCookie(ctx.Writer, app.Token, CookieMaxAge) + } } return authStateOk, nil } -func (a *Auth) readTokenFromRequest(ctx *gin.Context) string { +func (a *Auth) readTokenFromRequest(ctx *gin.Context) (string, bool) { if token := a.tokenFromQuery(ctx); token != "" { - return token + return token, false } else if token := a.tokenFromXGotifyHeader(ctx); token != "" { - return token + return token, false } else if token := a.tokenFromAuthorizationHeader(ctx); token != "" { - return token + return token, false } else if token := a.tokenFromCookie(ctx); token != "" { - return token + return token, true } - return "" + return "", false } func (a *Auth) tokenFromCookie(ctx *gin.Context) string { diff --git a/auth/cookie.go b/auth/cookie.go new file mode 100644 index 00000000..32e2ccf6 --- /dev/null +++ b/auth/cookie.go @@ -0,0 +1,22 @@ +package auth + +import ( + "net/http" +) + +// CookieMaxAge is the lifetime of the session cookie in seconds (7 days). +const CookieMaxAge = 7 * 24 * 60 * 60 + +const CookieName = "gotify-client-token" + +func SetCookie(w http.ResponseWriter, token string, maxAge int) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + MaxAge: maxAge, + Secure: true, // TODO: does this work? + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} diff --git a/auth/util.go b/auth/util.go index 156648ec..6f380e12 100644 --- a/auth/util.go +++ b/auth/util.go @@ -39,3 +39,7 @@ func TryGetUserID(ctx *gin.Context) *uint { func GetTokenID(ctx *gin.Context) string { return ctx.MustGet("tokenid").(string) } + +func TryGetTokenID(ctx *gin.Context) string { + return ctx.GetString("tokenid") +} diff --git a/router/router.go b/router/router.go index 2c166b33..925c1afc 100644 --- a/router/router.go +++ b/router/router.go @@ -86,6 +86,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co DB: db, ImageDir: conf.UploadedImagesDir, } + sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient} userChangeNotifier := new(api.UserChangeNotifier) userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration} @@ -133,6 +134,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co g.Group("/user").Use(authentication.Optional).POST("", userHandler.CreateUser) + g.POST("/auth/local/login", sessionHandler.Login) + g.OPTIONS("/*any") // swagger:operation GET /version version getVersion @@ -202,6 +205,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co clientAuth.GET("current/user", userHandler.GetCurrentUser) clientAuth.POST("current/user/password", userHandler.ChangePassword) + + clientAuth.POST("/auth/local/logout", sessionHandler.Logout) } authAdmin := g.Group("/user") From 862fa3b6e3cecbb965acf71abe04526365f3f46d Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 11:40:18 +0100 Subject: [PATCH 05/13] fix: use cookie login in UI --- ui/src/CurrentUser.ts | 75 +++++++++----------------------- ui/src/apiAuth.ts | 7 --- ui/src/message/WebSocketStore.ts | 7 ++- ui/vite.config.ts | 2 +- 4 files changed, 27 insertions(+), 64 deletions(-) diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index 2510de91..e0df3e24 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -3,12 +3,9 @@ import * as config from './config'; import {detect} from 'detect-browser'; import {SnackReporter} from './snack/SnackManager'; import {observable, runInAction, action} from 'mobx'; -import {IClient, IUser} from './types'; - -const tokenKey = 'gotify-login-key'; +import {IUser} from './types'; export class CurrentUser { - private tokenCache: string | null = null; private reconnectTimeoutId: number | null = null; private reconnectTime = 7500; @observable accessor loggedIn = false; @@ -19,25 +16,6 @@ export class CurrentUser { public constructor(private readonly snack: SnackReporter) {} - public token = (): string => { - if (this.tokenCache !== null) { - return this.tokenCache; - } - - const localStorageToken = window.localStorage.getItem(tokenKey); - if (localStorageToken) { - this.tokenCache = localStorageToken; - return localStorageToken; - } - - return ''; - }; - - private readonly setToken = (token: string) => { - this.tokenCache = token; - window.localStorage.setItem(tokenKey, token); - }; - public register = async (name: string, pass: string): Promise => axios .create() @@ -70,20 +48,21 @@ export class CurrentUser { axios .create() .request({ - url: config.get('url') + 'client', + url: config.get('url') + 'auth/local/login', method: 'POST', data: {name}, headers: {Authorization: 'Basic ' + btoa(username + ':' + password)}, }) - .then((resp: AxiosResponse) => { - this.snack(`A client named '${name}' was created for your session.`); - this.setToken(resp.data.token); - this.tryAuthenticate().catch(() => { - console.log( - 'create client succeeded, but authenticated with given token failed' - ); - }); - }) + .then( + action((resp: AxiosResponse) => { + this.snack(`A client named '${name}' was created for your session.`); + this.user = resp.data; + this.loggedIn = true; + this.authenticating = false; + this.connectionErrorMessage = null; + this.reconnectTime = 7500; + }) + ) .catch( action(() => { this.authenticating = false; @@ -93,16 +72,9 @@ export class CurrentUser { }; public tryAuthenticate = async (): Promise> => { - if (this.token() === '') { - runInAction(() => { - this.authenticating = false; - }); - return Promise.reject(); - } - return axios .create() - .get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}}) + .get(config.get('url') + 'current/user') .then( action((passThrough) => { this.user = passThrough.data; @@ -139,19 +111,14 @@ export class CurrentUser { }; public logout = async () => { - await axios - .get(config.get('url') + 'client') - .then((resp: AxiosResponse) => { - resp.data - .filter((client) => client.token === this.tokenCache) - .forEach((client) => axios.delete(config.get('url') + 'client/' + client.id)); - }) - .catch(() => Promise.resolve()); - window.localStorage.removeItem(tokenKey); - this.tokenCache = null; - runInAction(() => { - this.loggedIn = false; - }); + if (this.loggedIn) { + runInAction(() => { + this.loggedIn = false; + }); + await axios + .post(config.get('url') + 'auth/local/logout') + .catch(() => Promise.resolve()); + } }; public changePassword = (pass: string) => { diff --git a/ui/src/apiAuth.ts b/ui/src/apiAuth.ts index 183c8d66..7df7ad5d 100644 --- a/ui/src/apiAuth.ts +++ b/ui/src/apiAuth.ts @@ -3,13 +3,6 @@ import {CurrentUser} from './CurrentUser'; import {SnackReporter} from './snack/SnackManager'; export const initAxios = (currentUser: CurrentUser, snack: SnackReporter) => { - axios.interceptors.request.use((config) => { - if (!config.headers.has('x-gotify-key')) { - config.headers['x-gotify-key'] = currentUser.token(); - } - return config; - }); - axios.interceptors.response.use(undefined, (error) => { if (!error.response) { snack('Gotify server is not reachable, try refreshing the page.'); diff --git a/ui/src/message/WebSocketStore.ts b/ui/src/message/WebSocketStore.ts index 14a158fd..37740386 100644 --- a/ui/src/message/WebSocketStore.ts +++ b/ui/src/message/WebSocketStore.ts @@ -14,13 +14,13 @@ export class WebSocketStore { ) {} public listen = (callback: (msg: IMessage) => void) => { - if (!this.currentUser.token() || this.wsActive) { + if (!this.currentUser.loggedIn || this.wsActive) { return; } this.wsActive = true; const wsUrl = config.get('url').replace('http', 'ws').replace('https', 'wss'); - const ws = new WebSocket(wsUrl + 'stream?token=' + this.currentUser.token()); + const ws = new WebSocket(wsUrl + 'stream'); ws.onerror = (e) => { this.wsActive = false; @@ -31,6 +31,9 @@ export class WebSocketStore { ws.onclose = () => { this.wsActive = false; + if (!this.currentUser.loggedIn) { + return; + } this.currentUser .tryAuthenticate() .then(() => { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8086ef7d..730b02f6 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ server: { host: '0.0.0.0', proxy: { - '^/(application|message|client|current|user|plugin|version|image)': { + '^/(application|message|client|current|user|plugin|version|image|auth)': { target: `http://localhost:${GOTIFY_SERVER_PORT}/`, changeOrigin: true, secure: false, From 9a51dd70e5088ccd291b8d4a36a0d3ae0268d12f Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 13:30:06 +0100 Subject: [PATCH 06/13] fix: make secure cookie configurable --- api/session.go | 5 +++-- auth/authentication.go | 7 ++++--- auth/authentication_test.go | 2 +- auth/cookie.go | 4 ++-- config.example.yml | 1 + config/config.go | 1 + router/router.go | 4 ++-- 7 files changed, 14 insertions(+), 10 deletions(-) diff --git a/api/session.go b/api/session.go index 23dde4b0..5e581484 100644 --- a/api/session.go +++ b/api/session.go @@ -21,6 +21,7 @@ type SessionDatabase interface { type SessionAPI struct { DB SessionDatabase NotifyDeleted func(uint, string) + SecureCookie bool } // Login authenticates via basic auth, creates a client, sets an HttpOnly cookie, and returns user info. @@ -55,7 +56,7 @@ func (a *SessionAPI) Login(ctx *gin.Context) { return } - auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge) + auth.SetCookie(ctx.Writer, client.Token, auth.CookieMaxAge, a.SecureCookie) ctx.JSON(200, &model.UserExternal{ ID: user.ID, @@ -66,7 +67,7 @@ func (a *SessionAPI) Login(ctx *gin.Context) { // Logout deletes the client for the current session and clears the cookie. func (a *SessionAPI) Logout(ctx *gin.Context) { - auth.SetCookie(ctx.Writer, "", -1) + auth.SetCookie(ctx.Writer, "", -1, a.SecureCookie) tokenID := auth.TryGetTokenID(ctx) if tokenID == "" { diff --git a/auth/authentication.go b/auth/authentication.go index 94ace207..df84f6c5 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -35,7 +35,8 @@ type Database interface { // Auth is the provider for authentication middleware. type Auth struct { - DB Database + DB Database + SecureCookie bool } // RequireAdmin returns a gin middleware which requires a client token or basic authentication header to be supplied @@ -148,7 +149,7 @@ func (a *Auth) client(requireAdmin bool) func(ctx *gin.Context) (authState, erro return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, client.Token, CookieMaxAge) + SetCookie(ctx.Writer, client.Token, CookieMaxAge, a.SecureCookie) } } @@ -184,7 +185,7 @@ func (a *Auth) application(ctx *gin.Context) (authState, error) { return authStateSkip, err } if isCookie { - SetCookie(ctx.Writer, app.Token, CookieMaxAge) + SetCookie(ctx.Writer, app.Token, CookieMaxAge, a.SecureCookie) } } diff --git a/auth/authentication_test.go b/auth/authentication_test.go index 3aab3fb2..154a67ad 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -28,7 +28,7 @@ type AuthenticationSuite struct { func (s *AuthenticationSuite) SetupSuite() { mode.Set(mode.TestDev) s.DB = testdb.NewDB(s.T()) - s.auth = &Auth{s.DB} + s.auth = &Auth{DB: s.DB} s.DB.CreateUser(&model.User{ Name: "existing", diff --git a/auth/cookie.go b/auth/cookie.go index 32e2ccf6..ed68ca40 100644 --- a/auth/cookie.go +++ b/auth/cookie.go @@ -9,13 +9,13 @@ const CookieMaxAge = 7 * 24 * 60 * 60 const CookieName = "gotify-client-token" -func SetCookie(w http.ResponseWriter, token string, maxAge int) { +func SetCookie(w http.ResponseWriter, token string, maxAge int, secure bool) { http.SetCookie(w, &http.Cookie{ Name: CookieName, Value: token, Path: "/", MaxAge: maxAge, - Secure: true, // TODO: does this work? + Secure: secure, HttpOnly: true, SameSite: http.SameSiteStrictMode, }) diff --git a/config.example.yml b/config.example.yml index 393e9ea4..95bb986c 100644 --- a/config.example.yml +++ b/config.example.yml @@ -31,6 +31,7 @@ server: trustedproxies: # IPs or IP ranges of trusted proxies. Used to obtain the remote ip via the X-Forwarded-For header. (configure 127.0.0.1 to trust sockets) # - 127.0.0.1/32 # - ::1 + securecookie: false # If the secure flag should be set on cookies. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#secure cors: # Sets cors headers only when needed and provides support for multiple allowed origins. Overrides Access-Control-* Headers in response headers. alloworigins: diff --git a/config/config.go b/config/config.go index 32bd788d..22fa033e 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,7 @@ type Configuration struct { } TrustedProxies []string + SecureCookie bool `default:"false"` } Database struct { Dialect string `default:"sqlite3"` diff --git a/router/router.go b/router/router.go index 925c1afc..94918387 100644 --- a/router/router.go +++ b/router/router.go @@ -74,7 +74,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co db.UpdateClientTokensLastUsed(connectedTokens, &now) } }() - authentication := auth.Auth{DB: db} + authentication := auth.Auth{DB: db, SecureCookie: conf.Server.SecureCookie} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} clientHandler := api.ClientAPI{ @@ -86,7 +86,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co DB: db, ImageDir: conf.UploadedImagesDir, } - sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient} + sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient, SecureCookie: conf.Server.SecureCookie} userChangeNotifier := new(api.UserChangeNotifier) userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration} From e072c64cb64b2770020e37cdce5fb318fca39f6c Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 22 Mar 2026 13:57:18 +0100 Subject: [PATCH 07/13] feat: oidc --- api/oidc.go | 206 ++++++++++++++++++++++++++++++++++++++++++ config.example.yml | 8 ++ config/config.go | 10 ++ docs/spec.json | 67 ++++++++++++++ go.mod | 16 ++++ go.sum | 51 ++++++++++- router/router.go | 9 +- ui/serve.go | 5 +- ui/src/CurrentUser.ts | 8 +- ui/src/config.ts | 2 + ui/src/user/Login.tsx | 19 ++++ 11 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 api/oidc.go diff --git a/api/oidc.go b/api/oidc.go new file mode 100644 index 00000000..bc7ba965 --- /dev/null +++ b/api/oidc.go @@ -0,0 +1,206 @@ +package api + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/v2/auth" + "github.com/gotify/server/v2/config" + "github.com/gotify/server/v2/database" + "github.com/gotify/server/v2/model" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNotifier *UserChangeNotifier) *OIDCAPI { + scopes := conf.OIDC.Scopes + if len(scopes) == 0 { + scopes = []string{"openid", "profile", "email"} + } + + cookieKey := make([]byte, 32) + if _, err := rand.Read(cookieKey); err != nil { + log.Fatalf("failed to generate OIDC cookie key: %v", err) + } + cookieHandlerOpt := []httphelper.CookieHandlerOpt{} + if !conf.Server.SecureCookie { + cookieHandlerOpt = append(cookieHandlerOpt, httphelper.WithUnsecure()) + } + cookieHandler := httphelper.NewCookieHandler(cookieKey, cookieKey, cookieHandlerOpt...) + + opts := []rp.Option{rp.WithCookieHandler(cookieHandler), rp.WithPKCE(cookieHandler)} + + provider, err := rp.NewRelyingPartyOIDC( + context.Background(), + conf.OIDC.Issuer, + conf.OIDC.ClientID, + conf.OIDC.ClientSecret, + conf.OIDC.RedirectURL, + scopes, + opts..., + ) + if err != nil { + log.Fatalf("failed to initialize OIDC provider: %v", err) + } + + return &OIDCAPI{ + DB: db, + Provider: provider, + UserChangeNotifier: userChangeNotifier, + UsernameClaim: conf.OIDC.UsernameClaim, + PasswordStrength: conf.PassStrength, + SecureCookie: conf.Server.SecureCookie, + AutoRegister: conf.OIDC.AutoRegister, + } +} + +// OIDCAPI provides handlers for OIDC authentication. +type OIDCAPI struct { + DB *database.GormDatabase + Provider rp.RelyingParty + UserChangeNotifier *UserChangeNotifier + UsernameClaim string + PasswordStrength int + SecureCookie bool + AutoRegister bool +} + +// swagger:operation GET /auth/oidc/login oidc oidcLogin +// +// Start the OIDC login flow (browser). +// +// Redirects the user to the OIDC provider's authorization endpoint. +// After authentication, the provider redirects back to the callback endpoint. +// +// --- +// parameters: +// - name: name +// in: query +// description: the client name to create after login +// required: true +// type: string +// responses: +// 302: +// description: Redirect to OIDC provider +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) LoginHandler() gin.HandlerFunc { + return gin.WrapF(func(w http.ResponseWriter, r *http.Request) { + clientName := r.URL.Query().Get("name") + if clientName == "" { + http.Error(w, "invalid client name", http.StatusBadRequest) + return + } + state, err := a.generateState(clientName) + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) + return + } + rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r) + }) +} + +// swagger:operation GET /auth/oidc/callback oidc oidcCallback +// +// Handle the OIDC provider callback (browser). +// +// Exchanges the authorization code for tokens, resolves the user, +// creates a gotify client, sets a session cookie, and redirects to the UI. +// +// --- +// parameters: +// - name: code +// in: query +// description: the authorization code from the OIDC provider +// required: true +// type: string +// - name: state +// in: query +// description: the state parameter for CSRF protection +// required: true +// type: string +// responses: +// 307: +// description: Redirect to UI +// default: +// description: Error +// schema: +// $ref: "#/definitions/Error" +func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { + callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) { + user, status, err := a.resolveUser(info) + if err != nil { + http.Error(w, err.Error(), status) + return + } + clientName, _, _ := strings.Cut(state, ":") + client, err := a.createClient(clientName, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("failed to create client: %v", err), http.StatusInternalServerError) + return + } + auth.SetCookie(w, client.Token, auth.CookieMaxAge, a.SecureCookie) + // A reverse proxy may have already stripped a url prefix from the URL + // without us knowing, we have to make a relative redirect. + // We cannot use http.Redirect as this normalizes the Path with r.URL. + w.Header().Set("Location", "../../") + w.WriteHeader(http.StatusTemporaryRedirect) + } + return gin.WrapF(rp.CodeExchangeHandler(rp.UserinfoCallback(callback), a.Provider)) +} + +func (a *OIDCAPI) generateState(name string) (string, error) { + nonce := make([]byte, 20) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + return name + ":" + hex.EncodeToString(nonce), nil +} + +// resolveUser looks up or creates a user from OIDC userinfo claims. +func (a *OIDCAPI) resolveUser(info *oidc.UserInfo) (*model.User, int, error) { + usernameRaw, ok := info.Claims[a.UsernameClaim] + if !ok { + return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim) + } + username := fmt.Sprint(usernameRaw) + if username == "" || usernameRaw == nil { + return nil, http.StatusInternalServerError, fmt.Errorf("username claim was empty") + } + + user, err := a.DB.GetUserByName(username) + if err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err) + } + if user == nil { + if !a.AutoRegister { + return nil, http.StatusForbidden, fmt.Errorf("user does not exist and auto-registration is disabled") + } + user = &model.User{Name: username, Admin: false, Pass: nil} + if err := a.DB.CreateUser(user); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err) + } + if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil { + log.Printf("Could not notify user change: %v\n", err) + } + } + return user, 0, nil +} + +func (a *OIDCAPI) createClient(name string, userID uint) (*model.Client, error) { + client := &model.Client{ + Name: name, + Token: auth.GenerateNotExistingToken(generateClientToken, func(t string) bool { c, _ := a.DB.GetClientByToken(t); return c != nil }), + UserID: userID, + } + return client, a.DB.CreateClient(client) +} diff --git a/config.example.yml b/config.example.yml index 95bb986c..2fed4eb6 100644 --- a/config.example.yml +++ b/config.example.yml @@ -48,6 +48,14 @@ server: allowedorigins: # allowed origins for websocket connections (same origin is always allowed) # - ".+.example.com" # - "otherdomain.com" +oidc: + enabled: false # Enable OpenID Connect login, allowing users to authenticate via an external identity provider (e.g. Keycloak, Authelia, Google). + issuer: # The OIDC issuer URL. This is the base URL of your identity provider, used to discover endpoints. Example: "https://auth.example.com/realms/myrealm" + clientid: # The client ID registered with your identity provider for this application. + clientsecret: # The client secret for the registered client. + redirecturl: http://gotify.example.org/auth/oidc/callback # The callback URL that the identity provider redirects to after authentication. Must match exactly what is configured in your identity provider. + autoregister: true # If true, automatically create a new user on first OIDC login. If false, only existing users can log in via OIDC. + usernameclaim: preferred_username # The OIDC claim used to determine the username. Common values: "preferred_username" or "email". database: # for database see (configure database section) dialect: sqlite3 diff --git a/config/config.go b/config/config.go index 22fa033e..c299d8ee 100644 --- a/config/config.go +++ b/config/config.go @@ -56,6 +56,16 @@ type Configuration struct { UploadedImagesDir string `default:"data/images"` PluginsDir string `default:"data/plugins"` Registration bool `default:"false"` + OIDC struct { + Enabled bool `default:"false"` + Issuer string `default:""` + ClientID string `default:""` + ClientSecret string `default:""` + UsernameClaim string `default:"preferred_username"` + RedirectURL string `default:""` + AutoRegister bool `default:"true"` + Scopes []string + } } func configFiles() []string { diff --git a/docs/spec.json b/docs/spec.json index 1e26969f..787c873a 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -587,6 +587,73 @@ } } }, + "/auth/oidc/callback": { + "get": { + "description": "Exchanges the authorization code for tokens, resolves the user,\ncreates a gotify client, sets a session cookie, and redirects to the UI.", + "tags": [ + "oidc" + ], + "summary": "Handle the OIDC provider callback (browser).", + "operationId": "oidcCallback", + "parameters": [ + { + "type": "string", + "description": "the authorization code from the OIDC provider", + "name": "code", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "the state parameter for CSRF protection", + "name": "state", + "in": "query", + "required": true + } + ], + "responses": { + "307": { + "description": "Redirect to UI" + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/auth/oidc/login": { + "get": { + "description": "Redirects the user to the OIDC provider's authorization endpoint.\nAfter authentication, the provider redirects back to the callback endpoint.", + "tags": [ + "oidc" + ], + "summary": "Start the OIDC login flow (browser).", + "operationId": "oidcLogin", + "parameters": [ + { + "type": "string", + "description": "the client name to create after login", + "name": "name", + "in": "query", + "required": true + } + ], + "responses": { + "302": { + "description": "Redirect to OIDC provider" + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/client": { "get": { "security": [ diff --git a/go.mod b/go.mod index 0a9aa518..c519e2cc 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/robfig/cron v1.2.0 github.com/stretchr/testify v1.11.1 + github.com/zitadel/oidc/v3 v3.45.5 golang.org/x/crypto v0.48.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 @@ -28,15 +29,21 @@ require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect @@ -49,15 +56,24 @@ require ( github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/zitadel/logging v0.7.0 // indirect + github.com/zitadel/schema v1.3.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 2d258e77..fbb19cba 100644 --- a/go.sum +++ b/go.sum @@ -3,12 +3,16 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,6 +32,15 @@ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fq github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -46,6 +59,12 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotify/location v0.0.0-20170722210143-03bc4ad20437 h1:4qMhogAexRcnvdoY9O1RoCuuuNEhDF25jtbGIWPtcms= @@ -62,6 +81,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA= github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -90,6 +111,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -100,8 +125,12 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -119,8 +148,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= +github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= +github.com/zitadel/oidc/v3 v3.45.5 h1:CubfcXQiqtysk+FZyIcvj1+1ayvdSV89v5xWu5asrDQ= +github.com/zitadel/oidc/v3 v3.45.5/go.mod h1:MKHUazeiNX/jxRc6HD/Dv9qhL/wNuzrJAadBEGXiBeE= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= @@ -130,6 +173,8 @@ golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVo golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -147,6 +192,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/router/router.go b/router/router.go index 94918387..d7aa91a4 100644 --- a/router/router.go +++ b/router/router.go @@ -104,7 +104,14 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser) userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID) - ui.Register(g, *vInfo, conf.Registration) + ui.Register(g, *vInfo, conf.Registration, conf.OIDC.Enabled) + + if conf.OIDC.Enabled { + oidcHandler := api.NewOIDC(conf, db, userChangeNotifier) + oidcGroup := g.Group("/auth/oidc") + oidcGroup.GET("/login", oidcHandler.LoginHandler()) + oidcGroup.GET("/callback", oidcHandler.CallbackHandler()) + } g.Match([]string{"GET", "HEAD"}, "/health", healthHandler.Health) g.GET("/swagger", docs.Serve) diff --git a/ui/serve.go b/ui/serve.go index 07664937..45e46f44 100644 --- a/ui/serve.go +++ b/ui/serve.go @@ -18,11 +18,12 @@ var box embed.FS type uiConfig struct { Register bool `json:"register"` Version model.VersionInfo `json:"version"` + OIDC bool `json:"oidc"` } // Register registers the ui on the root path. -func Register(r *gin.Engine, version model.VersionInfo, register bool) { - uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register}) +func Register(r *gin.Engine, version model.VersionInfo, register, oidcEnabled bool) { + uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register, OIDC: oidcEnabled}) if err != nil { panic(err) } diff --git a/ui/src/CurrentUser.ts b/ui/src/CurrentUser.ts index e0df3e24..38b427c9 100644 --- a/ui/src/CurrentUser.ts +++ b/ui/src/CurrentUser.ts @@ -38,13 +38,17 @@ export class CurrentUser { return false; }); + public createClientName = (): string => { + const browser = detect(); + return (browser && browser.name + ' ' + browser.version) || 'unknown browser'; + }; + public login = async (username: string, password: string) => { runInAction(() => { this.loggedIn = false; this.authenticating = true; }); - const browser = detect(); - const name = (browser && browser.name + ' ' + browser.version) || 'unknown browser'; + const name = this.createClientName(); axios .create() .request({ diff --git a/ui/src/config.ts b/ui/src/config.ts index 16c81d20..00981ded 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -4,6 +4,7 @@ export interface IConfig { url: string; register: boolean; version: IVersion; + oidc: boolean; } declare global { @@ -16,6 +17,7 @@ const config: IConfig = { url: 'unset', register: false, version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, + oidc: false, ...window.config, }; diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx index 161e6ea3..e6e572a3 100644 --- a/ui/src/user/Login.tsx +++ b/ui/src/user/Login.tsx @@ -1,4 +1,5 @@ import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; import Grid from '@mui/material/Grid'; import TextField from '@mui/material/TextField'; import React from 'react'; @@ -80,6 +81,24 @@ const Login = observer(() => { Login + {config.get('oidc') && ( + <> + or +