Skip to content
Open
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
5 changes: 4 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ func serve(vInfo *model.VersionInfo) int {
conf, futureLogs := config.Get()
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339, NoColor: noColor(conf.NoColor)}).Level(zerolog.Level(conf.LogLevel))
log.Info().Str("version", vInfo.Version).Str("build_date", BuildDate).Msg("Gotify")
if !conf.LocalAuthEnabled && !conf.OIDC.Enabled {
log.Fatal().Msg("either local authentication or OIDC must be enabled")
}

exit := false
for _, futureLog := range futureLogs {
Expand All @@ -106,7 +109,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
10 changes: 7 additions & 3 deletions auth/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,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 @@ -146,6 +147,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
11 changes: 10 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, 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 @@ -270,6 +270,15 @@ 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.RequireClient, 401)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireAdmin, 401)
s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.RequireElevatedClient, 401)
}

func (s *AuthenticationSuite) TestOptionalAuth() {
// various invalid users
ctx := s.assertQueryRequest("token", "ergerogerg", s.auth.Optional, 200)
Expand Down
3 changes: 3 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 Down
2 changes: 2 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestConfigEnv(t *testing.T) {
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")
t.Setenv(EnvLocalAuthEnabled, "false")

defer func() {
os.Unsetenv("GOTIFY_DEFAULTUSER_NAME")
Expand All @@ -41,6 +42,7 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, []string{"GET", "POST"}, conf.Server.Cors.AllowMethods)
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
assert.False(t, conf.LocalAuthEnabled)
}

func TestFile(t *testing.T) {
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"
)
7 changes: 7 additions & 0 deletions docs/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2940,9 +2940,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
7 changes: 7 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@
# Type: text-list
# GOTIFY_OIDC_SCOPES=openid,profile,email

# Enable authentication via username and password.
# At least one of GOTIFY_LOCALAUTH_ENABLED or GOTIFY_OIDC_ENABLED must be set to
# true to allow users to login. Otherwise the server will refuse 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 @@ -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
15 changes: 9 additions & 6 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,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}
Expand Down Expand Up @@ -118,7 +119,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)
Expand Down Expand Up @@ -158,7 +159,9 @@ 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)
if conf.LocalAuthEnabled {
g.POST("/auth/local/login", sessionHandler.Login)
}

g.OPTIONS("/*any")

Expand Down Expand Up @@ -189,7 +192,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)
Expand Down
22 changes: 21 additions & 1 deletion router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, LocalAuthEnabled: true},
)
s.closable = closable
s.server = httptest.NewServer(g)
Expand Down Expand Up @@ -355,6 +355,26 @@ func (s *IntegrationSuite) TestPluginLoadFail_expectPanic() {
})
}

func TestLocalAuthDisabled(t *testing.T) {
mode.Set(mode.TestDev)
db := testdb.NewDBWithDefaultUser(t)
defer db.Close()

g, closeable := Create(db.GormDatabase, new(model.VersionInfo), &config.Configuration{PassStrength: 5})
defer closeable()

server := httptest.NewServer(g)
defer server.Close()

req, err := http.NewRequest(http.MethodPost, server.URL+"/auth/local/login", nil)
assert.NoError(t, err)
req.SetBasicAuth("admin", "pw")

res, err := client.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNotFound, res.StatusCode)
}

func (s *IntegrationSuite) TestAuthentication() {
req := s.newRequest("GET", "current/user", "")
req.SetBasicAuth("admin", "pw")
Expand Down
16 changes: 11 additions & 5 deletions ui/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
65 changes: 34 additions & 31 deletions ui/src/common/ElevationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,40 +49,42 @@ const ElevationForm = observer(() => {
return (
<>
<Typography>This action requires re-authentication.</Typography>
<form
onSubmit={(e) => {
e.preventDefault();
handleLocalElevate();
}}>
<TextField
autoFocus
margin="dense"
type="password"
label="Password"
className="elevation-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
fullWidth
error={!!error}
helperText={error}
/>
<Button
type="submit"
className="elevation-submit"
disabled={password.length === 0}
color="primary"
variant="contained"
fullWidth>
Elevate with Password
</Button>
</form>
{localAuthEnabled && (
<form
onSubmit={(e) => {
e.preventDefault();
handleLocalElevate();
}}>
<TextField
autoFocus
margin="dense"
type="password"
label="Password"
className="elevation-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
fullWidth
error={!!error}
helperText={error}
/>
<Button
type="submit"
className="elevation-submit"
disabled={password.length === 0}
color="primary"
variant="contained"
fullWidth>
Elevate with Password
</Button>
</form>
)}

{oidcEnabled && (
<>
<Divider sx={{my: 2}}>or</Divider>
{localAuthEnabled && <Divider sx={{my: 2}}>or</Divider>}
<Button
className="elevation-oidc"
variant="contained"
Expand Down
2 changes: 2 additions & 0 deletions ui/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface IConfig {
register: boolean;
version: IVersion;
oidc: boolean;
localauth: boolean;
}

declare global {
Expand All @@ -18,6 +19,7 @@ const config: IConfig = {
register: false,
version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'},
oidc: false,
localauth: true,
...window.config,
};

Expand Down
Loading