From 3cbf90d3f333a56e5e10f35d88f911b9a2f240b4 Mon Sep 17 00:00:00 2001 From: Bernd Konrad Date: Tue, 4 Aug 2026 20:22:20 +0200 Subject: [PATCH] implements https://github.com/gotify/server/issues/1007 - Option to disable local login when OIDC is enabled - Add GOTIFY_LOCALAUTH_ENABLED, defaulting to true - Block local login and Basic Auth when disabled - Expose local auth state to the UI and hide local login flows - Skip default local admin creation when local auth is disabled - Require either local auth or OIDC at startup --- app.go | 5 +- auth/authentication.go | 10 ++-- auth/authentication_test.go | 11 ++++- config/config.go | 3 ++ config/config_test.go | 2 + config/keys.go | 1 + docs/spec.json | 7 +++ gotify-server.env.example | 7 +++ model/gotifyinfo.go | 5 ++ router/router.go | 15 +++--- router/router_test.go | 22 ++++++++- ui/serve.go | 16 ++++-- ui/src/common/ElevationForm.tsx | 65 ++++++++++++------------ ui/src/config.ts | 2 + ui/src/user/Login.tsx | 88 ++++++++++++++++++--------------- 15 files changed, 170 insertions(+), 89 deletions(-) diff --git a/app.go b/app.go index 3935ffc6d..c63c7b061 100644 --- a/app.go +++ b/app.go @@ -85,6 +85,9 @@ func serve(vInfo *model.VersionInfo) int { conf, futureLogs := config.Get() log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor(conf.NoColor)}).Level(zerolog.Level(conf.LogLevel)) log.Info().Str("version", vInfo.Version).Str("build_date", BuildDate).Msg("Gotify") + if !conf.LocalAuthEnabled && !conf.OIDC.Enabled { + log.Fatal().Msg("either local authentication or OIDC must be enabled") + } exit := false for _, futureLog := range futureLogs { @@ -106,7 +109,7 @@ func serve(vInfo *model.VersionInfo) int { return 1 } - db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, true, time.Now) + db, err := database.New(conf.Database.Dialect, conf.Database.Connection, conf.DefaultUser.Name, conf.DefaultUser.Pass, conf.PassStrength, conf.LocalAuthEnabled, time.Now) if err != nil { log.Error().Err(err).Msg("Cannot initialize database") return 1 diff --git a/auth/authentication.go b/auth/authentication.go index 6d8338f92..b6bc259a2 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -39,9 +39,10 @@ type Database interface { // Auth is the provider for authentication middleware. type Auth struct { - DB Database - SecureCookie bool - CrossOrigin *http.CrossOriginProtection + DB Database + SecureCookie bool + LocalAuthEnabled bool + CrossOrigin *http.CrossOriginProtection } // RequireAdmin requires an elevated client token or basic auth, the user must be an admin. @@ -146,6 +147,9 @@ func (a *Auth) rejectForeignOrigin(ctx *gin.Context) bool { func (a *Auth) handleUser(checks ...func(*model.User) (authState, error)) func(ctx *gin.Context) (authState, error) { return func(ctx *gin.Context) (authState, error) { + if !a.LocalAuthEnabled { + return authStateSkip, nil + } if name, pass, ok := ctx.Request.BasicAuth(); ok { if user, err := a.DB.GetUserByName(name); err != nil { return authStateSkip, err diff --git a/auth/authentication_test.go b/auth/authentication_test.go index d92ecf10f..c4d96db35 100644 --- a/auth/authentication_test.go +++ b/auth/authentication_test.go @@ -29,7 +29,7 @@ type AuthenticationSuite struct { func (s *AuthenticationSuite) SetupSuite() { mode.Set(mode.TestDev) s.DB = testdb.NewDB(s.T()) - s.auth = &Auth{DB: s.DB, CrossOrigin: http.NewCrossOriginProtection()} + s.auth = &Auth{DB: s.DB, LocalAuthEnabled: true, CrossOrigin: http.NewCrossOriginProtection()} now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) timeNow = func() time.Time { return now } @@ -270,6 +270,15 @@ func (s *AuthenticationSuite) TestBasicAuth() { s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireElevatedClient, 401) } +func (s *AuthenticationSuite) TestBasicAuthDisabled() { + s.auth.LocalAuthEnabled = false + defer func() { s.auth.LocalAuthEnabled = true }() + + s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireClient, 401) + s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 401) + s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireElevatedClient, 401) +} + func (s *AuthenticationSuite) TestOptionalAuth() { // various invalid users ctx := s.assertQueryRequest("token", "ergerogerg", s.auth.Optional, 200) diff --git a/config/config.go b/config/config.go index 71a9963c6..68d97816e 100644 --- a/config/config.go +++ b/config/config.go @@ -79,6 +79,7 @@ type Configuration struct { UploadedImagesDir string PluginsDir string Registration bool + LocalAuthEnabled bool OIDC OIDC NoColor string } @@ -111,6 +112,7 @@ func Get() (*Configuration, []FutureLog) { PassStrength: 10, UploadedImagesDir: "data/images", PluginsDir: "data/plugins", + LocalAuthEnabled: true, OIDC: OIDC{ UsernameClaim: "preferred_username", AutoRegister: true, @@ -167,6 +169,7 @@ func Get() (*Configuration, []FutureLog) { add(parseString(&c.UploadedImagesDir, EnvUploadedImagesDir)) add(parseString(&c.PluginsDir, EnvPluginsDir)) add(parseBool(&c.Registration, EnvRegistration)) + add(parseBool(&c.LocalAuthEnabled, EnvLocalAuthEnabled)) add(parseBool(&c.OIDC.Enabled, EnvOIDCEnabled)) add(parseString(&c.OIDC.Issuer, EnvOIDCIssuer)) diff --git a/config/config_test.go b/config/config_test.go index 216d80789..1191bcf54 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -20,6 +20,7 @@ func TestConfigEnv(t *testing.T) { os.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "GET,POST") os.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "Authorization,content-type") os.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", ".+.example.com,otherdomain.com") + t.Setenv(EnvLocalAuthEnabled, "false") defer func() { os.Unsetenv("GOTIFY_DEFAULTUSER_NAME") @@ -41,6 +42,7 @@ func TestConfigEnv(t *testing.T) { assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods) assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders) assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins) + assert.False(t, conf.LocalAuthEnabled) } func TestFile(t *testing.T) { diff --git a/config/keys.go b/config/keys.go index 6578fd13f..e64aba965 100644 --- a/config/keys.go +++ b/config/keys.go @@ -40,6 +40,7 @@ const ( EnvOIDCRedirectURL = "GOTIFY_OIDC_REDIRECTURL" EnvOIDCAutoRegister = "GOTIFY_OIDC_AUTOREGISTER" EnvOIDCLinkByUsername = "GOTIFY_OIDC_LINK_BY_USERNAME" + EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED" EnvOIDCScopes = "GOTIFY_OIDC_SCOPES" EnvNoColor = "NOCOLOR" ) diff --git a/docs/spec.json b/docs/spec.json index a9f17989d..045b961f5 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2940,9 +2940,16 @@ "required": [ "version", "register", + "localauth", "oidc" ], "properties": { + "localauth": { + "description": "If local authentication is enabled.", + "type": "boolean", + "x-go-name": "LocalAuth", + "example": true + }, "oidc": { "description": "If oidc is enabled.", "type": "boolean", diff --git a/gotify-server.env.example b/gotify-server.env.example index c0b23d557..92803b7fd 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -224,6 +224,13 @@ # Type: text-list # GOTIFY_OIDC_SCOPES=openid,profile,email +# Enable authentication via username and password. +# At least one of GOTIFY_LOCALAUTH_ENABLED or GOTIFY_OIDC_ENABLED must be set to +# true to allow users to login. Otherwise the server will refuse to start. +# +# Type: boolean +# GOTIFY_LOCALAUTH_ENABLED=true + # Database driver to use. For mysql and postgres the target database must # already exist and the configured user must have sufficient permissions. # diff --git a/model/gotifyinfo.go b/model/gotifyinfo.go index c2db0bd2e..d89c0022e 100644 --- a/model/gotifyinfo.go +++ b/model/gotifyinfo.go @@ -14,6 +14,11 @@ type GotifyInfo struct { // required: true // example: true Register bool `json:"register"` + // If local authentication is enabled. + // + // required: true + // example: true + LocalAuth bool `json:"localauth"` // If oidc is enabled. // // required: true diff --git a/router/router.go b/router/router.go index cf4d8da46..9652aa4d1 100644 --- a/router/router.go +++ b/router/router.go @@ -85,9 +85,10 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co } }() authentication := auth.Auth{ - DB: db, - SecureCookie: conf.Server.SecureCookie, - CrossOrigin: http.NewCrossOriginProtection(), + DB: db, + SecureCookie: conf.Server.SecureCookie, + LocalAuthEnabled: conf.LocalAuthEnabled, + CrossOrigin: http.NewCrossOriginProtection(), } messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} @@ -118,7 +119,7 @@ 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, conf.OIDC.Enabled) + ui.Register(g, *vInfo, conf.Registration, conf.LocalAuthEnabled, conf.OIDC.Enabled) if conf.OIDC.Enabled { oidcHandler := api.NewOIDC(conf, db, userChangeNotifier) @@ -158,7 +159,9 @@ 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) + if conf.LocalAuthEnabled { + g.POST("/auth/local/login", sessionHandler.Login) + } g.OPTIONS("/*any") @@ -189,7 +192,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // schema: // $ref: "#/definitions/GotifyInfo" g.GET("gotifyinfo", func(ctx *gin.Context) { - ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration}) + ctx.JSON(200, &model.GotifyInfo{Version: vInfo.Version, Oidc: conf.OIDC.Enabled, Register: conf.Registration, LocalAuth: conf.LocalAuthEnabled}) }) g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage) diff --git a/router/router_test.go b/router/router_test.go index f72e95a8f..4e5b58f28 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -41,7 +41,7 @@ func (s *IntegrationSuite) BeforeTest(string, string) { g, closable := Create(s.db.GormDatabase, &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, - &config.Configuration{PassStrength: 5}, + &config.Configuration{PassStrength: 5, LocalAuthEnabled: true}, ) s.closable = closable s.server = httptest.NewServer(g) @@ -355,6 +355,26 @@ func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() { }) } +func TestLocalAuthDisabled(t *testing.T) { + mode.Set(mode.TestDev) + db := testdb.NewDBWithDefaultUser(t) + defer db.Close() + + g, closeable := Create(db.GormDatabase, new(model.VersionInfo), &config.Configuration{PassStrength: 5}) + defer closeable() + + server := httptest.NewServer(g) + defer server.Close() + + req, err := http.NewRequest(http.MethodPost, server.URL+"/auth/local/login", nil) + assert.NoError(t, err) + req.SetBasicAuth("admin", "pw") + + res, err := client.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, res.StatusCode) +} + func (s *IntegrationSuite) TestAuthentication() { req := s.newRequest("GET", "current/user", "") req.SetBasicAuth("admin", "pw") diff --git a/ui/serve.go b/ui/serve.go index 45e46f441..dd23999c4 100644 --- a/ui/serve.go +++ b/ui/serve.go @@ -16,14 +16,20 @@ import ( var box embed.FS type uiConfig struct { - Register bool `json:"register"` - Version model.VersionInfo `json:"version"` - OIDC bool `json:"oidc"` + Register bool `json:"register"` + Version model.VersionInfo `json:"version"` + LocalAuth bool `json:"localauth"` + OIDC bool `json:"oidc"` } // Register registers the ui on the root path. -func Register(r *gin.Engine, version model.VersionInfo, register, oidcEnabled bool) { - uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register, OIDC: oidcEnabled}) +func Register(r *gin.Engine, version model.VersionInfo, register, localAuthEnabled, oidcEnabled bool) { + uiConfigBytes, err := json.Marshal(uiConfig{ + Version: version, + Register: register, + LocalAuth: localAuthEnabled, + OIDC: oidcEnabled, + }) if err != nil { panic(err) } diff --git a/ui/src/common/ElevationForm.tsx b/ui/src/common/ElevationForm.tsx index 487a27f8d..7d695c828 100644 --- a/ui/src/common/ElevationForm.tsx +++ b/ui/src/common/ElevationForm.tsx @@ -15,6 +15,7 @@ const ElevationForm = observer(() => { const [password, setPassword] = useState(''); const [error, setError] = useState(''); + const localAuthEnabled = config.get('localauth'); const oidcEnabled = config.get('oidc'); const oidcPending = elevateStore.oidcElevatePending; @@ -48,40 +49,42 @@ const ElevationForm = observer(() => { return ( <> This action requires re-authentication. -
{ - e.preventDefault(); - handleLocalElevate(); - }}> - { - setPassword(e.target.value); - setError(''); - }} - fullWidth - error={!!error} - helperText={error} - /> - - + {localAuthEnabled && ( +
{ + e.preventDefault(); + handleLocalElevate(); + }}> + { + setPassword(e.target.value); + setError(''); + }} + fullWidth + error={!!error} + helperText={error} + /> + + + )} {oidcEnabled && ( <> - or + {localAuthEnabled && or} - - {config.get('oidc') && ( + {localAuthEnabled && ( +
e.preventDefault()} id="login-form"> + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + )} + {oidcEnabled && ( <> - or + {localAuthEnabled && ( + or + )}