实现思路
- 用户访问受保护资源时,若未认证则重定向到认证中心
- 认证中心验证用户身份,生成JWT令牌并存储到Redis
- 认证中心重定向回原应用并携带令牌
- 应用验证JWT有效性并从Redis获取会话信息
- 用户在其他应用访问时,通过相同机制实现单点登录
代码实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO 单点登录演示</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.system-card {
transition: all 0.3s ease;
cursor: pointer;
}
.system-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.logged-in {
border-left: 4px solid #198754;
}
.not-logged-in {
border-left: 4px solid #dc3545;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
}
.online {
background-color: #198754;
}
.offline {
background-color: #6c757d;
}
</style>
</head>
<body>
<div class="container my-5">
<div class="row mb-4">
<div class="col text-center">
<h1 class="display-4">单点登录(SSO)演示系统</h1>
<p class="lead">基于 Spring Session + Redis + JWT 实现</p>
<div class="mt-4" id="user-info" style="display: none;">
<p>当前用户: <span id="username" class="fw-bold">未登录</span></p>
<button id="logout-btn" class="btn btn-danger btn-sm">退出登录</button>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">登录认证中心</h5>
</div>
<div class="card-body">
<div id="login-form">
<div class="mb-3">
<label for="inputUsername" class="form-label">用户名</label>
<input type="text" class="form-control" id="inputUsername" value="admin">
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">密码</label>
<input type="password" class="form-control" id="inputPassword" value="password">
</div>
<button id="login-btn" class="btn btn-primary">登录</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<h3 class="mb-4">应用系统</h3>
<div class="col-md-4 mb-4">
<div class="card system-card not-logged-in">
<div class="card-body">
<h5 class="card-title">人力资源系统</h5>
<p class="card-text">访问员工信息、薪资数据等</p>
<div class="d-flex justify-content-between align-items-center">
<span><span class="status-indicator online"></span>运行中</span>
<span class="badge bg-secondary" id="hr-status">未登录</span>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card system-card not-logged-in">
<div class="card-body">
<h5 class="card-title">客户关系管理</h5>
<p class="card-text">管理客户信息和销售渠道</p>
<div class="d-flex justify-content-between align-items-center">
<span><span class="status-indicator online"></span>运行中</span>
<span class="badge bg-secondary" id="crm-status">未登录</span>
</div>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card system-card not-logged-in">
<div class="card-body">
<h5 class="card-title">财务系统</h5>
<p class="card-text">处理财务数据和报表</p>
<div class="d-flex justify-content-between align-items-center">
<span><span class="status-indicator online"></span>运行中</span>
<span class="badge bg-secondary" id="finance-status">未登录</span>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">SSO 流程说明</h5>
</div>
<div class="card-body">
<ol>
<li>用户在任意系统访问受保护资源</li>
<li>系统检查本地会话,若无有效会话则重定向到认证中心</li>
<li>认证中心检查全局会话,若已登录则直接颁发令牌</li>
<li>若未登录,显示登录页面,用户提交凭证</li>
<li>认证中心验证凭证,创建全局会话,生成JWT令牌</li>
<li>认证中心重定向回原系统并携带令牌</li>
<li>原系统验证JWT有效性,创建本地会话</li>
<li>用户访问其他系统时,重复上述流程实现单点登录</li>
</ol>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const userInfoDiv = document.getElementById('user-info');
const systems = [
{ id: 'hr-status', name: '人力资源系统' },
{ id: 'crm-status', name: '客户关系管理' },
{ id: 'finance-status', name: '财务系统' }
];
// 模拟登录功能
loginBtn.addEventListener('click', function() {
const username = document.getElementById('inputUsername').value;
const password = document.getElementById('inputPassword').value;
// 模拟认证过程
if (username && password) {
// 显示用户信息
document.getElementById('username').textContent = username;
userInfoDiv.style.display = 'block';
// 更新系统状态
systems.forEach(system => {
const element = document.getElementById(system.id);
element.className = 'badge bg-success';
element.textContent = '已登录';
// 更新卡片样式
const card = element.closest('.system-card');
card.classList.remove('not-logged-in');
card.classList.add('logged-in');
});
// 模拟JWT令牌生成和存储
const mockJwt = generateMockJWT(username);
localStorage.setItem('sso_token', mockJwt);
alert('登录成功!已生成JWT令牌并存储在Redis中。');
} else {
alert('请输入用户名和密码');
}
});
// 模拟退出功能
logoutBtn.addEventListener('click', function() {
// 清除用户信息
userInfoDiv.style.display = 'none';
// 更新系统状态
systems.forEach(system => {
const element = document.getElementById(system.id);
element.className = 'badge bg-secondary';
element.textContent = '未登录';
// 更新卡片样式
const card = element.closest('.system-card');
card.classList.remove('logged-in');
card.classList.add('not-logged-in');
});
// 清除本地存储的令牌
localStorage.removeItem('sso_token');
alert('已退出登录,全局会话已清除。');
});
// 模拟点击系统卡片
document.querySelectorAll('.system-card').forEach(card => {
card.addEventListener('click', function() {
const statusBadge = this.querySelector('.badge');
if (statusBadge.textContent === '未登录') {
// 检查是否有全局会话
const token = localStorage.getItem('sso_token');
if (token) {
// 有全局会话,直接登录该系统
statusBadge.className = 'badge bg-success';
statusBadge.textContent = '已登录';
this.classList.remove('not-logged-in');
this.classList.add('logged-in');
alert('单点登录成功!使用Redis中的会话信息自动登录。');
} else {
// 无全局会话,跳转到认证中心
alert('未登录,跳转到认证中心...');
// 实际场景中这里会重定向到认证中心
}
} else {
alert('访问 ' + this.querySelector('.card-title').textContent);
}
});
});
// 模拟生成JWT令牌的函数
function generateMockJWT(username) {
// 实际JWT包含header.payload.signature三部分
// 这里仅做演示,生成一个模拟的令牌
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = btoa(JSON.stringify({
sub: username,
iat: Date.now(),
exp: Date.now() + 3600000 // 1小时后过期
}));
const signature = 'mock-signature'; // 实际场景中会用密钥生成签名
return `${header}.${payload}.${signature}`;
}
});
</script>
</body>
</html>
技术实现说明
1. 核心组件
- Spring Session: 用于统一会话管理,将HTTP会话存储到Redis中
- Redis: 作为集中式会话存储,支持多个应用共享会话数据
- JWT: 作为身份验证令牌,在应用间安全传递用户身份信息
2. 关键流程
- 用户访问应用系统:系统检查本地会话是否存在
- 未认证重定向:若无有效会话,重定向到认证中心并携带回调地址
- 认证中心检查:认证中心检查是否存在全局会话
- 用户登录:若无全局会话,用户提交凭证进行认证
- 创建会话:认证成功后,创建全局会话并存储到Redis
- 颁发令牌:生成JWT并重定向回原应用
- 验证令牌:原应用验证JWT有效性,创建本地会话
- 单点访问:用户访问其他应用时,重复上述流程实现单点登录
3. 后端实现要点(伪代码)
// 认证中心控制器
@Controller
public class AuthController {
@PostMapping("/login")
public String login(String username, String password, String redirectUrl,
HttpSession session, Model model) {
// 验证用户凭证
User user = userService.authenticate(username, password);
if (user != null) {
// 创建全局会话
session.setAttribute("user", user);
// 生成JWT令牌
String token = JwtUtil.generateToken(user);
// 存储令牌与会话的关联到Redis
redisTemplate.opsForValue().set("sso:" + token, session.getId());
// 重定向回原系统
return "redirect:" + redirectUrl + "?token=" + token;
}
return "login";
}
}
// 应用系统拦截器
public class SsoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession(false);
// 检查本地会话
if (session != null && session.getAttribute("user") != null) {
return true;
}
// 检查请求中的JWT令牌
String token = request.getParameter("token");
if (token != null && JwtUtil.validateToken(token)) {
// 从Redis获取会话ID
String sessionId = redisTemplate.opsForValue().get("sso:" + token);
if (sessionId != null) {
// 创建本地会话
HttpSession newSession = request.getSession();
newSession.setAttribute("user", getUserFromToken(token));
return true;
}
}
// 重定向到认证中心
response.sendRedirect(authCenterUrl + "?redirectUrl=" + currentUrl);
return false;
}
}
扩展建议
- 安全性增强:使用HTTPS传输,添加CSRF保护,设置合理的JWT过期时间
- 性能优化:使用Redis集群提高会话存储性能,添加本地会话缓存
- 用户体验:实现无缝跳转,提供统一的登录/登出界面
- 监控管理:添加会话监控和管理功能,支持强制下线
这个演示展示了SSO的基本流程和实现方式,实际项目中需要根据具体需求进行调整和完善。