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 ( <> 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 + )}