Java SSO 统一认证方案

一、方案选型

表格

方案 协议 适用场景 复杂度
Apereo CAS CAS 协议 传统企业内网多系统 高(需独立部署 CAS Server)
Spring Authorization Server OAuth2 + OIDC 微服务 / 前后端分离 / 云原生
自研 JWT 共享认证 自定义 简单内网系统
Keycloak OAuth2/OIDC/SAML 中大型企业,需要身份管理 中(开箱即用)

推荐方案:Spring Authorization Server + Spring Security OAuth2 Client + JWT

理由:Spring 官方维护,与 Spring Boot/Cloud 生态无缝集成,支持 OAuth2 + OIDC 标准协议,可同时覆盖 Web 系统、移动端、API 网关等多种接入方式。


二、整体架构

plaintext

复制代码
┌──────────┐     1.未登录跳转      ┌──────────────────────┐
│  浏览器   │ ───────────────────> │  业务系统 A (Client)  │
│          │ <─────────────────── │                      │
└──────────┘     3.带code回调      └──────────┬───────────┘
     │                                       │
     │ 2.重定向到认证中心                     │ 4. code换token
     ▼                                       ▼
┌──────────────────────┐          ┌──────────────────────┐
│  SSO 认证中心         │          │   Redis (Token存储)   │
│  (Authorization       │◄────────►│   Session共享         │
│   Server)             │          └──────────────────────┘
│  - 用户认证            │
│  - Token签发           │          ┌──────────────────────┐
│  - 客户端管理          │◄────────►│   MySQL (用户/权限)    │
└──────────────────────┘          └──────────────────────┘
     ▲
     │ 5.校验Token
┌──────────┴───────────┐
│  业务系统 B (Client)  │
└──────────────────────┘

核心流程(Authorization Code 模式):

  1. 用户访问业务系统 A,未登录 → 重定向到 SSO 认证中心
  2. 用户在认证中心登录(首次),认证中心签发 SSO Session
  3. 认证中心重定向回业务系统 A,附带 authorization code
  4. 业务系统 A 用 code 换取 access_token + refresh_token
  5. 用户访问业务系统 B → 重定向到认证中心 → 检测到 SSO Session 已存在 → 直接回调签发 code → 无感登录

三、认证中心实现(Authorization Server)

3.1 依赖配置(pom.xml)

xml

复制代码
<dependencies>
    <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-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- Spring Authorization Server -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-oauth2-authorization-server</artifactId>
        <version>1.3.0</version>
    </dependency>
    <dependency>
        <groupId>com.nimbusds</groupId>
        <artifactId>nimbus-jose-jwt</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>

3.2 安全配置

java

运行

复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults()); // 启用 OIDC
        http.exceptionHandling(e -> e
            .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(form -> form.loginPage("/login").permitAll())
            .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**"));
        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return username -> {
            SysUser user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
            return new org.springframework.security.core.userdetails.User(
                user.getUsername(),
                user.getPassword(),
                user.getEnabled(),
                true, true, true,
                AuthorityUtils.createAuthorityList(
                    user.getRoles().stream()
                        .map(r -> "ROLE_" + r.getCode())
                        .toArray(String[]::new)
                )
            );
        };
    }

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

3.3 客户端与 Token 配置

java

运行

复制代码
@Configuration
public class AuthorizationServerConfig {

    @Bean
    public RegisteredClientRepository registeredClientRepository(
            JdbcTemplate jdbcTemplate) {
        // 生产环境从数据库加载客户端配置
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }

    @Bean
    public OAuth2AuthorizationService authorizationService(
            JdbcTemplate jdbcTemplate,
            RegisteredClientRepository clientRepository) {
        // 使用 Redis 存储 Token,支持分布式部署
        return new RedisOAuth2AuthorizationService(redisTemplate, clientRepository);
    }

    @Bean
    public OAuth2AuthorizationConsentService consentService(JdbcTemplate jdbcTemplate,
            RegisteredClientRepository clientRepository) {
        return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clientRepository);
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() throws Exception {
        RSAKey rsaKey = JwksGenerator.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, context) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("https://sso.example.com")
            .authorizationEndpoint("/oauth2/authorize")
            .tokenEndpoint("/oauth2/token")
            .jwkSetEndpoint("/oauth2/jwks")
            .oidcUserInfoEndpoint("/userinfo")
            .build();
    }

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
        return context -> {
            if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
                Authentication principal = context.getPrincipal();
                Set<String> authorities = principal.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toSet());
                context.getClaims().claim("authorities", authorities);
                context.getClaims().claim("user_id", 
                    ((UserDetails) principal.getPrincipal()).getUsername());
            }
        };
    }
}

3.4 RSA 密钥对生成器

java

运行

复制代码
public final class JwksGenerator {
    public static RSAKey generateRsa() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
            .privateKey(privateKey)
            .keyID(UUID.randomUUID().toString())
            .build();
    }

    private static KeyPair generateRsaKey() {
        try {
            KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
            generator.initialize(2048);
            return generator.generateKeyPair();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

3.5 登录控制器

java

运行

复制代码
@Controller
public class LoginController {

    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

3.6 数据库初始化脚本

sql

复制代码
-- 客户端注册信息表(Spring Authorization Server 标准表)
CREATE TABLE oauth2_registered_client (
    id varchar(100) NOT NULL,
    client_id varchar(100) NOT NULL,
    client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
    client_secret varchar(200) DEFAULT NULL,
    client_secret_expires_at timestamp DEFAULT NULL,
    client_name varchar(200) NOT NULL,
    client_authentication_methods varchar(1000) NOT NULL,
    authorization_grant_types varchar(1000) NOT NULL,
    redirect_uris varchar(1000) DEFAULT NULL,
    post_logout_redirect_uris varchar(1000) DEFAULT NULL,
    scopes varchar(1000) NOT NULL,
    client_settings varchar(2000) NOT NULL,
    token_settings varchar(2000) NOT NULL,
    PRIMARY KEY (id)
);

-- 用户表
CREATE TABLE sys_user (
    id bigint PRIMARY KEY AUTO_INCREMENT,
    username varchar(50) NOT NULL UNIQUE,
    password varchar(200) NOT NULL,
    real_name varchar(50),
    email varchar(100),
    phone varchar(20),
    enabled tinyint DEFAULT 1,
    created_at timestamp DEFAULT CURRENT_TIMESTAMP,
    updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 角色表
CREATE TABLE sys_role (
    id bigint PRIMARY KEY AUTO_INCREMENT,
    code varchar(50) NOT NULL UNIQUE,
    name varchar(50) NOT NULL
);

-- 用户角色关联
CREATE TABLE sys_user_role (
    user_id bigint NOT NULL,
    role_id bigint NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

-- 注册一个业务系统客户端(示例)
INSERT INTO oauth2_registered_client 
(id, client_id, client_secret, client_name, 
 client_authentication_methods, authorization_grant_types, 
 redirect_uris, scopes, client_settings, token_settings)
VALUES (
    'client-app-a',
    'app-a',
    '{bcrypt}$2a$10$...', -- 加密后的密钥
    '业务系统A',
    'client_secret_basic',
    'authorization_code,refresh_token',
    'http://app-a.example.com/login/oauth2/code/sso',
    'openid,profile,roles',
    '{"@class":"java.util.Collections$UnmodifiableMap","settings.client.require-proof-key":false,"settings.client.require-authorization-consent":false}',
    '{"@class":"java.util.Collections$UnmodifiableMap","settings.token.authorization-code-time-to-live":300,"settings.token.access-token-time-to-live":3600,"settings.token.access-token-format":"self-contained","settings.token.refresh-token-time-to-live":2592000,"settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":"RS256"}'
);

四、业务系统客户端实现

4.1 依赖配置

xml

复制代码
<dependencies>
    <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-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
</dependencies>

4.2 application.yml

yaml

复制代码
spring:
  security:
    oauth2:
      client:
        registration:
          sso:
            client-id: app-a
            client-secret: your-client-secret
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: openid, profile, roles
        provider:
          sso:
            issuer-uri: https://sso.example.com
            user-name-attribute: sub
      resourceserver:
        jwt:
          issuer-uri: https://sso.example.com

4.3 客户端安全配置

java

运行

复制代码
@Configuration
@EnableWebSecurity
public class ClientSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login**").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/oauth2/authorization/sso")
                .defaultSuccessUrl("/dashboard", true)
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(userAuthoritiesMapper())))
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .logout(logout -> logout
                .logoutSuccessUrl("https://sso.example.com/connect/logout?post_logout_redirect_uri=http://app-a.example.com&client_id=app-a"));
        return http.build();
    }

    @Bean
    public GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return authorities -> {
            Set<GrantedAuthority> mapped = new HashSet<>();
            authorities.forEach(auth -> {
                if (auth instanceof OidcUserAuthority oidcAuth) {
                    OidcUserInfo userInfo = oidcAuth.getUserInfo();
                    List<String> roles = userInfo.getClaimAsStringList("authorities");
                    if (roles != null) {
                        roles.forEach(role -> mapped.add(new SimpleGrantedAuthority(role)));
                    }
                }
                mapped.add(auth);
            });
            return mapped;
        };
    }
}

4.4 获取当前登录用户信息

java

运行

复制代码
@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/me")
    public Map<String, Object> currentUser(
            @AuthenticationPrincipal OidcUser principal) {
        Map<String, Object> result = new HashMap<>();
        result.put("username", principal.getClaim("user_id"));
        result.put("name", principal.getFullName());
        result.put("email", principal.getEmail());
        result.put("authorities", principal.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList());
        return result;
    }
}

五、网关层统一鉴权(微服务场景)

如果是 Spring Cloud 微服务架构,在 Gateway 层做统一 Token 校验:

java

运行

复制代码
@Configuration
public class GatewaySecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http.csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(exchange -> exchange
                .pathMatchers("/auth/**", "/public/**").permitAll()
                .anyExchange().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

yaml

复制代码
# Gateway application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://sso.example.com
  cloud:
    gateway:
      routes:
        - id: service-a
          uri: lb://service-a
          predicates:
            - Path=/api/a/**
          filters:
            - StripPrefix=2

六、单点登出(SLO)

6.1 认证中心登出端点

java

运行

复制代码
@RestController
public class LogoutController {

    @GetMapping("/connect/logout")
    public void logout(HttpServletRequest request, 
                       HttpServletResponse response,
                       @RequestParam("post_logout_redirect_uri") String redirectUri) 
                       throws IOException {
        // 清除认证中心 Session
        SecurityContextLogoutHandler handler = new SecurityContextLogoutHandler();
        handler.logout(request, response, 
            SecurityContextHolder.getContext().getAuthentication());
        // 通知所有已注册客户端登出(前端通过 iframe 轮询或后端回调)
        response.sendRedirect(redirectUri);
    }
}

6.2 前端单点登出方案(iframe 通知)

在认证中心登出页面嵌入所有客户端的登出端点:

html

预览

复制代码
<!-- 认证中心登出页面 -->
<iframe src="http://app-a.example.com/logout" style="display:none"></iframe>
<iframe src="http://app-b.example.com/logout" style="display:none"></iframe>
<script>
    setTimeout(() => { window.location.href = '/login'; }, 1000);
</script>

七、部署步骤

7.1 环境准备

bash

复制代码
# 1. 安装 MySQL 8.0 + Redis 6+
# 2. 创建数据库
mysql -u root -p -e "CREATE DATABASE sso_auth DEFAULT CHARACTER SET utf8mb4;"

# 3. 执行初始化脚本
mysql -u root -p sso_auth < schema.sql

7.2 认证中心部署

bash

复制代码
# 打包
mvn clean package -DskipTests

# 运行(生产环境使用 Docker/K8s)
java -jar sso-auth-server.jar \
  --spring.datasource.url=jdbc:mysql://mysql:3306/sso_auth \
  --spring.datasource.username=root \
  --spring.datasource.password=xxx \
  --spring.redis.host=redis \
  --server.port=9000

7.3 Nginx 反向代理配置

nginx

复制代码
upstream sso_server {
    server 127.0.0.1:9000;
}

server {
    listen 443 ssl;
    server_name sso.example.com;

    ssl_certificate     /etc/nginx/ssl/sso.crt;
    ssl_certificate_key /etc/nginx/ssl/sso.key;

    location / {
        proxy_pass http://sso_server;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=Lax";
    }
}

7.4 Docker Compose 一键部署

yaml

复制代码
version: '3.8'
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_DATABASE: sso_auth
    volumes:
      - mysql_data:/var/lib/mysql
      - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
    ports:
      - "3306:3306"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  sso-server:
    build: ./sso-auth-server
    depends_on:
      - mysql
      - redis
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/sso_auth
      SPRING_REDIS_HOST: redis
    ports:
      - "9000:9000"

volumes:
  mysql_data:

八、安全加固清单

表格

项目 措施
传输安全 全站 HTTPS,HSTS 启用
Token 安全 access_token 有效期 ≤ 2h,refresh_token 支持轮换
密码存储 BCrypt 加密,强度 ≥ 10
防重放 authorization_code 一次性使用,有效期 ≤ 5min
CSRF 表单登录启用 CSRF Token
密钥管理 RSA 私钥不硬编码,使用 KMS/Vault 管理
审计日志 记录登录 / 登出 / Token 签发 / 失败尝试
限流 登录接口限流(如 5 次 / 分钟 / IP)
Cookie HttpOnly + Secure + SameSite=Lax
会话固定 登录后重新生成 Session ID

九、关键注意事项

  1. 时钟同步:所有参与 SSO 的服务器必须 NTP 时间同步,否则 JWT 过期校验会异常
  2. 跨域配置:认证中心与各业务系统域名不同时,需正确配置 CORS
  3. Session 共享:认证中心集群部署时,Session 必须存 Redis 或使用无状态 JWT
  4. 客户端密钥轮换:定期更换 client_secret,支持双密钥过渡
  5. 兼容老系统:不支持 OAuth2 的老系统可通过 CAS 协议适配层或反向代理注入 Header 实现接入
相关推荐
START_GAME1 小时前
MSSQL$SQL2016
java·服务器·前端
ydd1001001 小时前
字符串转换整数
java
会编程的吕洞宾2 小时前
DeepAgents In Action学习(Second)
android·java·学习
梦梦代码精2 小时前
基于UniApp+Vue3+ThinkPHP 8,这套知识付费系统的架构设计有点东西
java·低代码·docker·uni-app·开源·php
Code额2 小时前
Python 连接 DeepSeek API,OpenAI
开发语言·python·ai·ai编程
-凌凌漆-2 小时前
【freertos】Task创建(v2)
java·开发语言·算法
牛油果子哥q2 小时前
C++内存模型与深浅拷贝万字详解:栈堆静态内存布局、深浅拷贝底层差异、内存泄漏根治、拷贝崩溃踩坑、手写深拷贝实战
java·开发语言·c++
曹牧3 小时前
C#:函数参数指定默认值
开发语言·c#
AAA@峥3 小时前
Python 基础开篇:认识 Python、版本选择、环境部署与首个 HelloWorld
开发语言·python