mirror of
https://github.com/pomerium/pomerium.git
synced 2025-05-31 01:47:33 +02:00
userinfo: fix logout button, add sign out confirm page (#3058)
* userinfo: fix logout button, add sign out confirm page * fix test
This commit is contained in:
parent
9300208e87
commit
38c7089642
18 changed files with 251 additions and 162 deletions
|
@ -8,7 +8,7 @@ linters-settings:
|
||||||
lines: 100
|
lines: 100
|
||||||
statements: 50
|
statements: 50
|
||||||
gci:
|
gci:
|
||||||
local-prefixes: github.com/pomerium/pomerium
|
local-prefixes: github.com/pomerium
|
||||||
goconst:
|
goconst:
|
||||||
min-len: 2
|
min-len: 2
|
||||||
min-occurrences: 2
|
min-occurrences: 2
|
||||||
|
@ -28,7 +28,7 @@ linters-settings:
|
||||||
gocyclo:
|
gocyclo:
|
||||||
min-complexity: 15
|
min-complexity: 15
|
||||||
goimports:
|
goimports:
|
||||||
local-prefixes: github.com/pomerium/pomerium
|
local-prefixes: github.com/pomerium
|
||||||
govet:
|
govet:
|
||||||
check-shadowing: false
|
check-shadowing: false
|
||||||
lll:
|
lll:
|
||||||
|
|
|
@ -12,11 +12,11 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/pomerium/csrf"
|
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
"github.com/pomerium/csrf"
|
||||||
"github.com/pomerium/pomerium/authenticate/handlers"
|
"github.com/pomerium/pomerium/authenticate/handlers"
|
||||||
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
"github.com/pomerium/pomerium/authenticate/handlers/webauthn"
|
||||||
"github.com/pomerium/pomerium/internal/httputil"
|
"github.com/pomerium/pomerium/internal/httputil"
|
||||||
|
@ -55,8 +55,7 @@ func (a *Authenticate) Mount(r *mux.Router) {
|
||||||
csrf.Path("/"),
|
csrf.Path("/"),
|
||||||
csrf.UnsafePaths(
|
csrf.UnsafePaths(
|
||||||
[]string{
|
[]string{
|
||||||
"/oauth2/callback", // rfc6749#section-10.12 accepts GET
|
"/oauth2/callback", // rfc6749#section-10.12 accepts GET
|
||||||
"/.pomerium/sign_out", // https://openid.net/specs/openid-connect-frontchannel-1_0.html
|
|
||||||
}),
|
}),
|
||||||
csrf.FormValueName("state"), // rfc6749#section-10.12
|
csrf.FormValueName("state"), // rfc6749#section-10.12
|
||||||
csrf.CookieName(csrfKey),
|
csrf.CookieName(csrfKey),
|
||||||
|
@ -96,14 +95,10 @@ func (a *Authenticate) mountDashboard(r *mux.Router) {
|
||||||
sr.Use(a.VerifySession)
|
sr.Use(a.VerifySession)
|
||||||
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
|
sr.Path("/").Handler(a.requireValidSignatureOnRedirect(a.userInfo))
|
||||||
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
|
sr.Path("/sign_in").Handler(a.requireValidSignature(a.SignIn))
|
||||||
sr.Path("/sign_out").Handler(a.requireValidSignature(a.SignOut))
|
sr.Path("/sign_out").Handler(httputil.HandlerFunc(a.SignOut))
|
||||||
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
|
sr.Path("/webauthn").Handler(webauthn.New(a.getWebauthnState))
|
||||||
sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
sr.Path("/device-enrolled").Handler(httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
authenticateURL, err := a.options.Load().GetAuthenticateURL()
|
handlers.DeviceEnrolled().ServeHTTP(w, r)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
handlers.DeviceEnrolled(authenticateURL, a.state.Load().sharedKey).ServeHTTP(w, r)
|
|
||||||
return nil
|
return nil
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@ -276,6 +271,25 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error {
|
||||||
// SignOut signs the user out and attempts to revoke the user's identity session
|
// SignOut signs the user out and attempts to revoke the user's identity session
|
||||||
// Handles both GET and POST.
|
// Handles both GET and POST.
|
||||||
func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error {
|
func (a *Authenticate) SignOut(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// check for an HMAC'd URL. If none is found, show a confirmation page.
|
||||||
|
err := middleware.ValidateRequestURL(a.getExternalRequest(r), a.state.Load().sharedKey)
|
||||||
|
if err != nil {
|
||||||
|
authenticateURL, err := a.options.Load().GetAuthenticateURL()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.SignOutConfirm(handlers.SignOutConfirmData{
|
||||||
|
URL: urlutil.SignOutURL(r, authenticateURL, a.state.Load().sharedKey),
|
||||||
|
}).ServeHTTP(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise actually do the sign out
|
||||||
|
return a.signOutRedirect(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticate) signOutRedirect(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut")
|
ctx, span := trace.StartSpan(r.Context(), "authenticate.SignOut")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
|
@ -553,7 +567,6 @@ func (a *Authenticate) userInfo(w http.ResponseWriter, r *http.Request) error {
|
||||||
DirectoryUser: pbDirectoryUser,
|
DirectoryUser: pbDirectoryUser,
|
||||||
IsImpersonated: isImpersonated,
|
IsImpersonated: isImpersonated,
|
||||||
Session: pbSession,
|
Session: pbSession,
|
||||||
SignOutURL: urlutil.SignOutURL(r, authenticateURL, state.sharedKey),
|
|
||||||
User: pbUser,
|
User: pbUser,
|
||||||
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
|
WebAuthnURL: urlutil.WebAuthnURL(r, authenticateURL, state.sharedKey, r.URL.Query()),
|
||||||
}).ServeHTTP(w, r)
|
}).ServeHTTP(w, r)
|
||||||
|
|
|
@ -2,18 +2,14 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/internal/httputil"
|
"github.com/pomerium/pomerium/internal/httputil"
|
||||||
"github.com/pomerium/pomerium/internal/urlutil"
|
|
||||||
"github.com/pomerium/pomerium/ui"
|
"github.com/pomerium/pomerium/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device.
|
// DeviceEnrolled displays an HTML page informing the user that they've successfully enrolled a device.
|
||||||
func DeviceEnrolled(authenticateURL *url.URL, sharedKey []byte) http.Handler {
|
func DeviceEnrolled() http.Handler {
|
||||||
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{
|
return ui.ServePage(w, r, "DeviceEnrolled", map[string]interface{}{})
|
||||||
"signOutUrl": urlutil.SignOutURL(r, authenticateURL, sharedKey),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
27
authenticate/handlers/signout.go
Normal file
27
authenticate/handlers/signout.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pomerium/pomerium/internal/httputil"
|
||||||
|
"github.com/pomerium/pomerium/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SignOutConfirmData is the data for the SignOutConfirm page.
|
||||||
|
type SignOutConfirmData struct {
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToJSON converts the data into a JSON map.
|
||||||
|
func (data SignOutConfirmData) ToJSON() map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"url": data.URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignOutConfirm returns a handler that renders the sign out confirm page.
|
||||||
|
func SignOutConfirm(data SignOutConfirmData) http.Handler {
|
||||||
|
return httputil.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return ui.ServePage(w, r, "SignOutConfirm", data.ToJSON())
|
||||||
|
})
|
||||||
|
}
|
|
@ -20,7 +20,6 @@ type UserInfoData struct {
|
||||||
DirectoryUser *directory.User
|
DirectoryUser *directory.User
|
||||||
IsImpersonated bool
|
IsImpersonated bool
|
||||||
Session *session.Session
|
Session *session.Session
|
||||||
SignOutURL string
|
|
||||||
User *user.User
|
User *user.User
|
||||||
WebAuthnURL string
|
WebAuthnURL string
|
||||||
}
|
}
|
||||||
|
@ -43,7 +42,6 @@ func (data UserInfoData) ToJSON() map[string]interface{} {
|
||||||
if bs, err := protojson.Marshal(data.Session); err == nil {
|
if bs, err := protojson.Marshal(data.Session); err == nil {
|
||||||
m["session"] = json.RawMessage(bs)
|
m["session"] = json.RawMessage(bs)
|
||||||
}
|
}
|
||||||
m["signOutUrl"] = data.SignOutURL
|
|
||||||
if bs, err := protojson.Marshal(data.User); err == nil {
|
if bs, err := protojson.Marshal(data.User); err == nil {
|
||||||
m["user"] = json.RawMessage(bs)
|
m["user"] = json.RawMessage(bs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
@ -30,6 +29,7 @@ import (
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||||
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
||||||
"github.com/pomerium/pomerium/ui"
|
"github.com/pomerium/pomerium/ui"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxAuthenticateResponses = 5
|
const maxAuthenticateResponses = 5
|
||||||
|
@ -373,7 +373,6 @@ func (h *Handler) handleView(w http.ResponseWriter, r *http.Request, state *Stat
|
||||||
"creationOptions": creationOptions,
|
"creationOptions": creationOptions,
|
||||||
"requestOptions": requestOptions,
|
"requestOptions": requestOptions,
|
||||||
"selfUrl": r.URL.String(),
|
"selfUrl": r.URL.String(),
|
||||||
"signOutUrl": urlutil.SignOutURL(r, state.AuthenticateURL, state.SharedKey),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -227,12 +227,84 @@ func TestAuthenticate_SignOut(t *testing.T) {
|
||||||
wantCode int
|
wantCode int
|
||||||
wantBody string
|
wantBody string
|
||||||
}{
|
}{
|
||||||
{"good post", http.MethodPost, nil, "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""},
|
{
|
||||||
{"signout redirect url", http.MethodPost, nil, "", "https://signout-redirect-url.example.com", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""},
|
"good post",
|
||||||
{"failed revoke", http.MethodPost, nil, "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""},
|
http.MethodPost,
|
||||||
{"load session error", http.MethodPost, errors.New("error"), "https://corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{RevokeError: errors.New("OH NO")}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""},
|
nil,
|
||||||
{"bad redirect uri", http.MethodPost, nil, "corp.pomerium.io/", "", "sig", "ts", identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusFound, ""},
|
"https://corp.pomerium.io/",
|
||||||
{"no redirect uri", http.MethodPost, nil, "", "", "sig", "ts", identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))}, &mstore.Store{Encrypted: true, Session: &sessions.State{}}, http.StatusOK, "{\"Status\":200,\"Error\":\"OK: user logged out\"}\n"},
|
"",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusFound,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signout redirect url",
|
||||||
|
http.MethodPost,
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"https://signout-redirect-url.example.com",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusFound,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"failed revoke",
|
||||||
|
http.MethodPost,
|
||||||
|
nil,
|
||||||
|
"https://corp.pomerium.io/",
|
||||||
|
"",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{RevokeError: errors.New("OH NO")},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusFound,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"load session error",
|
||||||
|
http.MethodPost,
|
||||||
|
errors.New("error"),
|
||||||
|
"https://corp.pomerium.io/",
|
||||||
|
"",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{RevokeError: errors.New("OH NO")},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusFound,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bad redirect uri",
|
||||||
|
http.MethodPost,
|
||||||
|
nil,
|
||||||
|
"corp.pomerium.io/",
|
||||||
|
"",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{LogOutError: oidc.ErrSignoutNotImplemented},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusFound,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no redirect uri",
|
||||||
|
http.MethodPost,
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"sig",
|
||||||
|
"ts",
|
||||||
|
identity.MockProvider{LogOutResponse: (*uriParseHelper("https://microsoft.com"))},
|
||||||
|
&mstore.Store{Encrypted: true, Session: &sessions.State{}},
|
||||||
|
http.StatusOK,
|
||||||
|
"{\"Status\":200,\"Error\":\"OK: user logged out\"}\n",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
tt := tt
|
tt := tt
|
||||||
|
@ -295,7 +367,7 @@ func TestAuthenticate_SignOut(t *testing.T) {
|
||||||
r.Header.Set("Accept", "application/json")
|
r.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
httputil.HandlerFunc(a.SignOut).ServeHTTP(w, r)
|
httputil.HandlerFunc(a.signOutRedirect).ServeHTTP(w, r)
|
||||||
if status := w.Code; status != tt.wantCode {
|
if status := w.Code; status != tt.wantCode {
|
||||||
t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode)
|
t.Errorf("handler returned wrong status code: got %v want %v", status, tt.wantCode)
|
||||||
}
|
}
|
||||||
|
@ -727,89 +799,6 @@ func (m mockDirectoryServiceClient) RefreshUser(ctx context.Context, in *directo
|
||||||
return nil, status.Error(codes.Unimplemented, "")
|
return nil, status.Error(codes.Unimplemented, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthenticate_SignOut_CSRF(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
signer, err := jws.NewHS256Signer(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
pbNow, _ := ptypes.TimestampProto(now)
|
|
||||||
a := &Authenticate{
|
|
||||||
options: config.NewAtomicOptions(),
|
|
||||||
state: newAtomicAuthenticateState(&authenticateState{
|
|
||||||
// sessionStore: tt.sessionStore,
|
|
||||||
cookieSecret: cryptutil.NewKey(),
|
|
||||||
encryptedEncoder: signer,
|
|
||||||
sharedEncoder: signer,
|
|
||||||
dataBrokerClient: mockDataBrokerServiceClient{
|
|
||||||
get: func(ctx context.Context, in *databroker.GetRequest, opts ...grpc.CallOption) (*databroker.GetResponse, error) {
|
|
||||||
data, err := ptypes.MarshalAny(&session.Session{
|
|
||||||
Id: "SESSION_ID",
|
|
||||||
UserId: "USER_ID",
|
|
||||||
IdToken: &session.IDToken{IssuedAt: pbNow},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &databroker.GetResponse{
|
|
||||||
Record: &databroker.Record{
|
|
||||||
Version: 1,
|
|
||||||
Type: data.GetTypeUrl(),
|
|
||||||
Id: "SESSION_ID",
|
|
||||||
Data: data,
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
directoryClient: new(mockDirectoryServiceClient),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setCSRFCookie bool
|
|
||||||
method string
|
|
||||||
wantStatus int
|
|
||||||
wantBody string
|
|
||||||
}{
|
|
||||||
{"GET without CSRF should fail", false, "GET", 400, "{\"Status\":400,\"Error\":\"Bad Request: CSRF token invalid\"}\n"},
|
|
||||||
{"POST without CSRF should fail", false, "POST", 400, "{\"Status\":400,\"Error\":\"Bad Request: CSRF token invalid\"}\n"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
s := a.Handler()
|
|
||||||
|
|
||||||
// Obtain a CSRF cookie via a GET request.
|
|
||||||
orr, err := http.NewRequest("GET", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rr, orr)
|
|
||||||
|
|
||||||
r, err := http.NewRequest(tt.method, "/.pomerium/sign_out", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if tt.setCSRFCookie {
|
|
||||||
r.Header.Set("Cookie", rr.Header().Get("Set-Cookie"))
|
|
||||||
}
|
|
||||||
r.Header.Set("Accept", "application/json")
|
|
||||||
r.Header.Set("Referer", "/")
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rr, r)
|
|
||||||
|
|
||||||
if rr.Code != tt.wantStatus {
|
|
||||||
t.Errorf("status: got %v want %v", rr.Code, tt.wantStatus)
|
|
||||||
}
|
|
||||||
body := rr.Body.String()
|
|
||||||
if diff := cmp.Diff(body, tt.wantBody); diff != "" {
|
|
||||||
t.Errorf("handler returned wrong body Body: %s", diff)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustParseURL(rawurl string) *url.URL {
|
func mustParseURL(rawurl string) *url.URL {
|
||||||
u, err := url.Parse(rawurl)
|
u, err := url.Parse(rawurl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/go-jose/go-jose/v3"
|
"github.com/go-jose/go-jose/v3"
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/config"
|
"github.com/pomerium/pomerium/config"
|
||||||
"github.com/pomerium/pomerium/internal/encoding"
|
"github.com/pomerium/pomerium/internal/encoding"
|
||||||
|
@ -24,6 +23,7 @@ import (
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/directory"
|
"github.com/pomerium/pomerium/pkg/grpc/directory"
|
||||||
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)
|
var outboundGRPCConnection = new(grpc.CachedOutboundGRPClientConn)
|
||||||
|
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/pomerium/csrf"
|
|
||||||
|
|
||||||
|
"github.com/pomerium/csrf"
|
||||||
"github.com/pomerium/pomerium/ui"
|
"github.com/pomerium/pomerium/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,13 @@ package webauthnutil
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/pkg/encoding/base58"
|
"github.com/pomerium/pomerium/pkg/encoding/base58"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CredentialStorage stores credentials in the databroker.
|
// CredentialStorage stores credentials in the databroker.
|
||||||
|
|
|
@ -4,13 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockDataBrokerServiceClient struct {
|
type mockDataBrokerServiceClient struct {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package webauthnutil
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/pomerium/webauthn/cose"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
@ -11,6 +10,7 @@ import (
|
||||||
"github.com/pomerium/pomerium/internal/urlutil"
|
"github.com/pomerium/pomerium/internal/urlutil"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||||
|
"github.com/pomerium/webauthn/cose"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultDeviceType is the default device type when none is specified.
|
// DefaultDeviceType is the default device type when none is specified.
|
||||||
|
|
|
@ -3,12 +3,12 @@ package webauthnutil
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
"github.com/pomerium/webauthn/cose"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/device"
|
"github.com/pomerium/pomerium/pkg/grpc/device"
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
|
"github.com/pomerium/webauthn/cose"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGenerateCreationOptions(t *testing.T) {
|
func TestGenerateCreationOptions(t *testing.T) {
|
||||||
|
|
|
@ -2,9 +2,9 @@ package webauthnutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/pomerium/webauthn"
|
|
||||||
|
|
||||||
"github.com/pomerium/pomerium/pkg/grpc/user"
|
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||||
|
"github.com/pomerium/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859")
|
var pomeriumUserNamespace = uuid.MustParse("2929d3f7-f0b0-478f-9dd5-970d51eb3859")
|
||||||
|
|
|
@ -2,16 +2,17 @@ import DeviceEnrolledPage from "./components/DeviceEnrolledPage";
|
||||||
import ErrorPage from "./components/ErrorPage";
|
import ErrorPage from "./components/ErrorPage";
|
||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
|
import SignOutConfirmPage from "./components/SignOutConfirmPage";
|
||||||
|
import { ToolbarOffset } from "./components/ToolbarOffset";
|
||||||
import UserInfoPage from "./components/UserInfoPage";
|
import UserInfoPage from "./components/UserInfoPage";
|
||||||
import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage";
|
import WebAuthnRegistrationPage from "./components/WebAuthnRegistrationPage";
|
||||||
|
import { SubpageContextProvider } from "./context/Subpage";
|
||||||
import { createTheme } from "./theme";
|
import { createTheme } from "./theme";
|
||||||
import { PageData } from "./types";
|
import { PageData } from "./types";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import CssBaseline from "@mui/material/CssBaseline";
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
import {ToolbarOffset} from "./components/ToolbarOffset";
|
|
||||||
import Box from "@mui/material/Box";
|
|
||||||
import {SubpageContextProvider} from "./context/Subpage";
|
|
||||||
|
|
||||||
const theme = createTheme();
|
const theme = createTheme();
|
||||||
|
|
||||||
|
@ -25,6 +26,9 @@ const App: FC = () => {
|
||||||
case "Error":
|
case "Error":
|
||||||
body = <ErrorPage data={data} />;
|
body = <ErrorPage data={data} />;
|
||||||
break;
|
break;
|
||||||
|
case "SignOutConfirm":
|
||||||
|
body = <SignOutConfirmPage data={data} />;
|
||||||
|
break;
|
||||||
case "UserInfo":
|
case "UserInfo":
|
||||||
body = <UserInfoPage data={data} />;
|
body = <UserInfoPage data={data} />;
|
||||||
break;
|
break;
|
||||||
|
@ -36,15 +40,16 @@ const App: FC = () => {
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<SubpageContextProvider>
|
<SubpageContextProvider>
|
||||||
<Header
|
<Header includeSidebar={data?.page === "UserInfo"} data={data} />
|
||||||
includeSidebar={data?.page === "UserInfo"}
|
|
||||||
data={data}
|
|
||||||
/>
|
|
||||||
<ToolbarOffset />
|
<ToolbarOffset />
|
||||||
<Box
|
<Box sx={{ overflow: "hidden", height: "calc(100vh - 120px)" }}>
|
||||||
sx={{overflow: 'hidden', height: 'calc(100vh - 120px)'}}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<Box sx={{overflow: 'auto', height: '100%', paddingTop: theme.spacing(5)}}>
|
overflow: "auto",
|
||||||
|
height: "100%",
|
||||||
|
paddingTop: theme.spacing(5),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{body}
|
{body}
|
||||||
<ToolbarOffset />
|
<ToolbarOffset />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -1,23 +1,29 @@
|
||||||
|
import { PageData } from "../types";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
|
import { ToolbarOffset } from "./ToolbarOffset";
|
||||||
|
import UserSidebarContent from "./UserSidebarContent";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
useMediaQuery,
|
||||||
|
} from "@mui/material";
|
||||||
import AppBar from "@mui/material/AppBar";
|
import AppBar from "@mui/material/AppBar";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import React, {FC, useState} from "react";
|
import { useTheme } from "@mui/material/styles";
|
||||||
import {useTheme} from "@mui/material/styles";
|
|
||||||
import {Drawer, IconButton, Menu, MenuItem, useMediaQuery} from "@mui/material";
|
|
||||||
import {ToolbarOffset} from "./ToolbarOffset";
|
|
||||||
import UserSidebarContent from "./UserSidebarContent";
|
|
||||||
import {ChevronLeft, ChevronRight, Menu as MenuIcon} from "react-feather";
|
|
||||||
import styled from "@mui/material/styles/styled";
|
import styled from "@mui/material/styles/styled";
|
||||||
import {Avatar} from "./Avatar";
|
import { get } from "lodash";
|
||||||
import {PageData} from "../types";
|
import React, { FC, useState } from "react";
|
||||||
import {get} from 'lodash';
|
import { ChevronLeft, ChevronRight, Menu as MenuIcon } from "react-feather";
|
||||||
|
|
||||||
const DrawerHeader = styled('div')(({ theme }) => ({
|
const DrawerHeader = styled("div")(({ theme }) => ({
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
padding: theme.spacing(0, 1),
|
padding: theme.spacing(0, 1),
|
||||||
justifyContent: 'flex-end',
|
justifyContent: "flex-end",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
|
@ -26,33 +32,34 @@ type HeaderProps = {
|
||||||
};
|
};
|
||||||
const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const mdUp = useMediaQuery(() => theme.breakpoints.up('md'), {
|
const mdUp = useMediaQuery(() => theme.breakpoints.up("md"), {
|
||||||
defaultMatches: true,
|
defaultMatches: true,
|
||||||
noSsr: false
|
noSsr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
const [anchorEl, setAnchorEl] = React.useState(null);
|
||||||
const handleMenuOpen = e => {
|
const handleMenuOpen = (e) => {
|
||||||
setAnchorEl(e.currentTarget);
|
setAnchorEl(e.currentTarget);
|
||||||
};
|
};
|
||||||
const handleMenuClose = () => {
|
const handleMenuClose = () => {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
const userName = get(data, 'user.name') || get(data, 'user.claims.given_name');
|
const userName =
|
||||||
|
get(data, "user.name") || get(data, "user.claims.given_name");
|
||||||
|
|
||||||
const handleDrawerOpen = () => {
|
const handleDrawerOpen = () => {
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrawerClose = ():void => {
|
const handleDrawerClose = (): void => {
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = (evt: React.MouseEvent):void => {
|
const handleLogout = (evt: React.MouseEvent): void => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
location.href = "/.pomerium/sign_out";
|
location.href = "/.pomerium/sign_out";
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar
|
<AppBar
|
||||||
|
@ -67,7 +74,7 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
||||||
aria-label="open drawer"
|
aria-label="open drawer"
|
||||||
onClick={handleDrawerOpen}
|
onClick={handleDrawerOpen}
|
||||||
edge="start"
|
edge="start"
|
||||||
sx={{ mr: 2, ...(drawerOpen && { display: 'none' }) }}
|
sx={{ mr: 2, ...(drawerOpen && { display: "none" }) }}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -75,11 +82,11 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
||||||
sx={{
|
sx={{
|
||||||
width: 256,
|
width: 256,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
'& .MuiDrawer-paper': {
|
"& .MuiDrawer-paper": {
|
||||||
width: 256,
|
width: 256,
|
||||||
boxSizing: 'border-box',
|
boxSizing: "border-box",
|
||||||
backgroundColor: 'neutral.900',
|
backgroundColor: "neutral.900",
|
||||||
height: '100vh',
|
height: "100vh",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
variant="persistent"
|
variant="persistent"
|
||||||
|
@ -88,10 +95,14 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
||||||
>
|
>
|
||||||
<DrawerHeader>
|
<DrawerHeader>
|
||||||
<IconButton onClick={handleDrawerClose}>
|
<IconButton onClick={handleDrawerClose}>
|
||||||
{theme.direction === 'ltr' ? <ChevronLeft /> : <ChevronRight />}
|
{theme.direction === "ltr" ? (
|
||||||
|
<ChevronLeft />
|
||||||
|
) : (
|
||||||
|
<ChevronRight />
|
||||||
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<UserSidebarContent close={handleDrawerClose}/>
|
<UserSidebarContent close={handleDrawerClose} />
|
||||||
<ToolbarOffset />
|
<ToolbarOffset />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
|
@ -104,13 +115,16 @@ const Header: FC<HeaderProps> = ({ includeSidebar, data }) => {
|
||||||
{userName && (
|
{userName && (
|
||||||
<>
|
<>
|
||||||
<IconButton color="inherit" onClick={handleMenuOpen}>
|
<IconButton color="inherit" onClick={handleMenuOpen}>
|
||||||
<Avatar name={userName} url={get(data, 'user.claims.picture', null)} />
|
<Avatar
|
||||||
|
name={userName}
|
||||||
|
url={get(data, "user.claims.picture", null)}
|
||||||
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Menu
|
<Menu
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
vertical: 'bottom',
|
vertical: "bottom",
|
||||||
horizontal: 'center'
|
horizontal: "center",
|
||||||
}}
|
}}
|
||||||
keepMounted
|
keepMounted
|
||||||
open={!!anchorEl}
|
open={!!anchorEl}
|
||||||
|
|
43
ui/src/components/SignOutConfirmPage.tsx
Normal file
43
ui/src/components/SignOutConfirmPage.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Container from "@mui/material/Container";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import styled from "@mui/material/styles/styled";
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import { SignOutConfirmPageData } from "src/types";
|
||||||
|
|
||||||
|
type SignOutConfirmPageProps = {
|
||||||
|
data: SignOutConfirmPageData;
|
||||||
|
};
|
||||||
|
const SignOutConfirmPage: FC<SignOutConfirmPageProps> = ({ data }) => {
|
||||||
|
function handleClickCancel(evt: React.MouseEvent) {
|
||||||
|
evt.preventDefault();
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickLogout(evt: React.MouseEvent) {
|
||||||
|
evt.preventDefault();
|
||||||
|
location.href = data.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Dialog open={true} disableEscapeKeyDown={true}>
|
||||||
|
<DialogTitle>Logout?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Are you sure you want to logout?
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClickCancel}>Cancel</Button>
|
||||||
|
<Button onClick={handleClickLogout}>Logout</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SignOutConfirmPage;
|
|
@ -81,7 +81,6 @@ export type WebAuthnRequestOptions = {
|
||||||
|
|
||||||
type BasePageData = {
|
type BasePageData = {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
signOutUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ErrorPageData = BasePageData & {
|
export type ErrorPageData = BasePageData & {
|
||||||
|
@ -100,6 +99,11 @@ export type DeviceEnrolledPageData = BasePageData & {
|
||||||
page: "DeviceEnrolled";
|
page: "DeviceEnrolled";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SignOutConfirmPageData = BasePageData & {
|
||||||
|
page: "SignOutConfirm";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserInfoPageData = BasePageData & {
|
export type UserInfoPageData = BasePageData & {
|
||||||
page: "UserInfo";
|
page: "UserInfo";
|
||||||
|
|
||||||
|
@ -123,5 +127,6 @@ export type WebAuthnRegistrationPageData = BasePageData & {
|
||||||
export type PageData =
|
export type PageData =
|
||||||
| ErrorPageData
|
| ErrorPageData
|
||||||
| DeviceEnrolledPageData
|
| DeviceEnrolledPageData
|
||||||
|
| SignOutConfirmPageData
|
||||||
| UserInfoPageData
|
| UserInfoPageData
|
||||||
| WebAuthnRegistrationPageData;
|
| WebAuthnRegistrationPageData;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue