在电商平台架构中,随着业务扩张,往往会拆分出用户中心、商品管理、订单系统、支付中心等多个独立服务。用户若在每个系统都重复登录,体验会极差;同时,各系统间的身份认证一致性、数据安全性也面临挑战。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 核心流程图
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.com,user.jam-mall.com与goods.jam-mall.com不同源); - 端口相同(如都是80端口,8080与8081不同源)。
2.3.2 同源策略的限制范围
同源策略是浏览器的安全机制,核心限制包括:
- 无法读取不同源页面的Cookie、LocalStorage、SessionStorage;
- 无法访问不同源页面的DOM元素;
- 无法发起不同源的AJAX(XMLHttpRequest/Fetch)请求(跨域请求被拦截)。
2.3.3 SSO中的跨域场景
电商SSO中,主要有两个核心跨域场景:
- 重定向跨域:用户从商品系统(
goods.jam-mall.com)重定向到SSO认证中心(user.jam-mall.com),登录后再重定向回商品系统,携带令牌; - 接口跨域:商品系统需要调用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为主,网关代理为辅”的方案:
- 对于简单跨域请求(如GET/POST),直接通过CORS配置解决,由SSO Server和各业务系统后端设置允许跨域的响应头;
- 对于复杂跨域请求(如带自定义头、预检请求),通过网关(如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 系统架构图
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 环境准备
- 启动MySQL8.0,创建
jam_sso数据库,执行4.3.1中的SQL脚本; - 启动SSO认证中心(端口8080);
- 启动商品系统(端口8081);
- 浏览器访问
http://localhost:8081/api/goods/index。
4.5.2 流程验证步骤
- 未登录重定向:访问商品系统首页,拦截器检测到本地会话无用户信息,自动重定向到SSO认证中心的登录页面(
http://localhost:8080/sso/loginPage?redirectUrl=http://localhost:8081/api/goods/index); - 用户登录:在登录页面输入用户名
jam_user、密码123456,提交登录; - 令牌生成与重定向:SSO认证中心验证账号密码成功,生成JWT令牌,重定向回商品系统首页(
http://localhost:8081/api/goods/index?token=xxx); - 令牌验证与本地会话创建:商品系统拦截器解析到令牌,调用SSO认证中心的
verifyToken接口验证,验证通过后创建本地会话; - 正常访问:商品系统返回首页数据,包含登录状态和令牌信息;
- 跨域验证:访问
http://localhost:8081/api/goods/1,无需再次登录,直接返回商品详情(本地会话生效); - 退出登录:访问
http://localhost:8081/api/goods/logout,销毁本地会话,重定向到SSO退出接口(可扩展全局退出逻辑)。
4.5.3 关键验证点
- 跨域请求是否正常:商品系统调用SSO认证中心的
verifyToken接口(跨域),返回200; - 令牌防篡改:修改JWT令牌后,调用
verifyToken接口,返回401(令牌无效); - 会话有效期:2小时后,本地会话和JWT令牌同时过期,访问商品系统会重定向到登录页。
五、总结
电商平台SSO单点跨域的核心是“统一认证+跨域通信+令牌传递”,本文从底层逻辑出发,拆解了SSO的核心流程和跨域问题根源,结合实战案例实现了一套可直接运行的SSO系统:
- 核心逻辑:通过“全局会话(SSO Server)+局部会话(SSO Client)”分离,结合JWT令牌实现身份传递,解决分布式系统的身份认证问题;
- 跨域解决:采用CORS配置允许跨域请求,结合网关代理(可扩展),突破浏览器同源策略限制;
- 安全保障:JWT令牌签名防篡改、BCrypt密码加密、会话有效期管控,满足电商场景的安全要求。
在实际电商项目中,可基于此方案扩展:
- 增加OAuth2.0授权模式,适配第三方应用接入;
- 实现SSO全局退出(销毁SSO Server的全局会话,通知所有Client销毁局部会话);
- 集成Redis存储JWT黑名单(处理令牌提前失效);
- 对接网关(如Spring Cloud Gateway),统一处理跨域和SSO拦截,减少各Client的重复配置。
通过这套方案,可彻底解决电商分布式系统的单点登录和跨域问题,兼顾用户体验、系统安全和架构扩展性。