如何在应用中利用Spring Security的OAuth2特性实现QQ登录集成
在前一个章节中我们使用最简配置实现了OAuth2客户端接入Github登录的功能,得益于Spring Security对OAuth2标准认证流程的封装,最简配置客户端也可以很方便地接入Google和Facebook等实现相对标准的OAuth2服务提供商。而对于QQ登录等不够标准的OAuth2流程来说,我们则可以在此基础上进行额外的适配工作,Spring Security良好的OAuth2扩展性同样为我们的适配提供了足够的支持。
针对Spring Security的OAuth2功能扩展流程
不同OAuth服务商提供的授权流程在细节上可能不同,这主要体现在与授权服务器交互过程中的传参、返回值解析,以及从资源服务器获取资源两个方面。但核心步骤上大致是相同的,都是OAuth标准所制定的形式:
- 获取code。
- 使用code交换access_token。
- 携带access_token请求被保护的用户信息和其它资源。
针对这3个核心步骤,Spring Security提供了相应的扩展接口和配置方法:
- 支持自定义重定向端点(Redirection Endpoint),OAuth2服务器通过重定向到此端点的方式将code传递给OAuth2客户端。
- 支持自定义OAuth2AccessTokenResponseClient,OAuth2AccessTokenResponseClient负责使用code交换access_token的具体逻辑。
-
支持自定义用户信息端点(UserInfo Endpoint),常用自定义方式:
- 自定义OAuth2User。不同OAuth2服务提供商的用户属性不同,可以针对不同的OAuth2服务商做适配。
- 自定义OAuth2UserService。OAuth2UserService负责请求用户信息(OAuth2User)。标准OAuth2协议可以直接携带access_token请求用户信息,而QQ则需要先获取OpenId,再使用OpenId获取用户信息。
考虑到我们在对接社交账号登录功能时,一般不会局限于单个OAuth服务商,而是同时提供多种流行的社交平台供用户选择,所以需要有多套OAuth方案并存的准备。为了避免项目中不同OAuth对接代码混乱的情况,推荐使用图1这种组织形式:
准备工作
在使用Spring Social对接QQ登录时,基本的准备工作已经都有提及,可以直接参考该部分内容。
编码实现
相对于标准的OAuth2授权码模式,QQ提供的API在交互上较为混乱,其响应类型为text/html,响应内容则同时存在普通文本、JSONP、JSON字符串等多种类型。
另外,QQ提供的API还需要先获取OpenId,再使用OpenId结合appId与access_token的方式来获取用户信息,而不是直接使用access_token,这些都是我们需要自定义实现的重点内容。
1. 新建项目
首先,新建Spring Boot 2.0工程,命名为client-social,引入spring-boot-starter-web和spring-boot-starter-security两个依赖包:
<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>
引入Spring Security对OAuth2实现支持的依赖包:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
2. 自定义QQUserInfo实现OAuth2User接口
QQ用户信息无法使用默认的DefaultOAuth2User表示,需要提供一个自定义的QQUserInfo类并实现OAuth2User接口:
public class QQUserInfo implements OAuth2User {
// 统一赋予USER角色
private List<GrantedAuthority> authorities =
AuthorityUtils.createAuthorityList("ROLE_USER");
private Map<String, Object> attributes;
private String nickname;
@JsonProperty("figureurl")
private String figureUrl30;
@JsonProperty("figureurl_1")
private String figureUrl50;
@JsonProperty("figureurl_2")
private String figureUrl100;
@JsonProperty("figureurl_qq_1")
private String qqFigureUrl40;
@JsonProperty("figureurl_qq_2")
private String qqFigureUrl100;
private String gender;
// 携带openId备用
private String openId;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public Map<String, Object> getAttributes() {
if (this.attributes == null) {
this.attributes = new HashMap<>();
this.attributes.put("nickname", this.getNickname());
this.attributes.put("figureUrl30", this.getFigureUrl30());
this.attributes.put("figureUrl50", this.getFigureUrl50());
this.attributes.put("figureUrl100", this.getFigureUrl100());
this.attributes.put("qqFigureUrl40", this.getQqFigureUrl40());
this.attributes.put("qqFigureUrl100", this.getQqFigureUrl100());
this.attributes.put("gender", this.getGender());
this.attributes.put("openId", this.getOpenId());
}
return attributes;
}
@Override
public String getName() {
return this.nickname;
}
// 省略setter getter
}
3. 添加RestTamplate解析模板
public class JacksonFromTextHtmlHttpMessageConverter extends MappingJackson2HttpMessageConverter {
// 添加对text/html的支持
public JacksonFromTextHtmlHttpMessageConverter() {
List mediaTypes = new ArrayList();
mediaTypes.add(MediaType.TEXT_HTML);
setSupportedMediaTypes(mediaTypes);
}
}
/**
* 由于与QQ接口的交互上,响应类型都为text/html的形式,且RestTemplate没有默认支持该解析模型,所以应当自行添加。
* 主要是两类,一类text/html转普通文本,一类则是text/html转JSON对象。
*/
public class TextHtmlHttpMessageConverter extends AbstractHttpMessageConverter {
public TextHtmlHttpMessageConverter() {
super(Charset.forName("UTF-8"), new MediaType[]{MediaType.TEXT_HTML});
}
@Override
protected boolean supports(Class clazz) {
return String.class == clazz;
}
@Override
protected Object readInternal(Class aClass, HttpInputMessage httpInputMessage) throws IOException, HttpMessageNotReadableException {
Charset charset = this.getContentTypeCharset(httpInputMessage.getHeaders().getContentType());
return StreamUtils.copyToString(httpInputMessage.getBody(), charset);
}
@Override
protected void writeInternal(Object o, HttpOutputMessage httpOutputMessage) throws IOException, HttpMessageNotWritableException {
}
private Charset getContentTypeCharset(MediaType contentType) {
return contentType != null && contentType.getCharset() != null ? contentType.getCharset() : this.getDefaultCharset();
}
}
4. 实现OAuth2AccessTokenResponseClient
OAuth2AccessTokenResponseClient负责使用code交换access_token的具体逻辑。默认提供的实现类NimbusAuthorizationCodeTokenResponseClient用于处理标准的OAuth2交换access_token逻辑,但QQ提供的方式并不标准,所以需要自定义实现OAuth2AccessTokenResponseClient接口:
public class QQOAuth2AccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private RestTemplate restTemplate;
private RestTemplate getRestTemplate() {
if (restTemplate == null) {
restTemplate = new RestTemplate();
restTemplate.getMessageConverters().add(new TextHtmlHttpMessageConverter());
}
return restTemplate;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest)
throws OAuth2AuthenticationException {
ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration();
OAuth2AuthorizationExchange oAuth2AuthorizationExchange = authorizationGrantRequest.getAuthorizationExchange();
// 根据API文档获取请求access_token参数
MultiValueMap<String, String> params = new LinkedMultiValueMap();
params.set("client_id", clientRegistration.getClientId());
params.set("client_secret", clientRegistration.getClientSecret());
params.set("code", oAuth2AuthorizationExchange.getAuthorizationResponse().getCode());
params.set("redirect_uri", oAuth2AuthorizationExchange.getAuthorizationRequest().getRedirectUri());
params.set("grant_type", "authorization_code");
String tmpTokenResponse = getRestTemplate().postForObject(clientRegistration.getProviderDetails().getTokenUri(), params, String.class);
// 从API文档中可以轻易获知解析accessToken的方式
String[] items = tmpTokenResponse.split("&");
//http://wiki.connect.qq.com/使用authorization_code获取access_token
//access_token=FE04************************CCE2&expires_in=7776000&refresh_token=88E4************************BE14
String accessToken = items[0].substring(items[0].lastIndexOf("=") + 1);
Long expiresIn = new Long(items[1].substring(items[1].lastIndexOf("=") + 1));
Set<String> scopes = new LinkedHashSet<>(oAuth2AuthorizationExchange.getAuthorizationRequest().getScopes());
Map<String, Object> additionalParameters = new LinkedHashMap<>();
OAuth2AccessToken.TokenType accessTokenType = OAuth2AccessToken.TokenType.BEARER;
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(accessTokenType)
.expiresIn(expiresIn)
.scopes(scopes)
.additionalParameters(additionalParameters)
.build();
}
}
主要是使用RestTemplate请求获取access_token,并对返回的结果执行自定义解析,最后构建成OAuth2AccessTokenResponse对象返回即可。
5. 实现OAuth2UserService接口
OAuth2UserService负责请求用户信息(OAuth2User),标准的OAuth2协议可以直接携带access_token请求用户信息,但QQ还需要获取到OpenId才能使用:
public class QQOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
// 获取用户信息的API
private static final String QQ_URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key={appId}&openid={openId}&access_token={access_token}";
private RestTemplate restTemplate;
private RestTemplate getRestTemplate() {
if (restTemplate == null) {
restTemplate = new RestTemplate();
//通过Jackson JSON processing library直接将返回值绑定到对象
restTemplate.getMessageConverters().add(new JacksonFromTextHtmlHttpMessageConverter());
}
return restTemplate;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 第一步:获取openId接口响应
String accessToken = userRequest.getAccessToken().getTokenValue();
String openIdUrl = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri() + "?access_token={accessToken}";
String result = getRestTemplate().getForObject(openIdUrl, String.class, accessToken);
// 提取openId
String openId = result.substring(result.lastIndexOf(":\"") + 2, result.indexOf("\"}"));
// 第二步:获取用户信息
String appId = userRequest.getClientRegistration().getClientId();
QQUserInfo qqUserInfo = getRestTemplate().getForObject(QQ_URL_GET_USER_INFO, QQUserInfo.class, appId, openId, accessToken);
// 为用户信息类补充openId
if (qqUserInfo != null) {
qqUserInfo.setOpenId(openId);
}
return qqUserInfo;
}
}
首先获取OpenId,再通过OpenId等参数获取用户信息,最终组装成QQUserInfo对象。
6. 多个OAuth2服务提供商并存
前面我们通过自定义实现QQOAuth2AccessTokenResponseClient和QQOAuth2UserService来支持QQ登录,但如果直接使用它们代替默认的NimbusAuthorizationCodeTokenResponseClient和DefaultOAuth2UserService,将会导致Github等标准OAuth2服务无法正常使用。为了允许多个OAuth服务并存,我们还可以使用组合模式进行设计:
6.1 提供CompositeOAuth2AccessTokenResponseClient
/**
* OAuth2AccessTokenResponseClient的组合类,使用了Composite Pattern(组合模式)
* 除了同时支持GOOGLE,OKTA,GITHUB,FACEBOOK之外,可能还需要同时支持QQ、微信等多种认证服务
* 根据registrationId选择相应的OAuth2AccessTokenResponseClient
*/
public class CompositeOAuth2AccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
private Map<String, OAuth2AccessTokenResponseClient> clients;
private static final String DefaultClientKey = "default_key";
public CompositeOAuth2AccessTokenResponseClient() {
this.clients = new HashMap();
// spring-security-oauth2-client默认的OAuth2AccessTokenResponseClient实现类是NimbusAuthorizationCodeTokenResponseClient
// 将其预置到组合类CompositeOAuth2AccessTokenResponseClient中,从而默认支持GOOGLE,OKTA,GITHUB,FACEBOOK
this.clients.put(DefaultClientKey, new NimbusAuthorizationCodeTokenResponseClient());
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2AuthorizationCodeGrantRequest authorizationGrantRequest)
throws OAuth2AuthenticationException {
ClientRegistration clientRegistration = authorizationGrantRequest.getClientRegistration();
OAuth2AccessTokenResponseClient client = clients.get(clientRegistration.getRegistrationId());
if (client == null) {
client = clients.get(DefaultClientKey);
}
return client.getTokenResponse(authorizationGrantRequest);
}
public Map<String, OAuth2AccessTokenResponseClient> getOAuth2AccessTokenResponseClients() {
return clients;
}
}
6.2 提供CompositeOAuth2UserService
/**
* OAuth2AccessTokenResponseClient的组合类,使用了Composite Pattern(组合模式)
* 除了同时支持GOOGLE,OKTA,GITHUB,FACEBOOK之外,可能还需要同时支持QQ、微信等多种认证服务
* 根据registrationId选择相应的OAuth2AccessTokenResponseClient
*/
public class CompositeOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private Map<String, OAuth2UserService> userServices;
private static final String DefaultUserServiceKey = "default_key";
public CompositeOAuth2UserService() {
this.userServices = new HashMap();
// DefaultOAuth2UserService是默认处理OAuth2协议获取用户逻辑的OAuth2UserService实现类
// 将其预置到组合类CompositeOAuth2UserService中,从而默认支持GOOGLE,OKTA,GITHUB,FACEBOOK
this.userServices.put(DefaultUserServiceKey, new DefaultOAuth2UserService());
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
ClientRegistration clientRegistration = userRequest.getClientRegistration();
OAuth2UserService service = userServices.get(clientRegistration.getRegistrationId());
if (service == null) {
service = userServices.get(DefaultUserServiceKey);
}
return service.loadUser(userRequest);
}
public Map<String, OAuth2UserService> getUserServices() {
return userServices;
}
}
7. 配置Spring Security
从Spring Security 5.0开始,在HttpSecurity中提供了OAuth2Login用于配置OAuth2客户端策略:
http.OAuth2Login()
完整配置如下:
@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
public static final String QQRegistrationId = "qq";
public static final String WeChatRegistrationId = "wechat";
public static final String LoginPagePath = "/login/oauth2";
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(LoginPagePath).permitAll()
.anyRequest()
.authenticated();
http.oauth2Login()
// 使用CompositeOAuth2AccessTokenResponseClient
.tokenEndpoint().accessTokenResponseClient(this.accessTokenResponseClient())
.and()
.userInfoEndpoint()
.customUserType(QQUserInfo.class, QQRegistrationId)
// 使用CompositeOAuth2UserService
.userService(oauth2UserService())
// 可选,要保证与redirect-uri-template匹配
.and()
.redirectionEndpoint().baseUri("/register/social/*");
//自定义登录页
http.oauth2Login().loginPage(LoginPagePath);
}
private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
CompositeOAuth2AccessTokenResponseClient client = new CompositeOAuth2AccessTokenResponseClient();
// 加入QQ自定义QQOAuth2AccessTokenResponseClient
client.getOAuth2AccessTokenResponseClients().put(QQRegistrationId, new QQOAuth2AccessTokenResponseClient());
return client;
}
private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
CompositeOAuth2UserService service = new CompositeOAuth2UserService();
// 加入QQ自定义QQOAuth2UserService
service.getUserServices().put(QQRegistrationId, new QQOAuth2UserService());
return service;
}
}
其中关于重定向端点( Redirection Endpoint)的配置是可选的,需要注意的是,当多种OAuth2服务提供商并存时,一定要保证baseUri、redirect-uri-template和
OAuth2注册的重定向地址三者相互匹配。
8. 工程配置文件
server:
port: 8080
logging:
level:
root: INFO
org.springframework.web: INFO
org.springframework.security: DEBUG
org.springframework.boot.autoconfigure: DEBUG
spring:
security:
oauth2:
client:
registration:
github:
client-id: {custom}
client-secret: {custom}
redirect-uri-template: "{baseUrl}/register/social/{registrationId}"
qq:
client-id: {custom appId}
client-secret: {custom appKey}
provider: qq
client-name: QQ登录
authorization-grant-type: authorization_code
client-authentication-method: post
scope: get_user_info,list_album,upload_pic,do_like
redirect-uri-template: "{baseUrl}/register/social/{registrationId}"
provider:
qq:
authorization-uri: https://graph.qq.com/oauth2.0/authorize
token-uri: https://graph.qq.com/oauth2.0/token
# 配置为QQ获取OpenId的Url
user-info-uri: https://graph.qq.com/oauth2.0/me
user-name-attribute: "nickname"
上面的配置同时支持了Github和QQ登录。
9. 自定登录页面配置
Spring Security的OAuth2功能通过DefaultLoginPageGeneratingFilter生成了一个默认的登录页面,同时也允许我们自定义。
9.1 定义login.html和index.html
我们使用thymeleaf模版,需要工程pom文件引入
<!--页面模版-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity4</artifactId>
</dependency>
index.html为主页面,主要用于展示信息:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4">
<head>
<title>Spring Security - OAuth 2.0 Login</title>
<meta charset="utf-8"/>
</head>
<body>
<div style="float: right" th:fragment="logout" sec:authorize="isAuthenticated()">
<div style="float:left">
<span style="font-weight:bold">用户: </span><span sec:authentication="name"></span>
</div>
<div style="float:none"> </div>
<div style="float:right">
<form action="#" th:action="@{/logout}" method="post">
<input type="submit" value="注销"/>
</form>
</div>
</div>
<h1>使用Spring Security OAuth 2.0 登录</h1>
<div>
恭喜您通过"<span style="font-weight:bold" th:text="${clientName}"></span>"
登录成功
</div>
</body>
</html>
login.html则是我们自定义的登录页面:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Spring Security - OAuth 2.0 Login</title>
<meta charset="utf-8"/>
</head>
<body>
<h1>自定义OAuth2登录页</h1>
<div>
<a href="/oauth2/authorization/github">Github登录</a>
<a href="/oauth2/authorization/qq">QQ登录</a>
</div>
</body>
</html>
9.2 定义Controller映射
@Controller
public class MainController {
@Autowired
private OAuth2AuthorizedClientService authorizedClientService;
@GetMapping("/")
public String index(Model model, OAuth2AuthenticationToken authentication) {
OAuth2AuthorizedClient authorizedClient = this.getAuthorizedClient(authentication);
model.addAttribute("userName", authentication.getName());
model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName());
return "index";
}
@GetMapping("/login/oauth2")
public String login() {
return "login";
}
private OAuth2AuthorizedClient getAuthorizedClient(OAuth2AuthenticationToken authentication) {
return this.authorizedClientService.loadAuthorizedClient(
authentication.getAuthorizedClientRegistrationId(), authentication.getName());
}
}
- OAuth2AuthenticationToken可以获取当前用户信息,由Spring Security自动注入。
- OAuth2AuthorizedClientService对象可以用来获取当前已经认证成功的OAuth2的客户端信息。
9.3 启用自定义登录页
http.oauth2Login()
.loginPage("/login/oauth2")
10. 运行演示
- 运行client-social工程
- 浏览器地址栏输入:http://{ip|host}:{port}/,访问将得到如图2所示的页面:
注意在QQ互联平台注册网站回调域时填写的回调地址应配置在本地的hosts中,并改用该域名访问测试,直接通过localhost测试是无法成功的。
- 点击Github登录或者QQ登录按钮,按照提示进行登录操作,登录成功后会跳转到主页,效果如图3所示: