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
12 changes: 9 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 @@ -54,6 +55,11 @@ type SessionAPI struct {
// 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
2 changes: 1 addition & 1 deletion api/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,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}

s.db.CreateUser(&model.User{
Name: "testuser",
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 @@ -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,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
6 changes: 6 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,9 @@ func Get() (*Configuration, []FutureLog) {

addTrailingSlashToPaths(c)

if !c.LocalAuthEnabled && !c.OIDC.Enabled {
logs = append(logs, futureFatal("either local authentication or OIDC must be enabled"))
}
return c, logs
}

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"
)
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",
"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 Expand Up @@ -3532,4 +3539,4 @@
"in": "query"
}
}
}
}
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
13 changes: 7 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 All @@ -100,7 +101,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}

Expand All @@ -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 @@ -189,7 +190,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
2 changes: 1 addition & 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
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