Skip to content

Commit f94e01b

Browse files
authored
Merge pull request #353 from outdoorsy/basic-auth-support
Add basic auth support for interface
2 parents c703eb6 + 2e0e557 commit f94e01b

File tree

4 files changed

+172
-7
lines changed

4 files changed

+172
-7
lines changed

docs/flagr_env.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,20 @@ The best way to configure service account for Flagr to use pubsub only use:
6565
FLAGR_RECORDER_PUBSUB_PROJECT_ID=google-project-id
6666
FLAGR_RECORDER_PUBSUB_KEYFILE=/path/to/service/account.json
6767
```
68+
69+
Basic Authentication for web interface
70+
71+
```
72+
FLAGR_BASIC_AUTH_ENABLED=true
73+
FLAGR_BASIC_AUTH_USERNAME=admin
74+
FLAGR_BASIC_AUTH_PASSWORD=password
75+
```
76+
77+
By default, UI access will prompt for a username/password login. Similar to JWT Auth, prefix and exact paths can be whitelisted to skip the username/password login. The default whitelist will allow api access to `/api/v1/flags` and `/api/v1/evaluation*`
78+
79+
NOTE: this doesn't prevent people from directly curling /api/v1/flags to update flags.
80+
81+
```
82+
FLAGR_BASIC_AUTH_WHITELIST_PATHS="/api/v1/flags,/api/v1/evaluation"
83+
FLAGR_BASIC_AUTH_EXACT_WHITELIST_PATHS=""
84+
```

pkg/config/env.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ var Config = struct {
189189
HeaderAuthEnabled bool `env:"FLAGR_HEADER_AUTH_ENABLED" envDefault:"false"`
190190
HeaderAuthUserField string `env:"FLAGR_HEADER_AUTH_USER_FIELD" envDefault:"X-Email"`
191191

192+
// Authenticate with basic auth
193+
BasicAuthEnabled bool `env:"FLAGR_BASIC_AUTH_ENABLED" envDefault:"false"`
194+
BasicAuthUsername string `env:"FLAGR_BASIC_AUTH_USERNAME" envDefault:""`
195+
BasicAuthPassword string `env:"FLAGR_BASIC_AUTH_PASSWORD" envDefault:""`
196+
BasicAuthPrefixWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_WHITELIST_PATHS" envDefault:"/api/v1/flags,/api/v1/evaluation" envSeparator:","`
197+
BasicAuthExactWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_EXACT_WHITELIST_PATHS" envDefault:"" envSeparator:","`
198+
192199
// WebPrefix - base path for web and API
193200
// e.g. FLAGR_WEB_PREFIX=/foo
194201
// UI path => localhost:18000/foo"

pkg/config/middleware.go

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"crypto/subtle"
45
"fmt"
56
"net/http"
67
"strconv"
@@ -78,6 +79,10 @@ func SetupGlobalMiddleware(handler http.Handler) http.Handler {
7879
n.Use(setupJWTAuthMiddleware())
7980
}
8081

82+
if Config.BasicAuthEnabled {
83+
n.Use(setupBasicAuthMiddleware())
84+
}
85+
8186
n.Use(&negroni.Static{
8287
Dir: http.Dir("./browser/flagr-ui/dist/"),
8388
Prefix: Config.WebPrefix,
@@ -118,7 +123,7 @@ func setupRecoveryMiddleware() *negroni.Recovery {
118123
/**
119124
setupJWTAuthMiddleware setup an JWTMiddleware from the ENV config
120125
*/
121-
func setupJWTAuthMiddleware() *auth {
126+
func setupJWTAuthMiddleware() *jwtAuth {
122127
var signingMethod jwt.SigningMethod
123128
var validationKey interface{}
124129
var errParsingKey error
@@ -138,7 +143,7 @@ func setupJWTAuthMiddleware() *auth {
138143
validationKey = []byte("")
139144
}
140145

141-
return &auth{
146+
return &jwtAuth{
142147
PrefixWhitelistPaths: Config.JWTAuthPrefixWhitelistPaths,
143148
ExactWhitelistPaths: Config.JWTAuthExactWhitelistPaths,
144149
JWTMiddleware: jwtmiddleware.New(jwtmiddleware.Options{
@@ -175,13 +180,13 @@ func jwtErrorHandler(w http.ResponseWriter, r *http.Request, err string) {
175180
}
176181
}
177182

178-
type auth struct {
183+
type jwtAuth struct {
179184
PrefixWhitelistPaths []string
180185
ExactWhitelistPaths []string
181186
JWTMiddleware *jwtmiddleware.JWTMiddleware
182187
}
183188

184-
func (a *auth) whitelist(req *http.Request) bool {
189+
func (a *jwtAuth) whitelist(req *http.Request) bool {
185190
path := req.URL.Path
186191

187192
// If we set to 401 unauthorized, let the client handles the 401 itself
@@ -201,14 +206,66 @@ func (a *auth) whitelist(req *http.Request) bool {
201206
return false
202207
}
203208

204-
func (a *auth) ServeHTTP(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
209+
func (a *jwtAuth) ServeHTTP(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
205210
if a.whitelist(req) {
206211
next(w, req)
207212
return
208213
}
209214
a.JWTMiddleware.HandlerWithNext(w, req, next)
210215
}
211216

217+
/**
218+
setupBasicAuthMiddleware setup an BasicMiddleware from the ENV config
219+
*/
220+
func setupBasicAuthMiddleware() *basicAuth {
221+
return &basicAuth{
222+
Username: []byte(Config.BasicAuthUsername),
223+
Password: []byte(Config.BasicAuthPassword),
224+
PrefixWhitelistPaths: Config.BasicAuthPrefixWhitelistPaths,
225+
ExactWhitelistPaths: Config.BasicAuthExactWhitelistPaths,
226+
}
227+
}
228+
229+
type basicAuth struct {
230+
Username []byte
231+
Password []byte
232+
PrefixWhitelistPaths []string
233+
ExactWhitelistPaths []string
234+
}
235+
236+
func (a *basicAuth) whitelist(req *http.Request) bool {
237+
path := req.URL.Path
238+
239+
for _, p := range a.ExactWhitelistPaths {
240+
if p == path {
241+
return true
242+
}
243+
}
244+
245+
for _, p := range a.PrefixWhitelistPaths {
246+
if p != "" && strings.HasPrefix(path, p) {
247+
return true
248+
}
249+
}
250+
return false
251+
}
252+
253+
func (a *basicAuth) ServeHTTP(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
254+
if a.whitelist(req) {
255+
next(w, req)
256+
return
257+
}
258+
259+
username, password, ok := req.BasicAuth()
260+
if !ok || subtle.ConstantTimeCompare(a.Username, []byte(username)) != 1 || subtle.ConstantTimeCompare(a.Password, []byte(password)) != 1 {
261+
w.Header().Set("WWW-Authenticate", `Basic realm="you shall not pass"`)
262+
http.Error(w, "Not authorized", http.StatusUnauthorized)
263+
return
264+
}
265+
266+
next(w, req)
267+
}
268+
212269
type statsdMiddleware struct {
213270
StatsdClient *statsd.Client
214271
}

pkg/config/middleware_test.go

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func TestSetupGlobalMiddleware(t *testing.T) {
5858
Config.PProfEnabled = true
5959
}
6060

61-
func TestAuthMiddleware(t *testing.T) {
61+
func TestJWTAuthMiddleware(t *testing.T) {
6262
h := &okHandler{}
6363

6464
t.Run("it will redirect if jwt enabled but no cookie passed", func(t *testing.T) {
@@ -255,7 +255,7 @@ o2kQ+X5xK9cipRgEKwIDAQAB
255255
})
256256
}
257257

258-
func TestAuthMiddlewareWithUnauthorized(t *testing.T) {
258+
func TestJWTAuthMiddlewareWithUnauthorized(t *testing.T) {
259259
h := &okHandler{}
260260

261261
t.Run("it will return 401 if no cookie passed", func(t *testing.T) {
@@ -315,3 +315,87 @@ func TestAuthMiddlewareWithUnauthorized(t *testing.T) {
315315
}
316316
})
317317
}
318+
319+
func TestBasicAuthMiddleware(t *testing.T) {
320+
h := &okHandler{}
321+
322+
t.Run("it will return 200 for web paths when disabled", func(t *testing.T) {
323+
testPaths := []string{"/", "", "/#", "/#/", "/static", "/static/"}
324+
for _, path := range testPaths {
325+
t.Run(fmt.Sprintf("path: %s", path), func(t *testing.T) {
326+
hh := SetupGlobalMiddleware(h)
327+
res := httptest.NewRecorder()
328+
res.Body = new(bytes.Buffer)
329+
req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:18000%s", path), nil)
330+
hh.ServeHTTP(res, req)
331+
assert.Equal(t, http.StatusOK, res.Code)
332+
})
333+
}
334+
})
335+
336+
t.Run("it will return 200 for whitelist path if basic auth is enabled", func(t *testing.T) {
337+
Config.BasicAuthEnabled = true
338+
Config.BasicAuthUsername = "admin"
339+
Config.BasicAuthPassword = "password"
340+
defer func() {
341+
Config.BasicAuthEnabled = false
342+
Config.BasicAuthUsername = ""
343+
Config.BasicAuthPassword = ""
344+
}()
345+
346+
hh := SetupGlobalMiddleware(h)
347+
res := httptest.NewRecorder()
348+
res.Body = new(bytes.Buffer)
349+
req, _ := http.NewRequest("GET", "http://localhost:18000/api/v1/flags", nil)
350+
hh.ServeHTTP(res, req)
351+
assert.Equal(t, http.StatusOK, res.Code)
352+
})
353+
354+
t.Run("it will return 401 for web paths when enabled and no basic auth passed", func(t *testing.T) {
355+
Config.BasicAuthEnabled = true
356+
Config.BasicAuthUsername = "admin"
357+
Config.BasicAuthPassword = "password"
358+
defer func() {
359+
Config.BasicAuthEnabled = false
360+
Config.BasicAuthUsername = ""
361+
Config.BasicAuthPassword = ""
362+
}()
363+
364+
testPaths := []string{"/", "", "/#", "/#/", "/static", "/static/"}
365+
for _, path := range testPaths {
366+
t.Run(fmt.Sprintf("path: %s", path), func(t *testing.T) {
367+
hh := SetupGlobalMiddleware(h)
368+
res := httptest.NewRecorder()
369+
res.Body = new(bytes.Buffer)
370+
req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:18000%s", path), nil)
371+
hh.ServeHTTP(res, req)
372+
assert.Equal(t, http.StatusUnauthorized, res.Code)
373+
})
374+
}
375+
})
376+
377+
t.Run("it will return 200 for web paths when enabled and basic auth passed", func(t *testing.T) {
378+
Config.BasicAuthEnabled = true
379+
Config.BasicAuthUsername = "admin"
380+
Config.BasicAuthPassword = "password"
381+
defer func() {
382+
Config.BasicAuthEnabled = false
383+
Config.BasicAuthUsername = ""
384+
Config.BasicAuthPassword = ""
385+
}()
386+
387+
testPaths := []string{"/", "", "/#", "/#/", "/static", "/static/"}
388+
for _, path := range testPaths {
389+
t.Run(fmt.Sprintf("path: %s", path), func(t *testing.T) {
390+
hh := SetupGlobalMiddleware(h)
391+
res := httptest.NewRecorder()
392+
res.Body = new(bytes.Buffer)
393+
req, _ := http.NewRequest("GET", fmt.Sprintf("http://localhost:18000%s", path), nil)
394+
req.SetBasicAuth(Config.BasicAuthUsername, Config.BasicAuthPassword)
395+
hh.ServeHTTP(res, req)
396+
assert.Equal(t, http.StatusOK, res.Code)
397+
})
398+
}
399+
})
400+
401+
}

0 commit comments

Comments
 (0)