feat(auth): 添加用户认证中间件并重构Linux.do登录流程

- 新增UserAuth中间件用于验证用户登录状态和权限
- 移除Linux.do登录中的redirectUri和minimumTrustLevel配置项
- 修改Linux.do登录流程以支持动态构建回调URL
- 将API v1接口组迁移至需要用户认证的路由
- 在远程AI服务调用中添加用户令牌认证
- 修复被封禁用户的认证检查逻辑
- 更新登录重定向函数以处理请求来源
- 添加RequestOrigin函数解析转发的主机和协议头
This commit is contained in:
HouYunFei
2026-05-25 12:05:02 +08:00
parent b2cae2471d
commit fe3294ed60
13 changed files with 72 additions and 67 deletions
-2
View File
@@ -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` 加权随机选择一个渠道。
+1 -1
View File
@@ -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 等比例时也会同步调整节点外框,需要确认上传、生成、重试、素材插入和空节点尺寸切换后的黑边情况。
+6 -6
View File
@@ -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) {
+11
View File
@@ -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))
-2
View File
@@ -66,8 +66,6 @@ type PrivateAuthSetting struct {
type PrivateLinuxDoAuthSetting struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RedirectURI string `json:"redirectUri"`
MinimumTrustLevel int `json:"minimumTrustLevel"`
}
// Setting 系统配置。
+7 -6
View File
@@ -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))
+26 -11
View File
@@ -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) != "" {
-3
View File
@@ -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"
+8 -28
View File
@@ -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<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
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" ? (
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Flex vertical gap={12}>
<Alert showIcon type="warning" title="当前还没有完整用户体系,所有访问到站点的用户都可以无条件使用后端渠道 API。请不要公网部署,避免私有渠道额度被他人消耗。" />
<Card
size="small"
title={
@@ -388,7 +375,12 @@ export default function AdminSettingsPage() {
}
>
<Flex vertical gap={14}>
<Alert showIcon type="info" message={linuxDoCallbackUrl ? `回调 URL 填 ${linuxDoCallbackUrl}` : "回调 URL 使用当前站点的 /api/auth/linux-do/callback"} />
<Typography.Text type="secondary">
/api/auth/linux-do/callback Linux.do
<Typography.Link href="https://connect.linux.do" target="_blank" rel="noreferrer">
LinuxDO OAuth App
</Typography.Link>
</Typography.Text>
<Row gutter={16}>
<Col xs={24} md={6}>
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
@@ -405,16 +397,6 @@ export default function AdminSettingsPage() {
<Input.Password placeholder="留空则沿用已保存的密钥" />
</Form.Item>
</Col>
<Col xs={24} md={18}>
<Form.Item name={["private", "auth", "linuxDo", "redirectUri"]} label="回调 URL">
<Input placeholder={linuxDoCallbackUrl || "https://your-domain.com/api/auth/linux-do/callback"} />
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item name={["private", "auth", "linuxDo", "minimumTrustLevel"]} label="最低信任等级">
<InputNumber min={0} max={4} precision={0} className="!w-full" placeholder="0" />
</Form.Item>
</Col>
</Row>
</Flex>
</Card>
@@ -672,8 +654,6 @@ function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}
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),
},
},
};
+2
View File
@@ -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;
}
-2
View File
@@ -163,8 +163,6 @@ export type AdminPrivateSettings = {
linuxDo: {
clientId: string;
clientSecret: string;
redirectUri: string;
minimumTrustLevel: number;
};
};
};
+6 -3
View File
@@ -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 } : {}),
+3 -1
View File
@@ -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[] = []) {