frontend: react+mui (#3004)

* mui v5 wip

* wip

* wip

* wip

* use compressor for all controlplane endpoints

* wip

* wip

* add deps

* fix authenticate URL

* fix test

* fix test

* fix build

* maybe fix build

* fix integration test

* remove image asset test

* add yarn.lock
This commit is contained in:
Caleb Doxsey 2022-02-07 08:47:58 -07:00 committed by GitHub
parent 64d8748251
commit 2824faecbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 13373 additions and 1455 deletions

View file

@ -6,11 +6,8 @@ import (
"context"
"errors"
"fmt"
"html/template"
"net/url"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/identity"
"github.com/pomerium/pomerium/internal/identity/oauth"
"github.com/pomerium/pomerium/internal/log"
@ -48,8 +45,6 @@ func ValidateOptions(o *config.Options) error {
// Authenticate contains data required to run the authenticate service.
type Authenticate struct {
templates *template.Template
options *config.AtomicOptions
provider *identity.AtomicAuthenticator
state *atomicAuthenticateState
@ -58,10 +53,9 @@ type Authenticate struct {
// New validates and creates a new authenticate service from a set of Options.
func New(cfg *config.Config) (*Authenticate, error) {
a := &Authenticate{
templates: template.Must(frontend.NewTemplates()),
options: config.NewAtomicOptions(),
provider: identity.NewAtomicAuthenticator(),
state: newAtomicAuthenticateState(newAuthenticateState()),
options: config.NewAtomicOptions(),
provider: identity.NewAtomicAuthenticator(),
state: newAtomicAuthenticateState(newAuthenticateState()),
}
state, err := newAuthenticateStateFromConfig(cfg)
@ -123,17 +117,3 @@ func (a *Authenticate) updateProvider(cfg *config.Config) error {
return nil
}
// buildURLValues creates a new url.Values map by traversing the keys in `defaults` and using the values
// from `values` if they exist, otherwise the provided defaults
func buildURLValues(values, defaults url.Values) url.Values {
result := make(url.Values)
for k, vs := range defaults {
if values.Has(k) {
result[k] = values[k]
} else if vs != nil {
result[k] = vs
}
}
return result
}

View file

@ -33,6 +33,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/ui"
)
// Handler returns the authenticate service's handler chain.
@ -99,7 +100,27 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut))
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
sr.Path("/device-enrolled").Handler(handlers.DeviceEnrolled())
sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
authenticateURL, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return err
}
handlers.DeviceEnrolled(authenticateURL, a.state.Load().sharedKey).ServeHTTP(w, r)
return nil
}))
for _, fileName := range []string{
"apple-touch-icon.png",
"favicon-16x16.png",
"favicon-32x32.png",
"favicon.ico",
"index.css",
"index.js",
} {
fileName := fileName
sr.Path("/" + fileName).Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return ui.ServeFile(w, r, fileName)
}))
}
cr := sr.PathPrefix("/callback").Subrouter()
cr.Use(func(h http.Handler) http.Handler {
@ -463,6 +484,11 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
state := a.state.Load()
authenticateURL, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return err
}
s, err := a.getSessionFromCtx(ctx)
if err != nil {
s.ID = uuid.New().String()
@ -500,52 +526,17 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
groups = append(groups, pbDirectoryGroup)
}
signoutURL, err := a.getSignOutURL(r)
if err != nil {
return fmt.Errorf("invalid signout url: %w", err)
}
webAuthnURL, err := a.getWebAuthnURL(r.URL.Query())
if err != nil {
return fmt.Errorf("invalid webauthn url: %w", err)
}
type DeviceCredentialInfo struct {
ID string
}
var currentDeviceCredentials, otherDeviceCredentials []DeviceCredentialInfo
for _, id := range pbUser.GetDeviceCredentialIds() {
selected := false
for _, c := range pbSession.GetDeviceCredentials() {
if c.GetId() == id {
selected = true
}
}
if selected {
currentDeviceCredentials = append(currentDeviceCredentials, DeviceCredentialInfo{
ID: id,
})
} else {
otherDeviceCredentials = append(otherDeviceCredentials, DeviceCredentialInfo{
ID: id,
})
}
}
input := map[string]interface{}{
"IsImpersonated": isImpersonated,
"State": s, // local session state (cookie, header, etc)
"Session": pbSession, // current access, refresh, id token
"User": pbUser, // user details inferred from oidc id_token
"CurrentDeviceCredentials": currentDeviceCredentials,
"OtherDeviceCredentials": otherDeviceCredentials,
"DirectoryUser": pbDirectoryUser, // user details inferred from idp directory
"DirectoryGroups": groups, // user's groups inferred from idp directory
"csrfField": csrf.TemplateField(r),
"SignOutURL": signoutURL,
"WebAuthnURL": webAuthnURL,
}
return a.templates.ExecuteTemplate(w, "userInfo.html", input)
handlers.UserInfo(handlers.UserInfoData{
CSRFToken: csrf.Token(r),
DirectoryGroups: groups,
DirectoryUser: pbDirectoryUser,
IsImpersonated: isImpersonated,
Session: pbSession,
SignOutURL: urlutil.SignOutURL(r, authenticateURL, state.sharedKey),
User: pbUser,
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
}).ServeHTTP(w, r)
return nil
}
func (a *Authenticate) saveSessionToDataBroker(
@ -682,12 +673,18 @@ func (a *Authenticate) getWebauthnState(ctx context.Context) (*webauthn.State, e
return nil, err
}
authenticateURL, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return nil, err
}
pomeriumDomains, err := a.options.Load().GetAllRouteableHTTPDomains()
if err != nil {
return nil, err
}
return &webauthn.State{
AuthenticateURL: authenticateURL,
SharedKey: state.sharedKey,
Client: state.dataBrokerClient,
PomeriumDomains: pomeriumDomains,

View file

@ -1,18 +1,19 @@
package handlers
import (
"html/template"
"net/http"
"net/url"
"github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/ui"
)
// DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device.
func DeviceEnrolled() http.Handler {
tpl := template.Must(frontend.NewTemplates())
type TemplateData struct{}
func DeviceEnrolled(authenticateURL *url.URL, sharedKey []byte) http.Handler {
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return tpl.ExecuteTemplate(w, "device-enrolled.html", TemplateData{})
return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{
"signOutUrl": urlutil.SignOutURL(r, authenticateURL, sharedKey),
})
})
}

View file

@ -0,0 +1,59 @@
package handlers
import (
"encoding/json"
"net/http"
"google.golang.org/protobuf/encoding/protojson"
"github.com/pomerium/pomerium/internal/directory"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/ui"
)
// UserInfoData is the data for the UserInfo page.
type UserInfoData struct {
CSRFToken string
DirectoryGroups []*directory.Group
DirectoryUser *directory.User
IsImpersonated bool
Session *session.Session
SignOutURL string
User *user.User
WebAuthnURL string
}
// ToJSON converts the data into a JSON map.
func (data UserInfoData) ToJSON() map[string]interface{} {
m := map[string]interface{}{}
m["csrfToken"] = data.CSRFToken
var directoryGroups []json.RawMessage
for _, directoryGroup := range data.DirectoryGroups {
if bs, err := protojson.Marshal(directoryGroup); err == nil {
directoryGroups = append(directoryGroups, json.RawMessage(bs))
}
}
m["directoryGroups"] = directoryGroups
if bs, err := protojson.Marshal(data.DirectoryUser); err == nil {
m["directoryUser"] = json.RawMessage(bs)
}
m["isImpersonated"] = data.IsImpersonated
if bs, err := protojson.Marshal(data.Session); err == nil {
m["session"] = json.RawMessage(bs)
}
m["signOutUrl"] = data.SignOutURL
if bs, err := protojson.Marshal(data.User); err == nil {
m["user"] = json.RawMessage(bs)
}
m["webAuthnUrl"] = data.WebAuthnURL
return m
}
// UserInfo returns a handler that renders the user info page.
func UserInfo(data UserInfoData) http.Handler {
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return ui.ServePage(w, r, "UserInfo", data.ToJSON())
})
}

View file

@ -8,21 +8,17 @@ import (
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"net/url"
"github.com/google/uuid"
"github.com/pomerium/csrf"
"github.com/pomerium/webauthn"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/pomerium/internal/encoding/jws"
"github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/middleware"
"github.com/pomerium/pomerium/internal/sessions"
@ -33,6 +29,7 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
"github.com/pomerium/pomerium/pkg/webauthnutil"
"github.com/pomerium/pomerium/ui"
)
const maxAuthenticateResponses = 5
@ -50,13 +47,14 @@ var (
// State is the state needed by the Handler to handle requests.
type State struct {
SharedKey []byte
AuthenticateURL *url.URL
Client databroker.DataBrokerServiceClient
PomeriumDomains []string
RelyingParty *webauthn.RelyingParty
Session *session.Session
SessionState *sessions.State
SessionStore sessions.SessionStore
RelyingParty *webauthn.RelyingParty
SharedKey []byte
}
// A StateProvider provides state for the handler.
@ -64,15 +62,13 @@ type StateProvider = func(context.Context) (*State, error)
// Handler is the WebAuthn device handler.
type Handler struct {
getState StateProvider
templates *template.Template
getState StateProvider
}
// New creates a new Handler.
func New(getState StateProvider) *Handler {
return &Handler{
getState: getState,
templates: template.Must(frontend.NewTemplates()),
getState: getState,
}
}
@ -373,23 +369,12 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat
creationOptions := webauthnutil.GenerateCreationOptions(state.SharedKey, deviceType, u)
requestOptions := webauthnutil.GenerateRequestOptions(state.SharedKey, deviceType, knownDeviceCredentials)
var buf bytes.Buffer
err = h.templates.ExecuteTemplate(&buf, "webauthn.html", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
"Data": map[string]interface{}{
"creationOptions": creationOptions,
"requestOptions": requestOptions,
},
"SelfURL": r.URL.String(),
return ui.ServePage(w, r, "WebAuthnRegistration", map[string]interface{}{
"creationOptions": creationOptions,
"requestOptions": requestOptions,
"selfUrl": r.URL.String(),
"signOutUrl": urlutil.SignOutURL(r, state.AuthenticateURL, state.SharedKey),
})
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, &buf)
return err
}
func (h *Handler) saveSessionAndRedirect(w http.ResponseWriter, r *http.Request, state *State, rawRedirectURI string) error {

View file

@ -5,7 +5,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
@ -29,7 +28,6 @@ import (
"github.com/pomerium/pomerium/internal/encoding"
"github.com/pomerium/pomerium/internal/encoding/jws"
"github.com/pomerium/pomerium/internal/encoding/mock"
"github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/identity"
"github.com/pomerium/pomerium/internal/identity/oidc"
@ -49,7 +47,6 @@ func testAuthenticate() *Authenticate {
redirectURL: redirectURL,
cookieSecret: cryptutil.NewKey(),
})
auth.templates = template.Must(frontend.NewTemplates())
auth.options = config.NewAtomicOptions()
auth.options.Store(&config.Options{
SharedKey: cryptutil.NewBase64Key(),
@ -268,9 +265,8 @@ func TestAuthenticate_SignOut(t *testing.T) {
},
directoryClient: new(mockDirectoryServiceClient),
}),
templates: template.Must(frontend.NewTemplates()),
options: config.NewAtomicOptions(),
provider: identity.NewAtomicAuthenticator(),
options: config.NewAtomicOptions(),
provider: identity.NewAtomicAuthenticator(),
}
if tt.signoutRedirectURL != "" {
opts := a.options.Load()
@ -671,7 +667,6 @@ func TestAuthenticate_userInfo(t *testing.T) {
},
directoryClient: new(mockDirectoryServiceClient),
}),
templates: template.Must(frontend.NewTemplates()),
}
r := httptest.NewRequest(tt.method, tt.url.String(), nil)
state, err := tt.sessionStore.LoadSession(r)
@ -761,7 +756,6 @@ func TestAuthenticate_SignOut_CSRF(t *testing.T) {
},
directoryClient: new(mockDirectoryServiceClient),
}),
templates: template.Must(frontend.NewTemplates()),
}
tests := []struct {
name string

View file

@ -1,57 +0,0 @@
package authenticate
import (
"net/http"
"net/url"
"github.com/pomerium/pomerium/internal/urlutil"
"github.com/pomerium/pomerium/pkg/webauthnutil"
)
func (a *Authenticate) getRedirectURI(r *http.Request) (string, bool) {
if v := r.FormValue(urlutil.QueryRedirectURI); v != "" {
return v, true
}
if c, err := r.Cookie(urlutil.QueryRedirectURI); err == nil {
return c.Value, true
}
return "", false
}
func (a *Authenticate) getSignOutURL(r *http.Request) (*url.URL, error) {
uri, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return nil, err
}
uri = uri.ResolveReference(&url.URL{
Path: "/.pomerium/sign_out",
})
if redirectURI, ok := a.getRedirectURI(r); ok {
uri.RawQuery = (&url.Values{
urlutil.QueryRedirectURI: {redirectURI},
}).Encode()
}
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
}
func (a *Authenticate) getWebAuthnURL(values url.Values) (*url.URL, error) {
uri, err := a.options.Load().GetAuthenticateURL()
if err != nil {
return nil, err
}
uri = uri.ResolveReference(&url.URL{
Path: "/.pomerium/webauthn",
RawQuery: buildURLValues(values, url.Values{
urlutil.QueryDeviceType: {webauthnutil.DefaultDeviceType},
urlutil.QueryEnrollmentToken: nil,
urlutil.QueryRedirectURI: {uri.ResolveReference(&url.URL{
Path: "/.pomerium/device-enrolled",
}).String()},
}).Encode(),
})
return urlutil.NewSignedURL(a.state.Load().sharedKey, uri).Sign(), nil
}

View file

@ -1,52 +0,0 @@
package authenticate
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pomerium/pomerium/internal/urlutil"
)
func TestAuthenticate_getRedirectURI(t *testing.T) {
t.Run("query", func(t *testing.T) {
r, err := http.NewRequest("GET", "https://www.example.com?"+(url.Values{
urlutil.QueryRedirectURI: {"https://www.example.com/redirect"},
}).Encode(), nil)
require.NoError(t, err)
a := new(Authenticate)
redirectURI, ok := a.getRedirectURI(r)
assert.True(t, ok)
assert.Equal(t, "https://www.example.com/redirect", redirectURI)
})
t.Run("form", func(t *testing.T) {
r, err := http.NewRequest("POST", "https://www.example.com", strings.NewReader((url.Values{
urlutil.QueryRedirectURI: {"https://www.example.com/redirect"},
}).Encode()))
require.NoError(t, err)
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
a := new(Authenticate)
redirectURI, ok := a.getRedirectURI(r)
assert.True(t, ok)
assert.Equal(t, "https://www.example.com/redirect", redirectURI)
})
t.Run("cookie", func(t *testing.T) {
r, err := http.NewRequest("GET", "https://www.example.com", nil)
require.NoError(t, err)
r.AddCookie(&http.Cookie{
Name: urlutil.QueryRedirectURI,
Value: "https://www.example.com/redirect",
})
a := new(Authenticate)
redirectURI, ok := a.getRedirectURI(r)
assert.True(t, ok)
assert.Equal(t, "https://www.example.com/redirect", redirectURI)
})
}