From 3f24ba87eef4ca5c9337958bcd23889f03a2503b Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 16 Apr 2026 17:07:06 +0800 Subject: [PATCH] Move Muse mirror-user provisioning into new-api control plane Muse needs a trusted path to create and disable per-user long-lived relay tokens so billing, routing, and downgrade logic can stay inside new-api instead of leaking back into Muse. This adds a small internal route group guarded by a shared secret, provisions a mirror user plus a dedicated long-lived token, and exposes disable/status/revoke operations for the control plane. The sync path stores the Muse user id in remark as a temporary lookup anchor until Muse persists the returned new-api ids. Constraint: Must reuse new-api billing/token model behavior without adding schema changes in phase 1 Constraint: Must avoid CreateUser side effects such as signup quota grants for mirrored internal users Rejected: Reusing existing self-service token controllers | they only manage the authenticated owner and cannot return control-plane ids/keys Rejected: Keying mirror users by username/email alone | unstable and collision-prone for external identity sync Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Treat remark-based muse_user_id lookup as a phase-1 bridge; prefer persisted new_api_user_id from Muse binding records for later operations Tested: go test ./controller ./service ./middleware -run MuseInternal -count=1 Tested: go test ./controller ./service ./middleware -count=1 Not-tested: Full relay/data-plane integration against a live Muse control-plane caller --- controller/muse_internal.go | 67 +++++++ controller/muse_internal_test.go | 151 +++++++++++++++ dto/muse_internal.go | 15 ++ middleware/muse_internal_auth.go | 25 +++ router/api-router.go | 9 + service/muse_internal_service.go | 305 +++++++++++++++++++++++++++++++ 6 files changed, 572 insertions(+) create mode 100644 controller/muse_internal.go create mode 100644 controller/muse_internal_test.go create mode 100644 dto/muse_internal.go create mode 100644 middleware/muse_internal_auth.go create mode 100644 service/muse_internal_service.go diff --git a/controller/muse_internal.go b/controller/muse_internal.go new file mode 100644 index 00000000..6a002c1a --- /dev/null +++ b/controller/muse_internal.go @@ -0,0 +1,67 @@ +package controller + +import ( + "errors" + "strconv" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +func SyncMuseUser(c *gin.Context) { + var req museDTO.SyncMuseUserRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, errors.New("invalid request body")) + return + } + response, err := service.SyncMuseUser(req) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func DisableMuseUser(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid user id")) + return + } + response, err := service.DisableMuseUserByID(userID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func RevokeMuseToken(c *gin.Context) { + tokenID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid token id")) + return + } + response, err := service.RevokeMuseToken(tokenID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func GetMuseUserStatus(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid user id")) + return + } + response, err := service.GetMuseUserStatus(userID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} diff --git a/controller/muse_internal_test.go b/controller/muse_internal_test.go new file mode 100644 index 00000000..c93be7c0 --- /dev/null +++ b/controller/muse_internal_test.go @@ -0,0 +1,151 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type museInternalAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data museDTO.SyncMuseUserResponse `json:"data"` +} + +func setupMuseInternalControllerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + common.GlobalApiRateLimitEnable = false + + t.Setenv("MUSE_INTERNAL_SECRET", "test-secret") + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{})) + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func decodeMuseInternalResponse(t *testing.T, recorder *httptest.ResponseRecorder) museInternalAPIResponse { + t.Helper() + + var response museInternalAPIResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + return response +} + +func newMuseInternalRouter() *gin.Engine { + engine := gin.New() + internal := engine.Group("/api/internal/muse") + internal.Use(middleware.MuseInternalAuth()) + { + internal.POST("/users/sync", SyncMuseUser) + internal.POST("/users/:id/disable", DisableMuseUser) + internal.POST("/tokens/:id/revoke", RevokeMuseToken) + internal.GET("/users/:id/status", GetMuseUserStatus) + } + return engine +} + +func TestMuseInternalSyncUserCreatesMirrorUserAndToken(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Muse-Service-Secret", "test-secret") + rr := httptest.NewRecorder() + + newMuseInternalRouter().ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeMuseInternalResponse(t, rr) + require.True(t, response.Success) + require.Equal(t, "active", response.Data.Status) + require.NotZero(t, response.Data.NewAPIUserID) + require.NotZero(t, response.Data.TokenID) + require.NotEmpty(t, response.Data.TokenKey) + + user, err := model.GetUserById(response.Data.NewAPIUserID, false) + require.NoError(t, err) + require.Equal(t, common.UserStatusEnabled, user.Status) + require.Equal(t, "alice", user.Username) + require.Equal(t, "muse_user_id:u-1", user.Remark) + + token, err := model.GetTokenById(response.Data.TokenID) + require.NoError(t, err) + require.Equal(t, user.Id, token.UserId) + require.Equal(t, common.TokenStatusEnabled, token.Status) + require.True(t, token.UnlimitedQuota) +} + +func TestMuseInternalSyncUserRejectsInvalidSecret(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Muse-Service-Secret", "wrong-secret") + rr := httptest.NewRecorder() + + newMuseInternalRouter().ServeHTTP(rr, req) + + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestMuseInternalDisableUserDisablesUserAndToken(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + syncReq := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-2","username":"bob","display_name":"Bob","status":"active"}`)) + syncReq.Header.Set("Content-Type", "application/json") + syncReq.Header.Set("X-Muse-Service-Secret", "test-secret") + syncRR := httptest.NewRecorder() + engine := newMuseInternalRouter() + engine.ServeHTTP(syncRR, syncReq) + + syncResponse := decodeMuseInternalResponse(t, syncRR) + require.True(t, syncResponse.Success) + + disableReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/internal/muse/users/%d/disable", syncResponse.Data.NewAPIUserID), nil) + disableReq.Header.Set("X-Muse-Service-Secret", "test-secret") + disableRR := httptest.NewRecorder() + engine.ServeHTTP(disableRR, disableReq) + + require.Equal(t, http.StatusOK, disableRR.Code) + disableResponse := decodeMuseInternalResponse(t, disableRR) + require.True(t, disableResponse.Success) + require.Equal(t, "disabled", disableResponse.Data.Status) + require.Equal(t, syncResponse.Data.NewAPIUserID, disableResponse.Data.NewAPIUserID) + + user, err := model.GetUserById(syncResponse.Data.NewAPIUserID, false) + require.NoError(t, err) + require.Equal(t, common.UserStatusDisabled, user.Status) + + token, err := model.GetTokenById(syncResponse.Data.TokenID) + require.NoError(t, err) + require.Equal(t, common.TokenStatusDisabled, token.Status) +} diff --git a/dto/muse_internal.go b/dto/muse_internal.go new file mode 100644 index 00000000..8fef6d4b --- /dev/null +++ b/dto/muse_internal.go @@ -0,0 +1,15 @@ +package dto + +type SyncMuseUserRequest struct { + MuseUserID string `json:"muse_user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Status string `json:"status"` +} + +type SyncMuseUserResponse struct { + NewAPIUserID int `json:"newapi_user_id"` + TokenID int `json:"token_id"` + TokenKey string `json:"token_key,omitempty"` + Status string `json:"status"` +} diff --git a/middleware/muse_internal_auth.go b/middleware/muse_internal_auth.go new file mode 100644 index 00000000..e9d84755 --- /dev/null +++ b/middleware/muse_internal_auth.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func MuseInternalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + secret := c.GetHeader("X-Muse-Service-Secret") + expected := common.GetEnvOrDefaultString("MUSE_INTERNAL_SECRET", "") + if expected == "" || subtle.ConstantTimeCompare([]byte(secret), []byte(expected)) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "invalid muse internal secret", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/router/api-router.go b/router/api-router.go index 35d11376..2fed3100 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -53,6 +53,15 @@ func SetApiRouter(router *gin.Engine) { // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + museInternalRoute := apiRouter.Group("/internal/muse") + museInternalRoute.Use(middleware.MuseInternalAuth()) + { + museInternalRoute.POST("/users/sync", controller.SyncMuseUser) + museInternalRoute.POST("/users/:id/disable", controller.DisableMuseUser) + museInternalRoute.POST("/tokens/:id/revoke", controller.RevokeMuseToken) + museInternalRoute.GET("/users/:id/status", controller.GetMuseUserStatus) + } + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) diff --git a/service/muse_internal_service.go b/service/muse_internal_service.go new file mode 100644 index 00000000..7eefbcf0 --- /dev/null +++ b/service/muse_internal_service.go @@ -0,0 +1,305 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" +) + +const ( + museUserStatusActive = "active" + museUserStatusDisabled = "disabled" + museMirrorTokenName = "muse-long-lived" + museRemarkPrefix = "muse_user_id:" +) + +func SyncMuseUser(req museDTO.SyncMuseUserRequest) (*museDTO.SyncMuseUserResponse, error) { + req.MuseUserID = strings.TrimSpace(req.MuseUserID) + req.Username = strings.TrimSpace(req.Username) + req.DisplayName = strings.TrimSpace(req.DisplayName) + if req.MuseUserID == "" { + return nil, errors.New("muse_user_id is required") + } + + if normalizeMuseStatus(req.Status) == museUserStatusDisabled { + return disableMuseUserByMuseUserID(req.MuseUserID) + } + + user, err := getMirrorUserByMuseUserID(req.MuseUserID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + user, err = createMirrorUser(req) + if err != nil { + return nil, err + } + } else if err = updateMirrorUser(user, req); err != nil { + return nil, err + } + + token, err := ensureMirrorToken(user.Id) + if err != nil { + return nil, err + } + return buildMirrorResponse(user, token, museUserStatusActive, true), nil +} + +func DisableMuseUserByID(newAPIUserID int) (*museDTO.SyncMuseUserResponse, error) { + if newAPIUserID <= 0 { + return nil, errors.New("newapi user id is required") + } + user, err := model.GetUserById(newAPIUserID, false) + if err != nil { + return nil, err + } + if err = disableUserAndTokens(user); err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func RevokeMuseToken(tokenID int) (*museDTO.SyncMuseUserResponse, error) { + if tokenID <= 0 { + return nil, errors.New("token id is required") + } + token, err := model.GetTokenById(tokenID) + if err != nil { + return nil, err + } + token.Status = common.TokenStatusDisabled + if err = token.Update(); err != nil { + return nil, err + } + user, err := model.GetUserById(token.UserId, false) + if err != nil { + return nil, err + } + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func GetMuseUserStatus(newAPIUserID int) (*museDTO.SyncMuseUserResponse, error) { + if newAPIUserID <= 0 { + return nil, errors.New("newapi user id is required") + } + user, err := model.GetUserById(newAPIUserID, false) + if err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, userStatusToMuseStatus(user.Status), false), nil +} + +func disableMuseUserByMuseUserID(museUserID string) (*museDTO.SyncMuseUserResponse, error) { + user, err := getMirrorUserByMuseUserID(museUserID) + if err != nil { + return nil, err + } + if err = disableUserAndTokens(user); err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func getMirrorUserByMuseUserID(museUserID string) (*model.User, error) { + user := &model.User{} + err := model.DB.Where("remark = ?", museRemarkPrefix+museUserID).First(user).Error + if err != nil { + return nil, err + } + return user, nil +} + +func createMirrorUser(req museDTO.SyncMuseUserRequest) (*model.User, error) { + hashedPassword, err := common.Password2Hash(common.GetRandomString(20)) + if err != nil { + return nil, err + } + user := &model.User{ + Username: chooseMirrorUsername(req.Username, req.MuseUserID, 0, ""), + Password: hashedPassword, + DisplayName: chooseDisplayName(req.DisplayName, req.Username), + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + Remark: museRemarkPrefix + req.MuseUserID, + } + if user.DisplayName == "" { + user.DisplayName = user.Username + } + if err = model.DB.Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +func updateMirrorUser(user *model.User, req museDTO.SyncMuseUserRequest) error { + username := chooseMirrorUsername(req.Username, req.MuseUserID, user.Id, user.Username) + displayName := chooseDisplayName(req.DisplayName, req.Username) + if displayName == "" { + displayName = user.DisplayName + } + if displayName == "" { + displayName = username + } + + user.Username = username + user.DisplayName = displayName + user.Status = common.UserStatusEnabled + user.Role = common.RoleCommonUser + user.Remark = museRemarkPrefix + req.MuseUserID + return user.Update(false) +} + +func ensureMirrorToken(userID int) (*model.Token, error) { + token, err := getMirrorToken(userID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + key, keyErr := common.GenerateKey() + if keyErr != nil { + return nil, keyErr + } + token = &model.Token{ + UserId: userID, + Name: museMirrorTokenName, + Key: key, + Status: common.TokenStatusEnabled, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, + RemainQuota: 0, + UnlimitedQuota: true, + } + if err = token.Insert(); err != nil { + return nil, err + } + return token, nil + } + + token.Status = common.TokenStatusEnabled + token.ExpiredTime = -1 + token.UnlimitedQuota = true + token.RemainQuota = 0 + token.AccessedTime = common.GetTimestamp() + if err = token.Update(); err != nil { + return nil, err + } + return token, nil +} + +func getMirrorToken(userID int) (*model.Token, error) { + token := &model.Token{} + err := model.DB.Where("user_id = ? AND name = ?", userID, museMirrorTokenName).Order("id desc").First(token).Error + if err != nil { + return nil, err + } + return token, nil +} + +func disableUserAndTokens(user *model.User) error { + user.Status = common.UserStatusDisabled + if err := user.Update(false); err != nil { + return err + } + + tokens := make([]model.Token, 0) + if err := model.DB.Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil { + return err + } + for i := range tokens { + tokens[i].Status = common.TokenStatusDisabled + if err := tokens[i].Update(); err != nil { + return err + } + } + return nil +} + +func buildMirrorResponse(user *model.User, token *model.Token, status string, includeTokenKey bool) *museDTO.SyncMuseUserResponse { + response := &museDTO.SyncMuseUserResponse{Status: status} + if user != nil { + response.NewAPIUserID = user.Id + } + if token != nil { + response.TokenID = token.Id + if includeTokenKey { + response.TokenKey = token.GetFullKey() + } + } + return response +} + +func normalizeMuseStatus(status string) string { + if strings.EqualFold(strings.TrimSpace(status), museUserStatusDisabled) { + return museUserStatusDisabled + } + return museUserStatusActive +} + +func chooseDisplayName(displayName string, username string) string { + if strings.TrimSpace(displayName) != "" { + return trimToLength(displayName, model.UserNameMaxLength) + } + return trimToLength(username, model.UserNameMaxLength) +} + +func chooseMirrorUsername(desired string, museUserID string, currentUserID int, currentUsername string) string { + desired = trimToLength(desired, model.UserNameMaxLength) + if desired != "" && usernameAvailable(desired, currentUserID) { + return desired + } + currentUsername = trimToLength(currentUsername, model.UserNameMaxLength) + if currentUsername != "" && usernameAvailable(currentUsername, currentUserID) { + return currentUsername + } + fallback := trimToLength(fmt.Sprintf("muse_%s", common.GenerateHMAC(museUserID)[:15]), model.UserNameMaxLength) + if usernameAvailable(fallback, currentUserID) { + return fallback + } + for i := 0; i < 3; i++ { + candidate := trimToLength("m"+common.GetRandomString(model.UserNameMaxLength-1), model.UserNameMaxLength) + if usernameAvailable(candidate, currentUserID) { + return candidate + } + } + return fallback +} + +func usernameAvailable(username string, currentUserID int) bool { + if username == "" { + return false + } + query := model.DB.Model(&model.User{}).Where("username = ?", username) + if currentUserID > 0 { + query = query.Where("id <> ?", currentUserID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return false + } + return count == 0 +} + +func trimToLength(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + return value[:limit] +} + +func userStatusToMuseStatus(status int) string { + if status == common.UserStatusDisabled { + return museUserStatusDisabled + } + return museUserStatusActive +}