diff --git a/authenticate/config.go b/authenticate/config.go index b0ed926f8..e3eedbeaf 100644 --- a/authenticate/config.go +++ b/authenticate/config.go @@ -9,6 +9,7 @@ import ( type authenticateConfig struct { getIdentityProvider func(options *config.Options, idpID string) (identity.Authenticator, error) profileTrimFn func(*identitypb.Profile) + authEventFn AuthEventFn } // An Option customizes the Authenticate config. @@ -36,3 +37,10 @@ func WithProfileTrimFn(profileTrimFn func(*identitypb.Profile)) Option { cfg.profileTrimFn = profileTrimFn } } + +// WithOnAuthenticationEventHook sets the authEventFn function in the config +func WithOnAuthenticationEventHook(fn AuthEventFn) Option { + return func(cfg *authenticateConfig) { + cfg.authEventFn = fn + } +} diff --git a/authenticate/events.go b/authenticate/events.go new file mode 100644 index 000000000..cdb251ed1 --- /dev/null +++ b/authenticate/events.go @@ -0,0 +1,101 @@ +package authenticate + +import ( + "context" + "net/http" + "net/url" + + "github.com/pomerium/pomerium/internal/httputil" + "github.com/pomerium/pomerium/internal/log" + "github.com/pomerium/pomerium/internal/urlutil" + "github.com/pomerium/pomerium/pkg/grpc/identity" + "github.com/pomerium/pomerium/pkg/hpke" +) + +// AuthEventKind is the type of an authentication event +type AuthEventKind string + +const ( + // AuthEventSignInRequest is an authentication event for a sign in request before IdP redirect + AuthEventSignInRequest AuthEventKind = "sign_in_request" + // AuthEventSignInComplete is an authentication event for a sign in request after IdP redirect + AuthEventSignInComplete AuthEventKind = "sign_in_complete" +) + +// AuthEvent is a log event for an authentication event +type AuthEvent struct { + // Event is the type of authentication event + Event AuthEventKind + // IP is the IP address of the client + IP string + // Version is the version of the Pomerium client + Version string + // RequestUUID is the UUID of the request + RequestUUID string + // PubKey is the public key of the client + PubKey string + // UID is the IdP user ID of the user + UID *string + // Email is the email of the user + Email *string + // Domain is the domain of the request (for sign in complete events) + Domain *string +} + +// AuthEventFn is a function that handles an authentication event +type AuthEventFn func(context.Context, AuthEvent) + +func (a *Authenticate) logAuthenticateEvent(r *http.Request, profile *identity.Profile) { + if a.cfg.authEventFn == nil { + return + } + + state := a.state.Load() + ctx := r.Context() + pub, params, err := hpke.DecryptURLValues(state.hpkePrivateKey, r.Form) + if err != nil { + log.Warn(ctx).Err(err).Msg("log authenticate event: failed to decrypt request params") + } + + evt := AuthEvent{ + IP: httputil.GetClientIP(r), + Version: params.Get(urlutil.QueryVersion), + RequestUUID: params.Get(urlutil.QueryRequestUUID), + PubKey: pub.String(), + } + + if uid := getUserClaim(profile, "sub"); uid != nil { + evt.UID = uid + } + if email := getUserClaim(profile, "email"); email != nil { + evt.Email = email + } + + if evt.UID != nil { + evt.Event = AuthEventSignInComplete + } else { + evt.Event = AuthEventSignInRequest + } + + if redirectURL, err := url.Parse(params.Get(urlutil.QueryRedirectURI)); err == nil { + domain := redirectURL.Hostname() + evt.Domain = &domain + } + + a.cfg.authEventFn(ctx, evt) +} + +func getUserClaim(profile *identity.Profile, field string) *string { + if profile == nil { + return nil + } + if profile.Claims == nil { + return nil + } + val, ok := profile.Claims.Fields[field] + if !ok || val == nil { + return nil + } + txt := val.GetStringValue() + return &txt +} diff --git a/authenticate/handlers.go b/authenticate/handlers.go index 5ea9c5c07..9b0aba49c 100644 --- a/authenticate/handlers.go +++ b/authenticate/handlers.go @@ -216,6 +216,8 @@ func (a *Authenticate) SignIn(w http.ResponseWriter, r *http.Request) error { a.cfg.profileTrimFn(profile) } + a.logAuthenticateEvent(r, profile) + redirectTo, err := urlutil.CallbackURL(state.hpkePrivateKey, proxyPublicKey, requestParams, profile) if err != nil { return httputil.NewError(http.StatusInternalServerError, err) @@ -315,6 +317,8 @@ func (a *Authenticate) reauthenticateOrFail(w http.ResponseWriter, r *http.Reque return err } + a.logAuthenticateEvent(r, nil) + state.sessionStore.ClearSession(w, r) redirectURL := state.redirectURL.ResolveReference(r.URL) nonce := csrf.Token(r) diff --git a/internal/httputil/ip.go b/internal/httputil/ip.go new file mode 100644 index 000000000..9f93cf8e7 --- /dev/null +++ b/internal/httputil/ip.go @@ -0,0 +1,14 @@ +package httputil + +import ( + "net/http" + "strings" +) + +// GetClientIP returns the client IP address from the request. +func GetClientIP(r *http.Request) string { + if clientIP := r.Header.Get("X-Forwarded-For"); clientIP != "" { + return strings.Split(clientIP, ",")[0] + } + return strings.Split(r.RemoteAddr, ":")[0] +} diff --git a/internal/urlutil/known.go b/internal/urlutil/known.go index e7833e098..20d1cd03d 100644 --- a/internal/urlutil/known.go +++ b/internal/urlutil/known.go @@ -4,8 +4,12 @@ import ( "fmt" "net/http" "net/url" + "os" + "runtime" + "strings" "time" + "github.com/google/uuid" "google.golang.org/protobuf/encoding/protojson" "github.com/pomerium/pomerium/internal/version" @@ -21,6 +25,15 @@ const DefaultDeviceType = "any" const signInExpiry = time.Minute * 5 +var ( + pomeriumRuntime = os.Getenv("POMERIUM_RUNTIME") + pomeriumArch = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) +) + +func versionStr() string { + return strings.Join([]string{version.FullVersion(), pomeriumArch, pomeriumRuntime}, " ") +} + // CallbackURL builds the callback URL using an HPKE encrypted query string. func CallbackURL( authenticatePrivateKey *hpke.PrivateKey, @@ -59,7 +72,7 @@ func CallbackURL( return "", fmt.Errorf("error marshaling identity profile: %w", err) } callbackParams.Set(QueryIdentityProfile, string(rawProfile)) - callbackParams.Set(QueryVersion, version.FullVersion()) + callbackParams.Set(QueryVersion, versionStr()) BuildTimeParameters(callbackParams, signInExpiry) @@ -99,7 +112,8 @@ func SignInURL( q := signInURL.Query() q.Set(QueryRedirectURI, redirectURL.String()) q.Set(QueryIdentityProviderID, idpID) - q.Set(QueryVersion, version.FullVersion()) + q.Set(QueryVersion, versionStr()) + q.Set(QueryRequestUUID, uuid.NewString()) BuildTimeParameters(q, signInExpiry) q, err := hpke.EncryptURLValues(senderPrivateKey, authenticatePublicKey, q) if err != nil { @@ -119,7 +133,7 @@ func SignOutURL(r *http.Request, authenticateURL *url.URL, key []byte) string { if redirectURI, ok := RedirectURL(r); ok { q.Set(QueryRedirectURI, redirectURI) } - q.Set(QueryVersion, version.FullVersion()) + q.Set(QueryVersion, versionStr()) u.RawQuery = q.Encode() return NewSignedURL(key, u).Sign().String() } diff --git a/internal/urlutil/query_params.go b/internal/urlutil/query_params.go index 15a0c850a..ac5a127bc 100644 --- a/internal/urlutil/query_params.go +++ b/internal/urlutil/query_params.go @@ -19,6 +19,7 @@ const ( QuerySessionEncrypted = "pomerium_session_encrypted" QuerySessionState = "pomerium_session_state" QueryVersion = "pomerium_version" + QueryRequestUUID = "pomerium_request_uuid" ) // URL signature based query params used for verifying the authenticity of a URL.