[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))
+}