Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions auth/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion auth/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
}
15 changes: 15 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -80,6 +85,7 @@ type Configuration struct {
PluginsDir string
Registration bool
OIDC OIDC
LocalAuth LocalAuth
NoColor string
}

Expand Down Expand Up @@ -116,6 +122,9 @@ func Get() (*Configuration, []FutureLog) {
AutoRegister: true,
Scopes: []string{"openid", "profile", "email"},
},
LocalAuth: LocalAuth{
Enabled: true,
},
}

logs := loadFiles()
Expand Down Expand Up @@ -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
}

Expand Down
36 changes: 36 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
15 changes: 15 additions & 0 deletions database/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
9 changes: 8 additions & 1 deletion docs/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
5 changes: 5 additions & 0 deletions model/gotifyinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
20 changes: 14 additions & 6 deletions router/router.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package router

import (
"errors"
"fmt"
"net/http"
"path/filepath"
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down
Loading