SpringBoot實(shí)現(xiàn)單點(diǎn)登錄(SSO)的四種方案
引言
單點(diǎn)登錄(Single Sign-On,SSO)是企業(yè)應(yīng)用系統(tǒng)中常見(jiàn)的用戶(hù)認(rèn)證方案,它允許用戶(hù)使用一組憑證訪問(wèn)多個(gè)相關(guān)但獨(dú)立的系統(tǒng),無(wú)需重復(fù)登錄。對(duì)于擁有多個(gè)應(yīng)用的企業(yè)來(lái)說(shuō),SSO可以顯著提升用戶(hù)體驗(yàn)并降低憑證管理成本。
一、基于Cookie-Session的傳統(tǒng)SSO方案
原理
這是最基礎(chǔ)的SSO實(shí)現(xiàn)方式,其核心是將用戶(hù)認(rèn)證狀態(tài)存儲(chǔ)在服務(wù)端Session中,并通過(guò)Cookie在客戶(hù)端保存Session標(biāo)識(shí)符。當(dāng)用戶(hù)登錄SSO服務(wù)器后,服務(wù)器創(chuàng)建Session存儲(chǔ)用戶(hù)信息,并將SessionID通過(guò)Cookie設(shè)置在頂級(jí)域名下,使所有子域應(yīng)用都能訪問(wèn)該Cookie并驗(yàn)證同一個(gè)Session。
實(shí)現(xiàn)方案
- 創(chuàng)建SSO服務(wù)端
@RestController
@RequestMapping("/sso")
public class SsoServerController {
@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) {
// 驗(yàn)證用戶(hù)憑證
User user = userService.authenticate(username, password);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}
// 創(chuàng)建session并存儲(chǔ)用戶(hù)信息
HttpSession session = request.getSession(true);
session.setAttribute("USER_INFO", user);
session.setMaxInactiveInterval(3600); // Session有效期1小時(shí)
// 獲取SessionID
String sessionId = session.getId();
// 設(shè)置Cookie
Cookie cookie = new Cookie("SSO_SESSION_ID", sessionId);
cookie.setMaxAge(3600); // 1小時(shí)有效期
cookie.setPath("/");
cookie.setDomain(".example.com"); // 關(guān)鍵:頂級(jí)域名,使所有子域都能訪問(wèn)
cookie.setHttpOnly(true);
cookie.setSecure(true); // 僅通過(guò)HTTPS傳輸
response.addCookie(cookie);
// 如果有重定向URL,則重定向回原應(yīng)用
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();
}
// 驗(yàn)證Session有效性并獲取用戶(hù)信息
HttpSession session = sessionRegistry.getSession(sessionId);
if (session == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// 從Session獲取用戶(hù)信息
User user = (User) session.getAttribute("USER_INFO");
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// 返回用戶(hù)信息
UserInfo userInfo = new UserInfo(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) {
HttpSession session = sessionRegistry.getSession(sessionId);
if (session != null) {
session.invalidate();
}
}
// 刪除Cookie
Cookie cookie = new Cookie("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();
}
}
- 客戶(hù)端應(yīng)用集成
@Component
public class SsoFilter implements Filter {
@Autowired
private RestTemplate restTemplate;
private static final String SSO_SERVER_URL = "https://sso.example.com";
private static final String SSO_VALIDATION_URL = SSO_SERVER_URL + "/sso/validate";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 檢查當(dāng)前應(yīng)用是否已有本地Session
HttpSession currentSession = httpRequest.getSession(false);
if (currentSession != null && currentSession.getAttribute("USER_INFO") != null) {
// 已有本地Session,繼續(xù)請(qǐng)求
chain.doFilter(request, response);
return;
}
// 獲取SSO Session Cookie
Cookie[] cookies = httpRequest.getCookies();
String ssoSessionId = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("SSO_SESSION_ID".equals(cookie.getName())) {
ssoSessionId = cookie.getValue();
break;
}
}
}
// 未找到SSO Cookie,重定向到SSO登錄頁(yè)
if (ssoSessionId == null) {
String redirectUrl = SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
return;
}
// 驗(yàn)證SSO Session有效性
try {
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", "SSO_SESSION_ID=" + ssoSessionId);
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<UserInfo> responseEntity = restTemplate.exchange(
SSO_VALIDATION_URL, HttpMethod.GET, entity, UserInfo.class);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
// SSO Session有效,創(chuàng)建本地Session
UserInfo userInfo = responseEntity.getBody();
HttpSession session = httpRequest.getSession(true);
session.setAttribute("USER_INFO", userInfo);
chain.doFilter(request, response);
} else {
// SSO Session無(wú)效,重定向到登錄頁(yè)
String redirectUrl = SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
} catch (Exception e) {
// 驗(yàn)證過(guò)程出錯(cuò),重定向到登錄頁(yè)
String redirectUrl = SSO_SERVER_URL + "/login?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
}
}
優(yōu)缺點(diǎn)
優(yōu)點(diǎn):
- 實(shí)現(xiàn)相對(duì)簡(jiǎn)單,遵循傳統(tǒng)Web開(kāi)發(fā)模式
- 服務(wù)端完全控制會(huì)話狀態(tài)和生命周期
- 客戶(hù)端無(wú)需存儲(chǔ)和管理復(fù)雜狀態(tài)
- 支持即時(shí)會(huì)話失效和撤銷(xiāo)
缺點(diǎn):
- 受同源策略限制,僅適用于同一頂級(jí)域名下的應(yīng)用
- 依賴(lài)Cookie機(jī)制,在某些環(huán)境可能受限(如移動(dòng)應(yīng)用)
- 存在CSRF風(fēng)險(xiǎn)
二、基于JWT的無(wú)狀態(tài)SSO方案
原理
JWT(JSON Web Token)是一種緊湊的、自包含的令牌格式,可以在不同應(yīng)用間安全地傳遞信息。使用JWT實(shí)現(xiàn)SSO時(shí),認(rèn)證服務(wù)器在用戶(hù)登錄后生成JWT令牌,其中包含用戶(hù)相關(guān)信息和簽名。由于JWT可以獨(dú)立驗(yàn)證而無(wú)需查詢(xún)中央服務(wù)器,它非常適合構(gòu)建無(wú)狀態(tài)的SSO系統(tǒng)。
實(shí)現(xiàn)方案
- 創(chuàng)建JWT認(rèn)證服務(wù)
首先添加依賴(lài):
<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>
實(shí)現(xiàn)JWT工具類(lèi):
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long expirationTime;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public String generateToken(User user) {
Map<String, Object> claims = new HashMap<>();
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) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expirationTime);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS512)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
public Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
.build()
.parseClaimsJws(token)
.getBody();
}
}
實(shí)現(xiàn)認(rèn)證控制器:
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private UserService userService;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) {
// 驗(yàn)證用戶(hù)憑證
User user = userService.authenticate(
loginRequest.getUsername(),
loginRequest.getPassword()
);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid credentials"));
}
// 生成JWT
String jwt = jwtUtil.generateToken(user);
// 設(shè)置刷新令牌(可選)
String refreshToken = UUID.randomUUID().toString();
userService.saveRefreshToken(user.getId(), refreshToken, 30); // 30天有效期
// 返回Token
return ResponseEntity.ok(new JwtResponse(jwt, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
// 驗(yàn)證刷新令牌
User user = userService.findUserByRefreshToken(refreshToken);
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid refresh token"));
}
// 生成新的JWT
String newToken = jwtUtil.generateToken(user);
return ResponseEntity.ok(new JwtResponse(newToken, refreshToken));
}
@PostMapping("/validate")
public ResponseEntity<?> validateToken(@RequestBody TokenValidationRequest request) {
String token = request.getToken();
if (!jwtUtil.validateToken(token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("Invalid token"));
}
// 提取用戶(hù)信息
Claims claims = jwtUtil.extractAllClaims(token);
Map<String, Object> userInfo = new HashMap<>(claims);
return ResponseEntity.ok(userInfo);
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody LogoutRequest request) {
// 刪除刷新令牌
userService.removeRefreshToken(request.getRefreshToken());
// JWT本身無(wú)法撤銷(xiāo),客戶(hù)端需要丟棄令牌
return ResponseEntity.ok(new SuccessResponse("Logout successful"));
}
}
- 客戶(hù)端應(yīng)用集成
JWT過(guò)濾器:
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// 從Authorization頭提取JWT
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
// 驗(yàn)證令牌并提取用戶(hù)信息
if (jwtUtil.validateToken(jwt)) {
Claims claims = jwtUtil.extractAllClaims(jwt);
username = claims.getSubject();
// 構(gòu)建認(rèn)證對(duì)象
List<String> roles = (List<String>) claims.get("roles");
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails userDetails = new User(username, "", authorities);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
// JWT驗(yàn)證失敗,不設(shè)置認(rèn)證信息
}
}
chain.doFilter(request, response);
}
}
安全配置:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtRequestFilter jwtRequestFilter;
@Override
protected void configure(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() {
return new BCryptPasswordEncoder();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
- 前端處理JWT
// 在登錄成功后存儲(chǔ)JWT令牌
function handleLoginSuccess(response) {
const { token, refreshToken } = response.data;
localStorage.setItem('jwtToken', token);
localStorage.setItem('refreshToken', refreshToken);
// 設(shè)置默認(rèn)Authorization頭
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// 重定向到應(yīng)用頁(yè)面
window.location.href = '/dashboard';
}
// 添加請(qǐng)求攔截器自動(dòng)附加令牌
axios.interceptors.request.use(config => {
const token = localStorage.getItem('jwtToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 添加響應(yīng)攔截器處理令牌過(guò)期
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// 如果是401錯(cuò)誤且不是刷新令牌請(qǐng)求,嘗試刷新令牌
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const response = 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) {
// 刷新失敗,重定向到登錄頁(yè)
localStorage.removeItem('jwtToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
}
}
return Promise.reject(error);
}
);
優(yōu)缺點(diǎn)
優(yōu)點(diǎn):
- 完全無(wú)狀態(tài),服務(wù)器不需要存儲(chǔ)會(huì)話信息
- 跨域支持,適用于分布式系統(tǒng)和微服務(wù)
- 可擴(kuò)展性好,JWT可包含豐富的用戶(hù)信息
- 不依賴(lài)Cookie,避免CSRF問(wèn)題
- 適用于各種客戶(hù)端(Web、移動(dòng)應(yīng)用、API)
缺點(diǎn):
- 無(wú)法主動(dòng)失效已頒發(fā)的令牌(除非使用黑名單機(jī)制)
- JWT可能較大,增加網(wǎng)絡(luò)傳輸負(fù)擔(dān)
- 令牌管理需要客戶(hù)端介入
- 刷新令牌機(jī)制較復(fù)雜
- 存在令牌被盜用的風(fēng)險(xiǎn)
三、基于OAuth 2.0/OpenID Connect的SSO方案
原理
OAuth 2.0是一個(gè)授權(quán)框架,而OpenID Connect(OIDC)是建立在OAuth 2.0之上的身份認(rèn)證層。這是目前最標(biāo)準(zhǔn)化和完善的SSO解決方案,特別適合企業(yè)級(jí)應(yīng)用和需要第三方集成的場(chǎng)景。它提供了豐富的授權(quán)流程選項(xiàng)和安全特性。
實(shí)現(xiàn)方案
在SpringBoot中,可以使用Spring Security OAuth2來(lái)實(shí)現(xiàn)OAuth 2.0/OIDC服務(wù)器和客戶(hù)端。
- 搭建授權(quán)服務(wù)器
首先添加依賴(lài):
<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>
配置授權(quán)服務(wù)器:
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private DataSource dataSource;
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("your-signing-key");
return converter;
}
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public TokenEnhancer tokenEnhancer() {
return new CustomTokenEnhancer();
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(
Arrays.asList(tokenEnhancer(), accessTokenConverter()));
endpoints
.tokenStore(tokenStore())
.tokenEnhancer(tokenEnhancerChain)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.reuseRefreshTokens(false);
}
@Override
public void configure(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
public void configure(AuthorizationServerSecurityConfigurer security) {
security
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()")
.allowFormAuthenticationForClients();
}
}
// 自定義令牌增強(qiáng)器,添加額外的用戶(hù)信息
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Map<String, Object> additionalInfo = new HashMap<>();
// 添加額外的用戶(hù)信息
if (userDetails instanceof CustomUserDetails) {
CustomUserDetails customUserDetails = (CustomUserDetails) userDetails;
additionalInfo.put("userId", customUserDetails.getId());
additionalInfo.put("email", customUserDetails.getEmail());
additionalInfo.put("fullName", customUserDetails.getFullName());
}
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
}
return accessToken;
}
}
配置資源服務(wù)器:
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/user/**").hasRole("USER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
}
}
- 客戶(hù)端應(yīng)用集成
客戶(hù)端配置(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
客戶(hù)端安全配置:
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(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() {
DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
return userRequest -> {
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 自定義用戶(hù)信息處理
Map<String, Object> attributes = oAuth2User.getAttributes();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
return new DefaultOAuth2User(
oAuth2User.getAuthorities(),
attributes,
userNameAttributeName);
};
}
}
- 完整的登出流程
@Controller
public class AuthController {
@GetMapping("/logout")
public String logout(HttpServletRequest request,
HttpServletResponse response,
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {
// 清除本地會(huì)話
SecurityContextHolder.clearContext();
HttpSession session = 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服務(wù)器的注銷(xiāo)端點(diǎn)
String logoutUrl = "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;
}
}
優(yōu)缺點(diǎn)
優(yōu)點(diǎn):
- 成熟的安全協(xié)議,廣泛采用的行業(yè)標(biāo)準(zhǔn)
- 支持多種認(rèn)證流程(授權(quán)碼、隱式、密碼等)
- 令牌撤銷(xiāo)機(jī)制完善
- 可擴(kuò)展性極好,適合企業(yè)級(jí)應(yīng)用
- 明確分離認(rèn)證與授權(quán)職責(zé)
缺點(diǎn):
- 實(shí)現(xiàn)復(fù)雜度高,小型應(yīng)用可能不合適
- 配置和理解學(xué)習(xí)曲線陡峭
四、基于Spring Session的共享會(huì)話SSO方案
原理
Spring Session提供了一個(gè)將會(huì)話數(shù)據(jù)存儲(chǔ)在共享外部存儲(chǔ)(如Redis)中的框架,使得不同的應(yīng)用之間能夠共享會(huì)話信息。這種方式特別適合基于Spring的同構(gòu)系統(tǒng),可以在保持簡(jiǎn)單實(shí)現(xiàn)的同時(shí)解決分布式會(huì)話共享問(wèn)題。
實(shí)現(xiàn)方案
- 添加依賴(lài)
<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>
- 配置Spring Session
@Configuration
@EnableRedisHttpSession
public class SessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
// Redis連接配置
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
redisConfig.setHostName("redis-host");
redisConfig.setPort(6379);
return new LettuceConnectionFactory(redisConfig);
}
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("SESSION"); // 使用統(tǒng)一的Cookie名
serializer.setCookiePath("/");
serializer.setDomainNamePattern("^.+?\.(\w+\.[a-z]+)$"); // 支持子域
serializer.setUseSecureCookie(true);
serializer.setUseHttpOnlyCookie(true);
return serializer;
}
@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new CookieHttpSessionIdResolver();
}
}
- 創(chuàng)建中央認(rèn)證服務(wù)
@Controller
@RequestMapping("/auth")
public class CentralAuthController {
@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) {
// 驗(yàn)證用戶(hù)憑證
User user = userService.authenticate(username, password);
if (user == null) {
redirectAttrs.addFlashAttribute("error", "Invalid credentials");
return "redirect:/auth/login";
}
// 將用戶(hù)信息存入共享會(huì)話
UserInfo userInfo = new UserInfo(user.getId(), user.getUsername(), user.getRoles());
session.setAttribute("USER_INFO", userInfo);
// 如果有重定向URL,則重定向回原應(yīng)用
if (redirect != null && !redirect.isEmpty()) {
return "redirect:" + redirect;
}
return "redirect:/dashboard";
}
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpSession session) {
// 使會(huì)話失效
session.invalidate();
// 可選:獲取要重定向的URL
String referer = request.getHeader("Referer");
return "redirect:/auth/login";
}
}
- 創(chuàng)建應(yīng)用內(nèi)認(rèn)證過(guò)濾器
@Component
public class SessionAuthenticationFilter implements Filter {
private static final String LOGIN_PAGE = "http://auth.example.com/auth/login";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 公開(kāi)路徑不需要認(rèn)證
String path = httpRequest.getRequestURI();
if (isPublicPath(path)) {
chain.doFilter(request, response);
return;
}
// 檢查session中是否有用戶(hù)信息
HttpSession session = httpRequest.getSession(false);
boolean authenticated = session != null && session.getAttribute("USER_INFO") != null;
if (authenticated) {
// 用戶(hù)已認(rèn)證,繼續(xù)請(qǐng)求
chain.doFilter(request, response);
} else {
// 未認(rèn)證,重定向到登錄頁(yè)面
String redirectUrl = LOGIN_PAGE + "?redirect=" +
URLEncoder.encode(httpRequest.getRequestURL().toString(), "UTF-8");
httpResponse.sendRedirect(redirectUrl);
}
}
private boolean isPublicPath(String path) {
return path.startsWith("/public/") ||
path.startsWith("/resources/") ||
path.equals("/error");
}
}
- 配置應(yīng)用內(nèi)WebMVC
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private SessionAuthenticationFilter sessionAuthenticationFilter;
@Bean
public FilterRegistrationBean<SessionAuthenticationFilter> sessionFilterRegistration() {
FilterRegistrationBean<SessionAuthenticationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(sessionAuthenticationFilter);
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1); // 在Spring Security之后執(zhí)行
return registration;
}
}
- 使用會(huì)話信息
@Controller
@RequestMapping("/dashboard")
public class DashboardController {
@GetMapping
public String dashboard(HttpSession session, Model model) {
UserInfo userInfo = (UserInfo) session.getAttribute("USER_INFO");
model.addAttribute("user", userInfo);
return "dashboard";
}
}
優(yōu)缺點(diǎn)
優(yōu)點(diǎn):
- 實(shí)現(xiàn)簡(jiǎn)單,易于理解
- 與Spring生態(tài)無(wú)縫集成
- 會(huì)話可包含豐富的信息
缺點(diǎn):
- 依賴(lài)中央存儲(chǔ)(如Redis)
- 會(huì)話數(shù)據(jù)需要序列化/反序列化
- 依賴(lài)Cookie,不適合非Web應(yīng)用
五、方案選擇與最佳實(shí)踐
選擇建議
| 方案類(lèi)型 | 推薦場(chǎng)景 | 不適合場(chǎng)景 |
|---|---|---|
| Cookie-Session | 同域名下的小型應(yīng)用,簡(jiǎn)單的認(rèn)證需求 | 跨域應(yīng)用,移動(dòng)應(yīng)用集成,高安全性需求 |
| JWT | 分布式微服務(wù),前后端分離應(yīng)用 | 需要即時(shí)撤銷(xiāo)令牌的場(chǎng)景,極高安全性要求 |
| OAuth 2.0/OIDC | 企業(yè)級(jí)應(yīng)用,需要第三方集成,多租戶(hù)系統(tǒng) | 小型應(yīng)用,資源受限環(huán)境,急速開(kāi)發(fā)需求 |
| Spring Session | Spring技術(shù)棧的應(yīng)用,中型企業(yè)應(yīng)用 | 異構(gòu)技術(shù)棧,非Web應(yīng)用集成 |
六、總結(jié)
從簡(jiǎn)單的基于Cookie-Session的方案,到復(fù)雜的OAuth 2.0/OIDC實(shí)現(xiàn),SSO方案的選擇應(yīng)該基于業(yè)務(wù)需求、安全要求、用戶(hù)體驗(yàn)?zāi)繕?biāo)和技術(shù)約束進(jìn)行綜合考量。
以上就是SpringBoot實(shí)現(xiàn)單點(diǎn)登錄(SSO)的四種方案的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot單點(diǎn)登錄SSO的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Spring Boot 中的 Spring Cloud Feign的原
Spring Cloud Feign 是 Spring Cloud 中的一個(gè)組件,它可以幫助我們實(shí)現(xiàn)聲明式的 REST 客戶(hù),這篇文章主要介紹了Spring Boot 中的 Spring Cloud Feign,需要的朋友可以參考下2023-07-07
Java實(shí)現(xiàn)Excel導(dǎo)入導(dǎo)出數(shù)據(jù)庫(kù)的方法示例
這篇文章主要介紹了Java實(shí)現(xiàn)Excel導(dǎo)入導(dǎo)出數(shù)據(jù)庫(kù)的方法,結(jié)合實(shí)例形式分析了java針對(duì)Excel的讀寫(xiě)及數(shù)據(jù)庫(kù)操作相關(guān)實(shí)現(xiàn)技巧,需要的朋友可以參考下2017-08-08
四步輕松搞定java web每天定時(shí)執(zhí)行任務(wù)
本篇文章主要介紹了四步輕松搞定java web每天定時(shí)執(zhí)行任務(wù),小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-01-01
Java?導(dǎo)出Excel增加下拉框選項(xiàng)
這篇文章主要介紹了Java?導(dǎo)出Excel增加下拉框選項(xiàng),excel對(duì)于下拉框較多選項(xiàng)的,需要使用隱藏工作簿來(lái)解決,使用函數(shù)取值來(lái)做選項(xiàng),下文具體的操作詳情,需要的小伙伴可以參考一下2022-04-04
Java實(shí)現(xiàn)遞歸刪除菜單和目錄及目錄下所有文件
這篇文章主要為大家詳細(xì)介紹了Java如何實(shí)現(xiàn)遞歸刪除菜單和刪除目錄及目錄下所有文件,文中的示例代碼講解詳細(xì),感興趣的小伙伴可以參考一下2025-03-03
解決springboot報(bào)錯(cuò)Could not resolve placeholder‘x
這篇文章主要介紹了解決springboot報(bào)錯(cuò):Could not resolve placeholder ‘xxx‘ in value “${XXXX}問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-11-11
Spring Cache優(yōu)化數(shù)據(jù)庫(kù)訪問(wèn)的項(xiàng)目實(shí)踐
本文主要介紹了Spring Cache優(yōu)化數(shù)據(jù)庫(kù)訪問(wèn)的項(xiàng)目實(shí)踐,將創(chuàng)建一個(gè)簡(jiǎn)單的圖書(shū)管理應(yīng)用作為示例,并演示如何通過(guò)緩存減少對(duì)數(shù)據(jù)庫(kù)的頻繁查詢(xún),感興趣的可以了解一下2024-01-01

