mirror of
https://github.com/lightninglabs/aperture.git
synced 2026-01-06 02:44:22 +01:00
challenger+auth: implement invoice checker
This commit is contained in:
@@ -390,7 +390,7 @@ func createProxy(cfg *config, genInvoiceReq InvoiceRequestGenerator,
|
||||
Secrets: newSecretStore(etcdClient),
|
||||
ServiceLimiter: newStaticServiceLimiter(cfg.Services),
|
||||
})
|
||||
authenticator := auth.NewLsatAuthenticator(minter)
|
||||
authenticator := auth.NewLsatAuthenticator(minter, challenger)
|
||||
return proxy.New(
|
||||
authenticator, cfg.Services, cfg.ServeStatic, cfg.StaticRoot,
|
||||
)
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
|
||||
"github.com/lightninglabs/aperture/lsat"
|
||||
"github.com/lightninglabs/aperture/mint"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
)
|
||||
|
||||
// LsatAuthenticator is an authenticator that uses the LSAT protocol to
|
||||
// authenticate requests.
|
||||
type LsatAuthenticator struct {
|
||||
minter Minter
|
||||
checker InvoiceChecker
|
||||
}
|
||||
|
||||
// A compile time flag to ensure the LsatAuthenticator satisfies the
|
||||
@@ -22,8 +24,13 @@ var _ Authenticator = (*LsatAuthenticator)(nil)
|
||||
|
||||
// NewLsatAuthenticator creates a new authenticator that authenticates requests
|
||||
// based on LSAT tokens.
|
||||
func NewLsatAuthenticator(minter Minter) *LsatAuthenticator {
|
||||
return &LsatAuthenticator{minter: minter}
|
||||
func NewLsatAuthenticator(minter Minter,
|
||||
checker InvoiceChecker) *LsatAuthenticator {
|
||||
|
||||
return &LsatAuthenticator{
|
||||
minter: minter,
|
||||
checker: checker,
|
||||
}
|
||||
}
|
||||
|
||||
// Accept returns whether or not the header successfully authenticates the user
|
||||
@@ -51,6 +58,16 @@ func (l *LsatAuthenticator) Accept(header *http.Header, serviceName string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
// Make sure the backend has the invoice recorded as settled.
|
||||
err = l.checker.VerifyInvoiceStatus(
|
||||
preimage.Hash(), lnrpc.Invoice_SETTLED,
|
||||
DefaultInvoiceLookupTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
log.Debugf("Deny: Invoice status mismatch: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth_test
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -46,6 +47,7 @@ func TestLsatAuthenticator(t *testing.T) {
|
||||
headerTests = []struct {
|
||||
id string
|
||||
header *http.Header
|
||||
checkErr error
|
||||
result bool
|
||||
}{
|
||||
{
|
||||
@@ -124,11 +126,23 @@ func TestLsatAuthenticator(t *testing.T) {
|
||||
},
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
id: "valid macaroon header, wrong invoice state",
|
||||
header: &http.Header{
|
||||
lsat.HeaderMacaroon: []string{
|
||||
testMacHex,
|
||||
},
|
||||
},
|
||||
checkErr: fmt.Errorf("nope"),
|
||||
result: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
a := auth.NewLsatAuthenticator(&mockMint{})
|
||||
c := &mockChecker{}
|
||||
a := auth.NewLsatAuthenticator(&mockMint{}, c)
|
||||
for _, testCase := range headerTests {
|
||||
c.err = testCase.checkErr
|
||||
result := a.Accept(testCase.header, "test")
|
||||
if result != testCase.result {
|
||||
t.Fatalf("test case %s failed. got %v expected %v",
|
||||
|
||||
@@ -3,12 +3,21 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/lightninglabs/aperture/lsat"
|
||||
"github.com/lightninglabs/aperture/mint"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"gopkg.in/macaroon.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultInvoiceLookupTimeout is the default maximum time we wait for
|
||||
// an invoice update to arrive.
|
||||
DefaultInvoiceLookupTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// Authenticator is the generic interface for validating client headers and
|
||||
// returning new challenge headers.
|
||||
type Authenticator interface {
|
||||
@@ -30,3 +39,14 @@ type Minter interface {
|
||||
// VerifyLSAT attempts to verify an LSAT with the given parameters.
|
||||
VerifyLSAT(context.Context, *mint.VerificationParams) error
|
||||
}
|
||||
|
||||
// InvoiceChecker is an entity that is able to check the status of an invoice,
|
||||
// particularly whether it's been paid or not.
|
||||
type InvoiceChecker interface {
|
||||
// VerifyInvoiceStatus checks that an invoice identified by a payment
|
||||
// hash has the desired status. To make sure we don't fail while the
|
||||
// invoice update is still on its way, we try several times until either
|
||||
// the desired status is set or the given timeout is reached.
|
||||
VerifyInvoiceStatus(lntypes.Hash, lnrpc.Invoice_InvoiceState,
|
||||
time.Duration) error
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/lightninglabs/aperture/auth"
|
||||
"github.com/lightninglabs/aperture/lsat"
|
||||
"github.com/lightninglabs/aperture/mint"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"gopkg.in/macaroon.v2"
|
||||
)
|
||||
|
||||
@@ -23,3 +26,15 @@ func (m *mockMint) MintLSAT(_ context.Context,
|
||||
func (m *mockMint) VerifyLSAT(_ context.Context, p *mint.VerificationParams) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockChecker struct {
|
||||
err error
|
||||
}
|
||||
|
||||
var _ auth.InvoiceChecker = (*mockChecker)(nil)
|
||||
|
||||
func (m *mockChecker) VerifyInvoiceStatus(lntypes.Hash,
|
||||
lnrpc.Invoice_InvoiceState, time.Duration) error {
|
||||
|
||||
return m.err
|
||||
}
|
||||
|
||||
268
challenger.go
268
challenger.go
@@ -3,7 +3,13 @@ package aperture
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lightninglabs/aperture/auth"
|
||||
"github.com/lightninglabs/aperture/mint"
|
||||
"github.com/lightninglabs/lndclient"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
@@ -19,14 +25,23 @@ type InvoiceRequestGenerator func(price int64) (*lnrpc.Invoice, error)
|
||||
type LndChallenger struct {
|
||||
client lnrpc.LightningClient
|
||||
genInvoiceReq InvoiceRequestGenerator
|
||||
|
||||
invoiceStates map[lntypes.Hash]lnrpc.Invoice_InvoiceState
|
||||
invoicesMtx *sync.Mutex
|
||||
invoicesCancel func()
|
||||
invoicesCond *sync.Cond
|
||||
|
||||
quit chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// A compile time flag to ensure the LndChallenger satisfies the
|
||||
// mint.Challenger interface.
|
||||
// mint.Challenger and auth.InvoiceChecker interface.
|
||||
var _ mint.Challenger = (*LndChallenger)(nil)
|
||||
var _ auth.InvoiceChecker = (*LndChallenger)(nil)
|
||||
|
||||
const (
|
||||
// invoiceMacaroonName is the name of the read-only macaroon belonging
|
||||
// invoiceMacaroonName is the name of the invoice macaroon belonging
|
||||
// to the target lnd node.
|
||||
invoiceMacaroonName = "invoice.macaroon"
|
||||
)
|
||||
@@ -47,16 +62,162 @@ func NewLndChallenger(cfg *authConfig, genInvoiceReq InvoiceRequestGenerator) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
invoicesMtx := &sync.Mutex{}
|
||||
return &LndChallenger{
|
||||
client: client,
|
||||
genInvoiceReq: genInvoiceReq,
|
||||
invoiceStates: make(map[lntypes.Hash]lnrpc.Invoice_InvoiceState),
|
||||
invoicesMtx: invoicesMtx,
|
||||
invoicesCond: sync.NewCond(invoicesMtx),
|
||||
quit: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start starts the challenger's main work which is to keep track of all
|
||||
// invoices and their states. For that the backing lnd node is queried for all
|
||||
// invoices on startup and the a subscription to all subsequent invoice updates
|
||||
// is created.
|
||||
func (l *LndChallenger) Start() error {
|
||||
// These are the default values for the subscription. In case there are
|
||||
// no invoices yet, this will instruct lnd to just send us all updates.
|
||||
// If there are existing invoices, these indices will be updated to
|
||||
// reflect the latest known invoices.
|
||||
addIndex := uint64(0)
|
||||
settleIndex := uint64(0)
|
||||
|
||||
// Get a list of all existing invoices on startup and add them to our
|
||||
// cache. We need to keep track of all invoices, even quite old ones to
|
||||
// make sure tokens are valid. But to save space we only keep track of
|
||||
// an invoice's state.
|
||||
invoiceResp, err := l.client.ListInvoices(
|
||||
context.Background(), &lnrpc.ListInvoiceRequest{
|
||||
NumMaxInvoices: math.MaxUint64,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Advance our indices to the latest known one so we'll only receive
|
||||
// updates for new invoices and/or newly settled invoices.
|
||||
l.invoicesMtx.Lock()
|
||||
for _, invoice := range invoiceResp.Invoices {
|
||||
if invoice.AddIndex > addIndex {
|
||||
addIndex = invoice.AddIndex
|
||||
}
|
||||
if invoice.SettleIndex > settleIndex {
|
||||
settleIndex = invoice.SettleIndex
|
||||
}
|
||||
hash, err := lntypes.MakeHash(invoice.RHash)
|
||||
if err != nil {
|
||||
l.invoicesMtx.Unlock()
|
||||
return fmt.Errorf("error parsing invoice hash: %v", err)
|
||||
}
|
||||
|
||||
// Don't track the state of canceled or expired invoices.
|
||||
if invoiceIrrelevant(invoice) {
|
||||
continue
|
||||
}
|
||||
l.invoiceStates[hash] = invoice.State
|
||||
}
|
||||
l.invoicesMtx.Unlock()
|
||||
|
||||
// We need to be able to cancel any subscription we make.
|
||||
ctxc, cancel := context.WithCancel(context.Background())
|
||||
l.invoicesCancel = cancel
|
||||
|
||||
subscriptionResp, err := l.client.SubscribeInvoices(
|
||||
ctxc, &lnrpc.InvoiceSubscription{
|
||||
AddIndex: addIndex,
|
||||
SettleIndex: settleIndex,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
l.wg.Add(1)
|
||||
go func() {
|
||||
defer l.wg.Done()
|
||||
defer cancel()
|
||||
|
||||
l.readInvoiceStream(subscriptionResp)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readInvoiceStream reads the invoice update messages sent on the stream until
|
||||
// the stream is aborted or the challenger is shutting down.
|
||||
func (l *LndChallenger) readInvoiceStream(
|
||||
stream lnrpc.Lightning_SubscribeInvoicesClient) {
|
||||
|
||||
for {
|
||||
// In case we receive the shutdown signal right after receiving
|
||||
// an update, we can exit early.
|
||||
select {
|
||||
case <-l.quit:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Wait for an update to arrive. This will block until either a
|
||||
// message receives, an error occurs or the underlying context
|
||||
// is canceled (which will also result in an error).
|
||||
invoice, err := stream.Recv()
|
||||
switch {
|
||||
|
||||
case err == io.EOF:
|
||||
return
|
||||
|
||||
case err != nil && strings.Contains(
|
||||
err.Error(), context.Canceled.Error(),
|
||||
):
|
||||
|
||||
return
|
||||
|
||||
case err != nil:
|
||||
log.Errorf("Received error from invoice subscription: "+
|
||||
"%v", err)
|
||||
return
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
hash, err := lntypes.MakeHash(invoice.RHash)
|
||||
if err != nil {
|
||||
log.Errorf("Error parsing invoice hash: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
l.invoicesMtx.Lock()
|
||||
if invoiceIrrelevant(invoice) {
|
||||
// Don't keep the state of canceled or expired invoices.
|
||||
delete(l.invoiceStates, hash)
|
||||
} else {
|
||||
l.invoiceStates[hash] = invoice.State
|
||||
}
|
||||
|
||||
// Before releasing the lock, notify our conditions that listen
|
||||
// for updates on the invoice state.
|
||||
l.invoicesCond.Broadcast()
|
||||
l.invoicesMtx.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop shuts down the challenger.
|
||||
func (l *LndChallenger) Stop() {
|
||||
l.invoicesCancel()
|
||||
close(l.quit)
|
||||
l.wg.Wait()
|
||||
}
|
||||
|
||||
// NewChallenge creates a new LSAT payment challenge, returning a payment
|
||||
// request (invoice) and the corresponding payment hash.
|
||||
//
|
||||
// NOTE: This is part of the Challenger interface.
|
||||
// NOTE: This is part of the mint.Challenger interface.
|
||||
func (l *LndChallenger) NewChallenge(price int64) (string, lntypes.Hash, error) {
|
||||
// Obtain a new invoice from lnd first. We need to know the payment hash
|
||||
// so we can add it as a caveat to the macaroon.
|
||||
@@ -79,3 +240,104 @@ func (l *LndChallenger) NewChallenge(price int64) (string, lntypes.Hash, error)
|
||||
|
||||
return response.PaymentRequest, paymentHash, nil
|
||||
}
|
||||
|
||||
// VerifyInvoiceStatus checks that an invoice identified by a payment
|
||||
// hash has the desired status. To make sure we don't fail while the
|
||||
// invoice update is still on its way, we try several times until either
|
||||
// the desired status is set or the given timeout is reached.
|
||||
//
|
||||
// NOTE: This is part of the auth.InvoiceChecker interface.
|
||||
func (l *LndChallenger) VerifyInvoiceStatus(hash lntypes.Hash,
|
||||
state lnrpc.Invoice_InvoiceState, timeout time.Duration) error {
|
||||
|
||||
// Prevent the challenger to be shut down while we're still waiting for
|
||||
// status updates.
|
||||
l.wg.Add(1)
|
||||
defer l.wg.Done()
|
||||
|
||||
var (
|
||||
condWg sync.WaitGroup
|
||||
doneChan = make(chan struct{})
|
||||
timeoutReached bool
|
||||
hasInvoice bool
|
||||
invoiceState lnrpc.Invoice_InvoiceState
|
||||
)
|
||||
|
||||
// First of all, spawn a goroutine that will signal us on timeout.
|
||||
// Otherwise if a client subscribes to an update on an invoice that
|
||||
// never arrives, and there is no other activity, it would block
|
||||
// forever in the condition.
|
||||
condWg.Add(1)
|
||||
go func() {
|
||||
defer condWg.Done()
|
||||
|
||||
select {
|
||||
case <-doneChan:
|
||||
case <-time.After(timeout):
|
||||
case <-l.quit:
|
||||
}
|
||||
|
||||
l.invoicesCond.L.Lock()
|
||||
timeoutReached = true
|
||||
l.invoicesCond.Broadcast()
|
||||
l.invoicesCond.L.Unlock()
|
||||
}()
|
||||
|
||||
// Now create the main goroutine that blocks until an update is received
|
||||
// on the condition.
|
||||
condWg.Add(1)
|
||||
go func() {
|
||||
defer condWg.Done()
|
||||
l.invoicesCond.L.Lock()
|
||||
|
||||
// Block here until our condition is met or the allowed time is
|
||||
// up. The Wait() will return whenever a signal is broadcast.
|
||||
invoiceState, hasInvoice = l.invoiceStates[hash]
|
||||
for !(hasInvoice && invoiceState == state) && !timeoutReached {
|
||||
l.invoicesCond.Wait()
|
||||
|
||||
// The Wait() above has re-acquired the lock so we can
|
||||
// safely access the states map.
|
||||
invoiceState, hasInvoice = l.invoiceStates[hash]
|
||||
}
|
||||
|
||||
// We're now done.
|
||||
l.invoicesCond.L.Unlock()
|
||||
close(doneChan)
|
||||
}()
|
||||
|
||||
// Wait until we're either done or timed out.
|
||||
condWg.Wait()
|
||||
|
||||
// Interpret the result so we can return a more descriptive error than
|
||||
// just "failed".
|
||||
switch {
|
||||
case !hasInvoice:
|
||||
return fmt.Errorf("no active or settled invoice found for "+
|
||||
"hash=%v", hash)
|
||||
|
||||
case invoiceState != state:
|
||||
return fmt.Errorf("invoice status not correct before timeout, "+
|
||||
"hash=%v, status=%v", hash, invoiceState)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// invoiceIrrelevant returns true if an invoice is nil, canceled or non-settled
|
||||
// and expired.
|
||||
func invoiceIrrelevant(invoice *lnrpc.Invoice) bool {
|
||||
if invoice == nil || invoice.State == lnrpc.Invoice_CANCELED {
|
||||
return true
|
||||
}
|
||||
|
||||
creation := time.Unix(invoice.CreationDate, 0)
|
||||
expiration := creation.Add(time.Duration(invoice.Expiry) * time.Second)
|
||||
expired := time.Now().After(expiration)
|
||||
|
||||
notSettled := invoice.State == lnrpc.Invoice_OPEN ||
|
||||
invoice.State == lnrpc.Invoice_ACCEPTED
|
||||
|
||||
return expired && notSettled
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user