⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 风象南 「风象南」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

单点登录(Single Sign-On,SSO)是企业应用系统中常见的用户认证方案,它允许用户使用一组凭证访问多个相关但独立的系统,无需重复登录。

对于拥有多个应用的企业来说,SSO可以显著提升用户体验并降低凭证管理成本。

一、基于Cookie-Session的传统SSO方案

原理

这是最基础的SSO实现方式,其核心是将用户认证状态存储在服务端Session中,并通过Cookie在客户端保存Session标识符。

当用户登录SSO服务器后,服务器创建Session存储用户信息,并将SessionID通过Cookie设置在顶级域名下,使所有子域应用都能访问该Cookie并验证同一个Session。

实现方案

1. 创建SSO服务端

@RestController
@RequestMapping("/sso")
publicclassSsoServerController {

@Autowired
private UserService userService;

@PostMapping("/login")
public ResponseEntity<String> login(
@RequestParam String username,
@RequestParam String password,
HttpServletRequest request,
HttpServletResponse response,
@RequestParam(required = false) String redirect) {

// 验证用户凭证
Useruser= userService.authenticate(username, password);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}

// 创建session并存储用户信息
HttpSessionsession= request.getSession(true);
session.setAttribute("USER_INFO", user);
session.setMaxInactiveInterval(3600); // Session有效期1小时

// 获取SessionID
StringsessionId= session.getId();

// 设置Cookie
Cookiecookie=newCookie("SSO_SESSION_ID", sessionId);
cookie.setMaxAge(3600); // 1小时有效期
cookie.setPath("/");
cookie.setDomain(".example.com"); // 关键:顶级域名,使所有子域都能访问
cookie.setHttpOnly(true);
cookie.setSecure(true); // 仅通过HTTPS传输

response.addCookie(cookie);

// 如果有重定向URL,则重定向回原应用
if (redirect != null && !redirect.isEmpty()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", redirect)
.build();
}

return ResponseEntity.ok("Login successful");
}

@GetMapping("/validate")
public ResponseEntity<UserInfo> validateSession(
@CookieValue(name = "SSO_SESSION_ID", required = false) String sessionId) {

if (sessionId == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

// 验证Session有效性并获取用户信息
HttpSessionsession= sessionRegistry.getSession(sessionId);
if (session == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

// 从Session获取用户信息
Useruser= (User) session.getAttribute("USER_INFO");
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

// 返回用户信息
UserInfouserInfo=newUserInfo(user.getId(), user.getUsername(), user.getRoles());
return ResponseEntity.ok(userInfo);
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(
@CookieValue(name = "SSO_SESSION_ID", required = false) String sessionId,
HttpServletResponse response,
@RequestParam(required = false) String redirect) {

// 使Session失效
if (sessionId != null) {
HttpSessionsession= sessionRegistry.getSession(sessionId);
if (session != null) {
session.invalidate();
}
}

// 删除Cookie
Cookiecookie=newCookie("SSO_SESSION_ID", null);
cookie.setMaxAge(0);
cookie.setPath("/");
cookie.setDomain(".example.com");
response.addCookie(cookie);

// 如果有重定向URL,则重定向
if (redirect != null && !redirect.isEmpty()) {
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", redirect)
.build();
}

return ResponseEntity.ok().build();
}
}

2.客户端应用集成

@Component
publicclassSsoFilterimplementsFilter {

@Autowired
private RestTemplate restTemplate;

privatestaticfinalStringSSO_SERVER_URL="https://sso.example.com";
privatestaticfinalStringSSO_VALIDATION_URL= SSO_SERVER_URL + "/sso/validate";

@Override
publicvoiddoFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletRequesthttpRequest= (HttpServletRequest) request;
HttpServletResponsehttpResponse= (HttpServletResponse) response;

// 检查当前应用是否已有本地Session
HttpSessioncurrentSession= httpRequest.getSession(false);
if (currentSession != null && currentSession.getAttribute("USER_INFO") != null) {
// 已有本地Session,继续请求
chain.doFilter(request, response);
return;
}

// 获取SSO Session Cookie
Cookie[] cookies = httpRequest.getCookies();
StringssoSessionId=null;

if (cookies != null) {
for (Cookie cookie : cookies) {
if ("SSO_SESSION_ID".equals(cookie.getName())) {
ssoSessionId = cookie.getValue();
break;
}
}
}

// 未找到SSO Cookie,重定向到SSO登录页
if (ssoSessionId == null) {
StringredirectUrl= SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
return;
}

// 验证SSO Session有效性
try {
HttpHeadersheaders=newHttpHeaders();
headers.add("Cookie", "SSO_SESSION_ID=" + ssoSessionId);
HttpEntity<String> entity = newHttpEntity<>(headers);

ResponseEntity<UserInfo> responseEntity = restTemplate.exchange(
SSO_VALIDATION_URL, HttpMethod.GET, entity, UserInfo.class);

if (responseEntity.getStatusCode() == HttpStatus.OK) {
// SSO Session有效,创建本地Session
UserInfouserInfo= responseEntity.getBody();
HttpSessionsession= httpRequest.getSession(true);
session.setAttribute("USER_INFO", userInfo);

chain.doFilter(request, response);
} else {
// SSO Session无效,重定向到登录页
StringredirectUrl= SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
} catch (Exception e) {
// 验证过程出错,重定向到登录页
StringredirectUrl= SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
}
}

优缺点

优点:

  • 实现相对简单,遵循传统Web开发模式
  • 服务端完全控制会话状态和生命周期
  • 客户端无需存储和管理复杂状态
  • 支持即时会话失效和撤销

缺点:

  • 受同源策略限制,仅适用于同一顶级域名下的应用
  • 依赖Cookie机制,在某些环境可能受限(如移动应用)
  • 存在CSRF风险

二、基于JWT的无状态SSO方案

原理

JWT(JSON Web Token)是一种紧凑的、自包含的令牌格式,可以在不同应用间安全地传递信息。

使用JWT实现SSO时,认证服务器在用户登录后生成JWT令牌,其中包含用户相关信息和签名。

由于JWT可以独立验证而无需查询中央服务器,它非常适合构建无状态的SSO系统。

实现方案

1.创建JWT认证服务

首先添加依赖:

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

实现JWT工具类:

@Component
publicclassJwtUtil {

@Value("${jwt.secret}")
private String secretKey;

@Value("${jwt.expiration}")
privatelong expirationTime;

@PostConstruct
protectedvoidinit() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}

public String generateToken(User user) {
Map<String, Object> claims = newHashMap<>();
claims.put("id", user.getId());
claims.put("username", user.getUsername());
claims.put("email", user.getEmail());
claims.put("roles", user.getRoles());

return createToken(claims, user.getUsername());
}

private String createToken(Map<String, Object> claims, String subject) {
Datenow=newDate();
DateexpiryDate=newDate(now.getTime() + expirationTime);

return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS512)
.compact();
}

publicbooleanvalidateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token);
returntrue;
} catch (Exception e) {
returnfalse;
}
}

public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
}
}

实现认证控制器:

@RestController
@RequestMapping("/auth")
publicclassAuthController {

@Autowired
private UserService userService;

@Autowired
private JwtUtil jwtUtil;

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
// 验证用户凭证
Useruser= userService.authenticate(
loginRequest.getUsername(),
loginRequest.getPassword()
);

if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(newErrorResponse("Invalid credentials"));
}

// 生成JWT
Stringjwt= jwtUtil.generateToken(user);

// 设置刷新令牌(可选)
StringrefreshToken= UUID.randomUUID().toString();
userService.saveRefreshToken(user.getId(), refreshToken, 30); // 30天有效期

// 返回Token
return ResponseEntity.ok(newJwtResponse(jwt, refreshToken));
}

@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest request) {
StringrefreshToken= request.getRefreshToken();

// 验证刷新令牌
Useruser= userService.findUserByRefreshToken(refreshToken);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(newErrorResponse("Invalid refresh token"));
}

// 生成新的JWT
StringnewToken= jwtUtil.generateToken(user);

return ResponseEntity.ok(newJwtResponse(newToken, refreshToken));
}

@PostMapping("/validate")
public ResponseEntity<?> validateToken(@RequestBody TokenValidationRequest request) {
Stringtoken= request.getToken();

if (!jwtUtil.validateToken(token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(newErrorResponse("Invalid token"));
}

// 提取用户信息
Claimsclaims= jwtUtil.extractAllClaims(token);
Map<String, Object> userInfo = newHashMap<>(claims);

return ResponseEntity.ok(userInfo);
}

@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody LogoutRequest request) {
// 删除刷新令牌
userService.removeRefreshToken(request.getRefreshToken());

// JWT本身无法撤销,客户端需要丢弃令牌
return ResponseEntity.ok(newSuccessResponse("Logout successful"));
}
}

2.客户端应用集成

JWT过滤器:

@Component
publicclassJwtRequestFilterextendsOncePerRequestFilter {

@Autowired
private JwtUtil jwtUtil;

@Override
protectedvoiddoFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {

finalStringauthorizationHeader= request.getHeader("Authorization");

Stringusername=null;
Stringjwt=null;

// 从Authorization头提取JWT
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
// 验证令牌并提取用户信息
if (jwtUtil.validateToken(jwt)) {
Claimsclaims= jwtUtil.extractAllClaims(jwt);
username = claims.getSubject();

// 构建认证对象
List<String> roles = (List<String>) claims.get("roles");
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());

UserDetailsuserDetails=newUser(username, "", authorities);

UsernamePasswordAuthenticationTokenauthentication=
newUsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());

authentication.setDetails(newWebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
// JWT验证失败,不设置认证信息
}
}

chain.doFilter(request, response);
}
}

安全配置:

@Configuration
@EnableWebSecurity
publicclassSecurityConfigextendsWebSecurityConfigurerAdapter {

@Autowired
private JwtRequestFilter jwtRequestFilter;

@Override
protectedvoidconfigure(HttpSecurity http)throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/public/**", "/auth/login", "/auth/refresh").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);

http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
}

@Bean
public PasswordEncoder passwordEncoder() {
returnnewBCryptPasswordEncoder();
}

@Override
@Bean
public AuthenticationManager authenticationManagerBean()throws Exception {
returnsuper.authenticationManagerBean();
}
}

3.前端处理JWT

// 在登录成功后存储JWT令牌
function handleLoginSuccess(response) {
const { token, refreshToken } = response.data;
localStorage.setItem('jwtToken', token);
localStorage.setItem('refreshToken', refreshToken);

// 设置默认Authorization头
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;

// 重定向到应用页面
window.location.href = '/dashboard';
}

// 添加请求拦截器自动附加令牌
axios.interceptors.request.use(config => {
consttoken= localStorage.getItem('jwtToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

// 添加响应拦截器处理令牌过期
axios.interceptors.response.use(
response => response,
asyncerror=> {
constoriginalRequest= error.config;

// 如果是401错误且不是刷新令牌请求,尝试刷新令牌
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

try {
constrefreshToken= localStorage.getItem('refreshToken');
constresponse= await axios.post('/auth/refresh', { refreshToken });

const { token } = response.data;
localStorage.setItem('jwtToken', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;

return axios(originalRequest);
} catch (err) {
// 刷新失败,重定向到登录页
localStorage.removeItem('jwtToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
}
}

return Promise.reject(error);
}
);

优缺点

优点:

  • 完全无状态,服务器不需要存储会话信息
  • 跨域支持,适用于分布式系统和微服务
  • 可扩展性好,JWT可包含丰富的用户信息
  • 不依赖Cookie,避免CSRF问题
  • 适用于各种客户端(Web、移动应用、API)

缺点:

  • 无法主动失效已颁发的令牌(除非使用黑名单机制)
  • JWT可能较大,增加网络传输负担
  • 令牌管理需要客户端介入
  • 刷新令牌机制较复杂
  • 存在令牌被盗用的风险

三、基于OAuth 2.0/OpenID Connect的SSO方案

原理

OAuth 2.0是一个授权框架,而OpenID Connect(OIDC)是建立在OAuth 2.0之上的身份认证层。

这是目前最标准化和完善的SSO解决方案,特别适合企业级应用和需要第三方集成的场景。它提供了丰富的授权流程选项和安全特性。

实现方案

在SpringBoot中,可以使用Spring Security OAuth2来实现OAuth 2.0/OIDC服务器和客户端。

1.搭建授权服务器

首先添加依赖:

<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.6.8</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

配置授权服务器:

@Configuration
@EnableAuthorizationServer
publicclassAuthorizationServerConfigextendsAuthorizationServerConfigurerAdapter {

@Autowired
private AuthenticationManager authenticationManager;

@Autowired
private PasswordEncoder passwordEncoder;

@Autowired
private UserDetailsService userDetailsService;

@Autowired
private DataSource dataSource;

@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverterconverter=newJwtAccessTokenConverter();
converter.setSigningKey("your-signing-key");
return converter;
}

@Bean
public TokenStore tokenStore() {
returnnewJwtTokenStore(accessTokenConverter());
}

@Bean
public TokenEnhancer tokenEnhancer() {
returnnewCustomTokenEnhancer();
}

@Override
publicvoidconfigure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChaintokenEnhancerChain=newTokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(
Arrays.asList(tokenEnhancer(), accessTokenConverter()));

endpoints
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.reuseRefreshTokens(false);
}

@Override
publicvoidconfigure(ClientDetailsServiceConfigurer clients)throws Exception {
clients.jdbc(dataSource)
.withClient("web-client")
.secret(passwordEncoder.encode("web-client-secret"))
.authorizedGrantTypes("password", "refresh_token", "authorization_code")
.scopes("read", "write")
.redirectUris("http://app1.example.com/login/oauth2/code/custom",
"http://app2.example.com/login/oauth2/code/custom")
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(86400)
.and()
.withClient("mobile-client")
.secret(passwordEncoder.encode("mobile-client-secret"))
.authorizedGrantTypes("password", "refresh_token")
.scopes("read")
.accessTokenValiditySeconds(7200)
.refreshTokenValiditySeconds(259200);
}

@Override
publicvoidconfigure(AuthorizationServerSecurityConfigurer security) {
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
}

// 自定义令牌增强器,添加额外的用户信息
publicclassCustomTokenEnhancerimplementsTokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetailsuserDetails= (UserDetails) authentication.getPrincipal();
Map<String, Object> additionalInfo = newHashMap<>();

// 添加额外的用户信息
if (userDetails instanceof CustomUserDetails) {
CustomUserDetailscustomUserDetails= (CustomUserDetails) userDetails;
additionalInfo.put("userId", customUserDetails.getId());
additionalInfo.put("email", customUserDetails.getEmail());
additionalInfo.put("fullName", customUserDetails.getFullName());
}

((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
}

return accessToken;
}
}

配置资源服务器:

@Configuration
@EnableResourceServer
publicclassResourceServerConfigextendsResourceServerConfigurerAdapter {

@Override
publicvoidconfigure(HttpSecurity http)throws Exception {
http.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/user/**").hasRole("USER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
}
}

2.客户端应用集成

客户端配置(application.yml):

spring:
security:
oauth2:
client:
registration:
custom:
client-id:web-client
client-secret:web-client-secret
authorization-grant-type:authorization_code
redirect-uri:"{baseUrl}/login/oauth2/code/{registrationId}"
scope:read,write
provider:
custom:
authorization-uri:http://auth-server.example.com/oauth/authorize
token-uri:http://auth-server.example.com/oauth/token
user-info-uri:http://auth-server.example.com/userinfo
jwk-set-uri:http://auth-server.example.com/.well-known/jwks.json
user-name-attribute: sub

客户端安全配置:

@EnableWebSecurity
publicclassSecurityConfigextendsWebSecurityConfigurerAdapter {

@Override
protectedvoidconfigure(HttpSecurity http)throws Exception {
http
.csrf().disable()
.authorizeRequests(authorizeRequests ->
authorizeRequests
.antMatchers("/", "/login/**", "/error", "/webjars/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2Login ->
oauth2Login
.loginPage("/login")
.defaultSuccessUrl("/home")
.userInfoEndpoint()
.userService(oAuth2UserService())
)
.logout(logout ->
logout
.logoutSuccessUrl("http://auth-server.example.com/logout?client_id=web-client")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
);
}

@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService() {
DefaultOAuth2UserServicedelegate=newDefaultOAuth2UserService();

return userRequest -> {
OAuth2UseroAuth2User= delegate.loadUser(userRequest);

// 自定义用户信息处理
Map<String, Object> attributes = oAuth2User.getAttributes();
StringuserNameAttributeName= userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

returnnewDefaultOAuth2User(
oAuth2User.getAuthorities(),
attributes,
userNameAttributeName);
};
}
}

3.完整的登出流程

@Controller
publicclassAuthController {

@GetMapping("/logout")
public String logout(HttpServletRequest request,
HttpServletResponse response,
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {

// 清除本地会话
SecurityContextHolder.clearContext();
HttpSessionsession= request.getSession(false);
if (session != null) {
session.invalidate();
}

// 清除Cookies
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}

// 重定向到OAuth2服务器的注销端点
StringlogoutUrl="http://auth-server.example.com/logout?client_id=" +
authorizedClient.getClientRegistration().getClientId() +
"&post_logout_redirect_uri=" +
URLEncoder.encode("http://app.example.com/", StandardCharsets.UTF_8);

return"redirect:" + logoutUrl;
}
}

优缺点

优点:

  • 成熟的安全协议,广泛采用的行业标准
  • 支持多种认证流程(授权码、隐式、密码等)
  • 令牌撤销机制完善
  • 可扩展性极好,适合企业级应用
  • 明确分离认证与授权职责

缺点:

  • 实现复杂度高,小型应用可能不合适
  • 配置和理解学习曲线陡峭

四、基于Spring Session的共享会话SSO方案

原理

Spring Session提供了一个将会话数据存储在共享外部存储(如Redis)中的框架,使得不同的应用之间能够共享会话信息。

这种方式特别适合基于Spring的同构系统,可以在保持简单实现的同时解决分布式会话共享问题。

实现方案

1.添加依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

2.配置Spring Session

@Configuration
@EnableRedisHttpSession
publicclassSessionConfig {

@Bean
public LettuceConnectionFactory connectionFactory() {
// Redis连接配置
RedisStandaloneConfigurationredisConfig=newRedisStandaloneConfiguration();
redisConfig.setHostName("redis-host");
redisConfig.setPort(6379);

returnnewLettuceConnectionFactory(redisConfig);
}

@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializerserializer=newDefaultCookieSerializer();
serializer.setCookieName("SESSION"); // 使用统一的Cookie名
serializer.setCookiePath("/");
serializer.setDomainNamePattern("^.+?\.(\w+\.[a-z]+)$"); // 支持子域
serializer.setUseSecureCookie(true);
serializer.setUseHttpOnlyCookie(true);

return serializer;
}

@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
returnnewCookieHttpSessionIdResolver();
}
}

3.创建中央认证服务

@Controller
@RequestMapping("/auth")
publicclassCentralAuthController {

@Autowired
private UserService userService;

@GetMapping("/login")
public String loginPage(@RequestParam(required = false) String redirect, Model model) {
model.addAttribute("redirectUrl", redirect);
return"login";
}

@PostMapping("/login")
public String login(@RequestParam String username,
@RequestParam String password,
@RequestParam(required = false) String redirect,
HttpSession session,
RedirectAttributes redirectAttrs) {

// 验证用户凭证
Useruser= userService.authenticate(username, password);
if (user == null) {
redirectAttrs.addFlashAttribute("error", "Invalid credentials");
return"redirect:/auth/login";
}

// 将用户信息存入共享会话
UserInfouserInfo=newUserInfo(user.getId(), user.getUsername(), user.getRoles());
session.setAttribute("USER_INFO", userInfo);

// 如果有重定向URL,则重定向回原应用
if (redirect != null && !redirect.isEmpty()) {
return"redirect:" + redirect;
}

return"redirect:/dashboard";
}

@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpSession session) {
// 使会话失效
session.invalidate();

// 可选:获取要重定向的URL
Stringreferer= request.getHeader("Referer");

return"redirect:/auth/login";
}
}

4.创建应用内认证过滤器

@Component
publicclassSessionAuthenticationFilterimplementsFilter {

privatestaticfinalStringLOGIN_PAGE="http://auth.example.com/auth/login";

@Override
publicvoiddoFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletRequesthttpRequest= (HttpServletRequest) request;
HttpServletResponsehttpResponse= (HttpServletResponse) response;

// 公开路径不需要认证
Stringpath= httpRequest.getRequestURI();
if (isPublicPath(path)) {
chain.doFilter(request, response);
return;
}

// 检查session中是否有用户信息
HttpSessionsession= httpRequest.getSession(false);
booleanauthenticated= session != null && session.getAttribute("USER_INFO") != null;

if (authenticated) {
// 用户已认证,继续请求
chain.doFilter(request, response);
} else {
// 未认证,重定向到登录页面
StringredirectUrl= LOGIN_PAGE + "?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
}

privatebooleanisPublicPath(String path) {
return path.startsWith("/public/") ||
path.startsWith("/resources/") ||
path.equals("/error");
}
}

5.配置应用内WebMVC

@Configuration
publicclassWebConfigimplementsWebMvcConfigurer {

@Autowired
private SessionAuthenticationFilter sessionAuthenticationFilter;

@Bean
public FilterRegistrationBean<SessionAuthenticationFilter> sessionFilterRegistration() {
FilterRegistrationBean<SessionAuthenticationFilter> registration = newFilterRegistrationBean<>();
registration.setFilter(sessionAuthenticationFilter);
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); // 在Spring Security之后执行
return registration;
}
}

6.使用会话信息

@Controller
@RequestMapping("/dashboard")
publicclassDashboardController {

@GetMapping
public String dashboard(HttpSession session, Model model) {
UserInfouserInfo= (UserInfo) session.getAttribute("USER_INFO");

model.addAttribute("user", userInfo);

return"dashboard";
}
}

优缺点

优点:

  • 实现简单,易于理解
  • 与Spring生态无缝集成
  • 会话可包含丰富的信息

缺点:

  • 依赖中央存储(如Redis)
  • 会话数据需要序列化/反序列化
  • 依赖Cookie,不适合非Web应用

五、方案选择与最佳实践

选择建议

方案类型 推荐场景 不适合场景
Cookie-Session 同域名下的小型应用,简单的认证需求 跨域应用,移动应用集成,高安全性需求
JWT 分布式微服务,前后端分离应用 需要即时撤销令牌的场景,极高安全性要求
OAuth 2.0/OIDC 企业级应用,需要第三方集成,多租户系统 小型应用,资源受限环境,急速开发需求
Spring Session Spring技术栈的应用,中型企业应用 异构技术栈,非Web应用集成

六、总结

从简单的基于Cookie-Session的方案,到复杂的OAuth 2.0/OIDC实现,SSO方案的选择应该基于业务需求、安全要求、用户体验目标和技术约束进行综合考量。

文章目录
  1. 1. 一、基于Cookie-Session的传统SSO方案
    1. 1.0.1. 原理
    2. 1.0.2. 实现方案
    3. 1.0.3. 优缺点
  • 2. 二、基于JWT的无状态SSO方案
    1. 2.0.1. 原理
    2. 2.0.2. 实现方案
    3. 2.0.3. 优缺点
  • 3. 三、基于OAuth 2.0/OpenID Connect的SSO方案
    1. 3.0.1. 原理
    2. 3.0.2. 实现方案
    3. 3.0.3. 优缺点
  • 4. 四、基于Spring Session的共享会话SSO方案
    1. 4.0.1. 原理
    2. 4.0.2. 实现方案
    3. 4.0.3. 优缺点
  • 5. 五、方案选择与最佳实践
    1. 5.0.1. 选择建议
  • 6. 六、总结