From 9a894284051aba1c877520c73c26a0272a93ba27 Mon Sep 17 00:00:00 2001
From: ryan
Date: Wed, 13 May 2026 12:09:15 +0800
Subject: [PATCH] =?UTF-8?q?[=E4=BF=AE=E5=A4=8D]=20=E4=B8=AA=E4=BA=BA?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=9F=A5=E7=9C=8B=E7=AC=AC=E4=B8=89=E6=96=B9?=
=?UTF-8?q?=E8=AE=A4=E8=AF=81=E6=BA=90=E4=B8=8E=E5=A2=9E=E5=8A=A0=E8=A7=A3?=
=?UTF-8?q?=E7=BB=91=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/guide/sso.md | 2 +
openflare_server/controller/auth_source.go | 24 ++++
openflare_server/model/auth_source.go | 56 +++++++++
openflare_server/router/api-router.go | 6 +
openflare_server/router/api_phase2_test.go | 51 ++++++++
.../web/features/settings/api/settings.ts | 11 ++
.../settings/components/settings-page.tsx | 114 ++++++++++++++++--
.../web/features/settings/types.ts | 11 ++
8 files changed, 264 insertions(+), 11 deletions(-)
diff --git a/docs/guide/sso.md b/docs/guide/sso.md
index 40a4175b..74687574 100644
--- a/docs/guide/sso.md
+++ b/docs/guide/sso.md
@@ -16,6 +16,8 @@ OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub
| Client Secret | 第三方平台创建应用后提供 |
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
+**确认系统设置->通用设置->服务器地址能正确和域名匹配**
+
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
## 回调地址
diff --git a/openflare_server/controller/auth_source.go b/openflare_server/controller/auth_source.go
index 5a0aebb4..7af625f6 100644
--- a/openflare_server/controller/auth_source.go
+++ b/openflare_server/controller/auth_source.go
@@ -269,6 +269,30 @@ func LinkExistingOAuthAccount(c *gin.Context) {
respondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
}
+func ListExternalAccounts(c *gin.Context) {
+ userID := c.GetInt("id")
+ accounts, err := model.ListExternalAccountsByUserID(userID)
+ if err != nil {
+ respondFailure(c, err.Error())
+ return
+ }
+ respondSuccess(c, accounts)
+}
+
+func DeleteExternalAccount(c *gin.Context) {
+ rawID := strings.TrimSpace(c.Param("id"))
+ parsedID, err := strconv.ParseUint(rawID, 10, 64)
+ if err != nil || parsedID == 0 {
+ respondBadRequest(c, "绑定记录 ID 无效")
+ return
+ }
+ if err := model.DeleteExternalAccountForUser(uint(parsedID), c.GetInt("id")); err != nil {
+ respondFailure(c, err.Error())
+ return
+ }
+ respondSuccessMessage(c, "")
+}
+
func parseAuthSourceID(c *gin.Context) (uint, error) {
raw := c.Param("source_id")
if raw == "" {
diff --git a/openflare_server/model/auth_source.go b/openflare_server/model/auth_source.go
index 18791912..7eba858b 100644
--- a/openflare_server/model/auth_source.go
+++ b/openflare_server/model/auth_source.go
@@ -44,6 +44,17 @@ type ExternalAccount struct {
UpdatedAt time.Time `json:"updated_at"`
}
+type ExternalAccountView struct {
+ ID uint `json:"id"`
+ AuthSourceID uint `json:"auth_source_id"`
+ AuthSourceName string `json:"auth_source_name"`
+ AuthSourceType string `json:"auth_source_type"`
+ AuthSourceLabel string `json:"auth_source_label"`
+ ExternalUsername string `json:"external_username"`
+ Email string `json:"email"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
func (source *AuthSource) Normalize() {
source.Name = strings.TrimSpace(source.Name)
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
@@ -216,3 +227,48 @@ func LinkExternalAccount(account *ExternalAccount) error {
ExternalID: account.ExternalID,
}).FirstOrCreate(account).Error
}
+
+func ListExternalAccountsByUserID(userID int) ([]ExternalAccountView, error) {
+ if userID <= 0 {
+ return nil, errors.New("用户 ID 不能为空")
+ }
+ var accounts []ExternalAccount
+ if err := DB.Preload("AuthSource").Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil {
+ return nil, err
+ }
+ views := make([]ExternalAccountView, 0, len(accounts))
+ for _, account := range accounts {
+ label := account.AuthSource.DisplayName
+ if label == "" {
+ label = account.AuthSource.Name
+ }
+ views = append(views, ExternalAccountView{
+ ID: account.ID,
+ AuthSourceID: account.AuthSourceID,
+ AuthSourceName: account.AuthSource.Name,
+ AuthSourceType: account.AuthSource.Type,
+ AuthSourceLabel: label,
+ ExternalUsername: account.ExternalUsername,
+ Email: account.Email,
+ CreatedAt: account.CreatedAt,
+ })
+ }
+ return views, nil
+}
+
+func DeleteExternalAccountForUser(id uint, userID int) error {
+ if id == 0 {
+ return errors.New("绑定记录 ID 不能为空")
+ }
+ if userID <= 0 {
+ return errors.New("用户 ID 不能为空")
+ }
+ result := DB.Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return errors.New("绑定记录不存在")
+ }
+ return nil
+}
diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go
index 6a401046..08dd89f2 100644
--- a/openflare_server/router/api-router.go
+++ b/openflare_server/router/api-router.go
@@ -24,6 +24,12 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount)
+ externalAccountRoute := apiRouter.Group("/oauth/external-accounts")
+ externalAccountRoute.Use(middleware.UserAuth(), middleware.NoTokenAuth())
+ {
+ externalAccountRoute.GET("/", controller.ListExternalAccounts)
+ externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount)
+ }
userRoute := apiRouter.Group("/user")
{
diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go
index 2719be7a..0ec57c69 100644
--- a/openflare_server/router/api_phase2_test.go
+++ b/openflare_server/router/api_phase2_test.go
@@ -269,6 +269,57 @@ func TestAuthSourceUpdateAcceptsClientSecret(t *testing.T) {
})
}
+func TestExternalAccountBindingsCanBeListedAndDeleted(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ common.RedisEnabled = false
+ setupTestDB(t)
+
+ engine := gin.New()
+ engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
+ router.SetApiRouter(engine)
+
+ loginCookie := loginAsRoot(t, engine)
+
+ source := &model.AuthSource{
+ Name: "logto",
+ Type: model.AuthSourceTypeOIDC,
+ DisplayName: "Logto",
+ ClientID: "logto-client-id",
+ ClientSecret: "logto-client-secret",
+ OpenIDDiscoveryURL: "https://auth.example.com/.well-known/openid-configuration",
+ }
+ if err := model.CreateAuthSource(source); err != nil {
+ t.Fatalf("create auth source: %v", err)
+ }
+ if err := model.LinkExternalAccount(&model.ExternalAccount{
+ AuthSourceID: source.ID,
+ UserID: 1,
+ ExternalID: "logto-user-1",
+ ExternalUsername: "ryan",
+ Email: "ryan@example.com",
+ }); err != nil {
+ t.Fatalf("link external account: %v", err)
+ }
+
+ listResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
+ var bindings []model.ExternalAccountView
+ decodeResponseData(t, listResp, &bindings)
+ if len(bindings) != 1 {
+ t.Fatalf("expected 1 binding, got %d", len(bindings))
+ }
+ if bindings[0].AuthSourceName != "logto" || bindings[0].ExternalUsername != "ryan" {
+ t.Fatalf("unexpected binding view: %+v", bindings[0])
+ }
+
+ performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/oauth/external-accounts/1/delete", nil)
+
+ listResp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/oauth/external-accounts/", nil)
+ decodeResponseData(t, listResp, &bindings)
+ if len(bindings) != 0 {
+ t.Fatalf("expected binding to be deleted, got %+v", bindings)
+ }
+}
+
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
t.Helper()
payload, err := json.Marshal(map[string]any{
diff --git a/openflare_server/web/features/settings/api/settings.ts b/openflare_server/web/features/settings/api/settings.ts
index a48575d5..15257a8f 100644
--- a/openflare_server/web/features/settings/api/settings.ts
+++ b/openflare_server/web/features/settings/api/settings.ts
@@ -6,6 +6,7 @@ import type {
AuthSourcePayload,
DatabaseCleanupPayload,
DatabaseCleanupResult,
+ ExternalAccountBinding,
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
@@ -76,6 +77,16 @@ export function deleteAuthSource(id: number) {
});
}
+export function getExternalAccountBindings() {
+ return apiRequest('/oauth/external-accounts/');
+}
+
+export function deleteExternalAccountBinding(id: number) {
+ return apiRequest(`/oauth/external-accounts/${id}/delete`, {
+ method: 'POST',
+ });
+}
+
export function getBootstrapToken() {
return apiRequest('/nodes/bootstrap-token');
}
diff --git a/openflare_server/web/features/settings/components/settings-page.tsx b/openflare_server/web/features/settings/components/settings-page.tsx
index 56e7b6d4..a7ce6da5 100644
--- a/openflare_server/web/features/settings/components/settings-page.tsx
+++ b/openflare_server/web/features/settings/components/settings-page.tsx
@@ -21,9 +21,11 @@ import { getPublicStatus } from '@/features/auth/api/public';
import {
bindEmail,
cleanupDatabaseObservability,
+ deleteExternalAccountBinding,
generateAccessToken,
getAuthSources,
getBootstrapToken,
+ getExternalAccountBindings,
getOptions,
getSettingsProfile,
lookupGeoIP,
@@ -55,6 +57,10 @@ import { formatDateTime } from '@/lib/utils/date';
const settingsQueryKey = ['settings', 'options'] as const;
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
+const externalAccountBindingsQueryKey = [
+ 'settings',
+ 'external-accounts',
+] as const;
const installerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
@@ -270,6 +276,11 @@ export function SettingsPage() {
queryFn: getSettingsProfile,
});
+ const externalAccountsQuery = useQuery({
+ queryKey: externalAccountBindingsQueryKey,
+ queryFn: getExternalAccountBindings,
+ });
+
const optionsQuery = useQuery({
queryKey: settingsQueryKey,
queryFn: getOptions,
@@ -642,6 +653,19 @@ export function SettingsPage() {
});
};
+ const handleUnbindAuthSource = (id: number, label: string) => {
+ if (!window.confirm(`确定解绑「${label}」吗?`)) {
+ return;
+ }
+ void runBusyAction(`auth-source-unbind-${id}`, async () => {
+ await deleteExternalAccountBinding(id);
+ await queryClient.invalidateQueries({
+ queryKey: externalAccountBindingsQueryKey,
+ });
+ setFeedback({ tone: 'success', message: '第三方账号已解绑。' });
+ });
+ };
+
const handleToggleOption = (
key: keyof typeof systemFields,
nextValue: boolean,
@@ -676,8 +700,21 @@ export function SettingsPage() {
);
}
+ if (externalAccountsQuery.isError) {
+ return (
+
+ );
+ }
+
const publicStatus = publicStatusQuery.data;
const profile = profileQuery.data;
+ const externalAccounts = externalAccountsQuery.data ?? [];
+ const externalAccountMap = new Map(
+ externalAccounts.map((account) => [account.auth_source_name, account]),
+ );
if (!publicStatus || !profile) {
return (
@@ -832,18 +869,73 @@ export function SettingsPage() {
登录状态下发起授权会直接绑定到当前账号。
-
+
{(publicStatus.auth_sources ?? []).length > 0 ? (
- publicStatus.auth_sources.map((source) => (
-
handleBindAuthSource(source.name)}
- disabled={busyKey === `auth-source-bind-${source.name}`}
- >
- 绑定 {source.display_name || source.name}
-
- ))
+ publicStatus.auth_sources.map((source) => {
+ const binding = externalAccountMap.get(source.name);
+ const label = source.display_name || source.name;
+
+ return (
+
+
+
+
+ {label}
+
+ {binding ? (
+ <>
+
+ 已绑定:
+ {binding.external_username ||
+ binding.email ||
+ '第三方账号'}
+
+ {binding.email ? (
+
+ 邮箱:{binding.email}
+
+ ) : null}
+
+ 绑定时间:
+ {formatDateTime(binding.created_at)}
+
+ >
+ ) : (
+
+ 未绑定
+
+ )}
+
+ {binding ? (
+
+ handleUnbindAuthSource(binding.id, label)
+ }
+ disabled={
+ busyKey === `auth-source-unbind-${binding.id}`
+ }
+ >
+ 解绑
+
+ ) : (
+
handleBindAuthSource(source.name)}
+ disabled={
+ busyKey === `auth-source-bind-${source.name}`
+ }
+ >
+ 绑定 {label}
+
+ )}
+
+
+ );
+ })
) : (
当前未启用认证源。
diff --git a/openflare_server/web/features/settings/types.ts b/openflare_server/web/features/settings/types.ts
index d435a8b5..55952755 100644
--- a/openflare_server/web/features/settings/types.ts
+++ b/openflare_server/web/features/settings/types.ts
@@ -25,6 +25,17 @@ export interface AuthSource {
icon_url: string;
}
+export interface ExternalAccountBinding {
+ id: number;
+ auth_source_id: number;
+ auth_source_name: string;
+ auth_source_type: AuthSourceType;
+ auth_source_label: string;
+ external_username: string;
+ email: string;
+ created_at: string;
+}
+
export type AuthSourcePayload = Omit<
AuthSource,
'id' | 'client_secret_configured'