From cb07d5d501d361d4255e9527a067eea3f4968058 Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Mon, 29 Jun 2026 16:09:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8D=20API=20Token=20?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E6=A0=A1=E9=AA=8C=E4=B8=8E=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将数据库中的 expires_at 设为必填,并在认证时仅允许未过期的令牌通过 - 增加空过期时间的更新校验,避免写入非法时间值 - 补充过期令牌与空过期时间更新的测试覆盖 - 调整前端日期输入与提交逻辑,避免创建 API Token 时表单异常中断 --- apps/api/internal/app/api_token_handlers.go | 4 ++++ apps/api/internal/app/app.go | 2 +- apps/api/internal/app/app_test.go | 13 +++++++++++++ apps/api/internal/app/router_auth.go | 2 +- apps/web/src/pages/profile.tsx | 21 +++++++++++++++------ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/api/internal/app/api_token_handlers.go b/apps/api/internal/app/api_token_handlers.go index ac91e84..265abc9 100644 --- a/apps/api/internal/app/api_token_handlers.go +++ b/apps/api/internal/app/api_token_handlers.go @@ -124,6 +124,10 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) { expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano) } if req.ExpiresAt != nil { + if strings.TrimSpace(*req.ExpiresAt) == "" { + badRequest(w, errors.New("expiresAt must be an RFC3339 timestamp")) + return + } expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC()) if err != nil { badRequest(w, err) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 365cfe0..3c82da0 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -169,7 +169,7 @@ func (a *App) migrate(ctx context.Context) error { name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, last_used_at TEXT, - expires_at TEXT, + expires_at TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 07ce249..3c845a2 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1705,6 +1705,15 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) { if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK { t.Fatalf("open api with bearer token code=%d", code) } + if _, err := a.db.Exec(`UPDATE api_tokens SET expires_at=? WHERE id=?`, a.now().UTC().Add(-time.Minute).Format(time.RFC3339Nano), created.Item.ID); err != nil { + t.Fatal(err) + } + if code := openAdmin.do("GET", "/api/open/domains", nil, &map[string]any{}); code != http.StatusUnauthorized { + t.Fatalf("expired bearer token code=%d", code) + } + if _, err := a.db.Exec(`UPDATE api_tokens SET expires_at=? WHERE id=?`, created.Item.ExpiresAt.UTC().Format(time.RFC3339Nano), created.Item.ID); err != nil { + t.Fatal(err) + } var listed struct { Items []APIToken `json:"items"` } @@ -1715,6 +1724,10 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) { t.Fatalf("listed tokens=%+v", listed.Items) } + if code := admin.do("POST", "/api/me/api-tokens/"+created.Item.ID, map[string]any{"expiresAt": ""}, &map[string]any{}); code != http.StatusBadRequest { + t.Fatalf("empty api token expiry update code=%d", code) + } + disabled := true var updated APIToken if code := admin.do("POST", "/api/me/api-tokens/"+created.Item.ID, map[string]any{"disabled": disabled}, &updated); code != http.StatusOK { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index efe1eed..30038b8 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -258,7 +258,7 @@ func (a *App) authenticateAPIToken(r *http.Request) (*User, error) { now := a.now().UTC().Format(time.RFC3339Nano) row := a.db.QueryRowContext(r.Context(), `SELECT at.id,u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at FROM api_tokens at JOIN users u ON u.id=at.user_id - WHERE at.token_hash=? AND at.disabled=0 AND (at.expires_at IS NULL OR at.expires_at > ?)`, hashToken(token), now) + WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now) var tokenID string var u User var disabled, twoFactorEnabled int diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index 6225950..25a3d18 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -906,12 +906,16 @@ function formatDateTime(value: string) { } function dateInputValue(date: Date) { - return date.toISOString().slice(0, 10) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` } function dateInputToISOString(value: string) { if (!value) return undefined - return new Date(`${value}T23:59:59.999Z`).toISOString() + const [year, month, day] = value.split("-").map(Number) + return new Date(year, month - 1, day, 23, 59, 59, 999).toISOString() } function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) { @@ -1008,11 +1012,16 @@ function ApiTokensSection({ items, loading, pending, onCreate, onUpdate, onDelet async function submit(event: React.FormEvent) { event.preventDefault() - const form = new FormData(event.currentTarget) + const target = event.currentTarget + const form = new FormData(target) const expiresAt = dateInputToISOString(String(form.get("expiresAt") || "")) - const res = await onCreate({ name: String(form.get("name") || ""), expiresAt }) - setCreatedToken(res.token) - event.currentTarget.reset() + try { + const res = await onCreate({ name: String(form.get("name") || ""), expiresAt }) + setCreatedToken(res.token) + target.reset() + } catch { + // Mutation-level error handling already shows the toast. + } } return (