feat: add sub-account login to AirGate
- IAMUser model: login_password_hash + login_enabled fields - Custom JWT auth for sub-accounts (role: iam_user) - Login/me/my-keys/reveal endpoints for sub-accounts - Admin can set login password via set-login endpoint - Sub-accounts can only see their own API Keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7feb007f57
commit
daa82aee76
@ -10,4 +10,10 @@ urlpatterns = [
|
|||||||
path('admins/create/', views.admin_create_view),
|
path('admins/create/', views.admin_create_view),
|
||||||
path('admins/<int:pk>/toggle/', views.admin_toggle_view),
|
path('admins/<int:pk>/toggle/', views.admin_toggle_view),
|
||||||
path('admins/<int:pk>/reset-password/', views.admin_reset_password_view),
|
path('admins/<int:pk>/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/<int:pk>/reveal/', views.iam_my_key_reveal_view),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from rest_framework import status
|
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.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
@ -195,3 +195,158 @@ def admin_reset_password_view(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@ -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 登录密码哈希'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,4 +1,5 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from django.contrib.auth.hashers import make_password, check_password
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
@ -37,6 +38,10 @@ class IAMUser(models.Model):
|
|||||||
phone = models.CharField('手机号', max_length=20, blank=True)
|
phone = models.CharField('手机号', max_length=20, blank=True)
|
||||||
status = models.CharField('状态', max_length=20, choices=Status.choices, default=Status.UNKNOWN)
|
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 keys (stored as JSON list of AK IDs, not secrets)
|
||||||
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
access_key_ids = models.JSONField('AccessKey ID 列表', default=list, blank=True)
|
||||||
|
|
||||||
@ -73,6 +78,12 @@ class IAMUser(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.display_name or self.username} ({self.status})"
|
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
|
@property
|
||||||
def remaining_quota(self):
|
def remaining_quota(self):
|
||||||
"""剩余额度"""
|
"""剩余额度"""
|
||||||
|
|||||||
@ -41,6 +41,7 @@ class IAMUserSerializer(serializers.ModelSerializer):
|
|||||||
'alert_thresholds', 'triggered_alerts',
|
'alert_thresholds', 'triggered_alerts',
|
||||||
'effective_alert_thresholds',
|
'effective_alert_thresholds',
|
||||||
'projects', 'monitored_project_count',
|
'projects', 'monitored_project_count',
|
||||||
|
'login_enabled',
|
||||||
'remark', 'created_at', 'updated_at',
|
'remark', 'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
read_only_fields = ['user_id', 'access_key_ids', 'status',
|
||||||
|
|||||||
@ -17,6 +17,7 @@ urlpatterns = [
|
|||||||
path('iam-users/import/', views.iam_user_import_view),
|
path('iam-users/import/', views.iam_user_import_view),
|
||||||
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
path('iam-users/<int:pk>/', views.iam_user_detail_view),
|
||||||
path('iam-users/<int:pk>/update/', views.iam_user_update_view),
|
path('iam-users/<int:pk>/update/', views.iam_user_update_view),
|
||||||
|
path('iam-users/<int:pk>/set-login/', views.iam_user_set_login_view),
|
||||||
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
path('iam-users/<int:pk>/disable/', views.iam_user_disable_view),
|
||||||
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
path('iam-users/<int:pk>/enable/', views.iam_user_enable_view),
|
||||||
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
path('iam-users/<int:pk>/policies/', views.iam_user_policies_view),
|
||||||
|
|||||||
@ -369,6 +369,42 @@ def iam_user_update_view(request, pk):
|
|||||||
return Response(IAMUserSerializer(user).data)
|
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'])
|
@api_view(['POST'])
|
||||||
def iam_user_disable_view(request, pk):
|
def iam_user_disable_view(request, pk):
|
||||||
"""停用子账号"""
|
"""停用子账号"""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user