fix: restore jwt login and control-plane checks
This commit is contained in:
@@ -9,6 +9,9 @@ kind: ClusterRole
|
||||
metadata:
|
||||
name: fission-console
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["fission.io"]
|
||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||
@@ -46,7 +49,7 @@ spec:
|
||||
serviceAccountName: fission-console
|
||||
containers:
|
||||
- name: console
|
||||
image: naeel/fission-console:v0.8.16
|
||||
image: naeel/fission-console@sha256:765fecf927e64ca50fffbb1bf50169ab318ab7bb2e0878493dd2b3aaf11989c3
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
|
||||
@@ -37,37 +37,32 @@ func (s *Server) userNS(r *http.Request) string {
|
||||
func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var ns string
|
||||
|
||||
if s.testMode {
|
||||
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
|
||||
if sub == "" {
|
||||
sub = strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
}
|
||||
if sub == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "test mode: X-Test-Sub required")
|
||||
return
|
||||
}
|
||||
h32 := sha256.Sum256([]byte(sub))
|
||||
ns = "fission-" + hex.EncodeToString(h32[:8])
|
||||
} else {
|
||||
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
env := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Auth-Env")))
|
||||
if _, ok := deckAPIs[env]; !ok {
|
||||
env = "test"
|
||||
}
|
||||
if token == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if err := s.validateDeckToken(token, env); err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
ns, err = namespaceFromJWT(token)
|
||||
|
||||
if s.testMode {
|
||||
sub := strings.TrimSpace(r.Header.Get("X-Test-Sub"))
|
||||
if sub != "" {
|
||||
ns = namespaceFromSub(sub)
|
||||
} else {
|
||||
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
resolvedNS, err := s.resolveNamespaceForToken(token, env, true)
|
||||
if err != nil {
|
||||
ns = s.ns
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
ns = resolvedNS
|
||||
}
|
||||
} else {
|
||||
token := strings.TrimSpace(r.Header.Get("X-Auth-Token"))
|
||||
resolvedNS, err := s.resolveNamespaceForToken(token, env, false)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
ns = resolvedNS
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxKeyNS{}, ns)
|
||||
@@ -83,6 +78,35 @@ func (s *Server) authMiddleware(h http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func namespaceFromSub(sub string) string {
|
||||
h32 := sha256.Sum256([]byte(sub))
|
||||
return "fission-" + hex.EncodeToString(h32[:8])
|
||||
}
|
||||
|
||||
func (s *Server) resolveNamespaceForToken(token, env string, allowTestSub bool) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
if allowTestSub {
|
||||
return "", fmt.Errorf("test mode: X-Test-Sub required")
|
||||
}
|
||||
return "", fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
if err := s.validateDeckToken(token, env); err == nil {
|
||||
ns, nsErr := namespaceFromJWT(token)
|
||||
if nsErr != nil {
|
||||
return s.ns, nil
|
||||
}
|
||||
return ns, nil
|
||||
}
|
||||
|
||||
if allowTestSub && strings.Contains(token, "@") {
|
||||
return namespaceFromSub(token), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// namespaceFromJWT декодирует JWT payload (без верификации подписи),
|
||||
// извлекает claim "sub" и вычисляет namespace: "fission-" + hex(SHA256(sub)[:8]).
|
||||
//
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const validJWTForTests = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItMTIzIn0.signature"
|
||||
|
||||
func TestResolveNamespaceForTokenRejectsInvalidTokenOutsideTestMode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/index.cfm/instances" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldDeckAPIs := deckAPIs
|
||||
deckAPIs = map[string]string{"test": server.URL}
|
||||
defer func() { deckAPIs = oldDeckAPIs }()
|
||||
|
||||
s := &Server{http: server.Client()}
|
||||
if _, err := s.resolveNamespaceForToken("user@example.com", "test", false); err == nil {
|
||||
t.Fatal("expected invalid token to be rejected outside test mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNamespaceForTokenAllowsValidatedJWTInTestMode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got == "" {
|
||||
t.Fatal("expected Authorization header")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldDeckAPIs := deckAPIs
|
||||
deckAPIs = map[string]string{"test": server.URL}
|
||||
defer func() { deckAPIs = oldDeckAPIs }()
|
||||
|
||||
s := &Server{http: server.Client()}
|
||||
ns, err := s.resolveNamespaceForToken(validJWTForTests, "test", true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected JWT to pass in test mode: %v", err)
|
||||
}
|
||||
|
||||
expected, err := namespaceFromJWT(validJWTForTests)
|
||||
if err != nil {
|
||||
t.Fatalf("namespaceFromJWT: %v", err)
|
||||
}
|
||||
if ns != expected {
|
||||
t.Fatalf("expected namespace %q, got %q", expected, ns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveNamespaceForTokenAllowsEmailFallbackOnlyInTestMode(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldDeckAPIs := deckAPIs
|
||||
deckAPIs = map[string]string{"test": server.URL}
|
||||
defer func() { deckAPIs = oldDeckAPIs }()
|
||||
|
||||
s := &Server{http: server.Client()}
|
||||
ns, err := s.resolveNamespaceForToken("user@example.com", "test", true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected email fallback in test mode: %v", err)
|
||||
}
|
||||
if ns != namespaceFromSub("user@example.com") {
|
||||
t.Fatalf("unexpected namespace: %q", ns)
|
||||
}
|
||||
|
||||
_, err = s.resolveNamespaceForToken("user@example.com", "test", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected email fallback to be rejected outside test mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeckTokenCachesSuccessfulValidation(t *testing.T) {
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"results":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldDeckAPIs := deckAPIs
|
||||
deckAPIs = map[string]string{"test": server.URL}
|
||||
defer func() { deckAPIs = oldDeckAPIs }()
|
||||
|
||||
s := &Server{http: server.Client()}
|
||||
if err := s.validateDeckToken(validJWTForTests, "test"); err != nil {
|
||||
t.Fatalf("first validate failed: %v", err)
|
||||
}
|
||||
if err := s.validateDeckToken(validJWTForTests, "test"); err != nil {
|
||||
t.Fatalf("second validate failed: %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("expected 1 upstream request because of cache, got %d", requests)
|
||||
}
|
||||
|
||||
if v, ok := s.tokenCache.Load("test:" + validJWTForTests); !ok || time.Now().After(v.(time.Time)) {
|
||||
t.Fatal("expected token to be cached")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -659,26 +658,10 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
env = "test"
|
||||
}
|
||||
|
||||
var ns string
|
||||
if s.testMode {
|
||||
// testMode: токен — это email (sub), Deck не вызывается
|
||||
if !strings.Contains(body.Token, "@") {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
h32 := sha256.Sum256([]byte(body.Token))
|
||||
ns = "fission-" + fmt.Sprintf("%x", h32[:8])
|
||||
} else {
|
||||
if err := s.validateDeckToken(body.Token, env); err != nil {
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
ns, err = namespaceFromJWT(body.Token)
|
||||
ns, err := s.resolveNamespaceForToken(body.Token, env, s.testMode)
|
||||
if err != nil {
|
||||
log.Printf("handleAuth: namespaceFromJWT: %v", err)
|
||||
ns = s.ns
|
||||
}
|
||||
writeJSONError(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
|
||||
@@ -6,6 +6,9 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["namespaces", "serviceaccounts", "resourcequotas", "limitranges"]
|
||||
verbs: ["get", "create", "list"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list"]
|
||||
- apiGroups: ["fission.io"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
|
||||
@@ -51,6 +51,15 @@ auth_call() {
|
||||
-d "{\"token\":\"${token}\",\"env\":\"${env_name}\"}"
|
||||
}
|
||||
|
||||
auth_get() {
|
||||
local path="$1"
|
||||
local token="$2"
|
||||
local env_name="${3:-test}"
|
||||
curl -s -w "\n%{http_code}" --max-time 45 "${BASE}${path}" \
|
||||
-H "X-Auth-Token: ${token}" \
|
||||
-H "X-Auth-Env: ${env_name}"
|
||||
}
|
||||
|
||||
create_call() {
|
||||
local sub="$1"
|
||||
local body="$2"
|
||||
@@ -112,6 +121,67 @@ wait_invoke_ok() {
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_init_ready() {
|
||||
local token="$1"
|
||||
local env_name="${2:-test}"
|
||||
local tries="$3"
|
||||
local delay="$4"
|
||||
local try debug_raw debug_code debug_body status_raw status_code status_body ready done dbg_ready exec_ready exec_pods router_ready router_pods
|
||||
for try in $(seq 1 "$tries"); do
|
||||
debug_raw=$(auth_get "/ns/debug" "$token" "$env_name")
|
||||
debug_code=$(printf '%s' "$debug_raw" | tail -1)
|
||||
debug_body=$(printf '%s' "$debug_raw" | sed '$d')
|
||||
status_raw=$(auth_get "/ns/status" "$token" "$env_name")
|
||||
status_code=$(printf '%s' "$status_raw" | tail -1)
|
||||
status_body=$(printf '%s' "$status_raw" | sed '$d')
|
||||
ready=$(json_field "$status_body" ready)
|
||||
done=$(printf '%s' "$status_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(sum(1 for s in data.get("stages", []) if s.get("done")))
|
||||
except Exception:
|
||||
print(0)')
|
||||
dbg_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(str(data.get("controlPlane", {}).get("ready", "")))
|
||||
except Exception:
|
||||
print("")')
|
||||
exec_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(str(data.get("controlPlane", {}).get("executor", {}).get("ready", "")))
|
||||
except Exception:
|
||||
print("")')
|
||||
exec_pods=$(printf '%s' "$debug_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(data.get("controlPlane", {}).get("executor", {}).get("podCount", ""))
|
||||
except Exception:
|
||||
print("")')
|
||||
router_ready=$(printf '%s' "$debug_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(str(data.get("controlPlane", {}).get("router", {}).get("ready", "")))
|
||||
except Exception:
|
||||
print("")')
|
||||
router_pods=$(printf '%s' "$debug_body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
print(data.get("controlPlane", {}).get("router", {}).get("podCount", ""))
|
||||
except Exception:
|
||||
print("")')
|
||||
echo " INIT_TRY=${try} status_http=${status_code} debug_http=${debug_code} ready=${ready} done=${done}/3 cp_ready=${dbg_ready} executor_ready=${exec_ready} executor_pods=${exec_pods} router_ready=${router_ready} router_pods=${router_pods}"
|
||||
if [ "$status_code" = "200" ] && [ "$debug_code" = "200" ] && [ "$ready" = "True" -o "$ready" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$try" -lt "$tries" ]; then
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " UI-like user flow tests"
|
||||
@@ -133,6 +203,14 @@ NS=$(json_field "$BODY" namespace)
|
||||
[ "$CODE" = "200" ] && pass "login valid token -> 200" || fail "login valid token -> got $CODE"
|
||||
printf '%s' "$NS" | grep -q '^fission-' && pass "login returns namespace" || fail "login namespace malformed: ${NS}"
|
||||
|
||||
echo ""
|
||||
echo ">>> T02b: первый вход ждёт init overlay до 3/3"
|
||||
if wait_init_ready "$LOGIN_SUB" "test" 12 5; then
|
||||
pass "init overlay path -> namespace ready 3/3"
|
||||
else
|
||||
fail "init overlay path did not reach 3/3"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo ">>> T03: ошибки create, которые типичны для UI"
|
||||
R=$(create_call "$NEG_SUB" '{"name":"","language":"python","code":"def main():\n return 1"}')
|
||||
|
||||
Reference in New Issue
Block a user