feat(auth): complete sso subsystem integration

This commit is contained in:
mac
2026-07-16 15:52:56 +08:00
parent f8c389606f
commit eb2621d914
24 changed files with 1977 additions and 111 deletions
+6 -2
View File
@@ -53,8 +53,12 @@ func (s *WayenService) Login(email, username string) (*WayenLoginResult, error)
if email == "" {
return nil, ErrWayenEmailMissing
}
if strings.TrimSpace(s.cfg.OAuthRedirectURI) != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
target, err := s.oauthLoginURL(s.cfg.OAuthRedirectURI, s.cfg.WayenTargetURL)
oauthLoginURL := strings.TrimSpace(s.cfg.WayenOAuthLoginURL)
if oauthLoginURL == "" {
oauthLoginURL = strings.TrimSpace(s.cfg.OAuthRedirectURI)
}
if oauthLoginURL != "" && strings.TrimSpace(s.cfg.WayenTargetURL) != "" {
target, err := s.oauthLoginURL(oauthLoginURL, s.cfg.WayenTargetURL)
if err != nil {
return nil, err
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"net/url"
"testing"
"github.com/1024XEngineer/xinfra/server/internal/config"
)
func TestWayenLoginUsesDedicatedOAuthLoginURL(t *testing.T) {
service := NewWayenService(config.Config{
WayenOAuthLoginURL: "http://218.11.5.223:32000/login/oauth2/oauth2",
OAuthRedirectURI: "http://218.11.5.223:30008/login/oauth2/oauth2",
WayenTargetURL: "http://218.11.5.223:32000/",
WayenOAuthRef: "/portal/namespace/1/app",
}, nil)
result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
parsed, err := url.Parse(result.TargetURL)
if err != nil {
t.Fatalf("invalid target url: %v", err)
}
if parsed.Host != "218.11.5.223:32000" {
t.Fatalf("target host = %q, want Wayne frontend 32000", parsed.Host)
}
if parsed.Path != "/login/oauth2/oauth2" {
t.Fatalf("target path = %q", parsed.Path)
}
next := parsed.Query().Get("next")
if next == "" {
t.Fatal("next is empty")
}
parsedNext, err := url.Parse(next)
if err != nil {
t.Fatalf("invalid next url: %v", err)
}
if parsedNext.Host != "218.11.5.223:32000" {
t.Fatalf("next host = %q, want Wayne frontend 32000", parsedNext.Host)
}
if parsedNext.Path != "/sign-in" {
t.Fatalf("next path = %q, want /sign-in", parsedNext.Path)
}
if parsedNext.Query().Get("ref") != "/portal/namespace/1/app" {
t.Fatalf("next ref = %q", parsedNext.Query().Get("ref"))
}
}
func TestWayenLoginFallsBackToOAuthRedirectURI(t *testing.T) {
service := NewWayenService(config.Config{
OAuthRedirectURI: "http://218.11.5.223:32000/login/oauth2/oauth2",
WayenTargetURL: "http://218.11.5.223:32000/",
}, nil)
result, err := service.Login("eastsales@qiniu.com", "eastsales@qiniu.com")
if err != nil {
t.Fatalf("Login() error = %v", err)
}
parsed, err := url.Parse(result.TargetURL)
if err != nil {
t.Fatalf("invalid target url: %v", err)
}
if parsed.Host != "218.11.5.223:32000" {
t.Fatalf("target host = %q, want fallback redirect host", parsed.Host)
}
}
@@ -0,0 +1,250 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
)
var (
ErrWayneRoleBindingNotConfigured = errors.New("wayne internal role binding api is not configured")
ErrWayneRoleBindingRequestFailed = errors.New("wayne internal role binding request failed")
)
type WayneRoleBindingRequest struct {
GroupIDs []uint64 `json:"groupIds,omitempty"`
OperatorUserID *uint64 `json:"operatorUserId,omitempty"`
OperatorName string `json:"operatorName,omitempty"`
Replace *bool `json:"replace,omitempty"`
RequestID string `json:"requestId,omitempty"`
Reason string `json:"reason,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
type WayneRoleBindingResult struct {
StatusCode int
ContentType string
Body []byte
}
type WayneRoleBindingHTTPError struct {
StatusCode int
Body []byte
}
func (e *WayneRoleBindingHTTPError) Error() string {
body := strings.TrimSpace(string(e.Body))
if body == "" {
return fmt.Sprintf("%s: status %d", ErrWayneRoleBindingRequestFailed, e.StatusCode)
}
if len(body) > 512 {
body = body[:512]
}
return fmt.Sprintf("%s: status %d: %s", ErrWayneRoleBindingRequestFailed, e.StatusCode, body)
}
type WayneRoleBindingService struct {
cfg config.Config
client *http.Client
now func() time.Time
}
func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
return &WayneRoleBindingService{
cfg: cfg,
client: &http.Client{
Timeout: 10 * time.Second,
},
now: time.Now,
}
}
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
}
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
return s.callRaw(ctx, http.MethodGet, "/api/v1/internal/namespaces", nil)
}
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
internalPath := "/api/v1/internal/groups"
if groupType != nil {
values := url.Values{}
values.Set("type", strconv.Itoa(*groupType))
internalPath += "?" + values.Encode()
}
return s.callRaw(ctx, http.MethodGet, internalPath, nil)
}
func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) {
username = strings.TrimSpace(username)
if username == "" {
return nil, ErrWayenEmailMissing
}
return s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/internal/users/%s/roles", url.PathEscape(username)), nil)
}
func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
}
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail)
}
func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
return nil, err
}
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing
}
req.OperatorUserID = nil
req.OperatorName = operatorEmail
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
return s.callRaw(ctx, method, internalPath, body)
}
func (s *WayneRoleBindingService) operatorPermissions(ctx context.Context, internalPath, operatorEmail string) (*WayneRoleBindingResult, error) {
operatorEmail = strings.TrimSpace(operatorEmail)
if operatorEmail == "" {
return nil, ErrWayenEmailMissing
}
values := url.Values{}
values.Set("operatorName", operatorEmail)
return s.callRaw(ctx, http.MethodGet, internalPath+"?"+values.Encode(), nil)
}
func (s *WayneRoleBindingService) callRaw(ctx context.Context, method, internalPath string, body []byte) (*WayneRoleBindingResult, error) {
if err := s.validateConfig(); err != nil {
return nil, err
}
if body == nil {
body = []byte{}
}
target, signingURI, err := s.requestURL(internalPath)
if err != nil {
return nil, err
}
log.Printf(
"wayne role binding request: method=%s target=%s signing_uri=%s body_bytes=%d service_name=%s secret_configured=%t",
method,
target,
signingURI,
len(body),
s.cfg.WayneServiceName,
strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != "",
)
httpReq, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
if len(body) > 0 {
httpReq.Header.Set("Content-Type", "application/json")
}
httpReq.Header.Set("Accept", "application/json")
headers, err := wayne.BuildSignedHeaders(s.cfg.WayneServiceName, s.cfg.WayneServiceAPISecretKey, method, signingURI, body, s.now())
if err != nil {
return nil, err
}
headers.Apply(httpReq)
resp, err := s.client.Do(httpReq)
if err != nil {
log.Printf("wayne role binding request failed: method=%s target=%s error=%v", method, target, err)
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
result := &WayneRoleBindingResult{
StatusCode: resp.StatusCode,
ContentType: resp.Header.Get("Content-Type"),
Body: respBody,
}
log.Printf(
"wayne role binding response: method=%s target=%s status=%d content_type=%q body=%q",
method,
target,
resp.StatusCode,
result.ContentType,
truncateForDebugLog(string(respBody), 512),
)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
return result, &WayneRoleBindingHTTPError{StatusCode: resp.StatusCode, Body: respBody}
}
return result, nil
}
func (s *WayneRoleBindingService) validateConfig() error {
baseConfigured := strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL) != ""
serviceConfigured := strings.TrimSpace(s.cfg.WayneServiceName) != ""
secretConfigured := strings.TrimSpace(s.cfg.WayneServiceAPISecretKey) != ""
if !baseConfigured || !serviceConfigured || !secretConfigured {
log.Printf(
"wayne role binding config invalid: base_url_configured=%t service_name_configured=%t secret_configured=%t",
baseConfigured,
serviceConfigured,
secretConfigured,
)
return ErrWayneRoleBindingNotConfigured
}
return nil
}
func (s *WayneRoleBindingService) requestURL(internalPath string) (string, string, error) {
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(s.cfg.WayneInternalAPIBaseURL), "/"))
if err != nil {
return "", "", err
}
if base.Scheme == "" || base.Host == "" {
return "", "", fmt.Errorf("invalid wayne internal api base url: %s", s.cfg.WayneInternalAPIBaseURL)
}
path, rawQuery, _ := strings.Cut(internalPath, "?")
base.Path = strings.TrimRight(base.Path, "/") + path
base.RawQuery = rawQuery
return base.String(), base.RequestURI(), nil
}
func truncateForDebugLog(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}
@@ -0,0 +1,248 @@
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/wayne"
)
func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testing.T) {
var requestPath string
var payload WayneRoleBindingRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestPath = r.URL.RequestURI()
body := readTestBody(t, r)
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid signature headers: %#v body=%s", r.Header, string(body))
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("invalid request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
svc.now = func() time.Time { return time.Unix(1721000000, 0) }
operatorUserID := uint64(123)
replace := false
result, err := svc.BindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{
GroupIDs: []uint64{10, 11},
OperatorUserID: &operatorUserID,
OperatorName: "attacker@example.com",
Replace: &replace,
RequestID: "req-001",
Reason: "grant",
})
if err != nil {
t.Fatalf("BindNamespace() error = %v", err)
}
if result.StatusCode != http.StatusOK {
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
}
if requestPath != "/api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles" {
t.Fatalf("requestPath = %q", requestPath)
}
if payload.OperatorName != "eastsales@qiniu.com" {
t.Fatalf("OperatorName = %q, want token email", payload.OperatorName)
}
if payload.OperatorUserID != nil {
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
}
if payload.Replace == nil || *payload.Replace {
t.Fatalf("Replace = %v, want false", payload.Replace)
}
}
func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
tests := []struct {
name string
call func(*WayneRoleBindingService) (*WayneRoleBindingResult, error)
want string
}{
{
name: "unbind namespace",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
},
want: "DELETE /api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles",
},
{
name: "bind app",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "PUT /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
},
{
name: "unbind app",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.UnbindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
},
want: "DELETE /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
},
{
name: "list namespaces",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListNamespaces(context.Background())
},
want: "GET /api/v1/internal/namespaces",
},
{
name: "list namespace groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
groupType := 1
return s.ListGroups(context.Background(), &groupType)
},
want: "GET /api/v1/internal/groups?type=1",
},
{
name: "list all groups",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.ListGroups(context.Background(), nil)
},
want: "GET /api/v1/internal/groups",
},
{
name: "get user roles",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.GetUserRoles(context.Background(), "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/users/eastsales@qiniu.com/roles",
},
{
name: "namespace operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com",
},
{
name: "app operator permissions",
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
return s.AppOperatorPermissions(context.Background(), 3, "eastsales@qiniu.com")
},
want: "GET /api/v1/internal/apps/3/operator-permissions?operatorName=eastsales%40qiniu.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Method + " " + r.URL.RequestURI()
_, _ = w.Write([]byte(`{"data":{"changed":true}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := tt.call(svc); err != nil {
t.Fatalf("call error = %v", err)
}
if got != tt.want {
t.Fatalf("got endpoint %q, want %q", got, tt.want)
}
})
}
}
func TestWayneRoleBindingServiceQuerySignsEmptyBodyAndQueryURI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if len(body) != 0 {
t.Fatalf("GET body length = %d, want 0", len(body))
}
if r.URL.RequestURI() != "/api/v1/internal/groups?type=1" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid GET signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
groupType := 1
if _, err := svc.ListGroups(context.Background(), &groupType); err != nil {
t.Fatalf("ListGroups() error = %v", err)
}
}
func TestWayneRoleBindingServiceOperatorPermissionsSignsQueryURI(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := readTestBody(t, r)
if r.URL.RequestURI() != "/api/v1/internal/namespaces/1/operator-permissions?operatorName=eastsales%40qiniu.com" {
t.Fatalf("RequestURI = %q", r.URL.RequestURI())
}
if !wayne.Verify("service-secret", r.Header.Get(wayne.HeaderSignature), r.Method, r.URL.RequestURI(), r.Header.Get(wayne.HeaderTimestamp), r.Header.Get(wayne.HeaderNonce), body) {
t.Fatalf("invalid operator permissions signature headers: %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":{"permissions":{"create":true,"update":true,"delete":false}}}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
if _, err := svc.NamespaceOperatorPermissions(context.Background(), 1, "eastsales@qiniu.com"); err != nil {
t.Fatalf("NamespaceOperatorPermissions() error = %v", err)
}
}
func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":403,"msg":"denied"}`))
}))
defer server.Close()
svc := NewWayneRoleBindingService(config.Config{
WayneInternalAPIBaseURL: server.URL,
WayneServiceName: "xinfra",
WayneServiceAPISecretKey: "service-secret",
})
result, err := svc.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
if err == nil {
t.Fatal("expected error")
}
if result == nil || result.StatusCode != http.StatusForbidden {
t.Fatalf("result = %#v, want 403", result)
}
if !strings.Contains(err.Error(), "denied") {
t.Fatalf("error = %q, want denied body", err.Error())
}
}
func readTestBody(t *testing.T, r *http.Request) []byte {
t.Helper()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return body
}