mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 18:26:36 +08:00
[修复] 个人设置查看第三方认证源与增加解绑功能
This commit is contained in:
@@ -16,6 +16,8 @@ OpenFlare 支持通过认证源配置第三方登录入口。当前支持 GitHub
|
|||||||
| Client Secret | 第三方平台创建应用后提供 |
|
| Client Secret | 第三方平台创建应用后提供 |
|
||||||
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
|
| OIDC Discovery URL | 仅 OIDC 需要,例如 `https://idp.example.com/.well-known/openid-configuration` |
|
||||||
|
|
||||||
|
**确认系统设置->通用设置->服务器地址能正确和域名匹配**
|
||||||
|
|
||||||
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
|
认证源名称只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。认证源名称会出现在回调地址中,保存后如需修改名称,也必须同步修改第三方平台中的回调地址。
|
||||||
|
|
||||||
## 回调地址
|
## 回调地址
|
||||||
|
|||||||
@@ -269,6 +269,30 @@ func LinkExistingOAuthAccount(c *gin.Context) {
|
|||||||
respondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
|
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) {
|
func parseAuthSourceID(c *gin.Context) (uint, error) {
|
||||||
raw := c.Param("source_id")
|
raw := c.Param("source_id")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ type ExternalAccount struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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() {
|
func (source *AuthSource) Normalize() {
|
||||||
source.Name = strings.TrimSpace(source.Name)
|
source.Name = strings.TrimSpace(source.Name)
|
||||||
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
|
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
|
||||||
@@ -216,3 +227,48 @@ func LinkExternalAccount(account *ExternalAccount) error {
|
|||||||
ExternalID: account.ExternalID,
|
ExternalID: account.ExternalID,
|
||||||
}).FirstOrCreate(account).Error
|
}).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
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
|
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
|
||||||
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
|
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
|
||||||
apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount)
|
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")
|
userRoute := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
payload, err := json.Marshal(map[string]any{
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AuthSourcePayload,
|
AuthSourcePayload,
|
||||||
DatabaseCleanupPayload,
|
DatabaseCleanupPayload,
|
||||||
DatabaseCleanupResult,
|
DatabaseCleanupResult,
|
||||||
|
ExternalAccountBinding,
|
||||||
GeoIPLookupResult,
|
GeoIPLookupResult,
|
||||||
OptionBatchPayload,
|
OptionBatchPayload,
|
||||||
OptionItem,
|
OptionItem,
|
||||||
@@ -76,6 +77,16 @@ export function deleteAuthSource(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getExternalAccountBindings() {
|
||||||
|
return apiRequest<ExternalAccountBinding[]>('/oauth/external-accounts/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteExternalAccountBinding(id: number) {
|
||||||
|
return apiRequest<void>(`/oauth/external-accounts/${id}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getBootstrapToken() {
|
export function getBootstrapToken() {
|
||||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import { getPublicStatus } from '@/features/auth/api/public';
|
|||||||
import {
|
import {
|
||||||
bindEmail,
|
bindEmail,
|
||||||
cleanupDatabaseObservability,
|
cleanupDatabaseObservability,
|
||||||
|
deleteExternalAccountBinding,
|
||||||
generateAccessToken,
|
generateAccessToken,
|
||||||
getAuthSources,
|
getAuthSources,
|
||||||
getBootstrapToken,
|
getBootstrapToken,
|
||||||
|
getExternalAccountBindings,
|
||||||
getOptions,
|
getOptions,
|
||||||
getSettingsProfile,
|
getSettingsProfile,
|
||||||
lookupGeoIP,
|
lookupGeoIP,
|
||||||
@@ -55,6 +57,10 @@ import { formatDateTime } from '@/lib/utils/date';
|
|||||||
|
|
||||||
const settingsQueryKey = ['settings', 'options'] as const;
|
const settingsQueryKey = ['settings', 'options'] as const;
|
||||||
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
||||||
|
const externalAccountBindingsQueryKey = [
|
||||||
|
'settings',
|
||||||
|
'external-accounts',
|
||||||
|
] as const;
|
||||||
const installerScriptUrl =
|
const installerScriptUrl =
|
||||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||||
|
|
||||||
@@ -270,6 +276,11 @@ export function SettingsPage() {
|
|||||||
queryFn: getSettingsProfile,
|
queryFn: getSettingsProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const externalAccountsQuery = useQuery({
|
||||||
|
queryKey: externalAccountBindingsQueryKey,
|
||||||
|
queryFn: getExternalAccountBindings,
|
||||||
|
});
|
||||||
|
|
||||||
const optionsQuery = useQuery({
|
const optionsQuery = useQuery({
|
||||||
queryKey: settingsQueryKey,
|
queryKey: settingsQueryKey,
|
||||||
queryFn: getOptions,
|
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 = (
|
const handleToggleOption = (
|
||||||
key: keyof typeof systemFields,
|
key: keyof typeof systemFields,
|
||||||
nextValue: boolean,
|
nextValue: boolean,
|
||||||
@@ -676,8 +700,21 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (externalAccountsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="账号绑定加载失败"
|
||||||
|
description={getErrorMessage(externalAccountsQuery.error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const publicStatus = publicStatusQuery.data;
|
const publicStatus = publicStatusQuery.data;
|
||||||
const profile = profileQuery.data;
|
const profile = profileQuery.data;
|
||||||
|
const externalAccounts = externalAccountsQuery.data ?? [];
|
||||||
|
const externalAccountMap = new Map(
|
||||||
|
externalAccounts.map((account) => [account.auth_source_name, account]),
|
||||||
|
);
|
||||||
|
|
||||||
if (!publicStatus || !profile) {
|
if (!publicStatus || !profile) {
|
||||||
return (
|
return (
|
||||||
@@ -832,18 +869,73 @@ export function SettingsPage() {
|
|||||||
登录状态下发起授权会直接绑定到当前账号。
|
登录状态下发起授权会直接绑定到当前账号。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="space-y-3">
|
||||||
{(publicStatus.auth_sources ?? []).length > 0 ? (
|
{(publicStatus.auth_sources ?? []).length > 0 ? (
|
||||||
publicStatus.auth_sources.map((source) => (
|
publicStatus.auth_sources.map((source) => {
|
||||||
<PrimaryButton
|
const binding = externalAccountMap.get(source.name);
|
||||||
key={source.id}
|
const label = source.display_name || source.name;
|
||||||
type="button"
|
|
||||||
onClick={() => handleBindAuthSource(source.name)}
|
return (
|
||||||
disabled={busyKey === `auth-source-bind-${source.name}`}
|
<div
|
||||||
>
|
key={source.id}
|
||||||
绑定 {source.display_name || source.name}
|
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-card)] px-4 py-3"
|
||||||
</PrimaryButton>
|
>
|
||||||
))
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{binding ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm break-all text-[var(--foreground-secondary)]">
|
||||||
|
已绑定:
|
||||||
|
{binding.external_username ||
|
||||||
|
binding.email ||
|
||||||
|
'第三方账号'}
|
||||||
|
</p>
|
||||||
|
{binding.email ? (
|
||||||
|
<p className="text-xs break-all text-[var(--foreground-muted)]">
|
||||||
|
邮箱:{binding.email}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-[var(--foreground-muted)]">
|
||||||
|
绑定时间:
|
||||||
|
{formatDateTime(binding.created_at)}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
未绑定
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{binding ? (
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleUnbindAuthSource(binding.id, label)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
busyKey === `auth-source-unbind-${binding.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
解绑
|
||||||
|
</DangerButton>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleBindAuthSource(source.name)}
|
||||||
|
disabled={
|
||||||
|
busyKey === `auth-source-bind-${source.name}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
绑定 {label}
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||||
当前未启用认证源。
|
当前未启用认证源。
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ export interface AuthSource {
|
|||||||
icon_url: string;
|
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<
|
export type AuthSourcePayload = Omit<
|
||||||
AuthSource,
|
AuthSource,
|
||||||
'id' | 'client_secret_configured'
|
'id' | 'client_secret_configured'
|
||||||
|
|||||||
Reference in New Issue
Block a user