feat: 账号凭据默认脱敏并支持主动查看
This commit is contained in:
@@ -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()
|
||||||
@@ -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),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user