Преглед на файлове

feat: 接入统一身份认证(SSO)

- 新增 SSO 模块:OAuth2 授权码流程、用户同步、角色映射
- 登录页添加 SSO 登录按钮
- SSO 回调页面处理授权码交换
- 配置通过环境变量注入(MAXKB_SSO_*)
- 支持 SSO_BASE_URL、CLIENT_ID、SECRET、REDIRECT_URI 配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip преди 6 дни
родител
ревизия
a5d2df275b
променени са 10 файла, в които са добавени 406 реда и са изтрити 9 реда
  1. 2 0
      apps/maxkb/urls/web.py
  2. 1 0
      apps/sso/__init__.py
  3. 89 0
      apps/sso/services/sso_client.py
  4. 10 0
      apps/sso/urls.py
  5. 144 0
      apps/sso/views/sso_view.py
  6. 12 0
      docker-compose.yml
  7. 31 0
      ui/src/api/sso.ts
  8. 5 0
      ui/src/router/routes.ts
  9. 28 9
      ui/src/views/login/index.vue
  10. 84 0
      ui/src/views/sso/SSOCallback.vue

+ 2 - 0
apps/maxkb/urls/web.py

@@ -45,6 +45,8 @@ urlpatterns = [
     path(admin_api_prefix, include("application.urls")),
     path(admin_api_prefix, include("knowledge.urls")),
     path(admin_api_prefix, include("tools.urls")),
+    path(admin_api_prefix, include("sso.urls")),
+    path(admin_api_prefix, include("plugin.urls")),
     path(builder_api_prefix, include("tools.urls")),
     path(builder_api_prefix, include("models_provider.urls")),
     path(builder_api_prefix, include("folders.urls")),

+ 1 - 0
apps/sso/__init__.py

@@ -0,0 +1 @@
+# coding=utf-8

+ 89 - 0
apps/sso/services/sso_client.py

@@ -0,0 +1,89 @@
+# coding=utf-8
+"""
+SSO 统一认证客户端
+对接 LQAI-middle-platform 统一认证平台
+"""
+from typing import Dict, Optional
+
+import requests
+from django.conf import settings
+from django.utils.translation import gettext as _
+
+from common.exception.app_exception import AppApiException
+from common.utils.logger import maxkb_logger
+
+
+class SSOClient:
+    """SSO 统一认证客户端"""
+
+    def __init__(self, base_url: str = None, client_id: str = None,
+                 client_secret: str = None, redirect_uri: str = None):
+        self.base_url = (base_url or getattr(settings, 'SSO_BASE_URL', '')).rstrip('/')
+        self.client_id = client_id or getattr(settings, 'SSO_CLIENT_ID', '')
+        self.client_secret = client_secret or getattr(settings, 'SSO_CLIENT_SECRET', '')
+        self.redirect_uri = redirect_uri or getattr(settings, 'SSO_REDIRECT_URI', '')
+
+    def get_authorize_url(self, state: str = None) -> str:
+        """获取 SSO 授权 URL"""
+        params = {
+            'response_type': 'code',
+            'client_id': self.client_id,
+            'redirect_uri': self.redirect_uri,
+            'scope': 'profile email',
+        }
+        if state:
+            params['state'] = state
+
+        query_string = '&'.join(f'{k}={v}' for k, v in params.items())
+        return f"{self.base_url}/oauth/authorize?{query_string}"
+
+    def exchange_code(self, code: str) -> Dict:
+        """用授权码换取 SSO access_token"""
+        url = f"{self.base_url}/oauth/token"
+        payload = {
+            'grant_type': 'authorization_code',
+            'code': code,
+            'redirect_uri': self.redirect_uri,
+            'client_id': self.client_id,
+            'client_secret': self.client_secret,
+        }
+
+        try:
+            response = requests.post(url, data=payload, timeout=10)
+            response.raise_for_status()
+            result = response.json()
+
+            if 'error' in result:
+                raise AppApiException(400, _('SSO token exchange failed: {error}').format(
+                    error=result.get('error_description', result['error'])))
+
+            return result
+        except requests.exceptions.RequestException as e:
+            maxkb_logger.error(f'SSO token exchange request failed: {e}')
+            raise AppApiException(502, _('Failed to connect to SSO server: {error}').format(error=str(e)))
+
+    def get_userinfo(self, access_token: str) -> Dict:
+        """获取用户信息"""
+        url = f"{self.base_url}/oauth/userinfo"
+        headers = {
+            'Authorization': f'Bearer {access_token}',
+        }
+
+        try:
+            response = requests.get(url, headers=headers, timeout=10)
+            response.raise_for_status()
+            result = response.json()
+
+            if 'error' in result:
+                raise AppApiException(400, _('SSO userinfo failed: {error}').format(
+                    error=result.get('error_description', result['error'])))
+
+            return result
+        except requests.exceptions.RequestException as e:
+            maxkb_logger.error(f'SSO userinfo request failed: {e}')
+            raise AppApiException(502, _('Failed to connect to SSO server: {error}').format(error=str(e)))
+
+
+def get_sso_client(**kwargs) -> SSOClient:
+    """获取 SSO 客户端实例"""
+    return SSOClient(**kwargs)

+ 10 - 0
apps/sso/urls.py

@@ -0,0 +1,10 @@
+from django.urls import path
+
+from .views.sso_view import SSOView
+
+app_name = "sso"
+
+urlpatterns = [
+    path('sso/authorize', SSOView.AuthorizeUrl.as_view()),
+    path('oauth/exchange-code', SSOView.ExchangeCode.as_view()),
+]

+ 144 - 0
apps/sso/views/sso_view.py

@@ -0,0 +1,144 @@
+# coding=utf-8
+"""
+SSO 统一认证视图
+对接 LQAI-middle-platform 统一认证平台
+"""
+import uuid_utils.compat as uuid
+from django.utils.translation import gettext as _
+from rest_framework.request import Request
+from rest_framework.views import APIView
+
+from common.auth import TokenAuth
+from common.exception.app_exception import AppApiException
+from common.result import result
+from sso.services.sso_client import get_sso_client
+from users.models import User
+from common.utils.logger import maxkb_logger
+
+# SSO 角色 code → 本地角色映射
+SSO_ROLE_MAP = {
+    'super_admin': 'ADMIN',
+    'ws_admin': 'WORKSPACE_MANAGE',
+    'user': 'USER',
+}
+
+
+class SSOView(APIView):
+    """SSO 统一认证"""
+    authentication_classes = [TokenAuth]
+
+    class AuthorizeUrl(APIView):
+        """获取 SSO 授权 URL"""
+        authentication_classes = []
+
+        def get(self, request: Request):
+            redirect = request.query_params.get('redirect', 'false') == 'true'
+            state = request.query_params.get('state', str(uuid.uuid4()))
+
+            client = get_sso_client()
+            authorize_url = client.get_authorize_url(state=state)
+
+            if redirect:
+                from django.http import HttpResponseRedirect
+                return HttpResponseRedirect(authorize_url)
+
+            return result.success({
+                'authorize_url': authorize_url,
+                'state': state,
+            })
+
+    class ExchangeCode(APIView):
+        """授权码交换(核心免登接口)
+        POST /api/oauth/exchange-code
+        """
+        authentication_classes = []
+
+        def post(self, request: Request):
+            code = request.data.get('code', '')
+            if not code:
+                raise AppApiException(400, _('缺少授权码'))
+
+            client = get_sso_client()
+
+            # Step 1: 用 code 换 SSO access_token
+            token_data = client.exchange_code(code)
+            sso_access_token = token_data.get('access_token', '')
+
+            if not sso_access_token:
+                raise AppApiException(400, _('SSO token exchange failed'))
+
+            # Step 2: 获取用户信息(含角色)
+            userinfo = client.get_userinfo(sso_access_token)
+
+            # Step 3: 同步用户到本地数据库
+            user = SSOView.ExchangeCode._sync_user(userinfo)
+
+            # Step 4: 同步角色
+            roles = userinfo.get('roles', [])
+            SSOView.ExchangeCode._sync_roles(user, roles)
+
+            # Step 5: 签发本地 JWT
+            from common.utils.common import signing
+            token = signing.dumps({'user_id': str(user.id)})
+            refresh_token = signing.dumps({'user_id': str(user.id), 'type': 'refresh'})
+
+            return result.success({
+                'token': token,
+                'refresh_token': refresh_token,
+                'user': {
+                    'id': str(user.id),
+                    'username': user.username,
+                    'email': user.email,
+                    'phone': user.phone,
+                    'is_superuser': user.is_superuser,
+                    'is_active': user.is_active,
+                    'roles': [user.role],
+                },
+            })
+
+        @staticmethod
+        def _sync_user(userinfo: dict) -> User:
+            """同步 SSO 用户到本地数据库"""
+            username = userinfo.get('username', '')
+            email = userinfo.get('email', '')
+            real_name = userinfo.get('real_name', '')
+
+            if not username:
+                raise AppApiException(400, _('SSO user info missing username'))
+
+            user, created = User.objects.get_or_create(
+                username=username,
+                defaults={
+                    'email': email,
+                    'nick_name': real_name or username,
+                    'is_active': True,
+                }
+            )
+
+            if not created:
+                user.email = email
+                if real_name:
+                    user.nick_name = real_name
+                user.save()
+
+            return user
+
+        @staticmethod
+        def _sync_roles(user: User, sso_roles: list):
+            """同步 SSO 角色到本地用户角色字段"""
+            if not sso_roles:
+                return
+
+            # 取第一个匹配的本地角色
+            for role_info in sso_roles:
+                code = role_info.get('code', '')
+                local_role = SSO_ROLE_MAP.get(code)
+                if local_role:
+                    user.role = local_role
+                    user.save()
+                    return
+
+            # 没有匹配的角色,默认普通用户
+            if not user.role:
+                user.role = 'USER'
+                user.save()

+ 12 - 0
docker-compose.yml

@@ -53,9 +53,15 @@ services:
       - MAXKB_REDIS_PASSWORD=
       - MAXKB_REDIS_DB=0
       - MAXKB_DEBUG=false
+      - MAXKB_SSO_BASE_URL=http://192.168.92.61:8200
+      - MAXKB_SSO_CLIENT_ID=kcoy9GEKTfCRUZOrwqOtce4vhiyXYjro
+      - MAXKB_SSO_CLIENT_SECRET=PNcpLCGXAOd6MSIs5WhQ52DyGEVcf8W45D5SLLXQcFCPij9ZZtssQYeGBPhbXZrp
+      - MAXKB_SSO_REDIRECT_URI=http://10.110.20.131/auth/callback
+      - MAXKB_SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
     volumes:
       - D:/UGit/maas_agent_storage/uploads:/app/uploads
       - ./apps/static:/app/apps/static
+      - ./config.yml:/app/config.yml:ro
     depends_on:
       db:
         condition: service_healthy
@@ -80,8 +86,14 @@ services:
       - MAXKB_REDIS_PASSWORD=
       - MAXKB_REDIS_DB=0
       - MAXKB_DEBUG=false
+      - MAXKB_SSO_BASE_URL=http://192.168.92.61:8200
+      - MAXKB_SSO_CLIENT_ID=kcoy9GEKTfCRUZOrwqOtce4vhiyXYjro
+      - MAXKB_SSO_CLIENT_SECRET=PNcpLCGXAOd6MSIs5WhQ52DyGEVcf8W45D5SLLXQcFCPij9ZZtssQYeGBPhbXZrp
+      - MAXKB_SSO_REDIRECT_URI=http://10.110.20.131/auth/callback
+      - MAXKB_SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
     volumes:
       - D:/UGit/maas_agent_storage/uploads:/app/uploads
+      - ./config.yml:/app/config.yml:ro
     depends_on:
       db:
         condition: service_healthy

+ 31 - 0
ui/src/api/sso.ts

@@ -0,0 +1,31 @@
+import { get, post } from '@/request/index'
+import { type Ref } from 'vue'
+import type { Result } from '@/request/Result'
+
+export interface SSOResponse {
+  token: string
+  refresh_token: string
+  user: {
+    id: string
+    username: string
+    email: string
+    phone: string
+    is_superuser: boolean
+    is_active: boolean
+    roles: string[]
+  }
+}
+
+export function getSSOAuthorizeUrl(
+  state?: string,
+  loading?: Ref<boolean>,
+): Promise<Result<{ authorize_url: string; state: string }>> {
+  return get('sso/authorize', { state }, loading)
+}
+
+export function exchangeSSOCode(
+  code: string,
+  loading?: Ref<boolean>,
+): Promise<Result<SSOResponse>> {
+  return post('sso/exchange-code', { code }, loading)
+}

+ 5 - 0
ui/src/router/routes.ts

@@ -73,6 +73,11 @@ export const routes: Array<RouteRecordRaw> = [
     name: 'login',
     component: () => import('@/views/login/index.vue'),
   },
+  {
+    path: '/auth/callback',
+    name: 'SSOCallback',
+    component: () => import('@/views/sso/SSOCallback.vue'),
+  },
   {
     path: '/forgot_password',
     name: 'ForgotPassword',

+ 28 - 9
ui/src/views/login/index.vue

@@ -58,15 +58,24 @@
           </div>
         </el-form>
 
-        <el-button
-          size="large"
-          type="primary"
-          class="w-full"
-          @click="loginHandle"
-          :loading="loading"
-        >
-          {{ $t('views.login.buttons.login') }}
-        </el-button>
+        <div class="login-buttons">
+          <el-button
+            size="large"
+            type="primary"
+            class="w-full"
+            @click="loginHandle"
+            :loading="loading"
+          >
+            {{ $t('views.login.buttons.login') }}
+          </el-button>
+          <el-button
+            size="large"
+            class="w-full mt-16"
+            @click="ssoLogin"
+          >
+            统一认证登录 (SSO)
+          </el-button>
+        </div>
         <div class="operate-container flex-between mt-12">
           <el-button
             :loading="loading"
@@ -223,6 +232,10 @@ const loginHandle = () => {
   })
 }
 
+const ssoLogin = () => {
+  window.location.href = '/admin/api/sso/authorize?redirect=true'
+}
+
 function makeCode(username?: string) {
   loginApi.getCaptcha(username).then((res: any) => {
     if (res && res.data && res.data.captcha) {
@@ -532,6 +545,12 @@ onMounted(() => {
 })
 </script>
 <style lang="scss" scoped>
+.login-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
 .login-gradient-divider {
   position: relative;
   text-align: center;

+ 84 - 0
ui/src/views/sso/SSOCallback.vue

@@ -0,0 +1,84 @@
+<template>
+  <div class="sso-callback">
+    <div v-if="loading" class="loading">
+      <el-icon class="is-loading" :size="40"><Loading /></el-icon>
+      <p>正在处理登录...</p>
+    </div>
+    <div v-else-if="error" class="error">
+      <el-icon :size="40" color="#F56C6C"><CircleCloseFilled /></el-icon>
+      <p>{{ error }}</p>
+      <el-button type="primary" @click="goToLogin">返回登录</el-button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { Loading, CircleCloseFilled } from '@element-plus/icons-vue'
+import { exchangeSSOCode } from '@/api/sso'
+import { ElMessage } from 'element-plus'
+
+const router = useRouter()
+const route = useRoute()
+
+const loading = ref(true)
+const error = ref('')
+
+onMounted(async () => {
+  const code = route.query.code as string
+
+  if (!code) {
+    error.value = '缺少授权码'
+    loading.value = false
+    return
+  }
+
+  try {
+    const res = await exchangeSSOCode(code)
+
+    if (res.data?.token) {
+      // 保存 Token(与 login store 使用相同的 key)
+      localStorage.setItem('token', res.data.token)
+      localStorage.setItem('refresh_token', res.data.refresh_token)
+      localStorage.setItem('user', JSON.stringify(res.data.user))
+
+      ElMessage.success('登录成功')
+
+      // 跳转到首页
+      router.push('/')
+    } else {
+      error.value = '登录失败:未获取到 Token'
+      loading.value = false
+    }
+  } catch (e: any) {
+    error.value = e?.message || '登录失败'
+    loading.value = false
+  }
+})
+
+const goToLogin = () => {
+  router.push('/login')
+}
+</script>
+
+<style scoped>
+.sso-callback {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  flex-direction: column;
+}
+.loading, .error {
+  text-align: center;
+}
+.loading p, .error p {
+  margin-top: 16px;
+  font-size: 16px;
+  color: #606266;
+}
+.error p {
+  color: #F56C6C;
+}
+</style>