单点登录(SSO)全面解析与Java实现
1. 单点登录概述
1.1 什么是单点登录
单点登录(Single Sign-On,SSO)是一种身份认证方案,允许用户使用一组凭证(如用户名和密码)登录多个相互信任的应用系统。用户只需在认证中心进行一次登录,即可访问所有授权的应用,无需重复输入凭证。
1.2 SSO的优势
- 用户体验提升:减少密码记忆负担,提高操作效率
- 安全性增强:聚焦管理认证,降低密码泄露风险
- 管理成本降低:统一用户管理,简化权限控制
- 系统集成便捷:便于微服务架构下的应用整合
1.3 SSO的应用场景
- 企业内部系统集成
- 跨域应用统一认证
- 第三方应用授权登录
- 微服务架构下的统一认证
2. 单点登录的核心概念
2.1 主要参与方
2.2 关键术语
- 认证中心(Authentication Center):负责用户身份验证的核心系统
- 服务提供方(Service Provider, SP):需要认证的业务应用
- 令牌(Token):认证凭证的载体
- 会话(Session):用户登录状态的保持机制
3. 单点登录的实现流程
3.1 基本流程时序图
3.2 详细步骤解析
步骤1:用户访问业务应用
用户尝试访问受保护的业务应用资源。
步骤2:检查本地会话
业务应用检查用户是否存在有效的本地会话。
步骤3:重定向到认证中心
如果无有效会话,业务应用将用户重定向到认证中心。
步骤4:认证中心检查全局会话
认证中心检查用户是否存在有效的全局会话。
步骤5:用户认证
如果无全局会话,要求用户进行身份认证。
步骤6:创建全局会话
认证成功后在认证中心创建全局会话。
步骤7:返回业务应用
认证中心生成令牌并重定向回业务应用。
步骤8:验证令牌
业务应用向认证中心验证令牌有效性。
步骤9:创建本地会话
验证成功后创建本地会话。
步骤10:返回请求资源
业务应用向用户返回请求的资源。
4. 基于Token的SSO实现方案
4.1 系统架构设计
java
// 系统架构核心组件
public class SSOArchitecture {
/**
* 认证中心组件
*/
public class AuthCenter {
private UserService userService;
private TokenService tokenService;
private SessionManager sessionManager;
}
/**
* 业务应用组件
*/
public class BusinessApp {
private AuthClient authClient;
private LocalSessionManager sessionManager;
}
/**
* 通用工具组件
*/
public class CommonUtils {
private CryptoUtil cryptoUtil;
private HttpUtil httpUtil;
private JsonUtil jsonUtil;
}
}
4.2 核心数据结构设计
java
/**
* SSO令牌实体类
*/
public class SSOToken implements Serializable {
private static final long serialVersionUID = 1L;
// 令牌ID
private String tokenId;
// 用户ID
private String userId;
// 用户名
private String username;
// 颁发时间
private Long issueTime;
// 过期时间
private Long expireTime;
// 客户端IP
private String clientIp;
// 签名
private String signature;
// 自定义数据
private Map<String, Object> claims;
// 构造方法、getter、setter等
public SSOToken() {}
public SSOToken(String tokenId, String userId, String username,
Long expireTime, String clientIp) {
this.tokenId = tokenId;
this.userId = userId;
this.username = username;
this.issueTime = System.currentTimeMillis();
this.expireTime = expireTime;
this.clientIp = clientIp;
this.claims = new HashMap<>();
}
// 省略getter和setter方法
}
/**
* 用户信息实体
*/
public class UserInfo implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String username;
private String email;
private String phone;
private List<String> roles;
private Map<String, Object> attributes;
// 构造方法、getter、setter
}
/**
* 认证响应结果
*/
public class AuthResult {
private boolean success;
private String message;
private SSOToken token;
private UserInfo userInfo;
private String redirectUrl;
// 构造方法
public AuthResult(boolean success, String message) {
this.success = success;
this.message = message;
}
// 省略其他构造方法和getter/setter
}
5. Java实现单点登录系统
5.1 认证中心实现
5.1.1 认证控制器
java
/**
* 认证中心控制器
*/
@RestController
@RequestMapping("/sso/auth")
public class AuthCenterController {
private static final Logger logger = LoggerFactory.getLogger(AuthCenterController.class);
@Autowired
private AuthService authService;
@Autowired
private TokenService tokenService;
/**
* 单点登录入口
*/
@GetMapping("/login")
public ResponseEntity<?> login(
@RequestParam String service,
@RequestParam(required = false) String redirectUrl,
HttpServletRequest request) {
try {
// 检查是否已登录
String globalToken = getGlobalToken(request);
if (globalToken != null && tokenService.validateToken(globalToken)) {
// 已登录,直接生成服务令牌并重定向
String serviceToken = tokenService.generateServiceToken(globalToken, service);
String targetUrl = buildRedirectUrl(redirectUrl, serviceToken);
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", targetUrl)
.build();
}
// 未登录,返回登录页面
LoginPageVO pageVO = new LoginPageVO();
pageVO.setService(service);
pageVO.setRedirectUrl(redirectUrl);
pageVO.setLoginUrl("/sso/auth/doLogin");
return ResponseEntity.ok(pageVO);
} catch (Exception e) {
logger.error("登录入口处理失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(AuthResult.error("系统错误"));
}
}
/**
* 执行登录认证
*/
@PostMapping("/doLogin")
public ResponseEntity<AuthResult> doLogin(
@RequestBody LoginRequest loginRequest,
HttpServletRequest request) {
try {
// 验证登录凭证
AuthResult authResult = authService.authenticate(
loginRequest.getUsername(),
loginRequest.getPassword(),
getClientIp(request));
if (!authResult.isSuccess()) {
return ResponseEntity.badRequest().body(authResult);
}
// 创建全局会话
SSOToken globalToken = tokenService.createGlobalToken(authResult.getUserInfo());
authResult.setToken(globalToken);
// 生成服务令牌
String serviceToken = tokenService.generateServiceToken(
globalToken.getTokenId(),
loginRequest.getService());
// 构建重定向URL
String redirectUrl = buildRedirectUrl(loginRequest.getRedirectUrl(), serviceToken);
authResult.setRedirectUrl(redirectUrl);
// 设置全局会话Cookie
setGlobalTokenCookie(globalToken.getTokenId(), request);
return ResponseEntity.ok(authResult);
} catch (Exception e) {
logger.error("登录认证失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(AuthResult.error("认证服务异常"));
}
}
/**
* 验证服务令牌
*/
@PostMapping("/validate")
public ResponseEntity<AuthResult> validateToken(@RequestBody TokenValidateRequest request) {
try {
AuthResult result = tokenService.validateServiceToken(
request.getToken(),
request.getService());
return ResponseEntity.ok(result);
} catch (Exception e) {
logger.error("令牌验证失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(AuthResult.error("令牌验证异常"));
}
}
/**
* 退出登录
*/
@PostMapping("/logout")
public ResponseEntity<AuthResult> logout(
@RequestParam String token,
@RequestParam(required = false) String service) {
try {
AuthResult result = tokenService.invalidateToken(token, service);
return ResponseEntity.ok(result);
} catch (Exception e) {
logger.error("退出登录失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(AuthResult.error("退出登录异常"));
}
}
// 辅助方法
private String getGlobalToken(HttpServletRequest request) {
// 从Cookie或Header中获取全局令牌
return CookieUtil.getCookieValue(request, "SSO_GLOBAL_TOKEN");
}
private void setGlobalTokenCookie(String token, HttpServletRequest request) {
// 设置全局令牌Cookie
// 实际实现中需要思考安全性和域设置
}
private String getClientIp(HttpServletRequest request) {
// 获取客户端IP
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
private String buildRedirectUrl(String baseUrl, String token) {
if (baseUrl.contains("?")) {
return baseUrl + "&sso_token=" + token;
} else {
return baseUrl + "?sso_token=" + token;
}
}
}
5.1.2 认证服务实现
java
/**
* 认证服务实现
*/
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public AuthResult authenticate(String username, String password, String clientIp) {
// 检查登录尝试次数
if (loginAttemptService.isBlocked(username, clientIp)) {
return AuthResult.error("登录失败次数过多,请稍后重试");
}
try {
// 验证用户凭证
UserInfo userInfo = userService.findByUsername(username);
if (userInfo == null) {
loginAttemptService.recordFailure(username, clientIp);
return AuthResult.error("用户名或密码错误");
}
if (!passwordEncoder.matches(password, userInfo.getPassword())) {
loginAttemptService.recordFailure(username, clientIp);
return AuthResult.error("用户名或密码错误");
}
if (!userInfo.isEnabled()) {
return AuthResult.error("账户已被禁用");
}
// 清除失败记录
loginAttemptService.clearFailure(username, clientIp);
return AuthResult.success("登录成功", userInfo);
} catch (Exception e) {
loginAttemptService.recordFailure(username, clientIp);
throw new AuthException("认证过程发生错误", e);
}
}
@Override
public void changePassword(String username, String oldPassword, String newPassword) {
// 密码修改逻辑
UserInfo userInfo = userService.findByUsername(username);
if (!passwordEncoder.matches(oldPassword, userInfo.getPassword())) {
throw new AuthException("原密码错误");
}
userService.updatePassword(username, passwordEncoder.encode(newPassword));
}
}
5.1.3 令牌服务实现
java
/**
* 令牌服务实现
*/
@Service
public class TokenServiceImpl implements TokenService {
@Autowired
private TokenStorage tokenStorage;
@Autowired
private CryptoUtil cryptoUtil;
@Value("${sso.token.global-timeout:1800}")
private long globalTokenTimeout; // 全局令牌超时时间(秒)
@Value("${sso.token.service-timeout:300}")
private long serviceTokenTimeout; // 服务令牌超时时间(秒)
@Override
public SSOToken createGlobalToken(UserInfo userInfo) {
String tokenId = generateTokenId();
long expireTime = System.currentTimeMillis() + globalTokenTimeout * 1000;
SSOToken token = new SSOToken(tokenId, userInfo.getUserId(),
userInfo.getUsername(), expireTime, userInfo.getClientIp());
// 设置用户信息
token.getClaims().put("userInfo", userInfo);
// 生成签名
String signature = generateSignature(token);
token.setSignature(signature);
// 存储令牌
tokenStorage.storeGlobalToken(tokenId, token, globalTokenTimeout);
return token;
}
@Override
public String generateServiceToken(String globalTokenId, String service) {
SSOToken globalToken = tokenStorage.getGlobalToken(globalTokenId);
if (globalToken == null) {
throw new TokenException("全局令牌不存在或已过期");
}
// 验证全局令牌有效性
if (!validateToken(globalToken)) {
throw new TokenException("全局令牌无效");
}
String serviceTokenId = generateTokenId();
long expireTime = System.currentTimeMillis() + serviceTokenTimeout * 1000;
SSOToken serviceToken = new SSOToken(serviceTokenId, globalToken.getUserId(),
globalToken.getUsername(), expireTime, globalToken.getClientIp());
// 设置关联的全局令牌和服务标识
serviceToken.getClaims().put("globalTokenId", globalTokenId);
serviceToken.getClaims().put("service", service);
serviceToken.getClaims().put("userInfo", globalToken.getClaims().get("userInfo"));
// 生成签名
String signature = generateSignature(serviceToken);
serviceToken.setSignature(signature);
// 存储服务令牌
tokenStorage.storeServiceToken(serviceTokenId, serviceToken, serviceTokenTimeout);
return serviceTokenId;
}
@Override
public AuthResult validateServiceToken(String tokenId, String service) {
SSOToken token = tokenStorage.getServiceToken(tokenId);
if (token == null) {
return AuthResult.error("服务令牌不存在或已过期");
}
// 验证令牌签名
if (!validateSignature(token)) {
return AuthResult.error("令牌签名无效");
}
// 验证令牌是否过期
if (System.currentTimeMillis() > token.getExpireTime()) {
return AuthResult.error("服务令牌已过期");
}
// 验证服务标识
String tokenService = (String) token.getClaims().get("service");
if (!service.equals(tokenService)) {
return AuthResult.error("服务标识不匹配");
}
// 验证关联的全局令牌
String globalTokenId = (String) token.getClaims().get("globalTokenId");
SSOToken globalToken = tokenStorage.getGlobalToken(globalTokenId);
if (globalToken == null || !validateToken(globalToken)) {
return AuthResult.error("关联的全局令牌无效");
}
UserInfo userInfo = (UserInfo) token.getClaims().get("userInfo");
return AuthResult.success("令牌验证成功", userInfo);
}
@Override
public boolean validateToken(SSOToken token) {
if (token == null) return false;
// 验证签名
if (!validateSignature(token)) return false;
// 验证过期时间
if (System.currentTimeMillis() > token.getExpireTime()) return false;
return true;
}
@Override
public AuthResult invalidateToken(String tokenId, String service) {
if (service != null) {
// 注销服务令牌
tokenStorage.removeServiceToken(tokenId);
} else {
// 注销全局令牌及所有关联的服务令牌
tokenStorage.removeGlobalToken(tokenId);
}
return AuthResult.success("注销成功");
}
// 辅助方法
private String generateTokenId() {
return UUID.randomUUID().toString().replace("-", "");
}
private String generateSignature(SSOToken token) {
String data = token.getTokenId() + token.getUserId() + token.getIssueTime();
return cryptoUtil.hmacSha256(data, getSecretKey());
}
private boolean validateSignature(SSOToken token) {
String expectedSignature = generateSignature(token);
return expectedSignature.equals(token.getSignature());
}
private String getSecretKey() {
// 从配置或密钥管理服务获取
return "your-secret-key"; // 实际应用中应该使用安全的密钥管理
}
}
5.2 业务应用集成
5.2.1 SSO客户端过滤器
java
/**
* SSO客户端过滤器
*/
@Component
public class SSOClientFilter implements Filter {
@Autowired
private SSOClient ssoClient;
@Value("${sso.auth-center-url}")
private String authCenterUrl;
@Value("${sso.client.service-id}")
private String serviceId;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 检查是否已登录
if (isAuthenticated(httpRequest)) {
chain.doFilter(request, response);
return;
}
// 检查是否有SSO令牌
String ssoToken = getSSOToken(httpRequest);
if (ssoToken != null) {
// 验证令牌
AuthResult authResult = ssoClient.validateToken(ssoToken, serviceId);
if (authResult.isSuccess()) {
// 创建本地会话
createLocalSession(httpRequest, authResult.getUserInfo());
chain.doFilter(request, response);
return;
}
}
// 重定向到认证中心
redirectToAuthCenter(httpRequest, httpResponse);
}
private boolean isAuthenticated(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute("userInfo") != null;
}
private String getSSOToken(HttpServletRequest request) {
// 从请求参数中获取令牌
String token = request.getParameter("sso_token");
if (token != null) return token;
// 从Header中获取
token = request.getHeader("X-SSO-Token");
if (token != null) return token;
return null;
}
private void createLocalSession(HttpServletRequest request, UserInfo userInfo) {
HttpSession session = request.getSession();
session.setAttribute("userInfo", userInfo);
session.setAttribute("loginTime", System.currentTimeMillis());
// 设置会话超时时间
session.setMaxInactiveInterval(30 * 60); // 30分钟
}
private void redirectToAuthCenter(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String currentUrl = getCurrentUrl(request);
String loginUrl = authCenterUrl + "/login?service=" + serviceId +
"&redirectUrl=" + URLEncoder.encode(currentUrl, "UTF-8");
response.sendRedirect(loginUrl);
}
private String getCurrentUrl(HttpServletRequest request) {
StringBuffer url = request.getRequestURL();
String queryString = request.getQueryString();
if (queryString != null) {
url.append("?").append(queryString);
}
return url.toString();
}
}
5.2.2 SSO客户端实现
java
/**
* SSO客户端服务
*/
@Service
public class SSOClientImpl implements SSOClient {
@Autowired
private RestTemplate restTemplate;
@Value("${sso.auth-center-url}")
private String authCenterUrl;
@Override
public AuthResult validateToken(String token, String service) {
try {
TokenValidateRequest request = new TokenValidateRequest(token, service);
ResponseEntity<AuthResult> response = restTemplate.postForEntity(
authCenterUrl + "/validate",
request,
AuthResult.class);
if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
} else {
return AuthResult.error("令牌验证请求失败");
}
} catch (Exception e) {
// 记录日志
return AuthResult.error("令牌验证服务不可用");
}
}
@Override
public void logout(String token) {
try {
restTemplate.postForEntity(
authCenterUrl + "/logout?token=" + token,
null,
AuthResult.class);
} catch (Exception e) {
// 记录日志,但不需要抛出异常
}
}
@Override
public String getLoginUrl(String redirectUrl) {
return authCenterUrl + "/login?service=" + getServiceId() +
"&redirectUrl=" + URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
}
private String getServiceId() {
// 从配置获取服务标识
return "your-service-id";
}
}
6. 安全思考与最佳实践
6.1 安全性设计
6.1.1 令牌安全
java
/**
* 增强的令牌安全服务
*/
@Service
public class EnhancedTokenService extends TokenServiceImpl {
@Autowired
private BlacklistService blacklistService;
@Autowired
private AuditService auditService;
@Override
public AuthResult validateServiceToken(String tokenId, String service) {
// 检查黑名单
if (blacklistService.isTokenBlacklisted(tokenId)) {
auditService.recordSecurityEvent("BLACKLISTED_TOKEN_ACCESS",
tokenId, service, getClientIp());
return AuthResult.error("令牌已被列入黑名单");
}
AuthResult result = super.validateServiceToken(tokenId, service);
if (!result.isSuccess()) {
// 记录验证失败事件
auditService.recordSecurityEvent("TOKEN_VALIDATION_FAILED",
tokenId, service, getClientIp());
// 多次失败后加入黑名单
if (shouldBlacklistToken(tokenId)) {
blacklistService.blacklistToken(tokenId, 3600); // 黑名单1小时
}
}
return result;
}
@Override
public String generateServiceToken(String globalTokenId, String service) {
// 添加设备指纹验证
String deviceFingerprint = generateDeviceFingerprint();
SSOToken token = super.generateServiceToken(globalTokenId, service);
token.getClaims().put("deviceFingerprint", deviceFingerprint);
return token;
}
private String generateDeviceFingerprint() {
// 基于用户代理、IP等信息生成设备指纹
return "device-fingerprint"; // 简化实现
}
}
6.1.2 通信安全
java
/**
* HTTPS安全配置
*/
@Configuration
public class SecurityConfig {
@Bean
public RestTemplate restTemplate() throws Exception {
// 配置SSL安全的RestTemplate
SSLContext sslContext = SSLContextBuilder
.create()
.loadTrustMaterial((chain, authType) -> true) // 实际生产环境需要严格验证
.build();
HttpClient client = HttpClients.custom()
.setSSLContext(sslContext)
.build();
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory(client);
return new RestTemplate(factory);
}
}
6.2 性能优化
6.2.1 令牌存储优化
java
/**
* 基于Redis的令牌存储
*/
@Service
public class RedisTokenStorage implements TokenStorage {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String GLOBAL_TOKEN_PREFIX = "sso:global:";
private static final String SERVICE_TOKEN_PREFIX = "sso:service:";
private static final String USER_SESSION_PREFIX = "sso:user:";
@Override
public void storeGlobalToken(String tokenId, SSOToken token, long timeout) {
String key = GLOBAL_TOKEN_PREFIX + tokenId;
redisTemplate.opsForValue().set(key, token, timeout, TimeUnit.SECONDS);
// 存储用户与令牌的映射
String userKey = USER_SESSION_PREFIX + token.getUserId();
redisTemplate.opsForSet().add(userKey, tokenId);
redisTemplate.expire(userKey, timeout, TimeUnit.SECONDS);
}
@Override
public SSOToken getGlobalToken(String tokenId) {
String key = GLOBAL_TOKEN_PREFIX + tokenId;
return (SSOToken) redisTemplate.opsForValue().get(key);
}
}
7. 部署与配置
7.1 配置文件示例
yaml
# application.yml
sso:
auth-center-url: https://auth.example.com
token:
global-timeout: 1800
service-timeout: 300
secret-key: ${SSO_SECRET_KEY:default-secret-key}
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD:}
security:
require-https: true
allowed-origins:
- https://app1.example.com
- https://app2.example.com
7.2 数据库设计
sql
-- 用户表
CREATE TABLE sso_users (
user_id VARCHAR(64) PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(100) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
enabled BOOLEAN DEFAULT TRUE,
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 令牌黑名单表
CREATE TABLE sso_blacklist (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
token_id VARCHAR(64) NOT NULL,
blacklist_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expire_time TIMESTAMP NOT NULL,
reason VARCHAR(200)
);
-- 审计日志表
CREATE TABLE sso_audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id VARCHAR(64),
token_id VARCHAR(64),
service_id VARCHAR(50),
client_ip VARCHAR(45),
event_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
details TEXT
);
8. 测试与验证
8.1 单元测试示例
java
/**
* 认证服务测试
*/
@SpringBootTest
class AuthServiceTest {
@Autowired
private AuthService authService;
@Test
void testAuthenticateSuccess() {
AuthResult result = authService.authenticate("testuser", "password", "127.0.0.1");
assertTrue(result.isSuccess());
assertEquals("登录成功", result.getMessage());
}
@Test
void testAuthenticateFailure() {
AuthResult result = authService.authenticate("wronguser", "wrongpass", "127.0.0.1");
assertFalse(result.isSuccess());
assertEquals("用户名或密码错误", result.getMessage());
}
}
9. 总结
本文详细介绍了单点登录的概念、原理和实现流程,并提供了完整的Java实现方案。通过认证中心、令牌服务、客户端集成等核心组件的实现,构建了一个功能完备的SSO系统。
关键要点总结:
- 架构设计:采用中心化的认证架构,确保认证逻辑的统一性
- 令牌机制:使用双重令牌(全局令牌+服务令牌)保证安全性
- 安全思考:包含签名验证、黑名单、审计日志等安全措施
- 性能优化:通过Redis缓存和连接池等技术提升系统性能
- 可扩展性:设计良好的接口和配置,便于系统扩展
单点登录是现代分布式系统的重大组成部分,正确的实现可以显著提升用户体验和系统安全性。本文提供的实现方案可以作为实际项目的基础,根据具体需求进行定制和扩展。
© 版权声明
文章版权归作者所有,未经允许请勿转载。如内容涉嫌侵权,请在本页底部进入<联系我们>进行举报投诉!
THE END
暂无评论内容