mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 06:39:08 +02:00
197 lines
9.2 KiB
Go
197 lines
9.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
|
servicemanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
|
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
mgmttypes "github.com/netbirdio/netbird/management/server/types"
|
|
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
|
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// localCredentialClient replaces the transport while keeping the real service
|
|
// store, credential verification, and session signing.
|
|
type localCredentialClient struct {
|
|
server *nbgrpc.ProxyServiceServer
|
|
}
|
|
|
|
func (c localCredentialClient) Authenticate(ctx context.Context, req *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) {
|
|
return c.server.Authenticate(ctx, req)
|
|
}
|
|
|
|
func credentialHandler(t *testing.T, field string) (*Middleware, http.Handler) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
s, err := store.NewStore(ctx, mgmttypes.SqliteStoreEngine, t.TempDir(), nil, false)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { assert.NoError(t, s.Close(ctx)) })
|
|
require.NoError(t, s.SaveAccount(ctx, &mgmttypes.Account{Id: "account"}))
|
|
keys := generateTestKeyPair(t)
|
|
svc := &service.Service{
|
|
ID: "service", AccountID: "account", Name: "test", Domain: "example.com",
|
|
Enabled: true, SessionPrivateKey: keys.PrivateKey, SessionPublicKey: keys.PublicKey,
|
|
Auth: service.AuthConfig{
|
|
PinAuth: &service.PINAuthConfig{Enabled: true, Pin: "842716"},
|
|
PasswordAuth: &service.PasswordAuthConfig{Enabled: true, Password: "842716"},
|
|
},
|
|
}
|
|
require.NoError(t, svc.Auth.HashSecrets())
|
|
require.NoError(t, s.CreateService(ctx, svc))
|
|
server := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
|
|
t.Cleanup(server.Close)
|
|
server.SetServiceManager(servicemanager.NewManager(s, nil, nil, nil, nil, nil))
|
|
client := localCredentialClient{server: server}
|
|
var scheme Scheme = NewPin(client, "service", "account")
|
|
if field == "password" {
|
|
scheme = NewPassword(client, "service", "account")
|
|
}
|
|
mw := NewMiddleware(nil, nil, nil)
|
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, time.Hour, "account", "service", nil, false, nil))
|
|
return mw, mw.Protect(newPassthroughHandler())
|
|
}
|
|
|
|
func credentialRequest(method, field, value string) *http.Request {
|
|
r := httptest.NewRequest(method, "https://example.com/", strings.NewReader(url.Values{field: {value}}.Encode()))
|
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
r.RemoteAddr = "198.51.100.25:12345"
|
|
return r
|
|
}
|
|
|
|
func TestCredentialAuthPOSTOnly(t *testing.T) {
|
|
for _, field := range []string{"pin", "password"} {
|
|
t.Run(field, func(t *testing.T) {
|
|
_, handler := credentialHandler(t, field)
|
|
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodPost} {
|
|
r := credentialRequest(method, field, "")
|
|
r.URL.RawQuery = url.Values{field: {"842716"}}.Encode()
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, r)
|
|
assert.Equal(t, http.StatusUnauthorized, resp.Code, "%s query credentials must not authenticate", method)
|
|
assert.Empty(t, resp.Result().Cookies(), "query credentials must not issue a session")
|
|
}
|
|
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(method, field, "842716"))
|
|
assert.Equal(t, http.StatusUnauthorized, resp.Code, "%s body credentials must not authenticate", method)
|
|
}
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "842716"))
|
|
assert.Equal(t, http.StatusSeeOther, resp.Code, "POST body credentials must authenticate")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCredentialAuthThrottling(t *testing.T) {
|
|
for _, field := range []string{"pin", "password"} {
|
|
t.Run(field, func(t *testing.T) {
|
|
_, handler := credentialHandler(t, field)
|
|
for range 5 {
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "000000"))
|
|
require.Equal(t, http.StatusUnauthorized, resp.Code, "initial wrong credentials must be rejected")
|
|
}
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "842716"))
|
|
assert.Equal(t, http.StatusTooManyRequests, resp.Code, "even correct credentials must wait for the block to expire")
|
|
assert.Equal(t, "900", resp.Header().Get("Retry-After"), "five failures must block the source for fifteen minutes")
|
|
assert.Empty(t, resp.Result().Cookies(), "blocked credentials must not issue a session")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCredentialAuthSessionAndClientIP(t *testing.T) {
|
|
keys := generateTestKeyPair(t)
|
|
token, err := sessionkey.SignToken(keys.PrivateKey, "pin-user", "", "example.com", proxyauth.MethodPIN, nil, nil, time.Hour)
|
|
require.NoError(t, err)
|
|
mw := NewMiddleware(nil, nil, nil)
|
|
now := time.Now()
|
|
mw.credentials.now = func() time.Time { return now }
|
|
scheme := &stubScheme{method: proxyauth.MethodPIN, promptID: "pin"}
|
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
|
|
handler := mw.Protect(newPassthroughHandler())
|
|
for range credentialFailureLimit {
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "000000"))
|
|
require.Equal(t, http.StatusUnauthorized, resp.Code, "bad PIN must consume the failure budget")
|
|
}
|
|
now = now.Add(credentialCheckInterval)
|
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
|
|
r := credentialRequest(http.MethodPost, "pin", "000000")
|
|
r.RemoteAddr = "[::ffff:198.51.100.25]:45678"
|
|
r.Header.Set("X-Forwarded-For", "192.0.2.5")
|
|
r.Header.Set("X-Real-IP", "192.0.2.6")
|
|
resp := httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, r)
|
|
assert.Equal(t, http.StatusTooManyRequests, resp.Code, "mapped addresses and untrusted forwarding headers must not bypass the source block")
|
|
assert.Equal(t, "no-store", resp.Header().Get("Cache-Control"), "rate limits must not be cached")
|
|
r.AddCookie(&http.Cookie{Name: proxyauth.SessionCookieName, Value: token})
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, r)
|
|
assert.Equal(t, http.StatusOK, resp.Code, "an existing session must pass even with credentials in the request")
|
|
assert.Equal(t, "backend", resp.Body.String(), "the authenticated request must reach the application")
|
|
r = credentialRequest(http.MethodPost, "pin", "000000")
|
|
cd := proxy.NewCapturedData("test")
|
|
cd.SetClientIP(netip.MustParseAddr("192.0.2.9"))
|
|
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, r)
|
|
assert.Equal(t, http.StatusUnauthorized, resp.Code, "a client resolved by the trusted-proxy middleware must get its own source budget")
|
|
r = credentialRequest(http.MethodPost, "pin", "000000")
|
|
r.RemoteAddr = "invalid"
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, r)
|
|
assert.Equal(t, http.StatusBadRequest, resp.Code, "an unresolvable client address must fail closed")
|
|
now = now.Add(credentialBlockDuration)
|
|
scheme.token = token
|
|
resp = httptest.NewRecorder()
|
|
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "842716"))
|
|
assert.Equal(t, http.StatusSeeOther, resp.Code, "credentials must work again after cooldown")
|
|
}
|
|
|
|
func TestCredentialAuthManagementThrottling(t *testing.T) {
|
|
s, err := status.New(codes.ResourceExhausted, "rate limited").WithDetails(&errdetails.RetryInfo{RetryDelay: durationpb.New(2500 * time.Millisecond)})
|
|
require.NoError(t, err)
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
code int
|
|
retry string
|
|
}{
|
|
{"retry info", fmt.Errorf("authenticate PIN: %w", s.Err()), http.StatusTooManyRequests, "3"},
|
|
{"missing retry info", status.Error(codes.ResourceExhausted, "rate limited"), http.StatusTooManyRequests, "6"},
|
|
{"unavailable", status.Error(codes.Unavailable, "unavailable"), http.StatusBadGateway, ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
keys := generateTestKeyPair(t)
|
|
mw := NewMiddleware(nil, nil, nil)
|
|
scheme := &stubScheme{method: proxyauth.MethodPIN, authFn: func(*http.Request) (string, string, error) { return "", "", tc.err }}
|
|
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
|
|
resp := httptest.NewRecorder()
|
|
mw.Protect(newPassthroughHandler()).ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "000000"))
|
|
assert.Equal(t, tc.code, resp.Code, "management errors must keep their HTTP meaning")
|
|
assert.Equal(t, tc.retry, resp.Header().Get("Retry-After"), "retry hints must round up to whole seconds")
|
|
})
|
|
}
|
|
}
|