乐途乐途
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
主页
  • 计算机基础

    • TCP/IP
    • Linux
    • HTTP
  • 数据库

    • SQL
    • MySQL 5.7
  • 编程语言

    • C
    • C++
    • Java SE
    • Python2
    • Python3
  • 数据格式

    • JSON
    • XML
  • 认证与安全

    • JWT
  • 工具

    • Markdown
  • Git

    • GitFlow
  • Quartz

    • Quartz
  • Java

    • Maven 入门
    • Maven 进阶
    • MyBatis
    • Spring
    • Spring MVC
  • Java

    • Spring Boot
    • Spring Cloud
    • Spring Cloud Alibaba
    • Spring Security
    • Spring AI
    • Spring Batch
    • Kafka
    • Java 设计模式
  • 缓存

    • Redis
  • 搜索引擎

    • Elasticsearch
  • 分布式协调

    • ZooKeeper
联系
阿里云
  • 学习路径
  • 第1章 Spring Security 基础

    • 本章定位
    • Spring Security 是什么
    • DelegatingFilterProxy
    • 安全过滤器链
    • SecurityFilterChain
    • 过滤器执行顺序
  • 第2章 认证

    • 本章定位
    • Authentication
    • 认证流程
    • AuthenticationManager
    • ProviderManager
    • DaoAuthenticationProvider
    • UserDetails
    • UserDetailsService
    • PasswordEncoder
    • BCryptPasswordEncoder
    • DelegatingPasswordEncoder
    • 表单登录
    • SecurityContext
    • SecurityContextHolder
    • UsernamePasswordAuthenticationToken
  • 第3章 授权

    • 本章定位
    • 授权模型
    • GrantedAuthority
    • AccessDecisionManager
    • AccessDecisionVoter
    • URL 级别授权
    • 方法级别安全
    • @PreAuthorize
    • @PostAuthorize
    • @PreFilter
    • @PostFilter
    • @Secured
    • RoleHierarchy
  • 第4章 过滤器链

    • 本章定位
    • FilterChainProxy
    • SecurityContextHolderFilter
    • LogoutFilter
    • BasicAuthenticationFilter
    • CsrfFilter
    • CorsFilter
    • HeaderWriterFilter
    • AnonymousAuthenticationFilter
    • RequestCacheAwareFilter
    • ExceptionTranslationFilter
    • FilterSecurityInterceptor
  • 第5章 会话管理

    • 本章定位
    • 会话管理
    • SessionFixation
    • 会话并发控制
    • SessionCreationPolicy
    • RememberMe
  • 第6章 JWT

    • 本章定位
    • JWT
    • JwtDecoder
    • JWT 认证
    • JwtAuthenticationConverter
  • 第7章 OAuth2

    • 本章定位
    • OAuth2 基础
    • OAuth2 Client
    • OAuth2 Resource Server
    • 第三方登录配置
  • 第8章 攻击防护

    • 本章定位
    • CSRF 跨站请求伪造防护
    • CORS 跨域防护
    • Clickjacking 点击劫持防护
    • 安全响应头
    • Session Fixation 会话固定防护
  • 第9章 测试

    • 本章定位
    • 安全测试
    • @WithMockUser
    • 最佳实践

第三方登录配置

定义与作用

第三方登录配置 是指在 Spring Security 5.x 中,通过 OAuth2 Client 模块接入外部身份提供商(如 GitHub、Google、Gitee)后,对获取到的第三方用户信息进行本地化处理、账号映射、权限分配和持久化的完整工程实践。它不仅仅是"登录成功",而是涵盖了从首次登录自动创建本地账号、到已有本地账号绑定多个社交账号、再到不同平台用户属性差异适配的全链路配置。

对比自建用户体系的痛点

痛点仅有 OAuth2 Client 基础配置完整第三方登录配置
用户信息孤岛每次登录只拿到临时的 OAuth2User,退出后无法识别用户历史数据首次登录将第三方用户信息写入本地数据库,生成稳定的 user_id,后续登录直接关联
多平台身份不统一同一用户用 GitHub 登录和 Google 登录被视为两个不同用户通过邮箱或手机号匹配,将多个社交账号绑定到同一本地用户
权限管理缺失OAuth2User 只携带 ROLE_USER,无法满足 RBAC 需求自定义 CustomOAuth2UserService 在登录时从本地数据库加载角色权限,合并到 Authentication
用户属性差异GitHub 返回 login 字段,Google 返回 name 字段,前端需写大量 if-else统一适配层将不同平台属性映射到本地 UserProfile 对象,前端只认一套字段
账号解绑困难社交账号与本地用户无关联记录,无法提供"解绑 GitHub"功能本地数据库维护 user_social_accounts 表,支持绑定、解绑、切换主账号

Spring Security 5.x 通过 OAuth2UserService 接口提供扩展点,允许开发者自定义 CustomOAuth2UserService 继承 DefaultOAuth2UserService,在 loadUser() 方法中执行用户信息持久化、账号绑定、权限映射等自定义逻辑。


核心原理

第三方登录完整集成流程

CustomOAuth2UserService 在过滤器链中的位置

CustomOAuth2UserService 的核心职责是:桥接外部 OAuth2 身份与内部用户体系。它接收 OAuth2UserRequest(包含客户端注册信息和 Access Token),返回 OAuth2User(包含处理后的用户属性)。开发者在此方法中决定:这个社交账号是创建新用户、还是绑定到已有用户、还是拒绝登录。


完整示例

示例一:首次登录自动注册(本地数据库创建用户)

场景说明:一个技术博客平台允许读者使用 GitHub 登录。对于首次登录的用户,系统自动创建本地账号并拉取 GitHub 头像和用户名作为博客平台的初始资料。

操作前配置(数据库表结构):

-- 本地用户表
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL,
    email VARCHAR(128),
    avatar_url VARCHAR(512),
    enabled BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 社交账号绑定表
CREATE TABLE user_social_accounts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    provider VARCHAR(32) NOT NULL, -- github, google, gitee
    provider_user_id VARCHAR(64) NOT NULL,
    access_token VARCHAR(256),
    refresh_token VARCHAR(256),
    token_expires_at TIMESTAMP,
    last_login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_provider_uid (provider, provider_user_id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- 用户角色表
CREATE TABLE user_roles (
    user_id BIGINT NOT NULL,
    role VARCHAR(32) NOT NULL,
    PRIMARY KEY (user_id, role)
);

操作后配置(CustomOAuth2UserService 实现):

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final UserSocialAccountRepository socialAccountRepository;
    private final UserRoleRepository userRoleRepository;

    public CustomOAuth2UserService(UserRepository userRepository,
                                   UserSocialAccountRepository socialAccountRepository,
                                   UserRoleRepository userRoleRepository) {
        this.userRepository = userRepository;
        this.socialAccountRepository = socialAccountRepository;
        this.userRoleRepository = userRoleRepository;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 1. 调用父类获取原始 OAuth2User
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // 2. 提取平台标识和用户属性
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String providerUserId = extractProviderUserId(registrationId, attributes);
        String email = extractEmail(registrationId, attributes);
        String username = extractUsername(registrationId, attributes);
        String avatarUrl = extractAvatarUrl(registrationId, attributes);

        // 3. 查询本地是否已存在该社交账号绑定
        UserSocialAccount socialAccount = socialAccountRepository
            .findByProviderAndProviderUserId(registrationId, providerUserId)
            .orElse(null);

        UserEntity user;
        if (socialAccount == null) {
            // 4. 首次登录:创建本地用户 + 社交绑定
            user = new UserEntity();
            user.setUsername(username);
            user.setEmail(email);
            user.setAvatarUrl(avatarUrl);
            user.setEnabled(true);
            userRepository.save(user);

            socialAccount = new UserSocialAccount();
            socialAccount.setUserId(user.getId());
            socialAccount.setProvider(registrationId);
            socialAccount.setProviderUserId(providerUserId);
            socialAccount.setAccessToken(userRequest.getAccessToken().getTokenValue());
            socialAccount.setLastLoginAt(LocalDateTime.now());
            socialAccountRepository.save(socialAccount);

            // 分配默认角色
            userRoleRepository.save(new UserRoleEntity(user.getId(), "ROLE_USER"));
        } else {
            // 5. 已有账号:更新登录时间和 Token
            user = userRepository.findById(socialAccount.getUserId())
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
            socialAccount.setAccessToken(userRequest.getAccessToken().getTokenValue());
            socialAccount.setLastLoginAt(LocalDateTime.now());
            socialAccountRepository.save(socialAccount);
        }

        // 6. 查询用户所有角色
        List<String> roles = userRoleRepository.findRolesByUserId(user.getId());
        List<GrantedAuthority> authorities = roles.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

        // 7. 构造自定义 OAuth2User,包含本地 user_id 和角色
        return new BlogOAuth2User(oAuth2User, user.getId(), authorities, attributes);
    }

    // 不同平台提取字段的适配方法
    private String extractProviderUserId(String provider, Map<String, Object> attributes) {
        if ("github".equals(provider)) {
            return String.valueOf(attributes.get("id"));
        } else if ("google".equals(provider)) {
            return String.valueOf(attributes.get("sub"));
        }
        return String.valueOf(attributes.get("id"));
    }

    private String extractEmail(String provider, Map<String, Object> attributes) {
        Object email = attributes.get("email");
        if (email != null) return (String) email;
        // GitHub 可能不公开邮箱,需要单独处理
        if ("github".equals(provider)) {
            return (String) attributes.getOrDefault("email", username + "@github.local");
        }
        return null;
    }

    private String extractUsername(String provider, Map<String, Object> attributes) {
        if ("github".equals(provider)) {
            return (String) attributes.get("login");
        } else if ("google".equals(provider)) {
            return (String) attributes.get("name");
        }
        return (String) attributes.get("name");
    }

    private String extractAvatarUrl(String provider, Map<String, Object> attributes) {
        if ("github".equals(provider)) {
            return (String) attributes.get("avatar_url");
        } else if ("google".equals(provider)) {
            return (String) attributes.get("picture");
        }
        return null;
    }
}

// 自定义 OAuth2User 实现,携带本地 user_id
public class BlogOAuth2User implements OAuth2User {
    private final OAuth2User delegate;
    private final Long localUserId;
    private final Collection<? extends GrantedAuthority> authorities;
    private final Map<String, Object> attributes;

    public BlogOAuth2User(OAuth2User delegate, Long localUserId,
                          Collection<? extends GrantedAuthority> authorities,
                          Map<String, Object> attributes) {
        this.delegate = delegate;
        this.localUserId = localUserId;
        this.authorities = authorities;
        this.attributes = new HashMap<>(attributes);
        this.attributes.put("localUserId", localUserId);
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getName() {
        return String.valueOf(localUserId);
    }

    public Long getLocalUserId() {
        return localUserId;
    }
}

操作后配置(Spring Security 5.x 配置类):

@Configuration
@EnableWebSecurity
public class SocialLoginConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2UserService customOAuth2UserService;

    public SocialLoginConfig(CustomOAuth2UserService customOAuth2UserService) {
        this.customOAuth2UserService = customOAuth2UserService;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests(authz -> authz
                .antMatchers("/", "/login", "/oauth2/**", "/css/**", "/js/**").permitAll()
                .antMatchers("/reader/**").hasAnyRole("USER", "ADMIN")
                .antMatchers("/author/**").hasAnyRole("AUTHOR", "ADMIN")
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .loginPage("/login")
                .defaultSuccessUrl("/reader/home", true)
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(customOAuth2UserService) // 注入自定义 UserService
                )
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
            );
    }
}

结果分析:

  1. 用户首次用 GitHub 登录时,CustomOAuth2UserService 在 user_social_accounts 表中查不到 (github, 12345678) 记录
  2. 在 users 表创建新记录,username 为 GitHub login 字段(如 zhangsan),avatar 为 avatar_url
  3. 在 user_social_accounts 表创建绑定记录,保存 GitHub id 和 Access Token
  4. 分配 ROLE_USER,用户成功登录后只能访问 /reader/** 路径
  5. 用户再次用同一 GitHub 账号登录时,直接更新 last_login_at 和 access_token,无需重复创建账号
  6. 在 Controller 中可通过 @AuthenticationPrincipal BlogOAuth2User user 获取 localUserId,直接查询本地业务数据

示例二:已有本地账号绑定多个社交平台(通过邮箱匹配)

场景说明:某 SaaS 平台已经运营多年,拥有大量通过邮箱注册的本地用户。现在需要新增社交登录功能,要求如果社交账号的邮箱与本地账号邮箱一致,则自动绑定到已有账号,而不是创建新用户。

操作前配置(数据库中已有用户数据):

-- 已有本地用户表(邮箱是唯一标识)
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL,
    email VARCHAR(128) NOT NULL UNIQUE,
    password_hash VARCHAR(256), -- 社交登录用户可为 NULL
    avatar_url VARCHAR(512),
    enabled BOOLEAN DEFAULT TRUE
);

-- 已有数据
INSERT INTO users (id, username, email, password_hash) VALUES
(1, 'lisi', 'lisi@example.com', '$2a$10$...');

操作后配置(修改后的 CustomOAuth2UserService,增加邮箱匹配逻辑):

@Service
public class BindingOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final UserSocialAccountRepository socialAccountRepository;
    private final UserRoleRepository userRoleRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String providerUserId = extractProviderUserId(registrationId, attributes);
        String email = extractEmail(registrationId, attributes);
        String username = extractUsername(registrationId, attributes);
        String avatarUrl = extractAvatarUrl(registrationId, attributes);

        // 1. 先按 provider + provider_user_id 查找
        UserSocialAccount socialAccount = socialAccountRepository
            .findByProviderAndProviderUserId(registrationId, providerUserId)
            .orElse(null);

        UserEntity user;
        if (socialAccount != null) {
            // 2. 已绑定该社交平台:直接登录
            user = userRepository.findById(socialAccount.getUserId())
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
            socialAccount.setAccessToken(userRequest.getAccessToken().getTokenValue());
            socialAccount.setLastLoginAt(LocalDateTime.now());
            socialAccountRepository.save(socialAccount);
        } else {
            // 3. 未绑定该社交平台:尝试通过邮箱匹配已有本地账号
            Optional<UserEntity> existingUser = userRepository.findByEmail(email);
            if (existingUser.isPresent()) {
                user = existingUser.get();
                // 4. 为已有账号新增社交绑定(不创建新用户)
                socialAccount = new UserSocialAccount();
                socialAccount.setUserId(user.getId());
                socialAccount.setProvider(registrationId);
                socialAccount.setProviderUserId(providerUserId);
                socialAccount.setAccessToken(userRequest.getAccessToken().getTokenValue());
                socialAccount.setLastLoginAt(LocalDateTime.now());
                socialAccountRepository.save(socialAccount);
                
                // 可选:如果本地账号没有头像,用社交头像补充
                if (user.getAvatarUrl() == null && avatarUrl != null) {
                    user.setAvatarUrl(avatarUrl);
                    userRepository.save(user);
                }
            } else {
                // 5. 邮箱也匹配不到:创建新用户(同示例一)
                user = new UserEntity();
                user.setUsername(username);
                user.setEmail(email);
                user.setAvatarUrl(avatarUrl);
                user.setEnabled(true);
                userRepository.save(user);

                socialAccount = new UserSocialAccount();
                socialAccount.setUserId(user.getId());
                socialAccount.setProvider(registrationId);
                socialAccount.setProviderUserId(providerUserId);
                socialAccount.setAccessToken(userRequest.getAccessToken().getTokenValue());
                socialAccount.setLastLoginAt(LocalDateTime.now());
                socialAccountRepository.save(socialAccount);

                userRoleRepository.save(new UserRoleEntity(user.getId(), "ROLE_USER"));
            }
        }

        List<String> roles = userRoleRepository.findRolesByUserId(user.getId());
        List<GrantedAuthority> authorities = roles.stream()
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

        return new SaasOAuth2User(oAuth2User, user.getId(), authorities, attributes, email);
    }

    // extract 方法同示例一,省略...
}

// SaaS 场景自定义 OAuth2User,增加 email 字段
public class SaasOAuth2User implements OAuth2User {
    private final OAuth2User delegate;
    private final Long localUserId;
    private final Collection<? extends GrantedAuthority> authorities;
    private final Map<String, Object> attributes;
    private final String email;

    public SaasOAuth2User(OAuth2User delegate, Long localUserId,
                            Collection<? extends GrantedAuthority> authorities,
                            Map<String, Object> attributes, String email) {
        this.delegate = delegate;
        this.localUserId = localUserId;
        this.authorities = authorities;
        this.attributes = new HashMap<>(attributes);
        this.attributes.put("localUserId", localUserId);
        this.attributes.put("email", email);
        this.email = email;
    }

    @Override
    public Map<String, Object> getAttributes() { return attributes; }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
    @Override
    public String getName() { return String.valueOf(localUserId); }
    public Long getLocalUserId() { return localUserId; }
    public String getEmail() { return email; }
}

结果分析:

  1. 用户 lisi 原本通过邮箱 lisi@example.com 注册,密码登录使用 users 表中的 password_hash
  2. 用户首次用 Google 登录,Google 返回的 email 也是 lisi@example.com
  3. BindingOAuth2UserService 在 user_social_accounts 中找不到 (google, 109...) 记录,但 users 表中存在 email='lisi@example.com' 的记录
  4. 不为用户创建新账号,而是在 user_social_accounts 表新增一条 (user_id=1, provider=google, provider_user_id=109...) 绑定记录
  5. 用户后续既可以用邮箱密码登录,也可以用 Google 登录,两种方式的 localUserId 都是 1,看到的数据完全一致
  6. 如果用户后续再用 GitHub 登录,且 GitHub 邮箱也是 lisi@example.com,则继续绑定到同一 user_id=1,实现多平台统一身份

易错场景与面试考点

易错场景:不同平台用户属性字段差异导致 NullPointerException

不同 OAuth2 提供商返回的用户属性字段命名差异极大,如果代码中硬编码某一平台的字段名,当切换到另一平台时会导致空指针或数据异常。

错误代码:

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) {
    OAuth2User oAuth2User = super.loadUser(userRequest);
    Map<String, Object> attributes = oAuth2User.getAttributes();
    
    // 假设所有平台都有 "login" 字段——这是 GitHub 特有的!
    String username = (String) attributes.get("login"); // Google 返回的是 "name"
    String email = (String) attributes.get("email");    // GitHub 可能为 null
    
    // 如果用户隐藏了邮箱,此处会 NPE 或存入 null
    user.setEmail(email.toLowerCase()); // NullPointerException!
}

各平台关键字段差异对照:

字段语义GitHubGoogleGitee
唯一标识id(整数)sub(字符串)id(整数)
用户名loginnamelogin
头像avatar_urlpictureavatar_url
邮箱email(可能为 null)email(通常有)email(通常有)
个人主页html_urlprofile(需额外 scope)html_url

正确做法:使用平台适配器模式,通过 registrationId 分流处理,并为可能为空的字段提供默认值:

private String extractUsername(String provider, Map<String, Object> attributes) {
    String username = null;
    if ("github".equals(provider)) {
        username = (String) attributes.get("login");
    } else if ("google".equals(provider)) {
        username = (String) attributes.get("name");
    } else if ("gitee".equals(provider)) {
        username = (String) attributes.get("login");
    }
    return username != null ? username : "user_" + System.currentTimeMillis();
}

private String extractEmail(String provider, Map<String, Object> attributes) {
    String email = (String) attributes.get("email");
    if (email == null && "github".equals(provider)) {
        // GitHub 用户可能设置邮箱为隐私,需要额外请求 /user/emails 端点
        // 或在配置中申请 user:email scope 后从主端点获取
        email = (String) attributes.getOrDefault("email", "unknown@local");
    }
    return email != null ? email : "unknown@local";
}

面试考点:

  • 面试官常问:当用户已经登录(通过表单登录),然后点击"绑定 GitHub 账号"时,如何在 CustomOAuth2UserService 中识别当前已登录用户?
  • 答:在绑定流程(而非登录流程)中,不应使用 oauth2Login() 的默认处理,而是需要自定义 Controller 手动执行 OAuth2 授权请求。在 CustomOAuth2UserService 中,可以通过 SecurityContextHolder.getContext().getAuthentication() 获取当前已认证用户(如果 Session 有效),但此方式在 OAuth2 回调过程中可能因过滤器链执行顺序而失效。更可靠的方案是:在发起绑定请求前将当前 user_id 存入 HttpSession 或自定义 OAuth2AuthorizationRequestRepository 的 additionalParameters 中,回调时从中取出,明确执行"绑定"而非"登录"逻辑。

版本说明:本节基于 Spring Security 5.x 编写。5.x 使用 WebSecurityConfigurerAdapter 和 antMatchers 作为标准配置方式,通过 http.oauth2Login().userInfoEndpoint().userService() 注入自定义 OAuth2UserService。升级到 6.x 后需改用 SecurityFilterChain Bean 和 requestMatchers,但 OAuth2UserService 的接口和注入方式保持不变。

上一页
OAuth2 Resource Server