feat: 账号凭据默认脱敏并支持主动查看

This commit is contained in:
yml2213
2026-08-13 16:32:49 +08:00
parent a223cb726d
commit 2e207f726c
3 changed files with 108 additions and 6 deletions
+73
View File
@@ -0,0 +1,73 @@
import os
import unittest
os.environ.setdefault("DATABASE_URL", "sqlite://")
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, User
from web.backend.routers.accounts import list_accounts
class AccountSensitiveFieldsTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
self.admin = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(self.admin)
self.session.add(Account(
username="account",
password="account-password",
email="account@example.com",
email_password="email-password",
))
self.session.commit()
def tearDown(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_sensitive_fields_are_hidden_by_default_even_for_admin(self):
result = list_accounts(
assigned_only=False,
tag=None,
has_cookie=False,
search="",
page=1,
page_size=20,
include_sensitive=False,
db=self.session,
current=self.admin,
)
item = result["items"][0]
self.assertIsNone(item.password)
self.assertIsNone(item.email)
self.assertIsNone(item.email_password)
def test_admin_can_explicitly_request_sensitive_fields(self):
result = list_accounts(
assigned_only=False,
tag=None,
has_cookie=False,
search="",
page=1,
page_size=20,
include_sensitive=True,
db=self.session,
current=self.admin,
)
item = result["items"][0]
self.assertEqual(item.password, "account-password")
self.assertEqual(item.email, "account@example.com")
self.assertEqual(item.email_password, "email-password")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -100,7 +100,7 @@ def list_accounts(
search: str = Query(""), search: str = Query(""),
page: int | None = Query(None, ge=1), page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200), page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(True), include_sensitive: bool = Query(False),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current: User = Depends(get_current_user), current: User = Depends(get_current_user),
): ):
+34 -5
View File
@@ -5,7 +5,7 @@ import {
} from 'antd'; } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd'; import type { TableProps } from 'antd';
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined } from '@ant-design/icons'; import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules'; import { accountApi, userApi, type AccountItem, type BasicSummary, type UserInfo } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
@@ -33,6 +33,7 @@ export default function AccountsPage() {
const [selectedAllMatching, setSelectedAllMatching] = useState(false); const [selectedAllMatching, setSelectedAllMatching] = useState(false);
const [batchTagInput, setBatchTagInput] = useState(''); const [batchTagInput, setBatchTagInput] = useState('');
const [batchTagVisible, setBatchTagVisible] = useState(false); const [batchTagVisible, setBatchTagVisible] = useState(false);
const [sensitiveVisible, setSensitiveVisible] = useState(false);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<BasicSummary>({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 }); const [summary, setSummary] = useState<BasicSummary>({ total: 0, assigned_count: 0, unassigned_count: 0, tag_count: 0 });
const [pageSize, setPageSize] = useState(() => { const [pageSize, setPageSize] = useState(() => {
@@ -58,7 +59,7 @@ export default function AccountsPage() {
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
search: searchText.trim() || undefined, search: searchText.trim() || undefined,
include_sensitive: canViewFull, include_sensitive: canViewFull && sensitiveVisible,
}); });
setAccounts(data.items); setAccounts(data.items);
setTotal(data.total); setTotal(data.total);
@@ -67,7 +68,7 @@ export default function AccountsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canViewFull, currentPage, pageSize, searchText, tagFilter]); }, [canViewFull, currentPage, pageSize, searchText, sensitiveVisible, tagFilter]);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
try { try {
@@ -224,6 +225,27 @@ export default function AccountsPage() {
} }
}; };
const handleSensitiveVisibility = () => {
if (sensitiveVisible) {
// 立即清除已加载的敏感内容,再由 effect 重新获取脱敏列表。
setAccounts((previous) => previous.map((account) => ({
...account,
password: undefined,
email: undefined,
email_password: undefined,
})));
setSensitiveVisible(false);
return;
}
Modal.confirm({
title: '显示账号凭据?',
content: '密码、邮箱和邮箱密码将临时显示在当前页面。关闭或刷新页面后会恢复脱敏。',
okText: '显示',
cancelText: '取消',
onOk: () => setSensitiveVisible(true),
});
};
const columns: TableProps<AccountItem>['columns'] = [ const columns: TableProps<AccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 60 }, { title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户名', dataIndex: 'username' }, { title: '用户名', dataIndex: 'username' },
@@ -277,8 +299,7 @@ export default function AccountsPage() {
}, },
]; ];
// 只有管理员可看完整字段(密码、邮箱等) if (canViewFull && sensitiveVisible) {
if (canViewFull) {
columns.push( columns.push(
{ title: '密码', dataIndex: 'password', width: 120 }, { title: '密码', dataIndex: 'password', width: 120 },
{ title: '邮箱', dataIndex: 'email' }, { title: '邮箱', dataIndex: 'email' },
@@ -346,6 +367,14 @@ export default function AccountsPage() {
setCurrentPage(1); setCurrentPage(1);
}} }}
/> />
{canViewFull && (
<Button
icon={sensitiveVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={handleSensitiveVisibility}
>
{sensitiveVisible ? '隐藏凭据' : '显示凭据'}
</Button>
)}
{canImport && ( {canImport && (
<Button <Button
disabled={selectedCount === 0} disabled={selectedCount === 0}