diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 9fc6636..6d63c3e 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -12,14 +12,10 @@ declare module 'vue' { AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default'] AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default'] ElButton: typeof import('element-plus/es')['ElButton'] - ElDivider: typeof import('element-plus/es')['ElDivider'] - ElForm: typeof import('element-plus/es')['ElForm'] - ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] ElOption: typeof import('element-plus/es')['ElOption'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElSpace: typeof import('element-plus/es')['ElSpace'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default'] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 87418d1..c1dd884 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,8 +4,7 @@ diff --git a/server/README.md b/server/README.md index 37c0c92..4740566 100644 --- a/server/README.md +++ b/server/README.md @@ -283,6 +283,64 @@ Wayne 会把回调地址拼成: 因此 `OAUTH_WAYNE_REDIRECT_URI` 必须和 Wayne 实际回调地址完全一致。浏览器访问 Wayne OAuth 登录入口后,如果 AuthServer 还没有登录态,会先跳内部 SAML;SAML 成功后再回到 OAuth authorize,签发 code 给 Wayne。 `WAYEN_OAUTH_REF` 是 AuthServer 发起 Wayne 登录时写入 Wayne `next` 参数的登录完成页,默认 `/portal/namespace/1/app`,对应 Wayne `DemoNamespaceId = 1` 的默认 namespace。不要配置成 `oauth` 或 `/oauth`,否则 Wayne 回调会把它当成前端路由跳到 `/oauth`。 +## Wayne 授权代理接口 + +AuthServer 的 Wayne 授权代理接口不要求调用方传 Wayne user ID。后端会从当前 `authserver_token` 里取 `email`,把它作为 Wayne username 传给 Wayne internal API。 + +对外接口: + +```text +GET /auth/api/v1/wayne/namespaces +GET /auth/api/v1/wayne/groups +GET /auth/api/v1/wayne/users/me/roles +GET /auth/api/v1/wayne/namespaces/:namespaceid/operator-permissions +GET /auth/api/v1/wayne/apps/:appid/operator-permissions +PUT /auth/api/v1/wayne/namespaces/:namespaceid/roles +DELETE /auth/api/v1/wayne/namespaces/:namespaceid/roles +PUT /auth/api/v1/wayne/apps/:appid/roles +DELETE /auth/api/v1/wayne/apps/:appid/roles +``` + +示例: + +```http +PUT /auth/api/v1/wayne/namespaces/1/roles +Authorization: Bearer +Content-Type: application/json + +{ + "groupIds": [10, 11], + "replace": false, + "requestId": "req-001", + "reason": "grant namespace access" +} +``` + +AuthServer 转发到 Wayne internal API 时会使用 token email: + +```text +PUT /api/v1/internal/namespaces/1/users//roles +``` + +并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。 + +相关配置: + +```env +WAYNE_INTERNAL_API_BASE_URL=http://wayne-backend.demo.svc.cluster.local:8080 +WAYNE_SERVICE_NAME=xinfra +WAYNE_SERVICE_API_SECRET_KEY= +``` + +Wayne internal API 签名规则: + +```text +bodyHash = SHA256_HEX(rawBody) +payload = METHOD + "\n" + URI + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash +signature = HMAC_SHA256_HEX(secret, payload) +X-Wayne-Signature = "sha256=" + signature +``` + 管理员可查看当前 SAML metadata 配置: ```text diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 9e58da7..9e4aa35 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -14,39 +14,43 @@ type OAuthClient struct { } type Config struct { - AppEnv string - HTTPAddr string - PublicBaseURL string - MySQLDSN string - AutoMigrate bool - JWTSecret string - JWTIssuer string - JWTTTLMinutes int - SAMLEntityID string - SAMLACSURL string - SAMLSPCert string - SAMLSPKey string - SAMLIDPMetaURL string - WayenLoginURL string - WayenTargetURL string - WayenUsernameKey string - WayenPasswordKey string - WayenLoginFormat string - WayenLoginValue string - WayenOAuthRef string - OAuthClientID string - OAuthClientSecret string - OAuthRedirectURI string - OAuthCodeTTLSeconds int - OIDCIssuer string - OIDCAuthorizeURL string - OIDCTokenURL string - OIDCUserInfoURL string - OIDCJWKSURL string - CloudDMClientID string - CloudDMClientSecret string - CloudDMRedirectURI string - CloudDMTargetURL string + AppEnv string + HTTPAddr string + PublicBaseURL string + MySQLDSN string + AutoMigrate bool + JWTSecret string + JWTIssuer string + JWTTTLMinutes int + SAMLEntityID string + SAMLACSURL string + SAMLSPCert string + SAMLSPKey string + SAMLIDPMetaURL string + WayenLoginURL string + WayenTargetURL string + WayenUsernameKey string + WayenPasswordKey string + WayenLoginFormat string + WayenLoginValue string + WayenOAuthRef string + WayenOAuthLoginURL string + WayneInternalAPIBaseURL string + WayneServiceName string + WayneServiceAPISecretKey string + OAuthClientID string + OAuthClientSecret string + OAuthRedirectURI string + OAuthCodeTTLSeconds int + OIDCIssuer string + OIDCAuthorizeURL string + OIDCTokenURL string + OIDCUserInfoURL string + OIDCJWKSURL string + CloudDMClientID string + CloudDMClientSecret string + CloudDMRedirectURI string + CloudDMTargetURL string } func Load() Config { @@ -59,39 +63,43 @@ func Load() Config { oidcIssuer = strings.TrimRight(oidcIssuer, "/") return Config{ - AppEnv: env("APP_ENV", "dev"), - HTTPAddr: httpAddr, - PublicBaseURL: publicBaseURL, - MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"), - AutoMigrate: envBool("AUTO_MIGRATE", true), - JWTSecret: env("JWT_SECRET", "change-this-secret"), - JWTIssuer: env("JWT_ISSUER", "authserver"), - JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120), - SAMLEntityID: samlEntityID, - SAMLACSURL: samlACSURL, - SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"), - SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"), - SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"), - WayenLoginURL: env("WAYEN_LOGIN_URL", ""), - WayenTargetURL: env("WAYEN_TARGET_URL", ""), - WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"), - WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"), - WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"), - WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"), - WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"), - OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"), - OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"), - OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""), - OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120), - OIDCIssuer: oidcIssuer, - OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")), - OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")), - OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")), - OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")), - CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"), - CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""), - CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""), - CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""), + AppEnv: env("APP_ENV", "dev"), + HTTPAddr: httpAddr, + PublicBaseURL: publicBaseURL, + MySQLDSN: env("MYSQL_DSN", "auth:auth@tcp(127.0.0.1:3306)/authserver?charset=utf8mb4&parseTime=True&loc=Local"), + AutoMigrate: envBool("AUTO_MIGRATE", true), + JWTSecret: env("JWT_SECRET", "change-this-secret"), + JWTIssuer: env("JWT_ISSUER", "authserver"), + JWTTTLMinutes: envInt("JWT_TTL_MINUTES", 120), + SAMLEntityID: samlEntityID, + SAMLACSURL: samlACSURL, + SAMLSPCert: env("SAML_SP_CERT_FILE", "certs/sp.crt"), + SAMLSPKey: env("SAML_SP_KEY_FILE", "certs/sp.key"), + SAMLIDPMetaURL: env("SAML_IDP_METADATA_URL", "http://sso-internal.dev.qiniu.io/saml2/meta"), + WayenLoginURL: env("WAYEN_LOGIN_URL", ""), + WayenTargetURL: env("WAYEN_TARGET_URL", ""), + WayenUsernameKey: env("WAYEN_USERNAME_KEY", "email"), + WayenPasswordKey: env("WAYEN_PASSWORD_KEY", "password"), + WayenLoginFormat: env("WAYEN_LOGIN_FORMAT", "form"), + WayenLoginValue: env("WAYEN_LOGIN_VALUE", "email"), + WayenOAuthRef: env("WAYEN_OAUTH_REF", "/portal/namespace/1/app"), + WayenOAuthLoginURL: trimURL(env("WAYEN_OAUTH_LOGIN_URL", "")), + WayneInternalAPIBaseURL: trimURL(env("WAYNE_INTERNAL_API_BASE_URL", "")), + WayneServiceName: env("WAYNE_SERVICE_NAME", "xinfra"), + WayneServiceAPISecretKey: env("WAYNE_SERVICE_API_SECRET_KEY", ""), + OAuthClientID: env("OAUTH_WAYNE_CLIENT_ID", "wayne"), + OAuthClientSecret: env("OAUTH_WAYNE_CLIENT_SECRET", "wayne-secret"), + OAuthRedirectURI: env("OAUTH_WAYNE_REDIRECT_URI", ""), + OAuthCodeTTLSeconds: envInt("OAUTH_CODE_TTL_SECONDS", 120), + OIDCIssuer: oidcIssuer, + OIDCAuthorizeURL: trimURL(env("OIDC_AUTHORIZATION_ENDPOINT", oidcIssuer+"/oauth/authorize")), + OIDCTokenURL: trimURL(env("OIDC_TOKEN_ENDPOINT", oidcIssuer+"/oauth/token")), + OIDCUserInfoURL: trimURL(env("OIDC_USERINFO_ENDPOINT", oidcIssuer+"/oauth/userinfo")), + OIDCJWKSURL: trimURL(env("OIDC_JWKS_URI", oidcIssuer+"/oauth/jwks")), + CloudDMClientID: env("OIDC_CLOUDDM_CLIENT_ID", "clouddm"), + CloudDMClientSecret: env("OIDC_CLOUDDM_CLIENT_SECRET", ""), + CloudDMRedirectURI: env("OIDC_CLOUDDM_REDIRECT_URI", ""), + CloudDMTargetURL: env("CLOUDDM_TARGET_URL", ""), } } diff --git a/server/internal/handler/saml.go b/server/internal/handler/saml.go index 4c21ed9..bfcac2d 100644 --- a/server/internal/handler/saml.go +++ b/server/internal/handler/saml.go @@ -62,6 +62,22 @@ func (h *SAMLHandler) Login(c *gin.Context) { c.Redirect(http.StatusFound, redirectURL) } +func (h *SAMLHandler) Logout(c *gin.Context) { + expired := time.Unix(0, 0) + for _, path := range []string{"/auth/", "/"} { + http.SetCookie(c.Writer, &http.Cookie{ + Name: AuthSessionCookieName, + Value: "", + Path: path, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + Expires: expired, + }) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + func (h *SAMLHandler) ACS(c *gin.Context) { if err := c.Request.ParseForm(); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -97,15 +113,12 @@ func (h *SAMLHandler) ACS(c *gin.Context) { func ssoRedirectURL(relayState, token string) string { target := strings.TrimSpace(relayState) - if target == "" || !strings.HasPrefix(target, "/auth/") { - target = "/auth/" - } - if strings.HasPrefix(target, "//") { - target = "/auth/" + if target == "" { + target = "/" } parsed, err := url.Parse(target) - if err != nil || parsed.IsAbs() { - parsed = &url.URL{Path: "/auth/"} + if err != nil || parsed.IsAbs() || !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") || strings.HasPrefix(parsed.Path, "/auth/api/") { + parsed = &url.URL{Path: "/"} } values := parsed.Query() values.Set("sso_token", token) diff --git a/server/internal/handler/saml_test.go b/server/internal/handler/saml_test.go new file mode 100644 index 0000000..c78eead --- /dev/null +++ b/server/internal/handler/saml_test.go @@ -0,0 +1,53 @@ +package handler + +import "testing" + +func TestSSORedirectURLAllowsFrontendRoutes(t *testing.T) { + tests := []struct { + name string + relay string + want string + }{ + { + name: "root", + relay: "/", + want: "/?sso_token=token-001", + }, + { + name: "frontend route", + relay: "/subsystem?open_app=wayne#section", + want: "/subsystem?open_app=wayne&sso_token=token-001#section", + }, + { + name: "oauth authorize route", + relay: "/auth/oauth/authorize?client_id=wayne", + want: "/auth/oauth/authorize?client_id=wayne&sso_token=token-001", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ssoRedirectURL(tt.relay, "token-001"); got != tt.want { + t.Fatalf("ssoRedirectURL(%q) = %q, want %q", tt.relay, got, tt.want) + } + }) + } +} + +func TestSSORedirectURLRejectsUnsafeRelayState(t *testing.T) { + tests := []string{ + "", + "https://evil.example.com/", + "//evil.example.com/", + "dashboard", + "/auth/api/v1/users/me", + } + + for _, relay := range tests { + t.Run(relay, func(t *testing.T) { + if got := ssoRedirectURL(relay, "token-001"); got != "/?sso_token=token-001" { + t.Fatalf("ssoRedirectURL(%q) = %q, want root fallback", relay, got) + } + }) + } +} diff --git a/server/internal/handler/wayne_role_binding.go b/server/internal/handler/wayne_role_binding.go new file mode 100644 index 0000000..04c036d --- /dev/null +++ b/server/internal/handler/wayne_role_binding.go @@ -0,0 +1,315 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/1024XEngineer/xinfra/server/internal/service" + + "github.com/gin-gonic/gin" +) + +type WayneRoleBindingHandler struct { + wayne *service.WayneRoleBindingService + audit *service.AuditService +} + +func NewWayneRoleBindingHandler(wayne *service.WayneRoleBindingService, audit *service.AuditService) *WayneRoleBindingHandler { + return &WayneRoleBindingHandler{wayne: wayne, audit: audit} +} + +func (h *WayneRoleBindingHandler) BindNamespace(c *gin.Context) { + h.handle(c, "namespace", http.MethodPut) +} + +func (h *WayneRoleBindingHandler) UnbindNamespace(c *gin.Context) { + h.handle(c, "namespace", http.MethodDelete) +} + +func (h *WayneRoleBindingHandler) BindApp(c *gin.Context) { + h.handle(c, "app", http.MethodPut) +} + +func (h *WayneRoleBindingHandler) UnbindApp(c *gin.Context) { + h.handle(c, "app", http.MethodDelete) +} + +func (h *WayneRoleBindingHandler) ListNamespaces(c *gin.Context) { + h.handleQuery(c, "namespaces", 0, "") +} + +func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) { + var groupType *int + if raw := strings.TrimSpace(c.Query("type")); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || (value != 0 && value != 1) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) + return + } + groupType = &value + } + result, err := h.wayne.ListGroups(c.Request.Context(), groupType) + if err != nil { + h.writeQueryAudit(c, "groups", 0, "deny", err.Error()) + writeWayneRoleBindingError(c, result, err) + return + } + h.writeQueryAudit(c, "groups", 0, "allow", "") + writeWayneRoleBindingResult(c, result) +} + +func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) { + username, ok := currentTokenEmail(c) + if !ok { + return + } + h.handleQuery(c, "user_roles", 0, username) +} + +func (h *WayneRoleBindingHandler) NamespaceOperatorPermissions(c *gin.Context) { + namespaceID, ok := parseUintPathParam(c, "namespaceid") + if !ok { + return + } + h.handleOperatorPermissions(c, "namespace", namespaceID) +} + +func (h *WayneRoleBindingHandler) AppOperatorPermissions(c *gin.Context) { + appID, ok := parseUintPathParam(c, "appid") + if !ok { + return + } + h.handleOperatorPermissions(c, "app", appID) +} + +func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method string) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + operatorEmail := strings.TrimSpace(claims.Email) + if operatorEmail == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"}) + return + } + + resourceParam := "namespaceid" + if scope == "app" { + resourceParam = "appid" + } + resourceID, ok := parseUintPathParam(c, resourceParam) + if !ok { + return + } + req, ok := parseRoleBindingRequest(c) + if !ok { + return + } + if method == http.MethodPut && len(req.GroupIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"}) + return + } + + result, err := h.call(c, scope, method, resourceID, operatorEmail, req) + if err != nil { + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "deny", req.RequestID, err.Error()) + writeWayneRoleBindingError(c, result, err) + return + } + + h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "allow", req.RequestID, "") + writeWayneRoleBindingResult(c, result) +} + +func (h *WayneRoleBindingHandler) handleQuery(c *gin.Context, resourceType string, resourceID uint64, username string) { + var result *service.WayneRoleBindingResult + var err error + switch resourceType { + case "namespaces": + result, err = h.wayne.ListNamespaces(c.Request.Context()) + case "user_roles": + result, err = h.wayne.GetUserRoles(c.Request.Context(), username) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported query resource"}) + return + } + if err != nil { + h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error()) + writeWayneRoleBindingError(c, result, err) + return + } + h.writeQueryAudit(c, resourceType, resourceID, "allow", "") + writeWayneRoleBindingResult(c, result) +} + +func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scope string, resourceID uint64) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return + } + operatorEmail := strings.TrimSpace(claims.Email) + if operatorEmail == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"}) + return + } + + var result *service.WayneRoleBindingResult + var err error + if scope == "namespace" { + result, err = h.wayne.NamespaceOperatorPermissions(c.Request.Context(), resourceID, operatorEmail) + } else { + result, err = h.wayne.AppOperatorPermissions(c.Request.Context(), resourceID, operatorEmail) + } + resourceType := scope + "_operator_permissions" + if err != nil { + h.writeQueryAudit(c, resourceType, resourceID, "deny", err.Error()) + writeWayneRoleBindingError(c, result, err) + return + } + h.writeQueryAudit(c, resourceType, resourceID, "allow", "") + writeWayneRoleBindingResult(c, result) +} + +func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) { + if scope == "namespace" { + if method == http.MethodPut { + return h.wayne.BindNamespace(c.Request.Context(), resourceID, operatorEmail, req) + } + return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, operatorEmail, req) + } + if method == http.MethodPut { + return h.wayne.BindApp(c.Request.Context(), resourceID, operatorEmail, req) + } + return h.wayne.UnbindApp(c.Request.Context(), resourceID, operatorEmail, req) +} + +func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) { + var req service.WayneRoleBindingRequest + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return req, false + } + if strings.TrimSpace(string(body)) == "" { + return req, true + } + if err := json.Unmarshal(body, &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return req, false + } + return req, true +} + +func parseUintPathParam(c *gin.Context, name string) (uint64, bool) { + raw := strings.TrimSpace(c.Param(name)) + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid %s", name)}) + return 0, false + } + return value, true +} + +func writeWayneRoleBindingResult(c *gin.Context, result *service.WayneRoleBindingResult) { + if result == nil { + c.JSON(http.StatusOK, gin.H{"data": gin.H{}}) + return + } + contentType := result.ContentType + if contentType == "" { + contentType = "application/json" + } + if len(result.Body) == 0 { + c.Status(result.StatusCode) + return + } + c.Data(result.StatusCode, contentType, result.Body) +} + +func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBindingResult, err error) { + if result != nil && len(result.Body) > 0 { + contentType := result.ContentType + if contentType == "" { + contentType = "application/json" + } + c.Data(result.StatusCode, contentType, result.Body) + return + } + var httpErr *service.WayneRoleBindingHTTPError + switch { + case errors.Is(err, service.ErrWayneRoleBindingNotConfigured): + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrWayenEmailMissing): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.As(err, &httpErr): + c.JSON(httpErr.StatusCode, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } +} + +func currentTokenEmail(c *gin.Context) (string, bool) { + claims, ok := CurrentClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) + return "", false + } + email := strings.TrimSpace(claims.Email) + if email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"}) + return "", false + } + return email, true +} + +func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) { + h.audit.Write(service.AuditEntry{ + RequestID: requestID, + ActorUserID: userID, + ActorUsername: operatorEmail, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "wayne.role_binding." + decision, + ResourceType: "wayne_" + scope, + ResourceID: strconv.FormatUint(resourceID, 10), + ScopeType: scope, + ScopeID: resourceID, + Decision: decision, + Reason: reason, + Metadata: map[string]any{ + "targetUsername": targetUsername, + }, + }) +} + +func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType string, resourceID uint64, decision, reason string) { + claims, _ := CurrentClaims(c) + var actorUserID uint64 + var actorUsername string + if claims != nil { + actorUserID = claims.UserID + actorUsername = strings.TrimSpace(claims.Email) + if actorUsername == "" { + actorUsername = claims.Username + } + } + h.audit.Write(service.AuditEntry{ + ActorUserID: actorUserID, + ActorUsername: actorUsername, + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Action: "wayne.role_query." + decision, + ResourceType: "wayne_" + resourceType, + ResourceID: strconv.FormatUint(resourceID, 10), + Decision: decision, + Reason: reason, + }) +} diff --git a/server/internal/router/router.go b/server/internal/router/router.go index 9ccc733..7c7e687 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -69,10 +69,12 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { auditService := service.NewAuditService(deps.DB) authService := service.NewAuthService(deps.Config, deps.DB, auditService) wayenService := service.NewWayenService(deps.Config, deps.DB) + wayneRoleBindingService := service.NewWayneRoleBindingService(deps.Config) healthHandler := handler.NewHealthHandler(deps.DB) userHandler := handler.NewUserHandler() wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService) + wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService) clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService) samlHandler := handler.NewSAMLHandler(deps.Config, authService) oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService) @@ -88,6 +90,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { v1 := r.Group("/auth/api/v1") { v1.GET("/login/internal-sso", samlHandler.Login) + v1.POST("/logout", samlHandler.Logout) v1.GET("/saml/metadata", samlHandler.Metadata) v1.POST("/saml/acs", samlHandler.ACS) @@ -97,6 +100,15 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) { protected.GET("/wayen/login", wayenHandler.Login) protected.GET("/wayen/credential", wayenHandler.GetCredential) protected.PUT("/wayen/credential", wayenHandler.SaveCredential) + protected.GET("/wayne/namespaces", wayneRoleBindingHandler.ListNamespaces) + protected.GET("/wayne/groups", wayneRoleBindingHandler.ListGroups) + protected.GET("/wayne/users/me/roles", wayneRoleBindingHandler.GetCurrentUserRoles) + protected.GET("/wayne/namespaces/:namespaceid/operator-permissions", wayneRoleBindingHandler.NamespaceOperatorPermissions) + protected.GET("/wayne/apps/:appid/operator-permissions", wayneRoleBindingHandler.AppOperatorPermissions) + protected.PUT("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.BindNamespace) + protected.DELETE("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.UnbindNamespace) + protected.PUT("/wayne/apps/:appid/roles", wayneRoleBindingHandler.BindApp) + protected.DELETE("/wayne/apps/:appid/roles", wayneRoleBindingHandler.UnbindApp) protected.GET("/clouddm/login", clouddmHandler.Login) } } diff --git a/server/internal/service/wayen.go b/server/internal/service/wayen.go index 996ef6b..f60e4e9 100644 --- a/server/internal/service/wayen.go +++ b/server/internal/service/wayen.go @@ -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 } diff --git a/server/internal/service/wayen_test.go b/server/internal/service/wayen_test.go new file mode 100644 index 0000000..8fbdc8e --- /dev/null +++ b/server/internal/service/wayen_test.go @@ -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) + } +} diff --git a/server/internal/service/wayne_role_binding.go b/server/internal/service/wayne_role_binding.go new file mode 100644 index 0000000..d331f3f --- /dev/null +++ b/server/internal/service/wayne_role_binding.go @@ -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)" +} diff --git a/server/internal/service/wayne_role_binding_test.go b/server/internal/service/wayne_role_binding_test.go new file mode 100644 index 0000000..c665046 --- /dev/null +++ b/server/internal/service/wayne_role_binding_test.go @@ -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 +} diff --git a/server/internal/sso/login.go b/server/internal/sso/login.go index c863fe4..14e6f77 100644 --- a/server/internal/sso/login.go +++ b/server/internal/sso/login.go @@ -27,6 +27,13 @@ import ( const ( samlHTTPRedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" samlHTTPPostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + + xmlEncAES128CBC = "http://www.w3.org/2001/04/xmlenc#aes128-cbc" + xmlEncAES192CBC = "http://www.w3.org/2001/04/xmlenc#aes192-cbc" + xmlEncAES256CBC = "http://www.w3.org/2001/04/xmlenc#aes256-cbc" + xmlEncAES128GCM = "http://www.w3.org/2009/xmlenc11#aes128-gcm" + xmlEncAES192GCM = "http://www.w3.org/2009/xmlenc11#aes192-gcm" + xmlEncAES256GCM = "http://www.w3.org/2009/xmlenc11#aes256-gcm" ) type LoginConfig struct { @@ -178,7 +185,7 @@ func decryptEncryptedAssertion(encrypted encryptedAssertion, privateKeyFile stri if err != nil { return "", assertion{}, fmt.Errorf("decode saml encrypted assertion: %w", err) } - plain, err := decryptAESCBC(encryptedValue, sessionKey) + plain, err := decryptAssertionCipherValue(encryptedValue, sessionKey, encrypted.EncryptedData.EncryptionMethod.Algorithm) if err != nil { return "", assertion{}, err } @@ -216,6 +223,17 @@ func readRSAPrivateKey(path string) (*rsa.PrivateKey, error) { return key, nil } +func decryptAssertionCipherValue(value, key []byte, algorithm string) ([]byte, error) { + switch strings.TrimSpace(algorithm) { + case "", xmlEncAES128CBC, xmlEncAES192CBC, xmlEncAES256CBC: + return decryptAESCBC(value, key) + case xmlEncAES128GCM, xmlEncAES192GCM, xmlEncAES256GCM: + return decryptAESGCM(value, key) + default: + return nil, fmt.Errorf("unsupported saml assertion encryption algorithm: %s", algorithm) + } +} + func decryptAESCBC(value, key []byte) ([]byte, error) { block, err := aes.NewCipher(key) if err != nil { @@ -235,6 +253,27 @@ func decryptAESCBC(value, key []byte) ([]byte, error) { return plain, nil } +func decryptAESGCM(value, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("init saml assertion cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("init saml assertion gcm: %w", err) + } + if len(value) <= aead.NonceSize()+aead.Overhead() { + return nil, errors.New("invalid saml encrypted assertion gcm length") + } + nonce := value[:aead.NonceSize()] + cipherText := value[aead.NonceSize():] + plain, err := aead.Open(nil, nonce, cipherText, nil) + if err != nil { + return nil, fmt.Errorf("decrypt saml assertion gcm: %w", err) + } + return plain, nil +} + func pkcs7Unpad(value []byte, blockSize int) ([]byte, error) { if len(value) == 0 || len(value)%blockSize != 0 { return nil, errors.New("invalid saml assertion padding length") diff --git a/server/internal/sso/login_test.go b/server/internal/sso/login_test.go index eb1d3c3..d61350f 100644 --- a/server/internal/sso/login_test.go +++ b/server/internal/sso/login_test.go @@ -143,6 +143,53 @@ func TestDecodeSAMLResponseDecryptsEncryptedAssertion(t *testing.T) { } } +func TestDecodeSAMLResponseDecryptsGCMEncryptedAssertion(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyFile := writeTestPrivateKey(t, key) + + assertion := ` + bob@example.com + + bob@example.com + +` + sessionKey := []byte("0123456789abcdef0123456789abcdef") + encryptedAssertion := encryptTestAssertionGCM(t, []byte(assertion), sessionKey) + encryptedKey, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &key.PublicKey, sessionKey, nil) + if err != nil { + t.Fatalf("encrypt key: %v", err) + } + response := ` + + + + + + ` + base64.StdEncoding.EncodeToString(encryptedKey) + ` + + + ` + base64.StdEncoding.EncodeToString(encryptedAssertion) + ` + + +` + + info, err := DecodeSAMLResponse(base64.StdEncoding.EncodeToString([]byte(response)), keyFile) + if err != nil { + t.Fatalf("DecodeSAMLResponse returned error: %v", err) + } + if info.NameID != "bob@example.com" { + t.Fatalf("unexpected name id: %q", info.NameID) + } + if got := info.Attributes["email"]; len(got) != 1 || got[0] != "bob@example.com" { + t.Fatalf("unexpected email attribute: %#v", got) + } +} + func writeTestPrivateKey(t *testing.T, key *rsa.PrivateKey) string { t.Helper() file, err := os.CreateTemp(t.TempDir(), "sp-*.key") @@ -174,6 +221,24 @@ func encryptTestAssertion(t *testing.T, plain, key []byte) []byte { return out } +func encryptTestAssertionGCM(t *testing.T, plain, key []byte) []byte { + t.Helper() + block, err := aes.NewCipher(key) + if err != nil { + t.Fatalf("init cipher: %v", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("init gcm: %v", err) + } + nonce := bytes.Repeat([]byte{2}, aead.NonceSize()) + cipherText := aead.Seal(nil, nonce, plain, nil) + out := make([]byte, len(nonce)+len(cipherText)) + copy(out, nonce) + copy(out[len(nonce):], cipherText) + return out +} + func pkcs7Pad(value []byte, blockSize int) []byte { padding := blockSize - len(value)%blockSize return append(value, bytes.Repeat([]byte{byte(padding)}, padding)...) diff --git a/server/internal/wayne/signature.go b/server/internal/wayne/signature.go new file mode 100644 index 0000000..3a2762d --- /dev/null +++ b/server/internal/wayne/signature.go @@ -0,0 +1,123 @@ +package wayne + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + HeaderService = "X-Wayne-Service" + HeaderTimestamp = "X-Wayne-Timestamp" + HeaderNonce = "X-Wayne-Nonce" + HeaderSignature = "X-Wayne-Signature" + + SignaturePrefix = "sha256=" +) + +var ( + ErrMissingSecret = errors.New("wayne service api secret key is empty") + ErrMissingHeader = errors.New("wayne service header value is empty") +) + +type SignedHeaders struct { + Service string + Timestamp string + Nonce string + Signature string +} + +func BodySHA256Hex(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +func SigningPayload(method, uri, timestamp, nonce string, body []byte) string { + return strings.Join([]string{ + strings.ToUpper(strings.TrimSpace(method)), + uri, + timestamp, + nonce, + BodySHA256Hex(body), + }, "\n") +} + +func Sign(secret, method, uri, timestamp, nonce string, body []byte) (string, error) { + secret = secretValue(secret) + if secret == "" { + return "", ErrMissingSecret + } + payload := SigningPayload(method, uri, timestamp, nonce, body) + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + return SignaturePrefix + hex.EncodeToString(mac.Sum(nil)), nil +} + +func Verify(secret, signature, method, uri, timestamp, nonce string, body []byte) bool { + expected, err := Sign(secret, method, uri, timestamp, nonce, body) + if err != nil { + return false + } + return subtle.ConstantTimeCompare([]byte(normalizeSignature(signature)), []byte(expected)) == 1 +} + +func BuildSignedHeaders(service, secret, method, uri string, body []byte, now time.Time) (SignedHeaders, error) { + service = strings.TrimSpace(service) + if service == "" { + return SignedHeaders{}, fmt.Errorf("%w: service", ErrMissingHeader) + } + timestamp := strconv.FormatInt(now.Unix(), 10) + nonce, err := NewNonce() + if err != nil { + return SignedHeaders{}, err + } + signature, err := Sign(secret, method, uri, timestamp, nonce, body) + if err != nil { + return SignedHeaders{}, err + } + return SignedHeaders{ + Service: service, + Timestamp: timestamp, + Nonce: nonce, + Signature: signature, + }, nil +} + +func (h SignedHeaders) Apply(req *http.Request) { + req.Header.Set(HeaderService, h.Service) + req.Header.Set(HeaderTimestamp, h.Timestamp) + req.Header.Set(HeaderNonce, h.Nonce) + req.Header.Set(HeaderSignature, h.Signature) +} + +func NewNonce() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func normalizeSignature(signature string) string { + signature = strings.TrimSpace(signature) + if strings.HasPrefix(signature, SignaturePrefix) { + return signature + } + return SignaturePrefix + signature +} + +func secretValue(secret string) string { + secret = strings.TrimSpace(secret) + if _, value, ok := strings.Cut(secret, ":"); ok { + return strings.TrimSpace(value) + } + return secret +} diff --git a/server/internal/wayne/signature_test.go b/server/internal/wayne/signature_test.go new file mode 100644 index 0000000..5c87635 --- /dev/null +++ b/server/internal/wayne/signature_test.go @@ -0,0 +1,100 @@ +package wayne + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func TestBodySHA256Hex(t *testing.T) { + got := BodySHA256Hex([]byte("hello")) + want := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + if got != want { + t.Fatalf("BodySHA256Hex() = %q, want %q", got, want) + } +} + +func TestSigningPayload(t *testing.T) { + body := []byte(`{"groupIds":[10]}`) + got := SigningPayload("put", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) + want := strings.Join([]string{ + "PUT", + "/api/v1/internal/namespaces/1/users/2001/roles", + "1721000000", + "nonce-001", + "cf296504b2a434969ee151c1a48aa07fabb64634c77e569af74260cf234080f2", + }, "\n") + if got != want { + t.Fatalf("SigningPayload() = %q, want %q", got, want) + } +} + +func TestSignAndVerify(t *testing.T) { + body := []byte(`{"groupIds":[10]}`) + signature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) + if err != nil { + t.Fatalf("Sign() error = %v", err) + } + want := "sha256=6da70ea095fde90b534d0677da62db867d0b8092f6b15ed86cb52d77571b5b1d" + if signature != want { + t.Fatalf("Sign() = %q, want %q", signature, want) + } + if !Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) { + t.Fatal("Verify() rejected valid signature") + } + if !Verify("test-secret", strings.TrimPrefix(signature, SignaturePrefix), "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) { + t.Fatal("Verify() rejected valid signature without prefix") + } + if Verify("test-secret", signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", []byte(`{"groupIds":[11]}`)) { + t.Fatal("Verify() accepted tampered body") + } +} + +func TestSignAcceptsServicePrefixedSecret(t *testing.T) { + body := []byte(`{"groupIds":[10]}`) + plainSecretSignature, err := Sign("test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) + if err != nil { + t.Fatalf("Sign() with plain secret error = %v", err) + } + prefixedSecretSignature, err := Sign("xinfra:test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", "1721000000", "nonce-001", body) + if err != nil { + t.Fatalf("Sign() with prefixed secret error = %v", err) + } + if prefixedSecretSignature != plainSecretSignature { + t.Fatalf("prefixed secret signature = %q, want %q", prefixedSecretSignature, plainSecretSignature) + } +} + +func TestBuildSignedHeadersAndApply(t *testing.T) { + body := []byte(`{"groupIds":[10]}`) + now := time.Unix(1721000000, 0) + headers, err := BuildSignedHeaders("xinfra", "test-secret", "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", body, now) + if err != nil { + t.Fatalf("BuildSignedHeaders() error = %v", err) + } + if headers.Service != "xinfra" { + t.Fatalf("headers.Service = %q, want xinfra", headers.Service) + } + if headers.Timestamp != "1721000000" { + t.Fatalf("headers.Timestamp = %q, want 1721000000", headers.Timestamp) + } + if headers.Nonce == "" { + t.Fatal("headers.Nonce is empty") + } + if !Verify("test-secret", headers.Signature, "PUT", "/api/v1/internal/namespaces/1/users/2001/roles", headers.Timestamp, headers.Nonce, body) { + t.Fatal("generated headers signature is invalid") + } + + req, err := http.NewRequest(http.MethodPut, "http://wayne.example.com/api/v1/internal/namespaces/1/users/2001/roles", strings.NewReader(string(body))) + if err != nil { + t.Fatalf("NewRequest() error = %v", err) + } + headers.Apply(req) + if req.Header.Get(HeaderService) != headers.Service || + req.Header.Get(HeaderTimestamp) != headers.Timestamp || + req.Header.Get(HeaderNonce) != headers.Nonce || + req.Header.Get(HeaderSignature) != headers.Signature { + t.Fatalf("Apply() did not write expected signed headers: %#v", req.Header) + } +}