[proxy] Add alogin.Login interface.
Also adds the two implementations: sklogin and proxylogin.
See: http://go/proxy-auth-skia
Bug: skia:11827
Change-Id: I92d4e51ef091abba7ec7c1007a01a47d6945ca72
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/393437
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
diff --git a/go/alogin/login.go b/go/alogin/login.go
new file mode 100644
index 0000000..a050215
--- /dev/null
+++ b/go/alogin/login.go
@@ -0,0 +1,54 @@
+// Package alogin defines the Login interface for handling login in web
+// applications.
+//
+// The implementations of Login should be used with the
+// //infra-sk/modules/alogin-sk control.
+package alogin
+
+import (
+ "net/http"
+
+ "github.com/gorilla/mux"
+)
+
+// Email is an email address.
+type EMail string
+
+// NotLoggedIn is the EMail value used to indicate a user is not logged in.
+const NotLoggedIn EMail = ""
+
+// Status describes the logged in status for a user. Email will be empty if the
+// user is not logged in.
+type Status struct {
+ // LoginURL is the URL to visit to log in.
+ LoginURL string `json:"login"`
+
+ // LogoutURL is the URL to visit to log out.
+ LogoutURL string `json:"logout"`
+
+ // EMail is the email address of the logged in user, or the empty string if
+ // they are not logged in.
+ EMail EMail `json:"email"`
+}
+
+// Login is an abstraction of the functionality we use out of the go/login
+// package.
+type Login interface {
+ // LoggedInAs returns the email of the logged in user, or the empty string
+ // of they are not logged in.
+ LoggedInAs(r *http.Request) EMail
+
+ // NeedsAuthentication will send the right response to the user if they
+ // attempt to use a resource that requires authentication, such as
+ // redirecting them to a login URL or returning an http.StatusForbidden
+ // response code.
+ NeedsAuthentication(w http.ResponseWriter, r *http.Request)
+
+ // RegisterHandlers registers HTTP handlers for any endpoints that need
+ // handling.
+ RegisterHandlers(router *mux.Router)
+
+ // Status returns the logged in status and other details about the current
+ // user.
+ Status(r *http.Request) Status
+}
diff --git a/go/alogin/mocks/Login.go b/go/alogin/mocks/Login.go
new file mode 100644
index 0000000..5822c18
--- /dev/null
+++ b/go/alogin/mocks/Login.go
@@ -0,0 +1,56 @@
+// Code generated by mockery v2.4.0. DO NOT EDIT.
+
+package mocks
+
+import (
+ http "net/http"
+
+ alogin "go.skia.org/infra/go/alogin"
+
+ mock "github.com/stretchr/testify/mock"
+
+ mux "github.com/gorilla/mux"
+)
+
+// Login is an autogenerated mock type for the Login type
+type Login struct {
+ mock.Mock
+}
+
+// LoggedInAs provides a mock function with given fields: r
+func (_m *Login) LoggedInAs(r *http.Request) alogin.EMail {
+ ret := _m.Called(r)
+
+ var r0 alogin.EMail
+ if rf, ok := ret.Get(0).(func(*http.Request) alogin.EMail); ok {
+ r0 = rf(r)
+ } else {
+ r0 = ret.Get(0).(alogin.EMail)
+ }
+
+ return r0
+}
+
+// NeedsAuthentication provides a mock function with given fields: w, r
+func (_m *Login) NeedsAuthentication(w http.ResponseWriter, r *http.Request) {
+ _m.Called(w, r)
+}
+
+// RegisterHandlers provides a mock function with given fields: router
+func (_m *Login) RegisterHandlers(router *mux.Router) {
+ _m.Called(router)
+}
+
+// Status provides a mock function with given fields: r
+func (_m *Login) Status(r *http.Request) alogin.Status {
+ ret := _m.Called(r)
+
+ var r0 alogin.Status
+ if rf, ok := ret.Get(0).(func(*http.Request) alogin.Status); ok {
+ r0 = rf(r)
+ } else {
+ r0 = ret.Get(0).(alogin.Status)
+ }
+
+ return r0
+}
diff --git a/go/alogin/mocks/generate.go b/go/alogin/mocks/generate.go
new file mode 100644
index 0000000..2621cec
--- /dev/null
+++ b/go/alogin/mocks/generate.go
@@ -0,0 +1,3 @@
+package mocks
+
+//go:generate mockery --name Login --dir ../ --output .
diff --git a/go/alogin/proxylogin/proxyauth.go b/go/alogin/proxylogin/proxyauth.go
new file mode 100644
index 0000000..c9ab1c1
--- /dev/null
+++ b/go/alogin/proxylogin/proxyauth.go
@@ -0,0 +1,89 @@
+// Package proxylogin implements alogin.Login when letting a reverse proxy handle
+// authentication
+package proxylogin
+
+import (
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/gorilla/mux"
+ "go.skia.org/infra/go/alogin"
+ "go.skia.org/infra/go/sklog"
+)
+
+// proxyLogin implements alogin.Login by relying on a reverse proxy doing the
+// authentication and then passing the user's logged in status via header value.
+//
+// See https://grafana.com/docs/grafana/latest/auth/auth-proxy/ and
+// https://cloud.google.com/iap/docs/identity-howto#getting_the_users_identity_with_signed_headers
+type proxyLogin struct {
+ // headerName is the name of the header we expect to have the users email.
+ headerName string
+
+ // emailRegex is an optional regex to extract the email address from the header value.
+ emailRegex *regexp.Regexp
+
+ // loginURL is the URL to visit to log in.
+ loginURL string
+
+ // logoutURL is the URL to visit to log out.
+ logoutURL string
+}
+
+// New returns a new instance of proxyLogin.
+//
+// headerName is the name of the header that contains the proxy authentication
+// information.
+//
+// emailRegex is a regex to extract the email address from the header value.
+// This value can be nil. This is useful for reverse proxies that include other
+// information in the header in addition to the email address, such as
+// https://cloud.google.com/iap/docs/identity-howto#getting_the_users_identity_with_signed_headers
+//
+// If supplied, the Regex must have a single subexpression that matches the email
+// address.
+func New(headerName string, emailRegex *regexp.Regexp, loginURL, logoutURL string) *proxyLogin {
+ return &proxyLogin{
+ headerName: headerName,
+ emailRegex: emailRegex,
+ loginURL: loginURL,
+ logoutURL: logoutURL,
+ }
+}
+
+// LoggedInAs implements alogin.Login.
+func (p *proxyLogin) LoggedInAs(r *http.Request) alogin.EMail {
+ value := r.Header.Get(p.headerName)
+ value = strings.TrimSpace(value)
+ if p.emailRegex == nil {
+ return alogin.EMail(value)
+ }
+ submatches := p.emailRegex.FindStringSubmatch(value)
+ if len(submatches) != 2 {
+ sklog.Errorf("Wrong number of regex matches for %q: %q", value, submatches)
+ return ""
+ }
+ return alogin.EMail(submatches[1])
+}
+
+// NeedsAuthentication implements alogin.Login.
+func (p *proxyLogin) NeedsAuthentication(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+}
+
+// RegisterHandlers implements alogin.Login.
+func (p *proxyLogin) RegisterHandlers(router *mux.Router) {
+ // Noop.
+}
+
+func (p *proxyLogin) Status(r *http.Request) alogin.Status {
+ return alogin.Status{
+ EMail: p.LoggedInAs(r),
+ LoginURL: p.loginURL,
+ LogoutURL: p.logoutURL,
+ }
+}
+
+// Assert proxyLogin implements alogin.Login.
+var _ alogin.Login = (*proxyLogin)(nil)
diff --git a/go/alogin/proxylogin/proxyauth_test.go b/go/alogin/proxylogin/proxyauth_test.go
new file mode 100644
index 0000000..1ad120b
--- /dev/null
+++ b/go/alogin/proxylogin/proxyauth_test.go
@@ -0,0 +1,76 @@
+package proxylogin
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "go.skia.org/infra/go/alogin"
+ "go.skia.org/infra/go/testutils/unittest"
+)
+
+const (
+ goodHeaderName = "X-AUTH-USER"
+ unknownHeaderName = "X-SOME-UNKNOWN-HEADER"
+ email alogin.EMail = "someone@example.org"
+ emailAsString string = string(email)
+ loginURL = "https://example.org/login"
+ logoutURL = "https://example.org/logout"
+)
+
+func TestLoggedInAs_HeaderIsMissing_ReturnsEmptyString(t *testing.T) {
+ unittest.SmallTest(t)
+
+ r := httptest.NewRequest("GET", "/", nil)
+ require.Equal(t, alogin.NotLoggedIn, New(unknownHeaderName, nil, loginURL, logoutURL).LoggedInAs(r))
+}
+
+func TestLoggedInAs_HeaderPresent_ReturnsUserEmail(t *testing.T) {
+ unittest.SmallTest(t)
+
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set(goodHeaderName, emailAsString)
+ require.Equal(t, email, New(goodHeaderName, nil, loginURL, logoutURL).LoggedInAs(r))
+}
+
+func TestLoggedInAs_RegexProvided_ReturnsUserEmail(t *testing.T) {
+ unittest.SmallTest(t)
+
+ reg := regexp.MustCompile("accounts.google.com:(.*)")
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set(goodHeaderName, "accounts.google.com:"+emailAsString)
+ require.Equal(t, email, New(goodHeaderName, reg, loginURL, logoutURL).LoggedInAs(r))
+}
+
+func TestLoggedInAs_RegexHasTooManySubGroups_ReturnsEmptyString(t *testing.T) {
+ unittest.SmallTest(t)
+
+ reg := regexp.MustCompile("(too)(many)(subgroups)")
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set(goodHeaderName, emailAsString)
+ require.Equal(t, alogin.NotLoggedIn, New(goodHeaderName, reg, loginURL, logoutURL).LoggedInAs(r))
+}
+
+func TestNeedsAuthentication_EmitsStatusForbidden(t *testing.T) {
+ unittest.SmallTest(t)
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", "/", nil)
+ New(goodHeaderName, nil, loginURL, logoutURL).NeedsAuthentication(w, r)
+ require.Equal(t, http.StatusForbidden, w.Result().StatusCode)
+}
+
+func TestStatus_HeaderPresent_ReturnsUserEmail(t *testing.T) {
+ unittest.SmallTest(t)
+
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set(goodHeaderName, emailAsString)
+ expected := alogin.Status{
+ EMail: email,
+ LoginURL: loginURL,
+ LogoutURL: logoutURL,
+ }
+ require.Equal(t, expected, New(goodHeaderName, nil, loginURL, logoutURL).Status(r))
+}
diff --git a/go/alogin/sklogin/sklogin.go b/go/alogin/sklogin/sklogin.go
new file mode 100644
index 0000000..b6cda2e
--- /dev/null
+++ b/go/alogin/sklogin/sklogin.go
@@ -0,0 +1,65 @@
+// Package sklogin implmements alogin.Login using the //go/login package.
+package sklogin
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gorilla/mux"
+ "go.skia.org/infra/go/alogin"
+ "go.skia.org/infra/go/login"
+ "go.skia.org/infra/go/skerr"
+)
+
+const (
+ loginPath = "/login/"
+ logoutPath = "/logout/"
+)
+
+// sklogin implements alogin.Login using the //go/login package.
+type sklogin struct{}
+
+// New returns a new sklogin instance.
+func New(port string, local bool, authBypassList string) (*sklogin, error) {
+ redirectURL := fmt.Sprintf("http://localhost%s/oauth2callback/", port)
+ if !local {
+ redirectURL = login.DEFAULT_REDIRECT_URL
+ }
+ if authBypassList == "" {
+ authBypassList = login.DEFAULT_ALLOWED_DOMAINS
+ }
+ if err := login.Init(redirectURL, authBypassList, ""); err != nil {
+ return nil, skerr.Wrap(err)
+ }
+
+ return &sklogin{}, nil
+}
+
+// LoggedInAs implements alogin.Login.
+func (_ *sklogin) LoggedInAs(r *http.Request) alogin.EMail {
+ return alogin.EMail(login.LoggedInAs(r))
+}
+
+// NeedsAuthentication implements alogin.Login.
+func (_ *sklogin) NeedsAuthentication(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, login.LoginURL(w, r), http.StatusTemporaryRedirect)
+}
+
+// RegisterHandlers implements alogin.Login.
+func (_ *sklogin) RegisterHandlers(router *mux.Router) {
+ router.HandleFunc(loginPath, login.LoginHandler)
+ router.HandleFunc(logoutPath, login.LogoutHandler)
+ router.HandleFunc("/loginstatus/", login.StatusHandler)
+ router.HandleFunc("/oauth2callback/", login.OAuth2CallbackHandler)
+}
+
+func (s *sklogin) Status(r *http.Request) alogin.Status {
+ return alogin.Status{
+ EMail: s.LoggedInAs(r),
+ LoginURL: loginPath,
+ LogoutURL: logoutPath,
+ }
+}
+
+// Assert sklogin implements alogin.Login.
+var _ alogin.Login = (*sklogin)(nil)
diff --git a/go/alogin/sklogin/sklogin_test.go b/go/alogin/sklogin/sklogin_test.go
new file mode 100644
index 0000000..194e327
--- /dev/null
+++ b/go/alogin/sklogin/sklogin_test.go
@@ -0,0 +1,33 @@
+package sklogin
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/require"
+ "go.skia.org/infra/go/alogin"
+ "go.skia.org/infra/go/testutils/unittest"
+)
+
+func TestRegisterHandlers_HandlersAreRegistered(t *testing.T) {
+ unittest.SmallTest(t)
+
+ router := mux.NewRouter()
+ (&sklogin{}).RegisterHandlers(router)
+ var out mux.RouteMatch
+ r := httptest.NewRequest("GET", "/logout/", nil)
+ require.True(t, router.Match(r, &out))
+}
+
+func TestStatus_CookiesAreNotPresent_EMailIsNotReturnedInStatus(t *testing.T) {
+ unittest.SmallTest(t)
+
+ r := httptest.NewRequest("GET", "/", nil)
+ expected := alogin.Status{
+ EMail: alogin.NotLoggedIn,
+ LoginURL: loginPath,
+ LogoutURL: logoutPath,
+ }
+ require.Equal(t, expected, (&sklogin{}).Status(r))
+}