Jimmy commited on
Commit
9eb834a
·
1 Parent(s): f34f49d

Improve user key management with editing capabilities and validation

Browse files
api/accounts.py CHANGED
@@ -31,6 +31,7 @@ class UserKeyCreateRequest(BaseModel):
31
  class UserKeyUpdateRequest(BaseModel):
32
  name: str | None = None
33
  enabled: bool | None = None
 
34
 
35
 
36
  class AccountCreateRequest(BaseModel):
@@ -116,12 +117,16 @@ def create_router() -> APIRouter:
116
  for key, value in {
117
  "name": body.name,
118
  "enabled": body.enabled,
 
119
  }.items()
120
  if value is not None
121
  }
122
  if not updates:
123
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
124
- item = auth_service.update_key(key_id, updates, role="user")
 
 
 
125
  if item is None:
126
  raise HTTPException(status_code=404, detail={"error": "user key not found"})
127
  return {"item": item, "items": auth_service.list_keys(role="user")}
 
31
  class UserKeyUpdateRequest(BaseModel):
32
  name: str | None = None
33
  enabled: bool | None = None
34
+ key: str | None = None
35
 
36
 
37
  class AccountCreateRequest(BaseModel):
 
117
  for key, value in {
118
  "name": body.name,
119
  "enabled": body.enabled,
120
+ "key": body.key,
121
  }.items()
122
  if value is not None
123
  }
124
  if not updates:
125
  raise HTTPException(status_code=400, detail={"error": "no updates provided"})
126
+ try:
127
+ item = auth_service.update_key(key_id, updates, role="user")
128
+ except ValueError as exc:
129
+ raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
130
  if item is None:
131
  raise HTTPException(status_code=404, detail={"error": "user key not found"})
132
  return {"item": item, "items": auth_service.list_keys(role="user")}
services/auth_service.py CHANGED
@@ -33,6 +33,10 @@ class AuthService:
33
  def _clean(value: object) -> str:
34
  return str(value or "").strip()
35
 
 
 
 
 
36
  def _normalize_item(self, raw: object) -> dict[str, object] | None:
37
  if not isinstance(raw, dict):
38
  return None
@@ -43,7 +47,7 @@ class AuthService:
43
  if not key_hash:
44
  return None
45
  item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
46
- name = self._clean(raw.get("name")) or ("管理员密钥" if role == "admin" else "普通用户")
47
  created_at = self._clean(raw.get("created_at")) or _now_iso()
48
  last_used_at = self._clean(raw.get("last_used_at")) or None
49
  return {
@@ -68,6 +72,9 @@ class AuthService:
68
  def _save(self) -> None:
69
  self.storage.save_auth_keys(self._items)
70
 
 
 
 
71
  @staticmethod
72
  def _public_item(item: dict[str, object]) -> dict[str, object]:
73
  return {
@@ -81,22 +88,52 @@ class AuthService:
81
 
82
  def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
83
  with self._lock:
 
84
  items = [item for item in self._items if role is None or item.get("role") == role]
85
  return [self._public_item(item) for item in items]
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
88
- normalized_name = self._clean(name) or ("管理员密钥" if role == "admin" else "普通用户")
89
- raw_key = f"sk-{secrets.token_urlsafe(24)}"
90
- item = {
91
- "id": uuid.uuid4().hex[:12],
92
- "name": normalized_name,
93
- "role": role,
94
- "key_hash": _hash_key(raw_key),
95
- "enabled": True,
96
- "created_at": _now_iso(),
97
- "last_used_at": None,
98
- }
99
  with self._lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  self._items.append(item)
101
  self._save()
102
  return self._public_item(item), raw_key
@@ -112,6 +149,7 @@ class AuthService:
112
  if not normalized_id:
113
  return None
114
  with self._lock:
 
115
  for index, item in enumerate(self._items):
116
  if item.get("id") != normalized_id:
117
  continue
@@ -119,9 +157,11 @@ class AuthService:
119
  return None
120
  next_item = dict(item)
121
  if "name" in updates and updates.get("name") is not None:
122
- next_item["name"] = self._clean(updates.get("name")) or next_item.get("name") or "普通用户"
123
  if "enabled" in updates and updates.get("enabled") is not None:
124
  next_item["enabled"] = bool(updates.get("enabled"))
 
 
125
  self._items[index] = next_item
126
  self._save()
127
  return self._public_item(next_item)
@@ -132,6 +172,7 @@ class AuthService:
132
  if not normalized_id:
133
  return False
134
  with self._lock:
 
135
  before = len(self._items)
136
  self._items = [
137
  item
@@ -149,6 +190,7 @@ class AuthService:
149
  return None
150
  candidate_hash = _hash_key(candidate)
151
  with self._lock:
 
152
  for index, item in enumerate(self._items):
153
  if not bool(item.get("enabled", True)):
154
  continue
 
33
  def _clean(value: object) -> str:
34
  return str(value or "").strip()
35
 
36
+ @staticmethod
37
+ def _default_name(role: object) -> str:
38
+ return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
39
+
40
  def _normalize_item(self, raw: object) -> dict[str, object] | None:
41
  if not isinstance(raw, dict):
42
  return None
 
47
  if not key_hash:
48
  return None
49
  item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
50
+ name = self._clean(raw.get("name")) or self._default_name(role)
51
  created_at = self._clean(raw.get("created_at")) or _now_iso()
52
  last_used_at = self._clean(raw.get("last_used_at")) or None
53
  return {
 
72
  def _save(self) -> None:
73
  self.storage.save_auth_keys(self._items)
74
 
75
+ def _reload_locked(self) -> None:
76
+ self._items = self._load()
77
+
78
  @staticmethod
79
  def _public_item(item: dict[str, object]) -> dict[str, object]:
80
  return {
 
88
 
89
  def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
90
  with self._lock:
91
+ self._reload_locked()
92
  items = [item for item in self._items if role is None or item.get("role") == role]
93
  return [self._public_item(item) for item in items]
94
 
95
+ def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
96
+ for item in self._items:
97
+ item_id = self._clean(item.get("id"))
98
+ if exclude_id and item_id == exclude_id:
99
+ continue
100
+ stored_hash = self._clean(item.get("key_hash"))
101
+ if stored_hash and hmac.compare_digest(stored_hash, key_hash):
102
+ return True
103
+ return False
104
+
105
+ def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
106
+ candidate = self._clean(raw_key)
107
+ if not candidate:
108
+ raise ValueError("key is required")
109
+ admin_key = self._clean(config.auth_key)
110
+ if admin_key and hmac.compare_digest(candidate, admin_key):
111
+ raise ValueError("key conflicts with admin auth key")
112
+ key_hash = _hash_key(candidate)
113
+ if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
114
+ raise ValueError("key already exists")
115
+ return key_hash
116
+
117
  def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
118
+ normalized_name = self._clean(name) or self._default_name(role)
 
 
 
 
 
 
 
 
 
 
119
  with self._lock:
120
+ self._reload_locked()
121
+ while True:
122
+ raw_key = f"sk-{secrets.token_urlsafe(24)}"
123
+ try:
124
+ key_hash = self._build_key_hash_locked(raw_key)
125
+ break
126
+ except ValueError:
127
+ continue
128
+ item = {
129
+ "id": uuid.uuid4().hex[:12],
130
+ "name": normalized_name,
131
+ "role": role,
132
+ "key_hash": key_hash,
133
+ "enabled": True,
134
+ "created_at": _now_iso(),
135
+ "last_used_at": None,
136
+ }
137
  self._items.append(item)
138
  self._save()
139
  return self._public_item(item), raw_key
 
149
  if not normalized_id:
150
  return None
151
  with self._lock:
152
+ self._reload_locked()
153
  for index, item in enumerate(self._items):
154
  if item.get("id") != normalized_id:
155
  continue
 
157
  return None
158
  next_item = dict(item)
159
  if "name" in updates and updates.get("name") is not None:
160
+ next_item["name"] = self._clean(updates.get("name")) or self._default_name(next_item.get("role"))
161
  if "enabled" in updates and updates.get("enabled") is not None:
162
  next_item["enabled"] = bool(updates.get("enabled"))
163
+ if "key" in updates and updates.get("key") is not None:
164
+ next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
165
  self._items[index] = next_item
166
  self._save()
167
  return self._public_item(next_item)
 
172
  if not normalized_id:
173
  return False
174
  with self._lock:
175
+ self._reload_locked()
176
  before = len(self._items)
177
  self._items = [
178
  item
 
190
  return None
191
  candidate_hash = _hash_key(candidate)
192
  with self._lock:
193
+ self._reload_locked()
194
  for index, item in enumerate(self._items):
195
  if not bool(item.get("enabled", True)):
196
  continue
test/test_account_image_capabilities.py CHANGED
@@ -119,6 +119,20 @@ class AuthServiceTests(unittest.TestCase):
119
  self.assertEqual(authed["id"], item["id"])
120
  self.assertIsNotNone(authed["last_used_at"])
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  if __name__ == "__main__":
124
  unittest.main()
 
119
  self.assertEqual(authed["id"], item["id"])
120
  self.assertIsNotNone(authed["last_used_at"])
121
 
122
+ def test_update_user_key_replaces_raw_key(self) -> None:
123
+ with tempfile.TemporaryDirectory() as tmp_dir:
124
+ service = AuthService(JSONStorageBackend(Path(tmp_dir) / "accounts.json", Path(tmp_dir) / "auth_keys.json"))
125
+ item, raw_key = service.create_key(role="user", name="Alice")
126
+
127
+ updated = service.update_key(item["id"], {"key": "sk-user-custom-key"}, role="user")
128
+
129
+ self.assertIsNotNone(updated)
130
+ self.assertIsNone(service.authenticate(raw_key))
131
+
132
+ authed = service.authenticate("sk-user-custom-key")
133
+ self.assertIsNotNone(authed)
134
+ self.assertEqual(authed["id"], item["id"])
135
+
136
 
137
  if __name__ == "__main__":
138
  unittest.main()
web/src/app/page.tsx CHANGED
@@ -3,7 +3,8 @@
3
  import { useEffect } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
- import { getDefaultRouteForRole, getStoredAuthSession } from "@/store/auth";
 
7
 
8
  export default function HomePage() {
9
  const router = useRouter();
@@ -12,7 +13,7 @@ export default function HomePage() {
12
  let active = true;
13
 
14
  const redirect = async () => {
15
- const session = await getStoredAuthSession();
16
  if (!active) {
17
  return;
18
  }
 
3
  import { useEffect } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
+ import { getValidatedAuthSession } from "@/lib/auth-session";
7
+ import { getDefaultRouteForRole } from "@/store/auth";
8
 
9
  export default function HomePage() {
10
  const router = useRouter();
 
13
  let active = true;
14
 
15
  const redirect = async () => {
16
+ const session = await getValidatedAuthSession();
17
  if (!active) {
18
  return;
19
  }
web/src/app/settings/components/user-keys-card.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
- import { Ban, CheckCircle2, Copy, KeyRound, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
@@ -45,6 +45,9 @@ export function UserKeysCard() {
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
47
  const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
 
 
 
48
 
49
  const load = async () => {
50
  setIsLoading(true);
@@ -125,6 +128,40 @@ export function UserKeysCard() {
125
  }
126
  };
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  const handleCopy = async (value: string) => {
129
  try {
130
  await navigator.clipboard.writeText(value);
@@ -200,6 +237,16 @@ export function UserKeysCard() {
200
  </div>
201
 
202
  <div className="flex items-center gap-2">
 
 
 
 
 
 
 
 
 
 
203
  <Button
204
  type="button"
205
  variant="outline"
@@ -305,6 +352,71 @@ export function UserKeysCard() {
305
  </DialogFooter>
306
  </DialogContent>
307
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  </>
309
  );
310
  }
 
1
  "use client";
2
 
3
  import { useEffect, useRef, useState } from "react";
4
+ import { Ban, CheckCircle2, Copy, KeyRound, LoaderCircle, Pencil, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { Badge } from "@/components/ui/badge";
 
45
  const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
46
  const [revealedKey, setRevealedKey] = useState("");
47
  const [deletingItem, setDeletingItem] = useState<UserKey | null>(null);
48
+ const [editingItem, setEditingItem] = useState<UserKey | null>(null);
49
+ const [editName, setEditName] = useState("");
50
+ const [editKey, setEditKey] = useState("");
51
 
52
  const load = async () => {
53
  setIsLoading(true);
 
128
  }
129
  };
130
 
131
+ const openEditDialog = (item: UserKey) => {
132
+ setEditingItem(item);
133
+ setEditName(item.name);
134
+ setEditKey("");
135
+ };
136
+
137
+ const handleEdit = async () => {
138
+ if (!editingItem) {
139
+ return;
140
+ }
141
+ const item = editingItem;
142
+ const trimmedName = editName.trim();
143
+ const trimmedKey = editKey.trim();
144
+ if (trimmedName === item.name && !trimmedKey) {
145
+ setEditingItem(null);
146
+ return;
147
+ }
148
+ setItemPending(item.id, true);
149
+ try {
150
+ const data = await updateUserKey(item.id, {
151
+ ...(trimmedName !== item.name ? { name: trimmedName } : {}),
152
+ ...(trimmedKey ? { key: trimmedKey } : {}),
153
+ });
154
+ setItems(data.items);
155
+ setEditingItem(null);
156
+ setEditKey("");
157
+ toast.success(trimmedKey ? "用户密钥已更新" : "用户名称已更新");
158
+ } catch (error) {
159
+ toast.error(error instanceof Error ? error.message : "更新用户密钥失败");
160
+ } finally {
161
+ setItemPending(item.id, false);
162
+ }
163
+ };
164
+
165
  const handleCopy = async (value: string) => {
166
  try {
167
  await navigator.clipboard.writeText(value);
 
237
  </div>
238
 
239
  <div className="flex items-center gap-2">
240
+ <Button
241
+ type="button"
242
+ variant="outline"
243
+ className="h-9 rounded-xl border-stone-200 bg-white px-4 text-stone-700"
244
+ onClick={() => openEditDialog(item)}
245
+ disabled={isPending}
246
+ >
247
+ {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
248
+ 编辑
249
+ </Button>
250
  <Button
251
  type="button"
252
  variant="outline"
 
352
  </DialogFooter>
353
  </DialogContent>
354
  </Dialog>
355
+
356
+ <Dialog
357
+ open={Boolean(editingItem)}
358
+ onOpenChange={(open) => {
359
+ if (!open) {
360
+ setEditingItem(null);
361
+ setEditKey("");
362
+ }
363
+ }}
364
+ >
365
+ <DialogContent className="rounded-2xl p-6">
366
+ <DialogHeader className="gap-2">
367
+ <DialogTitle>编辑用户密钥</DialogTitle>
368
+ <DialogDescription className="text-sm leading-6">
369
+ 可以修改备注名称;如需更换专用密钥,直接填写新的原始密钥即可。留空则保持当前密钥不变。
370
+ </DialogDescription>
371
+ </DialogHeader>
372
+ <div className="space-y-4">
373
+ <div className="space-y-2">
374
+ <label className="text-sm font-medium text-stone-700">名称</label>
375
+ <Input
376
+ value={editName}
377
+ onChange={(event) => setEditName(event.target.value)}
378
+ placeholder="例如:设计同学 A、运营临时账号"
379
+ className="h-11 rounded-xl border-stone-200 bg-white"
380
+ />
381
+ </div>
382
+ <div className="space-y-2">
383
+ <label className="text-sm font-medium text-stone-700">新的专用密钥(可选)</label>
384
+ <Input
385
+ value={editKey}
386
+ onChange={(event) => setEditKey(event.target.value)}
387
+ placeholder="例如:sk-your-custom-user-key"
388
+ className="h-11 rounded-xl border-stone-200 bg-white font-mono"
389
+ />
390
+ <p className="text-xs leading-5 text-stone-500">
391
+ 保存后旧密钥会立即失效,新密钥生效。系统仍只保存哈希,不会回显当前密钥。
392
+ </p>
393
+ </div>
394
+ </div>
395
+ <DialogFooter>
396
+ <Button
397
+ type="button"
398
+ variant="secondary"
399
+ className="h-10 rounded-xl bg-stone-100 px-5 text-stone-700 hover:bg-stone-200"
400
+ onClick={() => {
401
+ setEditingItem(null);
402
+ setEditKey("");
403
+ }}
404
+ disabled={editingItem ? pendingIds.has(editingItem.id) : false}
405
+ >
406
+ 取消
407
+ </Button>
408
+ <Button
409
+ type="button"
410
+ className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
411
+ onClick={() => void handleEdit()}
412
+ disabled={editingItem ? pendingIds.has(editingItem.id) : false}
413
+ >
414
+ {editingItem && pendingIds.has(editingItem.id) ? <LoaderCircle className="size-4 animate-spin" /> : <Pencil className="size-4" />}
415
+ 保存
416
+ </Button>
417
+ </DialogFooter>
418
+ </DialogContent>
419
+ </Dialog>
420
  </>
421
  );
422
  }
web/src/components/top-nav.tsx CHANGED
@@ -6,8 +6,9 @@ import { Github } from "lucide-react";
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
- import { clearStoredAuthSession, getStoredAuthSession, type StoredAuthSession } from "@/store/auth";
10
  import { cn } from "@/lib/utils";
 
11
 
12
  const adminNavItems = [
13
  { href: "/image", label: "画图" },
@@ -37,7 +38,7 @@ export function TopNav() {
37
  return;
38
  }
39
 
40
- const storedSession = await getStoredAuthSession();
41
  if (!active) {
42
  return;
43
  }
 
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
+ import { getValidatedAuthSession } from "@/lib/auth-session";
10
  import { cn } from "@/lib/utils";
11
+ import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
12
 
13
  const adminNavItems = [
14
  { href: "/image", label: "画图" },
 
38
  return;
39
  }
40
 
41
+ const storedSession = await getValidatedAuthSession();
42
  if (!active) {
43
  return;
44
  }
web/src/lib/api.ts CHANGED
@@ -341,7 +341,7 @@ export async function createUserKey(name: string) {
341
  });
342
  }
343
 
344
- export async function updateUserKey(keyId: string, updates: { enabled?: boolean; name?: string }) {
345
  return httpRequest<{ item: UserKey; items: UserKey[] }>(`/api/auth/users/${keyId}`, {
346
  method: "POST",
347
  body: updates,
 
341
  });
342
  }
343
 
344
+ export async function updateUserKey(keyId: string, updates: { enabled?: boolean; name?: string; key?: string }) {
345
  return httpRequest<{ item: UserKey; items: UserKey[] }>(`/api/auth/users/${keyId}`, {
346
  method: "POST",
347
  body: updates,
web/src/lib/auth-session.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { login } from "@/lib/api";
4
+ import { clearStoredAuthSession, getStoredAuthSession, setStoredAuthSession, type StoredAuthSession } from "@/store/auth";
5
+
6
+ export async function getValidatedAuthSession(): Promise<StoredAuthSession | null> {
7
+ const storedSession = await getStoredAuthSession();
8
+ if (!storedSession) {
9
+ return null;
10
+ }
11
+
12
+ try {
13
+ const data = await login(storedSession.key);
14
+ const nextSession: StoredAuthSession = {
15
+ key: storedSession.key,
16
+ role: data.role,
17
+ subjectId: data.subject_id,
18
+ name: data.name,
19
+ };
20
+ await setStoredAuthSession(nextSession);
21
+ return nextSession;
22
+ } catch {
23
+ await clearStoredAuthSession();
24
+ return null;
25
+ }
26
+ }
web/src/lib/use-auth-guard.ts CHANGED
@@ -3,9 +3,9 @@
3
  import { useEffect, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
 
 
6
  import {
7
  getDefaultRouteForRole,
8
- getStoredAuthSession,
9
  type AuthRole,
10
  type StoredAuthSession,
11
  } from "@/store/auth";
@@ -26,7 +26,7 @@ export function useAuthGuard(allowedRoles?: AuthRole[]): UseAuthGuardResult {
26
 
27
  const load = async () => {
28
  const roleList = allowedRolesKey ? (allowedRolesKey.split(",") as AuthRole[]) : [];
29
- const storedSession = await getStoredAuthSession();
30
  if (!active) {
31
  return;
32
  }
@@ -66,7 +66,7 @@ export function useRedirectIfAuthenticated() {
66
  let active = true;
67
 
68
  const load = async () => {
69
- const storedSession = await getStoredAuthSession();
70
  if (!active) {
71
  return;
72
  }
 
3
  import { useEffect, useState } from "react";
4
  import { useRouter } from "next/navigation";
5
 
6
+ import { getValidatedAuthSession } from "@/lib/auth-session";
7
  import {
8
  getDefaultRouteForRole,
 
9
  type AuthRole,
10
  type StoredAuthSession,
11
  } from "@/store/auth";
 
26
 
27
  const load = async () => {
28
  const roleList = allowedRolesKey ? (allowedRolesKey.split(",") as AuthRole[]) : [];
29
+ const storedSession = await getValidatedAuthSession();
30
  if (!active) {
31
  return;
32
  }
 
66
  let active = true;
67
 
68
  const load = async () => {
69
+ const storedSession = await getValidatedAuthSession();
70
  if (!active) {
71
  return;
72
  }