ipfs-cluster/api/common/api_test.go
Hector Sanjuan d7da1b6044 API: Support JWT bearer token authorization
The Pinning Services API standard mandates Bearer token authentication.

This adds JWT bearer token authentication to the IPFS Cluster REST and PINSVC
APIs.

The basic_auth_credentials configuration option needs to be not null and have
at least one username/passwords entry.

A user authenticated via Basic Auth can then "POST /token" and obtain a json
object:

```json { "token" : "<JWTtoken>" } ```

The JWT token has the "iss" (issuer) field set to the Basic auth user that
authorized its creation and is HMAC-signed with its password.

When basic-auth-credentials are set, the APIs will verify that requests come
with either Basic Auth authorization header or with a Bearer token
authorization header.

Bearer tokens will be decoded and the signature will be verified against the
password of the issuer.

At the moment we provide no support to revoke tokens, set "expiration date",
"not before" etc, but this may come in the future.
2022-06-20 20:04:39 +02:00

645 lines
15 KiB
Go

package common
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"testing"
"time"
"github.com/ipfs-cluster/ipfs-cluster/api"
"github.com/ipfs-cluster/ipfs-cluster/api/common/test"
rpctest "github.com/ipfs-cluster/ipfs-cluster/test"
libp2p "github.com/libp2p/go-libp2p"
rpc "github.com/libp2p/go-libp2p-gorpc"
ma "github.com/multiformats/go-multiaddr"
)
const (
SSLCertFile = "test/server.crt"
SSLKeyFile = "test/server.key"
validUserName = "validUserName"
validUserPassword = "validUserPassword"
adminUserName = "adminUserName"
adminUserPassword = "adminUserPassword"
invalidUserName = "invalidUserName"
invalidUserPassword = "invalidUserPassword"
)
var (
validToken, _ = generateSignedTokenString(validUserName, validUserPassword)
invalidToken, _ = generateSignedTokenString(invalidUserName, invalidUserPassword)
)
func routes(c *rpc.Client) []Route {
return []Route{
{
"Test",
"GET",
"/test",
func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
w.Write([]byte(`{ "thisis": "atest" }`))
},
},
}
}
func testAPIwithConfig(t *testing.T, cfg *Config, name string) *API {
ctx := context.Background()
apiMAddr, _ := ma.NewMultiaddr("/ip4/127.0.0.1/tcp/0")
h, err := libp2p.New(libp2p.ListenAddrs(apiMAddr))
if err != nil {
t.Fatal(err)
}
cfg.HTTPListenAddr = []ma.Multiaddr{apiMAddr}
rest, err := NewAPIWithHost(ctx, cfg, h, routes)
if err != nil {
t.Fatalf("should be able to create a new %s API: %s", name, err)
}
// No keep alive for tests
rest.server.SetKeepAlivesEnabled(false)
rest.SetClient(rpctest.NewMockRPCClient(t))
return rest
}
func testAPI(t *testing.T) *API {
cfg := newDefaultTestConfig(t)
cfg.CORSAllowedOrigins = []string{test.ClientOrigin}
cfg.CORSAllowedMethods = []string{"GET", "POST", "DELETE"}
//cfg.CORSAllowedHeaders = []string{"Content-Type"}
cfg.CORSMaxAge = 10 * time.Minute
return testAPIwithConfig(t, cfg, "basic")
}
func testHTTPSAPI(t *testing.T) *API {
cfg := newDefaultTestConfig(t)
cfg.PathSSLCertFile = SSLCertFile
cfg.PathSSLKeyFile = SSLKeyFile
var err error
cfg.TLS, err = newTLSConfig(cfg.PathSSLCertFile, cfg.PathSSLKeyFile)
if err != nil {
t.Fatal(err)
}
return testAPIwithConfig(t, cfg, "https")
}
func testAPIwithBasicAuth(t *testing.T) *API {
cfg := newDefaultTestConfig(t)
cfg.BasicAuthCredentials = map[string]string{
validUserName: validUserPassword,
adminUserName: adminUserPassword,
}
return testAPIwithConfig(t, cfg, "Basic Authentication")
}
func TestAPIShutdown(t *testing.T) {
ctx := context.Background()
rest := testAPI(t)
err := rest.Shutdown(ctx)
if err != nil {
t.Error("should shutdown cleanly: ", err)
}
// test shutting down twice
rest.Shutdown(ctx)
}
func TestHTTPSTestEndpoint(t *testing.T) {
ctx := context.Background()
rest := testAPI(t)
httpsrest := testHTTPSAPI(t)
defer rest.Shutdown(ctx)
defer httpsrest.Shutdown(ctx)
tf := func(t *testing.T, url test.URLFunc) {
r := make(map[string]string)
test.MakeGet(t, rest, url(rest)+"/test", &r)
if r["thisis"] != "atest" {
t.Error("expected correct body")
}
}
httpstf := func(t *testing.T, url test.URLFunc) {
r := make(map[string]string)
test.MakeGet(t, httpsrest, url(httpsrest)+"/test", &r)
if r["thisis"] != "atest" {
t.Error("expected correct body")
}
}
test.BothEndpoints(t, tf)
test.HTTPSEndPoint(t, httpstf)
}
func TestAPILogging(t *testing.T) {
ctx := context.Background()
cfg := newDefaultTestConfig(t)
logFile, err := filepath.Abs("http.log")
if err != nil {
t.Fatal(err)
}
cfg.HTTPLogFile = logFile
rest := testAPIwithConfig(t, cfg, "log_enabled")
defer os.Remove(cfg.HTTPLogFile)
info, err := os.Stat(cfg.HTTPLogFile)
if err != nil {
t.Fatal(err)
}
if info.Size() > 0 {
t.Errorf("expected empty log file")
}
id := api.ID{}
test.MakeGet(t, rest, test.HTTPURL(rest)+"/test", &id)
info, err = os.Stat(cfg.HTTPLogFile)
if err != nil {
t.Fatal(err)
}
size1 := info.Size()
if size1 == 0 {
t.Error("did not expect an empty log file")
}
// Restart API and make sure that logs are being appended
rest.Shutdown(ctx)
rest = testAPIwithConfig(t, cfg, "log_enabled")
defer rest.Shutdown(ctx)
test.MakeGet(t, rest, test.HTTPURL(rest)+"/id", &id)
info, err = os.Stat(cfg.HTTPLogFile)
if err != nil {
t.Fatal(err)
}
size2 := info.Size()
if size2 == 0 {
t.Error("did not expect an empty log file")
}
if !(size2 > size1) {
t.Error("logs were not appended")
}
}
func TestNotFoundHandler(t *testing.T) {
ctx := context.Background()
rest := testAPI(t)
defer rest.Shutdown(ctx)
tf := func(t *testing.T, url test.URLFunc) {
bytes := make([]byte, 10)
for i := 0; i < 10; i++ {
bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25
}
var errResp api.Error
test.MakePost(t, rest, url(rest)+"/"+string(bytes), []byte{}, &errResp)
if errResp.Code != 404 {
t.Errorf("expected error not found: %+v", errResp)
}
var errResp1 api.Error
test.MakeGet(t, rest, url(rest)+"/"+string(bytes), &errResp1)
if errResp1.Code != 404 {
t.Errorf("expected error not found: %+v", errResp)
}
}
test.BothEndpoints(t, tf)
}
func TestCORS(t *testing.T) {
ctx := context.Background()
rest := testAPI(t)
defer rest.Shutdown(ctx)
type testcase struct {
method string
path string
}
tf := func(t *testing.T, url test.URLFunc) {
reqHeaders := make(http.Header)
reqHeaders.Set("Origin", "myorigin")
reqHeaders.Set("Access-Control-Request-Headers", "Content-Type")
for _, tc := range []testcase{
{"GET", "/test"},
// testcase{},
} {
reqHeaders.Set("Access-Control-Request-Method", tc.method)
headers := test.MakeOptions(t, rest, url(rest)+tc.path, reqHeaders)
aorigin := headers.Get("Access-Control-Allow-Origin")
amethods := headers.Get("Access-Control-Allow-Methods")
aheaders := headers.Get("Access-Control-Allow-Headers")
acreds := headers.Get("Access-Control-Allow-Credentials")
maxage := headers.Get("Access-Control-Max-Age")
if aorigin != "myorigin" {
t.Error("Bad ACA-Origin:", aorigin)
}
if amethods != tc.method {
t.Error("Bad ACA-Methods:", amethods)
}
if aheaders != "Content-Type" {
t.Error("Bad ACA-Headers:", aheaders)
}
if acreds != "true" {
t.Error("Bad ACA-Credentials:", acreds)
}
if maxage != "600" {
t.Error("Bad AC-Max-Age:", maxage)
}
}
}
test.BothEndpoints(t, tf)
}
type responseChecker func(*http.Response) error
type requestShaper func(*http.Request) error
type httpTestcase struct {
method string
path string
header http.Header
body io.ReadCloser
shaper requestShaper
checker responseChecker
}
func httpStatusCodeChecker(resp *http.Response, expectedStatus int) error {
if resp.StatusCode == expectedStatus {
return nil
}
return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode)
}
func assertHTTPStatusIsUnauthoriazed(resp *http.Response) error {
return httpStatusCodeChecker(resp, http.StatusUnauthorized)
}
func assertHTTPStatusIsTooLarge(resp *http.Response) error {
return httpStatusCodeChecker(resp, http.StatusRequestHeaderFieldsTooLarge)
}
func makeHTTPStatusNegatedAssert(checker responseChecker) responseChecker {
return func(resp *http.Response) error {
if checker(resp) == nil {
return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode)
}
return nil
}
}
func (tc *httpTestcase) getTestFunction(api *API) test.Func {
return func(t *testing.T, prefixMaker test.URLFunc) {
h := test.MakeHost(t, api)
defer h.Close()
url := prefixMaker(api) + tc.path
c := test.HTTPClient(t, h, test.IsHTTPS(url))
req, err := http.NewRequest(tc.method, url, tc.body)
if err != nil {
t.Fatal("Failed to assemble a HTTP request: ", err)
}
if tc.header != nil {
req.Header = tc.header
}
if tc.shaper != nil {
err := tc.shaper(req)
if err != nil {
t.Fatal("Failed to shape a HTTP request: ", err)
}
}
resp, err := c.Do(req)
if err != nil {
t.Fatal("Failed to make a HTTP request: ", err)
}
if tc.checker != nil {
if err := tc.checker(resp); err != nil {
r, e := httputil.DumpRequest(req, true)
if e != nil {
t.Errorf("Assertion failed with: %q", err)
} else {
t.Errorf("Assertion failed with: %q on request: \n%.100s", err, r)
}
}
}
}
}
func makeBasicAuthRequestShaper(username, password string) requestShaper {
return func(req *http.Request) error {
req.SetBasicAuth(username, password)
return nil
}
}
func makeTokenAuthRequestShaper(token string) requestShaper {
return func(req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}
}
func makeLongHeaderShaper(size int) requestShaper {
return func(req *http.Request) error {
for sz := size; sz > 0; sz -= 8 {
req.Header.Add("Foo", "bar")
}
return nil
}
}
func TestBasicAuth(t *testing.T) {
ctx := context.Background()
rest := testAPIwithBasicAuth(t)
defer rest.Shutdown(ctx)
for _, tc := range []httpTestcase{
{},
{
method: "",
path: "",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "POST",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "DELETE",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "HEAD",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "OPTIONS", // Always allowed for CORS
path: "/foo",
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "PUT",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "TRACE",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "CONNECT",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "BAR",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeBasicAuthRequestShaper(invalidUserName, invalidUserPassword),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeBasicAuthRequestShaper(validUserName, invalidUserPassword),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeBasicAuthRequestShaper(invalidUserName, validUserPassword),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeBasicAuthRequestShaper(adminUserName, validUserPassword),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "POST",
path: "/foo",
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "DELETE",
path: "/foo",
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "BAR",
path: "/foo",
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "GET",
path: "/test",
shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
} {
test.BothEndpoints(t, tc.getTestFunction(rest))
}
}
func TestTokenAuth(t *testing.T) {
ctx := context.Background()
rest := testAPIwithBasicAuth(t)
defer rest.Shutdown(ctx)
for _, tc := range []httpTestcase{
{},
{
method: "",
path: "",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "POST",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "DELETE",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "HEAD",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "OPTIONS", // Always allowed for CORS
path: "/foo",
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "PUT",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "TRACE",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "CONNECT",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "BAR",
path: "/foo",
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeTokenAuthRequestShaper(invalidToken),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeTokenAuthRequestShaper(invalidToken),
checker: assertHTTPStatusIsUnauthoriazed,
},
{
method: "GET",
path: "/foo",
shaper: makeTokenAuthRequestShaper(validToken),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "POST",
path: "/foo",
shaper: makeTokenAuthRequestShaper(validToken),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "DELETE",
path: "/foo",
shaper: makeTokenAuthRequestShaper(validToken),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "BAR",
path: "/foo",
shaper: makeTokenAuthRequestShaper(validToken),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
{
method: "GET",
path: "/test",
shaper: makeTokenAuthRequestShaper(validToken),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed),
},
} {
test.BothEndpoints(t, tc.getTestFunction(rest))
}
}
func TestLimitMaxHeaderSize(t *testing.T) {
maxHeaderBytes := 4 * DefaultMaxHeaderBytes
cfg := newTestConfig()
cfg.MaxHeaderBytes = maxHeaderBytes
ctx := context.Background()
rest := testAPIwithConfig(t, cfg, "http with maxHeaderBytes")
defer rest.Shutdown(ctx)
for _, tc := range []httpTestcase{
{
method: "GET",
path: "/foo",
shaper: makeLongHeaderShaper(maxHeaderBytes * 2),
checker: assertHTTPStatusIsTooLarge,
},
{
method: "GET",
path: "/foo",
shaper: makeLongHeaderShaper(maxHeaderBytes / 2),
checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsTooLarge),
},
} {
test.BothEndpoints(t, tc.getTestFunction(rest))
}
}