diff --git a/api/session.go b/api/session.go
index 924245351..7a7de783d 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
@@ -53,7 +54,16 @@ type SessionAPI struct {
// description: Unauthorized
// schema:
// $ref: "#/definitions/Error"
+// 403:
+// description: Forbidden
+// 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 8817de62c..21d841b77 100644
--- a/api/session_test.go
+++ b/api/session_test.go
@@ -38,7 +38,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}
pw, err := password.CreatePassword("testpass", 5)
require.NoError(s.T(), err)
@@ -93,6 +93,21 @@ func (s *SessionSuite) Test_Login_Success() {
assert.Equal(s.T(), uint(auth.CookieMaxAge), clients[0].ExpiresAfterInactivitySeconds)
}
+func (s *SessionSuite) Test_Login_LocalAuthDisabled() {
+ s.a.LocalAuthEnabled = false
+ 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(), 403, s.recorder.Code)
+
+ for _, c := range s.recorder.Result().Cookies() {
+ assert.NotEqual(s.T(), auth.CookieName, c.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")
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 ce35c7a09..1bfee9070 100644
--- a/auth/authentication_test.go
+++ b/auth/authentication_test.go
@@ -30,7 +30,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 }
@@ -274,6 +274,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..bc49bcf5b 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,12 @@ func Get() (*Configuration, []FutureLog) {
addTrailingSlashToPaths(c)
+ if !c.LocalAuthEnabled && !c.OIDC.Enabled {
+ logs = append(logs, futureFatal("either local authentication or OIDC must be enabled"))
+ }
+ if c.Registration && !c.LocalAuthEnabled {
+ logs = append(logs, futureFatal("registration requires local authentication to be enabled"))
+ }
return c, logs
}
diff --git a/config/config_test.go b/config/config_test.go
index e570db969..5ca4c9666 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -6,31 +6,22 @@ import (
"testing"
"github.com/gotify/server/v2/mode"
+ "github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
func TestConfigEnv(t *testing.T) {
mode.Set(mode.TestDev)
- os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis")
- os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "push.example.tld,push.other.tld")
- os.Setenv(
+ t.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis")
+ t.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS", "push.example.tld,push.other.tld")
+ t.Setenv(
"GOTIFY_SERVER_RESPONSEHEADERS",
`{"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST"}`,
)
- os.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", ".+.example.com,otherdomain.com")
- 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")
-
- defer func() {
- os.Unsetenv("GOTIFY_DEFAULTUSER_NAME")
- os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS")
- os.Unsetenv("GOTIFY_SERVER_RESPONSEHEADERS")
- os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWORIGINS")
- os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWMETHODS")
- os.Unsetenv("GOTIFY_SERVER_CORS_ALLOWHEADERS")
- os.Unsetenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS")
- }()
+ t.Setenv("GOTIFY_SERVER_CORS_ALLOWORIGINS", ".+.example.com,otherdomain.com")
+ t.Setenv("GOTIFY_SERVER_CORS_ALLOWMETHODS", "GET,POST")
+ t.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "Authorization,content-type")
+ t.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", ".+.example.com,otherdomain.com")
conf, _ := Get()
assert.Equal(t, 80, conf.Server.Port, "should use defaults")
@@ -44,6 +35,53 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
}
+func TestLocalAuthDisabled(t *testing.T) {
+ tests := []struct {
+ name string
+ env map[string]string
+ fatals []FutureLog
+ }{
+ {
+ name: "with oidc",
+ env: map[string]string{EnvLocalAuthEnabled: "false", EnvOIDCEnabled: "true"},
+ },
+ {
+ name: "without oidc",
+ env: map[string]string{EnvLocalAuthEnabled: "false"},
+ fatals: []FutureLog{futureFatal("either local authentication or OIDC must be enabled")},
+ },
+ {
+ name: "with registration",
+ env: map[string]string{
+ EnvLocalAuthEnabled: "false",
+ EnvOIDCEnabled: "true",
+ EnvRegistration: "true",
+ },
+ fatals: []FutureLog{futureFatal("registration requires local authentication to be enabled")},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ mode.Set(mode.TestDev)
+ for key, value := range tc.env {
+ t.Setenv(key, value)
+ }
+
+ conf, logs := Get()
+ assert.False(t, conf.LocalAuthEnabled)
+
+ var fatals []FutureLog
+ for _, entry := range logs {
+ if entry.Level == zerolog.FatalLevel {
+ fatals = append(fatals, entry)
+ }
+ }
+ assert.Equal(t, tc.fatals, fatals)
+ })
+ }
+}
+
func TestFile(t *testing.T) {
mode.Set(mode.TestDev)
dir := t.TempDir()
@@ -52,10 +90,8 @@ func TestFile(t *testing.T) {
assert.Nil(t, os.WriteFile(passPath, []byte("filesecret\n"), 0o600))
assert.Nil(t, os.WriteFile(hostsPath, []byte("a.example.com,b.example.com"), 0o600))
- os.Setenv("GOTIFY_DEFAULTUSER_PASS_FILE", passPath)
- os.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE", hostsPath)
- defer os.Unsetenv("GOTIFY_DEFAULTUSER_PASS_FILE")
- defer os.Unsetenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE")
+ t.Setenv("GOTIFY_DEFAULTUSER_PASS_FILE", passPath)
+ t.Setenv("GOTIFY_SERVER_SSL_LETSENCRYPT_HOSTS_FILE", hostsPath)
conf, _ := Get()
assert.Equal(t, "filesecret", conf.DefaultUser.Pass)
@@ -68,8 +104,7 @@ func TestGotifyConfigFile(t *testing.T) {
configPath := filepath.Join(dir, "custom.env")
assert.Nil(t, os.WriteFile(configPath, []byte("GOTIFY_DEFAULTUSER_NAME=fromfile\n"), 0o600))
- os.Setenv("GOTIFY_CONFIG_FILE", configPath)
- defer os.Unsetenv("GOTIFY_CONFIG_FILE")
+ t.Setenv("GOTIFY_CONFIG_FILE", configPath)
conf, _ := Get()
assert.Equal(t, "fromfile", conf.DefaultUser.Name)
@@ -77,18 +112,16 @@ func TestGotifyConfigFile(t *testing.T) {
func TestAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
- os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images")
+ t.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/images")
conf, _ := Get()
assert.Equal(t, "../data/images"+string(filepath.Separator), conf.UploadedImagesDir)
- os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
func TestNotAddSlash(t *testing.T) {
mode.Set(mode.TestDev)
- os.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/")
+ t.Setenv("GOTIFY_UPLOADEDIMAGESDIR", "../data/")
conf, _ := Get()
assert.Equal(t, "../data/", conf.UploadedImagesDir)
- os.Unsetenv("GOTIFY_UPLOADEDIMAGESDIR")
}
func TestParseList(t *testing.T) {
@@ -106,8 +139,7 @@ func TestParseList(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- os.Setenv(env, tc.raw)
- defer os.Unsetenv(env)
+ t.Setenv(env, tc.raw)
var got []string
assert.Nil(t, parseList(&got, env))
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..67de08719 100644
--- a/docs/spec.json
+++ b/docs/spec.json
@@ -720,6 +720,12 @@
"schema": {
"$ref": "#/definitions/Error"
}
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/Error"
+ }
}
}
}
@@ -2940,9 +2946,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..f205bf05c 100644
--- a/gotify-server.env.example
+++ b/gotify-server.env.example
@@ -224,6 +224,10 @@
# Type: text-list
# GOTIFY_OIDC_SCOPES=openid,profile,email
+# Enable authentication via username and password.
+# 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 9a2a4854b..895054715 100644
--- a/router/router.go
+++ b/router/router.go
@@ -86,9 +86,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}
@@ -101,7 +102,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}
@@ -119,7 +120,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)
@@ -190,7 +191,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 34f6a909a..58daed955 100644
--- a/router/router_test.go
+++ b/router/router_test.go
@@ -42,7 +42,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 (
<>