diff --git a/backend/apps/accounts/urls.py b/backend/apps/accounts/urls.py index 23c181a..e8472ba 100644 --- a/backend/apps/accounts/urls.py +++ b/backend/apps/accounts/urls.py @@ -10,4 +10,10 @@ urlpatterns = [ path('admins/create/', views.admin_create_view), path('admins//toggle/', views.admin_toggle_view), path('admins//reset-password/', views.admin_reset_password_view), + + # Sub-account (IAM user) login + path('iam/login/', views.iam_login_view), + path('iam/me/', views.iam_me_view), + path('iam/my-keys/', views.iam_my_keys_view), + path('iam/my-keys//reveal/', views.iam_my_key_reveal_view), ] diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index e5f7d67..83377af 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -1,6 +1,6 @@ from django.contrib.auth import authenticate from rest_framework import status -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework_simplejwt.tokens import RefreshToken @@ -195,3 +195,158 @@ def admin_reset_password_view(request, pk): ) return Response({'message': f'已重置 {user.username} 的密码'}) + + +# ==================== Sub-account (IAM User) Login ==================== + +@api_view(['POST']) +@permission_classes([AllowAny]) +def iam_login_view(request): + """子账号登录 AirGate""" + username = request.data.get('username', '') + password = request.data.get('password', '') + + if not username or not password: + return Response({'error': 'missing', 'message': '请输入用户名和密码'}, + status=status.HTTP_400_BAD_REQUEST) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(username=username) + except IAMUser.DoesNotExist: + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + if not iam_user.login_enabled: + return Response({'error': 'login_disabled', 'message': '此账号未开通 AirGate 登录'}, + status=status.HTTP_403_FORBIDDEN) + + if iam_user.status != IAMUser.Status.ACTIVE: + return Response({'error': 'user_disabled', 'message': '账号已停用'}, + status=status.HTTP_403_FORBIDDEN) + + if not iam_user.check_login_password(password): + return Response({'error': 'invalid_credentials', 'message': '用户名或密码错误'}, + status=status.HTTP_401_UNAUTHORIZED) + + # Generate JWT token with iam_user info (use a dummy admin user for simplejwt) + import jwt + from django.conf import settings + from datetime import datetime, timedelta, timezone + payload = { + 'token_type': 'access', + 'iam_user_id': iam_user.id, + 'username': iam_user.username, + 'role': 'iam_user', + 'exp': datetime.now(timezone.utc) + timedelta(hours=24), + 'iat': datetime.now(timezone.utc), + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + + return Response({ + 'access': token, + 'user': { + 'id': iam_user.id, + 'username': iam_user.username, + 'display_name': iam_user.display_name, + 'role': 'iam_user', + } + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_me_view(request): + """子账号获取自身信息(通过 JWT token 中的 iam_user_id)""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return Response({'error': 'token_expired'}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import IAMUser + try: + iam_user = IAMUser.objects.get(pk=payload['iam_user_id']) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + from apps.monitor.serializers import IAMUserSerializer + return Response({ + 'role': 'iam_user', + 'user': IAMUserSerializer(iam_user).data, + }) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_keys_view(request): + """子账号查看自己的 API Key""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from apps.monitor.serializers import ArkApiKeySerializer + keys = ArkApiKey.objects.filter(iam_user_id=payload['iam_user_id']) + return Response(ArkApiKeySerializer(keys, many=True).data) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def iam_my_key_reveal_view(request, pk): + """子账号查看自己的 API Key 明文""" + import jwt + from django.conf import settings + + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED) + + token = auth_header.split(' ', 1)[1] + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED) + + if payload.get('role') != 'iam_user': + return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN) + + from apps.monitor.models import ArkApiKey + from utils.crypto import decrypt + try: + key = ArkApiKey.objects.get(pk=pk, iam_user_id=payload['iam_user_id']) + except ArkApiKey.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + return Response({ + 'api_key': decrypt(key.api_key_enc), + 'key_name': key.key_name, + 'project_name': key.project_name, + }) diff --git a/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py new file mode 100644 index 0000000..411e4e1 --- /dev/null +++ b/backend/apps/monitor/migrations/0008_iamuser_login_enabled_iamuser_login_password_hash.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.21 on 2026-03-20 17:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0007_arkapikey'), + ] + + operations = [ + migrations.AddField( + model_name='iamuser', + name='login_enabled', + field=models.BooleanField(default=False, verbose_name='允许登录 AirGate'), + ), + migrations.AddField( + model_name='iamuser', + name='login_password_hash', + field=models.CharField(blank=True, max_length=256, verbose_name='AirGate 登录密码哈希'), + ), + ] diff --git a/backend/apps/monitor/models.py b/backend/apps/monitor/models.py index 66b5411..dfc54bb 100644 --- a/backend/apps/monitor/models.py +++ b/backend/apps/monitor/models.py @@ -1,4 +1,5 @@ from decimal import Decimal +from django.contrib.auth.hashers import make_password, check_password from django.db import models @@ -37,6 +38,10 @@ class IAMUser(models.Model): phone = models.CharField('手机号', max_length=20, blank=True) status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN) + # AirGate 本地登录密码(子账号用来登录 AirGate,与火山控制台无关) + login_password_hash = models.CharField('AirGate 登录密码哈希', max_length=256, blank=True) + login_enabled = models.BooleanField('允许登录 AirGate', default=False) + # Access keys (stored as JSON list of AK IDs, not secrets) access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True) @@ -73,6 +78,12 @@ class IAMUser(models.Model): def __str__(self): return f"{self.display_name or self.username} ({self.status})" + def set_login_password(self, raw_password): + self.login_password_hash = make_password(raw_password) + + def check_login_password(self, raw_password): + return check_password(raw_password, self.login_password_hash) + @property def remaining_quota(self): """剩余额度""" diff --git a/backend/apps/monitor/serializers.py b/backend/apps/monitor/serializers.py index a33e961..31e37c6 100644 --- a/backend/apps/monitor/serializers.py +++ b/backend/apps/monitor/serializers.py @@ -41,6 +41,7 @@ class IAMUserSerializer(serializers.ModelSerializer): 'alert_thresholds', 'triggered_alerts', 'effective_alert_thresholds', 'projects', 'monitored_project_count', + 'login_enabled', 'remark', 'created_at', 'updated_at', ] read_only_fields = ['user_id', 'access_key_ids', 'status', diff --git a/backend/apps/monitor/urls.py b/backend/apps/monitor/urls.py index 80fb5b8..7ff3cb5 100644 --- a/backend/apps/monitor/urls.py +++ b/backend/apps/monitor/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path('iam-users/import/', views.iam_user_import_view), path('iam-users//', views.iam_user_detail_view), path('iam-users//update/', views.iam_user_update_view), + path('iam-users//set-login/', views.iam_user_set_login_view), path('iam-users//disable/', views.iam_user_disable_view), path('iam-users//enable/', views.iam_user_enable_view), path('iam-users//policies/', views.iam_user_policies_view), diff --git a/backend/apps/monitor/views.py b/backend/apps/monitor/views.py index 70fd797..e4a21d3 100644 --- a/backend/apps/monitor/views.py +++ b/backend/apps/monitor/views.py @@ -369,6 +369,42 @@ def iam_user_update_view(request, pk): return Response(IAMUserSerializer(user).data) +@api_view(['POST']) +def iam_user_set_login_view(request, pk): + """设置子账号的 AirGate 登录密码""" + try: + user = IAMUser.objects.get(pk=pk) + except IAMUser.DoesNotExist: + return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND) + + password = request.data.get('password', '') + enabled = request.data.get('login_enabled') + + if password: + if len(password) < 6: + return Response({'error': 'weak_password', 'message': '密码至少6位'}, + status=status.HTTP_400_BAD_REQUEST) + user.set_login_password(password) + user.login_enabled = True + + if enabled is not None: + user.login_enabled = enabled + + user.save(update_fields=['login_password_hash', 'login_enabled']) + + AlertRecord.objects.create( + iam_user=user, + alert_type=AlertRecord.AlertType.MANUAL, + title=f"设置子账号 {user.username} 的 AirGate 登录", + content=f"操作人: {request.user.username},登录: {'开启' if user.login_enabled else '关闭'}", + ) + + return Response({ + 'message': f'已{"开启" if user.login_enabled else "关闭"}子账号 {user.username} 的 AirGate 登录', + 'login_enabled': user.login_enabled, + }) + + @api_view(['POST']) def iam_user_disable_view(request, pk): """停用子账号"""