Skip to content

Commit 916a9cf

Browse files
committed
feat(auth): 支持通过令牌hash值直接认证,令牌列表添加复制按钮
认证中间件(RequireAPIAuth)新增双路径验证:先尝试直接匹配hash值, 再尝试SHA256匹配明文。列表API返回完整hash,前端负责脱敏显示并 提供复制按钮,用户可将hash用作API凭证在多设备间复用。 Closes #14
1 parent 5202dc4 commit 916a9cf

5 files changed

Lines changed: 112 additions & 16 deletions

File tree

internal/app/admin_auth_tokens.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,6 @@ func (s *Server) HandleListAuthTokens(c *gin.Context) {
3131
return
3232
}
3333

34-
// 脱敏处理(仅显示前4后4字符)
35-
for _, t := range tokens {
36-
t.Token = model.MaskToken(t.Token)
37-
}
3834
if tokens == nil {
3935
tokens = make([]*model.AuthToken, 0)
4036
}
@@ -270,8 +266,6 @@ func (s *Server) HandleUpdateAuthToken(c *gin.Context) {
270266
log.Print("[WARN] 热更新失败: " + err.Error())
271267
}
272268

273-
// 返回脱敏后的令牌信息
274-
token.Token = model.MaskToken(token.Token)
275269
RespondJSON(c, http.StatusOK, token)
276270
}
277271

internal/app/auth_middleware_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,68 @@ func TestRequireTokenAuth_NoBearerPrefix(t *testing.T) {
282282
}
283283
}
284284

285+
func TestRequireAPIAuth_HashDirectMatch(t *testing.T) {
286+
t.Parallel()
287+
svc := newTestAuthService(t)
288+
injectAPIToken(svc, "plaintext-token", 0, 10)
289+
290+
// 计算hash,用hash值作为Bearer token发送
291+
hash := model.HashToken("plaintext-token")
292+
req := httptest.NewRequest(http.MethodGet, "/test", nil)
293+
req.Header.Set("Authorization", "Bearer "+hash)
294+
295+
w := runMiddleware(t, svc.RequireAPIAuth(), req)
296+
if w.Code != http.StatusOK {
297+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
298+
}
299+
300+
// 验证 context 中的 token_hash 和 token_id
301+
var resp map[string]any
302+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
303+
t.Fatalf("unmarshal response: %v", err)
304+
}
305+
if got, ok := resp["token_hash"].(string); !ok || got != hash {
306+
t.Fatalf("expected token_hash=%s, got=%v", hash, resp["token_hash"])
307+
}
308+
if got, ok := resp["token_id"].(float64); !ok || int64(got) != 10 {
309+
t.Fatalf("expected token_id=10, got=%v", resp["token_id"])
310+
}
311+
}
312+
313+
func TestRequireAPIAuth_HashExpired(t *testing.T) {
314+
t.Parallel()
315+
svc := newTestAuthService(t)
316+
expiredAt := time.Now().Add(-time.Hour).UnixMilli()
317+
injectAPIToken(svc, "expired-plain", expiredAt, 20)
318+
319+
// 用hash值作为Bearer token发送
320+
hash := model.HashToken("expired-plain")
321+
req := httptest.NewRequest(http.MethodGet, "/test", nil)
322+
req.Header.Set("Authorization", "Bearer "+hash)
323+
324+
w := runMiddleware(t, svc.RequireAPIAuth(), req)
325+
if w.Code != http.StatusUnauthorized {
326+
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
327+
}
328+
329+
// 验证响应包含 "token expired"
330+
var resp map[string]string
331+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
332+
t.Fatalf("unmarshal response: %v", err)
333+
}
334+
if resp["error"] != "token expired" {
335+
t.Fatalf("expected 'token expired' error, got: %s", resp["error"])
336+
}
337+
338+
// 验证懒惰删除:hash应已从内存中移除
339+
svc.authTokensMux.RLock()
340+
_, stillExists := svc.authTokens[hash]
341+
svc.authTokensMux.RUnlock()
342+
if stillExists {
343+
t.Fatal("expected expired token to be lazily deleted from memory")
344+
}
345+
}
346+
285347
// TestRequireAPIAuth_TokenPriority 验证 token 提取优先级(Bearer > X-API-Key > x-goog-api-key > query)
286348
func TestRequireAPIAuth_TokenPriority(t *testing.T) {
287349
t.Parallel()

internal/app/auth_service.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,11 +316,16 @@ func (s *AuthService) RequireAPIAuth() gin.HandlerFunc {
316316
return
317317
}
318318

319-
// 计算令牌哈希并验证
320-
tokenHash := model.HashToken(token)
321-
319+
// 双路径验证:先尝试直接匹配(客户端发送的是hash值),再尝试SHA256匹配(客户端发送的是明文)
322320
s.authTokensMux.RLock()
323-
expiresAt, exists := s.authTokens[tokenHash]
321+
var tokenHash string
322+
expiresAt, exists := s.authTokens[token]
323+
if exists {
324+
tokenHash = token
325+
} else {
326+
tokenHash = model.HashToken(token)
327+
expiresAt, exists = s.authTokens[tokenHash]
328+
}
324329
tokenID, hasTokenID := s.authTokenIDs[tokenHash]
325330
s.authTokensMux.RUnlock()
326331

web/assets/js/tokens.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@
8989
container.addEventListener('click', (e) => {
9090
const target = e.target;
9191

92+
// 处理复制令牌按钮
93+
if (target.classList.contains('btn-copy-token')) {
94+
const tokenHash = target.dataset.token;
95+
if (tokenHash) copyTokenToClipboard(tokenHash);
96+
return;
97+
}
98+
9299
// 处理编辑按钮
93100
if (target.classList.contains('btn-edit')) {
94101
const row = target.closest('tr');
@@ -154,7 +161,7 @@
154161
<th style="text-align: center;">${t('tokens.table.streamAvg')}</th>
155162
<th style="text-align: center;">${t('tokens.table.nonStreamAvg')}</th>
156163
<th>${t('tokens.table.lastUsed')}</th>
157-
<th style="width: 200px;">${t('tokens.table.actions')}</th>
164+
<th style="width: 260px;">${t('tokens.table.actions')}</th>
158165
</tr>
159166
</thead>
160167
`;
@@ -217,10 +224,15 @@
217224
const nonStreamAvgHtml = buildResponseTimeHtml(token.non_stream_avg_rt, token.non_stream_count);
218225

219226
// 使用模板引擎渲染
227+
const maskedToken = token.token.length > 8
228+
? token.token.substring(0, 4) + '****' + token.token.slice(-4)
229+
: token.token;
230+
220231
return TemplateEngine.render('tpl-token-row', {
221232
id: token.id,
222233
description: token.description,
223234
token: token.token,
235+
maskedToken: maskedToken,
224236
statusClass: status.class,
225237
createdAt: createdAt,
226238
createdLabel: t('tokens.createdSuffix'),
@@ -425,11 +437,15 @@
425437
const streamAvgHtml = buildResponseTimeHtml(token.stream_avg_ttfb, token.stream_count);
426438
const nonStreamAvgHtml = buildResponseTimeHtml(token.non_stream_avg_rt, token.non_stream_count);
427439

440+
const maskedToken = token.token.length > 8
441+
? token.token.substring(0, 4) + '****' + token.token.slice(-4)
442+
: token.token;
443+
428444
return `
429445
<tr data-token-id="${token.id}">
430446
<td style="font-weight: 500;">${escapeHtml(token.description)}</td>
431447
<td>
432-
<div><span class="token-display token-display-${status.class}">${escapeHtml(token.token)}</span></div>
448+
<div><span class="token-display token-display-${status.class}">${escapeHtml(maskedToken)}</span></div>
433449
<div style="font-size: 12px; color: var(--neutral-500); margin-top: 4px;">${createdAt}${t('tokens.createdSuffix')} · ${expiresAt}</div>
434450
</td>
435451
<td style="text-align: center;">${callsHtml}</td>
@@ -440,7 +456,8 @@
440456
<td style="text-align: center;">${streamAvgHtml}</td>
441457
<td style="text-align: center;">${nonStreamAvgHtml}</td>
442458
<td style="color: var(--neutral-600);">${lastUsed}</td>
443-
<td>
459+
<td style="white-space: nowrap;">
460+
<button class="btn-copy-token btn btn-secondary" style="padding: 4px 12px; font-size: 13px; margin-right: 4px;" data-token="${escapeHtml(token.token)}">${t('common.copy')}</button>
444461
<button class="btn btn-secondary btn-edit" style="padding: 4px 12px; font-size: 13px; margin-right: 4px;">${t('common.edit')}</button>
445462
<button class="btn btn-danger btn-delete" style="padding: 4px 12px; font-size: 13px;">${t('common.delete')}</button>
446463
</td>
@@ -520,10 +537,27 @@
520537
const textarea = document.getElementById('newTokenValue');
521538
textarea.select();
522539
document.execCommand('copy');
523-
540+
524541
window.showNotification(t('tokens.msg.copySuccess'), 'success');
525542
}
526543

544+
function copyTokenToClipboard(hash) {
545+
navigator.clipboard.writeText(hash).then(() => {
546+
window.showNotification(t('tokens.msg.copySuccess'), 'success');
547+
}).catch(() => {
548+
// fallback
549+
const textarea = document.createElement('textarea');
550+
textarea.value = hash;
551+
textarea.style.position = 'fixed';
552+
textarea.style.opacity = '0';
553+
document.body.appendChild(textarea);
554+
textarea.select();
555+
document.execCommand('copy');
556+
document.body.removeChild(textarea);
557+
window.showNotification(t('tokens.msg.copySuccess'), 'success');
558+
});
559+
}
560+
527561
function closeTokenResultModal() {
528562
document.getElementById('tokenResultModal').style.display = 'none';
529563
document.getElementById('newTokenValue').value = '';

web/tokens.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ <h2 style="margin: 0; font-size: 18px;" data-i18n="tokens.importModelTitle">手
317317
<tr data-token-id="{{id}}">
318318
<td style="font-weight: 500;">{{description}}</td>
319319
<td>
320-
<div><span class="token-display token-display-{{statusClass}}">{{token}}</span></div>
320+
<div><span class="token-display token-display-{{statusClass}}">{{maskedToken}}</span></div>
321321
<div style="font-size: 12px; color: var(--neutral-500); margin-top: 4px;">{{createdAt}}{{createdLabel}} · {{expiresAt}}</div>
322322
</td>
323323
<td style="text-align: center;">{{{callsHtml}}}</td>
@@ -328,7 +328,8 @@ <h2 style="margin: 0; font-size: 18px;" data-i18n="tokens.importModelTitle">手
328328
<td style="text-align: center;">{{{streamAvgHtml}}}</td>
329329
<td style="text-align: center;">{{{nonStreamAvgHtml}}}</td>
330330
<td style="color: var(--neutral-600);">{{lastUsed}}</td>
331-
<td>
331+
<td style="white-space: nowrap;">
332+
<button class="btn-copy-token btn btn-secondary" style="padding: 4px 12px; font-size: 13px; margin-right: 4px;" data-token="{{token}}" data-i18n="common.copy">复制</button>
332333
<button class="btn btn-secondary btn-edit" style="padding: 4px 12px; font-size: 13px; margin-right: 4px;" data-i18n="common.edit">编辑</button>
333334
<button class="btn btn-danger btn-delete" style="padding: 4px 12px; font-size: 13px;" data-i18n="common.delete">删除</button>
334335
</td>

0 commit comments

Comments
 (0)