155 lines
5.5 KiB
Go
155 lines
5.5 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
|
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
|
)
|
|
|
|
type fakeConsoleAuth struct {
|
|
valid bool
|
|
login consoleauth.LoginResult
|
|
loginError error
|
|
}
|
|
|
|
func (f *fakeConsoleAuth) Login(context.Context, string, string, string, string) (consoleauth.LoginResult, error) {
|
|
return f.login, f.loginError
|
|
}
|
|
func (f *fakeConsoleAuth) ValidateSession(context.Context, auth.Token) error {
|
|
if !f.valid {
|
|
return context.Canceled
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeConsoleAuth) RevokeSession(context.Context, auth.Token) error { return nil }
|
|
|
|
func TestConsoleBusinessPageRedirectsToLogin(t *testing.T) {
|
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
|
req := httptest.NewRequest(http.MethodGet, "/console/", nil)
|
|
res := httptest.NewRecorder()
|
|
server.Handler.ServeHTTP(res, req)
|
|
if res.Code != http.StatusFound || res.Header().Get("Location") != "/console/login?return=%2Fconsole%2F" {
|
|
t.Fatalf("redirect = %d %q", res.Code, res.Header().Get("Location"))
|
|
}
|
|
}
|
|
|
|
func TestConsoleSessionMiddlewareCopiesValidatedCookieToBearer(t *testing.T) {
|
|
ns, _ := core.NewID()
|
|
cred, _ := core.NewID()
|
|
raw, _, err := auth.Issue(ns, cred)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := &fakeConsoleAuth{valid: true}
|
|
handler := ConsoleSessionMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got := r.Header.Get("Authorization"); got != "Bearer "+raw {
|
|
t.Fatalf("authorization = %q", got)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}), store)
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/tasks", nil)
|
|
req.AddCookie(&http.Cookie{Name: consoleSessionCookie, Value: raw})
|
|
res := httptest.NewRecorder()
|
|
handler.ServeHTTP(res, req)
|
|
if res.Code != http.StatusNoContent {
|
|
t.Fatalf("status = %d", res.Code)
|
|
}
|
|
}
|
|
|
|
func TestConsoleLoginSetsRootScopedHttpOnlyCookie(t *testing.T) {
|
|
ns, _ := core.NewID()
|
|
cred, _ := core.NewID()
|
|
raw, _, err := auth.Issue(ns, cred)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{login: consoleauth.LoginResult{Token: raw, ExpiresAt: time.Now().Add(time.Hour)}})
|
|
req := httptest.NewRequest(http.MethodPost, "/console/auth/login", strings.NewReader(`{"username":"operator","password":"password-password"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
res := httptest.NewRecorder()
|
|
server.Handler.ServeHTTP(res, req)
|
|
if res.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", res.Code)
|
|
}
|
|
cookie := res.Result().Cookies()[0]
|
|
if cookie.Path != "/" || !cookie.HttpOnly {
|
|
t.Fatalf("cookie path/httpOnly = %q/%v", cookie.Path, cookie.HttpOnly)
|
|
}
|
|
}
|
|
|
|
func TestConsoleLoginRejectsWithRetryAndNoCookie(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
status int
|
|
code string
|
|
}{
|
|
{"password", fault.Unauthorized, 401, "UNAUTHENTICATED"},
|
|
{"locked", &consoleauth.LoginBlocked{Locked: true, RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_LOCKED"},
|
|
{"rate", &consoleauth.LoginBlocked{RetryAfter: 1500 * time.Millisecond}, 429, "LOGIN_RATE_LIMITED"},
|
|
{"storage", errors.New("private database error"), 503, "UNAVAILABLE"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{loginError: tc.err})
|
|
req := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"password-password"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
server.Handler.ServeHTTP(w, req)
|
|
if w.Code != tc.status || !strings.Contains(w.Body.String(), tc.code) || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private database") {
|
|
t.Fatalf("response: %d %s", w.Code, w.Body.String())
|
|
}
|
|
if w.Code == 429 && w.Header().Get("Retry-After") != "2" {
|
|
t.Fatal("missing rounded Retry-After")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type policyConsoleAuth struct {
|
|
fakeConsoleAuth
|
|
policy consoleauth.LoginPolicy
|
|
}
|
|
|
|
func (s *policyConsoleAuth) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
|
func TestLoginSourceOnlyTrustsConfiguredProxyChain(t *testing.T) {
|
|
s := &policyConsoleAuth{policy: consoleauth.DefaultLoginPolicy()}
|
|
s.policy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("::1/128")}
|
|
for _, tc := range []struct{ peer, forward, want string }{
|
|
{"192.0.2.1:1234", "198.51.100.7", "192.0.2.1"},
|
|
{"10.0.0.1:1234", "198.51.100.7, 10.0.0.2", "198.51.100.7"},
|
|
{"10.0.0.1:1234", "203.0.113.99, 198.51.100.7", "198.51.100.7"},
|
|
{"10.0.0.1:1234", "garbage, 10.0.0.2", "10.0.0.1"},
|
|
{"[::1]:1234", "::ffff:192.0.2.1", "192.0.2.1"},
|
|
} {
|
|
r := httptest.NewRequest("POST", "/console/auth/login", nil)
|
|
r.RemoteAddr = tc.peer
|
|
r.Header.Set("X-Forwarded-For", tc.forward)
|
|
r.Header.Set("X-Real-IP", "203.0.113.123")
|
|
if got := loginRemoteAddress(r, s); got != tc.want {
|
|
t.Fatalf("%s / %s: got %s", tc.peer, tc.forward, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConsoleLoginRejectsCrossSite(t *testing.T) {
|
|
server := NewWithConsoleAuth("127.0.0.1:0", nil, &fakeConsoleAuth{})
|
|
r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Origin", "https://evil.invalid")
|
|
w := httptest.NewRecorder()
|
|
server.Handler.ServeHTTP(w, r)
|
|
if w.Code != 403 || len(w.Result().Cookies()) != 0 {
|
|
t.Fatalf("cross-site response: %d", w.Code)
|
|
}
|
|
}
|