mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 17:46:08 +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:
@@ -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