一文带你了解Java单点登录(SSO)
目录
单点登录简介
什么是单点登录(SSO)
单点登录(Single Sign-On, SSO)是一种身份验证机制,允许用户使用一组凭据登录多个相关但独立的软件系统。用户只需要登录一次,就可以访问所有被授权的系统,无需重复输入用户名和密码。
SSO的核心概念
核心概念:
身份认证: 验证用户身份的过程
会话管理: 维护用户登录状态
票据机制: 用于验证用户身份的凭证
信任关系: 各系统间的互相信任机制
单点登出: 一次登出所有系统的机制
SSO的优势
优势:
- 用户体验: 一次登录,多处访问,提升用户体验
- 安全性: 集中管理用户身份,降低密码泄露风险
- 管理效率: 统一用户管理,减少维护成本
- 合规性: 满足企业安全策略和合规要求
- 扩展性: 易于集成新的应用系统
SSO流程简介图
基本SSO流程
基于Cookie的SSO流程
基于Token的SSO流程
单点登录的实现方式
1. 基于Cookie的SSO
实现原理
@Component
public class CookieBasedSSO {
private static final String SSO_COOKIE_NAME = "SSO_SESSION_ID";
private static final String SSO_DOMAIN = ".example.com";
public void setSSOCookie(HttpServletResponse response, String sessionId) {
Cookie ssoCookie = new Cookie(SSO_COOKIE_NAME, sessionId);
ssoCookie.setDomain(SSO_DOMAIN);
ssoCookie.setPath("/");
ssoCookie.setHttpOnly(true);
ssoCookie.setSecure(true); // HTTPS环境下使用
ssoCookie.setMaxAge(3600); // 1小时过期
response.addCookie(ssoCookie);
}
public String getSSOCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (SSO_COOKIE_NAME.equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
会话验证
@Service
public class CookieSSOService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public boolean validateSSOSession(String sessionId) {
String key = "sso:session:" + sessionId;
Object userInfo = redisTemplate.opsForValue().get(key);
return userInfo != null;
}
public UserInfo getUserInfo(String sessionId) {
String key = "sso:session:" + sessionId;
return (UserInfo) redisTemplate.opsForValue().get(key);
}
public void createSSOSession(String sessionId, UserInfo userInfo) {
String key = "sso:session:" + sessionId;
redisTemplate.opsForValue().set(key, userInfo, Duration.ofHours(1));
}
}
2. 基于Token的SSO
JWT Token生成
@Service
public class JWTSSOService {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpiration;
public String generateSSOToken(UserInfo userInfo) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpiration);
return Jwts.builder()
.setSubject(userInfo.getUsername())
.claim("userId", userInfo.getUserId())
.claim("roles", userInfo.getRoles())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
public Claims validateSSOToken(String token) {
try {
return Jwts.parser()
.setSigningKey(jwtSecret)
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new InvalidTokenException("Invalid JWT token");
}
}
public UserInfo extractUserInfo(String token) {
Claims claims = validateSSOToken(token);
UserInfo userInfo = new UserInfo();
userInfo.setUsername(claims.getSubject());
userInfo.setUserId(claims.get("userId", Long.class));
userInfo.setRoles(claims.get("roles", List.class));
return userInfo;
}
}
Token验证中间件
@Component
public class JWTSSOInterceptor implements HandlerInterceptor {
@Autowired
private JWTSSOService jwtSSOService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 跳过不需要验证的路径
if (isExcludedPath(request.getRequestURI())) {
return true;
}
String token = extractToken(request);
if (token == null) {
redirectToLogin(request, response);
return false;
}
try {
UserInfo userInfo = jwtSSOService.extractUserInfo(token);
request.setAttribute("userInfo", userInfo);
return true;
} catch (InvalidTokenException e) {
redirectToLogin(request, response);
return false;
}
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
// 从URL参数获取token
return request.getParameter("token");
}
private void redirectToLogin(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String loginUrl = "https://sso.example.com/login?redirect=" +
URLEncoder.encode(request.getRequestURL().toString(), "UTF-8");
response.sendRedirect(loginUrl);
}
private boolean isExcludedPath(String uri) {
return uri.startsWith("/public/") ||
uri.startsWith("/static/") ||
uri.equals("/health");
}
}
3. 基于CAS的SSO
CAS客户端配置
@Configuration
@EnableCas
public class CASConfig {
@Value("${cas.server.url}")
private String casServerUrl;
@Value("${cas.client.url}")
private String casClientUrl;
@Bean
public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
entryPoint.setLoginUrl(casServerUrl + "/login");
entryPoint.setServiceProperties(serviceProperties());
return entryPoint;
}
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService(casClientUrl + "/login/cas");
serviceProperties.setSendRenew(false);
return serviceProperties;
}
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider provider = new CasAuthenticationProvider();
provider.setServiceProperties(serviceProperties());
provider.setTicketValidator(cas20ServiceTicketValidator());
provider.setAuthenticationUserDetailsService(userDetailsService());
provider.setKey("casProviderKey");
return provider;
}
@Bean
public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
return new Cas20ServiceTicketValidator(casServerUrl);
}
}
4. 基于OAuth2的SSO
OAuth2配置
@Configuration
@EnableAuthorizationServer
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("web-app")
.secret("{noop}secret")
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("read", "write")
.redirectUris("http://localhost:8080/login/oauth2/code/")
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(86400);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService());
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) {
security.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}
}
重难点分析
1. 安全性问题
会话劫持防护
@Component
public class SessionSecurityService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void createSecureSession(String sessionId, UserInfo userInfo,
HttpServletRequest request) {
// 绑定IP地址
String clientIP = getClientIP(request);
String userAgent = request.getHeader("User-Agent");
SessionInfo sessionInfo = new SessionInfo();
sessionInfo.setUserInfo(userInfo);
sessionInfo.setClientIP(clientIP);
sessionInfo.setUserAgent(userAgent);
sessionInfo.setCreateTime(LocalDateTime.now());
String key = "sso:session:" + sessionId;
redisTemplate.opsForValue().set(key, sessionInfo, Duration.ofHours(1));
}
public boolean validateSessionSecurity(String sessionId, HttpServletRequest request) {
String key = "sso:session:" + sessionId;
SessionInfo sessionInfo = (SessionInfo) redisTemplate.opsForValue().get(key);
if (sessionInfo == null) {
return false;
}
// 检查IP地址是否变化
String currentIP = getClientIP(request);
if (!currentIP.equals(sessionInfo.getClientIP())) {
log.warn("IP地址变化,可能存在会话劫持: {}", sessionId);
return false;
}
// 检查User-Agent是否变化
String currentUserAgent = request.getHeader("User-Agent");
if (!currentUserAgent.equals(sessionInfo.getUserAgent())) {
log.warn("User-Agent变化,可能存在会话劫持: {}", sessionId);
return false;
}
return true;
}
private String getClientIP(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
防重放攻击
@Component
public class ReplayAttackProtection {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public boolean validateNonce(String nonce, long timestamp) {
// 检查时间戳是否在有效期内(5分钟)
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - timestamp) > 300000) {
return false;
}
// 检查nonce是否已被使用
String key = "nonce:" + nonce;
Boolean isUsed = redisTemplate.hasKey(key);
if (Boolean.TRUE.equals(isUsed)) {
return false;
}
// 标记nonce为已使用,设置过期时间
redisTemplate.opsForValue().set(key, "used", Duration.ofMinutes(5));
return true;
}
public String generateNonce() {
return UUID.randomUUID().toString();
}
}
2. 性能优化
分布式会话管理
@Configuration
public class RedisSessionConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置序列化器
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(mapper);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
@Bean
public RedisSessionRepository redisSessionRepository(RedisTemplate<String, Object> redisTemplate) {
return new RedisSessionRepository(redisTemplate);
}
}
会话缓存策略
@Service
public class SessionCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Cacheable(value = "session", key = "#sessionId")
public SessionInfo getSessionInfo(String sessionId) {
String key = "sso:session:" + sessionId;
return (SessionInfo) redisTemplate.opsForValue().get(key);
}
@CacheEvict(value = "session", key = "#sessionId")
public void invalidateSession(String sessionId) {
String key = "sso:session:" + sessionId;
redisTemplate.delete(key);
}
@CachePut(value = "session", key = "#sessionId")
public SessionInfo updateSession(String sessionId, SessionInfo sessionInfo) {
String key = "sso:session:" + sessionId;
redisTemplate.opsForValue().set(key, sessionInfo, Duration.ofHours(1));
return sessionInfo;
}
}
3. 单点登出问题
全局登出实现
@Service
public class GlobalLogoutService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationEventPublisher eventPublisher;
public void globalLogout(String sessionId) {
// 1. 获取用户信息
SessionInfo sessionInfo = getSessionInfo(sessionId);
if (sessionInfo == null) {
return;
}
// 2. 获取用户的所有活跃会话
String userKey = "user:sessions:" + sessionInfo.getUserInfo().getUserId();
Set<String> userSessions = redisTemplate.opsForSet().members(userKey);
// 3. 清除所有会话
if (userSessions != null) {
for (String session : userSessions) {
invalidateSession(session);
}
}
// 4. 清除用户会话集合
redisTemplate.delete(userKey);
// 5. 发布登出事件
eventPublisher.publishEvent(new UserLogoutEvent(sessionInfo.getUserInfo()));
log.info("用户 {} 全局登出成功", sessionInfo.getUserInfo().getUsername());
}
public void registerUserSession(String userId, String sessionId) {
String userKey = "user:sessions:" + userId;
redisTemplate.opsForSet().add(userKey, sessionId);
redisTemplate.expire(userKey, Duration.ofHours(1));
}
}
应用场景
1. 企业内部系统集成
@Service
public class EnterpriseSSOService {
@Autowired
private SSOClient ssoClient;
public void integrateWithEnterpriseSystems(String userId) {
// 集成ERP系统
integrateERP(userId);
// 集成CRM系统
integrateCRM(userId);
// 集成OA系统
integrateOA(userId);
// 集成财务系统
integrateFinance(userId);
}
private void integrateERP(String userId) {
// 调用ERP系统API,同步用户信息
ERPUserInfo erpUser = ssoClient.getERPUserInfo(userId);
if (erpUser != null) {
// 处理ERP用户信息
log.info("ERP系统集成成功: {}", userId);
}
}
private void integrateCRM(String userId) {
// 调用CRM系统API,同步用户信息
CRMUserInfo crmUser = ssoClient.getCRMUserInfo(userId);
if (crmUser != null) {
// 处理CRM用户信息
log.info("CRM系统集成成功: {}", userId);
}
}
}
2. 多租户SaaS应用
@Service
public class MultiTenantSSOService {
@Autowired
private TenantService tenantService;
public SSOConfig getTenantSSOConfig(String tenantId) {
Tenant tenant = tenantService.getTenant(tenantId);
if (tenant == null) {
throw new TenantNotFoundException("租户不存在: " + tenantId);
}
return SSOConfig.builder()
.ssoServerUrl(tenant.getSsoServerUrl())
.clientId(tenant.getClientId())
.clientSecret(tenant.getClientSecret())
.redirectUri(tenant.getRedirectUri())
.build();
}
public void handleTenantLogin(String tenantId, String username, String password) {
SSOConfig config = getTenantSSOConfig(tenantId);
// 根据租户配置进行SSO认证
SSOResult result = authenticateWithTenant(config, username, password);
if (result.isSuccess()) {
// 创建租户特定的会话
createTenantSession(tenantId, result.getUserInfo());
}
}
}
3. 移动应用SSO
@Service
public class MobileSSOService {
@Autowired
private JWTSSOService jwtSSOService;
public MobileSSOResponse mobileLogin(String username, String password, String deviceId) {
// 验证用户凭据
UserInfo userInfo = authenticateUser(username, password);
// 生成移动端专用Token
String mobileToken = generateMobileToken(userInfo, deviceId);
// 记录设备登录信息
recordDeviceLogin(userInfo.getUserId(), deviceId);
return MobileSSOResponse.builder()
.token(mobileToken)
.userInfo(userInfo)
.expiresIn(3600)
.build();
}
private String generateMobileToken(UserInfo userInfo, String deviceId) {
// 在JWT中添加设备信息
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userInfo.getUserId());
claims.put("deviceId", deviceId);
claims.put("tokenType", "mobile");
return Jwts.builder()
.setSubject(userInfo.getUsername())
.addClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 3600000))
.signWith(SignatureAlgorithm.HS512, jwtSecret)
.compact();
}
}
Spring Boot集成
1. 主配置类
@SpringBootApplication
@EnableCaching
@EnableAsync
public class SSOApplication {
public static void main(String[] args) {
SpringApplication.run(SSOApplication.class, args);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
mapper.setTimeZone(TimeZone.getDefault());
return mapper;
}
}
2. 安全配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JWTSSOService jwtSSOService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**", "/auth/**", "/health").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(new JWTSSOFilter(jwtSSOService),
UsernamePasswordAuthenticationFilter.class)
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
3. 控制器
@RestController
@RequestMapping("/api/sso")
public class SSOController {
@Autowired
private SSOService ssoService;
@Autowired
private JWTSSOService jwtSSOService;
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
try {
UserInfo userInfo = ssoService.authenticate(request.getUsername(),
request.getPassword());
String token = jwtSSOService.generateSSOToken(userInfo);
return ResponseEntity.ok(LoginResponse.builder()
.token(token)
.userInfo(userInfo)
.expiresIn(3600)
.build());
} catch (AuthenticationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(LoginResponse.builder()
.error("认证失败")
.build());
}
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader("Authorization") String token) {
if (token != null && token.startsWith("Bearer ")) {
String jwtToken = token.substring(7);
ssoService.logout(jwtToken);
}
return ResponseEntity.ok().build();
}
@GetMapping("/user")
public ResponseEntity<UserInfo> getCurrentUser(@RequestAttribute UserInfo userInfo) {
return ResponseEntity.ok(userInfo);
}
@PostMapping("/validate")
public ResponseEntity<ValidationResponse> validateToken(@RequestBody String token) {
try {
UserInfo userInfo = jwtSSOService.extractUserInfo(token);
return ResponseEntity.ok(ValidationResponse.builder()
.valid(true)
.userInfo(userInfo)
.build());
} catch (Exception e) {
return ResponseEntity.ok(ValidationResponse.builder()
.valid(false)
.error(e.getMessage())
.build());
}
}
}
4. 配置文件
# application.yml
spring:
redis:
host: localhost
port: 6379
database: 0
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
cache:
type: redis
redis:
time-to-live: 3600000
cache-null-values: false
jwt:
secret: your-secret-key-here
expiration: 3600000
sso:
server:
url: https://sso.example.com
login-path: /login
logout-path: /logout
validate-path: /validate
client:
id: your-client-id
secret: your-client-secret
redirect-uri: http://localhost:8080/callback
logging:
level:
com.example.sso: DEBUG
org.springframework.security: DEBUG
5. 异常处理
@ControllerAdvice
public class SSOExceptionHandler {
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ErrorResponse> handleInvalidToken(InvalidTokenException e) {
ErrorResponse error = ErrorResponse.builder()
.error("INVALID_TOKEN")
.message("Token无效或已过期")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthentication(AuthenticationException e) {
ErrorResponse error = ErrorResponse.builder()
.error("AUTHENTICATION_FAILED")
.message("用户名或密码错误")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception e) {
ErrorResponse error = ErrorResponse.builder()
.error("INTERNAL_ERROR")
.message("服务器内部错误")
.timestamp(LocalDateTime.now())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
最佳实践
1. 安全最佳实践
安全建议:
- 使用HTTPS传输所有SSO相关数据
- 实现Token过期和刷新机制
- 记录所有SSO操作的审计日志
- 实现防暴力破解机制
- 定期轮换密钥和证书
- 实现多因素认证(MFA)
- 监控异常登录行为
2. 性能最佳实践
性能建议:
- 使用Redis等缓存存储会话信息
- 实现Token黑名单机制
- 合理设置Token过期时间
- 使用异步处理非关键操作
- 实现负载均衡和集群部署
- 监控SSO系统性能指标
3. 监控和日志
@Component
public class SSOMonitoringService {
@Autowired
private MeterRegistry meterRegistry;
public void recordLoginAttempt(String username, boolean success) {
if (success) {
meterRegistry.counter("sso.login.success").increment();
} else {
meterRegistry.counter("sso.login.failure").increment();
}
}
public void recordTokenValidation(String token, boolean valid) {
if (valid) {
meterRegistry.counter("sso.token.valid").increment();
} else {
meterRegistry.counter("sso.token.invalid").increment();
}
}
@EventListener
public void handleUserLoginEvent(UserLoginEvent event) {
log.info("用户登录事件: username={}, timestamp={}, ip={}",
event.getUsername(), event.getTimestamp(), event.getClientIP());
}
@EventListener
public void handleUserLogoutEvent(UserLogoutEvent event) {
log.info("用户登出事件: username={}, timestamp={}",
event.getUsername(), event.getTimestamp());
}
}
总结
Java单点登录(SSO)是企业级应用中的重要技术,通过本文章的学习,您应该能够:
- 理解SSO的核心概念和优势
- 掌握不同SSO实现方式的原理和代码
- 分析SSO系统的重难点和解决方案
- 了解SSO在各种应用场景中的使用
- 在Spring Boot中正确集成SSO功能
- 遵循最佳实践,构建安全可靠的SSO系统
SSO系统的成功实施需要综合考虑安全性、性能、可维护性等多个方面,建议在实际项目中根据具体需求选择合适的实现方案。
6820

被折叠的 条评论
为什么被折叠?



