From fe3294ed607a0042b734f707319016855d6ecc8d Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 25 May 2026 12:05:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=AE=A4=E8=AF=81=E4=B8=AD=E9=97=B4=E4=BB=B6=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E6=9E=84Linux.do=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增UserAuth中间件用于验证用户登录状态和权限 - 移除Linux.do登录中的redirectUri和minimumTrustLevel配置项 - 修改Linux.do登录流程以支持动态构建回调URL - 将API v1接口组迁移至需要用户认证的路由 - 在远程AI服务调用中添加用户令牌认证 - 修复被封禁用户的认证检查逻辑 - 更新登录重定向函数以处理请求来源 - 添加RequestOrigin函数解析转发的主机和协议头 --- docs/backend-database.md | 2 -- docs/pending-test.md | 2 +- handler/auth.go | 12 +++---- middleware/admin.go | 11 ++++++ model/setting.go | 6 ++-- router/router.go | 13 ++++---- service/auth.go | 37 +++++++++++++++------ service/settings.go | 3 -- web/src/app/(admin)/admin/settings/page.tsx | 36 +++++--------------- web/src/app/api/[...path]/route.ts | 2 ++ web/src/services/api/admin.ts | 2 -- web/src/services/api/image.ts | 9 +++-- web/src/services/api/video.ts | 4 ++- 13 files changed, 72 insertions(+), 67 deletions(-) diff --git a/docs/backend-database.md b/docs/backend-database.md index 016dc32..2acc30b 100644 --- a/docs/backend-database.md +++ b/docs/backend-database.md @@ -167,8 +167,6 @@ | --- | --- | --- | | `clientId` | string | Linux.do OAuth App Client ID | | `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 | -| `redirectUri` | string | Linux.do OAuth 回调 URL | -| `minimumTrustLevel` | number | 允许登录的最低信任等级 | 后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 1a976d5..1828110 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,6 +1,6 @@ # 待测试 -- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,后台 `/admin/users` 可新增、编辑、删除用户并手动调整算力点,算力点变化会写入 `credit_logs`,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除和算力点流水都正常。 +- 用户模块新增 `users` 完整字段和 `credit_logs` 表;账号密码注册/登录已开放,后端 `/api/v1` 模型渠道接口需要登录后调用;Linux.do OAuth 登录参数移到后台系统设置,登录页按开关展示 Linux.do 按钮并使用 `public/icons/linuxdo.svg` 图标,后台 `/admin/users` 可新增、编辑、删除用户并手动调整算力点,算力点变化会写入 `credit_logs`,需要确认注册、登录、Linux.do 配置保存和回调、后台筛选分页、用户保存删除、登录态下生图/对话/视频和算力点流水都正常。 - 配置弹窗和后台公开配置新增默认视频模型;视频节点通过设置弹层单独配置视频质量、尺寸和秒数,连接图片节点时会作为 `input_reference` 参考图生成视频,并按 OpenAI 视频接口格式提交 `multipart/form-data`、`size`、`seconds`、`vquality` 和参考图参数,需要确认本地直连和云端渠道下默认视频模型选择、节点视频参数配置、参考图生成、保存和重试都正常。 - 画布新增视频节点:工具栏可新建视频节点,上传/拖拽本地视频后可在节点内播放、下载、保存到我的素材;节点下方对话框和生成配置节点支持视频生成,前端会调用 OpenAI 兼容 `videos` 接口并把生成结果保存为本地视频 Blob;后端远程渠道新增 `/api/v1/videos`、`/api/v1/videos/:id`、`/api/v1/videos/:id/content` 代理,需要确认本地渠道和远程渠道都能生成、轮询和下载视频。 - 图片和视频节点会按素材或生成结果的真实宽高比调整节点尺寸;空图片/视频节点在尺寸设置切换到 16:9、9:16 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。 diff --git a/handler/auth.go b/handler/auth.go index 50e7171..da0f635 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -55,7 +55,7 @@ func Login(w http.ResponseWriter, r *http.Request) { } func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) { - authURL, err := service.LinuxDoAuthorizeURL(r.URL.Query().Get("redirect")) + authURL, err := service.LinuxDoAuthorizeURL(r, r.URL.Query().Get("redirect")) if err != nil { FailError(w, err) return @@ -64,12 +64,12 @@ func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) { } func LinuxDoCallback(w http.ResponseWriter, r *http.Request) { - session, redirect, err := service.LoginWithLinuxDo(r.URL.Query().Get("code"), r.URL.Query().Get("state")) + session, redirect, err := service.LoginWithLinuxDo(r, r.URL.Query().Get("code"), r.URL.Query().Get("state")) if err != nil { - http.Redirect(w, r, loginRedirect(redirect, "", err.Error()), http.StatusFound) + http.Redirect(w, r, loginRedirect(r, redirect, "", err.Error()), http.StatusFound) return } - http.Redirect(w, r, loginRedirect(redirect, session.Token, ""), http.StatusFound) + http.Redirect(w, r, loginRedirect(r, redirect, session.Token, ""), http.StatusFound) } func AdminLogin(w http.ResponseWriter, r *http.Request) { @@ -124,7 +124,7 @@ func AdminSaveUser(w http.ResponseWriter, r *http.Request) { OK(w, user) } -func loginRedirect(redirect string, token string, message string) string { +func loginRedirect(r *http.Request, redirect string, token string, message string) string { values := url.Values{} if strings.TrimSpace(token) != "" { values.Set("token", token) @@ -135,7 +135,7 @@ func loginRedirect(redirect string, token string, message string) string { if strings.TrimSpace(redirect) != "" { values.Set("redirect", redirect) } - return "/login?" + values.Encode() + return service.RequestOrigin(r) + "/login?" + values.Encode() } func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) { diff --git a/middleware/admin.go b/middleware/admin.go index 9995596..67d4f7a 100644 --- a/middleware/admin.go +++ b/middleware/admin.go @@ -21,6 +21,17 @@ func AdminAuth(c *gin.Context) { c.Next() } +func UserAuth(c *gin.Context) { + user, ok := authUser(c) + if !ok || user.Role == model.UserRoleGuest { + handler.Fail(c.Writer, "未登录或权限不足") + c.Abort() + return + } + c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user)) + c.Next() +} + func OptionalAuth(c *gin.Context) { if user, ok := authUser(c); ok { c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user)) diff --git a/model/setting.go b/model/setting.go index 3b3a3b3..76f00ac 100644 --- a/model/setting.go +++ b/model/setting.go @@ -64,10 +64,8 @@ type PrivateAuthSetting struct { } type PrivateLinuxDoAuthSetting struct { - ClientID string `json:"clientId"` - ClientSecret string `json:"clientSecret"` - RedirectURI string `json:"redirectUri"` - MinimumTrustLevel int `json:"minimumTrustLevel"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` } // Setting 系统配置。 diff --git a/router/router.go b/router/router.go index f80350f..c1cebbd 100644 --- a/router/router.go +++ b/router/router.go @@ -22,14 +22,15 @@ func New() *gin.Engine { api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback)) api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser)) api.GET("/settings", gin.WrapF(handler.Settings)) - api.POST("/v1/images/generations", gin.WrapF(handler.AIImagesGenerations)) - api.POST("/v1/images/edits", gin.WrapF(handler.AIImagesEdits)) - api.POST("/v1/chat/completions", gin.WrapF(handler.AIChatCompletions)) - api.POST("/v1/videos", gin.WrapF(handler.AIVideos)) - api.GET("/v1/videos/:id", func(c *gin.Context) { + v1 := api.Group("/v1", middleware.UserAuth) + v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations)) + v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits)) + v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions)) + v1.POST("/videos", gin.WrapF(handler.AIVideos)) + v1.GET("/videos/:id", func(c *gin.Context) { handler.AIVideo(c.Writer, c.Request, c.Param("id")) }) - api.GET("/v1/videos/:id/content", func(c *gin.Context) { + v1.GET("/videos/:id/content", func(c *gin.Context) { handler.AIVideoContent(c.Writer, c.Request, c.Param("id")) }) api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts)) diff --git a/service/auth.go b/service/auth.go index d789de7..e8ed52d 100644 --- a/service/auth.go +++ b/service/auth.go @@ -109,7 +109,7 @@ func Login(username string, password string) (model.AuthSession, error) { return newSession(user) } -func LinuxDoAuthorizeURL(redirect string) (string, error) { +func LinuxDoAuthorizeURL(r *http.Request, redirect string) (string, error) { settings, err := repository.GetSettings() if err != nil { return "", err @@ -119,19 +119,19 @@ func LinuxDoAuthorizeURL(redirect string) (string, error) { if !settings.Public.Auth.LinuxDo.Enabled { return "", safeMessageError{message: "Linux.do 登录未开启"} } - if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" || strings.TrimSpace(linuxDo.RedirectURI) == "" { + if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" { return "", safeMessageError{message: "Linux.do 登录未配置"} } values := url.Values{} values.Set("client_id", linuxDo.ClientID) - values.Set("redirect_uri", linuxDo.RedirectURI) + values.Set("redirect_uri", linuxDoRedirectURI(r)) values.Set("response_type", "code") values.Set("scope", "read") values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect))) return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil } -func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, error) { +func LoginWithLinuxDo(r *http.Request, code string, state string) (model.AuthSession, string, error) { redirect := decodeState(state) settings, err := repository.GetSettings() if err != nil { @@ -142,7 +142,7 @@ func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, err if !settings.Public.Auth.LinuxDo.Enabled { return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"} } - token, err := linuxDoAccessToken(code, linuxDo) + token, err := linuxDoAccessToken(r, code, linuxDo) if err != nil { return model.AuthSession{}, redirect, err } @@ -154,9 +154,6 @@ func LoginWithLinuxDo(code string, state string) (model.AuthSession, string, err if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" { return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"} } - if profile.TrustLevel < linuxDo.MinimumTrustLevel { - return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 账号信任等级不足"} - } user, ok, err := repository.GetUserByLinuxDoID(linuxDoID) if err != nil { return model.AuthSession{}, redirect, err @@ -213,6 +210,9 @@ func CurrentAuthUser(tokenText string) (model.AuthUser, bool) { if err != nil || !ok { return model.AuthUser{}, false } + if user.Status == model.UserStatusBan { + return model.AuthUser{}, false + } return model.PublicUser(user), true } @@ -365,16 +365,15 @@ type linuxDoUserResponse struct { Username string `json:"username"` Name string `json:"name"` AvatarTemplate string `json:"avatar_template"` - TrustLevel int `json:"trust_level"` } -func linuxDoAccessToken(code string, setting model.PrivateLinuxDoAuthSetting) (string, error) { +func linuxDoAccessToken(r *http.Request, code string, setting model.PrivateLinuxDoAuthSetting) (string, error) { values := url.Values{} values.Set("client_id", setting.ClientID) values.Set("client_secret", setting.ClientSecret) values.Set("grant_type", "authorization_code") values.Set("code", code) - values.Set("redirect_uri", setting.RedirectURI) + values.Set("redirect_uri", linuxDoRedirectURI(r)) req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") var payload linuxDoTokenResponse @@ -387,6 +386,10 @@ func linuxDoAccessToken(code string, setting model.PrivateLinuxDoAuthSetting) (s return payload.AccessToken, nil } +func linuxDoRedirectURI(r *http.Request) string { + return RequestOrigin(r) + "/api/auth/linux-do/callback" +} + func linuxDoProfile(token string) (linuxDoUserResponse, error) { req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil) req.Header.Set("Authorization", "Bearer "+token) @@ -444,6 +447,18 @@ func decodeState(state string) string { return redirect } +func RequestOrigin(r *http.Request) string { + host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")) + if host == "" { + host = r.Host + } + proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if proto == "" { + proto = "http" + } + return proto + "://" + host +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/service/settings.go b/service/settings.go index 7267b14..2c18436 100644 --- a/service/settings.go +++ b/service/settings.go @@ -77,9 +77,6 @@ func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting setting.Channels = []model.ModelChannel{} } setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync) - if setting.Auth.LinuxDo.MinimumTrustLevel < 0 { - setting.Auth.LinuxDo.MinimumTrustLevel = 0 - } for i := range setting.Channels { if setting.Channels[i].Protocol == "" { setting.Channels[i].Protocol = "openai" diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 51502eb..0fb9004 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -2,7 +2,7 @@ import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons"; import { json } from "@codemirror/lang-json"; -import { Alert, App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd"; +import { App, Button, Card, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd"; import dynamic from "next/dynamic"; import { useEffect, useMemo, useState } from "react"; import { EditorView } from "@uiw/react-codemirror"; @@ -37,7 +37,7 @@ const emptySettings: AdminSettings = { }, auth: { linuxDo: { enabled: false } }, }, - private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "", redirectUri: "", minimumTrustLevel: 0 } } }, + private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } }, }; const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; @@ -62,9 +62,7 @@ export default function AdminSettingsPage() { const [testResults, setTestResults] = useState>({}); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); - const [linuxDoCallbackUrl, setLinuxDoCallbackUrl] = useState(""); const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || []; - const linuxDoRedirectUri = Form.useWatch(["private", "auth", "linuxDo", "redirectUri"], form); const channelModels = useMemo(() => collectChannelModels(channels), [channels]); const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]); const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]); @@ -94,16 +92,6 @@ export default function AdminSettingsPage() { void loadSettings(); }, [token]); - useEffect(() => { - setLinuxDoCallbackUrl(`${window.location.origin}/api/auth/linux-do/callback`); - }, []); - - useEffect(() => { - if (!linuxDoCallbackUrl) return; - if (linuxDoRedirectUri) return; - form.setFieldValue(["private", "auth", "linuxDo", "redirectUri"], linuxDoCallbackUrl); - }, [form, linuxDoCallbackUrl, linuxDoRedirectUri]); - const changeTab = (nextTab: SettingsTabKey) => { setActiveTab(nextTab); }; @@ -377,7 +365,6 @@ export default function AdminSettingsPage() { ) : activeMode === "visual" ? (
- - + + 本项目接口回调地址是 /api/auth/linux-do/callback,请在 Linux.do 应用后台自行拼接站点前缀。 + + 点击此处管理你的 LinuxDO OAuth App + + @@ -405,16 +397,6 @@ export default function AdminSettingsPage() { - - - - - - - - - - @@ -672,8 +654,6 @@ function normalizePrivateSetting(setting: Partial = {} linuxDo: { clientId: setting.auth?.linuxDo?.clientId || "", clientSecret: setting.auth?.linuxDo?.clientSecret || "", - redirectUri: setting.auth?.linuxDo?.redirectUri || "", - minimumTrustLevel: Math.max(0, Number(setting.auth?.linuxDo?.minimumTrustLevel) || 0), }, }, }; diff --git a/web/src/app/api/[...path]/route.ts b/web/src/app/api/[...path]/route.ts index f932727..1abbc35 100644 --- a/web/src/app/api/[...path]/route.ts +++ b/web/src/app/api/[...path]/route.ts @@ -12,6 +12,8 @@ function proxyHeaders(request: NextRequest) { headers.delete("host"); headers.delete("content-length"); headers.delete("connection"); + headers.set("x-forwarded-host", request.nextUrl.host); + headers.set("x-forwarded-proto", request.nextUrl.protocol.replace(":", "")); return headers; } diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts index 1d5f5ed..b72f967 100644 --- a/web/src/services/api/admin.ts +++ b/web/src/services/api/admin.ts @@ -163,8 +163,6 @@ export type AdminPrivateSettings = { linuxDo: { clientId: string; clientSecret: string; - redirectUri: string; - minimumTrustLevel: number; }; }; }; diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 3f4b233..2ebc8ef 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,7 @@ import axios from "axios"; import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import { useUserStore } from "@/stores/use-user-store"; import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { imageToDataUrl } from "@/services/image-storage"; @@ -77,10 +78,12 @@ function aiApiUrl(config: AiConfig, path: string) { } function aiHeaders(config: AiConfig, contentType?: string) { + const token = useUserStore.getState().token; return config.channelMode === "remote" - ? contentType - ? { "Content-Type": contentType } - : undefined + ? { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(contentType ? { "Content-Type": contentType } : {}), + } : { Authorization: `Bearer ${config.apiKey}`, ...(contentType ? { "Content-Type": contentType } : {}), diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index bc73d1e..59f9721 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -3,6 +3,7 @@ import axios from "axios"; import { dataUrlToFile } from "@/lib/image-utils"; import { imageToDataUrl } from "@/services/image-storage"; import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import { useUserStore } from "@/stores/use-user-store"; import type { ReferenceImage } from "@/types/image"; type VideoResponse = { id: string; status?: string; error?: { message?: string } }; @@ -12,7 +13,8 @@ function aiApiUrl(config: AiConfig, path: string) { } function aiHeaders(config: AiConfig) { - return config.channelMode === "remote" ? undefined : { Authorization: `Bearer ${config.apiKey}` }; + const token = useUserStore.getState().token; + return config.channelMode === "remote" ? (token ? { Authorization: `Bearer ${token}` } : undefined) : { Authorization: `Bearer ${config.apiKey}` }; } export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = []) {