mirror of
https://github.com/pomerium/pomerium.git
synced 2025-04-29 18:36:30 +02:00
If an authorization policy requires a client certificate, but an incoming request does not include a valid certificate, we should serve a deny error page right away, regardless of whether the user is authenticated via the identity provider or not. Do not redirect to the identity provider login page in this case. Update the existing integration tests accordingly, and add a unit test case for this scenario.
345 lines
11 KiB
Go
345 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 result.Deny.Reasons.Has(criteria.ReasonInvalidClientCertificate) {
|
|
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 reasons.Has(criteria.ReasonInvalidClientCertificate):
|
|
denyStatusCode = httputil.StatusInvalidClientCertificate
|
|
denyStatusText = httputil.DetailsText(httputil.StatusInvalidClientCertificate)
|
|
}
|
|
|
|
return a.deniedResponse(ctx, in, denyStatusCode, denyStatusText, nil)
|
|
}
|
|
|
|
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"
|
|
}
|