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'