Merge pull request #16 from guggero/auth-whitelist

proxy: add authentication whitelist
This commit is contained in:
Oliver Gugger
2019-12-04 10:46:39 +01:00
committed by GitHub
7 changed files with 296 additions and 159 deletions

View File

@@ -32,7 +32,6 @@ const (
var (
authRegex = regexp.MustCompile("LSAT (.*?):([a-f0-9]{64})")
authFormat = "LSAT %s:%s"
opWildcard = "*"
)
// LsatAuthenticator is an authenticator that uses the LSAT protocol to

View File

@@ -8,6 +8,11 @@ import (
"github.com/lightninglabs/kirin/freebie"
)
const (
// LevelOff is the default level where no authentication is required.
LevelOff Level = "off"
)
type Level string
func (l Level) lower() string {

View File

@@ -91,14 +91,15 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Determine auth level required to access service and dispatch request
// accordingly.
authLevel := target.AuthRequired(r)
switch {
case target.Auth.IsOn():
case authLevel.IsOn():
if !p.authenticator.Accept(&r.Header, target.Name) {
prefixLog.Infof("Authentication failed. Sending 402.")
p.handlePaymentRequired(w, r, target.Name)
return
}
case target.Auth.IsFreebie():
case authLevel.IsFreebie():
// We only need to respect the freebie counter if the user
// is not authenticated at all.
if !p.authenticator.Accept(&r.Header, target.Name) {
@@ -127,7 +128,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}
case target.Auth.IsOff():
case authLevel.IsOff():
}
// If we got here, it means everything is OK to pass the request to the

View File

@@ -1,21 +1,13 @@
package proxy_test
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io/ioutil"
"math/big"
"net"
"net/http"
"os"
"path"
"strings"
"testing"
@@ -24,6 +16,7 @@ import (
"github.com/lightninglabs/kirin/auth"
"github.com/lightninglabs/kirin/proxy"
proxytest "github.com/lightninglabs/kirin/proxy/testdata"
"github.com/lightningnetwork/lnd/cert"
"github.com/lightningnetwork/lnd/macaroons"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -36,21 +29,11 @@ const (
testProxyAddr = "localhost:10019"
testHostRegexp = "^localhost:.*$"
testPathRegexpHTTP = "^/http/.*$"
testPathRegexpGRPC = "^/proxy_test.*$"
testPathRegexpGRPC = "^/proxy_test\\.Greeter/.*$"
testTargetServiceAddress = "localhost:8082"
testHTTPResponseBody = "HTTP Hello"
)
var (
serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 128)
tlsCipherSuites = []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
}
)
// helloServer is a simple server that implements the GreeterServer interface.
type helloServer struct{}
@@ -64,6 +47,17 @@ func (s *helloServer) SayHello(ctx context.Context,
}, nil
}
// SayHello returns a simple string that also contains a string from the
// request. This RPC method should be whitelisted to be called without any
// authentication required.
func (s *helloServer) SayHelloNoAuth(ctx context.Context,
req *proxytest.HelloRequest) (*proxytest.HelloReply, error) {
return &proxytest.HelloReply{
Message: fmt.Sprintf("Hello %s", req.Name),
}, nil
}
// TestProxyHTTP tests that the proxy can forward HTTP requests to a backend
// service and handle LSAT authentication correctly.
func TestProxyHTTP(t *testing.T) {
@@ -157,7 +151,7 @@ func TestProxyGRPC(t *testing.T) {
}
certFile := path.Join(tempDirName, "proxy.cert")
keyFile := path.Join(tempDirName, "proxy.key")
certPool, creds, cert, err := genCertPair(certFile, keyFile)
certPool, creds, certData, err := genCertPair(certFile, keyFile)
if err != nil {
t.Fatalf("unable to create cert pair: %v", err)
}
@@ -189,11 +183,7 @@ func TestProxyGRPC(t *testing.T) {
defer server.Close()
// Start the target backend service also on TLS.
tlsConf := &tls.Config{
Certificates: []tls.Certificate{cert},
CipherSuites: tlsCipherSuites,
MinVersion: tls.VersionTLS12,
}
tlsConf := cert.TLSConfFromCert(certData)
serverOpts := []grpc.ServerOption{
grpc.Creds(credentials.NewTLS(tlsConf)),
}
@@ -259,6 +249,192 @@ func TestProxyGRPC(t *testing.T) {
}
}
// TestWhitelistHTTP verifies that a white list entry for a service allows an
// authentication exception to be configured.
func TestWhitelistHTTP(t *testing.T) {
// Create a service with authentication on by default, with one
// exception configured as whitelist.
services := []*proxy.Service{{
Address: testTargetServiceAddress,
HostRegexp: testHostRegexp,
PathRegexp: testPathRegexpHTTP,
Protocol: "http",
Auth: "on",
AuthWhitelistPaths: []string{"^/http/white.*$"},
}}
mockAuth := auth.NewMockAuthenticator()
p, err := proxy.New(mockAuth, services, "static")
if err != nil {
t.Fatalf("failed to create new proxy: %v", err)
}
// Start server that gives requests to the proxy.
server := &http.Server{
Addr: testProxyAddr,
Handler: http.HandlerFunc(p.ServeHTTP),
}
go server.ListenAndServe()
defer server.Close()
// Start the target backend service.
backendService := &http.Server{Addr: testTargetServiceAddress}
go startBackendHTTP(backendService)
defer backendService.Close()
// Wait for servers to start.
time.Sleep(100 * time.Millisecond)
// Test making a request to the backend service to an URL where
// authentication is enabled.
client := &http.Client{}
url := fmt.Sprintf("http://%s/http/black", testProxyAddr)
resp, err := client.Get(url)
if err != nil {
t.Fatalf("errored making http request: %v", err)
}
if resp.Status != "402 Payment Required" {
t.Fatalf("expected 402 status code, got: %v", resp.Status)
}
authHeader := resp.Header.Get("Www-Authenticate")
if !strings.Contains(authHeader, "LSAT") {
t.Fatalf("expected partial LSAT in response header, got: %v",
authHeader)
}
// Make sure that if we query an URL that is on the whitelist, we don't
// get the 402 response.
url = fmt.Sprintf("http://%s/http/white", testProxyAddr)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatalf("error creating request: %v", err)
}
resp, err = client.Do(req)
if err != nil {
t.Fatalf("errored making http request: %v", err)
}
if resp.Status != "200 OK" {
t.Fatalf("expected 200 OK status code, got: %v", resp.Status)
}
// Ensure that we got the response body we expect.
defer resp.Body.Close()
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
if string(bodyBytes) != testHTTPResponseBody {
t.Fatalf("expected response body %v, got %v",
testHTTPResponseBody, string(bodyBytes))
}
}
// TestWhitelistGRPC verifies that a white list entry for a service allows an
// authentication exception to be configured.
func TestWhitelistGRPC(t *testing.T) {
// Since gRPC only really works over TLS, we need to generate a
// certificate and key pair first.
tempDirName, err := ioutil.TempDir("", "proxytest")
if err != nil {
t.Fatalf("unable to create temp dir: %v", err)
}
certFile := path.Join(tempDirName, "proxy.cert")
keyFile := path.Join(tempDirName, "proxy.key")
certPool, creds, certData, err := genCertPair(certFile, keyFile)
if err != nil {
t.Fatalf("unable to create cert pair: %v", err)
}
opts := []grpc.DialOption{grpc.WithTransportCredentials(creds)}
// Create a list of services to proxy between.
services := []*proxy.Service{{
Address: testTargetServiceAddress,
HostRegexp: testHostRegexp,
PathRegexp: testPathRegexpGRPC,
Protocol: "https",
TLSCertPath: certFile,
Auth: "on",
AuthWhitelistPaths: []string{
"^/proxy_test\\.Greeter/SayHelloNoAuth.*$",
},
}}
// Create the proxy server and start serving on TLS.
mockAuth := auth.NewMockAuthenticator()
p, err := proxy.New(mockAuth, services, "static")
if err != nil {
t.Fatalf("failed to create new proxy: %v", err)
}
server := &http.Server{
Addr: testProxyAddr,
Handler: http.HandlerFunc(p.ServeHTTP),
TLSConfig: &tls.Config{
RootCAs: certPool,
InsecureSkipVerify: true,
},
}
go server.ListenAndServeTLS(certFile, keyFile)
defer server.Close()
// Start the target backend service also on TLS.
tlsConf := cert.TLSConfFromCert(certData)
serverOpts := []grpc.ServerOption{
grpc.Creds(credentials.NewTLS(tlsConf)),
}
backendService := grpc.NewServer(serverOpts...)
go startBackendGRPC(backendService)
defer backendService.Stop()
// Dial to the proxy now, without any authentication.
conn, err := grpc.Dial(testProxyAddr, opts...)
if err != nil {
t.Fatalf("unable to connect to RPC server: %v", err)
}
client := proxytest.NewGreeterClient(conn)
// Test making a request to the backend service to an URL where
// authentication is enabled.
req := &proxytest.HelloRequest{Name: "foo"}
res, err := client.SayHello(
context.Background(), req, grpc.WaitForReady(true),
)
if err == nil {
t.Fatalf("expected error to be returned without auth")
}
statusErr, ok := status.FromError(err)
if !ok {
t.Fatalf("expected error to be status.Status")
}
if statusErr.Code() != codes.Internal {
t.Fatalf("unexpected code. wanted %d, got %d",
codes.Internal, statusErr.Code())
}
if statusErr.Message() != "payment required" {
t.Fatalf("invalid error. expected [%s] got [%s]",
"payment required", err.Error())
}
// Make sure that if we query an URL that is on the whitelist, we don't
// get the 402 response.
conn, err = grpc.Dial(testProxyAddr, opts...)
if err != nil {
t.Fatalf("unable to connect to RPC server: %v", err)
}
client = proxytest.NewGreeterClient(conn)
// Make the request. This time no error should be returned.
req = &proxytest.HelloRequest{Name: "foo"}
res, err = client.SayHelloNoAuth(context.Background(), req)
if err != nil {
t.Fatalf("unable to call service: %v", err)
}
if res.Message != "Hello foo" {
t.Fatalf("unexpected reply, wanted %s, got %s",
"Hello foo", res.Message)
}
}
// startBackendHTTP starts the given HTTP server and blocks until the server
// is shut down.
func startBackendHTTP(server *http.Server) error {
@@ -291,142 +467,28 @@ func startBackendGRPC(grpcServer *grpc.Server) error {
func genCertPair(certFile, keyFile string) (*x509.CertPool,
credentials.TransportCredentials, tls.Certificate, error) {
org := "kirin autogenerated cert"
cert := tls.Certificate{}
now := time.Now()
validUntil := now.Add(1 * time.Hour)
// Generate a serial number that's below the serialNumberLimit.
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, cert, fmt.Errorf("failed to generate serial "+
"number: %s", err)
}
// Collect the host's IP addresses, including loopback, in a slice.
ipAddresses := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
// addIP appends an IP address only if it isn't already in the slice.
addIP := func(ipAddr net.IP) {
for _, ip := range ipAddresses {
if ip.Equal(ipAddr) {
return
}
}
ipAddresses = append(ipAddresses, ipAddr)
}
// Add all the interface IPs that aren't already in the slice.
addrs, err := net.InterfaceAddrs()
if err != nil {
return nil, nil, cert, err
}
for _, a := range addrs {
ipAddr, _, err := net.ParseCIDR(a.String())
if err == nil {
addIP(ipAddr)
}
}
// Collect the host's names into a slice.
host, err := os.Hostname()
if err != nil {
return nil, nil, cert, err
}
dnsNames := []string{host}
if host != "localhost" {
dnsNames = append(dnsNames, "localhost")
}
// Generate a private key for the certificate.
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, cert, err
}
// Construct the certificate template.
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{org},
CommonName: host,
},
NotBefore: now.Add(-time.Hour * 24),
NotAfter: validUntil,
KeyUsage: x509.KeyUsageKeyEncipherment |
x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
IsCA: true, // so can sign self.
BasicConstraintsValid: true,
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
derBytes, err := x509.CreateCertificate(
rand.Reader, &template, &template, &priv.PublicKey, priv,
crt := tls.Certificate{}
err := cert.GenCertPair(
"kirin autogenerated cert", certFile, keyFile, nil, nil,
cert.DefaultAutogenValidity,
)
if err != nil {
return nil, nil, cert, fmt.Errorf("failed to create "+
"certificate: %v", err)
return nil, nil, crt, fmt.Errorf("unable to generate cert "+
"pair: %v", err)
}
certBuf := &bytes.Buffer{}
err = pem.Encode(
certBuf,
&pem.Block{Type: "CERTIFICATE",
Bytes: derBytes,
},
)
crt, x509Cert, err := cert.LoadCert(certFile, keyFile)
if err != nil {
return nil, nil, cert, fmt.Errorf("failed to encode "+
"certificate: %v", err)
}
keybytes, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, cert, fmt.Errorf("unable to encode privkey: "+
"%v", err)
}
keyBuf := &bytes.Buffer{}
err = pem.Encode(
keyBuf,
&pem.Block{
Type: "EC PRIVATE KEY",
Bytes: keybytes,
},
)
if err != nil {
return nil, nil, cert, fmt.Errorf("failed to encode private "+
"key: %v", err)
}
cert, err = tls.X509KeyPair(certBuf.Bytes(), keyBuf.Bytes())
if err != nil {
return nil, nil, cert, fmt.Errorf("failed to create key pair: "+
"%v", err)
}
// Write cert and key files.
if err = ioutil.WriteFile(certFile, certBuf.Bytes(), 0644); err != nil {
return nil, nil, cert, fmt.Errorf("unable to write cert file "+
"at %v: %v", certFile, err)
}
if err = ioutil.WriteFile(keyFile, keyBuf.Bytes(), 0600); err != nil {
os.Remove(certFile)
return nil, nil, cert, fmt.Errorf("unable to write key file "+
"at %v: %v", keyFile, err)
return nil, nil, crt, fmt.Errorf("unable to load cert: %v", err)
}
cp := x509.NewCertPool()
if !cp.AppendCertsFromPEM(certBuf.Bytes()) {
return nil, nil, cert, fmt.Errorf("credentials: failed to " +
"append certificate")
}
cp.AddCert(x509Cert)
creds, err := credentials.NewClientTLSFromFile(certFile, "")
if err != nil {
return nil, nil, cert, fmt.Errorf("unable to load cert file: "+
return nil, nil, crt, fmt.Errorf("unable to load cert file: "+
"%v", err)
}
return cp, creds, cert, nil
return cp, creds, crt, nil
}

View File

@@ -5,6 +5,8 @@ import (
"encoding/hex"
"fmt"
"io/ioutil"
"net/http"
"regexp"
"strings"
"github.com/lightninglabs/kirin/auth"
@@ -67,9 +69,33 @@ type Service struct {
// correspond to the caveat's condition.
Constraints map[string]string `long:"constraints" description:"The service constraints to enforce at the base tier"`
// AuthWhitelistPaths is an optional list of regular expressions that
// are matched against the path of the URL of a request. If the request
// URL matches any of those regular expressions, the call is treated as
// if Auth was set to "off". This allows certain RPC methods to not
// require an LSAT token. E.g. the path for a gRPC call looks like this:
// /package_name.ServiceName/MethodName
AuthWhitelistPaths []string `long:"authwhitelistpaths" description:"List of regular expressions for paths that don't require authentication'"`
freebieDb freebie.DB
}
// AuthRequired determines the auth level required for a given request.
func (s *Service) AuthRequired(r *http.Request) auth.Level {
// Does the request match any whitelist entry?
for _, pathRegexp := range s.AuthWhitelistPaths {
pathRegexp := regexp.MustCompile(pathRegexp)
if pathRegexp.MatchString(r.URL.Path) {
log.Tracef("Req path [%s] matches whitelist entry "+
"[%s].", r.URL.Path, pathRegexp)
return auth.LevelOff
}
}
// By default we always return the service level auth setting.
return s.Auth
}
// prepareServices prepares the backend service configurations to be used by the
// proxy.
func prepareServices(services []*Service) error {
@@ -117,6 +143,15 @@ func prepareServices(services []*Service) error {
"format %s", value)
}
}
// Make sure all whitelist regular expression entries actually
// compile so we run into an eventual panic during startup and
// not only when the request happens.
for _, entry := range service.AuthWhitelistPaths {
_, err := regexp.Compile(entry)
return fmt.Errorf("error validating auth whitelist: %v",
err)
}
}
return nil
}

View File

@@ -108,16 +108,17 @@ func init() {
func init() { proto.RegisterFile("hello.proto", fileDescriptor_61ef911816e0a8ce) }
var fileDescriptor_61ef911816e0a8ce = []byte{
// 145 bytes of a gzipped FileDescriptorProto
// 161 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0xce, 0x48, 0xcd, 0xc9,
0xc9, 0xd7, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x2a, 0x28, 0xca, 0xaf, 0xa8, 0x8c, 0x2f,
0x49, 0x2d, 0x2e, 0x51, 0x52, 0xe2, 0xe2, 0xf1, 0x00, 0x49, 0x05, 0xa5, 0x16, 0x96, 0xa6, 0x16,
0x97, 0x08, 0x09, 0x71, 0xb1, 0xe4, 0x25, 0xe6, 0xa6, 0x4a, 0x30, 0x2a, 0x30, 0x6a, 0x70, 0x06,
0x81, 0xd9, 0x4a, 0x6a, 0x5c, 0x5c, 0x50, 0x35, 0x05, 0x39, 0x95, 0x42, 0x12, 0x5c, 0xec, 0xb9,
0xa9, 0xc5, 0xc5, 0x89, 0xe9, 0x30, 0x45, 0x30, 0xae, 0x91, 0x27, 0x17, 0xbb, 0x7b, 0x51, 0x6a,
0x6a, 0x49, 0x6a, 0x91, 0x90, 0x1d, 0x17, 0x47, 0x70, 0x62, 0x25, 0x58, 0x97, 0x90, 0x84, 0x1e,
0xc2, 0x3e, 0x3d, 0x64, 0xcb, 0xa4, 0xc4, 0xb0, 0xc8, 0x14, 0xe4, 0x54, 0x2a, 0x31, 0x24, 0xb1,
0x81, 0x5d, 0x6a, 0x0c, 0x08, 0x00, 0x00, 0xff, 0xff, 0xe3, 0x7f, 0xe6, 0xbe, 0xb8, 0x00, 0x00,
0xa9, 0xc5, 0xc5, 0x89, 0xe9, 0x30, 0x45, 0x30, 0xae, 0x51, 0x3f, 0x23, 0x17, 0xbb, 0x7b, 0x51,
0x6a, 0x6a, 0x49, 0x6a, 0x91, 0x90, 0x1d, 0x17, 0x47, 0x70, 0x62, 0x25, 0x58, 0x9b, 0x90, 0x84,
0x1e, 0xc2, 0x42, 0x3d, 0x64, 0xdb, 0xa4, 0xc4, 0xb0, 0xc8, 0x14, 0xe4, 0x54, 0x2a, 0x31, 0x08,
0xb9, 0x70, 0xf1, 0xc1, 0xf4, 0xfb, 0xe5, 0x3b, 0x96, 0x96, 0x64, 0x90, 0x63, 0x4a, 0x12, 0x1b,
0xd8, 0xc3, 0xc6, 0x80, 0x00, 0x00, 0x00, 0xff, 0xff, 0x61, 0x16, 0xd9, 0xde, 0xff, 0x00, 0x00,
0x00,
}
@@ -134,6 +135,7 @@ const _ = grpc.SupportPackageIsVersion4
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type GreeterClient interface {
SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
SayHelloNoAuth(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}
type greeterClient struct {
@@ -153,9 +155,19 @@ func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...
return out, nil
}
func (c *greeterClient) SayHelloNoAuth(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
out := new(HelloReply)
err := c.cc.Invoke(ctx, "/proxy_test.Greeter/SayHelloNoAuth", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// GreeterServer is the server API for Greeter service.
type GreeterServer interface {
SayHello(context.Context, *HelloRequest) (*HelloReply, error)
SayHelloNoAuth(context.Context, *HelloRequest) (*HelloReply, error)
}
func RegisterGreeterServer(s *grpc.Server, srv GreeterServer) {
@@ -180,6 +192,24 @@ func _Greeter_SayHello_Handler(srv interface{}, ctx context.Context, dec func(in
return interceptor(ctx, in, info, handler)
}
func _Greeter_SayHelloNoAuth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HelloRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GreeterServer).SayHelloNoAuth(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proxy_test.Greeter/SayHelloNoAuth",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GreeterServer).SayHelloNoAuth(ctx, req.(*HelloRequest))
}
return interceptor(ctx, in, info, handler)
}
var _Greeter_serviceDesc = grpc.ServiceDesc{
ServiceName: "proxy_test.Greeter",
HandlerType: (*GreeterServer)(nil),
@@ -188,6 +218,10 @@ var _Greeter_serviceDesc = grpc.ServiceDesc{
MethodName: "SayHello",
Handler: _Greeter_SayHello_Handler,
},
{
MethodName: "SayHelloNoAuth",
Handler: _Greeter_SayHelloNoAuth_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "hello.proto",

View File

@@ -4,6 +4,7 @@ package proxy_test;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHelloNoAuth (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {