mirror of
https://github.com/pomerium/pomerium.git
synced 2025-06-03 03:12:50 +02:00
authorize: add client mTLS support (#751)
* authorize: add client mtls support * authorize: better error messages for envoy * switch from function to input * add TrustedCa to envoy config so that users are prompted for the correct client certificate * update documentation * fix invalid ClientCAFile * regenerate cache protobuf * avoid recursion, add test * move comment line * use http.StatusOK * various fixes
This commit is contained in:
parent
3f1faf2e9e
commit
e4832cb4ed
24 changed files with 995 additions and 279 deletions
|
@ -2,10 +2,14 @@
|
|||
// if a given request should be authorized (AuthZ).
|
||||
package authorize
|
||||
|
||||
//go:generate ../scripts/protoc -I ../internal/grpc/authorize/ --go_out=plugins=grpc:../internal/grpc/authorize/ ../internal/grpc/authorize/authorize.proto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/pomerium/pomerium/authorize/evaluator"
|
||||
|
@ -14,6 +18,7 @@ import (
|
|||
"github.com/pomerium/pomerium/internal/cryptutil"
|
||||
"github.com/pomerium/pomerium/internal/encoding"
|
||||
"github.com/pomerium/pomerium/internal/encoding/jws"
|
||||
"github.com/pomerium/pomerium/internal/frontend"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
"github.com/pomerium/pomerium/internal/telemetry/metrics"
|
||||
"github.com/pomerium/pomerium/internal/telemetry/trace"
|
||||
|
@ -51,6 +56,7 @@ type Authorize struct {
|
|||
|
||||
currentOptions atomicOptions
|
||||
currentEncoder atomicMarshalUnmarshaler
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// New validates and creates a new Authorize service from a set of config options.
|
||||
|
@ -58,7 +64,9 @@ func New(opts config.Options) (*Authorize, error) {
|
|||
if err := validateOptions(opts); err != nil {
|
||||
return nil, fmt.Errorf("authorize: bad options: %w", err)
|
||||
}
|
||||
var a Authorize
|
||||
a := Authorize{
|
||||
templates: template.Must(frontend.NewTemplates()),
|
||||
}
|
||||
|
||||
var host string
|
||||
if opts.AuthenticateURL != nil {
|
||||
|
@ -117,12 +125,28 @@ func newPolicyEvaluator(opts *config.Options) (evaluator.Evaluator, error) {
|
|||
jwk.Key = keyBytes
|
||||
}
|
||||
|
||||
var clientCA string
|
||||
if opts.ClientCA != "" {
|
||||
bs, err := base64.StdEncoding.DecodeString(opts.ClientCA)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorize: invalid client ca: %w", err)
|
||||
}
|
||||
clientCA = string(bs)
|
||||
} else if opts.ClientCAFile != "" {
|
||||
bs, err := ioutil.ReadFile(opts.ClientCAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorize: invalid client ca file: %w", err)
|
||||
}
|
||||
clientCA = string(bs)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"shared_key": opts.SharedKey,
|
||||
"route_policies": opts.Policies,
|
||||
"admins": opts.Administrators,
|
||||
"signing_key": jwk,
|
||||
"authenticate_url": opts.AuthenticateURLString,
|
||||
"client_ca": clientCA,
|
||||
}
|
||||
|
||||
return opa.New(ctx, &opa.Options{Data: data})
|
||||
|
|
106
authorize/errors.go
Normal file
106
authorize/errors.go
Normal file
|
@ -0,0 +1,106 @@
|
|||
package authorize
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
|
||||
envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2"
|
||||
envoy_type "github.com/envoyproxy/go-control-plane/envoy/type"
|
||||
"google.golang.org/genproto/googleapis/rpc/status"
|
||||
"google.golang.org/grpc/codes"
|
||||
|
||||
"github.com/pomerium/pomerium/internal/httputil"
|
||||
"github.com/pomerium/pomerium/internal/log"
|
||||
)
|
||||
|
||||
func (a *Authorize) deniedResponse(in *envoy_service_auth_v2.CheckRequest,
|
||||
code int32, reason string, headers map[string]string) *envoy_service_auth_v2.CheckResponse {
|
||||
|
||||
returnHTMLError := true
|
||||
inHeaders := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
|
||||
if inHeaders != nil {
|
||||
returnHTMLError = strings.Contains(inHeaders["accept"], "text/html")
|
||||
}
|
||||
|
||||
if returnHTMLError {
|
||||
return a.htmlDeniedResponse(code, reason, headers)
|
||||
}
|
||||
return a.plainTextDeniedResponse(code, reason, headers)
|
||||
}
|
||||
|
||||
func (a *Authorize) htmlDeniedResponse(code int32, reason string, headers map[string]string) *envoy_service_auth_v2.CheckResponse {
|
||||
var details string
|
||||
switch code {
|
||||
case httputil.StatusInvalidClientCertificate:
|
||||
details = "a valid client certificate is required to access this page"
|
||||
case http.StatusForbidden:
|
||||
details = "access to this page is forbidden"
|
||||
default:
|
||||
details = reason
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := a.templates.ExecuteTemplate(&buf, "error.html", map[string]interface{}{
|
||||
"Status": code,
|
||||
"StatusText": reason,
|
||||
"CanDebug": code/100 == 4,
|
||||
"Error": details,
|
||||
})
|
||||
if err != nil {
|
||||
buf.WriteString(reason)
|
||||
log.Error().Err(err).Msg("error executing error template")
|
||||
}
|
||||
|
||||
envoyHeaders := []*envoy_api_v2_core.HeaderValueOption{
|
||||
mkHeader("Content-Type", "text/html"),
|
||||
}
|
||||
for k, v := range headers {
|
||||
envoyHeaders = append(envoyHeaders, mkHeader(k, v))
|
||||
}
|
||||
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
|
||||
HttpResponse: &envoy_service_auth_v2.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &envoy_service_auth_v2.DeniedHttpResponse{
|
||||
Status: &envoy_type.HttpStatus{
|
||||
Code: envoy_type.StatusCode(code),
|
||||
},
|
||||
Headers: envoyHeaders,
|
||||
Body: buf.String(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Authorize) plainTextDeniedResponse(code int32, reason string, headers map[string]string) *envoy_service_auth_v2.CheckResponse {
|
||||
envoyHeaders := []*envoy_api_v2_core.HeaderValueOption{
|
||||
mkHeader("Content-Type", "text/plain"),
|
||||
}
|
||||
for k, v := range headers {
|
||||
envoyHeaders = append(envoyHeaders, mkHeader(k, v))
|
||||
}
|
||||
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
|
||||
HttpResponse: &envoy_service_auth_v2.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &envoy_service_auth_v2.DeniedHttpResponse{
|
||||
Status: &envoy_type.HttpStatus{
|
||||
Code: envoy_type.StatusCode(code),
|
||||
},
|
||||
Headers: envoyHeaders,
|
||||
Body: reason,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mkHeader(k, v string) *envoy_api_v2_core.HeaderValueOption {
|
||||
return &envoy_api_v2_core.HeaderValueOption{
|
||||
Header: &envoy_api_v2_core.HeaderValue{
|
||||
Key: k,
|
||||
Value: v,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import (
|
|||
|
||||
// Evaluator specifies the interface for a policy engine.
|
||||
type Evaluator interface {
|
||||
IsAuthorized(ctx context.Context, input interface{}) (*pb.IsAuthorizedReply, error)
|
||||
IsAuthorized(ctx context.Context, req *Request) (*pb.IsAuthorizedReply, error)
|
||||
PutData(ctx context.Context, data map[string]interface{}) error
|
||||
}
|
||||
|
||||
|
@ -44,6 +44,11 @@ type Request struct {
|
|||
// It is an error to set this field in an HTTP client request.
|
||||
RequestURI string `json:"request_uri,omitempty"`
|
||||
|
||||
// Connection context
|
||||
//
|
||||
// ClientCertificate is the PEM-encoded public certificate used for the user's TLS connection.
|
||||
ClientCertificate string `json:"client_certificate"`
|
||||
|
||||
// Device context
|
||||
//
|
||||
// todo(bdd): Use the peer TLS certificate to bind device state with a request
|
||||
|
|
58
authorize/evaluator/opa/functions.go
Normal file
58
authorize/evaluator/opa/functions.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package opa
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
)
|
||||
|
||||
var isValidClientCertificateCache, _ = lru.New2Q(100)
|
||||
|
||||
func isValidClientCertificate(ca, cert string) (bool, error) {
|
||||
// when ca is the empty string, client certificates are always accepted
|
||||
if ca == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// when cert is the empty string, no client certificate was supplied
|
||||
if cert == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cacheKey := [2]string{ca, cert}
|
||||
|
||||
value, ok := isValidClientCertificateCache.Get(cacheKey)
|
||||
if ok {
|
||||
return value.(bool), nil
|
||||
}
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM([]byte(ca))
|
||||
|
||||
xcert, err := parseCertificate(cert)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, verifyErr := xcert.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
})
|
||||
valid := verifyErr == nil
|
||||
|
||||
isValidClientCertificateCache.Add(cacheKey, valid)
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
func parseCertificate(pemStr string) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid certificate")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("unknown PEM type: %s", block.Type)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
122
authorize/evaluator/opa/functions_test.go
Normal file
122
authorize/evaluator/opa/functions_test.go
Normal file
|
@ -0,0 +1,122 @@
|
|||
package opa
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
testCA = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEtjCCAx6gAwIBAgIRAJFkXxMjoQzoojykk6CiiGkwDQYJKoZIhvcNAQELBQAw
|
||||
czEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSQwIgYDVQQLDBtjYWxl
|
||||
YkBwb3Atb3MgKENhbGViIERveHNleSkxKzApBgNVBAMMIm1rY2VydCBjYWxlYkBw
|
||||
b3Atb3MgKENhbGViIERveHNleSkwHhcNMjAwNDI0MTY1MzEwWhcNMzAwNDI0MTY1
|
||||
MzEwWjBzMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExJDAiBgNVBAsM
|
||||
G2NhbGViQHBvcC1vcyAoQ2FsZWIgRG94c2V5KTErMCkGA1UEAwwibWtjZXJ0IGNh
|
||||
bGViQHBvcC1vcyAoQ2FsZWIgRG94c2V5KTCCAaIwDQYJKoZIhvcNAQEBBQADggGP
|
||||
ADCCAYoCggGBAL2QSyQGjaGD97K7HSExJfMcuyEoh+ewAkPZ/HZR4n12zwAn1sLK
|
||||
RqusKSfMe8qG6KgsojXrJ9AXEkD7x3bmK5j/4M/lwlNGulg+k5MSu3leoLpOZwfX
|
||||
JQTu+HDzWubu5cjy7taHyeZc35VbOBWEaDJgVxmJvE9TJIOr8POZ7DD/rlkbgQas
|
||||
s6G/8cg2mRX0Rh3O20/1bvi9Uen/kraBgGMOyG5MfuiiTl3KsrGST848Q+jiSbu3
|
||||
5F5MAzdO4tlR6kqEZk/Igog6OPkTb82vMli/R+mR37JYncQcj0WNYS4PkfjofVpb
|
||||
FwrHtfdkVYJ9T2yNvQnJVu6MF9fhj9FqWQbsdbYKlUDow5KwI+BxmCAmGwgzmCOy
|
||||
ONkglj76fPKFkoF4s+DSFocbAwhdazaViAcCB+x6yohOUjgG7H9NJo0MasPHuqUO
|
||||
8d56Bf0BTXfNX6nOgYYisrOoEATCbs729vHMaQ/7pG2zf9dnEuw95gZTSr9Rv3dx
|
||||
2NjmM6+tNOMCzwIDAQABo0UwQzAOBgNVHQ8BAf8EBAMCAgQwEgYDVR0TAQH/BAgw
|
||||
BgEB/wIBADAdBgNVHQ4EFgQUShofXNkcXh2q4wnnWZ2bco24XEQwDQYJKoZIhvcN
|
||||
AQELBQADggGBAJQzfmr84xtvoUgnq8T4ND0Q166dlnbDnASRFhbmyZcxzvDJsPs4
|
||||
N45HbIg0xOsXOaBaE+jTSV4GmZ/vtyP8WbQrhabL2zpnjnLF1d9B0qx/RlIzoDEa
|
||||
e/0zc0R6RAd64/aE/jHNDhfTNXD/NmnI25RqgnsZXXXRVMTl+PzQ1A8XQghZVWHN
|
||||
vbyFFd3GE5Qs+vxMzwKCqp6f3MI8KyI2aM4hZZ+zULdEuSw0hWzMOkeZY6LC0flW
|
||||
/rpkT+GLA3uZ357iehSISLqnkIozw92ldov5oZDthoy3i1I6gIDkngk7BGKr42pD
|
||||
L2sWi1MEEIhymy4K1DnRkGre3mqzus2y/nE4ruuJlctq6QXcCSnko717vukVtoE8
|
||||
o5SkW4usivU8yZeBLt56sySRyCpe/T1XAFTQZ5Q4S5ssGmNpOLS9Aa5iOUz9/62S
|
||||
uvjFyvOEE3yqd/d3py8qm6olcjaMooVA8j5G+QF/UiH951azGIez6/Ui1lg1m0T6
|
||||
+YLkPqNIt0o9dQ==
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
testValidCert = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIESDCCArCgAwIBAgIQG/h9GflpINqLLv4Tde9+STANBgkqhkiG9w0BAQsFADBz
|
||||
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExJDAiBgNVBAsMG2NhbGVi
|
||||
QHBvcC1vcyAoQ2FsZWIgRG94c2V5KTErMCkGA1UEAwwibWtjZXJ0IGNhbGViQHBv
|
||||
cC1vcyAoQ2FsZWIgRG94c2V5KTAeFw0xOTA2MDEwMDAwMDBaFw0zMDA1MjAyMDM4
|
||||
NDRaME8xJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEk
|
||||
MCIGA1UECwwbY2FsZWJAcG9wLW9zIChDYWxlYiBEb3hzZXkpMIIBIjANBgkqhkiG
|
||||
9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5ouz2dlXHALdxiLcLwAvxg02CN/Jdcrmyyzm
|
||||
bzKHqIpknotZSlbPgE/mp5wMwIoyMqFIEm3IzXFEf3cjFYYG4b6wp4zlFrx7jCOa
|
||||
vhEHpH3yM71xt1I/BME6VrmX7sRKO90dwpTxCOadx9aGEn1AlHuPfhMMm/WTLynD
|
||||
d5hbsHKp7eZMYHvQnferTelq5NnBySBP/HaAtF76qTSQzHev5K/cgioDZAaM0dnP
|
||||
bicl0Zay+f5INrDr9XtQo/FHwGI/YLMW5TWXYmHjYmdD8s4Tg/KUoRMgJp4mlkkF
|
||||
9t1pwArbNFU/4wQWPbpWBLh1gcnQxojSZ3a6aI+V+REDzV/PVQIDAQABo3wwejAO
|
||||
BgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMAwG
|
||||
A1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUShofXNkcXh2q4wnnWZ2bco24XEQwGgYD
|
||||
VR0RBBMwEYIPZXhhbXBsZS1zdWJqZWN0MA0GCSqGSIb3DQEBCwUAA4IBgQC78S2n
|
||||
6jcKfWbm24g/U5tkWiBVnBk1jphH7Ct69Lw2JNstGLtNs4AiE9lKmXDQ82MiAFYg
|
||||
gaeiHRhTebkOTF9Kx3Jwq7bwhhzONqPp5a0SkY4EWjZ7c5k/fZc8DkrRE71hOgMf
|
||||
rFbRBZCywBVtGbXIA1uMsCijTe4sQF1ZA918NmfjhpIhRHljQJM16RJ753s+0CZ8
|
||||
WomOW4JrtjJefRuV97PRADvRNQbtZYelnoTfbp1afGhbQpKjyylCDGlpJS4mGrSA
|
||||
lPaRVhEB+wI8gA3lzpa6adXsc1yueZ19++dxQNYxAawCMQNjjxy3aLWzy8aPWxxq
|
||||
Qo/Q9rqjre3SpJfARLOV9ezQNbqsXvJW+5DcoG5dx8s6jAhMusNjUHpf6oVgnv65
|
||||
3Bvl124bZyf9q4lW9g8pvZkrgQ3Fx2IahqhXhyF5zrqf2r9+1l0fXocIUP2GQ+Fr
|
||||
b9j9bWWhov5aidEjPwpFeTmzcGqCWQBEA4H+yo/4YaIN0sOfE2yaAmc3gcU=
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
testUnsignedCert = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIESTCCArGgAwIBAgIRAIE9860UHBIVofXB5cu/aWAwDQYJKoZIhvcNAQELBQAw
|
||||
czEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSQwIgYDVQQLDBtjYWxl
|
||||
YkBwb3Atb3MgKENhbGViIERveHNleSkxKzApBgNVBAMMIm1rY2VydCBjYWxlYkBw
|
||||
b3Atb3MgKENhbGViIERveHNleSkwHhcNMTkwNjAxMDAwMDAwWhcNMzAwNTIwMjIw
|
||||
NDAxWjBPMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUx
|
||||
JDAiBgNVBAsMG2NhbGViQHBvcC1vcyAoQ2FsZWIgRG94c2V5KTCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKPgWHAJ58p7ZZ6MHA6QHA9rQQWKSvYbN9zz
|
||||
fCCURqHFbQHCCJs2D39XPioo9EMZcD6J7ldwEOJsdSNw3+dzBCvIl7wP6fqtbo/3
|
||||
SNgRaLAB+Mb4S8oek6P6zHkjuOXzodhCZjLO7oxY9pjGREy6hC/SjylJFgw9mKEG
|
||||
SYmsyCqeP5BfW9DghRgd5uJe0HtwlBZLPS91Mk5whn7YOxnWslS/REwZdd12s3DI
|
||||
WQdmvGhMakIAiMKmx+LX9qS3Ua2gUArHnSFXcOAg9iK+MM68T1KsQTCYnRZVK4v5
|
||||
Na4qEjiPhmkzzEExZa787ClL6UXfoXB+jXy2sXu0CDD4tv2D7R8CAwEAAaN8MHow
|
||||
DgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAM
|
||||
BgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFH8wenPOF2tE2EIksItmlkWfgEMkMBoG
|
||||
A1UdEQQTMBGCD2ludmFsaWQtc3ViamVjdDANBgkqhkiG9w0BAQsFAAOCAYEAJCdl
|
||||
c6J/x/UY6vEDzplwR8iZ5s7dyKKF7bwNdjEvBREgkTY6GmwDC9HOmWWPs7vENqEX
|
||||
jUwHEK+v7A7AUIS4WeJrJgogzEDPI7ZlVtzQNviqMavzk/I1Us00WYtMQQFb1Sgz
|
||||
xIRskug5wH6vPcR4XbCftx6NP9UFG8pJLPTJ67ZUaTP23ccsToMM/Dd17LFrtleE
|
||||
9xAvdqA54vcBiJ99uts+xWlQznjIgdauNC6sOmL3JAflyj6aBy+Dcos9R35ERIXz
|
||||
3rRl25yXjtidPDo8YxmtHs+Ijw4R3iJ44NCcc/+LfACYUcua0cBF2Ixk2JrFYx8n
|
||||
wwRJukrHXI+RFBmSOlUripyyJH92H5vXvj8lO5wM8wVVVe8anr5TOvxFOAjNC5a3
|
||||
vJByvJQTUEkx8rT7zZi8eSQJHP3Eoqr9g4ajqIU22yrCxiiQXpZLJ4JFQQEgyD9A
|
||||
Y+E5W+FKfIBv9yvdNBYZsL6IZ0Yh1ctKwB5gnajO8+swx5BeaCIbBrCtOBSB
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
)
|
||||
|
||||
func Test_isValidClientCertificate(t *testing.T) {
|
||||
t.Run("no ca", func(t *testing.T) {
|
||||
valid, err := isValidClientCertificate("", "WHATEVER!")
|
||||
assert.NoError(t, err, "should not return an error")
|
||||
assert.True(t, valid, "should return true")
|
||||
})
|
||||
t.Run("no cert", func(t *testing.T) {
|
||||
valid, err := isValidClientCertificate(testCA, "")
|
||||
assert.NoError(t, err, "should not return an error")
|
||||
assert.False(t, valid, "should return false")
|
||||
})
|
||||
t.Run("valid cert", func(t *testing.T) {
|
||||
valid, err := isValidClientCertificate(testCA, testValidCert)
|
||||
assert.NoError(t, err, "should not return an error")
|
||||
assert.True(t, valid, "should return true")
|
||||
})
|
||||
t.Run("unsigned cert", func(t *testing.T) {
|
||||
valid, err := isValidClientCertificate(testCA, testUnsignedCert)
|
||||
assert.NoError(t, err, "should not return an error")
|
||||
assert.False(t, valid, "should return false")
|
||||
})
|
||||
t.Run("not a cert", func(t *testing.T) {
|
||||
valid, err := isValidClientCertificate(testCA, "WHATEVER!")
|
||||
assert.Error(t, err, "should return an error")
|
||||
assert.False(t, valid, "should return false")
|
||||
})
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
//go:generate statik -src=./policy -include=*.rego -ns rego -p policy
|
||||
//go:generate go run github.com/rakyll/statik -src=./policy -include=*.rego -ns rego -p policy
|
||||
//go:generate go fmt ./policy/statik.go
|
||||
|
||||
// Package opa implements the policy evaluator interface to make authorization
|
||||
// decisions.
|
||||
|
@ -6,9 +7,11 @@ package opa
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
|
@ -37,6 +40,7 @@ type PolicyEvaluator struct {
|
|||
mu sync.RWMutex
|
||||
store storage.Store
|
||||
isAuthorized rego.PreparedEvalQuery
|
||||
clientCA string
|
||||
}
|
||||
|
||||
// Options represent OPA's evaluator configurations.
|
||||
|
@ -96,10 +100,10 @@ func (pe *PolicyEvaluator) UpdatePolicy(ctx context.Context, authz string) error
|
|||
}
|
||||
|
||||
// IsAuthorized determines if a given request input is authorized.
|
||||
func (pe *PolicyEvaluator) IsAuthorized(ctx context.Context, input interface{}) (*pb.IsAuthorizedReply, error) {
|
||||
func (pe *PolicyEvaluator) IsAuthorized(ctx context.Context, req *evaluator.Request) (*pb.IsAuthorizedReply, error) {
|
||||
ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.IsAuthorized")
|
||||
defer span.End()
|
||||
return pe.runBoolQuery(ctx, input, pe.isAuthorized)
|
||||
return pe.runBoolQuery(ctx, req, pe.isAuthorized)
|
||||
}
|
||||
|
||||
// PutData adds (or replaces if the mapping key is the same) contextual data
|
||||
|
@ -110,6 +114,11 @@ func (pe *PolicyEvaluator) PutData(ctx context.Context, data map[string]interfac
|
|||
|
||||
pe.mu.Lock()
|
||||
defer pe.mu.Unlock()
|
||||
|
||||
if ca, ok := data["client_ca"].(string); ok {
|
||||
pe.clientCA = ca
|
||||
}
|
||||
|
||||
txn, err := pe.store.NewTransaction(ctx, storage.WriteParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opa: bad transaction: %w", err)
|
||||
|
@ -171,12 +180,48 @@ func decisionFromInterface(i interface{}) (*pb.IsAuthorizedReply, error) {
|
|||
if v, ok := m["signed_jwt"].(string); ok {
|
||||
d.SignedJwt = v
|
||||
}
|
||||
|
||||
// http_status = [200, "OK", { "HEADER": "VALUE" }]
|
||||
if v, ok := m["http_status"].([]interface{}); ok {
|
||||
d.HttpStatus = new(pb.HTTPStatus)
|
||||
if len(v) > 0 {
|
||||
d.HttpStatus.Code = int32(anyToInt(v[0]))
|
||||
}
|
||||
if len(v) > 1 {
|
||||
if msg, ok := v[1].(string); ok {
|
||||
d.HttpStatus.Message = msg
|
||||
}
|
||||
}
|
||||
if len(v) > 2 {
|
||||
if headers, ok := v[2].(map[string]interface{}); ok {
|
||||
d.HttpStatus.Headers = make(map[string]string)
|
||||
for hk, hv := range headers {
|
||||
d.HttpStatus.Headers[hk] = fmt.Sprint(hv)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (pe *PolicyEvaluator) runBoolQuery(ctx context.Context, input interface{}, q rego.PreparedEvalQuery) (*pb.IsAuthorizedReply, error) {
|
||||
func (pe *PolicyEvaluator) runBoolQuery(ctx context.Context, req *evaluator.Request, q rego.PreparedEvalQuery) (*pb.IsAuthorizedReply, error) {
|
||||
pe.mu.RLock()
|
||||
defer pe.mu.RUnlock()
|
||||
|
||||
// `opa test` doesn't support custom function, so we'll pre-compute is_valid_client_certificate
|
||||
isValid, err := isValidClientCertificate(pe.clientCA, req.ClientCertificate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certificate error: %w", err)
|
||||
}
|
||||
input := struct {
|
||||
*evaluator.Request
|
||||
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
|
||||
}{
|
||||
Request: req,
|
||||
IsValidClientCertificate: isValid,
|
||||
}
|
||||
|
||||
rs, err := q.Eval(ctx, rego.EvalInput(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eval query: %w", err)
|
||||
|
@ -199,3 +244,35 @@ func readPolicy(fn string) ([]byte, error) {
|
|||
defer r.Close()
|
||||
return ioutil.ReadAll(r)
|
||||
}
|
||||
|
||||
func anyToInt(obj interface{}) int {
|
||||
switch v := obj.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case int32:
|
||||
return int(v)
|
||||
case int16:
|
||||
return int(v)
|
||||
case int8:
|
||||
return int(v)
|
||||
case uint64:
|
||||
return int(v)
|
||||
case uint32:
|
||||
return int(v)
|
||||
case uint16:
|
||||
return int(v)
|
||||
case uint8:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
i, _ := v.Int64()
|
||||
return int(i)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(v)
|
||||
return i
|
||||
default:
|
||||
i, _ := strconv.Atoi(fmt.Sprint(v))
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,12 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pomerium/pomerium/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
"gopkg.in/square/go-jose.v2/jwt"
|
||||
|
||||
"github.com/pomerium/pomerium/authorize/evaluator"
|
||||
"github.com/pomerium/pomerium/config"
|
||||
)
|
||||
|
||||
func Test_Eval(t *testing.T) {
|
||||
|
@ -89,11 +92,7 @@ func Test_Eval(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
}{
|
||||
req := &evaluator.Request{
|
||||
Host: tt.route,
|
||||
URL: "https://" + tt.route,
|
||||
User: rawJWT,
|
||||
|
@ -108,3 +107,17 @@ func Test_Eval(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_anyToInt(t *testing.T) {
|
||||
assert.Equal(t, 5, anyToInt("5"))
|
||||
assert.Equal(t, 7, anyToInt(7))
|
||||
assert.Equal(t, 9, anyToInt(int8(9)))
|
||||
assert.Equal(t, 9, anyToInt(int16(9)))
|
||||
assert.Equal(t, 9, anyToInt(int32(9)))
|
||||
assert.Equal(t, 9, anyToInt(int64(9)))
|
||||
assert.Equal(t, 11, anyToInt(uint8(11)))
|
||||
assert.Equal(t, 11, anyToInt(uint16(11)))
|
||||
assert.Equal(t, 11, anyToInt(uint32(11)))
|
||||
assert.Equal(t, 11, anyToInt(uint64(11)))
|
||||
assert.Equal(t, 13, anyToInt(13.0))
|
||||
}
|
||||
|
|
|
@ -5,6 +5,10 @@ import data.shared_key
|
|||
|
||||
default allow = false
|
||||
|
||||
http_status = [495, "invalid client certificate"]{
|
||||
not input.is_valid_client_certificate
|
||||
}
|
||||
|
||||
# allow public
|
||||
allow {
|
||||
route := first_allowed_route(input.url)
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -20,7 +20,6 @@ import (
|
|||
|
||||
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
|
||||
envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2"
|
||||
envoy_type "github.com/envoyproxy/go-control-plane/envoy/type"
|
||||
"google.golang.org/genproto/googleapis/rpc/status"
|
||||
"google.golang.org/grpc/codes"
|
||||
)
|
||||
|
@ -60,13 +59,14 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
|
|||
|
||||
requestURL := getCheckRequestURL(in)
|
||||
req := &evaluator.Request{
|
||||
User: string(sess),
|
||||
Header: hdrs,
|
||||
Host: hattrs.GetHost(),
|
||||
Method: hattrs.GetMethod(),
|
||||
RequestURI: requestURL.String(),
|
||||
RemoteAddr: in.GetAttributes().GetSource().GetAddress().String(),
|
||||
URL: requestURL.String(),
|
||||
User: string(sess),
|
||||
Header: hdrs,
|
||||
Host: hattrs.GetHost(),
|
||||
Method: hattrs.GetMethod(),
|
||||
RequestURI: requestURL.String(),
|
||||
RemoteAddr: in.GetAttributes().GetSource().GetAddress().String(),
|
||||
URL: requestURL.String(),
|
||||
ClientCertificate: getPeerCertificate(in),
|
||||
}
|
||||
reply, err := a.pe.IsAuthorized(ctx, req)
|
||||
if err != nil {
|
||||
|
@ -88,7 +88,12 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
|
|||
evt = evt.Strs("deny-reasons", reply.GetDenyReasons())
|
||||
evt = evt.Str("email", reply.GetEmail())
|
||||
evt = evt.Strs("groups", reply.GetGroups())
|
||||
evt = evt.Str("session", string(sess))
|
||||
if sess != nil {
|
||||
evt = evt.Str("session", string(sess))
|
||||
}
|
||||
if reply.GetHttpStatus() != nil {
|
||||
evt = evt.Interface("http_status", reply.GetHttpStatus())
|
||||
}
|
||||
evt.Msg("authorize check")
|
||||
|
||||
requestHeaders = append(requestHeaders,
|
||||
|
@ -99,6 +104,14 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
|
|||
},
|
||||
})
|
||||
|
||||
if reply.GetHttpStatus().GetCode() > 0 && reply.GetHttpStatus().GetCode() != http.StatusOK {
|
||||
return a.deniedResponse(in,
|
||||
reply.GetHttpStatus().GetCode(),
|
||||
reply.GetHttpStatus().GetMessage(),
|
||||
reply.GetHttpStatus().GetHeaders(),
|
||||
), nil
|
||||
}
|
||||
|
||||
if reply.Allow {
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{Code: int32(codes.OK), Message: "OK"},
|
||||
|
@ -123,30 +136,12 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
|
|||
msg = sesserr.Error()
|
||||
}
|
||||
// all other errors
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: msg},
|
||||
HttpResponse: &envoy_service_auth_v2.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &envoy_service_auth_v2.DeniedHttpResponse{
|
||||
Status: &envoy_type.HttpStatus{
|
||||
Code: envoy_type.StatusCode_Forbidden,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return a.deniedResponse(in, http.StatusForbidden, msg, nil), nil
|
||||
}
|
||||
|
||||
// no redirect for forward auth, that's handled by a separate config setting
|
||||
if isForwardAuth {
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{Code: int32(codes.Unauthenticated)},
|
||||
HttpResponse: &envoy_service_auth_v2.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &envoy_service_auth_v2.DeniedHttpResponse{
|
||||
Status: &envoy_type.HttpStatus{
|
||||
Code: envoy_type.StatusCode_Unauthorized,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return a.deniedResponse(in, http.StatusUnauthorized, "Unauthenticated", nil), nil
|
||||
}
|
||||
|
||||
signinURL := opts.AuthenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/sign_in"})
|
||||
|
@ -155,25 +150,9 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
|
|||
signinURL.RawQuery = q.Encode()
|
||||
redirectTo := urlutil.NewSignedURL(opts.SharedKey, signinURL).String()
|
||||
|
||||
return &envoy_service_auth_v2.CheckResponse{
|
||||
Status: &status.Status{
|
||||
Code: int32(codes.Unauthenticated),
|
||||
Message: "unauthenticated",
|
||||
},
|
||||
HttpResponse: &envoy_service_auth_v2.CheckResponse_DeniedResponse{
|
||||
DeniedResponse: &envoy_service_auth_v2.DeniedHttpResponse{
|
||||
Status: &envoy_type.HttpStatus{
|
||||
Code: envoy_type.StatusCode_Found,
|
||||
},
|
||||
Headers: []*envoy_api_v2_core.HeaderValueOption{{
|
||||
Header: &envoy_api_v2_core.HeaderValue{
|
||||
Key: "Location",
|
||||
Value: redirectTo,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return a.deniedResponse(in, http.StatusFound, "Login", map[string]string{
|
||||
"Location": redirectTo,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (a *Authorize) getEnvoyRequestHeaders(rawjwt []byte, isNewSession bool) ([]*envoy_api_v2_core.HeaderValueOption, error) {
|
||||
|
@ -329,3 +308,10 @@ func handleForwardAuth(opts config.Options, req *envoy_service_auth_v2.CheckRequ
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
// getPeerCertificate gets the PEM-encoded peer certificate from the check request
|
||||
func getPeerCertificate(in *envoy_service_auth_v2.CheckRequest) string {
|
||||
// ignore the error as we will just return the empty string in that case
|
||||
cert, _ := url.QueryUnescape(in.GetAttributes().GetSource().GetCertificate())
|
||||
return cert
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue