mirror of
https://github.com/pomerium/pomerium.git
synced 2025-04-29 18:36:30 +02:00
Add a new reason "client-certificate-required" that will be returned by the invalid_client_certificate criterion in the case that no client certificate was provided. Determine this using the new 'presented' field populated from the Envoy metadata.
350 lines
11 KiB
Go
350 lines
11 KiB
Go
package authorize
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
|
|
envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
|
|
envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
|
|
"github.com/tniswong/go.rfcx/rfc7231"
|
|
"google.golang.org/genproto/googleapis/rpc/status"
|
|
"google.golang.org/grpc/codes"
|
|
|
|
"github.com/pomerium/pomerium/authorize/evaluator"
|
|
"github.com/pomerium/pomerium/internal/httputil"
|
|
"github.com/pomerium/pomerium/internal/log"
|
|
"github.com/pomerium/pomerium/internal/telemetry/requestid"
|
|
"github.com/pomerium/pomerium/internal/urlutil"
|
|
"github.com/pomerium/pomerium/pkg/policy/criteria"
|
|
"github.com/pomerium/pomerium/pkg/webauthnutil"
|
|
)
|
|
|
|
func (a *Authorize) handleResult(
|
|
ctx context.Context,
|
|
in *envoy_service_auth_v3.CheckRequest,
|
|
request *evaluator.Request,
|
|
result *evaluator.Result,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
// If a client certificate is required, but the client did not provide a
|
|
// valid certificate, deny right away. Do not redirect to authenticate.
|
|
if invalidClientCertReason(result.Deny.Reasons) {
|
|
return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
|
|
}
|
|
|
|
// when the user is unauthenticated it means they haven't
|
|
// logged in yet, so redirect to authenticate
|
|
if result.Allow.Reasons.Has(criteria.ReasonUserUnauthenticated) ||
|
|
result.Deny.Reasons.Has(criteria.ReasonUserUnauthenticated) {
|
|
return a.requireLoginResponse(ctx, in, request)
|
|
}
|
|
|
|
// when the user's device is unauthenticated it means they haven't
|
|
// registered a webauthn device yet, so redirect to the webauthn flow
|
|
if result.Allow.Reasons.Has(criteria.ReasonDeviceUnauthenticated) ||
|
|
result.Deny.Reasons.Has(criteria.ReasonDeviceUnauthenticated) {
|
|
return a.requireWebAuthnResponse(ctx, in, request, result)
|
|
}
|
|
|
|
// if there's a deny, the result is denied using the deny reasons.
|
|
if result.Deny.Value {
|
|
return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
|
|
}
|
|
|
|
// if there's an allow, the result is allowed.
|
|
if result.Allow.Value {
|
|
return a.handleResultAllowed(ctx, in, result)
|
|
}
|
|
|
|
// otherwise, the result is denied using the allow reasons.
|
|
return a.handleResultDenied(ctx, in, request, result, result.Allow.Reasons)
|
|
}
|
|
|
|
func (a *Authorize) handleResultAllowed(
|
|
_ context.Context,
|
|
_ *envoy_service_auth_v3.CheckRequest,
|
|
result *evaluator.Result,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
return a.okResponse(result.Headers), nil
|
|
}
|
|
|
|
func (a *Authorize) handleResultDenied(
|
|
ctx context.Context,
|
|
in *envoy_service_auth_v3.CheckRequest,
|
|
request *evaluator.Request,
|
|
result *evaluator.Result,
|
|
reasons criteria.Reasons,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
denyStatusCode := int32(http.StatusForbidden)
|
|
denyStatusText := http.StatusText(http.StatusForbidden)
|
|
|
|
switch {
|
|
case reasons.Has(criteria.ReasonDeviceUnauthenticated):
|
|
return a.requireWebAuthnResponse(ctx, in, request, result)
|
|
case reasons.Has(criteria.ReasonDeviceUnauthorized):
|
|
denyStatusCode = httputil.StatusDeviceUnauthorized
|
|
denyStatusText = httputil.DetailsText(httputil.StatusDeviceUnauthorized)
|
|
case reasons.Has(criteria.ReasonRouteNotFound):
|
|
denyStatusCode = http.StatusNotFound
|
|
denyStatusText = httputil.DetailsText(http.StatusNotFound)
|
|
case invalidClientCertReason(reasons):
|
|
denyStatusCode = httputil.StatusInvalidClientCertificate
|
|
denyStatusText = httputil.DetailsText(httputil.StatusInvalidClientCertificate)
|
|
}
|
|
|
|
return a.deniedResponse(ctx, in, denyStatusCode, denyStatusText, nil)
|
|
}
|
|
|
|
func invalidClientCertReason(reasons criteria.Reasons) bool {
|
|
return reasons.Has(criteria.ReasonClientCertificateRequired) ||
|
|
reasons.Has(criteria.ReasonInvalidClientCertificate)
|
|
}
|
|
|
|
func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.CheckResponse {
|
|
var requestHeaders []*envoy_config_core_v3.HeaderValueOption
|
|
for k, vs := range headers {
|
|
requestHeaders = append(requestHeaders, mkHeader(k, strings.Join(vs, ",")))
|
|
}
|
|
// ensure request headers are sorted by key for deterministic output
|
|
sort.Slice(requestHeaders, func(i, j int) bool {
|
|
return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value
|
|
})
|
|
return &envoy_service_auth_v3.CheckResponse{
|
|
Status: &status.Status{Code: int32(codes.OK), Message: "OK"},
|
|
HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{
|
|
OkResponse: &envoy_service_auth_v3.OkHttpResponse{
|
|
Headers: requestHeaders,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (a *Authorize) deniedResponse(
|
|
ctx context.Context,
|
|
in *envoy_service_auth_v3.CheckRequest,
|
|
code int32, reason string, headers map[string]string,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
respHeader := []*envoy_config_core_v3.HeaderValueOption{}
|
|
|
|
// create a http response writer recorder
|
|
w := httptest.NewRecorder()
|
|
r := getHTTPRequestFromCheckRequest(in)
|
|
|
|
// build the user info / debug endpoint
|
|
debugEndpoint, _ := a.userInfoEndpointURL(in) // if there's an error, we just wont display it
|
|
|
|
// run the request through our go error handler
|
|
httpErr := httputil.HTTPError{
|
|
Status: int(code),
|
|
Err: errors.New(reason),
|
|
DebugURL: debugEndpoint,
|
|
RequestID: requestid.FromContext(ctx),
|
|
BrandingOptions: a.currentOptions.Load().BrandingOptions,
|
|
}
|
|
httpErr.ErrorResponse(ctx, w, r)
|
|
|
|
// transpose the go http response writer into a envoy response
|
|
resp := w.Result()
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Error(ctx).Err(err).Msg("error executing error template")
|
|
return nil, err
|
|
}
|
|
// convert go headers to envoy headers
|
|
respHeader = append(respHeader, toEnvoyHeaders(resp.Header)...)
|
|
|
|
// add any additional headers
|
|
for k, v := range headers {
|
|
respHeader = append(respHeader, mkHeader(k, v))
|
|
}
|
|
|
|
return &envoy_service_auth_v3.CheckResponse{
|
|
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
|
|
HttpResponse: &envoy_service_auth_v3.CheckResponse_DeniedResponse{
|
|
DeniedResponse: &envoy_service_auth_v3.DeniedHttpResponse{
|
|
Status: &envoy_type_v3.HttpStatus{
|
|
Code: envoy_type_v3.StatusCode(code),
|
|
},
|
|
Headers: respHeader,
|
|
Body: string(respBody),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (a *Authorize) requireLoginResponse(
|
|
ctx context.Context,
|
|
in *envoy_service_auth_v3.CheckRequest,
|
|
request *evaluator.Request,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
options := a.currentOptions.Load()
|
|
state := a.state.Load()
|
|
|
|
if !a.shouldRedirect(in) {
|
|
return a.deniedResponse(ctx, in, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), nil)
|
|
}
|
|
|
|
authenticateURL, err := options.GetAuthenticateURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
idp, err := options.GetIdentityProviderForPolicy(request.Policy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
authenticateHPKEPublicKey, err := state.authenticateKeyFetcher.FetchPublicKey(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// always assume https scheme
|
|
checkRequestURL := getCheckRequestURL(in)
|
|
checkRequestURL.Scheme = "https"
|
|
|
|
redirectTo, err := urlutil.SignInURL(
|
|
state.hpkePrivateKey,
|
|
authenticateHPKEPublicKey,
|
|
authenticateURL,
|
|
&checkRequestURL,
|
|
idp.GetId(),
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{
|
|
"Location": redirectTo,
|
|
})
|
|
}
|
|
|
|
func (a *Authorize) requireWebAuthnResponse(
|
|
ctx context.Context,
|
|
in *envoy_service_auth_v3.CheckRequest,
|
|
request *evaluator.Request,
|
|
result *evaluator.Result,
|
|
) (*envoy_service_auth_v3.CheckResponse, error) {
|
|
opts := a.currentOptions.Load()
|
|
state := a.state.Load()
|
|
|
|
// always assume https scheme
|
|
checkRequestURL := getCheckRequestURL(in)
|
|
checkRequestURL.Scheme = "https"
|
|
|
|
// If we're already on a webauthn route, return OK.
|
|
// https://github.com/pomerium/pomerium-console/issues/3210
|
|
if checkRequestURL.Path == urlutil.WebAuthnURLPath || checkRequestURL.Path == urlutil.DeviceEnrolledPath {
|
|
return a.okResponse(result.Headers), nil
|
|
}
|
|
|
|
if !a.shouldRedirect(in) {
|
|
return a.deniedResponse(ctx, in, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), nil)
|
|
}
|
|
|
|
q := url.Values{}
|
|
if deviceType, ok := result.Allow.AdditionalData["device_type"].(string); ok {
|
|
q.Set(urlutil.QueryDeviceType, deviceType)
|
|
} else if deviceType, ok := result.Deny.AdditionalData["device_type"].(string); ok {
|
|
q.Set(urlutil.QueryDeviceType, deviceType)
|
|
} else {
|
|
q.Set(urlutil.QueryDeviceType, webauthnutil.DefaultDeviceType)
|
|
}
|
|
q.Set(urlutil.QueryRedirectURI, checkRequestURL.String())
|
|
idp, err := opts.GetIdentityProviderForPolicy(request.Policy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q.Set(urlutil.QueryIdentityProviderID, idp.GetId())
|
|
signinURL := urlutil.WebAuthnURL(getHTTPRequestFromCheckRequest(in), &checkRequestURL, state.sharedKey, q)
|
|
return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{
|
|
"Location": signinURL,
|
|
})
|
|
}
|
|
|
|
func mkHeader(k, v string) *envoy_config_core_v3.HeaderValueOption {
|
|
return &envoy_config_core_v3.HeaderValueOption{
|
|
Header: &envoy_config_core_v3.HeaderValue{
|
|
Key: k,
|
|
Value: v,
|
|
},
|
|
AppendAction: envoy_config_core_v3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD,
|
|
}
|
|
}
|
|
|
|
func toEnvoyHeaders(headers http.Header) []*envoy_config_core_v3.HeaderValueOption {
|
|
var ks []string
|
|
for k := range headers {
|
|
ks = append(ks, k)
|
|
}
|
|
sort.Strings(ks)
|
|
|
|
envoyHeaders := make([]*envoy_config_core_v3.HeaderValueOption, 0, len(headers))
|
|
for _, k := range ks {
|
|
envoyHeaders = append(envoyHeaders, mkHeader(k, headers.Get(k)))
|
|
}
|
|
return envoyHeaders
|
|
}
|
|
|
|
// userInfoEndpointURL returns the user info endpoint url which can be used to debug the user's
|
|
// session that lives on the authenticate service.
|
|
func (a *Authorize) userInfoEndpointURL(in *envoy_service_auth_v3.CheckRequest) (*url.URL, error) {
|
|
opts := a.currentOptions.Load()
|
|
authenticateURL, err := opts.GetAuthenticateURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
debugEndpoint := authenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/"})
|
|
|
|
r := getHTTPRequestFromCheckRequest(in)
|
|
redirectURL := urlutil.GetAbsoluteURL(r).String()
|
|
if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" {
|
|
redirectURL = ref
|
|
}
|
|
|
|
debugEndpoint = debugEndpoint.ResolveReference(&url.URL{
|
|
RawQuery: url.Values{
|
|
urlutil.QueryRedirectURI: {redirectURL},
|
|
}.Encode(),
|
|
})
|
|
|
|
return urlutil.NewSignedURL(a.state.Load().sharedKey, debugEndpoint).Sign(), nil
|
|
}
|
|
|
|
func (a *Authorize) shouldRedirect(in *envoy_service_auth_v3.CheckRequest) bool {
|
|
requestHeaders := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
|
|
if requestHeaders == nil {
|
|
return true
|
|
}
|
|
|
|
if strings.HasPrefix(requestHeaders["content-type"], "application/grpc") {
|
|
return false
|
|
}
|
|
|
|
accept, err := rfc7231.ParseAccept(requestHeaders["accept"])
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
mediaType, ok := accept.MostAcceptable([]string{
|
|
"text/html",
|
|
"application/json",
|
|
"text/plain",
|
|
"application/grpc-web-text",
|
|
"application/grpc-web+proto",
|
|
"application/grpc+proto",
|
|
})
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
return mediaType == "text/html"
|
|
}
|