diff --git a/api/session.go b/api/session.go
index 924245351..03e91d563 100644
--- a/api/session.go
+++ b/api/session.go
@@ -20,9 +20,10 @@ type SessionDatabase interface {
// SessionAPI provides handlers for cookie-based session authentication.
type SessionAPI struct {
- DB SessionDatabase
- NotifyDeleted func(uint, string)
- SecureCookie bool
+ DB SessionDatabase
+ NotifyDeleted func(uint, string)
+ SecureCookie bool
+ LocalAuthEnabled bool
}
// swagger:operation POST /auth/local/login auth localLogin
@@ -54,6 +55,11 @@ type SessionAPI struct {
// schema:
// $ref: "#/definitions/Error"
func (a *SessionAPI) Login(ctx *gin.Context) {
+ if !a.LocalAuthEnabled {
+ ctx.AbortWithError(403, errors.New("local authentication is disabled"))
+ return
+ }
+
name, pass, ok := ctx.Request.BasicAuth()
if !ok {
ctx.AbortWithError(401, errors.New("basic auth required"))
diff --git a/api/session_test.go b/api/session_test.go
index fea0bbd6c..1dfcb1181 100644
--- a/api/session_test.go
+++ b/api/session_test.go
@@ -37,7 +37,7 @@ func (s *SessionSuite) BeforeTest(suiteName, testName string) {
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.a = &SessionAPI{DB: s.db, NotifyDeleted: s.notify, LocalAuthEnabled: true}
s.db.CreateUser(&model.User{
Name: "testuser",
diff --git a/app.go b/app.go
index 3935ffc6d..79667d9ca 100644
--- a/app.go
+++ b/app.go
@@ -106,7 +106,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..363d1f45a 100644
--- a/auth/authentication.go
+++ b/auth/authentication.go
@@ -18,6 +18,7 @@ const (
authStateForbidden
authStateNotElevated
authStateOk
+ authStateLocalAuthDisabled
)
const (
@@ -39,9 +40,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.
@@ -109,6 +111,9 @@ func (a *Auth) evaluate(ctx *gin.Context, funcs ...func(ctx *gin.Context) (authS
case authStateNotElevated:
ctx.AbortWithError(403, errors.New("session not elevated, use basic auth or call /client:elevate"))
return true
+ case authStateLocalAuthDisabled:
+ ctx.AbortWithError(403, errors.New("local authentication is disabled"))
+ return true
case authStateOk:
ctx.Next()
return true
@@ -147,6 +152,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 name, pass, ok := ctx.Request.BasicAuth(); ok {
+ if !a.LocalAuthEnabled {
+ return authStateLocalAuthDisabled, nil
+ }
if user, err := a.DB.GetUserByName(name); err != nil {
return authStateSkip, err
} else if user != nil && password.ComparePassword(user.Pass, []byte(pass)) {
diff --git a/auth/authentication_test.go b/auth/authentication_test.go
index d92ecf10f..d0f5a9345 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,16 @@ 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.RequireApplicationToken, 403)
+ s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireClient, 403)
+ s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 403)
+ s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireElevatedClient, 403)
+}
+
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..ab0d286cb 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))
@@ -182,6 +185,9 @@ func Get() (*Configuration, []FutureLog) {
addTrailingSlashToPaths(c)
+ if !c.LocalAuthEnabled && !c.OIDC.Enabled {
+ logs = append(logs, futureFatal("either local authentication or OIDC must be enabled"))
+ }
return c, logs
}
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..516cbb09c 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",
@@ -3532,4 +3539,4 @@
"in": "query"
}
}
-}
\ No newline at end of file
+}
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..38692adc0 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..3b0e016f5 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}
@@ -100,7 +101,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, SecureCookie: conf.Server.SecureCookie}
+ sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient, SecureCookie: conf.Server.SecureCookie, LocalAuthEnabled: conf.LocalAuthEnabled}
userChangeNotifier := new(api.UserChangeNotifier)
userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration}
@@ -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)
@@ -189,7 +190,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..4fc4653cf 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)
diff --git a/ui/serve.go b/ui/serve.go
index 45e46f441..12e325905 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..bed7c6204 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 (
<>