fix(api): 修复 API Token 过期校验与更新校验
- 将数据库中的 expires_at 设为必填,并在认证时仅允许未过期的令牌通过 - 增加空过期时间的更新校验,避免写入非法时间值 - 补充过期令牌与空过期时间更新的测试覆盖 - 调整前端日期输入与提交逻辑,避免创建 API Token 时表单异常中断
This commit is contained in:
@@ -124,6 +124,10 @@ func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||||
}
|
}
|
||||||
if req.ExpiresAt != nil {
|
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())
|
expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
last_used_at TEXT,
|
last_used_at TEXT,
|
||||||
expires_at TEXT,
|
expires_at TEXT NOT NULL,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
|
|||||||
@@ -1705,6 +1705,15 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) {
|
|||||||
if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK {
|
if code := openAdmin.do("GET", "/api/open/domains", nil, &domains); code != http.StatusOK {
|
||||||
t.Fatalf("open api with bearer token code=%d", code)
|
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 {
|
var listed struct {
|
||||||
Items []APIToken `json:"items"`
|
Items []APIToken `json:"items"`
|
||||||
}
|
}
|
||||||
@@ -1715,6 +1724,10 @@ func TestAPITokenManagementStoresHashAndRevokes(t *testing.T) {
|
|||||||
t.Fatalf("listed tokens=%+v", listed.Items)
|
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
|
disabled := true
|
||||||
var updated APIToken
|
var updated APIToken
|
||||||
if code := admin.do("POST", "/api/me/api-tokens/"+created.Item.ID, map[string]any{"disabled": disabled}, &updated); code != http.StatusOK {
|
if code := admin.do("POST", "/api/me/api-tokens/"+created.Item.ID, map[string]any{"disabled": disabled}, &updated); code != http.StatusOK {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (a *App) authenticateAPIToken(r *http.Request) (*User, error) {
|
|||||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
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
|
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
|
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 tokenID string
|
||||||
var u User
|
var u User
|
||||||
var disabled, twoFactorEnabled int
|
var disabled, twoFactorEnabled int
|
||||||
|
|||||||
@@ -906,12 +906,16 @@ function formatDateTime(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dateInputValue(date: Date) {
|
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) {
|
function dateInputToISOString(value: string) {
|
||||||
if (!value) return undefined
|
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 }) {
|
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<HTMLFormElement>) {
|
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
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 expiresAt = dateInputToISOString(String(form.get("expiresAt") || ""))
|
||||||
const res = await onCreate({ name: String(form.get("name") || ""), expiresAt })
|
try {
|
||||||
setCreatedToken(res.token)
|
const res = await onCreate({ name: String(form.get("name") || ""), expiresAt })
|
||||||
event.currentTarget.reset()
|
setCreatedToken(res.token)
|
||||||
|
target.reset()
|
||||||
|
} catch {
|
||||||
|
// Mutation-level error handling already shows the toast.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user