首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >解密电商平台 SSO 单点跨域

解密电商平台 SSO 单点跨域

作者头像
果酱带你啃java
发布2026-04-14 14:13:27
发布2026-04-14 14:13:27
530
举报

在电商平台架构中,随着业务扩张,往往会拆分出用户中心、商品管理、订单系统、支付中心等多个独立服务。用户若在每个系统都重复登录,体验会极差;同时,各系统间的身份认证一致性、数据安全性也面临挑战。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.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 系统架构图

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

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

4.3.1 数据库设计(MySQL8.0)

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

代码语言:javascript
复制
-- 用户表
CREATETABLE`sys_user` (
`id`bigintNOTNULL AUTO_INCREMENT COMMENT'用户ID',
`username`varchar(50) NOTNULLCOMMENT'用户名',
`password`varchar(100) NOTNULLCOMMENT'加密密码(BCrypt)',
`nickname`varchar(50) DEFAULTNULLCOMMENT'用户昵称',
`phone`varchar(20) DEFAULTNULLCOMMENT'手机号',
`status`tinyintNOTNULLDEFAULT1COMMENT'状态:1-正常,0-禁用',
`create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
  PRIMARY KEY (`id`),
UNIQUEKEY`uk_username` (`username`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='电商用户表';

-- 角色表
CREATETABLE`sys_role` (
`id`bigintNOTNULL AUTO_INCREMENT COMMENT'角色ID',
`role_name`varchar(50) NOTNULLCOMMENT'角色名称(如:ROLE_USER/ROLE_ADMIN)',
`role_desc`varchar(100) DEFAULTNULLCOMMENT'角色描述',
  PRIMARY KEY (`id`),
UNIQUEKEY`uk_role_name` (`role_name`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='角色表';

-- 用户角色关联表
CREATETABLE`sys_user_role` (
`id`bigintNOTNULL AUTO_INCREMENT COMMENT'关联ID',
`user_id`bigintNOTNULLCOMMENT'用户ID',
`role_id`bigintNOTNULLCOMMENT'角色ID',
  PRIMARY KEY (`id`),
UNIQUEKEY`uk_user_role` (`user_id`,`role_id`)
) ENGINE=InnoDBDEFAULTCHARSET=utf8mb4 COMMENT='用户角色关联表';

-- 初始化数据
INSERTINTO`sys_role` (`role_name`, `role_desc`) VALUES ('ROLE_USER', '普通用户'), ('ROLE_ADMIN', '管理员');
-- 密码:123456(BCrypt加密)
INSERTINTO`sys_user` (`username`, `password`, `nickname`, `phone`) VALUES ('jam_user', '$2a$10$EixZaYb4xU58Gpq1R0yWbeb00LU5qUaK6x8h8y08Qv10hP7w2u7aK', '果酱用户', '13800138000');
INSERTINTO`sys_user_role` (`user_id`, `role_id`) VALUES (1, 1);
4.3.2 项目结构(Maven)
代码语言:javascript
复制
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)
代码语言:javascript
复制
<?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认证中心的跨域问题,允许各业务系统(如商品系统、订单系统)的跨域请求:

代码语言:javascript
复制
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
publicclass 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);
        returnnew CorsFilter(source);
    }
}
(2)JWT配置(JwtConfig.java)

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

代码语言:javascript
复制
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
publicclass JwtConfig {

    /**
     * JWT密钥(生产环境需放在配置中心,加密存储)
     */
    @Value("${jwt.secret:jam-sso-secret-key-2025-encrypt-123456}")
    private String secret;

    /**
     * JWT过期时间(单位:秒),电商场景建议2小时(7200秒)
     */
    @Value("${jwt.expire:7200}")
    privatelong 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相关接口,适配自定义认证流程:

代码语言:javascript
复制
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
publicclass 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() {
        returnnew BCryptPasswordEncoder();
    }
}
4.3.5 核心工具类与实体类
(1)JWT工具类(JwtUtil.java)

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

代码语言:javascript
复制
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
publicclass JwtUtil {

    privatefinal 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);
            returntrue;
        } catch (Exception e) {
            log.error("JWT令牌验证失败,token:{},错误信息:{}", token, e.getMessage());
            returnfalse;
        }
    }

    /**
     * 解析JWT令牌,获取负载信息
     * @param token JWT令牌
     * @return Claims 负载信息
     */
    public Claims parseClaims(String token) {
        if (ObjectUtils.isEmpty(token)) {
            log.error("JWT令牌为空,无法解析");
            thrownew IllegalArgumentException("JWT令牌不能为空");
        }
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(getSecretKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            log.error("JWT令牌解析失败,token:{},错误信息:{}", token, e.getMessage());
            thrownew 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)
代码语言:javascript
复制
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")
publicclass 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)
代码语言:javascript
复制
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")
publicclass 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)
代码语言:javascript
复制
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
publicinterface 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)
代码语言:javascript
复制
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
publicclass SysUserService extends ServiceImpl<SysUserMapper, SysUser> {

    privatefinal SysUserMapper sysUserMapper;
    privatefinal PasswordEncoder passwordEncoder;

    /**
     * 根据用户名查询用户(含角色信息)
     * @param username 用户名
     * @return SysUser
     */
    public SysUser getUserByUsername(String username) {
        if (ObjectUtils.isEmpty(username)) {
            log.error("查询用户失败:用户名为空");
            thrownew 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);
            returnnull;
        }
        // 2. 验证密码(BCrypt加密匹配)
        if (!passwordEncoder.matches(password, user.getPassword())) {
            log.error("验证失败:密码错误,username:{}", username);
            returnnull;
        }
        // 3. 验证用户状态
        if (user.getStatus() != 1) {
            log.error("验证失败:用户已禁用,username:{}", username);
            returnnull;
        }
        return user;
    }

    /**
     * 根据用户ID查询角色列表
     * @param userId 用户ID
     * @return 角色名称列表
     */
    public List<String> getRolesByUserId(Long userId) {
        if (ObjectUtils.isEmpty(userId)) {
            log.error("查询角色失败:用户ID为空");
            thrownew IllegalArgumentException("用户ID为空");
        }
        return sysUserMapper.selectRolesByUserId(userId);
    }
}
4.3.7 控制层(SSO接口)

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

代码语言:javascript
复制
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 = "电商平台单点登录核心接口")
publicclass SsoController {

    privatefinal SysUserService sysUserService;
    privatefinal 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)
代码语言:javascript
复制
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")
publicclass SsoServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(SsoServerApplication.class, args);
    }
}
4.3.9 配置文件(application.yml)
代码语言:javascript
复制
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)
代码语言:javascript
复制
com.jam.demo.sso.client.goods
├── config/          # 配置类(拦截器、跨域等)
├── controller/      # 接口层(商品查询、首页等)
├── util/            # 工具类(HTTP请求工具等)
├── GoodsClientApplication.java  # 启动类
└── pom.xml          # 依赖配置

4.4.2 依赖配置(pom.xml)

代码语言:javascript
复制
<?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认证中心的跨域交互:

代码语言:javascript
复制
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
publicclass 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登录拦截器,拦截商品系统的所有请求,校验登录状态:

代码语言:javascript
复制
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
publicclass WebMvcConfig implements WebMvcConfigurer {

    privatefinal 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认证中心的令牌验证接口:

代码语言:javascript
复制
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
publicclass RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        returnnew RestTemplate();
    }
}

4.4.4 核心工具类

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

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

代码语言:javascript
复制
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
publicclass HttpUtil {

    privatefinal 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为空");
            thrownew 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());
            thrownew 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为空");
            thrownew 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());
            thrownew RuntimeException("HTTP POST请求失败", e);
        }
    }
}
(2)SSO客户端工具类(SsoClientUtil.java)

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

代码语言:javascript
复制
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
publicclass SsoClientUtil {

    /**
     * SSO认证中心地址
     */
    @Value("${sso.server.url:http://localhost:8080}")
    private String ssoServerUrl;

    /**
     * 当前客户端(商品系统)地址
     */
    @Value("${sso.client.url:http://localhost:8081}")
    private String ssoClientUrl;

    privatefinal 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("令牌验证失败:令牌为空");
            returnfalse;
        }

        // 调用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());
            returnfalse;
        }
    }

    /**
     * 获取当前请求的完整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;
        }
        returnnull;
    }
}

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

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

代码语言:javascript
复制
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
publicclass SsoLoginInterceptor implements HandlerInterceptor {

    privatefinal 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());
            returntrue;
        }

        // 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());
                returntrue;
            } else {
                log.error("令牌无效,重定向到SSO登录页:{}", request.getRequestURI());
            }
        }

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

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

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

代码语言:javascript
复制
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 = "电商商品系统核心接口")
publicclass 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)

代码语言:javascript
复制
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)

代码语言:javascript
复制
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的重复配置。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-12-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 果酱带你啃java 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 在电商平台架构中,随着业务扩张,往往会拆分出用户中心、商品管理、订单系统、支付中心等多个独立服务。用户若在每个系统都重复登录,体验会极差;同时,各系统间的身份认证一致性、数据安全性也面临挑战。SSO(Single Sign-On,单点登录)技术应运而生,它能让用户在多个关联系统中仅需登录一次,即可无缝访问所有授权服务。而跨域问题,作为分布式系统的“拦路虎”,是SSO实现过程中必须攻克的核心难点。
    • 1.1 SSO的核心定义与价值
    • 1.2 SSO与普通登录的核心区别
    • 1.3 电商SSO的核心组成角色
    • 二、底层逻辑拆解:SSO单点登录的核心流程
      • 2.1 核心流程图
      • 2.2 关键环节拆解
      • 2.3 跨域问题根源:浏览器同源策略
    • 三、跨域解决方案选型:电商场景最优解
      • 3.1 主流跨域方案对比
      • 3.2 电商场景最优解:CORS+网关代理
    • 四、实战落地:电商SSO单点跨域完整方案
      • 4.1 技术栈选型(最新稳定版本)
      • 4.2 系统架构图
      • 4.3 第一步:搭建SSO认证中心(用户中心)
      • 4.4 第二步:搭建商品系统(SSO Client)
      • 4.4.2 依赖配置(pom.xml)
      • 4.4.3 核心配置类
      • 4.4.4 核心工具类
      • 4.4.5 SSO登录拦截器(SsoLoginInterceptor.java)
      • 4.4.6 控制层(商品系统接口)
      • 4.4.7 启动类(GoodsClientApplication.java)
      • 4.4.8 配置文件(application.yml)
      • 4.5 测试验证(完整SSO跨域流程)
    • 五、总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档