diff --git a/app.go b/app.go index 3935ffc6d..316be895c 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.LocalAuth.Enabled, 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..d9fe981ca 100644 --- a/auth/authentication.go +++ b/auth/authentication.go @@ -42,6 +42,9 @@ type Auth struct { DB Database SecureCookie bool CrossOrigin *http.CrossOriginProtection + // LocalAuthEnabled controls whether username/password (basic auth) credentials + // are accepted. When false, only token based auth and OIDC sessions work. + LocalAuthEnabled bool } // RequireAdmin requires an elevated client token or basic auth, the user must be an admin. @@ -146,6 +149,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..a7e951967 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, CrossOrigin: http.NewCrossOriginProtection(), LocalAuthEnabled: true} now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) timeNow = func() time.Time { return now } @@ -416,3 +416,48 @@ func (s *AuthenticationSuite) assertCsrfRequest(headers map[string]string, cooki } type fMiddleware gin.HandlerFunc + +func (s *AuthenticationSuite) TestLocalAuthDisabledRejectsBasicAuth() { + disabled := &Auth{DB: s.DB, CrossOrigin: http.NewCrossOriginProtection(), LocalAuthEnabled: false} + + // Valid local credentials must not authenticate anywhere, otherwise disabling + // local auth could be bypassed with basic auth. + s.assertBasicAuthRequest("admin", "pw", disabled.RequireClient, 401) + s.assertBasicAuthRequest("admin", "pw", disabled.RequireAdmin, 401) + s.assertBasicAuthRequest("admin", "pw", disabled.RequireElevatedClient, 401) + s.assertBasicAuthRequest("admin", "pw", disabled.RequireApplicationOrClient, 401) + s.assertBasicAuthRequest("existing", "pw", disabled.RequireClient, 401) + + // Optional auth must not register the user either. + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.SetBasicAuth("admin", "pw") + disabled.Optional(ctx) + assert.Nil(s.T(), ctx.Keys["user"]) + + // Token based auth keeps working. + s.assertHeaderRequestWith("X-Gotify-Key", "clienttoken", disabled.RequireClient, 200) + s.assertHeaderRequestWith("X-Gotify-Key", "apptoken", disabled.RequireApplicationToken, 200) + + // With local auth enabled the same credentials still work. + s.assertBasicAuthRequest("admin", "pw", s.auth.RequireAdmin, 200) +} + +func (s *AuthenticationSuite) assertBasicAuthRequest(user, pass string, f fMiddleware, code int) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.SetBasicAuth(user, pass) + f(ctx) + assert.Equal(s.T(), code, recorder.Code) +} + +func (s *AuthenticationSuite) assertHeaderRequestWith(key, value string, f fMiddleware, code int) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest("GET", "/", nil) + ctx.Request.Header.Set(key, value) + f(ctx) + assert.Equal(s.T(), code, recorder.Code) +} diff --git a/config/config.go b/config/config.go index 71a9963c6..6362b5ccf 100644 --- a/config/config.go +++ b/config/config.go @@ -70,6 +70,11 @@ type OIDC struct { Scopes []string } +// LocalAuth configures the built-in username/password authentication. +type LocalAuth struct { + Enabled bool +} + type Configuration struct { LogLevel LogLevel Server Server @@ -80,6 +85,7 @@ type Configuration struct { PluginsDir string Registration bool OIDC OIDC + LocalAuth LocalAuth NoColor string } @@ -116,6 +122,9 @@ func Get() (*Configuration, []FutureLog) { AutoRegister: true, Scopes: []string{"openid", "profile", "email"}, }, + LocalAuth: LocalAuth{ + Enabled: true, + }, } logs := loadFiles() @@ -178,10 +187,16 @@ func Get() (*Configuration, []FutureLog) { add(parseBool(&c.OIDC.LinkByUsername, EnvOIDCLinkByUsername)) add(parseList(&c.OIDC.Scopes, EnvOIDCScopes)) + add(parseBool(&c.LocalAuth.Enabled, EnvLocalAuthEnabled)) + add(parseString(&c.NoColor, EnvNoColor)) addTrailingSlashToPaths(c) + if !c.LocalAuth.Enabled && !c.OIDC.Enabled { + logs = append(logs, futureFatal(EnvLocalAuthEnabled+" is false and "+EnvOIDCEnabled+" is false, there would be no way to authenticate. Enable OIDC or re-enable local authentication.")) + } + return c, logs } diff --git a/config/config_test.go b/config/config_test.go index 216d80789..d6d38f7bf 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,9 +6,45 @@ import ( "testing" "github.com/gotify/server/v2/mode" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" ) +func TestLocalAuthEnabled(t *testing.T) { + mode.Set(mode.TestDev) + + conf, logs := Get() + assert.True(t, conf.LocalAuth.Enabled, "should default to true") + assert.Empty(t, fatalLogs(logs)) + + os.Setenv("GOTIFY_LOCALAUTH_ENABLED", "false") + defer os.Unsetenv("GOTIFY_LOCALAUTH_ENABLED") + + // localauth disabled without OIDC leaves no way to authenticate -> fatal. + conf, logs = Get() + assert.False(t, conf.LocalAuth.Enabled, "should parse env var") + assert.Len(t, fatalLogs(logs), 1, "should refuse to start without any auth method") + + // localauth disabled with OIDC enabled is a valid combination. + os.Setenv("GOTIFY_OIDC_ENABLED", "true") + defer os.Unsetenv("GOTIFY_OIDC_ENABLED") + + conf, logs = Get() + assert.False(t, conf.LocalAuth.Enabled) + assert.True(t, conf.OIDC.Enabled) + assert.Empty(t, fatalLogs(logs), "should be allowed when OIDC can authenticate users") +} + +func fatalLogs(logs []FutureLog) []FutureLog { + var fatal []FutureLog + for _, l := range logs { + if l.Level == zerolog.FatalLevel { + fatal = append(fatal, l) + } + } + return fatal +} + func TestConfigEnv(t *testing.T) { mode.Set(mode.TestDev) os.Setenv("GOTIFY_DEFAULTUSER_NAME", "jmattheis") diff --git a/config/keys.go b/config/keys.go index 6578fd13f..beef379fc 100644 --- a/config/keys.go +++ b/config/keys.go @@ -41,5 +41,6 @@ const ( EnvOIDCAutoRegister = "GOTIFY_OIDC_AUTOREGISTER" EnvOIDCLinkByUsername = "GOTIFY_OIDC_LINK_BY_USERNAME" EnvOIDCScopes = "GOTIFY_OIDC_SCOPES" + EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED" EnvNoColor = "NOCOLOR" ) diff --git a/database/database_test.go b/database/database_test.go index 5212f9fbe..89d25bbf2 100644 --- a/database/database_test.go +++ b/database/database_test.go @@ -116,3 +116,18 @@ func TestMigrateSortKey(t *testing.T) { assert.Equal(t, apps[0].Name, "one-other") assert.Equal(t, apps[0].SortKey, "a0") } + +func TestNoDefaultUserWhenDisabled(t *testing.T) { + tmpDir := test.NewTmpDir("gotify_testnodefaultuser") + defer tmpDir.Clean() + + // Mirrors localauth being disabled: no default admin may be created, + // otherwise a password login account would exist that cannot be used. + db, err := New("sqlite3", tmpDir.Path("testdb.db"), "defaultUser", "defaultPass", 5, false, fixedNow) + assert.Nil(t, err) + defer db.Close() + + users, err := db.GetUsers() + assert.Nil(t, err) + assert.Empty(t, users, "no default user should be created") +} diff --git a/docs/spec.json b/docs/spec.json index a9f17989d..3f8226884 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2940,9 +2940,16 @@ "required": [ "version", "register", - "oidc" + "oidc", + "localauth" ], "properties": { + "localauth": { + "description": "If local (username/password) 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..439265059 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -224,6 +224,17 @@ # Type: text-list # GOTIFY_OIDC_SCOPES=openid,profile,email +# Enable the built-in local username/password authentication. When disabled, +# the local login endpoint is rejected, username/password credentials are no +# longer accepted via HTTP basic auth on the API, the login form is hidden in +# the WebUI and the default admin user is not created. +# +# Disabling this requires OIDC to be enabled (GOTIFY_OIDC_ENABLED=true), +# otherwise there would be no way to authenticate and Gotify refuses 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..726a4f0f1 100644 --- a/model/gotifyinfo.go +++ b/model/gotifyinfo.go @@ -19,4 +19,9 @@ type GotifyInfo struct { // required: true // example: true Oidc bool `json:"oidc"` + // If local (username/password) authentication is enabled. + // + // required: true + // example: true + LocalAuth bool `json:"localauth"` } diff --git a/router/router.go b/router/router.go index cf4d8da46..1cebfddac 100644 --- a/router/router.go +++ b/router/router.go @@ -1,6 +1,7 @@ package router import ( + "errors" "fmt" "net/http" "path/filepath" @@ -85,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, + CrossOrigin: http.NewCrossOriginProtection(), + LocalAuthEnabled: conf.LocalAuth.Enabled, } messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} healthHandler := api.HealthAPI{DB: db} @@ -118,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.OIDC.Enabled, conf.LocalAuth.Enabled) if conf.OIDC.Enabled { oidcHandler := api.NewOIDC(conf, db, userChangeNotifier) @@ -158,7 +160,13 @@ 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) + login := sessionHandler.Login + if !conf.LocalAuth.Enabled { + login = func(ctx *gin.Context) { + ctx.AbortWithError(http.StatusForbidden, errors.New("local authentication is disabled")) + } + } + g.POST("/auth/local/login", login) g.OPTIONS("/*any") @@ -189,7 +197,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.LocalAuth.Enabled}) }) g.Group("/").Use(authentication.RequireApplicationOrClient).POST("/message", messageHandler.CreateMessage) diff --git a/router/router_test.go b/router/router_test.go index f72e95a8f..efd6b16b0 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, LocalAuth: config.LocalAuth{Enabled: true}}, ) s.closable = closable s.server = httptest.NewServer(g) @@ -73,7 +73,7 @@ func TestHeadersFromConfiguration(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.ResponseHeaders = map[string]string{ "New-Cool-Header": "Nice", "Access-Control-Allow-Origin": "http://test1.com", @@ -105,7 +105,7 @@ func TestHeadersFromCORSConfig(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.Cors.AllowOrigins = []string{"---", "http://test.com"} g, closable := Create(db.GormDatabase, @@ -134,7 +134,7 @@ func TestInvalidOrigin(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.Cors.AllowOrigins = []string{"---", "http://test.com"} g, closable := Create(db.GormDatabase, @@ -163,7 +163,7 @@ func TestAllowedOriginFromResponseHeaders(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.ResponseHeaders = map[string]string{ "Access-Control-Allow-Origin": "http://test1.com", "Access-Control-Allow-Methods": "GET,POST", @@ -201,7 +201,7 @@ func TestAllowedWildcardOriginInHeader(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.ResponseHeaders = map[string]string{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST", @@ -233,7 +233,7 @@ func TestCORSHeaderRegex(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.Cors.AllowOrigins = []string{"---", "^http://test\\d{3}.com$"} g, closable := Create(db.GormDatabase, @@ -263,7 +263,7 @@ func TestCORSConfigOverride(t *testing.T) { db := testdb.NewDBWithDefaultUser(t) defer db.Close() - config := config.Configuration{PassStrength: 5} + config := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} config.Server.ResponseHeaders = map[string]string{ "New-Cool-Header": "Nice", "Access-Control-Allow-Origin": "http://example.com/", @@ -390,6 +390,89 @@ func (s *IntegrationSuite) TestAuthentication() { assert.Equal(s.T(), "android-client", token.Name) } +func TestLocalAuthDisabled(t *testing.T) { + mode.Set(mode.TestDev) + db := testdb.NewDBWithDefaultUser(t) + defer db.Close() + + conf := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} + conf.LocalAuth.Enabled = false + + g, closable := Create(db.GormDatabase, + &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, + &conf, + ) + server := httptest.NewServer(g) + defer func() { + closable() + server.Close() + }() + + do := func(method, path string, basicAuth bool) *http.Response { + req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", server.URL, path), strings.NewReader(`{"name":"test"}`)) + assert.Nil(t, err) + req.Header.Add("Content-Type", "application/json") + if basicAuth { + req.SetBasicAuth("admin", "pw") + } + res, err := client.Do(req) + assert.Nil(t, err) + return res + } + + assert.Equal(t, http.StatusForbidden, do("POST", "auth/local/login", true).StatusCode, + "local login endpoint must be rejected") + + // Basic auth with valid local credentials must not work anywhere, otherwise + // local login could be bypassed via the regular API. + assert.Equal(t, http.StatusUnauthorized, do("GET", "current/user", true).StatusCode, + "basic auth must be rejected on client endpoints") + assert.Equal(t, http.StatusUnauthorized, do("GET", "application", true).StatusCode, + "basic auth must be rejected on application endpoints") + assert.Equal(t, http.StatusUnauthorized, do("GET", "user", true).StatusCode, + "basic auth must be rejected on admin endpoints") + + res := do("GET", "gotifyinfo", false) + info := &model.GotifyInfo{} + json.NewDecoder(res.Body).Decode(info) + assert.False(t, info.LocalAuth, "gotifyinfo should report localauth as disabled") +} + +func TestLocalAuthEnabledByDefaultKeepsBasicAuth(t *testing.T) { + mode.Set(mode.TestDev) + db := testdb.NewDBWithDefaultUser(t) + defer db.Close() + + conf := config.Configuration{PassStrength: 5, LocalAuth: config.LocalAuth{Enabled: true}} + conf.LocalAuth.Enabled = true + + g, closable := Create(db.GormDatabase, + &model.VersionInfo{Version: "1.0.0", BuildDate: "2018-02-20-17:30:47", Commit: "asdasds"}, + &conf, + ) + server := httptest.NewServer(g) + defer func() { + closable() + server.Close() + }() + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/auth/local/login", server.URL), strings.NewReader(`{"name":"test"}`)) + assert.Nil(t, err) + req.Header.Add("Content-Type", "application/json") + req.SetBasicAuth("admin", "pw") + res, err := client.Do(req) + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, res.StatusCode) + + req, err = http.NewRequest("GET", fmt.Sprintf("%s/gotifyinfo", server.URL), nil) + assert.Nil(t, err) + res, err = client.Do(req) + assert.Nil(t, err) + info := &model.GotifyInfo{} + json.NewDecoder(res.Body).Decode(info) + assert.True(t, info.LocalAuth, "gotifyinfo should report localauth as enabled") +} + func (s *IntegrationSuite) newRequest(method, url, body string) *http.Request { req, err := http.NewRequest(method, fmt.Sprintf("%s/%s", s.server.URL, url), strings.NewReader(body)) req.Header.Add("Content-Type", "application/json") diff --git a/ui/serve.go b/ui/serve.go index 45e46f441..08ba06fac 100644 --- a/ui/serve.go +++ b/ui/serve.go @@ -16,14 +16,15 @@ 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"` + OIDC bool `json:"oidc"` + LocalAuth bool `json:"localauth"` } // 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, oidcEnabled, localAuth bool) { + uiConfigBytes, err := json.Marshal(uiConfig{Version: version, Register: register, OIDC: oidcEnabled, LocalAuth: localAuth}) if err != nil { panic(err) } diff --git a/ui/src/config.ts b/ui/src/config.ts index 00981ded9..36ed7aacb 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -5,6 +5,7 @@ export interface IConfig { register: boolean; version: IVersion; oidc: boolean; + localauth: boolean; } declare global { @@ -18,6 +19,7 @@ const config: IConfig = { register: false, version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, oidc: false, + localauth: true, ...window.config, }; diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx index 77f93750b..9ad86e068 100644 --- a/ui/src/user/Login.tsx +++ b/ui/src/user/Login.tsx @@ -23,7 +23,7 @@ const Login = observer(() => { } }, [currentUser.loggedIn]); const registerButton = () => { - if (config.get('register')) + if (config.get('localauth') && config.get('register')) return ( - + {localAuth && ( +
e.preventDefault()} id="login-form"> + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + )} {config.get('oidc') && ( <> - or + {localAuth && ( + or + )}