fix(api-token): hash tokens at rest and show plaintext only once

Store API tokens as SHA-256 hashes instead of plaintext and return the token value only in the create response. List no longer exposes the token, and the UI drops the Show/Copy buttons in favor of a one-time reveal modal at creation.

Match hashes the presented bearer token before the constant-time compare, and a migration hashes any pre-existing plaintext rows in place so existing tokens keep authenticating. Docs and translations updated.
This commit is contained in:
MHSanaei
2026-06-03 22:57:50 +02:00
parent 7a72aeda7a
commit 4813a2fe00
21 changed files with 145 additions and 65 deletions

View File

@@ -951,18 +951,18 @@ export const sections: readonly Section[] = [
id: 'api-tokens',
title: 'API Tokens',
description:
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
endpoints: [
{
method: 'GET',
path: '/panel/setting/apiTokens',
summary: 'List every API token, enabled or not.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/create',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
params: [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
],

View File

@@ -83,6 +83,11 @@
word-break: break-all;
}
.api-token-created-notice {
margin: 0 0 12px;
font-size: 13px;
}
.security-actions {
padding: 12px 0;
display: flex;

View File

@@ -30,7 +30,6 @@ interface ApiMsg<T = unknown> {
interface ApiTokenRow {
id: number;
name: string;
token: string;
enabled: boolean;
createdAt: number;
}
@@ -77,10 +76,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
const [apiTokensLoading, setApiTokensLoading] = useState(false);
const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [creating, setCreating] = useState(false);
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
setTfa({ ...opts, open: true });
@@ -137,14 +136,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
loadApiTokens();
}, [loadApiTokens]);
function toggleTokenVisibility(id: number) {
setVisibleTokenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function copyToken(token: string) {
if (!token) return;
const ok = await ClipboardManager.copyText(token);
@@ -165,17 +156,12 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
}
setCreating(true);
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
if (msg.obj?.id != null) {
const id = msg.obj.id;
setVisibleTokenIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
if (msg.obj?.token) {
setCreatedToken({ name, token: msg.obj.token });
}
}
} finally {
@@ -206,11 +192,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
}
}
function maskToken(token: string): string {
if (!token) return '';
return '•'.repeat(Math.min(token.length, 24));
}
function formatTokenDate(ts: number): string {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
@@ -326,17 +307,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
</Button>
</div>
</div>
<div className="api-token-value-wrap">
<code className="api-token-value">
{visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
</code>
<Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
{visibleTokenIds.has(row.id)
? (t('pages.settings.security.hide') || 'Hide')
: (t('pages.settings.security.show') || 'Show')}
</Button>
<Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
</div>
</div>
))}
</Spin>
@@ -367,6 +337,26 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
</Form>
</Modal>
<Modal
open={!!createdToken}
title={t('pages.settings.security.apiTokenCreatedTitle') || 'Token created'}
okText={t('done')}
onOk={() => setCreatedToken(null)}
onCancel={() => setCreatedToken(null)}
cancelButtonProps={{ style: { display: 'none' } }}
>
<p className="api-token-created-notice">
{t('pages.settings.security.apiTokenCreatedNotice')
|| 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
</p>
<div className="api-token-value-wrap">
<code className="api-token-value">{createdToken?.token}</code>
<Button size="small" type="primary" onClick={() => createdToken && copyToken(createdToken.token)}>
{t('copy')}
</Button>
</div>
</Modal>
<TwoFactorModal
open={tfa.open}
title={tfa.title}