解密电商平台 SSO 单点跨域

简介: 本文深入解析电商平台SSO单点登录与跨域问题,涵盖核心概念、流程拆解及实战方案。通过统一认证中心与JWT令牌实现多系统无缝访问,结合CORS解决跨域难题,提升用户体验与系统安全性。

在电商平台架构中,随着业务扩张,往往会拆分出用户中心、商品管理、订单系统、支付中心等多个独立服务。用户若在每个系统都重复登录,体验会极差;同时,各系统间的身份认证一致性、数据安全性也面临挑战。SSO(Single Sign-On,单点登录)技术应运而生,它能让用户在多个关联系统中仅需登录一次,即可无缝访问所有授权服务。而跨域问题,作为分布式系统的“拦路虎”,是SSO实现过程中必须攻克的核心难点。

一、先搞懂核心:SSO单点登录到底是什么?

1.1 SSO的核心定义与价值

SSO即单点登录,是一种身份认证与授权技术,核心目标是:用户在多个相互信任的系统(服务)中,只需完成一次身份验证,即可获得所有关联系统的访问权限,无需重复登录。

在电商场景中,SSO的价值体现在三个维度:

  • 用户体验:用户登录用户中心后,访问商品详情、下单、支付等系统时无需再次输入账号密码,流程顺畅;
  • 系统安全:统一身份认证入口,便于集中管控用户权限、审计登录日志,降低分散认证带来的安全风险;
  • 架构效率:避免各系统重复开发身份认证模块,减少冗余代码,提升团队协作效率。

1.2 SSO与普通登录的核心区别

普通登录(如单一系统的账号密码登录)中,认证信息(如Session)存储在当前系统服务器,仅对当前系统有效;而SSO的核心是“统一认证中心”,所有关联系统(称为“依赖方”)的身份认证都委托给该中心处理,认证信息由中心统一管理。

举个通俗例子:普通登录像“每个小区都有独立门禁,需单独登记身份”;SSO像“城市一卡通,在所有合作小区门禁系统中只需验证一次,即可通行”。

1.3 电商SSO的核心组成角色

一个完整的电商SSO架构,包含三个核心角色:

  • 认证中心(SSO Server):核心组件,负责用户身份认证、发放认证凭证、验证凭证有效性,如电商平台的“用户中心”;
  • 依赖方系统(SSO Client):需要接入SSO的业务系统,如商品系统、订单系统、支付中心等,自身不存储用户密码,依赖认证中心完成身份校验;
  • 用户(User):访问各业务系统的主体,是认证流程的触发者。

二、底层逻辑拆解:SSO单点登录的核心流程

SSO的核心逻辑是“统一认证、凭证共享、信任传递”,不同实现方案(如Cookie+Session、JWT、OAuth2.0)的流程本质一致,只是凭证类型和传递方式不同。下面以电商最常用的“JWT令牌模式”为例,用流程图拆解完整流程,并解释关键环节的设计思路。

2.1 核心流程图

image.png

2.2 关键环节拆解

2.2.1 全局会话与局部会话

SSO的核心设计是“全局会话”与“局部会话”的分离:

  • 全局会话:存储在SSO认证中心的用户登录状态,由认证中心统一管理(如用户登录后,SSO Server生成的JWT令牌有效期内,全局会话有效);
  • 局部会话:存储在各依赖方系统(如商品系统)的用户登录状态,是全局会话的“镜像”。当用户通过SSO认证后,依赖方系统会创建本地局部会话,避免每次访问都调用SSO Server验证。

2.2.2 令牌:SSO的“身份通行证”

令牌是SSO中传递身份信息的核心载体,电商场景中最常用的是JWT(JSON Web Token),相比传统的SessionID,JWT具有“无状态、可携带自定义信息、支持跨域传递”的优势,适合分布式电商架构。

JWT的核心作用:

  • 身份标识:令牌中包含用户ID、用户名等核心信息,作为用户身份的“电子凭证”;
  • 权限携带:可嵌入用户角色、权限列表(如“普通用户”“管理员”),减少依赖方系统查询数据库的次数;
  • 防篡改:通过密钥签名(如HS256算法),确保令牌在传输过程中不被篡改,保证安全性。

2.2.3 跨域的“坑”:为什么SSO必须解决跨域?

在电商架构中,用户中心(SSO Server)的域名可能是user.jam-mall.com,商品系统是goods.jam-mall.com,订单系统是order.jam-mall.com——这些系统属于不同域名(跨域场景)。

根据浏览器的“同源策略”:不同源的页面之间,无法直接读取对方的Cookie、LocalStorage,也无法直接发起AJAX请求。而SSO的核心流程(如重定向携带令牌、依赖方验证令牌)都需要跨域交互,若不解决跨域问题,令牌无法正常传递,SSO流程会直接中断。

2.3 跨域问题根源:浏览器同源策略

2.3.1 同源的定义

浏览器判断两个URL是否“同源”,需同时满足三个条件:

  • 协议相同(如都是HTTP或HTTPS);
  • 域名相同(如都是jam-mall.comuser.jam-mall.comgoods.jam-mall.com不同源);
  • 端口相同(如都是80端口,8080与8081不同源)。

2.3.2 同源策略的限制范围

同源策略是浏览器的安全机制,核心限制包括:

  • 无法读取不同源页面的Cookie、LocalStorage、SessionStorage;
  • 无法访问不同源页面的DOM元素;
  • 无法发起不同源的AJAX(XMLHttpRequest/Fetch)请求(跨域请求被拦截)。

2.3.3 SSO中的跨域场景

电商SSO中,主要有两个核心跨域场景:

  1. 重定向跨域:用户从商品系统(goods.jam-mall.com)重定向到SSO认证中心(user.jam-mall.com),登录后再重定向回商品系统,携带令牌;
  2. 接口跨域:商品系统需要调用SSO认证中心的“令牌验证接口”(如user.jam-mall.com/api/sso/verifyToken),验证令牌有效性。

三、跨域解决方案选型:电商场景最优解

解决跨域的方案有很多,如JSONP、代理转发、CORS、iframe等。结合电商场景的高可用、高安全性要求,我们需要对比各方案的优缺点,选择最优解。

3.1 主流跨域方案对比

方案 核心原理 优点 缺点 电商场景适用性
JSONP 利用script标签不受同源策略限制,加载远程脚本 兼容性好(支持旧浏览器) 仅支持GET请求,安全性低(易受XSS攻击) 不推荐
代理转发 后端服务器转发跨域请求(如Nginx/网关) 前端无感知,安全性高 增加服务器转发压力,需配置网关 推荐(配合CORS)
CORS 服务器端设置响应头,允许跨域请求 支持所有HTTP方法,安全性高,配置简单 兼容性依赖浏览器(现代浏览器均支持) 推荐(核心方案)
iframe+Cookie 利用iframe嵌套,共享Cookie 无需修改前端逻辑 安全性低(易受CSRF攻击),Cookie配置复杂 不推荐

3.2 电商场景最优解:CORS+网关代理

结合电商架构特点(微服务+网关),我们采用“CORS为主,网关代理为辅”的方案:

  1. 对于简单跨域请求(如GET/POST),直接通过CORS配置解决,由SSO Server和各业务系统后端设置允许跨域的响应头;
  2. 对于复杂跨域请求(如带自定义头、预检请求),通过网关(如Spring Cloud Gateway)统一转发,减少各系统重复配置,同时增强安全性。

核心原因:

  • CORS配置简单,无需前端大量改造,适合微服务架构;
  • 网关代理可集中管控跨域规则,便于后续扩展(如新增业务系统);
  • 两者结合,兼顾灵活性与安全性,满足电商高可用要求。

四、实战落地:电商SSO单点跨域完整方案

下面我们基于电商实际场景,搭建一套完整的SSO单点登录系统,包含“SSO认证中心(用户中心)”“商品系统(SSO Client)”两个核心服务,解决跨域问题,实现用户一次登录、多系统访问。

4.1 技术栈选型

组件 版本 作用
JDK 17 开发环境
Spring Boot 3.2.5 微服务基础框架
Spring Security 6.2.4 安全框架(辅助认证授权)
MyBatis-Plus 3.5.5 持久层框架(操作MySQL)
JWT JJWT 0.11.5 生成/验证令牌
MySQL 8.0.36 存储用户信息、权限数据
Lombok 1.18.30 简化Java代码(@Slf4j等)
Fastjson2 2.0.46 JSON序列化/反序列化
SpringDoc-OpenAPI 2.3.0 Swagger3,接口文档生成
Guava 33.2.1-jre 集合工具类(Lists/Maps)

4.2 系统架构图

image.png

4.3 第一步:搭建SSO认证中心(用户中心)

SSO认证中心是核心,负责用户登录、令牌生成、令牌验证,需解决跨域问题,提供标准化接口给各业务系统。

4.3.1 数据库设计(MySQL8.0)

创建用户表(sys_user)和角色表(sys_role),采用RBAC权限模型(简化版,满足电商基础权限需求):

-- 用户表
CREATE TABLE `sys_user` (
 `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
 `username` varchar(50) NOT NULL COMMENT '用户名',
 `password` varchar(100) NOT NULL COMMENT '加密密码(BCrypt)',
 `nickname` varchar(50) DEFAULT NULL COMMENT '用户昵称',
 `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
 `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:1-正常,0-禁用',
 `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
 `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电商用户表';

-- 角色表
CREATE TABLE `sys_role` (
 `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色ID',
 `role_name` varchar(50) NOT NULL COMMENT '角色名称(如:ROLE_USER/ROLE_ADMIN)',
 `role_desc` varchar(100) DEFAULT NULL COMMENT '角色描述',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

-- 用户角色关联表
CREATE TABLE `sys_user_role` (
 `id` bigint NOT NULL AUTO_INCREMENT COMMENT '关联ID',
 `user_id` bigint NOT NULL COMMENT '用户ID',
 `role_id` bigint NOT NULL COMMENT '角色ID',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';

-- 初始化数据
INSERT INTO `sys_role` (`role_name`, `role_desc`) VALUES ('ROLE_USER', '普通用户'), ('ROLE_ADMIN', '管理员');
-- 密码:123456(BCrypt加密)
INSERT INTO `sys_user` (`username`, `password`, `nickname`, `phone`) VALUES ('jam_user', '$2a$10$EixZaYb4xU58Gpq1R0yWbeb00LU5qUaK6x8h8y08Qv10hP7w2u7aK', '果酱用户', '13800138000');
INSERT INTO `sys_user_role` (`user_id`, `role_id`) VALUES (1, 1);

4.3.2 项目结构(Maven)

com.jam.demo.sso.server
├── config/          # 配置类(CORS、JWT、Security等)
├── controller/      # 接口层(登录、令牌验证等)
├── entity/          # 实体类(User、Role等)
├── mapper/          # Mapper接口(MyBatis-Plus)
├── service/         # 服务层(用户校验、权限查询等)
├── util/            # 工具类(JWT工具类等)
├── SsoServerApplication.java  # 启动类
└── pom.xml          # 依赖配置

4.3.3 依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

   <modelVersion>4.0.0</modelVersion>
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.2.5</version>
       <relativePath/>
   </parent>
   <groupId>com.jam.demo</groupId>
   <artifactId>sso-server</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>sso-server</name>
   <description>电商SSO认证中心</description>
   
   <properties>
       <java.version>17</java.version>
       <mybatis-plus.version>3.5.5</mybatis-plus.version>
       <jjwt.version>0.11.5</jjwt.version>
       <fastjson2.version>2.0.46</fastjson2.version>
       <guava.version>33.2.1-jre</guava.version>
       <springdoc.version>2.3.0</springdoc.version>
   </properties>
   
   <dependencies>
       <!-- Spring Boot核心依赖 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-security</artifactId>
       </dependency>
       
       <!-- 数据库相关 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-jdbc</artifactId>
       </dependency>
       <dependency>
           <groupId>com.mysql</groupId>
           <artifactId>mysql-connector-j</artifactId>
           <scope>runtime</scope>
       </dependency>
       
       <!-- MyBatis-Plus -->
       <dependency>
           <groupId>com.baomidou</groupId>
           <artifactId>mybatis-plus-boot-starter</artifactId>
           <version>${mybatis-plus.version}</version>
       </dependency>
       
       <!-- JWT -->
       <dependency>
           <groupId>io.jsonwebtoken</groupId>
           <artifactId>jjwt-api</artifactId>
           <version>${jjwt.version}</version>
       </dependency>
       <dependency>
           <groupId>io.jsonwebtoken</groupId>
           <artifactId>jjwt-impl</artifactId>
           <version>${jjwt.version}</version>
           <scope>runtime</scope>
       </dependency>
       <dependency>
           <groupId>io.jsonwebtoken</groupId>
           <artifactId>jjwt-jackson</artifactId>
           <version>${jjwt.version}</version>
           <scope>runtime</scope>
       </dependency>
       
       <!-- 工具类 -->
       <dependency>
           <groupId>org.projectlombok</groupId>
           <artifactId>lombok</artifactId>
           <version>1.18.30</version>
           <scope>provided</scope>
       </dependency>
       <dependency>
           <groupId>com.alibaba.fastjson2</groupId>
           <artifactId>fastjson2</artifactId>
           <version>${fastjson2.version}</version>
       </dependency>
       <dependency>
           <groupId>com.google.guava</groupId>
           <artifactId>guava</artifactId>
           <version>${guava.version}</version>
       </dependency>
       
       <!-- Swagger3 -->
       <dependency>
           <groupId>org.springdoc</groupId>
           <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
           <version>${springdoc.version}</version>
       </dependency>
       
       <!-- 测试 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
   </dependencies>
   
   <build>
       <plugins>
           <plugin>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-maven-plugin</artifactId>
               <configuration>
                   <excludes>
                       <exclude>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </exclude>
                   </excludes>
               </configuration>
           </plugin>
       </plugins>
   </build>
</project>

4.3.4 核心配置类

(1)跨域配置(CorsConfig.java)

解决SSO认证中心的跨域问题,允许各业务系统(如商品系统、订单系统)的跨域请求:

package com.jam.demo.sso.server.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* 跨域配置类
* 允许所有合法来源的跨域请求,适配电商多系统场景
* @author ken
*/

@Configuration
public class CorsConfig implements WebMvcConfigurer {

   @Override
   public void addCorsMappings(CorsRegistry registry) {
       // 允许所有路径的跨域请求
       registry.addMapping("/**")
               // 允许的来源(电商各业务系统域名,实际生产环境需指定具体域名,如"https://goods.jam-mall.com")
               .allowedOrigins("http://localhost:8081", "http://localhost:8082")
               // 允许的请求方法
               .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
               // 允许的请求头
               .allowedHeaders("*")
               // 允许携带Cookie(SSO需要传递认证信息)
               .allowCredentials(true)
               // 预检请求缓存时间(减少预检请求次数,提升性能)
               .maxAge(3600);
   }

   /**
    * 跨域过滤器(优先级高于addCorsMappings)
    * @return CorsFilter
    */

   @Bean
   public CorsFilter corsFilter() {
       CorsConfiguration config = new CorsConfiguration();
       config.addAllowedOrigin("http://localhost:8081");
       config.addAllowedOrigin("http://localhost:8082");
       config.addAllowedMethod("*");
       config.addAllowedHeader("*");
       config.setAllowCredentials(true);
       config.setMaxAge(3600L);

       UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
       source.registerCorsConfiguration("/**", config);
       return new CorsFilter(source);
   }
}

(2)JWT配置(JwtConfig.java)

配置JWT的密钥、过期时间等核心参数:

package com.jam.demo.sso.server.config;

import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

import java.util.Base64;

/**
* JWT配置类
* @author ken
*/

@Configuration
public class JwtConfig {

   /**
    * JWT密钥(生产环境需放在配置中心,加密存储)
    */

   @Value("${jwt.secret:jam-sso-secret-key-2025-encrypt-123456}")
   private String secret;

   /**
    * JWT过期时间(单位:秒),电商场景建议2小时(7200秒)
    */

   @Value("${jwt.expire:7200}")
   private long expire;

   /**
    * 签发者(区分不同环境,如dev/prod)
    */

   @Value("${jwt.issuer:jam-sso-server}")
   private String issuer;

   /**
    * 获取Base64编码后的密钥(JJWT要求密钥为Base64编码)
    * @return 编码后的密钥
    */

   public String getSecretKey() {
       return Base64.getEncoder().encodeToString(secret.getBytes());
   }

   public long getExpire() {
       return expire;
   }

   public String getIssuer() {
       return issuer;
   }

   /**
    * 获取签名算法(采用HS256,对称加密,适合内部系统)
    * @return SignatureAlgorithm
    */

   public SignatureAlgorithm getSignatureAlgorithm() {
       return SignatureAlgorithm.HS256;
   }
}

(3)Spring Security配置(SecurityConfig.java)

关闭默认登录页面,放行SSO相关接口,适配自定义认证流程:

package com.jam.demo.sso.server.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

/**
* Spring Security配置类
* 关闭默认认证,放行SSO接口
* @author ken
*/

@Configuration
@EnableWebSecurity
public class SecurityConfig {

   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
       http
               // 关闭CSRF(电商场景中,前端通过JWT令牌验证,无需CSRF防护)
               .csrf(csrf -> csrf.disable())
               // 关闭默认登录页面和注销功能
               .formLogin(form -> form.disable())
               .logout(logout -> logout.disable())
               // 配置接口权限
               .authorizeHttpRequests(auth -> auth
                       // 放行SSO相关接口(登录、令牌验证、页面跳转)
                       .requestMatchers("/api/sso/login", "/api/sso/verifyToken", "/sso/loginPage").permitAll()
                       // 放行Swagger3接口
                       .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                       // 其他接口需认证
                       .anyRequest().authenticated()
               );
       return http.build();
   }

   /**
    * 密码加密器(BCrypt算法,电商场景标准加密方式)
    * @return PasswordEncoder
    */

   @Bean
   public PasswordEncoder passwordEncoder() {
       return new BCryptPasswordEncoder();
   }
}

4.3.5 核心工具类与实体类

(1)JWT工具类(JwtUtil.java)

负责JWT令牌的生成、验证、解析:

package com.jam.demo.sso.server.util;

import com.jam.demo.sso.server.config.JwtConfig;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

import java.security.Key;
import java.util.Date;
import java.util.Map;

/**
* JWT工具类(生成、验证、解析令牌)
* @author ken
*/

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {

   private final JwtConfig jwtConfig;

   /**
    * 生成JWT令牌
    * @param userId 用户ID
    * @param username 用户名
    * @param claims 额外负载(如角色、权限)
    * @return JWT令牌字符串
    */

   public String generateToken(Long userId, String username, Map<String, Object> claims) {
       // 计算过期时间
       Date expireDate = new Date(System.currentTimeMillis() + jwtConfig.getExpire() * 1000);
       // 构建JWT令牌
       return Jwts.builder()
               // 额外负载
               .setClaims(claims)
               // 主题(用户名)
               .setSubject(username)
               // 签发者
               .setIssuer(jwtConfig.getIssuer())
               // 签发时间
               .setIssuedAt(new Date())
               // 过期时间
               .setExpiration(expireDate)
               // 签名(密钥+算法)
               .signWith(getSecretKey(), jwtConfig.getSignatureAlgorithm())
               .compact();
   }

   /**
    * 验证JWT令牌有效性
    * @param token JWT令牌
    * @return 有效返回true,无效返回false
    */

   public boolean verifyToken(String token) {
       try {
           Jwts.parserBuilder()
                   .setSigningKey(getSecretKey())
                   .build()
                   .parseClaimsJws(token);
           return true;
       } catch (Exception e) {
           log.error("JWT令牌验证失败,token:{},错误信息:{}", token, e.getMessage());
           return false;
       }
   }

   /**
    * 解析JWT令牌,获取负载信息
    * @param token JWT令牌
    * @return Claims 负载信息
    */

   public Claims parseClaims(String token) {
       if (ObjectUtils.isEmpty(token)) {
           log.error("JWT令牌为空,无法解析");
           throw new IllegalArgumentException("JWT令牌不能为空");
       }
       try {
           return Jwts.parserBuilder()
                   .setSigningKey(getSecretKey())
                   .build()
                   .parseClaimsJws(token)
                   .getBody();
       } catch (Exception e) {
           log.error("JWT令牌解析失败,token:{},错误信息:{}", token, e.getMessage());
           throw new RuntimeException("JWT令牌解析失败", e);
       }
   }

   /**
    * 从令牌中获取用户名
    * @param token JWT令牌
    * @return 用户名
    */

   public String getUsernameFromToken(String token) {
       Claims claims = parseClaims(token);
       return claims.getSubject();
   }

   /**
    * 从令牌中获取用户ID
    * @param token JWT令牌
    * @return 用户ID
    */

   public Long getUserIdFromToken(String token) {
       Claims claims = parseClaims(token);
       return claims.get("userId", Long.class);
   }

   /**
    * 获取签名密钥
    * @return Key
    */

   private Key getSecretKey() {
       return Keys.hmacShaKeyFor(jwtConfig.getSecretKey().getBytes());
   }
}

(2)用户实体类(SysUser.java)

package com.jam.demo.sso.server.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 系统用户实体类
* @author ken
*/

@Data
@TableName("sys_user")
public class SysUser {

   /**
    * 用户ID
    */

   @TableId(type = IdType.AUTO)
   private Long id;

   /**
    * 用户名
    */

   private String username;

   /**
    * 加密密码
    */

   private String password;

   /**
    * 用户昵称
    */

   private String nickname;

   /**
    * 手机号
    */

   private String phone;

   /**
    * 状态:1-正常,0-禁用
    */

   private Integer status;

   /**
    * 创建时间
    */

   private LocalDateTime createTime;

   /**
    * 更新时间
    */

   private LocalDateTime updateTime;
}

(3)角色实体类(SysRole.java)

package com.jam.demo.sso.server.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

/**
* 角色实体类
* @author ken
*/

@Data
@TableName("sys_role")
public class SysRole {

   /**
    * 角色ID
    */

   @TableId(type = IdType.AUTO)
   private Long id;

   /**
    * 角色名称(如:ROLE_USER/ROLE_ADMIN)
    */

   private String roleName;

   /**
    * 角色描述
    */

   private String roleDesc;
}

4.3.6 持久层与服务层

(1)用户Mapper(SysUserMapper.java)

package com.jam.demo.sso.server.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.sso.server.entity.SysUser;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

/**
* 用户Mapper接口
* @author ken
*/

@Repository
public interface SysUserMapper extends BaseMapper<SysUser> {

   /**
    * 根据用户名查询用户
    * @param username 用户名
    * @return SysUser
    */

   SysUser selectByUsername(@Param("username") String username);

   /**
    * 根据用户ID查询角色列表
    * @param userId 用户ID
    * @return 角色名称列表
    */

   List<String> selectRolesByUserId(@Param("userId") Long userId);
}

(2)用户Service(SysUserService.java)

package com.jam.demo.sso.server.service;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.sso.server.entity.SysUser;
import com.jam.demo.sso.server.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;

import java.util.List;

/**
* 用户服务类
* @author ken
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {

   private final SysUserMapper sysUserMapper;
   private final PasswordEncoder passwordEncoder;

   /**
    * 根据用户名查询用户(含角色信息)
    * @param username 用户名
    * @return SysUser
    */

   public SysUser getUserByUsername(String username) {
       if (ObjectUtils.isEmpty(username)) {
           log.error("查询用户失败:用户名为空");
           throw new IllegalArgumentException("用户名为空");
       }
       return sysUserMapper.selectByUsername(username);
   }

   /**
    * 验证用户名和密码
    * @param username 用户名
    * @param password 原始密码
    * @return 验证通过返回用户信息,失败返回null
    */

   public SysUser verifyUsernameAndPassword(String username, String password) {
       // 1. 查询用户
       SysUser user = getUserByUsername(username);
       if (ObjectUtils.isEmpty(user)) {
           log.error("验证失败:用户不存在,username:{}", username);
           return null;
       }
       // 2. 验证密码(BCrypt加密匹配)
       if (!passwordEncoder.matches(password, user.getPassword())) {
           log.error("验证失败:密码错误,username:{}", username);
           return null;
       }
       // 3. 验证用户状态
       if (user.getStatus() != 1) {
           log.error("验证失败:用户已禁用,username:{}", username);
           return null;
       }
       return user;
   }

   /**
    * 根据用户ID查询角色列表
    * @param userId 用户ID
    * @return 角色名称列表
    */

   public List<String> getRolesByUserId(Long userId) {
       if (ObjectUtils.isEmpty(userId)) {
           log.error("查询角色失败:用户ID为空");
           throw new IllegalArgumentException("用户ID为空");
       }
       return sysUserMapper.selectRolesByUserId(userId);
   }
}

4.3.7 控制层(SSO接口)

提供登录、令牌验证、登录页面跳转接口,供各业务系统调用:

package com.jam.demo.sso.server.controller;

import com.alibaba.fastjson2.JSONObject;
import com.google.common.collect.Maps;
import com.jam.demo.sso.server.entity.SysUser;
import com.jam.demo.sso.server.service.SysUserService;
import com.jam.demo.sso.server.util.JwtUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

/**
* SSO认证中心接口层
* 提供登录、令牌验证等核心接口
* @author ken
*/

@Slf4j
@RestController
@RequestMapping("/api/sso")
@RequiredArgsConstructor
@Tag(name = "SSO认证接口", description = "电商平台单点登录核心接口")
public class SsoController {

   private final SysUserService sysUserService;
   private final JwtUtil jwtUtil;

   /**
    * 用户登录接口
    * @param username 用户名
    * @param password 密码
    * @param redirectUrl 登录成功后重定向的地址(业务系统地址)
    * @return 登录结果(含JWT令牌、重定向地址)
    */

   @PostMapping("/login")
   @Operation(
           summary = "用户登录",
           description = "用户输入账号密码,验证通过后生成JWT令牌,返回重定向地址",
           parameters = {
                   @Parameter(name = "username", description = "用户名", required = true),
                   @Parameter(name = "password", description = "密码", required = true),
                   @Parameter(name = "redirectUrl", description = "登录成功重定向地址", required = true)
           },
           responses = @ApiResponse(
                   responseCode = "200",
                   description = "登录成功",
                   content = @Content(schema = @Schema(example = "{\"code\":200,\"msg\":\"登录成功\",\"data\":{\"token\":\"xxx\",\"redirectUrl\":\"http://localhost:8081/index\"}}"))
           )
   )
   public JSONObject login(
           @RequestParam String username,
           @RequestParam String password,
           @RequestParam String redirectUrl
   )
{
       // 参数校验
       StringUtils.hasText(username, "用户名不能为空");
       StringUtils.hasText(password, "密码不能为空");
       StringUtils.hasText(redirectUrl, "重定向地址不能为空");

       // 验证用户名和密码
       SysUser user = sysUserService.verifyUsernameAndPassword(username, password);
       if (ObjectUtils.isEmpty(user)) {
           log.error("登录失败:用户名或密码错误,username:{}", username);
           return buildResult(401, "用户名或密码错误", null);
       }

       // 查询用户角色
       List<String> roles = sysUserService.getRolesByUserId(user.getId());

       // 构建JWT负载(含用户核心信息)
       Map<String, Object> claims = Maps.newHashMap();
       claims.put("userId", user.getId());
       claims.put("username", user.getUsername());
       claims.put("roles", roles);

       // 生成JWT令牌
       String token = jwtUtil.generateToken(user.getId(), user.getUsername(), claims);
       log.info("用户登录成功,生成JWT令牌,username:{},token:{}", username, token);

       // 构建返回结果(含令牌和重定向地址)
       Map<String, String> data = Maps.newHashMap();
       data.put("token", token);
       data.put("redirectUrl", redirectUrl);
       return buildResult(200, "登录成功", data);
   }

   /**
    * 验证JWT令牌有效性
    * @param token JWT令牌
    * @return 令牌验证结果(含用户信息)
    */

   @GetMapping("/verifyToken")
   @Operation(
           summary = "验证JWT令牌",
           description = "业务系统调用此接口验证令牌有效性,返回用户信息",
           parameters = @Parameter(name = "token", description = "JWT令牌", required = true),
           responses = @ApiResponse(
                   responseCode = "200",
                   description = "令牌有效",
                   content = @Content(schema = @Schema(example = "{\"code\":200,\"msg\":\"令牌有效\",\"data\":{\"userId\":1,\"username\":\"jam_user\",\"roles\":[\"ROLE_USER\"]}}"))
           )
   )
   public JSONObject verifyToken(@RequestParam String token) {
       // 参数校验
       StringUtils.hasText(token, "令牌不能为空");

       // 验证令牌
       boolean valid = jwtUtil.verifyToken(token);
       if (!valid) {
           log.error("令牌无效,token:{}", token);
           return buildResult(401, "令牌无效或已过期", null);
       }

       // 解析令牌,获取用户信息
       Long userId = jwtUtil.getUserIdFromToken(token);
       String username = jwtUtil.getUsernameFromToken(token);
       List<String> roles = jwtUtil.parseClaims(token).get("roles", List.class);

       // 构建返回结果
       Map<String, Object> data = Maps.newHashMap();
       data.put("userId", userId);
       data.put("username", username);
       data.put("roles", roles);
       return buildResult(200, "令牌有效", data);
   }

   /**
    * 跳转登录页面(供业务系统重定向)
    * @param redirectUrl 登录成功后重定向的业务系统地址
    * @return 登录页面(这里简化为返回HTML,实际项目中可集成Thymeleaf等模板引擎)
    */

   @GetMapping("/loginPage")
   @Operation(
           summary = "跳转登录页面",
           description = "业务系统未登录时,重定向到该接口,返回登录页面",
           parameters = @Parameter(name = "redirectUrl", description = "登录成功重定向地址", required = true)
   )
   public String loginPage(@RequestParam String redirectUrl) {
       // 简化实现:返回一个HTML登录页面,表单提交到/login接口
       return "<!DOCTYPE html>\n" +
               "<html lang=\"zh-CN\">\n" +
               "<head>\n" +
               "    <meta charset=\"UTF-8\">\n" +
               "    <title>电商平台-单点登录</title>\n" +
               "</head>\n" +
               "<body>\n" +
               "    <h3>电商平台单点登录</h3>\n" +
               "    <form action=\"/api/sso/login\" method=\"post\">\n" +
               "        <input type=\"hidden\" name=\"redirectUrl\" value=\"" + redirectUrl + "\">\n" +
               "        用户名:<input type=\"text\" name=\"username\" required><br>\n" +
               "        密码:<input type=\"password\" name=\"password\" required><br>\n" +
               "        <button type=\"submit\">登录</button>\n" +
               "    </form>\n" +
               "</body>\n" +
               "</html>";
   }

   /**
    * 构建统一返回结果
    * @param code 状态码
    * @param msg 提示信息
    * @param data 业务数据
    * @return JSONObject
    */

   private JSONObject buildResult(int code, String msg, Object data) {
       JSONObject result = new JSONObject();
       result.put("code", code);
       result.put("msg", msg);
       result.put("data", data);
       return result;
   }
}

4.3.8 启动类(SsoServerApplication.java)

package com.jam.demo.sso.server;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* SSO认证中心启动类
* @author ken
*/

@SpringBootApplication
@MapperScan("com.jam.demo.sso.server.mapper")
public class SsoServerApplication {

   public static void main(String[] args) {
       SpringApplication.run(SsoServerApplication.class, args);
   }
}

4.3.9 配置文件(application.yml)

server:
 port: 8080  # SSO认证中心端口
 servlet:
   context-path: /

spring:
 # 数据库配置
 datasource:
   url: jdbc:mysql://localhost:3306/jam_sso?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
   username: root
   password: root
   driver-class-name: com.mysql.cj.jdbc.Driver

# MyBatis-Plus配置
mybatis-plus:
 mapper-locations: classpath:mapper/**/*.xml
 type-aliases-package: com.jam.demo.sso.server.entity
 configuration:
   map-underscore-to-camel-case: true  # 下划线转驼峰
   log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 打印SQL日志

# JWT配置(可根据实际需求调整)
jwt:
 secret: jam-sso-secret-key-2025-encrypt-123456  # 生产环境需加密存储
 expire: 7200  # 令牌过期时间(2小时)
 issuer: jam-sso-server-dev  # 开发环境标识

# SpringDoc-OpenAPI(Swagger3)配置
springdoc:
 api-docs:
   path: /v3/api-docs
 swagger-ui:
   path: /swagger-ui.html
   operationsSorter: method  # 按方法排序

4.4 第二步:搭建商品系统(SSO Client)

商品系统作为SSO依赖方,需实现“未登录重定向到SSO认证中心”“登录后验证令牌”“创建本地会话”等功能。

4.4.1 项目结构(Maven)

com.jam.demo.sso.client.goods
├── config/          # 配置类(拦截器、跨域等)
├── controller/      # 接口层(商品查询、首页等)
├── util/            # 工具类(HTTP请求工具等)
├── GoodsClientApplication.java  # 启动类
└── pom.xml          # 依赖配置

4.4.2 依赖配置(pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

   <modelVersion>4.0.0</modelVersion>
   <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>3.2.5</version>
       <relativePath/>
   </parent>
   <groupId>com.jam.demo</groupId>
   <artifactId>sso-client-goods</artifactId>
   <version>0.0.1-SNAPSHOT</version>
   <name>sso-client-goods</name>
   <description>电商SSO依赖方-商品系统</description>
   
   <properties>
       <java.version>17</java.version>
       <fastjson2.version>2.0.46</fastjson2.version>
       <guava.version>33.2.1-jre</guava.version>
       <springdoc.version>2.3.0</springdoc.version>
   </properties>
   
   <dependencies>
       <!-- Spring Boot核心依赖 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-web</artifactId>
       </dependency>
       
       <!-- 工具类 -->
       <dependency>
           <groupId>org.projectlombok</groupId>
           <artifactId>lombok</artifactId>
           <version>1.18.30</version>
           <scope>provided</scope>
       </dependency>
       <dependency>
           <groupId>com.alibaba.fastjson2</groupId>
           <artifactId>fastjson2</artifactId>
           <version>${fastjson2.version}</version>
       </dependency>
       <dependency>
           <groupId>com.google.guava</groupId>
           <artifactId>guava</artifactId>
           <version>${guava.version}</version>
       </dependency>
       
       <!-- Swagger3 -->
       <dependency>
           <groupId>org.springdoc</groupId>
           <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
           <version>${springdoc.version}</version>
       </dependency>
       
       <!-- 测试 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
   </dependencies>
   
   <build>
       <plugins>
           <plugin>
               <groupId>org.springframework.boot</groupId>
               <artifactId>spring-boot-maven-plugin</artifactId>
               <configuration>
                   <excludes>
                       <exclude>
                           <groupId>org.projectlombok</groupId>
                           <artifactId>lombok</artifactId>
                       </exclude>
                   </excludes>
               </configuration>
           </plugin>
       </plugins>
   </build>
</project>

4.4.3 核心配置类

(1)跨域配置(CorsConfig.java)

商品系统作为SSO依赖方,需允许与SSO认证中心的跨域交互:

package com.jam.demo.sso.client.goods.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* 商品系统跨域配置
* @author ken
*/

@Configuration
public class CorsConfig implements WebMvcConfigurer {

   @Override
   public void addCorsMappings(CorsRegistry registry) {
       registry.addMapping("/**")
               // 允许SSO认证中心的跨域请求
               .allowedOrigins("http://localhost:8080")
               .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
               .allowedHeaders("*")
               .allowCredentials(true)
               .maxAge(3600);
   }
}

(2)拦截器配置(WebMvcConfig.java)

注册SSO登录拦截器,拦截商品系统的所有请求,校验登录状态:

package com.jam.demo.sso.client.goods.config;

import com.jam.demo.sso.client.goods.interceptor.SsoLoginInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* WebMvc配置类,注册SSO拦截器
* @author ken
*/

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

   private final SsoLoginInterceptor ssoLoginInterceptor;

   @Override
   public void addInterceptors(InterceptorRegistry registry) {
       // 注册SSO登录拦截器,拦截所有请求(排除Swagger接口)
       registry.addInterceptor(ssoLoginInterceptor)
               .addPathPatterns("/**")
               .excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**");
   }
}

(3)RestTemplate配置(RestTemplateConfig.java)

用于商品系统调用SSO认证中心的令牌验证接口:

package com.jam.demo.sso.client.goods.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

/**
* RestTemplate配置类(用于跨服务HTTP请求)
* @author ken
*/

@Configuration
public class RestTemplateConfig {

   @Bean
   public RestTemplate restTemplate() {
       return new RestTemplate();
   }
}

4.4.4 核心工具类

(1)HTTP请求工具类(HttpUtil.java)

封装HTTP GET/POST请求,简化调用SSO接口的逻辑:

package com.jam.demo.sso.client.goods.util;

import com.alibaba.fastjson2.JSONObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

import java.util.Map;

/**
* HTTP请求工具类
* @author ken
*/

@Slf4j
@Component
@RequiredArgsConstructor
public class HttpUtil {

   private final RestTemplate restTemplate;

   /**
    * 发送GET请求
    * @param url 请求地址
    * @param params 请求参数
    * @return 响应结果(JSONObject)
    */

   public JSONObject doGet(String url, Map<String, String> params) {
       if (!StringUtils.hasText(url)) {
           log.error("GET请求失败:URL为空");
           throw new IllegalArgumentException("URL不能为空");
       }

       try {
           // 拼接参数
           StringBuilder urlBuilder = new StringBuilder(url);
           if (params != null && !params.isEmpty()) {
               urlBuilder.append("?");
               for (Map.Entry<String, String> entry : params.entrySet()) {
                   urlBuilder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
               }
               url = urlBuilder.substring(0, urlBuilder.length() - 1);
           }

           ResponseEntity<String> response = restTemplate.exchange(
                   url,
                   HttpMethod.GET,
                   new HttpEntity<>(new HttpHeaders()),
                   String.class
           )
;

           return JSONObject.parseObject(response.getBody());
       } catch (Exception e) {
           log.error("GET请求失败,url:{},params:{},错误信息:{}", url, params, e.getMessage());
           throw new RuntimeException("HTTP GET请求失败", e);
       }
   }

   /**
    * 发送POST请求
    * @param url 请求地址
    * @param params 请求参数
    * @return 响应结果(JSONObject)
    */

   public JSONObject doPost(String url, Map<String, String> params) {
       if (!StringUtils.hasText(url)) {
           log.error("POST请求失败:URL为空");
           throw new IllegalArgumentException("URL不能为空");
       }

       try {
           HttpHeaders headers = new HttpHeaders();
           headers.set("Content-Type", "application/x-www-form-urlencoded");
           HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(params, headers);

           ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
           return JSONObject.parseObject(response.getBody());
       } catch (Exception e) {
           log.error("POST请求失败,url:{},params:{},错误信息:{}", url, params, e.getMessage());
           throw new RuntimeException("HTTP POST请求失败", e);
       }
   }
}

(2)SSO客户端工具类(SsoClientUtil.java)

封装SSO相关逻辑(令牌验证、重定向地址拼接):

package com.jam.demo.sso.client.goods.util;

import com.alibaba.fastjson2.JSONObject;
import com.google.common.collect.Maps;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
* SSO客户端工具类
* @author ken
*/

@Slf4j
@Component
@RequiredArgsConstructor
public class SsoClientUtil {

   /**
    * SSO认证中心地址
    */

   @Value("${sso.server.url:http://localhost:8080}")
   private String ssoServerUrl;

   /**
    * 当前客户端(商品系统)地址
    */

   @Value("${sso.client.url:http://localhost:8081}")
   private String ssoClientUrl;

   private final HttpUtil httpUtil;

   /**
    * 拼接SSO登录页面地址(含重定向参数)
    * @param request 当前请求(用于获取完整的回调地址)
    * @return SSO登录页面地址
    */

   public String buildSsoLoginUrl(HttpServletRequest request) {
       // 获取当前请求的完整地址(作为登录成功后的回调地址)
       String currentUrl = getCurrentRequestUrl(request);
       // 拼接SSO登录页面地址
       return ssoServerUrl + "/sso/loginPage?redirectUrl=" + currentUrl;
   }

   /**
    * 验证JWT令牌(调用SSO认证中心接口)
    * @param token JWT令牌
    * @return 验证结果(true=有效,false=无效)
    */

   public boolean verifyToken(String token) {
       if (!StringUtils.hasText(token)) {
           log.error("令牌验证失败:令牌为空");
           return false;
       }

       // 调用SSO认证中心的令牌验证接口
       String verifyUrl = ssoServerUrl + "/api/sso/verifyToken";
       Map<String, String> params = Maps.newHashMap();
       params.put("token", token);

       try {
           JSONObject result = httpUtil.doGet(verifyUrl, params);
           // 校验响应码(200表示令牌有效)
           return result.getInteger("code") == 200;
       } catch (Exception e) {
           log.error("令牌验证失败,token:{},错误信息:{}", token, e.getMessage());
           return false;
       }
   }

   /**
    * 获取当前请求的完整URL(用于回调)
    * @param request HttpServletRequest
    * @return 完整URL
    */

   private String getCurrentRequestUrl(HttpServletRequest request) {
       String scheme = request.getScheme(); // http/https
       String serverName = request.getServerName(); // 域名/IP
       int serverPort = request.getServerPort(); // 端口
       String requestURI = request.getRequestURI(); // 请求路径
       String queryString = request.getQueryString(); // 请求参数

       StringBuilder url = new StringBuilder();
       url.append(scheme).append("://").append(serverName);
       if (serverPort != 80 && serverPort != 443) {
           url.append(":").append(serverPort);
       }
       url.append(requestURI);
       if (StringUtils.hasText(queryString)) {
           url.append("?").append(queryString);
       }

       return url.toString();
   }

   /**
    * 从SSO重定向的参数中解析JWT令牌
    * @param request HttpServletRequest
    * @return JWT令牌
    */

   public String getTokenFromRequest(HttpServletRequest request) {
       // 从请求参数中获取token(SSO登录成功后重定向时携带)
       String token = request.getParameter("token");
       if (StringUtils.hasText(token)) {
           log.info("从请求参数中解析到JWT令牌:{}", token);
           return token;
       }
       return null;
   }
}

4.4.5 SSO登录拦截器(SsoLoginInterceptor.java)

核心拦截器,校验用户登录状态,未登录则重定向到SSO认证中心:

package com.jam.demo.sso.client.goods.interceptor;

import com.jam.demo.sso.client.goods.util.SsoClientUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

/**
* SSO登录拦截器
* 校验用户登录状态,未登录则重定向到SSO认证中心
* @author ken
*/

@Slf4j
@Component
@RequiredArgsConstructor
public class SsoLoginInterceptor implements HandlerInterceptor {

   private final SsoClientUtil ssoClientUtil;

   /**
    * 预处理:校验登录状态
    */

   @Override
   public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       HttpSession session = request.getSession();
       // 1. 检查本地会话是否已登录(存在用户信息)
       Object userInfo = session.getAttribute("userInfo");
       if (!ObjectUtils.isEmpty(userInfo)) {
           log.info("本地会话已登录,放行请求:{}", request.getRequestURI());
           return true;
       }

       // 2. 检查请求参数中是否有SSO返回的JWT令牌(登录成功后重定向)
       String token = ssoClientUtil.getTokenFromRequest(request);
       if (StringUtils.hasText(token)) {
           // 3. 验证令牌有效性
           boolean valid = ssoClientUtil.verifyToken(token);
           if (valid) {
               // 4. 令牌有效,创建本地会话(存储用户信息,避免重复验证)
               session.setAttribute("userInfo", token); // 简化:直接存储令牌,实际可解析存储用户信息
               session.setMaxInactiveInterval(7200); // 本地会话过期时间与JWT一致(2小时)
               log.info("令牌验证通过,创建本地会话,放行请求:{}", request.getRequestURI());
               return true;
           } else {
               log.error("令牌无效,重定向到SSO登录页:{}", request.getRequestURI());
           }
       }

       // 5. 未登录/令牌无效,重定向到SSO认证中心登录页
       String ssoLoginUrl = ssoClientUtil.buildSsoLoginUrl(request);
       log.info("未登录,重定向到SSO登录页:{}", ssoLoginUrl);
       response.sendRedirect(ssoLoginUrl);
       return false;
   }
}

4.4.6 控制层(商品系统接口)

提供商品查询、首页等接口,验证SSO登录拦截逻辑:

package com.jam.demo.sso.client.goods.controller;

import com.alibaba.fastjson2.JSONObject;
import com.google.common.collect.Maps;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpSession;
import java.util.Map;

/**
* 商品系统接口层
* @author ken
*/

@Slf4j
@RestController
@RequestMapping("/api/goods")
@Tag(name = "商品接口", description = "电商商品系统核心接口")
public class GoodsController {

   /**
    * 商品系统首页
    */

   @GetMapping("/index")
   @Operation(summary = "商品系统首页", description = "验证SSO登录状态,返回用户信息和首页数据")
   public JSONObject index(HttpSession session) {
       // 获取本地会话中的用户信息(简化:存储的是JWT令牌)
       String token = (String) session.getAttribute("userInfo");
       Map<String, Object> data = Maps.newHashMap();
       data.put("msg", "欢迎访问商品系统首页");
       data.put("loginStatus", "已登录");
       data.put("token", token);

       JSONObject result = new JSONObject();
       result.put("code", 200);
       result.put("msg", "success");
       result.put("data", data);
       return result;
   }

   /**
    * 根据商品ID查询商品信息
    */

   @GetMapping("/{goodsId}")
   @Operation(summary = "查询商品详情", description = "验证SSO登录状态后,返回商品详情")
   public JSONObject getGoodsById(@PathVariable Long goodsId) {
       // 模拟商品数据
       Map<String, Object> goods = Maps.newHashMap();
       goods.put("goodsId", goodsId);
       goods.put("goodsName", "电商爆款商品-" + goodsId);
       goods.put("price", 99.9);
       goods.put("stock", 1000);

       JSONObject result = new JSONObject();
       result.put("code", 200);
       result.put("msg", "success");
       result.put("data", goods);
       return result;
   }

   /**
    * 退出登录(销毁本地会话,并重定向到SSO退出接口)
    */

   @GetMapping("/logout")
   @Operation(summary = "退出登录", description = "销毁本地会话,并重定向到SSO认证中心退出")
   public void logout(HttpSession session, HttpServletResponse response) throws Exception {
       // 1. 销毁本地会话
       session.invalidate();
       log.info("商品系统本地会话已销毁");

       // 2. 重定向到SSO认证中心退出接口(实际项目中需实现SSO全局退出逻辑)
       String ssoLogoutUrl = "http://localhost:8080/api/sso/logout";
       response.sendRedirect(ssoLogoutUrl);
   }
}

4.4.7 启动类(GoodsClientApplication.java)

package com.jam.demo.sso.client.goods;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* 商品系统(SSO Client)启动类
* @author ken
*/

@SpringBootApplication
public class GoodsClientApplication {

   public static void main(String[] args) {
       SpringApplication.run(GoodsClientApplication.class, args);
   }
}

4.4.8 配置文件(application.yml)

server:
 port: 8081  # 商品系统端口
 servlet:
   context-path: /

# SSO配置
sso:
 server:
   url: http://localhost:8080  # SSO认证中心地址
 client:
   url: http://localhost:8081  # 当前客户端(商品系统)地址

# SpringDoc-OpenAPI(Swagger3)配置
springdoc:
 api-docs:
   path: /v3/api-docs
 swagger-ui:
   path: /swagger-ui.html
   operationsSorter: method

4.5 测试验证(完整SSO跨域流程)

4.5.1 环境准备

  1. 启动MySQL8.0,创建jam_sso数据库,执行4.3.1中的SQL脚本;
  2. 启动SSO认证中心(端口8080);
  3. 启动商品系统(端口8081);
  4. 浏览器访问http://localhost:8081/api/goods/index

4.5.2 流程验证步骤

  1. 未登录重定向:访问商品系统首页,拦截器检测到本地会话无用户信息,自动重定向到SSO认证中心的登录页面(http://localhost:8080/sso/loginPage?redirectUrl=http://localhost:8081/api/goods/index);
  2. 用户登录:在登录页面输入用户名jam_user、密码123456,提交登录;
  3. 令牌生成与重定向:SSO认证中心验证账号密码成功,生成JWT令牌,重定向回商品系统首页(http://localhost:8081/api/goods/index?token=xxx);
  4. 令牌验证与本地会话创建:商品系统拦截器解析到令牌,调用SSO认证中心的verifyToken接口验证,验证通过后创建本地会话;
  5. 正常访问:商品系统返回首页数据,包含登录状态和令牌信息;
  6. 跨域验证:访问http://localhost:8081/api/goods/1,无需再次登录,直接返回商品详情(本地会话生效);
  7. 退出登录:访问http://localhost:8081/api/goods/logout,销毁本地会话,重定向到SSO退出接口(可扩展全局退出逻辑)。

4.5.3 关键验证点

  • 跨域请求是否正常:商品系统调用SSO认证中心的verifyToken接口(跨域),返回200;
  • 令牌防篡改:修改JWT令牌后,调用verifyToken接口,返回401(令牌无效);
  • 会话有效期:2小时后,本地会话和JWT令牌同时过期,访问商品系统会重定向到登录页。

五、总结

电商平台SSO单点跨域的核心是“统一认证+跨域通信+令牌传递”,本文从底层逻辑出发,拆解了SSO的核心流程和跨域问题根源,结合实战案例实现了一套可直接运行的SSO系统:

  1. 核心逻辑:通过“全局会话(SSO Server)+局部会话(SSO Client)”分离,结合JWT令牌实现身份传递,解决分布式系统的身份认证问题;
  2. 跨域解决:采用CORS配置允许跨域请求,结合网关代理(可扩展),突破浏览器同源策略限制;
  3. 安全保障:JWT令牌签名防篡改、BCrypt密码加密、会话有效期管控,满足电商场景的安全要求。

在实际电商项目中,可基于此方案扩展:

  • 增加OAuth2.0授权模式,适配第三方应用接入;
  • 实现SSO全局退出(销毁SSO Server的全局会话,通知所有Client销毁局部会话);
  • 集成Redis存储JWT黑名单(处理令牌提前失效);
  • 对接网关(如Spring Cloud Gateway),统一处理跨域和SSO拦截,减少各Client的重复配置。

通过这套方案,可彻底解决电商分布式系统的单点登录和跨域问题,兼顾用户体验、系统安全和架构扩展性。

目录
相关文章
|
1天前
|
数据采集 人工智能 安全
|
11天前
|
云安全 监控 安全
|
2天前
|
自然语言处理 API
万相 Wan2.6 全新升级发布!人人都能当导演的时代来了
通义万相2.6全新升级,支持文生图、图生视频、文生视频,打造电影级创作体验。智能分镜、角色扮演、音画同步,让创意一键成片,大众也能轻松制作高质量短视频。
952 151
|
2天前
|
编解码 人工智能 机器人
通义万相2.6,模型使用指南
智能分镜 | 多镜头叙事 | 支持15秒视频生成 | 高品质声音生成 | 多人稳定对话
|
16天前
|
机器学习/深度学习 人工智能 自然语言处理
Z-Image:冲击体验上限的下一代图像生成模型
通义实验室推出全新文生图模型Z-Image,以6B参数实现“快、稳、轻、准”突破。Turbo版本仅需8步亚秒级生成,支持16GB显存设备,中英双语理解与文字渲染尤为出色,真实感和美学表现媲美国际顶尖模型,被誉为“最值得关注的开源生图模型之一”。
1665 8
|
7天前
|
人工智能 自然语言处理 API
一句话生成拓扑图!AI+Draw.io 封神开源组合,工具让你的效率爆炸
一句话生成拓扑图!next-ai-draw-io 结合 AI 与 Draw.io,通过自然语言秒出架构图,支持私有部署、免费大模型接口,彻底解放生产力,绘图效率直接爆炸。
620 152
|
9天前
|
人工智能 安全 前端开发
AgentScope Java v1.0 发布,让 Java 开发者轻松构建企业级 Agentic 应用
AgentScope 重磅发布 Java 版本,拥抱企业开发主流技术栈。
593 16
|
9天前
|
人工智能 自然语言处理 API
Next AI Draw.io:当AI遇见Draw.io图表绘制
Next AI Draw.io 是一款融合AI与图表绘制的开源工具,基于Next.js实现,支持自然语言生成架构图、流程图等专业图表。集成多款主流大模型,提供智能绘图、图像识别优化、版本管理等功能,部署简单,安全可控,助力技术文档与系统设计高效创作。
673 151