SpringBoot 實(shí)現(xiàn)單點(diǎn)登錄:從傳統(tǒng)到現(xiàn)代的演進(jìn)之路
前言
在企業(yè)級(jí)應(yīng)用架構(gòu)中,單點(diǎn)登錄(Single Sign-On,SSO)已成為不可或缺的關(guān)鍵組件。它允許用戶只需一次登錄,就能訪問多個(gè)相互信任的應(yīng)用系統(tǒng),大幅提升了用戶體驗(yàn)與系統(tǒng)安全性。
本文將深入探討在SpringBoot環(huán)境下實(shí)現(xiàn)SSO的四種主流方案:Cookie-Session模式、JWT無狀態(tài)模式、OAuth 2.0授權(quán)框架以及Spring Session分布式方案。
什么是單點(diǎn)登錄?
單點(diǎn)登錄是一種身份認(rèn)證機(jī)制,其核心思想是:用戶在認(rèn)證中心完成一次登錄后,即可訪問所有信任該認(rèn)證中心的應(yīng)用系統(tǒng),無需重復(fù)登錄。這種機(jī)制帶來了多重優(yōu)勢(shì):
- 提升用戶體驗(yàn):消除重復(fù)登錄的繁瑣過程
 - 增強(qiáng)安全性:集中管理認(rèn)證過程,便于實(shí)施統(tǒng)一的安全策略
 - 簡(jiǎn)化系統(tǒng)管理:集中式的用戶身份管理降低了運(yùn)維成本
 - 支持跨域認(rèn)證:解決不同域名應(yīng)用間的身份共享問題
 
實(shí)現(xiàn)
效果圖
圖片
基于 Cookie-Session 的傳統(tǒng)實(shí)現(xiàn)
Cookie-Session模式是最經(jīng)典的SSO實(shí)現(xiàn)方式,依賴于服務(wù)器端存儲(chǔ)會(huì)話狀態(tài)和客戶端存儲(chǔ)標(biāo)識(shí)信息。
實(shí)現(xiàn)原理
- 用戶訪問應(yīng)用系統(tǒng),發(fā)現(xiàn)未登錄,重定向到認(rèn)證中心
 - 用戶在認(rèn)證中心輸入
credentials進(jìn)行登錄 - 認(rèn)證中心驗(yàn)證通過后,創(chuàng)建
Session存儲(chǔ)用戶信息,生成SessionID - 認(rèn)證中心將
SessionID寫入Cookie,并重定向回原應(yīng)用系統(tǒng) - 應(yīng)用系統(tǒng)向認(rèn)證中心驗(yàn)證
SessionID的有效性 - 驗(yàn)證通過后,應(yīng)用系統(tǒng)可創(chuàng)建本地會(huì)話或直接信任該身份
 
示例代碼
認(rèn)證中心
@RestController
public class AuthController {
    
    @Autowired
    private HttpSession session;
    
    // 登錄接口
    @PostMapping("/login")
    public String login(@RequestParam String username, 
                       @RequestParam String password,
                       HttpServletResponse response) {
        // 驗(yàn)證用戶名密碼(實(shí)際應(yīng)用中應(yīng)連接數(shù)據(jù)庫)
        if ("admin".equals(username) && "admin123".equals(password)) {
            // 存儲(chǔ)用戶信息到Session
            session.setAttribute("user", username);
            // 設(shè)置SessionID到Cookie(跨域場(chǎng)景需要特殊配置)
            Cookie cookie = new Cookie("SESSIONID", session.getId());
            cookie.setDomain("example.com"); // 設(shè)置主域名,實(shí)現(xiàn)跨子域共享
            cookie.setPath("/");
            response.addCookie(cookie);
            return"登錄成功";
        }
        return"用戶名或密碼錯(cuò)誤";
    }
    
    // 驗(yàn)證Session有效性接口
    @GetMapping("/verify")
    public String verify(@CookieValue(value = "SESSIONID", required = false) String sessionId) {
        if (sessionId == null) {
            return"未登錄";
        }
        // 實(shí)際應(yīng)用中應(yīng)通過SessionRepository查找對(duì)應(yīng)Session
        if (session.getId().equals(sessionId) && session.getAttribute("user") != null) {
            return session.getAttribute("user").toString();
        }
        return"無效會(huì)話";
    }
}客戶端應(yīng)用集成
@Configuration
public class SsoConfig implements WebMvcConfigurer {
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new HandlerInterceptor() {
            @Override
            public boolean preHandle(HttpServletRequest request, 
                                   HttpServletResponse response, 
                                   Object handler) throws Exception {
                // 檢查是否為公開路徑
                String path = request.getRequestURI();
                if (path.contains("/login") || path.contains("/public")) {
                    returntrue;
                }
                
                // 從Cookie獲取SESSIONID
                String sessionId = null;
                Cookie[] cookies = request.getCookies();
                if (cookies != null) {
                    for (Cookie cookie : cookies) {
                        if ("SESSIONID".equals(cookie.getName())) {
                            sessionId = cookie.getValue();
                            break;
                        }
                    }
                }
                
                // 調(diào)用認(rèn)證中心驗(yàn)證
                if (sessionId != null) {
                    RestTemplate restTemplate = new RestTemplate();
                    String username = restTemplate.getForObject(
                        "http://auth.example.com/verify?sessinotallow=" + sessionId, 
                        String.class);
                    if (!"未登錄".equals(username) && !"無效會(huì)話".equals(username)) {
                        // 驗(yàn)證通過,存儲(chǔ)用戶信息到本地
                        request.setAttribute("currentUser", username);
                        returntrue;
                    }
                }
                
                // 驗(yàn)證失敗,重定向到認(rèn)證中心
                response.sendRedirect("http://auth.example.com/login?redirect=" + 
                                     URLEncoder.encode(request.getRequestURL().toString(), "UTF-8"));
                returnfalse;
            }
        });
    }
}優(yōu)點(diǎn):
- 實(shí)現(xiàn)簡(jiǎn)單,易于理解和開發(fā)
 - 會(huì)話狀態(tài)存儲(chǔ)在服務(wù)器,安全性較高
 - 支持主動(dòng)使會(huì)話失效
 
缺點(diǎn):
- 分布式環(huán)境下需要解決
Session共享問題 - 跨域場(chǎng)景處理復(fù)雜,
Cookie存在跨域限制 - 服務(wù)器存儲(chǔ)會(huì)話狀態(tài),增加了服務(wù)器負(fù)擔(dān)
 - 不適合前后端分離和移動(dòng)端應(yīng)用
 
基于 JWT 的無狀態(tài)實(shí)現(xiàn)
JWT(JSON Web Token)是一種輕量級(jí)的認(rèn)證令牌,它將用戶信息編碼到令牌中,實(shí)現(xiàn)了無狀態(tài)的認(rèn)證機(jī)制,非常適合分布式系統(tǒng)。
實(shí)現(xiàn)原理
- 用戶在認(rèn)證中心登錄成功后,服務(wù)器生成包含用戶信息的
JWT令牌 - 認(rèn)證中心將
JWT返回給客戶端(通常存儲(chǔ)在localStorage或Cookie中) - 客戶端后續(xù)請(qǐng)求在
Authorization頭中攜帶JWT - 各應(yīng)用系統(tǒng)接收到請(qǐng)求后,驗(yàn)證
JWT的簽名有效性 - 驗(yàn)證通過后,從
JWT中解析出用戶信息,無需與認(rèn)證中心交互 
JWT 結(jié)構(gòu)解析
Header(頭部):指定令牌類型和簽名算法Payload(載荷):包含聲明信息,如用戶ID、角色、過期時(shí)間等Signature(簽名):使用服務(wù)器密鑰對(duì)前兩部分進(jìn)行簽名,確保令牌未被篡改
示例代碼
JWT 工具類
@Component
public class JwtUtils {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.expiration}")
    private long expiration; // 單位:毫秒
    
    // 生成JWT令牌
    public String generateToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);
        
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    
    // 從JWT令牌中獲取用戶名
    public String getUsernameFromToken(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
    
    // 驗(yàn)證JWT令牌
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
            returntrue;
        } catch (Exception e) {
            // 令牌無效或已過期
            returnfalse;
        }
    }
}認(rèn)證中心
@RestController
public class AuthController {
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestParam String username, 
                                  @RequestParam String password) {
        // 驗(yàn)證用戶名密碼
        if ("admin".equals(username) && "admin123".equals(password)) {
            // 生成JWT令牌
            String token = jwtUtils.generateToken(username);
            return ResponseEntity.ok(new JwtResponse(token));
        }
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("認(rèn)證失敗");
    }
    
    // JWT響應(yīng)實(shí)體
    static class JwtResponse {
        private String token;
        
        public JwtResponse(String token) {
            this.token = token;
        }
        
        // getter and setter
    }
}客戶端應(yīng)用
@Component
public class JwtInterceptor implements HandlerInterceptor {
    
    @Autowired
    private JwtUtils jwtUtils;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                           HttpServletResponse response, 
                           Object handler) throws Exception {
        // 獲取Authorization頭
        String authorizationHeader = request.getHeader("Authorization");
        
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            String token = authorizationHeader.substring(7);
            
            // 驗(yàn)證令牌
            if (jwtUtils.validateToken(token)) {
                String username = jwtUtils.getUsernameFromToken(token);
                // 將用戶信息存入請(qǐng)求
                request.setAttribute("currentUser", username);
                returntrue;
            }
        }
        
        // 令牌無效,返回401
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().write("Unauthorized");
        returnfalse;
    }
}優(yōu)點(diǎn):
- 無狀態(tài)設(shè)計(jì),服務(wù)器無需存儲(chǔ)會(huì)話信息,易于水平擴(kuò)展
 - 適合分布式系統(tǒng)和微服務(wù)架構(gòu)
 - 支持跨域認(rèn)證,適用于前后端分離和移動(dòng)端應(yīng)用
 - 減少了服務(wù)間的通信開銷
 
缺點(diǎn):
- 令牌一旦生成無法直接修改或撤銷,除非維護(hù)黑名單
 - 令牌包含用戶信息,雖有簽名但不宜存儲(chǔ)敏感數(shù)據(jù)
 - 令牌過長(zhǎng)可能增加網(wǎng)絡(luò)傳輸負(fù)擔(dān)
 - 續(xù)簽機(jī)制相對(duì)復(fù)雜
 
基于 OAuth 2.0 的授權(quán)框架
OAuth 2.0是一個(gè)開放標(biāo)準(zhǔn)的授權(quán)框架,不僅用于單點(diǎn)登錄,更廣泛應(yīng)用于第三方應(yīng)用授權(quán)場(chǎng)景(如使用微信、QQ登錄其他應(yīng)用)。
核心概念
- 資源所有者:通常指用戶,擁有可訪問的資源
 - 客戶端:請(qǐng)求訪問資源的應(yīng)用程序
 - 授權(quán)服務(wù)器:負(fù)責(zé)認(rèn)證用戶并頒發(fā)令牌
 - 資源服務(wù)器:存儲(chǔ)受保護(hù)資源的服務(wù)器,驗(yàn)證令牌有效性
 
授權(quán)流程(授權(quán)碼模式)
- 客戶端引導(dǎo)用戶到授權(quán)服務(wù)器
 - 用戶在授權(quán)服務(wù)器進(jìn)行認(rèn)證并授予權(quán)限
 - 授權(quán)服務(wù)器返回授權(quán)碼給客戶端
 - 客戶端使用授權(quán)碼向授權(quán)服務(wù)器請(qǐng)求訪問令牌
 - 授權(quán)服務(wù)器驗(yàn)證授權(quán)碼,頒發(fā)訪問令牌(可能包含刷新令牌)
 - 客戶端使用訪問令牌訪問資源服務(wù)器
 - 資源服務(wù)器驗(yàn)證令牌,返回受保護(hù)資源
 
示例代碼
授權(quán)服務(wù)器配置
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    
    @Autowired
    private AuthenticationManager authenticationManager;
    
    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            // 客戶端ID和密鑰
            .withClient("client-app")
            .secret(passwordEncoder().encode("client-secret"))
            // 授權(quán)類型
            .authorizedGrantTypes("authorization_code", "refresh_token")
            // 授權(quán)范圍
            .scopes("read", "write")
            // 回調(diào)地址
            .redirectUris("http://client.example.com/callback")
            // 訪問令牌有效期
            .accessTokenValiditySeconds(3600)
            // 刷新令牌有效期
            .refreshTokenValiditySeconds(86400);
    }
    
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
            .authenticationManager(authenticationManager)
            .userDetailsService(userDetailsService);
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}資源服務(wù)器配置
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/public/**").permitAll()
            .anyRequest().authenticated();
    }
}客戶端應(yīng)用配置
@Configuration
@EnableOAuth2Sso
public class ClientConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/", "/login**").permitAll()
            .anyRequest().authenticated()
            .and()
            .logout()
            .logoutSuccessUrl("http://auth.example.com/logout")
            .permitAll();
    }
}客戶端屬性配置
# OAuth2客戶端注冊(cè)配置(針對(duì)具體客戶端)
spring.security.oauth2.client.registration.my-client.client-id=client-app
spring.security.oauth2.client.registration.my-client.client-secret=client-secret
# 授權(quán)類型(根據(jù)實(shí)際場(chǎng)景選擇,如authorization_code、password等)
spring.security.oauth2.client.registration.my-client.authorization-grant-type=authorization_code
# 回調(diào)地址(需與認(rèn)證服務(wù)器配置的一致)
spring.security.oauth2.client.registration.my-client.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}
# 認(rèn)證服務(wù)器(Provider)配置
spring.security.oauth2.client.provider.my-provider.access-token-uri=http://auth.example.com/oauth/token
spring.security.oauth2.client.provider.my-provider.authorization-uri=http://auth.example.com/oauth/authorize
# 用戶信息端點(diǎn)(用于獲取登錄用戶詳情)
spring.security.oauth2.client.provider.my-provider.user-info-uri=http://auth.example.com/user
# 從用戶信息響應(yīng)中提取用戶名的字段(默認(rèn)是username,根據(jù)實(shí)際響應(yīng)調(diào)整)
spring.security.oauth2.client.provider.my-provider.user-name-attribute=username優(yōu)點(diǎn):
- 標(biāo)準(zhǔn)化協(xié)議,生態(tài)完善,支持多種授權(quán)模式
 - 安全性高,支持精細(xì)的權(quán)限控制
 - 非常適合第三方應(yīng)用授權(quán)場(chǎng)景
 - 支持令牌刷新機(jī)制
 
缺點(diǎn):
- 實(shí)現(xiàn)相對(duì)復(fù)雜,學(xué)習(xí)成本較高
 - 流程相對(duì)繁瑣,增加了網(wǎng)絡(luò)請(qǐng)求次數(shù)
 - 不適合對(duì)性能要求極高的內(nèi)部系統(tǒng)
 
基于 Spring Session 的分布式實(shí)現(xiàn)
Spring Session提供了一種簡(jiǎn)化的方式來管理用戶會(huì)話,支持將會(huì)話數(shù)據(jù)存儲(chǔ)在分布式環(huán)境中(如 Redis、Mongo 等),非常適合集群部署的SSO系統(tǒng)。
實(shí)現(xiàn)原理
- 擴(kuò)展了
HttpSession,將會(huì)話數(shù)據(jù)存儲(chǔ)在外部數(shù)據(jù)源 - 各應(yīng)用節(jié)點(diǎn)通過統(tǒng)一的
SessionID訪問共享的會(huì)話數(shù)據(jù) - 支持跨域會(huì)話共享,解決了傳統(tǒng)
Cookie-Session的分布式問題 
示例代碼
配置屬性
# application.properties
spring.session.store-type=redis
spring.session.redis.namespace=spring:session:sso
server.servlet.session.cookie.name=SSOSESSION
server.servlet.session.cookie.domain=example.com
server.servlet.session.timeout=30m認(rèn)證中心登錄
@RestController
public class AuthController {
    
    @PostMapping("/login")
    public String login(@RequestParam String username, 
                       @RequestParam String password,
                       HttpSession session) {
        // 驗(yàn)證用戶名密碼
        if ("admin".equals(username) && "admin123".equals(password)) {
            // 存儲(chǔ)用戶信息到Session
            session.setAttribute("user", username);
            return"登錄成功,SessionID: " + session.getId();
        }
        return"認(rèn)證失敗";
    }
    
    @GetMapping("/user")
    public String getUser(HttpSession session) {
        return session.getAttribute("user") != null ? 
               session.getAttribute("user").toString() : "未登錄";
    }
}客戶端應(yīng)用
@RestController
public class ClientController {
    
    @GetMapping("/hello")
    public String hello(HttpSession session) {
        String user = (String) session.getAttribute("user");
        if (user != null) {
            return "Hello, " + user + "! 這是客戶端應(yīng)用";
        }
        return "請(qǐng)先登錄";
    }
}優(yōu)點(diǎn):
- 透明集成
Spring生態(tài),無需大量修改現(xiàn)有代碼 - 完美解決分布式系統(tǒng)的
Session共享問題 - 支持多種存儲(chǔ)方式,易于擴(kuò)展
 - 保留了傳統(tǒng)
Session的使用習(xí)慣,學(xué)習(xí)成本低 
缺點(diǎn):
- 需要額外的存儲(chǔ)服務(wù)(如
Redis) - 仍依賴
Cookie傳遞SessionID,跨域存在一定限制 - 相比
JWT增加了存儲(chǔ)訪問開銷 
方案對(duì)比與選擇建議
方案  | 優(yōu)勢(shì)場(chǎng)景  | 缺點(diǎn)  | 適用系統(tǒng)  | 
Cookie-Session  | 簡(jiǎn)單應(yīng)用、內(nèi)部系統(tǒng)  | 分布式支持差、跨域限制  | 小型單體應(yīng)用  | 
JWT  | 前后端分離、移動(dòng)端、微服務(wù)  | 無法即時(shí)吊銷、存儲(chǔ)限制  | 分布式系統(tǒng)、API 服務(wù)  | 
OAuth 2.0  | 第三方授權(quán)、開放平臺(tái)  | 實(shí)現(xiàn)復(fù)雜、流程長(zhǎng)  | 開放平臺(tái)、多客戶端場(chǎng)景  | 
Spring Session  | 分布式集群、Session 共享  | 依賴外部存儲(chǔ)  | 集群部署的 Web 應(yīng)用  | 















 
 
 















 
 
 
 