Skip to content
Merged
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
16 changes: 13 additions & 3 deletions api/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
17 changes: 16 additions & 1 deletion api/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
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.LocalAuthEnabled, time.Now)
if err != nil {
log.Error().Err(err).Msg("Cannot initialize database")
return 1
Expand Down
14 changes: 11 additions & 3 deletions auth/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const (
authStateForbidden
authStateNotElevated
authStateOk
authStateLocalAuthDisabled
)

const (
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
12 changes: 11 additions & 1 deletion auth/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ type Configuration struct {
UploadedImagesDir string
PluginsDir string
Registration bool
LocalAuthEnabled bool
OIDC OIDC
NoColor string
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand All @@ -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
}

Expand Down
90 changes: 61 additions & 29 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -68,27 +104,24 @@ 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)
}

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) {
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
13 changes: 13 additions & 0 deletions docs/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,12 @@
"schema": {
"$ref": "#/definitions/Error"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down
5 changes: 5 additions & 0 deletions model/gotifyinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading