Skip to content

Commit

Permalink
Implement support for authentication via Auth Proxy
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
pborzenkov authored and fguillot committed Feb 25, 2020
1 parent d5adf8b commit 7389c79
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 1 deletion.
70 changes: 70 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 20 additions & 0 deletions config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const (
defaultPocketConsumerKey = ""
defaultHTTPClientTimeout = 20
defaultHTTPClientMaxBodySize = 15
defaultAuthProxyHeader = ""
defaultAuthProxyUserCreation = false
)

// Options contains configuration options.
Expand Down Expand Up @@ -82,6 +84,8 @@ type Options struct {
pocketConsumerKey string
httpClientTimeout int
httpClientMaxBodySize int64
authProxyHeader string
authProxyUserCreation bool
}

// NewOptions returns Options with default values.
Expand Down Expand Up @@ -121,6 +125,8 @@ func NewOptions() *Options {
pocketConsumerKey: defaultPocketConsumerKey,
httpClientTimeout: defaultHTTPClientTimeout,
httpClientMaxBodySize: defaultHTTPClientMaxBodySize * 1024 * 1024,
authProxyHeader: defaultAuthProxyHeader,
authProxyUserCreation: defaultAuthProxyUserCreation,
}
}

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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 changes: 4 additions & 0 deletions config/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
64 changes: 64 additions & 0 deletions ui/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"miniflux.app/logger"
"miniflux.app/model"
"miniflux.app/storage"
"miniflux.app/ui/session"

"github.com/gorilla/mux"
)
Expand Down Expand Up @@ -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"))
})
}
2 changes: 1 addition & 1 deletion ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 7389c79

Please sign in to comment.