搜索结果

×

搜索结果将在这里显示。

🪁 sso业务系统登录集成指南

如何接入登录系统和创建菜单

本文档说明如何将您的应用接入 SSO 单点登录系统,以及如何为应用创建菜单。

目录

  1. 快速开始
  2. 登录流程
  3. 实现步骤
  4. 创建菜单
  5. API 端点
  6. 常见问题

快速开始

1. 创建应用并获取凭证

  1. 登录管理后台

    • 访问:http://your-sso-server:3007/admin
    • 使用管理员账号登录
  2. 创建应用

    • 进入"应用管理" → "创建应用"
    • 填写信息:
      • 应用名称:您的应用名称
      • 回调地址 (Redirect URI):例如 http://your-app.com/auth/callback
      • 权限范围 (Scopes):默认 openid profile email
    • 保存后获得:
      • Client ID:应用标识符
      • Client Secret:应用密钥(请妥善保管)
  3. 为用户授权

    • 进入"用户管理"
    • 为用户分配该应用的访问权限

登录流程

流程图

用户访问应用
    ↓
应用检测未登录
    ↓
重定向到 SSO 授权页面
    ↓
用户登录(如未登录)
    ↓
用户授权(如未授权)
    ↓
SSO 重定向回应用(带授权码)
    ↓
应用用授权码换取 Access Token
    ↓
应用使用 Token 获取用户信息
    ↓
登录成功

实现步骤

步骤 1:重定向用户到 SSO 登录

当检测到用户未登录时,重定向到 SSO 授权端点:

// 生成随机 state(用于 CSRF 防护)
const state = generateRandomString(32);
sessionStorage.setItem('oauth_state', state);

// 构建授权 URL
const authUrl = new URL('http://your-sso-server:8080/oauth/authorize');
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.set('redirect_uri', 'http://your-app.com/auth/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);

// 重定向
window.location.href = authUrl.toString();

步骤 2:处理回调

SSO 会重定向回您的回调地址,并携带授权码:

// 在回调页面处理
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');

// 验证 state
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
  console.error('Invalid state parameter');
  return;
}

// 用授权码换取 Token
const tokenData = await exchangeCodeForToken(code);

步骤 3:用授权码换取 Access Token

async function exchangeCodeForToken(code) {
  const response = await fetch('http://your-sso-server:8080/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      client_id: 'YOUR_CLIENT_ID',
      client_secret: 'YOUR_CLIENT_SECRET',
      redirect_uri: 'http://your-app.com/auth/callback',
    }),
  });

  if (!response.ok) {
    throw new Error('Token exchange failed');
  }

  return await response.json();
}

响应示例

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "refresh_token_string",
  "scope": "openid profile email"
}

步骤 4:获取用户信息

async function getUserInfo(accessToken) {
  const response = await fetch('http://your-sso-server:8080/oauth/userinfo', {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error('Get userinfo failed');
  }

  return await response.json();
}

响应示例

{
  "sub": "user_id",
  "username": "john_doe",
  "email": "john@example.com",
  "email_verified": true,
  "name": "John Doe"
}

步骤 5:保存用户信息并完成登录

// 保存 Token 和用户信息
localStorage.setItem('access_token', tokenData.access_token);
localStorage.setItem('refresh_token', tokenData.refresh_token);
localStorage.setItem('user', JSON.stringify(userInfo));

// 跳转到应用首页
window.location.href = '/dashboard';

创建菜单

菜单数据结构

{
  "path": "/dashboard",           // 必填:路由路径
  "name": "dashboard",            // 必填:菜单名称(唯一标识)
  "component": "/views/dashboard", // 必填:组件路径
  "parent_id": "",                // 可选:父菜单ID,空字符串表示顶级菜单
  "client_id": "your-client-id",  // 必填:应用ID(从应用详情页获取)
  "sort_order": 1,                // 可选:排序号,数字越小越靠前
  "meta": {                       // 必填:菜单元数据
    "key": "dashboard",           // 必填:菜单唯一标识(通常与name相同)
    "icon": "DashboardOutlined",  // 可选:图标名称
    "title": "仪表盘",             // 必填:菜单显示标题
    "isHide": false,              // 可选:是否隐藏,默认false
    "isFull": false,              // 可选:是否全屏显示,默认false
    "isAffix": false,             // 可选:是否固定在标签页,默认false
    "isKeepAlive": true           // 可选:是否缓存页面,默认true
  }
}

字段详细说明

1. path(路由路径)- 必填

菜单对应的路由路径,必须以 / 开头。

示例

  • /dashboard - 仪表盘
  • /users/list - 用户列表
  • /products/detail/:id - 产品详情(动态路由)

2. name(菜单名称)- 必填

菜单的唯一标识符,用于路由匹配。建议使用小写字母和下划线。

示例

  • dashboard
  • user_list
  • product_detail

3. component(组件路径)- 必填

前端组件的路径,相对于项目根目录。

示例

  • /views/dashboard/index.vue
  • /admin/users/index
  • @/views/products/detail.vue

4. parent_id(父菜单ID)- 可选

用于创建多级菜单。如果为空字符串,表示顶级菜单。

示例

  • "" - 顶级菜单
  • "menu-users" - 子菜单,父菜单ID为 menu-users

5. client_id(应用ID)- 必填

应用的唯一标识符,从应用详情页获取。

获取方式

  1. 进入应用管理页面
  2. 点击应用名称进入详情页
  3. 在URL中可以看到 client_id,例如:/admin/clients/your-client-id

6. sort_order(排序号)- 可选

菜单的显示顺序,数字越小越靠前。默认为 0。

7. meta(元数据)- 必填

菜单的元数据对象,包含显示相关的配置。

meta.key(菜单标识)- 必填

菜单的唯一标识,通常与 name 字段相同。

meta.icon(图标)- 可选

菜单图标名称,支持 Ant Design Icons。

常用图标示例

  • HomeOutlined - 首页
  • UserOutlined - 用户
  • AppstoreOutlined - 应用
  • SettingOutlined - 设置
  • DashboardOutlined - 仪表盘
  • FileOutlined - 文件
  • ShoppingOutlined - 购物
  • TeamOutlined - 团队

查看所有图标https://ant.design/components/icon

meta.title(显示标题)- 必填

菜单在界面上显示的中文标题。

示例

  • "仪表盘"
  • "用户管理"
  • "产品列表"
meta.isHide(是否隐藏)- 可选

是否在菜单中隐藏该菜单项。默认为 false

  • false - 显示在菜单中
  • true - 隐藏(但仍可通过路径访问)
meta.isFull(是否全屏)- 可选

是否全屏显示页面。默认为 false

meta.isAffix(是否固定)- 可选

是否固定在标签页,关闭后不会自动移除。默认为 false

meta.isKeepAlive(是否缓存)- 可选

是否缓存页面,切换标签页时保持页面状态。默认为 true

创建菜单的方式

方式一:通过管理后台创建(推荐)

  1. 进入应用详情页

    • 访问:http://your-sso-server:3007/admin/clients
    • 点击应用名称进入详情页
  2. 添加菜单

    • 在"菜单管理"部分,点击"添加菜单"按钮
    • 填写菜单信息:
      • 路径:例如 /dashboard
      • 名称:例如 dashboard
      • 组件:例如 /views/dashboard/index.vue
      • 标题:例如 仪表盘
      • 图标:例如 DashboardOutlined(可选)
      • 父菜单:选择父菜单(如果是子菜单)
      • 排序:例如 1
      • 其他选项:根据需要勾选
  3. 保存

    • 点击"保存"按钮
    • 菜单创建成功后会显示在列表中

方式二:通过 API 创建

API 端点
POST /api/admin/menus
Authorization: Bearer <session_token>
Content-Type: application/json
请求示例
const response = await fetch('http://your-sso-server:8080/api/admin/menus', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${sessionToken}`
  },
  body: JSON.stringify({
    path: '/dashboard',
    name: 'dashboard',
    component: '/views/dashboard/index.vue',
    parent_id: '',
    client_id: 'your-client-id',
    sort_order: 1,
    meta: {
      key: 'dashboard',
      icon: 'DashboardOutlined',
      title: '仪表盘',
      isHide: false,
      isFull: false,
      isAffix: false,
      isKeepAlive: true
    }
  })
});

const result = await response.json();
console.log(result);

多级菜单示例

父菜单:用户管理

{
  "path": "/users",
  "name": "users",
  "component": "/views/users/index.vue",
  "parent_id": "",
  "client_id": "your-client-id",
  "sort_order": 1,
  "meta": {
    "key": "users",
    "icon": "UserOutlined",
    "title": "用户管理",
    "isHide": false,
    "isFull": false,
    "isAffix": false,
    "isKeepAlive": true
  }
}

子菜单:用户列表

{
  "path": "/users/list",
  "name": "user_list",
  "component": "/views/users/list.vue",
  "parent_id": "menu-users-123",  // 父菜单的ID
  "client_id": "your-client-id",
  "sort_order": 1,
  "meta": {
    "key": "user_list",
    "icon": "UnorderedListOutlined",
    "title": "用户列表",
    "isHide": false,
    "isFull": false,
    "isAffix": false,
    "isKeepAlive": true
  }
}

注意:创建子菜单时,需要先创建父菜单,然后使用父菜单的 id 作为 parent_id

获取菜单列表

API 端点

GET /api/menus
Authorization: Bearer <access_token>

请求示例

const response = await fetch('http://your-sso-server:8080/api/menus', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
});

const result = await response.json();
console.log(result.data); // 菜单列表

响应示例

{
  "code": 0,
  "msg": "success",
  "data": [
    {
      "path": "/dashboard",
      "name": "dashboard",
      "component": "/views/dashboard/index.vue",
      "meta": {
        "key": "dashboard",
        "icon": "DashboardOutlined",
        "title": "仪表盘",
        "isHide": false,
        "isFull": false,
        "isAffix": false,
        "isKeepAlive": true
      },
      "children": []
    }
  ]
}

完整示例代码

JavaScript/前端示例

<!DOCTYPE html>
<html>
<head>
  <title>登录示例</title>
</head>
<body>
  <button id="loginBtn">登录</button>
  <div id="userInfo"></div>

  <script>
    const SSO_BASE_URL = 'http://your-sso-server:8080';
    const CLIENT_ID = 'YOUR_CLIENT_ID';
    const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
    const REDIRECT_URI = window.location.origin + '/callback.html';

    // 生成随机字符串
    function generateRandomString(length) {
      const array = new Uint8Array(length);
      crypto.getRandomValues(array);
      return btoa(String.fromCharCode.apply(null, array))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    }

    // 登录按钮点击事件
    document.getElementById('loginBtn').addEventListener('click', () => {
      const state = generateRandomString(32);
      sessionStorage.setItem('oauth_state', state);

      const authUrl = new URL(`${SSO_BASE_URL}/oauth/authorize`);
      authUrl.searchParams.set('client_id', CLIENT_ID);
      authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
      authUrl.searchParams.set('response_type', 'code');
      authUrl.searchParams.set('scope', 'openid profile email');
      authUrl.searchParams.set('state', state);

      window.location.href = authUrl.toString();
    });

    // 检查是否已登录
    const accessToken = localStorage.getItem('access_token');
    if (accessToken) {
      loadUserInfo(accessToken);
      loadMenus(accessToken);
    }

    async function loadUserInfo(token) {
      try {
        const response = await fetch(`${SSO_BASE_URL}/oauth/userinfo`, {
          headers: {
            'Authorization': `Bearer ${token}`,
          },
        });

        if (response.ok) {
          const user = await response.json();
          document.getElementById('userInfo').innerHTML = `
            <p>欢迎,${user.name || user.username}!</p>
            <p>邮箱:${user.email}</p>
            <button onclick="logout()">退出登录</button>
          `;
        }
      } catch (error) {
        console.error('Failed to load user info:', error);
      }
    }

    async function loadMenus(token) {
      try {
        const response = await fetch(`${SSO_BASE_URL}/api/menus`, {
          headers: {
            'Authorization': `Bearer ${token}`,
          },
        });

        if (response.ok) {
          const result = await response.json();
          console.log('Menus:', result.data);
          // 使用菜单数据渲染导航菜单
        }
      } catch (error) {
        console.error('Failed to load menus:', error);
      }
    }

    function logout() {
      localStorage.removeItem('access_token');
      localStorage.removeItem('refresh_token');
      localStorage.removeItem('user');
      window.location.reload();
    }
  </script>
</body>
</html>

回调页面 (callback.html)

<!DOCTYPE html>
<html>
<head>
  <title>处理登录回调</title>
</head>
<body>
  <p>正在处理登录...</p>

  <script>
    const SSO_BASE_URL = 'http://your-sso-server:8080';
    const CLIENT_ID = 'YOUR_CLIENT_ID';
    const CLIENT_SECRET = 'YOUR_CLIENT_SECRET';
    const REDIRECT_URI = window.location.origin + '/callback.html';

    (async () => {
      try {
        // 获取授权码
        const urlParams = new URLSearchParams(window.location.search);
        const code = urlParams.get('code');
        const state = urlParams.get('state');
        const error = urlParams.get('error');

        if (error) {
          alert('登录失败:' + error);
          window.location.href = '/';
          return;
        }

        // 验证 state
        const savedState = sessionStorage.getItem('oauth_state');
        if (state !== savedState) {
          throw new Error('Invalid state parameter');
        }

        // 用授权码换取 Token
        const tokenResponse = await fetch(`${SSO_BASE_URL}/oauth/token`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: new URLSearchParams({
            grant_type: 'authorization_code',
            code: code,
            client_id: CLIENT_ID,
            client_secret: CLIENT_SECRET,
            redirect_uri: REDIRECT_URI,
          }),
        });

        if (!tokenResponse.ok) {
          const error = await tokenResponse.json();
          throw new Error(error.error_description || 'Token exchange failed');
        }

        const tokenData = await tokenResponse.json();

        // 获取用户信息
        const userResponse = await fetch(`${SSO_BASE_URL}/oauth/userinfo`, {
          headers: {
            'Authorization': `Bearer ${tokenData.access_token}`,
          },
        });

        if (!userResponse.ok) {
          throw new Error('Get userinfo failed');
        }

        const userInfo = await userResponse.json();

        // 保存到本地存储
        localStorage.setItem('access_token', tokenData.access_token);
        localStorage.setItem('refresh_token', tokenData.refresh_token);
        localStorage.setItem('user', JSON.stringify(userInfo));

        // 清除 state
        sessionStorage.removeItem('oauth_state');

        // 跳转到应用首页
        window.location.href = '/';
      } catch (error) {
        console.error('Login error:', error);
        alert('登录失败:' + error.message);
        window.location.href = '/';
      }
    })();
  </script>
</body>
</html>

API 端点

授权端点

GET /oauth/authorize

查询参数

  • client_id (必需)
  • redirect_uri (必需)
  • response_type (固定: code)
  • scope (可选,默认: openid profile email)
  • state (推荐)

Token 端点

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

请求体

  • grant_type=authorization_code
  • code (授权码)
  • client_id
  • client_secret
  • redirect_uri

用户信息端点

GET /oauth/userinfo
Authorization: Bearer {access_token}

菜单端点

GET /api/menus
Authorization: Bearer {access_token}

返回当前用户有权限访问的菜单列表。


常见问题

登录相关问题

1. 重定向 URI 不匹配

错误redirect_uri_mismatch

解决:确保回调地址与注册时填写的完全一致(包括协议、域名、端口、路径)。

2. 用户没有权限

错误access_denied

解决:在管理后台为用户授权该应用的访问权限。

3. 授权码已使用或过期

错误invalid_grant

解决

  • 授权码只能使用一次
  • 授权码有效期通常为 10 分钟
  • 确保在获取授权码后立即使用

4. Client Secret 错误

错误invalid_client

解决:检查 Client ID 和 Client Secret 是否正确。

菜单相关问题

1. 菜单创建后不显示

可能原因

  • 用户没有该菜单的权限
  • isHide 设置为 true
  • 菜单未分配给角色

解决方法

  1. 检查菜单的 isHide 字段是否为 false
  2. 在"菜单权限管理"页面为角色分配菜单权限
  3. 确保用户拥有相应的角色

2. 子菜单不显示

可能原因

  • parent_id 填写错误
  • 父菜单不存在

解决方法

  1. 确认父菜单已创建
  2. 使用父菜单的 id(不是 name)作为 parent_id
  3. 检查父菜单的 client_id 是否与子菜单一致

3. 菜单顺序不对

解决方法

  • 调整 sort_order 字段,数字越小越靠前
  • 重新排序后刷新页面

4. 图标不显示

解决方法


安全建议

  1. 使用 HTTPS:生产环境必须使用 HTTPS
  2. 保护 Client Secret:不要在前端代码中暴露 Client Secret(后端应用除外)
  3. 验证 State 参数:防止 CSRF 攻击
  4. 安全存储 Token:使用安全的存储方式(如 HttpOnly Cookie)
  5. 及时刷新 Token:在 Access Token 过期前使用 Refresh Token 刷新

注意事项

登录相关

  • client_id 必须正确
  • redirect_uri 必须与注册时完全一致
  • state 参数用于防止 CSRF 攻击

菜单相关

  • client_id 必须正确:菜单必须关联到正确的应用ID
  • path 必须唯一:同一应用下的菜单路径不能重复
  • name 必须唯一:同一应用下的菜单名称不能重复
  • 父菜单先创建:创建子菜单前,必须先创建父菜单
  • 权限分配:创建菜单后,需要在"菜单权限管理"中为角色分配菜单权限

更多资源