三方登录认证
-
OAuth 2.0 是一个授权框架,Spring OAuth2 封装了三方认证功能
- 作为三方服务器,实现了【不感知用户密码前提下,实现三方认证】
- 作为授权服务器,实现了【三方认证请求处理】(TODO)
-
四种OAuth模式
- 授权码模式【首选】:前端获得三方授权码 → 后端携带授权码请求三方登录 → 前后端用Redis通信
- 密码模式【老项目】:前端直接传密码 → 后端携带密码请求三方登录 → 前后端用Redis通信
- 服务器凭证模式【内部子系统】:本地登录,后端定时同步三方用户数据
- 简化模式【慎重使用】:全程前端实现
前端 后端 GitHub 1. GET /oauth2/authorization/github 2. 302重定向到GitHub登录页 3. 用户登录GitHub(输入密码) 认证失败 4. github回调后端接口 /login/oauth2/code/github?code=xxx 5. 用code + client_secret换token 6. 返回access_token 7. 用access_token获取用户信息 8. 返回用户信息 9. 返回JWT/session给前端 前端 后端 GitHub Github OAuth2 授权码流程图
前端 后端 Google 1. GET /oauth2/authorization/google 2. 302重定向到Google授权页面 3. 用户登录Google(输入密码) 认证失败 5. 回调到 /login/oauth2/code/google?code=xxx&state=yyy 6. 用code + client_secret换token(包含access_token和id_token) 7. 返回id_token(JWT封装,直接包含了用户信息) 8. 验证id_token签名 9. 返回id_token签名对应公钥 10. 根据公钥直接从id_token提取用户信息 11. 返回JWT/session给前端 前端 后端 Google Google OAuth2 授权码流程图
-
技术选择
- 标准 OAuth 2.0 服务商(GitHub、Google、Facebook)推荐使用此框架
- 非标准 OAuth 服务商(微信、QQ)使用OAuth 2.0属于过度设计,建议手动实现
yaml
# 标准 OAuth 2.0 服务商必须提供这些端点:
必需端点:
1. 授权端点 (Authorization Endpoint):
- 路径: 通常是 /oauth/authorize
- 方法: GET
- 标准参数:
* response_type: "code" (授权码模式)
* client_id: 客户端ID
* redirect_uri: 回调地址
* scope: 权限范围
* state: CSRF防护
2. 令牌端点 (Token Endpoint):
- 路径: 通常是 /oauth/token
- 方法: POST
- 标准参数:
* grant_type: "authorization_code" 或 "refresh_token"
* code: 授权码
* redirect_uri: 必须与授权时一致
* client_id, client_secret: 客户端凭证
3. 用户信息端点 (UserInfo Endpoint):
- 路径: 通常是 /oauth/userinfo 或 /userinfo
- 方法: GET
- 需要: Authorization: Bearer <access_token>
4. 发现端点 (Discovery Endpoint):
- 路径: /.well-known/oauth-authorization-server
- 返回: 所有端点和配置信息
5. JWK 端点 (JWKS Endpoint):
- 路径: /.well-known/jwks.json
- 返回: JWT 签名公钥
yaml
// GitHub (标准)
github: {
authorizationRequest: {
url: "https://github.com/login/oauth/authorize",
params: {
client_id: "YOUR_CLIENT_ID", // ✅ 标准
redirect_uri: "https://...", // ✅ 标准
scope: "user:email read:user", // ✅ 标准
state: "RANDOM_STRING", // ✅ 标准
response_type: "code" // ✅ 标准
}
},
tokenRequest: {
url: "https://github.com/login/oauth/access_token",
headers: {
"Accept": "application/json", // ✅ 标准
"Content-Type": "application/x-www-form-urlencoded"
},
body: {
client_id: "YOUR_CLIENT_ID", // ✅ 标准
client_secret: "YOUR_SECRET", // ✅ 标准
code: "AUTHORIZATION_CODE", // ✅ 标准
redirect_uri: "https://...", // ✅ 标准
grant_type: "authorization_code" // ✅ 标准
}
}
}
// 微信 (非标准)
wechat: {
authorizationRequest: {
url: "https://open.weixin.qq.com/connect/oauth2/authorize",
params: {
appid: "YOUR_APPID", // ❌ 非标准(appid非client_id)
redirect_uri: "https://...",
response_type: "code",
scope: "snsapi_userinfo",
state: "RANDOM_STRING",
#wechat_redirect: "" // ❌ 非标准(URL fragment)
}
},
tokenRequest: {
url: "https://api.weixin.qq.com/sns/oauth2/access_token",
params: { // ❌ GET而非POST
appid: "YOUR_APPID", // ❌ 非标准
secret: "YOUR_SECRET", // ❌ 非标准
code: "AUTHORIZATION_CODE",
grant_type: "authorization_code"
}
}
}
- 引入依赖
xml
<!-- 建议配合Spring Security,也可以单独使用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
配置OAuth本地客户端
-
以Github登录为例,需要先去授权服务器注册本服务白名单
- 访问 GitHub → Settings → Developer settings → OAuth Apps
- 点击 "New OAuth App"
- 填写你的应用相关信息(应用名、IP、Authorization callback URL):生成 Client ID 和 Client Secret
java
@Configuration
public class OAuth2ClientConfig {
@Autowired
private Github github;
@Autowired
private Google google;
/**
* 创建客户端注册仓库
*/
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
List<ClientRegistration> registrations = new ArrayList<>();
// 添加 GitHub 配置
if (github.isEnabled()) {
registrations.add(githubClientRegistration());
}
// 添加其他平台配置...
if (google.isEnabled()) {
registrations.add(googleClientRegistration());
}
return new InMemoryClientRegistrationRepository(registrations);
}
/**
* GitHub 客户端注册
*/
private ClientRegistration githubClientRegistration() {
OAuth2Properties.Github github = properties.getGithub();
return ClientRegistration.withRegistrationId("github")
.clientId(github.getClientId())
.clientSecret(github.getClientSecret())
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationUri("https://github.com/login/oauth/authorize") // GitHub的授权页面URL(用户登录页)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") // GitHub授权成功后,会重定向到这个地址
.scope("user:email", "read:user") // 申请的权限范围
.tokenUri("https://github.com/login/oauth/access_token") // 授权码换access_token URL
.userInfoUri("https://api.github.com/user") // access_token查用户信息
.userNameAttributeName("id") // 用户信息中作为用户名的字段(GitHub返回的JSON中的"id"字段)
.clientName("GitHub")
.build();
}
/**
* Google 客户端注册
*/
private ClientRegistration googleClientRegistration() {
OAuth2Properties.Google google = properties.getGoogle();
return ClientRegistration.withRegistrationId("google")
// 基本配置
.clientId(google.getClientId()) // 从Google Cloud Console获取
.clientSecret(google.getClientSecret()) // 从Google Cloud Console获取
// 客户端认证方式(Google通常用BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
// 授权类型(标准授权码模式)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
// 重定向URI(Google授权成功后回调的地址)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
// 申请的权限范围(Google使用OpenID Connect)
.scope("openid", "profile", "email") // OpenID Connect标准scope
// Google OAuth2 端点配置
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") // 授权端点
.tokenUri("https://oauth2.googleapis.com/token") // 令牌端点
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs") // JWK端点(用于验证JWT)
// OpenID Connect 配置(可选)
.issuerUri("https://accounts.google.com") // 颁发者URI
// 用户信息端点配置
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo") // 用户信息端点
// 用户信息映射
.userNameAttributeName("sub") // OpenID Connect标准sub(Subject)字段
// 客户端名称(显示给用户的)
.clientName("Google")
.build();
}
@Component
@ConfigurationProperties(prefix = "oauth2") // Client ID 和 Client Secret 需要去三方授权服务器获取
@Data
public static class Github {
private boolean enabled = true;
private String clientId = "";
private String clientSecret = "";
private String[] scope = {"user:email", "read:user"};
}
@Component
@ConfigurationProperties(prefix = "oauth2") // Client ID 和 Client Secret 需要去三方授权服务器获取
@Data
public static class Google {
private boolean enabled = true;
private String clientId = "";
private String clientSecret = "";
private String[] scope = {"user:email", "read:user"};
}
}
整合Security客户端
-
OAuth通常和Security配合使用
- 对于非标准OAuth厂商/本地登录,由于身份认证逻辑不通用,需要在业务层/过滤器/Provider实现认证逻辑
- 对于标准OAuth厂商,Security封装了登录所有流程,无需再编写三方登录接口
- 用户原始的OAuth2登录请求(即
.authorizationEndpoint())不会被Security拦截,但后续的处理仍然受Security控制
-
OAuth认证处理器
- 三方认证失败:由
.failureHandler(oAuth2AuthenticationFailureHandler())处理 - 认证成功后置逻辑:由
.successHandler(oAuth2AuthenticationSuccessHandler())处理,例如存入Redis实现分布式会话
- 三方认证失败:由
-
OAuth不影响[【本地登录 | 鉴权 | 服务资源保护】](Spring Security)功能
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true) // 启用@PreAuthorize
public class SecurityConfig {
@Autowired
private JwtRefreshFilter jwtRefreshFilter; // 自定义过滤器
@Autowired
private AuthenticationExceptionHandler authenticationExceptionHandler; // 认证异常处理器
@Autowired
private LogoutHandler logoutHandler; // 注销处理器
@Autowired
private AccessDeniedExceptionHandler accessDeniedExceptionHandler; // 鉴权异常处理器
@Autowired
private UserDetailsService userDetailsService; // 本地用户信息加载实现类
@Autowired
private CustomOAuth2UserService customOAuth2UserService; // OAuth加载用户信息实现类
@Autowired
private OAuth2AuthenticationFailureHandler accessDeniedExceptionHandler; // OAuth登录成功后置处理器
@Autowired
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; // OAuth登录失败处理器
@Autowired
private RedisClient redisClient; // redis缓存
/**
* 密码编码器
* Spring Security 必须通过 PasswordEncoder 加密密码,若不配置该 Bean,启动会直接报错
* 采用 BCrypt 哈希算法,不可逆,只能密文比较
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 关闭csrf防护,用于前后端分离场景
http.csrf(AbstractHttpConfigurer::disable);
// 关闭Session,用于分布式场景
http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 注册web接口
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/user/login").permitAll()
.requestMatchers("/test").permitAll()
.requestMatchers(HttpMethod.POST, "/user/logout").permitAll()
.anyRequest().authenticated()
);
// 注册OAuth2登录接口
http.oauth2Login(oauth2 -> oauth2
/**
* 1. 接受前端发送原始登录请求,重定向到三方登录页
* 请求为/oauth2/authorization/github
* → 得registrationId = "github"
* → 从本地OAuth配置的 ClientRegistrationRepository 查找对应的平台
* → 构建重定向URL到github平台登录页上
*/
.authorizationEndpoint(authorization -> authorization
.baseUri("/oauth2/authorization/{registrationId}") // 默认值
)
/**
* 2. 接受携带授权码的认证请求:/login/oauth2/code/*,后端开始认证
* 重定向到三方登录页,授权成功后
* → 自动重定向到/login/oauth2/code/github(三方会自动添加?code=xxx&state=yyy)
* → 得registrationId = "github"
* → 从本地OAuth配置的 ClientRegistrationRepository 查找对应的平台
*/
.redirectionEndpoint(redirection -> redirection
.baseUri("/login/oauth2/code/*") // 默认值,例如/login/oauth2/code/github?code=xxx
)
/**
* 3. 后端查询用户信息,成功即认证完毕
* 上一步已经匹配到对应平台
* → 携带授权码访问对应平台的Token接口
* → 收到返回的succeed token
* → 携带succeed token再查平台的用户查询接口
* → 实现OAuth2UserService:将查询的用户信息以OAuth2User接受
*/
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService)
)
// 4. 成功处理器:认证成功后的处理
.successHandler(oAuth2AuthenticationSuccessHandler)
// 5. 失败处理器:认证失败后的处理
.failureHandler(oAuth2AuthenticationFailureHandler)
);
// 注册自定义过滤器
http.addFilterBefore(jwtRefreshFilter, UsernamePasswordAuthenticationFilter.class);
// 注册自定义处理器
http.logout(logout -> logout
.logoutUrl("/user/logout") // 自定义注销路径(替代默认 /logout)
.logoutSuccessHandler(logoutHandler) // 你的自定义注销成功处理器
);
// 注册异常处理器
http.exceptionHandling(ex -> ex
.accessDeniedHandler(accessDeniedExceptionHandler) // 权限不足
.authenticationEntryPoint(authenticationExceptionHandler) // 未认证
);
return http.build();
}
/**
* SpringSecurity 认证管理器
*/
@Bean
public AuthenticationManager configureAuthenticationManager(PasswordEncoder passwordEncoder) {
// 1. 创建 DaoAuthenticationProvider(用于用户名密码登录)
DaoAuthenticationProvider daoAuthProvider = new DaoAuthenticationProvider();
daoAuthProvider.setUserDetailsService(userDetailsService);
daoAuthProvider.setPasswordEncoder(passwordEncoder);
// 2.创建 SmsCodeAuthenticationProvider (用户验证码登录)
SmsCodeAuthenticationProvider smsAuthProvider = new SmsCodeAuthenticationProvider();
smsAuthProvider.setUserDetailsService(userDetailsService);
smsAuthProvider.setRedisClient(redisClient);
// 2. 返回包含所有 Provider 的 AuthenticationManager
return new ProviderManager(Arrays.asList(
daoAuthProvider, // 用户名密码登录
smsAuthProvider // 短信登录
));
}
}
OAuth2User封装用户信息
- 与
UserDetails类似,Spring OAuth提供了OAuth2User接口,用于三方登录充当用户信息实体类
java
/**
* `OAuth2UserService`只支持`OAuth2User`接受用户信息
*/
public class CustomOAuth2User implements OAuth2User {
private final String id;
private final String name;
private final String email;
private final String avatar;
private final Map<String, Object> attributes;
private final Collection<? extends GrantedAuthority> authorities;
public CustomOAuth2User(String id, String name, String email, String avatar,
Map<String, Object> attributes, Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.name = name;
this.email = email;
this.avatar = avatar;
this.attributes = attributes;
this.authorities = authorities;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return this.name;
}
public String getId() {
return id;
}
public String getEmail() {
return email;
}
public String getAvatar() {
return avatar;
}
public String getSource() {
return (String) attributes.get("source");
}
}
OAuth2UserService加载用户信息
- 在前端发送携带授权码的认证请求时调用
java
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1. 获取OAuth2用户,这里会OAuth封装了【授权码换token+token查用户】的逻辑,见父类CustomOAuth2UserService
OAuth2User oauth2User = super.loadUser(userRequest);
// 2. 构建统一的用户属性
Map<String, Object> attributes = new HashMap<>(oauth2User.getAttributes());
// 3. 根据不同的第三方平台,提取用户信息
String id = attributes.get("id").toString();
String name = (String) attributes.get("name");
String email = (String) attributes.get("email");
String avatar = (String) attributes.get("avatar_url");
String login = (String) attributes.get("login");
// 如果name为空,使用login
if (name == null || name.trim().isEmpty()) {
name = login;
}
// 5. 返回自定义的OAuth2用户
return new CustomOAuth2User(id, name, email, avatar, attributes, oauth2User.getAuthorities());
}
}
- 如果支持多端三方登录,可以进一步拓展
java
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 1. 获取默认的OAuth2用户
OAuth2User oauth2User = super.loadUser(userRequest);
// 2. 获取注册ID(github, google, wechat等),从已经配置的ClientRegistrationRepository中拿
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 3. 构建统一的用户属性
Map<String, Object> attributes = new HashMap<>(oauth2User.getAttributes());
// 4. 根据不同的第三方平台,提取用户信息
OAuth2UserInfo userInfo = extractUserInfo(registrationId, attributes);
// 5. 返回自定义的OAuth2用户
return new CustomOAuth2User(
userInfo.getId(),
userInfo.getName(),
userInfo.getEmail(),
userInfo.getAvatar(),
attributes,
oauth2User.getAuthorities()
);
}
private OAuth2UserInfo extractUserInfo(String registrationId, Map<String, Object> attributes) {
switch (registrationId.toLowerCase()) {
case "github":
return extractGithubUserInfo(attributes);
case "wechat":
return extractWechatUserInfo(attributes);
default:
throw new OAuth2AuthenticationException("不支持的登录方式: " + registrationId);
}
}
private OAuth2UserInfo extractGithubUserInfo(Map<String, Object> attributes) {
String id = attributes.get("id").toString();
String name = (String) attributes.get("name");
String email = (String) attributes.get("email");
String avatar = (String) attributes.get("avatar_url");
String login = (String) attributes.get("login");
// 如果name为空,使用login
if (name == null || name.trim().isEmpty()) {
name = login;
}
return OAuth2UserInfo.builder()
.id(id)
.name(name)
.email(email)
.avatar(avatar)
.source("github")
.build();
}
private OAuth2UserInfo extractWechatUserInfo(Map<String, Object> attributes) {
String id = attributes.get("openid").toString();
String name = (String) attributes.get("nickname");
String avatar = (String) attributes.get("headimgurl");
return OAuth2UserInfo.builder()
.id(id)
.name(name)
.avatar(avatar)
.source("wechat")
.build();
}
/**
* 用户信息DTO
*/
@Data
@Builder
public static class OAuth2UserInfo {
private String id; // 用户在第三方平台的唯一ID
private String name; // 用户名
private String email; // 邮箱
private String avatar; // 头像
private String source; // 来源平台
}
}
配置认证成功处理器
- 主流做法是本地JWT封装,存入Redis,可以自主控制有效期和刷新信息逻辑
java
@Component
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private JwtService jwtService;
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
if (authentication instanceof OAuth2AuthenticationToken) {
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oauth2User = oauthToken.getPrincipal();
// 从OAuth2用户信息中提取数据
Map<String, Object> attributes = oauth2User.getAttributes();
String registrationId = oauthToken.getAuthorizedClientRegistrationId();
// 提取用户信息
String userId = extractUserId(registrationId, attributes);
String username = extractUsername(registrationId, attributes);
String email = extractEmail(registrationId, attributes);
String avatar = extractAvatar(registrationId, attributes);
// 生成JWT token
String token = jwtService.generateToken(userId, username, email, avatar, registrationId);
// 构建返回数据
Map<String, Object> data = new HashMap<>();
data.put("token", token);
data.put("user", Map.of(
"id", userId,
"username", username,
"email", email,
"avatar", avatar,
"source", registrationId
));
// 返回JSON响应
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
ApiResponse<Map<String, Object>> apiResponse = ApiResponse.success(data);
response.getWriter().write(objectMapper.writeValueAsString(apiResponse));
}
}
private String extractUserId(String registrationId, Map<String, Object> attributes) {
switch (registrationId) {
case "github":
return attributes.get("id").toString();
case "google":
return attributes.get("sub").toString();
case "wechat":
return attributes.get("openid").toString();
default:
return attributes.get("id") != null ? attributes.get("id").toString() : "unknown";
}
}
private String extractUsername(String registrationId, Map<String, Object> attributes) {
switch (registrationId) {
case "github":
return (String) attributes.getOrDefault("login", attributes.get("name"));
case "google":
return (String) attributes.get("name");
case "wechat":
return (String) attributes.get("nickname");
default:
return (String) attributes.getOrDefault("username", "unknown");
}
}
private String extractEmail(String registrationId, Map<String, Object> attributes) {
switch (registrationId) {
case "github":
return (String) attributes.get("email");
case "google":
return (String) attributes.get("email");
default:
return (String) attributes.getOrDefault("email", "");
}
}
private String extractAvatar(String registrationId, Map<String, Object> attributes) {
switch (registrationId) {
case "github":
return (String) attributes.get("avatar_url");
case "google":
return (String) attributes.get("picture");
case "wechat":
return (String) attributes.get("headimgurl");
default:
return (String) attributes.getOrDefault("avatar", "");
}
}
}
配置认证失败处理器
- OAuth认证失败后置处理
java
@Component
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ApiResponse<String> apiResponse = ApiResponse.error(
HttpServletResponse.SC_UNAUTHORIZED,
"第三方登录失败: " + exception.getMessage()
);
response.getWriter().write(objectMapper.writeValueAsString(apiResponse));
}
}