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:
parent
203603f69a
commit
afcff9455f
@ -41,6 +41,7 @@ urlpatterns = [
|
|||||||
# ── Super Admin: Anomaly Detection ──
|
# ── Super Admin: Anomaly Detection ──
|
||||||
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
|
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-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>/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'),
|
path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'),
|
||||||
|
|
||||||
|
|||||||
@ -1733,6 +1733,21 @@ def admin_test_feishu_view(request):
|
|||||||
return Response({'error': message}, status=status.HTTP_400_BAD_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'])
|
@api_view(['POST'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_team_auto_learn_view(request, team_id):
|
def admin_team_auto_learn_view(request, team_id):
|
||||||
|
|||||||
@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
# Third party
|
# Third party
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt.token_blacklist',
|
||||||
'corsheaders',
|
'corsheaders',
|
||||||
# Local apps
|
# Local apps
|
||||||
'apps.accounts',
|
'apps.accounts',
|
||||||
@ -151,7 +152,8 @@ REST_FRAMEWORK = {
|
|||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
'ROTATE_REFRESH_TOKENS': False,
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
|
'BLACKLIST_AFTER_ROTATION': True,
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -311,6 +311,77 @@ def send_sms_alert(anomaly):
|
|||||||
logger.error('SMS alert error for %s: %s', mobile, e)
|
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):
|
def send_feishu_test(mobile):
|
||||||
"""发送测试消息到指定手机号。Returns (success, message)。"""
|
"""发送测试消息到指定手机号。Returns (success, message)。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -67,6 +67,9 @@ api.interceptors.response.use(
|
|||||||
refresh: refreshToken,
|
refresh: refreshToken,
|
||||||
});
|
});
|
||||||
localStorage.setItem('access_token', data.access);
|
localStorage.setItem('access_token', data.access);
|
||||||
|
if (data.refresh) {
|
||||||
|
localStorage.setItem('refresh_token', data.refresh);
|
||||||
|
}
|
||||||
originalRequest.headers.Authorization = `Bearer ${data.access}`;
|
originalRequest.headers.Authorization = `Bearer ${data.access}`;
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
} catch {
|
} catch {
|
||||||
@ -96,7 +99,7 @@ export const authApi = {
|
|||||||
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
|
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
|
||||||
|
|
||||||
refreshToken: (refresh: string) =>
|
refreshToken: (refresh: string) =>
|
||||||
api.post<{ access: string }>('/auth/token/refresh', { refresh }),
|
api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }),
|
||||||
|
|
||||||
getMe: () =>
|
getMe: () =>
|
||||||
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
|
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
|
||||||
@ -280,6 +283,9 @@ export const adminApi = {
|
|||||||
testFeishu: (mobile: string) =>
|
testFeishu: (mobile: string) =>
|
||||||
api.post<{ message: string }>('/admin/test-feishu', { mobile }),
|
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) =>
|
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 }>(
|
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 }
|
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function SettingsPage() {
|
|||||||
alert_cooldown_seconds: 1800,
|
alert_cooldown_seconds: 1800,
|
||||||
});
|
});
|
||||||
const [testingFeishu, setTestingFeishu] = useState(false);
|
const [testingFeishu, setTestingFeishu] = useState(false);
|
||||||
|
const [testingSms, setTestingSms] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@ -330,12 +345,23 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>短信告警手机号</label>
|
<label>短信告警手机号</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
type="text"
|
<input
|
||||||
value={settings.sms_alert_mobiles}
|
type="text"
|
||||||
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
|
value={settings.sms_alert_mobiles}
|
||||||
placeholder="多个手机号用逗号分隔,如 13800138000,13900139000"
|
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>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
|
|||||||
@ -75,7 +75,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (!refresh) throw new Error('No refresh token');
|
if (!refresh) throw new Error('No refresh token');
|
||||||
const { data } = await authApi.refreshToken(refresh);
|
const { data } = await authApi.refreshToken(refresh);
|
||||||
localStorage.setItem('access_token', data.access);
|
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 () => {
|
fetchUserInfo: async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user