Bladeren bron

Implement support for authentication via Auth Proxy

Auth Proxy allows to authenticate a user using an HTTP header provided
by an external authentication service. This provides a way to
authenticate users in miniflux using authentication schemes not
supported by miniflux itself (LDAP, non-Google OAuth2 providers, etc.)
and to implement SSO for multiple applications behind single
authentication service.

Auth Proxy header is checked for the '/' endpoint only, as the rest are
protected by the miniflux user/app sessions.

Closes #534

Signed-off-by: Pavel Borzenkov <pavel.borzenkov@gmail.com>
Pavel Borzenkov 6 jaren geleden
bovenliggende
commit
7389c79c52
5 gewijzigde bestanden met toevoegingen van 159 en 1 verwijderingen
  1. 70 0
      config/config_test.go
  2. 20 0
      config/options.go
  3. 4 0
      config/parser.go
  4. 64 0
      ui/middleware.go
  5. 1 1
      ui/ui.go

+ 70 - 0
config/config_test.go

@@ -1257,3 +1257,73 @@ Invalid text
 		t.Fatal(err)
 	}
 }
+
+func TestAuthProxyHeader(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("AUTH_PROXY_HEADER", "X-Forwarded-User")
+
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
+
+	expected := "X-Forwarded-User"
+	result := opts.AuthProxyHeader()
+
+	if result != expected {
+		t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
+	}
+}
+
+func TestDefaultAuthProxyHeaderValue(t *testing.T) {
+	os.Clearenv()
+
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
+
+	expected := defaultAuthProxyHeader
+	result := opts.AuthProxyHeader()
+
+	if result != expected {
+		t.Fatalf(`Unexpected AUTH_PROXY_HEADER value, got %q instead of %q`, result, expected)
+	}
+}
+
+func TestAuthProxyUserCreationWhenUnset(t *testing.T) {
+	os.Clearenv()
+
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
+
+	expected := false
+	result := opts.IsAuthProxyUserCreationAllowed()
+
+	if result != expected {
+		t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
+	}
+}
+
+func TestAuthProxyUserCreationAdmin(t *testing.T) {
+	os.Clearenv()
+	os.Setenv("AUTH_PROXY_USER_CREATION", "1")
+
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
+
+	expected := true
+	result := opts.IsAuthProxyUserCreationAllowed()
+
+	if result != expected {
+		t.Fatalf(`Unexpected AUTH_PROXY_USER_CREATION value, got %v instead of %v`, result, expected)
+	}
+}

+ 20 - 0
config/options.go

@@ -44,6 +44,8 @@ const (
 	defaultPocketConsumerKey         = ""
 	defaultHTTPClientTimeout         = 20
 	defaultHTTPClientMaxBodySize     = 15
+	defaultAuthProxyHeader           = ""
+	defaultAuthProxyUserCreation     = false
 )
 
 // Options contains configuration options.
@@ -82,6 +84,8 @@ type Options struct {
 	pocketConsumerKey         string
 	httpClientTimeout         int
 	httpClientMaxBodySize     int64
+	authProxyHeader           string
+	authProxyUserCreation     bool
 }
 
 // NewOptions returns Options with default values.
@@ -121,6 +125,8 @@ func NewOptions() *Options {
 		pocketConsumerKey:         defaultPocketConsumerKey,
 		httpClientTimeout:         defaultHTTPClientTimeout,
 		httpClientMaxBodySize:     defaultHTTPClientMaxBodySize * 1024 * 1024,
+		authProxyHeader:           defaultAuthProxyHeader,
+		authProxyUserCreation:     defaultAuthProxyUserCreation,
 	}
 }
 
@@ -297,6 +303,18 @@ func (o *Options) HTTPClientMaxBodySize() int64 {
 	return o.httpClientMaxBodySize
 }
 
+// AuthProxyHeader returns an HTTP header name that contains username for
+// authentication using auth proxy.
+func (o *Options) AuthProxyHeader() string {
+	return o.authProxyHeader
+}
+
+// IsAuthProxyUserCreationAllowed returns true if user creation is allowed for
+// users authenticated using auth proxy.
+func (o *Options) IsAuthProxyUserCreationAllowed() bool {
+	return o.authProxyUserCreation
+}
+
 func (o *Options) String() string {
 	var builder strings.Builder
 	builder.WriteString(fmt.Sprintf("LOG_DATE_TIME: %v\n", o.logDateTime))
@@ -333,5 +351,7 @@ func (o *Options) String() string {
 	builder.WriteString(fmt.Sprintf("OAUTH2_PROVIDER: %v\n", o.oauth2Provider))
 	builder.WriteString(fmt.Sprintf("HTTP_CLIENT_TIMEOUT: %v\n", o.httpClientTimeout))
 	builder.WriteString(fmt.Sprintf("HTTP_CLIENT_MAX_BODY_SIZE: %v\n", o.httpClientMaxBodySize))
+	builder.WriteString(fmt.Sprintf("AUTH_PROXY_HEADER: %v\n", o.authProxyHeader))
+	builder.WriteString(fmt.Sprintf("AUTH_PROXY_USER_CREATION: %v\n", o.authProxyUserCreation))
 	return builder.String()
 }

+ 4 - 0
config/parser.go

@@ -158,6 +158,10 @@ func (p *Parser) parseLines(lines []string) (err error) {
 			p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
 		case "HTTP_CLIENT_MAX_BODY_SIZE":
 			p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
+		case "AUTH_PROXY_HEADER":
+			p.opts.authProxyHeader = parseString(value, defaultAuthProxyHeader)
+		case "AUTH_PROXY_USER_CREATION":
+			p.opts.authProxyUserCreation = parseBool(value, defaultAuthProxyUserCreation)
 		}
 	}
 

+ 64 - 0
ui/middleware.go

@@ -17,6 +17,7 @@ import (
 	"miniflux.app/logger"
 	"miniflux.app/model"
 	"miniflux.app/storage"
+	"miniflux.app/ui/session"
 
 	"github.com/gorilla/mux"
 )
@@ -155,3 +156,66 @@ func (m *middleware) getUserSessionFromCookie(r *http.Request) *model.UserSessio
 
 	return session
 }
+
+func (m *middleware) handleAuthProxy(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if request.IsAuthenticated(r) || config.Opts.AuthProxyHeader() == "" {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		username := r.Header.Get(config.Opts.AuthProxyHeader())
+		if username == "" {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		sess := session.New(m.store, request.SessionID(r))
+		clientIP := request.ClientIP(r)
+
+		logger.Info("[AuthProxy] Successful auth for %s", username)
+
+		user, err := m.store.UserByUsername(username)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+
+		if user == nil {
+			if !config.Opts.IsAuthProxyUserCreationAllowed() {
+				html.Forbidden(w, r)
+				return
+			}
+
+			user = model.NewUser()
+			user.Username = username
+			user.IsAdmin = false
+
+			if err := m.store.CreateUser(user); err != nil {
+				html.ServerError(w, r, err)
+				return
+			}
+		}
+
+		sessionToken, _, err := m.store.CreateUserSession(user.Username, r.UserAgent(), clientIP)
+		if err != nil {
+			html.ServerError(w, r, err)
+			return
+		}
+
+		logger.Info("[AuthProxy] username=%s just logged in", user.Username)
+
+		m.store.SetLastLogin(user.ID)
+		sess.SetLanguage(user.Language)
+		sess.SetTheme(user.Theme)
+
+		http.SetCookie(w, cookie.New(
+			cookie.CookieUserSessionID,
+			sessionToken,
+			config.Opts.HTTPS,
+			config.Opts.BasePath(),
+		))
+
+		html.Redirect(w, r, route.Path(m.router, "unread"))
+	})
+}

+ 1 - 1
ui/ui.go

@@ -123,7 +123,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool, feedHa
 	// Authentication pages.
 	uiRouter.HandleFunc("/login", handler.checkLogin).Name("checkLogin").Methods("POST")
 	uiRouter.HandleFunc("/logout", handler.logout).Name("logout").Methods("GET")
-	uiRouter.HandleFunc("/", handler.showLoginPage).Name("login").Methods("GET")
+	uiRouter.Handle("/", middleware.handleAuthProxy(http.HandlerFunc(handler.showLoginPage))).Name("login").Methods("GET")
 
 	router.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Type", "text/plain")