feat: v0.12.1 安全加固补充 + 短信测试按钮

①Refresh Token 轮换(ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION)
②前端 token 刷新时保存新 refresh token(auth store + axios 拦截器)
③短信告警测试按钮(/admin/test-sms + 系统设置页按钮)
④安全审查完成:S2 git 历史无泄露、S4 无攻击面、S7 nginx 已配、S10 全接口有权限

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-22 19:38:42 +08:00
parent 203603f69a
commit afcff9455f
7 changed files with 135 additions and 9 deletions

View File

@ -41,6 +41,7 @@ urlpatterns = [
# ── Super Admin: Anomaly Detection ──
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
path('admin/test-sms', views.admin_test_sms_view, name='admin_test_sms'),
path('admin/teams/<int:team_id>/auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'),
path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'),

View File

@ -1733,6 +1733,21 @@ def admin_test_feishu_view(request):
return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([IsSuperAdmin])
def admin_test_sms_view(request):
"""POST /api/v1/admin/test-sms — Send a test SMS alert."""
mobile = request.data.get('mobile', '').strip()
if not mobile:
return Response({'error': '请输入手机号'}, status=status.HTTP_400_BAD_REQUEST)
from utils.alert_service import send_sms_test
success, message = send_sms_test(mobile)
if success:
return Response({'message': message})
return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([IsSuperAdmin])
def admin_team_auto_learn_view(request, team_id):

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# Third party
'rest_framework',
'rest_framework_simplejwt.token_blacklist',
'corsheaders',
# Local apps
'apps.accounts',
@ -151,7 +152,8 @@ REST_FRAMEWORK = {
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'AUTH_HEADER_TYPES': ('Bearer',),
}

View File

@ -311,6 +311,77 @@ def send_sms_alert(anomaly):
logger.error('SMS alert error for %s: %s', mobile, e)
def send_sms_test(mobile):
"""发送短信测试到指定手机号。Returns (success, message)。"""
from django.conf import settings as django_settings
access_key = django_settings.ALIYUN_SMS_ACCESS_KEY
access_secret = django_settings.ALIYUN_SMS_ACCESS_SECRET
sign_name = django_settings.ALIYUN_SMS_SIGN_NAME
template_code = django_settings.ALIYUN_SMS_TEMPLATE_CODE
if not all([access_key, access_secret, template_code]):
return False, '阿里云短信密钥未配置ALIYUN_SMS_ACCESS_KEY / ALIYUN_SMS_ACCESS_SECRET'
template_param = json.dumps({
'team_name': '测试团队',
'rule_name': '告警测试',
'username': '测试用户',
'city': '测试城市',
'auto_action': '仅测试',
}, ensure_ascii=False)
import hashlib
import hmac
import base64
import urllib.parse
import uuid
from datetime import datetime
def _percent_encode(s):
return urllib.parse.quote(s, safe='', encoding='utf-8')
try:
params = {
'AccessKeyId': access_key,
'Action': 'SendSms',
'Format': 'JSON',
'PhoneNumbers': mobile,
'RegionId': 'cn-hangzhou',
'SignName': sign_name,
'SignatureMethod': 'HMAC-SHA1',
'SignatureNonce': str(uuid.uuid4()),
'SignatureVersion': '1.0',
'TemplateCode': template_code,
'TemplateParam': template_param,
'Timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
'Version': '2017-05-25',
}
sorted_params = sorted(params.items())
query_string = '&'.join(f'{_percent_encode(k)}={_percent_encode(v)}' for k, v in sorted_params)
string_to_sign = f'GET&{_percent_encode("/")}&{_percent_encode(query_string)}'
sign_key = (access_secret + '&').encode('utf-8')
signature = base64.b64encode(
hmac.new(sign_key, string_to_sign.encode('utf-8'), hashlib.sha1).digest()
).decode('utf-8')
params['Signature'] = signature
resp = requests.get(
'https://dysmsapi.aliyuncs.com/',
params=params,
timeout=10,
)
data = resp.json()
if data.get('Code') == 'OK':
return True, '测试短信已发送'
return False, f'发送失败: {data.get("Message", data.get("Code", "未知错误"))}'
except Exception as e:
return False, str(e)
def send_feishu_test(mobile):
"""发送测试消息到指定手机号。Returns (success, message)。"""
try:

View File

@ -67,6 +67,9 @@ api.interceptors.response.use(
refresh: refreshToken,
});
localStorage.setItem('access_token', data.access);
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
}
originalRequest.headers.Authorization = `Bearer ${data.access}`;
return api(originalRequest);
} catch {
@ -96,7 +99,7 @@ export const authApi = {
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
refreshToken: (refresh: string) =>
api.post<{ access: string }>('/auth/token/refresh', { refresh }),
api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }),
getMe: () =>
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
@ -280,6 +283,9 @@ export const adminApi = {
testFeishu: (mobile: string) =>
api.post<{ message: string }>('/admin/test-feishu', { mobile }),
testSms: (mobile: string) =>
api.post<{ message: string }>('/admin/test-sms', { mobile }),
teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) =>
api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>(
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }

View File

@ -33,6 +33,7 @@ export function SettingsPage() {
alert_cooldown_seconds: 1800,
});
const [testingFeishu, setTestingFeishu] = useState(false);
const [testingSms, setTestingSms] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -87,6 +88,20 @@ export function SettingsPage() {
}
};
const handleTestSms = async () => {
const mobiles = settings.sms_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
if (mobiles.length === 0) { showToast('请先填写短信告警手机号'); return; }
setTestingSms(true);
try {
await adminApi.testSms(mobiles[0]);
showToast('测试短信已发送');
} catch (err: any) {
showToast(err.response?.data?.error || '发送失败');
} finally {
setTestingSms(false);
}
};
if (loading) {
return (
<div className={styles.page}>
@ -330,12 +345,23 @@ export function SettingsPage() {
<div className={styles.formGroup}>
<label></label>
<input
type="text"
value={settings.sms_alert_mobiles}
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
placeholder="多个手机号用逗号分隔,如 13800138000,13900139000"
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={settings.sms_alert_mobiles}
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
placeholder="多个手机号用逗号分隔,如 13800138000,13900139000"
style={{ flex: 1 }}
/>
<button
className={styles.saveBtn}
onClick={handleTestSms}
disabled={testingSms}
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
>
{testingSms ? '发送中...' : '测试'}
</button>
</div>
</div>
<div className={styles.formGroup}>

View File

@ -75,7 +75,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!refresh) throw new Error('No refresh token');
const { data } = await authApi.refreshToken(refresh);
localStorage.setItem('access_token', data.access);
set({ accessToken: data.access });
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
set({ accessToken: data.access, refreshToken: data.refresh });
} else {
set({ accessToken: data.access });
}
},
fetchUserInfo: async () => {