432 lines
17 KiB
Go
432 lines
17 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
func basicHeader(user, pass string) string {
|
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
|
}
|
|
|
|
func modeAPIKey(c *config.Config) {
|
|
c.API.Auth = config.Auth{
|
|
Mode: "apikey",
|
|
APIKeys: []string{"rw-key"},
|
|
ReadOnlyAPIKeys: []string{"ro-key"},
|
|
SessionTTL: config.Duration(time.Hour),
|
|
TrustLoopback: false,
|
|
}
|
|
}
|
|
|
|
func modeBasic(c *config.Config) {
|
|
c.API.Auth = config.Auth{
|
|
Mode: "basic",
|
|
Username: "admin",
|
|
Password: "s3cret",
|
|
SessionTTL: config.Duration(time.Hour),
|
|
TrustLoopback: false,
|
|
}
|
|
}
|
|
|
|
func modeOIDCWithAPIKey(c *config.Config) {
|
|
c.API.Auth = config.Auth{
|
|
Mode: "oidc", APIKeys: []string{"machine-key"},
|
|
OIDCIssuer: "https://users.example.test", OIDCAudience: "https://dns.example.test", OIDCClientID: "fancy-dns",
|
|
TrustLoopback: false, SessionTTL: config.Duration(time.Hour),
|
|
}
|
|
}
|
|
|
|
func TestOIDCModeKeepsAPIKeyLoginSessionsUsable(t *testing.T) {
|
|
h := newHarness(t, modeOIDCWithAPIKey)
|
|
res := h.do(http.MethodPost, "/api/auth/login", `{"api_key":"machine-key"}`)
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("API key login = %d: %s", res.status, res.body)
|
|
}
|
|
token, _ := res.json(t)["token"].(string)
|
|
if token == "" {
|
|
t.Fatal("login returned an empty bearer token")
|
|
}
|
|
res = h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token)
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("session bearer in OIDC mode = %d: %s", res.status, res.body)
|
|
}
|
|
}
|
|
|
|
// TestAuthModeAPIKey covers every way a request can present a key in apikey
|
|
// mode, and the invariant that matters: the right key always works and every
|
|
// other credential never does.
|
|
func TestAuthModeAPIKey(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
want int
|
|
wantName string
|
|
wantWrite bool
|
|
wantAuthMd string
|
|
}{
|
|
{name: "no credentials", header: "", want: http.StatusUnauthorized},
|
|
{name: "correct key", header: "X-API-Key: rw-key", want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
|
|
{name: "read-only key", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
|
|
{name: "wrong key", header: "X-API-Key: nope", want: http.StatusUnauthorized},
|
|
{name: "key prefix", header: "X-API-Key: rw-ke", want: http.StatusUnauthorized},
|
|
{name: "key with suffix", header: "X-API-Key: rw-keyx", want: http.StatusUnauthorized},
|
|
{name: "key case folded", header: "X-API-Key: RW-KEY", want: http.StatusUnauthorized},
|
|
{name: "read-only key keeps its label", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
|
|
// curl -u any:KEY is a common client convention and is supported.
|
|
{name: "key in the basic password field", header: "Authorization: " + basicHeader("any", "rw-key"), want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
|
|
{name: "wrong basic password", header: "Authorization: " + basicHeader("any", "nope"), want: http.StatusUnauthorized},
|
|
{name: "unsupported scheme", header: "Authorization: Digest whatever", want: http.StatusUnauthorized},
|
|
{name: "malformed authorization", header: "Authorization: Bearer", want: http.StatusUnauthorized},
|
|
{name: "unknown bearer token", header: "Authorization: Bearer deadbeef", want: http.StatusUnauthorized},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newHarness(t, modeAPIKey)
|
|
res := h.do(http.MethodGet, "/api/whoami", "", tc.header)
|
|
if res.status != tc.want {
|
|
t.Fatalf("GET /api/whoami with %q = %d, want %d: %s", tc.header, res.status, tc.want, res.body)
|
|
}
|
|
if tc.want != http.StatusOK {
|
|
return
|
|
}
|
|
who := res.json(t)
|
|
if who["name"] != tc.wantName {
|
|
t.Errorf("name = %v, want %v", who["name"], tc.wantName)
|
|
}
|
|
if who["can_write"] != tc.wantWrite {
|
|
t.Errorf("can_write = %v, want %v", who["can_write"], tc.wantWrite)
|
|
}
|
|
if who["auth_mode"] != tc.wantAuthMd {
|
|
t.Errorf("auth_mode = %v, want %v", who["auth_mode"], tc.wantAuthMd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthModeBasic covers the password login, the bearer session it issues and
|
|
// logout, plus the per-request basic form.
|
|
func TestAuthModeBasic(t *testing.T) {
|
|
h := newHarness(t, modeBasic)
|
|
|
|
// Wrong password is refused and does not issue a token.
|
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"wrong"}`)
|
|
if res.status != http.StatusUnauthorized {
|
|
t.Fatalf("bad password login = %d, want 401: %s", res.status, res.body)
|
|
}
|
|
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"nobody","password":"s3cret"}`)
|
|
if res.status != http.StatusUnauthorized {
|
|
t.Fatalf("bad username login = %d, want 401: %s", res.status, res.body)
|
|
}
|
|
|
|
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("login = %d: %s", res.status, res.body)
|
|
}
|
|
body := res.json(t)
|
|
token, _ := body["token"].(string)
|
|
if token == "" {
|
|
t.Fatalf("login returned no token: %s", res.body)
|
|
}
|
|
if body["mode"] != "basic" || body["read_only"] != false {
|
|
t.Errorf("login payload mode/read_only = %v/%v", body["mode"], body["read_only"])
|
|
}
|
|
|
|
// The token is a bearer credential for the whole API.
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
|
t.Errorf("bearer whoami = %d: %s", res.status, res.body)
|
|
}
|
|
// Basic auth also works per request, without a session.
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: "+basicHeader("admin", "s3cret")); res.status != http.StatusOK {
|
|
t.Errorf("basic whoami = %d: %s", res.status, res.body)
|
|
}
|
|
// An API key is not a credential in basic mode.
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusUnauthorized {
|
|
t.Errorf("api key in basic mode = %d, want 401", res.status)
|
|
}
|
|
|
|
// Logout revokes the session; the token must stop working.
|
|
if res := h.do(http.MethodPost, "/api/auth/logout", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
|
t.Fatalf("logout = %d: %s", res.status, res.body)
|
|
}
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusUnauthorized {
|
|
t.Errorf("revoked token = %d, want 401", res.status)
|
|
}
|
|
}
|
|
|
|
// TestSessionExpiry checks a session stops being accepted once it is past its
|
|
// TTL, so a stolen token is not valid forever.
|
|
func TestSessionExpiry(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
modeBasic(c)
|
|
c.API.Auth.SessionTTL = config.Duration(10 * time.Millisecond)
|
|
})
|
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("login = %d: %s", res.status, res.body)
|
|
}
|
|
token, _ := res.json(t)["token"].(string)
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
|
t.Fatalf("fresh session = %d: %s", res.status, res.body)
|
|
}
|
|
waitFor(t, time.Second, "the session to expire", func() bool {
|
|
return h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token).status == http.StatusUnauthorized
|
|
})
|
|
}
|
|
|
|
// TestAuthModeNone pins mode "none": nothing is required, and the caller runs
|
|
// as a local principal that may write.
|
|
func TestAuthModeNone(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
|
|
})
|
|
res := h.do(http.MethodGet, "/api/whoami", "")
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("unauthenticated whoami in mode none = %d: %s", res.status, res.body)
|
|
}
|
|
who := res.json(t)
|
|
if who["mode"] != "loopback" || who["can_write"] != true {
|
|
t.Errorf("whoami = %+v, want a writable loopback principal", who)
|
|
}
|
|
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: totally-wrong"); res.status != http.StatusOK {
|
|
t.Errorf("a rejected key in mode none = %d, want 200: the mode wins", res.status)
|
|
}
|
|
// Basic auth is not a configured scheme in this mode.
|
|
if res := h.do(http.MethodPost, "/api/auth/login", `{"username":"a","password":"b"}`); res.status != http.StatusUnauthorized {
|
|
t.Errorf("login in mode none = %d, want 401", res.status)
|
|
}
|
|
}
|
|
|
|
// TestLoginRequestValidation covers the login endpoint's own input checks.
|
|
func TestLoginRequestValidation(t *testing.T) {
|
|
h := newHarness(t, modeAPIKey)
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want int
|
|
}{
|
|
{"empty body", ``, http.StatusBadRequest},
|
|
{"no credentials", `{}`, http.StatusBadRequest},
|
|
{"malformed json", `{`, http.StatusBadRequest},
|
|
{"unknown field", `{"api_key":"rw-key","nope":1}`, http.StatusBadRequest},
|
|
{"wrong key", `{"api_key":"nope"}`, http.StatusUnauthorized},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res := h.do(http.MethodPost, "/api/auth/login", tc.body)
|
|
if res.status != tc.want {
|
|
t.Errorf("login %q = %d, want %d: %s", tc.body, res.status, tc.want, res.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReadOnlyKeyCannotWrite walks every mutating route with a read-only key:
|
|
// each one must be refused and none of them may reach its hook.
|
|
func TestReadOnlyKeyCannotWrite(t *testing.T) {
|
|
writes := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
hook string
|
|
}{
|
|
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
|
|
{"logs clear", http.MethodPost, "/api/logs/clear", "", "ClearLog"},
|
|
{"domain add", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, "AddDomain"},
|
|
{"domain delete", http.MethodDelete, "/api/domains/a.example.com", "", "DeleteDomain"},
|
|
{"blocklist refresh", http.MethodPost, "/api/blocklists/refresh", "", "RefreshBlocklists"},
|
|
{"blocklist toggle", http.MethodPost, "/api/blocklists/toggle", `{"name":"a","enabled":true}`, "ToggleBlocklist"},
|
|
{"clients put", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "PutACL"},
|
|
{"upstreams put", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, "PutUpstream"},
|
|
{"settings put", http.MethodPut, "/api/settings", settingsBody(t, nil), ""},
|
|
{"settings reload", http.MethodPost, "/api/settings/reload", "", ""},
|
|
}
|
|
for _, tc := range writes {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newHarness(t, modeAPIKey)
|
|
before := h.readConfig()
|
|
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: ro-key")
|
|
if res.status != http.StatusForbidden {
|
|
t.Fatalf("%s %s with a read-only key = %d, want 403: %s", tc.method, tc.path, res.status, res.body)
|
|
}
|
|
if got := res.json(t)["error"]; got != "this key is read-only" {
|
|
t.Errorf("error = %v, want \"this key is read-only\"", got)
|
|
}
|
|
if tc.hook != "" && h.fake.count(tc.hook) != 0 {
|
|
t.Errorf("refused request still reached hook %s", tc.hook)
|
|
}
|
|
if h.readConfig() != before {
|
|
t.Errorf("%s %s rewrote the config file", tc.method, tc.path)
|
|
}
|
|
// The same request with a read-write key is allowed, so the 403
|
|
// above is about the key and not about the payload.
|
|
if res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
|
t.Errorf("%s %s with a read-write key = %d: %s", tc.method, tc.path, res.status, res.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConfigWriteDisabled checks allow_config_write: false refuses the settings
|
|
// editor with a valid key, while the other mutations still work.
|
|
func TestConfigWriteDisabled(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
modeAPIKey(c)
|
|
c.API.AllowConfigWrite = false
|
|
})
|
|
before := h.readConfig()
|
|
res := h.do(http.MethodPut, "/api/settings", settingsBody(t, nil), "X-API-Key: rw-key")
|
|
if res.status != http.StatusForbidden {
|
|
t.Fatalf("settings put = %d, want 403: %s", res.status, res.body)
|
|
}
|
|
if got := res.json(t)["error"]; got != "config writes are disabled" {
|
|
t.Errorf("error = %v, want \"config writes are disabled\"", got)
|
|
}
|
|
if h.readConfig() != before {
|
|
t.Error("a refused settings write still rewrote the config file")
|
|
}
|
|
// The gate is specific to the config document.
|
|
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
|
|
t.Errorf("cache flush with config writes disabled = %d, want 200", res.status)
|
|
}
|
|
}
|
|
|
|
// TestTrustLoopback checks trust_loopback lets a local client through with no
|
|
// credentials at all, and that a *wrong* credential is still refused: the
|
|
// loopback exemption is a fallback, not an override.
|
|
func TestTrustLoopback(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
modeAPIKey(c)
|
|
c.API.Auth.TrustLoopback = true
|
|
})
|
|
res := h.do(http.MethodGet, "/api/whoami", "")
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("loopback whoami without a key = %d, want 200: %s", res.status, res.body)
|
|
}
|
|
// A key that does not match is not accepted as a key: the loopback
|
|
// exemption is the fallback for a request with no usable credential, so a
|
|
// local client lands on the loopback principal rather than a named one.
|
|
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusOK {
|
|
t.Fatalf("loopback with a wrong key = %d, want 200: %s", res.status, res.body)
|
|
} else if name := res.json(t)["name"]; name != "loopback" {
|
|
t.Errorf("a wrong key on loopback ran as %v, want the loopback principal", name)
|
|
}
|
|
// Without trust_loopback the same request is refused.
|
|
hNoTrust := newHarness(t, modeAPIKey)
|
|
if res := hNoTrust.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusUnauthorized {
|
|
t.Errorf("wrong key without trust_loopback = %d, want 401", res.status)
|
|
}
|
|
// A trusted proxy in front of a loopback client does not extend the
|
|
// exemption to the forwarded address, which is not loopback.
|
|
h2 := newHarness(t, func(c *config.Config) {
|
|
modeAPIKey(c)
|
|
c.API.Auth.TrustLoopback = true
|
|
c.API.TrustedProxies = []string{"127.0.0.0/8"}
|
|
})
|
|
res = h2.do(http.MethodGet, "/api/whoami", "", "X-Forwarded-For: 198.51.100.7")
|
|
if res.status != http.StatusUnauthorized {
|
|
t.Errorf("forwarded non-loopback client = %d, want 401", res.status)
|
|
}
|
|
}
|
|
|
|
// TestAPIKeysAreNotStoredInPlaintext pins the hashing: the authenticator keys
|
|
// its maps on the SHA-256 of the credential, so the configured key never sits
|
|
// in the long-lived structure as itself.
|
|
func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
|
|
a := newAuthenticator(config.Auth{
|
|
Mode: "apikey",
|
|
APIKeys: []string{"rw-key"},
|
|
ReadOnlyAPIKeys: []string{"ro-key"},
|
|
Username: "admin",
|
|
Password: "s3cret",
|
|
SessionTTL: config.Duration(time.Hour),
|
|
})
|
|
if label, ok := a.readWrite[hashKey("rw-key")]; !ok || label != "rw-1" {
|
|
t.Errorf("readWrite is not keyed on the key hash: %v", a.readWrite)
|
|
}
|
|
if label, ok := a.readOnly[hashKey("ro-key")]; !ok || label != "ro-1" {
|
|
t.Errorf("readOnly is not keyed on the key hash: %v", a.readOnly)
|
|
}
|
|
if a.passwordHash == "s3cret" || a.passwordHash != hashKey("s3cret") {
|
|
t.Errorf("passwordHash = %q, want the sha256 of the password", a.passwordHash)
|
|
}
|
|
for _, stored := range append(mapKeys(a.readWrite), mapKeys(a.readOnly)...) {
|
|
if stored == "rw-key" || stored == "ro-key" || stored == "s3cret" {
|
|
t.Errorf("a credential is stored in plaintext: %q", stored)
|
|
}
|
|
if len(stored) != 64 {
|
|
t.Errorf("stored form %q is not a hex sha256", stored)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mapKeys(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestAPIModeReported checks AuthMode/whoami report the configured mode, which
|
|
// is what the console uses to decide whether to offer a login form.
|
|
func TestAPIModeReported(t *testing.T) {
|
|
cases := []struct {
|
|
mode string
|
|
want string
|
|
}{
|
|
{"apikey", "apikey"},
|
|
{"basic", "basic"},
|
|
{"none", "none"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.mode, func(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
modeAPIKey(c)
|
|
if tc.mode == "basic" {
|
|
modeBasic(c)
|
|
} else if tc.mode == "none" {
|
|
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
|
|
}
|
|
})
|
|
if got := h.srv.AuthMode(); got != tc.want {
|
|
t.Errorf("AuthMode() = %q, want %q", got, tc.want)
|
|
}
|
|
auth := "X-API-Key: rw-key"
|
|
if tc.mode == "basic" {
|
|
auth = "Authorization: " + basicHeader("admin", "s3cret")
|
|
} else if tc.mode == "none" {
|
|
auth = ""
|
|
}
|
|
res := h.do(http.MethodGet, "/api/whoami", "", auth)
|
|
if res.status != http.StatusOK {
|
|
t.Fatalf("whoami = %d: %s", res.status, res.body)
|
|
}
|
|
if got := res.json(t)["auth_mode"]; got != tc.want {
|
|
t.Errorf("whoami auth_mode = %v, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBasicAuthDisabledWithoutPassword checks basic mode with no configured
|
|
// password refuses rather than accepting an empty one.
|
|
func TestBasicAuthDisabledWithoutPassword(t *testing.T) {
|
|
h := newHarness(t, func(c *config.Config) {
|
|
c.API.Auth = config.Auth{Mode: "basic", Username: "admin", SessionTTL: config.Duration(time.Hour)}
|
|
})
|
|
if err := h.cfg.Validate(); err != nil && !strings.Contains(err.Error(), "username") {
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
}
|
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":""}`)
|
|
if res.status != http.StatusUnauthorized {
|
|
t.Errorf("login with no password configured = %d, want 401: %s", res.status, res.body)
|
|
}
|
|
}
|