🪁 sso业务系统登录集成指南
如何接入登录系统和创建菜单
本文档说明如何将您的应用接入 SSO 单点登录系统,以及如何为应用创建菜单。
目录
快速开始
1. 创建应用并获取凭证
-
登录管理后台
- 访问:
http://your-sso-server:3007/admin - 使用管理员账号登录
- 访问:
-
创建应用
- 进入"应用管理" → "创建应用"
- 填写信息:
- 应用名称:您的应用名称
- 回调地址 (Redirect URI):例如
http://your-app.com/auth/callback - 权限范围 (Scopes):默认
openid profile email
- 保存后获得:
- Client ID:应用标识符
- Client Secret:应用密钥(请妥善保管)
-
为用户授权
- 进入"用户管理"
- 为用户分配该应用的访问权限
登录流程
流程图
用户访问应用
↓
应用检测未登录
↓
重定向到 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(菜单名称)- 必填
菜单的唯一标识符,用于路由匹配。建议使用小写字母和下划线。
示例:
dashboarduser_listproduct_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)- 必填
应用的唯一标识符,从应用详情页获取。
获取方式:
- 进入应用管理页面
- 点击应用名称进入详情页
- 在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。
创建菜单的方式
方式一:通过管理后台创建(推荐)
-
进入应用详情页
- 访问:
http://your-sso-server:3007/admin/clients - 点击应用名称进入详情页
- 访问:
-
添加菜单
- 在"菜单管理"部分,点击"添加菜单"按钮
- 填写菜单信息:
- 路径:例如
/dashboard - 名称:例如
dashboard - 组件:例如
/views/dashboard/index.vue - 标题:例如
仪表盘 - 图标:例如
DashboardOutlined(可选) - 父菜单:选择父菜单(如果是子菜单)
- 排序:例如
1 - 其他选项:根据需要勾选
- 路径:例如
-
保存
- 点击"保存"按钮
- 菜单创建成功后会显示在列表中
方式二:通过 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_codecode(授权码)client_idclient_secretredirect_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- 菜单未分配给角色
解决方法:
- 检查菜单的
isHide字段是否为false - 在"菜单权限管理"页面为角色分配菜单权限
- 确保用户拥有相应的角色
2. 子菜单不显示
可能原因:
parent_id填写错误- 父菜单不存在
解决方法:
- 确认父菜单已创建
- 使用父菜单的
id(不是name)作为parent_id - 检查父菜单的
client_id是否与子菜单一致
3. 菜单顺序不对
解决方法:
- 调整
sort_order字段,数字越小越靠前 - 重新排序后刷新页面
4. 图标不显示
解决方法:
- 确认图标名称正确(区分大小写)
- 使用 Ant Design Icons 支持的图标名称
- 参考:https://ant.design/components/icon
安全建议
- 使用 HTTPS:生产环境必须使用 HTTPS
- 保护 Client Secret:不要在前端代码中暴露 Client Secret(后端应用除外)
- 验证 State 参数:防止 CSRF 攻击
- 安全存储 Token:使用安全的存储方式(如 HttpOnly Cookie)
- 及时刷新 Token:在 Access Token 过期前使用 Refresh Token 刷新
注意事项
登录相关
client_id必须正确redirect_uri必须与注册时完全一致state参数用于防止 CSRF 攻击
菜单相关
client_id必须正确:菜单必须关联到正确的应用IDpath必须唯一:同一应用下的菜单路径不能重复name必须唯一:同一应用下的菜单名称不能重复- 父菜单先创建:创建子菜单前,必须先创建父菜单
- 权限分配:创建菜单后,需要在"菜单权限管理"中为角色分配菜单权限