diff --git a/config/config_test.go b/config/config_test.go index c1c9e2af..ad4ce6cf 100644 --- a/config/config_test.go +++ b/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) + } +} diff --git a/config/options.go b/config/options.go index 27387a26..3e817162 100644 --- a/config/options.go +++ b/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() } diff --git a/config/parser.go b/config/parser.go index 2e8f92d4..4011c56c 100644 --- a/config/parser.go +++ b/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) } } diff --git a/ui/middleware.go b/ui/middleware.go index 5f6dacd2..d023cb0d 100644 --- a/ui/middleware.go +++ b/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")) + }) +} diff --git a/ui/ui.go b/ui/ui.go index dabc2fcc..7c884ff6 100644 --- a/ui/ui.go +++ b/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")