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 (
<>