541 lines
16 KiB
Go
541 lines
16 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/andydixon/relata/internal/domain"
|
|
"github.com/andydixon/relata/internal/ids"
|
|
)
|
|
|
|
// --- Fakes ------------------------------------------------------------------
|
|
|
|
type fakeTokens struct {
|
|
mu sync.Mutex
|
|
byID map[string]*domain.APIToken
|
|
byHash map[string]*domain.APIToken
|
|
}
|
|
|
|
func newFakeTokens() *fakeTokens {
|
|
return &fakeTokens{byID: map[string]*domain.APIToken{}, byHash: map[string]*domain.APIToken{}}
|
|
}
|
|
|
|
func (f *fakeTokens) Create(_ context.Context, t *domain.APIToken) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, clash := f.byHash[t.TokenHash]; clash {
|
|
return domain.ErrDuplicate
|
|
}
|
|
cp := *t
|
|
f.byID[t.ID] = &cp
|
|
f.byHash[t.TokenHash] = &cp
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTokens) GetByHash(_ context.Context, hash string) (*domain.APIToken, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
t, ok := f.byHash[hash]
|
|
if !ok {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
cp := *t
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeTokens) ListForUser(_ context.Context, ws, user string) ([]*domain.APIToken, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var out []*domain.APIToken
|
|
for _, t := range f.byID {
|
|
if t.WorkspaceID == ws && t.UserID == user {
|
|
cp := *t
|
|
out = append(out, &cp)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeTokens) Get(_ context.Context, ws, id string) (*domain.APIToken, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
t, ok := f.byID[id]
|
|
if !ok || t.WorkspaceID != ws {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
cp := *t
|
|
return &cp, nil
|
|
}
|
|
|
|
func (f *fakeTokens) Revoke(_ context.Context, ws, id, by string, at time.Time) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
t, ok := f.byID[id]
|
|
if !ok || t.WorkspaceID != ws {
|
|
return domain.ErrNotFound
|
|
}
|
|
t.RevokedAt, t.RevokedBy = &at, by
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTokens) TouchLastUsed(_ context.Context, id string, at time.Time) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if t, ok := f.byID[id]; ok {
|
|
t.LastUsedAt = &at
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTokens) DeleteForUser(_ context.Context, ws, user string) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
type fakeUsers struct {
|
|
mu sync.Mutex
|
|
byID map[string]*domain.User
|
|
bySub map[string]*domain.User
|
|
}
|
|
|
|
func newFakeUsers(users ...*domain.User) *fakeUsers {
|
|
f := &fakeUsers{byID: map[string]*domain.User{}, bySub: map[string]*domain.User{}}
|
|
for _, u := range users {
|
|
f.put(u)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// put stores a copy under both indexes.
|
|
//
|
|
// Storing a copy rather than the caller's pointer is what makes this fake
|
|
// behave like a database: a caller that keeps mutating the struct it passed in
|
|
// cannot reach into the store, and two callers never share memory. Handing back
|
|
// the live pointer produced a data race that no real repository could have.
|
|
func (f *fakeUsers) put(u *domain.User) {
|
|
cp := *u
|
|
f.byID[cp.ID] = &cp
|
|
if cp.OAuthSubject != "" {
|
|
f.bySub[cp.OAuthProvider+"|"+cp.OAuthSubject] = &cp
|
|
}
|
|
}
|
|
|
|
func (f *fakeUsers) Create(_ context.Context, u *domain.User) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if u.OAuthSubject != "" {
|
|
if _, clash := f.bySub[u.OAuthProvider+"|"+u.OAuthSubject]; clash {
|
|
return domain.ErrDuplicate
|
|
}
|
|
}
|
|
f.put(u)
|
|
return nil
|
|
}
|
|
func (f *fakeUsers) GetByUsername(_ context.Context, n string) (*domain.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
for _, u := range f.byID {
|
|
if u.Username == n {
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
}
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
func (f *fakeUsers) GetByID(_ context.Context, id string) (*domain.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.byID[id]
|
|
if !ok {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
func (f *fakeUsers) Update(_ context.Context, u *domain.User) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
// Both indexes must see the update. A real collection has one document; a
|
|
// fake whose indexes diverge would let a test pass on a change the database
|
|
// would never have made.
|
|
f.put(u)
|
|
return nil
|
|
}
|
|
func (f *fakeUsers) Count(context.Context) (int64, error) { return int64(len(f.byID)), nil }
|
|
func (f *fakeUsers) GetByOAuthSubject(_ context.Context, p, sub string) (*domain.User, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
u, ok := f.bySub[p+"|"+sub]
|
|
if !ok {
|
|
return nil, domain.ErrNotFound
|
|
}
|
|
cp := *u
|
|
return &cp, nil
|
|
}
|
|
|
|
// --- Helpers ----------------------------------------------------------------
|
|
|
|
func editorUser() *domain.User {
|
|
return &domain.User{
|
|
ID: ids.New(), Username: "editor", Role: domain.RoleEditor,
|
|
WorkspaceID: "ws-1", PasswordHash: "x",
|
|
}
|
|
}
|
|
|
|
func principalFor(u *domain.User) *domain.Principal {
|
|
return &domain.Principal{
|
|
UserID: u.ID, Username: u.Username, Role: u.Role, WorkspaceID: u.WorkspaceID,
|
|
}
|
|
}
|
|
|
|
func newTestTokenService(u *domain.User) (*TokenService, *fakeTokens) {
|
|
tk := newFakeTokens()
|
|
return NewTokenService(tk, newFakeUsers(u), TokenConfig{
|
|
DefaultTTL: 24 * time.Hour, MaxTTL: 72 * time.Hour,
|
|
RateBurst: 1000, RateWindow: time.Minute,
|
|
}), tk
|
|
}
|
|
|
|
// --- Tests ------------------------------------------------------------------
|
|
|
|
// The secret must never be recoverable from what is stored.
|
|
func TestOnlyTheHashIsStored(t *testing.T) {
|
|
u := editorUser()
|
|
svc, store := newTestTokenService(u)
|
|
|
|
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
|
|
Name: "script", Scopes: []domain.TokenScope{domain.ScopeQuery},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stored, err := store.GetByHash(context.Background(), ids.HashToken(issued.Secret))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(stored.TokenHash, issued.Secret) {
|
|
t.Fatal("the stored hash contains the secret")
|
|
}
|
|
if stored.Prefix == issued.Secret {
|
|
t.Fatal("the whole secret was stored as the display prefix")
|
|
}
|
|
if !strings.HasPrefix(issued.Secret, stored.Prefix) {
|
|
t.Fatal("the display prefix does not match the token it identifies")
|
|
}
|
|
if len(stored.Prefix) >= len(issued.Secret)/2 {
|
|
t.Fatalf("the display prefix is %d of %d characters; it must not be a usable fraction "+
|
|
"of the secret", len(stored.Prefix), len(issued.Secret))
|
|
}
|
|
}
|
|
|
|
// Two tokens must never collide, and each must be unguessable.
|
|
func TestTokensAreUniqueAndHighEntropy(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 500; i++ {
|
|
s, err := newTokenSecret()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seen[s] {
|
|
t.Fatal("two generated tokens were identical")
|
|
}
|
|
seen[s] = true
|
|
if !strings.HasPrefix(s, tokenPrefix) {
|
|
t.Fatalf("token %q lacks the version prefix", s)
|
|
}
|
|
if len(s)-len(tokenPrefix) < 40 {
|
|
t.Fatalf("token body is only %d characters", len(s)-len(tokenPrefix))
|
|
}
|
|
}
|
|
}
|
|
|
|
// A token may never carry more authority than the person who created it.
|
|
func TestScopesAreNarrowedToTheCreatorsRole(t *testing.T) {
|
|
viewer := &domain.User{ID: ids.New(), Username: "viewer", Role: domain.RoleViewer, WorkspaceID: "ws-1"}
|
|
svc, _ := newTestTokenService(viewer)
|
|
|
|
// Asking for everything as a viewer yields query only.
|
|
issued, err := svc.Create(context.Background(), principalFor(viewer), CreateRequest{
|
|
Name: "over-reaching",
|
|
Scopes: []domain.TokenScope{
|
|
domain.ScopeQuery, domain.ScopeIngest, domain.ScopeReview,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(issued.Token.Scopes) != 1 || issued.Token.Scopes[0] != domain.ScopeQuery {
|
|
t.Fatalf("a viewer was granted %v; only query is available to that role", issued.Token.Scopes)
|
|
}
|
|
|
|
// Asking for nothing available at all is refused rather than issuing an
|
|
// empty token that would fail confusingly on first use.
|
|
if _, err := svc.Create(context.Background(), principalFor(viewer), CreateRequest{
|
|
Name: "nothing", Scopes: []domain.TokenScope{domain.ScopeIngest},
|
|
}); err == nil {
|
|
t.Fatal("a token with no usable scopes was issued")
|
|
}
|
|
}
|
|
|
|
// The role is re-read on every request, so demoting someone takes effect at once.
|
|
func TestDemotingTheOwnerImmediatelyNarrowsTheToken(t *testing.T) {
|
|
u := editorUser()
|
|
users := newFakeUsers(u)
|
|
store := newFakeTokens()
|
|
svc := NewTokenService(store, users, TokenConfig{DefaultTTL: time.Hour, MaxTTL: time.Hour, RateBurst: 100, RateWindow: time.Minute})
|
|
|
|
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
|
|
Name: "ingester", Scopes: []domain.TokenScope{domain.ScopeIngest, domain.ScopeQuery},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
p, err := svc.Authenticate(context.Background(), issued.Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !p.Permits(domain.ScopeIngest) {
|
|
t.Fatal("an editor's ingest token was refused ingest")
|
|
}
|
|
|
|
// Demote. The token is untouched.
|
|
u.Role = domain.RoleViewer
|
|
_ = users.Update(context.Background(), u)
|
|
|
|
p2, err := svc.Authenticate(context.Background(), issued.Secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p2.Permits(domain.ScopeIngest) {
|
|
t.Fatal("the token still permits ingest after its owner was demoted to viewer")
|
|
}
|
|
if !p2.Permits(domain.ScopeQuery) {
|
|
t.Fatal("the token lost query, which a viewer still has")
|
|
}
|
|
}
|
|
|
|
// Every rejection must look the same to the caller.
|
|
func TestAuthenticateRefusesEverythingItShould(t *testing.T) {
|
|
u := editorUser()
|
|
users := newFakeUsers(u)
|
|
store := newFakeTokens()
|
|
svc := NewTokenService(store, users, TokenConfig{DefaultTTL: time.Hour, MaxTTL: time.Hour, RateBurst: 1000, RateWindow: time.Minute})
|
|
|
|
issue := func(t *testing.T) string {
|
|
t.Helper()
|
|
iss, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
|
|
Name: "t", Scopes: []domain.TokenScope{domain.ScopeQuery},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return iss.Secret
|
|
}
|
|
|
|
t.Run("garbage", func(t *testing.T) {
|
|
for _, bad := range []string{"", " ", "not-a-token", "Bearer x", tokenPrefix, "rlt_v1_"} {
|
|
if _, err := svc.Authenticate(context.Background(), bad); !errors.Is(err, ErrInvalidToken) {
|
|
t.Errorf("Authenticate(%q) = %v, want ErrInvalidToken", bad, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("revoked", func(t *testing.T) {
|
|
secret := issue(t)
|
|
p, _ := svc.Authenticate(context.Background(), secret)
|
|
if p == nil {
|
|
t.Fatal("the token did not work before revocation")
|
|
}
|
|
toks, _ := store.ListForUser(context.Background(), "ws-1", u.ID)
|
|
var id string
|
|
for _, tok := range toks {
|
|
if tok.TokenHash == ids.HashToken(secret) {
|
|
id = tok.ID
|
|
}
|
|
}
|
|
if err := svc.Revoke(context.Background(), principalFor(u), id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("a revoked token still authenticated: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("expired", func(t *testing.T) {
|
|
secret := issue(t)
|
|
toks, _ := store.ListForUser(context.Background(), "ws-1", u.ID)
|
|
for _, tok := range toks {
|
|
if tok.TokenHash == ids.HashToken(secret) {
|
|
store.mu.Lock()
|
|
store.byID[tok.ID].ExpiresAt = time.Now().Add(-time.Hour)
|
|
store.byHash[tok.TokenHash].ExpiresAt = time.Now().Add(-time.Hour)
|
|
store.mu.Unlock()
|
|
}
|
|
}
|
|
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("an expired token still authenticated: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("disabled owner", func(t *testing.T) {
|
|
secret := issue(t)
|
|
u.Disabled = true
|
|
_ = users.Update(context.Background(), u)
|
|
defer func() { u.Disabled = false; _ = users.Update(context.Background(), u) }()
|
|
|
|
if _, err := svc.Authenticate(context.Background(), secret); !errors.Is(err, ErrInvalidToken) {
|
|
t.Fatalf("a token belonging to a disabled account still authenticated: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A leaked token must not be able to mint replacements or revoke rivals.
|
|
func TestATokenCannotManageTokens(t *testing.T) {
|
|
u := editorUser()
|
|
svc, _ := newTestTokenService(u)
|
|
|
|
tokenPrincipal := &domain.Principal{
|
|
UserID: u.ID, Role: u.Role, WorkspaceID: u.WorkspaceID,
|
|
TokenID: "tok-1", TokenScopes: []domain.TokenScope{domain.ScopeIngest, domain.ScopeQuery},
|
|
}
|
|
|
|
if _, err := svc.Create(context.Background(), tokenPrincipal, CreateRequest{
|
|
Name: "child", Scopes: []domain.TokenScope{domain.ScopeQuery},
|
|
}); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Fatalf("a token created another token: %v", err)
|
|
}
|
|
if err := svc.Revoke(context.Background(), tokenPrincipal, "any"); !errors.Is(err, domain.ErrForbidden) {
|
|
t.Fatalf("a token revoked a token: %v", err)
|
|
}
|
|
}
|
|
|
|
// Bearer credentials come from the header and nowhere else.
|
|
func TestBearerIsReadOnlyFromTheAuthorizationHeader(t *testing.T) {
|
|
cases := []struct {
|
|
header string
|
|
want string
|
|
}{
|
|
{"Bearer abc123", "abc123"},
|
|
{"bearer abc123", "abc123"},
|
|
{"BEARER abc123", "abc123"},
|
|
{"Bearer abc123 ", "abc123"},
|
|
{"Basic abc123", ""},
|
|
{"abc123", ""},
|
|
{"Bearer", ""},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
r := httptestRequest(c.header)
|
|
if got := BearerFromRequest(r); got != c.want {
|
|
t.Errorf("BearerFromRequest(%q) = %q, want %q", c.header, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The rate limiter must actually stop a runaway token.
|
|
func TestTokenRateLimitBites(t *testing.T) {
|
|
u := editorUser()
|
|
users := newFakeUsers(u)
|
|
store := newFakeTokens()
|
|
svc := NewTokenService(store, users, TokenConfig{
|
|
DefaultTTL: time.Hour, MaxTTL: time.Hour,
|
|
RateBurst: 5, RateWindow: time.Minute,
|
|
})
|
|
|
|
issued, err := svc.Create(context.Background(), principalFor(u), CreateRequest{
|
|
Name: "busy", Scopes: []domain.TokenScope{domain.ScopeQuery},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var limited bool
|
|
for i := 0; i < 20; i++ {
|
|
if _, err := svc.Authenticate(context.Background(), issued.Secret); errors.Is(err, ErrTokenRateLimit) {
|
|
limited = true
|
|
break
|
|
}
|
|
}
|
|
if !limited {
|
|
t.Fatal("a token made 20 requests against a burst of 5 without being limited")
|
|
}
|
|
}
|
|
|
|
func httptestRequest(authHeader string) *http.Request {
|
|
r := httptest.NewRequest(http.MethodGet, "/api/v1/search", nil)
|
|
if authHeader != "" {
|
|
r.Header.Set("Authorization", authHeader)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// --- Client identification behind a proxy -----------------------------------
|
|
|
|
// The client key is what login rate limiting buckets on. A client that can
|
|
// choose its own key can have as many buckets as it likes, which is the same as
|
|
// having no rate limit at all.
|
|
func TestClientKeyCannotBeChosenByTheClient(t *testing.T) {
|
|
const realClient = "203.0.113.9"
|
|
|
|
newReq := func(headers map[string]string) *http.Request {
|
|
r := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
r.RemoteAddr = "10.0.0.5:34567" // the proxy
|
|
for k, v := range headers {
|
|
r.Header.Set(k, v)
|
|
}
|
|
return r
|
|
}
|
|
|
|
t.Run("X-Real-IP wins and cannot be overridden by a forged XFF", func(t *testing.T) {
|
|
// What nginx actually sends: X-Real-IP is $remote_addr, and XFF has the
|
|
// client's forged value appended with the real one.
|
|
r := newReq(map[string]string{
|
|
"X-Real-IP": realClient,
|
|
"X-Forwarded-For": "1.2.3.4, " + realClient,
|
|
})
|
|
if got := ClientKey(r, true); got != realClient {
|
|
t.Fatalf("ClientKey = %q, want the real client %q — a forged X-Forwarded-For "+
|
|
"must not choose the rate-limit bucket", got, realClient)
|
|
}
|
|
})
|
|
|
|
t.Run("without X-Real-IP the rightmost XFF entry is used", func(t *testing.T) {
|
|
r := newReq(map[string]string{"X-Forwarded-For": "1.2.3.4, 5.6.7.8, " + realClient})
|
|
if got := ClientKey(r, true); got != realClient {
|
|
t.Fatalf("ClientKey = %q, want %q — the trustworthy entry is the one the "+
|
|
"nearest proxy appended, not the one the client sent first", got, realClient)
|
|
}
|
|
})
|
|
|
|
t.Run("a hundred different forged values all land in one bucket", func(t *testing.T) {
|
|
seen := map[string]bool{}
|
|
for i := 0; i < 100; i++ {
|
|
r := newReq(map[string]string{
|
|
"X-Real-IP": realClient,
|
|
"X-Forwarded-For": fmt.Sprintf("10.%d.%d.%d, %s", i, i, i, realClient),
|
|
})
|
|
seen[ClientKey(r, true)] = true
|
|
}
|
|
if len(seen) != 1 {
|
|
t.Fatalf("100 forged headers produced %d distinct rate-limit buckets; "+
|
|
"login throttling would be trivially bypassed", len(seen))
|
|
}
|
|
})
|
|
|
|
t.Run("proxy headers are ignored entirely when not trusted", func(t *testing.T) {
|
|
r := newReq(map[string]string{"X-Real-IP": "1.2.3.4", "X-Forwarded-For": "1.2.3.4"})
|
|
if got := ClientKey(r, false); got != "10.0.0.5" {
|
|
t.Fatalf("ClientKey = %q, want the socket address; proxy headers must be "+
|
|
"ignored unless explicitly trusted", got)
|
|
}
|
|
})
|
|
}
|