diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index d7e15377..f8b22ee7 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -28,6 +28,26 @@ jobs: working-directory: backend run: make test-integration + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Frontend typecheck and critical vitest + run: make test-frontend + golangci-lint: runs-on: ubuntu-latest steps: @@ -46,4 +66,4 @@ jobs: with: version: v2.9 args: --timeout=30m - working-directory: backend \ No newline at end of file + working-directory: backend diff --git a/Makefile b/Makefile index fd6a5a9a..d00d0c4f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,12 @@ -.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-datamanagementd secret-scan +.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan + +FRONTEND_CRITICAL_VITEST := \ + src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \ + src/views/auth/__tests__/WechatCallbackView.spec.ts \ + src/views/user/__tests__/PaymentView.spec.ts \ + src/views/user/__tests__/PaymentResultView.spec.ts \ + src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \ + src/views/admin/__tests__/SettingsView.spec.ts # 一键编译前后端 build: build-backend build-frontend @@ -24,6 +32,10 @@ test-backend: test-frontend: @pnpm --dir frontend run lint:check @pnpm --dir frontend run typecheck + @$(MAKE) test-frontend-critical + +test-frontend-critical: + @pnpm --dir frontend exec vitest run $(FRONTEND_CRITICAL_VITEST) test-datamanagementd: @cd datamanagement && go test ./... diff --git a/README.md b/README.md index 3e609d65..aa27d907 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,18 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot - **Smart Scheduling** - Intelligent account selection with sticky sessions - **Concurrency Control** - Per-user and per-account concurrency limits - **Rate Limiting** - Configurable request and token rate limits -- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md)) +- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Payment Setup](#payment)) - **Admin Dashboard** - Web interface for monitoring and management - **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard +## Payment + +Sub2API includes the payment system in the main service. No standalone payment service or separate payment guide is required. + +- Supported providers: EasyPay, Alipay, WeChat Pay, Stripe +- The frontend keeps user-facing methods unified; admins choose the backing source in `Admin -> Settings -> Payment` +- Callback URLs are generated from the site domain when configuring providers + ## ❤️ Sponsors > [Want to appear here?](mailto:support@pincc.ai) @@ -109,7 +117,7 @@ Community projects that extend or integrate with Sub2API: | Project | Description | Features | |---------|-------------|----------| -| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Configuration Guide](docs/PAYMENT.md) | +| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Setup](#payment) | | [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | Mobile admin console | Cross-platform app (iOS/Android/Web) for user management, account management, monitoring dashboard, and multi-backend switching; built with Expo + React Native | ## Tech Stack diff --git a/README_CN.md b/README_CN.md index add32a17..530a0c80 100644 --- a/README_CN.md +++ b/README_CN.md @@ -41,10 +41,18 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 - **智能调度** - 智能账号选择,支持粘性会话 - **并发控制** - 用户级和账号级并发限制 - **速率限制** - 可配置的请求和 Token 速率限制 -- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md)) +- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([支付说明](#支付)) - **管理后台** - Web 界面进行监控和管理 - **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能 +## 支付 + +Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也不再依赖单独的支付配置文档。 + +- 支持服务商:EasyPay 易支付、支付宝官方、微信官方、Stripe +- 前台统一展示用户可见支付方式,管理员在 `管理后台 -> 设置 -> 支付` 里选择对应来源 +- 添加服务商时会基于站点域名生成回调地址 + ## ❤️ 赞助商 > [想出现在这里?](mailto:support@pincc.ai) @@ -108,7 +116,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 | 项目 | 说明 | 功能 | |------|------|------| -| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) | +| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付说明](#支付) | | [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 | ## 技术栈 diff --git a/README_JA.md b/README_JA.md index ccd595b9..b852b358 100644 --- a/README_JA.md +++ b/README_JA.md @@ -42,10 +42,18 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを - **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択 - **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限 - **レート制限** - 設定可能なリクエスト数およびトークンレート制限 -- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md)) +- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([決済案内](#決済)) - **管理ダッシュボード** - 監視・管理のための Web インターフェース - **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能 +## 決済 + +Sub2API の決済機能は本体に統合されています。独立した決済サービスや別個の決済ガイドは不要です。 + +- 対応プロバイダー: EasyPay、Alipay、WeChat Pay、Stripe +- フロントエンドではユーザー向け決済方法を統一表示し、管理者は `管理画面 -> 設定 -> 決済` で実際の接続先を選択します +- プロバイダー設定時のコールバック URL はサイトドメインから自動生成されます + ## ❤️ スポンサー > [こちらに掲載しませんか?](mailto:support@pincc.ai) @@ -108,7 +116,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト: | プロジェクト | 説明 | 機能 | |---------|-------------|----------| -| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済設定ガイド](docs/PAYMENT.md)をご参照ください | +| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済案内](#決済)をご参照ください | | [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリ(iOS/Android/Web)。Expo + React Native で構築 | ## 技術スタック diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 867d8c9e..f74c2b72 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -249,7 +249,7 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) { return } - updatedUser, err := h.userService.UnbindUserAuthProvider( + updatedUser, unbound, err := h.userService.UnbindUserAuthProviderWithResult( c.Request.Context(), subject.UserID, c.Param("provider"), @@ -258,7 +258,7 @@ func (h *UserHandler) UnbindIdentity(c *gin.Context) { response.ErrorFrom(c, err) return } - if h.authService != nil { + if unbound && h.authService != nil { if err := h.authService.RevokeAllUserTokens(c.Request.Context(), subject.UserID); err != nil { response.ErrorFrom(c, err) return @@ -512,7 +512,7 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity var avatarSource *userProfileSourceContext avatarValue := strings.TrimSpace(user.AvatarURL) for _, summary := range thirdParty { - if avatarValue != "" && avatarValue == strings.TrimSpace(summary.DisplayName) { + if avatarValue != "" && avatarValue == strings.TrimSpace(summary.AvatarURL) { avatarSource = buildUserProfileSourceContext(summary.Provider) break } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index c212603b..e4985a22 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -636,6 +636,50 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure require.Equal(t, int64(5), repo.user.TokenVersion) } +func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &userHandlerRepoStub{ + user: &service.User{ + ID: 24, + Email: "identity@example.com", + Username: "identity-user", + Role: service.RoleUser, + Status: service.StatusActive, + TokenVersion: 4, + }, + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "email", + ProviderKey: "email", + ProviderSubject: "identity@example.com", + }, + }, + } + refreshTokenCache := &userHandlerRefreshTokenCacheStub{} + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-secret", + ExpireHour: 1, + }, + } + authService := service.NewAuthService(nil, repo, nil, refreshTokenCache, cfg, nil, nil, nil, nil, nil, nil) + handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), authService, nil, nil) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodDelete, "/api/v1/user/account-bindings/linuxdo", nil) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 24}) + c.Params = gin.Params{{Key: "provider", Value: "linuxdo"}} + + handler.UnbindIdentity(c) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Empty(t, repo.unbound) + require.Empty(t, refreshTokenCache.revokedUserIDs) + require.Equal(t, int64(4), repo.user.TokenVersion) +} + func TestUserHandlerBindEmailIdentityRejectsWrongCurrentPasswordForBoundEmail(t *testing.T) { gin.SetMode(gin.TestMode) @@ -728,7 +772,7 @@ func TestUserHandlerStartIdentityBindingReturnsAuthorizeURL(t *testing.T) { require.Equal(t, "wechat", resp.Data.Provider) require.Equal(t, "GET", resp.Data.Method) require.True(t, resp.Data.UseBrowserRedirect) - require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/start") + require.Contains(t, resp.Data.AuthorizeURL, "/api/v1/auth/oauth/wechat/bind/start") require.Contains(t, resp.Data.AuthorizeURL, "intent=bind_current_user") require.Contains(t, resp.Data.AuthorizeURL, "redirect=%2Fsettings%2Fprofile") } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 3d933dbc..30ddf0a2 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -85,7 +85,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "oidc": { "provider": "oidc", @@ -93,7 +93,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "wechat": { "provider": "wechat", @@ -101,7 +101,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "identity_bindings": { @@ -122,7 +122,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "oidc": { "provider": "oidc", @@ -130,7 +130,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "wechat": { "provider": "wechat", @@ -138,7 +138,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "auth_bindings": { @@ -159,7 +159,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/linuxdo/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "oidc": { "provider": "oidc", @@ -167,7 +167,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/oidc/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" }, "wechat": { "provider": "wechat", @@ -175,7 +175,7 @@ func TestAPIContracts(t *testing.T) { "bound_count": 0, "can_bind": true, "can_unbind": false, - "bind_start_path": "/api/v1/auth/oauth/wechat/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "run_mode": "standard" diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index b4b75795..642a2103 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -63,8 +63,20 @@ func RegisterAuthRoutes( FailureMode: middleware.RateLimitFailClose, }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) + auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) { + query := c.Request.URL.Query() + query.Set("intent", "bind_current_user") + c.Request.URL.RawQuery = query.Encode() + h.Auth.LinuxDoOAuthStart(c) + }) auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback) auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart) + auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) { + query := c.Request.URL.Query() + query.Set("intent", "bind_current_user") + c.Request.URL.RawQuery = query.Encode() + h.Auth.WeChatOAuthStart(c) + }) auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback) auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart) auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback) @@ -129,6 +141,12 @@ func RegisterAuthRoutes( h.Auth.CreateWeChatOAuthAccount, ) auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart) + auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) { + query := c.Request.URL.Query() + query.Set("intent", "bind_current_user") + c.Request.URL.RawQuery = query.Encode() + h.Auth.OIDCOAuthStart(c) + }) auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback) auth.POST("/oauth/oidc/complete-registration", rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{ @@ -165,23 +183,5 @@ func RegisterAuthRoutes( // 撤销所有会话(需要认证) authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions) authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie) - authenticated.GET("/auth/oauth/linuxdo/bind/start", func(c *gin.Context) { - query := c.Request.URL.Query() - query.Set("intent", "bind_current_user") - c.Request.URL.RawQuery = query.Encode() - h.Auth.LinuxDoOAuthStart(c) - }) - authenticated.GET("/auth/oauth/oidc/bind/start", func(c *gin.Context) { - query := c.Request.URL.Query() - query.Set("intent", "bind_current_user") - c.Request.URL.RawQuery = query.Encode() - h.Auth.OIDCOAuthStart(c) - }) - authenticated.GET("/auth/oauth/wechat/bind/start", func(c *gin.Context) { - query := c.Request.URL.Query() - query.Set("intent", "bind_current_user") - c.Request.URL.RawQuery = query.Encode() - h.Auth.WeChatOAuthStart(c) - }) } } diff --git a/backend/internal/service/auth_email_binding.go b/backend/internal/service/auth_email_binding.go index f0483800..78f1185d 100644 --- a/backend/internal/service/auth_email_binding.go +++ b/backend/internal/service/auth_email_binding.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/authidentity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) // BindEmailIdentity verifies and binds a local email/password identity to the @@ -69,6 +70,7 @@ func (s *AuthService) BindEmailIdentity( if err := s.updateBoundEmailIdentityTx(ctx, currentUser, normalizedEmail, hashedPassword, firstRealEmailBind); err != nil { return nil, err } + s.revokeEmailIdentitySessions(ctx, userID) return currentUser, nil } @@ -87,6 +89,7 @@ func (s *AuthService) BindEmailIdentity( } } + s.revokeEmailIdentitySessions(ctx, userID) return currentUser, nil } @@ -219,6 +222,12 @@ func (s *AuthService) updateBoundEmailIdentityWithClient( return nil } +func (s *AuthService) revokeEmailIdentitySessions(ctx context.Context, userID int64) { + if err := s.RevokeAllUserSessions(ctx, userID); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to revoke refresh sessions after email identity bind for user %d: %v", userID, err) + } +} + func replaceBoundEmailAuthIdentityWithClient( ctx context.Context, client *dbent.Client, diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index d32a4a40..cced842a 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -6,6 +6,7 @@ import ( "context" "database/sql" "errors" + "sync" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/Wei-Shaw/sub2api/ent/authidentity" "github.com/Wei-Shaw/sub2api/ent/enttest" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/stretchr/testify/require" @@ -54,6 +56,16 @@ func newAuthServiceForEmailBind( settings map[string]string, emailCache service.EmailCache, defaultSubAssigner service.DefaultSubscriptionAssigner, +) (*service.AuthService, service.UserRepository, *dbent.Client) { + return newAuthServiceForEmailBindWithRefreshCache(t, settings, emailCache, defaultSubAssigner, nil) +} + +func newAuthServiceForEmailBindWithRefreshCache( + t *testing.T, + settings map[string]string, + emailCache service.EmailCache, + defaultSubAssigner service.DefaultSubscriptionAssigner, + refreshTokenCache service.RefreshTokenCache, ) (*service.AuthService, service.UserRepository, *dbent.Client) { t.Helper() @@ -98,7 +110,7 @@ CREATE TABLE IF NOT EXISTS user_provider_default_grants ( emailSvc = service.NewEmailService(settingRepo, emailCache) } - svc := service.NewAuthService(client, repo, nil, nil, cfg, settingSvc, emailSvc, nil, nil, nil, defaultSubAssigner) + svc := service.NewAuthService(client, repo, nil, refreshTokenCache, cfg, settingSvc, emailSvc, nil, nil, nil, defaultSubAssigner) return svc, repo, client } @@ -427,6 +439,61 @@ func TestAuthServiceBindEmailIdentity_RejectsWrongCurrentPasswordForBoundEmail(t require.Equal(t, 0, newIdentityCount) } +func TestAuthServiceBindEmailIdentity_RevokesExistingAccessAndRefreshTokens(t *testing.T) { + ctx := context.Background() + cache := &emailBindCacheStub{ + data: &service.VerificationCodeData{ + Code: "123456", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }, + } + refreshTokenCache := newEmailBindRefreshTokenCacheStub() + userRepo := newEmailBindUserRepoStub(&service.User{ + ID: 41, + Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain, + Username: "legacy-user", + PasswordHash: "old-hash", + Role: service.RoleUser, + Status: service.StatusActive, + TokenVersion: 4, + }) + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: "test-bind-email-secret", + ExpireHour: 1, + AccessTokenExpireMinutes: 60, + RefreshTokenExpireDays: 7, + }, + } + emailService := service.NewEmailService(nil, cache) + svc := service.NewAuthService(nil, userRepo, nil, refreshTokenCache, cfg, nil, emailService, nil, nil, nil, nil) + + oldTokenPair, err := svc.GenerateTokenPair(ctx, &service.User{ + ID: 41, + Email: "legacy-user" + service.OIDCConnectSyntheticEmailDomain, + Role: service.RoleUser, + Status: service.StatusActive, + TokenVersion: 4, + }, "") + require.NoError(t, err) + + updatedUser, err := svc.BindEmailIdentity(ctx, 41, "new@example.com", "123456", "new-password") + require.NoError(t, err) + require.NotNil(t, updatedUser) + + storedUser, err := userRepo.GetByID(ctx, 41) + require.NoError(t, err) + require.Equal(t, "new@example.com", storedUser.Email) + require.True(t, svc.CheckPassword("new-password", storedUser.PasswordHash)) + + _, err = svc.RefreshToken(ctx, oldTokenPair.AccessToken) + require.ErrorIs(t, err, service.ErrTokenRevoked) + + _, err = svc.RefreshTokenPair(ctx, oldTokenPair.RefreshToken) + require.True(t, errors.Is(err, service.ErrTokenRevoked) || errors.Is(err, service.ErrRefreshTokenInvalid)) +} + type emailBindSettingRepoStub struct { values map[string]string } @@ -527,3 +594,260 @@ func (s *emailBindCacheStub) GetNotifyCodeUserRate(context.Context, int64) (int6 func (s *emailBindCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) { return 0, nil } + +type emailBindRefreshTokenCacheStub struct { + mu sync.Mutex + tokens map[string]*service.RefreshTokenData + userSets map[int64]map[string]struct{} + families map[string]map[string]struct{} +} + +func newEmailBindRefreshTokenCacheStub() *emailBindRefreshTokenCacheStub { + return &emailBindRefreshTokenCacheStub{ + tokens: make(map[string]*service.RefreshTokenData), + userSets: make(map[int64]map[string]struct{}), + families: make(map[string]map[string]struct{}), + } +} + +func (s *emailBindRefreshTokenCacheStub) StoreRefreshToken(_ context.Context, tokenHash string, data *service.RefreshTokenData, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + cloned := *data + s.tokens[tokenHash] = &cloned + return nil +} + +func (s *emailBindRefreshTokenCacheStub) GetRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, ok := s.tokens[tokenHash] + if !ok { + return nil, service.ErrRefreshTokenNotFound + } + cloned := *data + return &cloned, nil +} + +func (s *emailBindRefreshTokenCacheStub) DeleteRefreshToken(_ context.Context, tokenHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.tokens, tokenHash) + for _, tokenSet := range s.userSets { + delete(tokenSet, tokenHash) + } + for _, tokenSet := range s.families { + delete(tokenSet, tokenHash) + } + return nil +} + +func (s *emailBindRefreshTokenCacheStub) DeleteUserRefreshTokens(_ context.Context, userID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + for tokenHash := range s.userSets[userID] { + delete(s.tokens, tokenHash) + for _, tokenSet := range s.families { + delete(tokenSet, tokenHash) + } + } + delete(s.userSets, userID) + return nil +} + +func (s *emailBindRefreshTokenCacheStub) DeleteTokenFamily(_ context.Context, familyID string) error { + s.mu.Lock() + defer s.mu.Unlock() + for tokenHash := range s.families[familyID] { + delete(s.tokens, tokenHash) + for _, tokenSet := range s.userSets { + delete(tokenSet, tokenHash) + } + } + delete(s.families, familyID) + return nil +} + +func (s *emailBindRefreshTokenCacheStub) AddToUserTokenSet(_ context.Context, userID int64, tokenHash string, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.userSets[userID] == nil { + s.userSets[userID] = make(map[string]struct{}) + } + s.userSets[userID][tokenHash] = struct{}{} + return nil +} + +func (s *emailBindRefreshTokenCacheStub) AddToFamilyTokenSet(_ context.Context, familyID string, tokenHash string, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.families[familyID] == nil { + s.families[familyID] = make(map[string]struct{}) + } + s.families[familyID][tokenHash] = struct{}{} + return nil +} + +func (s *emailBindRefreshTokenCacheStub) GetUserTokenHashes(_ context.Context, userID int64) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + tokenSet := s.userSets[userID] + out := make([]string, 0, len(tokenSet)) + for tokenHash := range tokenSet { + out = append(out, tokenHash) + } + return out, nil +} + +func (s *emailBindRefreshTokenCacheStub) GetFamilyTokenHashes(_ context.Context, familyID string) ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + tokenSet := s.families[familyID] + out := make([]string, 0, len(tokenSet)) + for tokenHash := range tokenSet { + out = append(out, tokenHash) + } + return out, nil +} + +func (s *emailBindRefreshTokenCacheStub) IsTokenInFamily(_ context.Context, familyID string, tokenHash string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.families[familyID][tokenHash] + return ok, nil +} + +type emailBindUserRepoStub struct { + mu sync.Mutex + usersByID map[int64]*service.User + usersByEmail map[string]*service.User +} + +func newEmailBindUserRepoStub(user *service.User) *emailBindUserRepoStub { + cloned := cloneEmailBindUser(user) + return &emailBindUserRepoStub{ + usersByID: map[int64]*service.User{ + cloned.ID: cloned, + }, + usersByEmail: map[string]*service.User{ + cloned.Email: cloned, + }, + } +} + +func (s *emailBindUserRepoStub) Create(context.Context, *service.User) error { return nil } + +func (s *emailBindUserRepoStub) GetByID(_ context.Context, id int64) (*service.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + user, ok := s.usersByID[id] + if !ok { + return nil, service.ErrUserNotFound + } + return cloneEmailBindUser(user), nil +} + +func (s *emailBindUserRepoStub) GetByEmail(_ context.Context, email string) (*service.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + user, ok := s.usersByEmail[email] + if !ok { + return nil, service.ErrUserNotFound + } + return cloneEmailBindUser(user), nil +} + +func (s *emailBindUserRepoStub) GetFirstAdmin(context.Context) (*service.User, error) { + panic("unexpected GetFirstAdmin call") +} + +func (s *emailBindUserRepoStub) Update(_ context.Context, user *service.User) error { + s.mu.Lock() + defer s.mu.Unlock() + existing, ok := s.usersByID[user.ID] + if !ok { + return service.ErrUserNotFound + } + delete(s.usersByEmail, existing.Email) + cloned := cloneEmailBindUser(user) + s.usersByID[user.ID] = cloned + s.usersByEmail[cloned.Email] = cloned + return nil +} + +func (s *emailBindUserRepoStub) Delete(context.Context, int64) error { return nil } + +func (s *emailBindUserRepoStub) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) { + return nil, nil +} + +func (s *emailBindUserRepoStub) UpsertUserAvatar(context.Context, int64, service.UpsertUserAvatarInput) (*service.UserAvatar, error) { + panic("unexpected UpsertUserAvatar call") +} + +func (s *emailBindUserRepoStub) DeleteUserAvatar(context.Context, int64) error { + panic("unexpected DeleteUserAvatar call") +} + +func (s *emailBindUserRepoStub) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (s *emailBindUserRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (s *emailBindUserRepoStub) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { + return map[int64]*time.Time{}, nil +} + +func (s *emailBindUserRepoStub) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) { + return nil, nil +} + +func (s *emailBindUserRepoStub) UpdateUserLastActiveAt(context.Context, int64, time.Time) error { + return nil +} + +func (s *emailBindUserRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil } +func (s *emailBindUserRepoStub) DeductBalance(context.Context, int64, float64) error { return nil } +func (s *emailBindUserRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil } + +func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.usersByEmail[email] + return ok, nil +} + +func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { + return 0, nil +} + +func (s *emailBindUserRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { + return nil +} + +func (s *emailBindUserRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { + return nil +} + +func (s *emailBindUserRepoStub) ListUserAuthIdentities(context.Context, int64) ([]service.UserAuthIdentityRecord, error) { + return nil, nil +} + +func (s *emailBindUserRepoStub) UnbindUserAuthProvider(context.Context, int64, string) error { + return nil +} + +func (s *emailBindUserRepoStub) UpdateTotpSecret(context.Context, int64, *string) error { return nil } +func (s *emailBindUserRepoStub) EnableTotp(context.Context, int64) error { return nil } +func (s *emailBindUserRepoStub) DisableTotp(context.Context, int64) error { return nil } + +func cloneEmailBindUser(user *service.User) *service.User { + if user == nil { + return nil + } + cloned := *user + return &cloned +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index c16d810b..a211103f 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -127,6 +127,7 @@ type UserIdentitySummary struct { Bound bool `json:"bound"` BoundCount int `json:"bound_count"` DisplayName string `json:"display_name,omitempty"` + AvatarURL string `json:"-"` SubjectHint string `json:"subject_hint,omitempty"` ProviderKey string `json:"provider_key,omitempty"` VerifiedAt *time.Time `json:"verified_at,omitempty"` @@ -228,6 +229,7 @@ func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, erro if err != nil { return nil, fmt.Errorf("get user: %w", err) } + normalizeLoadedUserTokenVersion(user) if err := s.hydrateUserAvatar(ctx, user); err != nil { return nil, fmt.Errorf("get user avatar: %w", err) } @@ -323,29 +325,34 @@ func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUs } func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) (*User, error) { + user, _, err := s.UnbindUserAuthProviderWithResult(ctx, userID, provider) + return user, err +} + +func (s *UserService) UnbindUserAuthProviderWithResult(ctx context.Context, userID int64, provider string) (*User, bool, error) { provider = normalizeUserIdentityProvider(provider) if provider == "" || provider == "email" { - return nil, ErrIdentityProviderInvalid + return nil, false, ErrIdentityProviderInvalid } user, err := s.userRepo.GetByID(ctx, userID) if err != nil { - return nil, fmt.Errorf("get user: %w", err) + return nil, false, fmt.Errorf("get user: %w", err) } records, err := s.listUserAuthIdentities(ctx, userID) if err != nil { - return nil, err + return nil, false, err } if len(filterUserAuthIdentities(records, provider)) == 0 { - return user, nil + return user, false, nil } if !s.canUnbindProvider(provider, user, records) { - return nil, ErrIdentityUnbindLastMethod + return nil, false, ErrIdentityUnbindLastMethod } if err := s.userRepo.UnbindUserAuthProvider(ctx, userID, provider); err != nil { - return nil, err + return nil, false, err } if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) @@ -353,9 +360,9 @@ func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, updatedUser, err := s.GetProfile(ctx, userID) if err != nil { - return nil, err + return nil, false, err } - return updatedUser, nil + return updatedUser, true, nil } // UpdateProfile 更新用户资料 @@ -655,6 +662,7 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User, summary.Bound = true summary.BoundCount = len(filtered) summary.DisplayName = userAuthIdentityDisplayName(primary) + summary.AvatarURL = strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "avatar_url", "suggested_avatar_url", "headimgurl")) summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject) summary.ProviderKey = strings.TrimSpace(primary.ProviderKey) summary.VerifiedAt = primary.VerifiedAt @@ -672,7 +680,7 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U return false } - if s.buildEmailIdentitySummary(user, records).Bound { + if s.canUseEmailAsSignInMethod(user, records) { return true } @@ -688,6 +696,44 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U return false } +func (s *UserService) canUseEmailAsSignInMethod(user *User, records []UserAuthIdentityRecord) bool { + if user == nil { + return false + } + + email := strings.ToLower(strings.TrimSpace(user.Email)) + if email == "" || isReservedEmail(email) { + return false + } + + if emailSignupSourceAllowsLogin(user.SignupSource) { + return true + } + + for _, record := range filterUserAuthIdentities(records, "email") { + if emailIdentitySupportsSignIn(record) { + return true + } + } + + return false +} + +func emailSignupSourceAllowsLogin(signupSource string) bool { + signupSource = strings.ToLower(strings.TrimSpace(signupSource)) + return signupSource == "" || signupSource == "email" +} + +func emailIdentitySupportsSignIn(record UserAuthIdentityRecord) bool { + source := strings.TrimSpace(firstStringIdentityValue(record.Metadata, "source")) + switch source { + case "auth_service_email_bind", "auth_service_login_backfill", "auth_service_dual_write": + return true + default: + return false + } +} + func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) { if userID <= 0 || s == nil || s.userRepo == nil { return nil, nil @@ -709,11 +755,11 @@ func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, err path := "" switch provider { case "linuxdo": - path = "/api/v1/auth/oauth/linuxdo/start" + path = "/api/v1/auth/oauth/linuxdo/bind/start" case "oidc": - path = "/api/v1/auth/oauth/oidc/start" + path = "/api/v1/auth/oauth/oidc/bind/start" case "wechat": - path = "/api/v1/auth/oauth/wechat/start" + path = "/api/v1/auth/oauth/wechat/bind/start" default: return "", ErrIdentityProviderInvalid } @@ -889,12 +935,20 @@ func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) { if err != nil { return nil, fmt.Errorf("get user: %w", err) } + normalizeLoadedUserTokenVersion(user) if err := s.hydrateUserAvatar(ctx, user); err != nil { return nil, fmt.Errorf("get user avatar: %w", err) } return user, nil } +func normalizeLoadedUserTokenVersion(user *User) { + if user == nil { + return + } + user.TokenVersion = resolvedTokenVersion(user) +} + // TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。 // 该操作为尽力而为,不应中断正常请求。 func (s *UserService) TouchLastActive(ctx context.Context, userID int64) { diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 0ad95356..ff55c2a5 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -387,6 +387,70 @@ func TestUnbindUserAuthProviderRejectsLastRemainingLoginMethod(t *testing.T) { require.Empty(t, repo.unboundProviders) } +func TestGetProfileIdentitySummaries_DoesNotTreatOAuthOnlyCompatEmailAsAlternativeLoginMethod(t *testing.T) { + repo := &mockUserRepo{ + getByIDUser: &User{ + ID: 10, + Email: "oauth-only@example.com", + SignupSource: "oidc", + }, + identities: []UserAuthIdentityRecord{ + { + ProviderType: "oidc", + ProviderKey: "https://issuer.example.com", + ProviderSubject: "oidc-only-subject", + }, + }, + } + svc := NewUserService(repo, nil, nil, nil) + + summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 10, repo.getByIDUser) + + require.NoError(t, err) + require.False(t, summaries.OIDC.CanUnbind) + + _, err = svc.UnbindUserAuthProvider(context.Background(), 10, "oidc") + require.ErrorIs(t, err, ErrIdentityUnbindLastMethod) + require.Empty(t, repo.unboundProviders) +} + +func TestGetProfileIdentitySummaries_DoesNotTreatCompatBackfilledEmailIdentityAsAlternativeLoginMethod(t *testing.T) { + repo := &mockUserRepo{ + getByIDUser: &User{ + ID: 11, + Email: "oauth-only@example.com", + SignupSource: "wechat", + }, + identities: []UserAuthIdentityRecord{ + { + ProviderType: "email", + ProviderKey: "email", + ProviderSubject: "oauth-only@example.com", + Metadata: map[string]any{ + "backfill_source": "users.email", + "migration": "109_auth_identity_compat_backfill", + }, + }, + { + ProviderType: "wechat", + ProviderKey: "wechat", + ProviderSubject: "wechat-only-subject", + }, + }, + } + svc := NewUserService(repo, nil, nil, nil) + + summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 11, repo.getByIDUser) + + require.NoError(t, err) + require.True(t, summaries.Email.Bound) + require.False(t, summaries.WeChat.CanUnbind) + + _, err = svc.UnbindUserAuthProvider(context.Background(), 11, "wechat") + require.ErrorIs(t, err, ErrIdentityUnbindLastMethod) + require.Empty(t, repo.unboundProviders) +} + func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testing.T) { repo := &mockUserRepo{ getByIDUser: &User{ @@ -451,6 +515,42 @@ func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabl require.Empty(t, summaries.LinuxDo.BindStartPath) } +func TestGetProfileIdentitySummaries_UsesBindStartRoute(t *testing.T) { + repo := &mockUserRepo{ + getByIDUser: &User{ + ID: 16, + Email: "alice@example.com", + }, + identities: []UserAuthIdentityRecord{ + { + ProviderType: "email", + ProviderKey: "email", + ProviderSubject: "alice@example.com", + }, + }, + } + svc := NewUserService(repo, nil, nil, nil) + + summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 16, repo.getByIDUser) + + require.NoError(t, err) + require.Equal( + t, + "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile", + summaries.LinuxDo.BindStartPath, + ) + require.Equal( + t, + "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile", + summaries.OIDC.BindStartPath, + ) + require.Equal( + t, + "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile", + summaries.WeChat.BindStartPath, + ) +} + func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) { repo := &mockUserRepo{} svc := NewUserService(repo, nil, nil, nil) // billingCache = nil diff --git a/frontend/src/api/__tests__/admin.users.spec.ts b/frontend/src/api/__tests__/admin.users.spec.ts new file mode 100644 index 00000000..37656b78 --- /dev/null +++ b/frontend/src/api/__tests__/admin.users.spec.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { post } = vi.hoisted(() => ({ + post: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ + apiClient: { + post, + }, +})) + +import { + bindUserAuthIdentity, + type AdminBindAuthIdentityRequest, + type AdminBoundAuthIdentity, +} from '@/api/admin/users' + +type Assert = T +type IsExact = ( + (() => G extends T ? 1 : 2) extends (() => G extends U ? 1 : 2) + ? ((() => G extends U ? 1 : 2) extends (() => G extends T ? 1 : 2) ? true : false) + : false +) + +type ExpectedAdminBindAuthIdentityRequest = { + provider_type: string + provider_key: string + provider_subject: string + issuer?: string + metadata?: Record + channel?: { + channel: string + channel_app_id: string + channel_subject: string + metadata?: Record + } +} + +type ExpectedAdminBoundAuthIdentity = { + user_id: number + provider_type: string + provider_key: string + provider_subject: string + verified_at?: string | null + issuer?: string | null + metadata: Record | null + created_at: string + updated_at: string + channel?: { + channel: string + channel_app_id: string + channel_subject: string + metadata: Record | null + created_at: string + updated_at: string + } | null +} + +const requestContractExact: Assert< + IsExact +> = true +const responseContractExact: Assert< + IsExact +> = true + +describe('admin users api auth identity binding', () => { + beforeEach(() => { + post.mockReset() + }) + + it('posts the backend-compatible auth identity bind payload and returns the backend response shape', async () => { + const payload: AdminBindAuthIdentityRequest = { + provider_type: 'wechat', + provider_key: 'wechat-main', + provider_subject: 'union-123', + metadata: { source: 'admin-repair' }, + channel: { + channel: 'open', + channel_app_id: 'wx-open', + channel_subject: 'openid-123', + metadata: { scene: 'migration' }, + }, + } + + const response: AdminBoundAuthIdentity = { + user_id: 9, + provider_type: 'wechat', + provider_key: 'wechat-main', + provider_subject: 'union-123', + verified_at: '2026-04-22T00:00:00Z', + issuer: null, + metadata: { source: 'admin-repair' }, + created_at: '2026-04-22T00:00:00Z', + updated_at: '2026-04-22T00:00:00Z', + channel: { + channel: 'open', + channel_app_id: 'wx-open', + channel_subject: 'openid-123', + metadata: { scene: 'migration' }, + created_at: '2026-04-22T00:00:00Z', + updated_at: '2026-04-22T00:00:00Z', + }, + } + post.mockResolvedValue({ data: response }) + + const result = await bindUserAuthIdentity(9, payload) + + expect(post).toHaveBeenCalledWith('/admin/users/9/auth-identities', payload) + expect(result).toEqual(response) + }) + + it('keeps bind auth identity request and response types aligned with the backend contract', () => { + expect(requestContractExact).toBe(true) + expect(responseContractExact).toBe(true) + }) +}) diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index 0f663e76..a46c39eb 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -91,6 +91,22 @@ describe('API Client', () => { const config = adapter.mock.calls[0][0] expect(config.params?.timezone).toBeUndefined() }) + + it('请求默认带 withCredentials 以支持跨域 cookie', async () => { + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + await apiClient.post('/auth/oauth/bind-token') + + const config = adapter.mock.calls[0][0] + expect(config.withCredentials).toBe(true) + }) }) // --- 响应拦截器 --- diff --git a/frontend/src/api/__tests__/user.spec.ts b/frontend/src/api/__tests__/user.spec.ts new file mode 100644 index 00000000..887046da --- /dev/null +++ b/frontend/src/api/__tests__/user.spec.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('user api oauth binding urls', () => { + beforeEach(() => { + vi.resetModules() + vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/api/v1') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('builds third-party bind urls against the bind start endpoint', async () => { + const { buildOAuthBindingStartURL } = await import('@/api/user') + + expect(buildOAuthBindingStartURL('linuxdo', { redirectTo: '/settings/profile' })).toBe( + 'https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user' + ) + expect( + buildOAuthBindingStartURL('wechat', { + redirectTo: '/settings/profile', + wechatOAuthSettings: { + wechat_oauth_open_enabled: true, + wechat_oauth_mp_enabled: false, + wechat_oauth_mobile_enabled: false + } + }) + ).toBe( + 'https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open' + ) + }) +}) diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 1bb3d54c..3c75a6c4 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -8,26 +8,40 @@ import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/ export interface AdminBindAuthIdentityChannelRequest { channel: string - channel_app_id?: string + channel_app_id: string channel_subject: string - metadata?: Record + metadata?: Record | null } export interface AdminBindAuthIdentityRequest { provider_type: string provider_key: string provider_subject: string - issuer?: string - metadata?: Record + issuer?: string | null + metadata?: Record | null channel?: AdminBindAuthIdentityChannelRequest } +export interface AdminBoundAuthIdentityChannel { + channel: string + channel_app_id: string + channel_subject: string + metadata: Record | null + created_at: string + updated_at: string +} + export interface AdminBoundAuthIdentity { - identity_id: number + user_id: number provider_type: string provider_key: string provider_subject: string - channel_id?: number | null + verified_at?: string | null + issuer?: string | null + metadata: Record | null + created_at: string + updated_at: string + channel?: AdminBoundAuthIdentityChannel | null } /** diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 9621c26e..f49f3a1f 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -194,6 +194,7 @@ export interface OAuthTokenResponse { } export interface PendingOAuthBindLoginResponse extends Partial { + auth_result?: string redirect?: string error?: string requires_2fa?: boolean @@ -206,7 +207,9 @@ export interface PendingOAuthBindLoginResponse extends Partial : t('profile.authBindings.confirmEmailBindAction') ) +function resolveLegacyCompatibleWeChatSettings( + settings: WeChatOAuthPublicSettings | null | undefined +): (WeChatOAuthPublicSettings & { + wechat_oauth_open_enabled: boolean + wechat_oauth_mp_enabled: boolean +}) | null { + if (!settings) { + return null + } + + if (hasExplicitWeChatOAuthCapabilities(settings)) { + return settings + } + + if (typeof settings.wechat_oauth_enabled !== 'boolean') { + return null + } + + return { + ...settings, + wechat_oauth_open_enabled: settings.wechat_oauth_enabled, + wechat_oauth_mp_enabled: settings.wechat_oauth_enabled, + } +} + const wechatOAuthSettings = computed(() => { - if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) { - return appStore.cachedPublicSettings + const cachedSettings = resolveLegacyCompatibleWeChatSettings(appStore.cachedPublicSettings) + if (cachedSettings) { + return cachedSettings } - if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') { - return { - wechat_oauth_enabled: props.wechatEnabled, - wechat_oauth_open_enabled: props.wechatOpenEnabled, - wechat_oauth_mp_enabled: props.wechatMpEnabled, - } - } - - return null + return resolveLegacyCompatibleWeChatSettings({ + wechat_oauth_enabled: props.wechatEnabled, + wechat_oauth_open_enabled: props.wechatOpenEnabled, + wechat_oauth_mp_enabled: props.wechatMpEnabled, + }) }) const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value)) @@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus | return binding } +function getDisplayableEmail(user: User | null | undefined): string { + const email = user?.email?.trim() || '' + if (!email) { + return '' + } + if (email.endsWith('.invalid') && !getBindingStatusForUser(user, 'email')) { + return '' + } + return email +} + function isProviderEnabledForBinding(provider: BindableProvider): boolean { if (provider === 'linuxdo') { return props.linuxdoEnabled @@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string { function providerSummary(provider: UserAuthProvider): string { if (provider === 'email') { - const email = currentUser.value?.email?.trim() || '' - if (!email) { - return '' - } - if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) { - return '' - } - return email + return getDisplayableEmail(currentUser.value) } return '' } diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 4544c337..37ee8a55 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue' import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue' -import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types' +import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceContext } from '@/types' const props = withDefaults(defineProps<{ user: User | null @@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{ const { t } = useI18n() +function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null { + if (typeof binding === 'boolean') { + return binding + } + if (!binding) { + return null + } + if (typeof binding.bound === 'boolean') { + return binding.bound + } + return Boolean(binding.provider_subject || binding.issuer || binding.provider_key) +} + +function isEmailBound(user: User | null | undefined): boolean { + if (typeof user?.email_bound === 'boolean') { + return user.email_bound + } + + const nested = user?.auth_bindings?.email ?? user?.identity_bindings?.email + const normalized = normalizeBindingStatus(nested) + return normalized ?? false +} + const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '') const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user')) const primaryEmailDisplay = computed(() => { @@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => { if (!email) { return '' } - if (props.user?.email_bound === false && email.endsWith('.invalid')) { + if (email.endsWith('.invalid') && !isEmailBound(props.user)) { return '' } return email diff --git a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts index 77d2219e..b54a1cce 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts @@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => { expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false) }) - it('hides the WeChat bind action when only the legacy aggregate setting is present', () => { + it('keeps the WeChat bind action visible when only the legacy aggregate setting is present', () => { const wrapper = mount(ProfileIdentityBindingsSection, { global: { plugins: [pinia], @@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => { }, }) - expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(true) + }) + + it('starts the WeChat bind flow when only the legacy aggregate setting is present', async () => { + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser(), + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: true, + }, + }) + + await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click') + + expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?') + expect(locationState.current.href).toContain('mode=open') + expect(locationState.current.href).toContain('intent=bind_current_user') + expect(locationState.current.href).toContain('redirect=%2Fprofile') }) it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => { @@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => { expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound') }) + it('does not show a synthetic oauth-only email when only fallback auth bindings mark email as unbound', () => { + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser({ + email: 'legacy-user@wechat-connect.invalid', + auth_bindings: { + email: { bound: false }, + }, + }), + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: false, + }, + }) + + expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid') + expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound') + }) + it('keeps the email form available for replacing a bound primary email', async () => { userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined) userApiMocks.bindEmailIdentity.mockResolvedValue( diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index c7e60d9b..51653c6a 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => { expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid') }) + it('does not display synthetic oauth-only emails when only legacy identity bindings mark email as unbound', () => { + const wrapper = mount(ProfileInfoCard, { + props: { + user: createUser({ + email: 'legacy-user@wechat-connect.invalid', + identity_bindings: { + email: { bound: false } + } + }) + }, + global: { + stubs: { + Icon: true + } + } + }) + + expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid') + }) + it('renders the approved overview hero and two-column content shell', () => { const wrapper = mount(ProfileInfoCard, { props: { diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 5772c501..90daae5f 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3763,11 +3763,7 @@

{{ t("admin.settings.payment.description") }} {{ t("admin.settings.payment.enabledPaymentTypesHint") }} + locale.value.startsWith("zh") + ? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98" + : "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment", +); + type SettingsTab = | "general" | "security" diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 10c51b2a..c294756e 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -46,6 +46,8 @@ const { showSuccess: vi.fn(), })); +const localeRef = vi.hoisted(() => ({ value: "zh-CN" })); + vi.mock("@/api", () => ({ adminAPI: { settings: { @@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => { "admin.settings.paymentVisibleMethods.sourceLabel": "支付来源", "admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。", "admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。", + "admin.settings.payment.configGuide": "查看支付配置说明", + "admin.settings.payment.findProvider": "查看支持的支付方式", "admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略", "admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。", }; @@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => { useI18n: () => ({ t: (key: string, params?: Record) => (translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`), - locale: ref("zh-CN"), + locale: localeRef, }), }; }); @@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => { adminSettingsFetch.mockReset(); showError.mockReset(); showSuccess.mockReset(); + localeRef.value = "zh-CN"; getSettings.mockResolvedValue({ ...baseSettingsResponse }); updateSettings.mockImplementation(async (payload) => ({ @@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => { expect(wrapper.text()).not.toContain("支付来源"); }); + it("links payment guidance to README sections instead of removed payment docs", async () => { + const wrapper = mountView(); + + await flushPromises(); + await openPaymentTab(wrapper); + + const paymentLinks = wrapper + .findAll("a") + .filter((node) => + ["查看支付配置说明", "查看支持的支付方式"].includes(node.text()), + ); + + expect(paymentLinks).toHaveLength(2); + expect(paymentLinks[0]?.attributes("href")).toBe( + "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98", + ); + expect(paymentLinks[1]?.attributes("href")).toBe( + "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98", + ); + for (const link of paymentLinks) { + expect(link.attributes("href")).not.toContain("docs/PAYMENT"); + } + }); + it("does not submit legacy visible payment method settings", async () => { const wrapper = mountView(); diff --git a/frontend/src/views/auth/LinuxDoCallbackView.vue b/frontend/src/views/auth/LinuxDoCallbackView.vue index 2cf4e694..f73d77de 100644 --- a/frontend/src/views/auth/LinuxDoCallbackView.vue +++ b/frontend/src/views/auth/LinuxDoCallbackView.vue @@ -456,7 +456,14 @@ function resolvePendingAccountAction( if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') { return 'create_account' } - if (raw === 'bind_login_required' || raw === 'bind_login') { + if ( + raw === 'bind_login_required' || + raw === 'bind_login' || + raw === 'existing_account' || + raw === 'existing_account_required' || + raw === 'existing_account_binding_required' || + raw === 'adopt_existing_user_by_email' + ) { return 'bind_login' } return 'none' diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index bae20df8..9ecc5e47 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -613,8 +613,12 @@ async function handleBindCurrentAccount() { return } - await prepareOAuthBindAccessTokenCookie() - window.location.href = startURL + try { + await prepareOAuthBindAccessTokenCookie() + window.location.href = startURL + } catch (e: unknown) { + errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed')) + } } async function handleExistingAccountBinding() { diff --git a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts index 3fee2c27..333f8dc5 100644 --- a/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts @@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => { ) }) + it('keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success', async () => { + exchangePendingOAuthCompletion.mockResolvedValue({ + error: 'adopt_existing_user_by_email', + redirect: '/profile/security', + email: 'existing@example.com' + }) + + const wrapper = mount(LinuxDoCallbackView, { + global: { + stubs: { + AuthLayout: { template: '

' }, + Icon: true, + RouterLink: { template: '' }, + transition: false + } + } + }) + + await flushPromises() + + expect(showSuccess).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() + expect((wrapper.get('[data-testid="linuxdo-bind-login-email"]').element as HTMLInputElement).value).toBe( + 'existing@example.com' + ) + }) + it('persists a pending auth session when the oauth flow still needs account creation', async () => { exchangePendingOAuthCompletion.mockResolvedValue({ error: 'email_required', diff --git a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts index da41c987..7150dd7e 100644 --- a/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts +++ b/frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts @@ -621,6 +621,34 @@ describe('WechatCallbackView', () => { expect(locationState.current.href).toContain('mode=open') }) + it('shows an error and stays on the page when preparing bind-token for the current account fails', async () => { + exchangePendingOAuthCompletionMock.mockResolvedValue({ + error: 'invitation_required', + redirect: '/usage', + }) + getAuthTokenMock.mockReturnValue('current-auth-token') + prepareOAuthBindAccessTokenCookieMock.mockRejectedValue(new Error('bind token failed')) + + const wrapper = mount(WechatCallbackView, { + global: { + stubs: { + AuthLayout: { template: '
' }, + Icon: true, + RouterLink: { template: '' }, + transition: false, + }, + }, + }) + + await flushPromises() + + await wrapper.get('[data-testid="existing-account-submit"]').trigger('click').catch(() => undefined) + await flushPromises() + + expect(showErrorMock).toHaveBeenCalledWith('bind token failed') + expect(locationState.current.href).toBe('http://localhost/auth/wechat/callback') + }) + it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => { getPublicSettingsMock.mockResolvedValue({ invitation_code_enabled: true,