mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 04:19:34 +00:00
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:
@@ -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 <token></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 <token></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".' },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user