最近项目需要使用oauth2做第三方登录验证,原本以为在spring boot项目中,使用oauth2是个很简单的事情,毕竟spring security也支持oauth2 客户端模式嘛,谁知毕竟too naive,最后兜兜转转花了不少时间,才算搞定。
搜spring boot关于oauth2的使用,首先都是讲@EnableOAuth2Sso,一个注解加上配置文件就能搞定,我也建议都先用这种方式尝试一下,能搞定当然是最好的,搞不定的可以浏览下本篇文章,说不定能有帮到你的地方。
本文主要总结一下使用spring-security-oauth2适配三方登录验证的过程,authorization code模式。代码示例基于spring boot使用oauth2教程源码。
在服务端强校验redirect_uri的场景下,该值可能需要被设置以通过校验。而redirect_uri在spring-security-oauth2中并不是可配置的信息,只有通过源码方面的适配才能做到。
获取redirect_uri的功能是在 AbstractRedirectResourceDetails类中定义,AuthorizationCodeResourceDetails继承该类,验证码模式中使用该类来获取回调地址。
public String getRedirectUri(AccessTokenRequest request) {
String redirectUri = request.getFirst("redirect_uri");
if (redirectUri == null && request.getCurrentUri() != null && useCurrentUri) {
redirectUri = request.getCurrentUri();
}
if (redirectUri == null && getPreEstablishedRedirectUri() != null) {
// Override the redirect_uri if it is pre-registered
redirectUri = getPreEstablishedRedirectUri();
}如果
return redirectUri;
}
从该函数的定义可以看出,回调地址会首先从AccessTokenRequest中获取,如果request中没有设置,则以当前访问地址为准,再者以上一次设置的redirect_uri为准。结合该段代码逻辑以及上文提到的filter,有三种方式可以设置redirect_uri:
这三种修改将在最后的代码实例中体现。
验证码模式中,三方验证通过后,会通过回调地址传回验证码,一般包含code和state两个参数,通常,不太会出问题,但是奇葩就奇葩在参数命名上,不是每个服务器都使用code来命名参数,同时该参数也未提供配置支持,so,继续代码适配。
获取验证码是在DefaultAccessTokenRequest中定义:
/**
* The authorization code for this context.
*
* @return The authorization code, or null if none.
*/
public String getAuthorizationCode() {
return getFirst("code");
}
(不知道你们看到这段代码是怎么想的,反正我当时是有点吃惊的,我以为最起码会定义个常量之类的),DefaultAccessTokenRequest通过http request获取到请求参数,然后其他类会调用getAuthorizationCode函数获取验证码,如果服务器返回的参数不是code而是auth_code或者其它东东,那这个流程肯定是不通的。
没有找到其它方法,只有重新实现AccessTokenRequest,使之能获取到正确的验证码。
同code的获取一样,这一步也可能会存在参数名不是默认名称的情况,但是我遇到的更奇葩,解析返回内容出错。。。
解析类的设置是在OAuth2AccessTokenSupport中定义,具体的解析功能是在FormOAuth2AccessTokenMessageConverter中定义:
public FormOAuth2AccessTokenMessageConverter() {
super(new MediaType[]{MediaType.APPLICATION_FORM_URLENCODED, MediaType.TEXT_PLAIN});
}
构造函数中指定了支持的MediaType,如果MediaType不同,是走不到解析的,逻辑判断是在AbstractHttpMessageConverter的canRead函数中定义:
MediaType supportedMediaType;
do {
if (!var2.hasNext()) {
return false;
}
supportedMediaType = (MediaType)var2.next();
} while(!supportedMediaType.includes(mediaType));
同样,倒霉的我碰到的MediaType偏偏不是上面定义的这两种,只有适配喽。
顺便说一下,access token的设置和获取是在DefaultOAuth2AccessToken中:
public static OAuth2AccessToken valueOf(Map tokenParams) {
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken((String)tokenParams.get("access_token"));
...
}
所以,如果遇到access token的返回不是默认参数名称access_token的时候,在重写converter的时候,OAuth2AccessToken也需要重写。
默认的用户信息的获取功能是在UserInfoTokenServices中定义,同上面几种情况一样,如果在请求用户信息的时候需要额外的参数设置,或者返回内容无法使用默认函数解析的时候,只有重写实现ResourceServerTokenServices接口了。
重新实现AccessTokenRequest:
public class MyAccessTokenRequest extends DefaultAccessTokenRequest {
...
@Override
public String getAuthorizationCode() {
return getFirst("authorization_code");
}
@Override
public void setAuthorizationCode(String code) {
set("authorization_code", code);
}
}
可以根据需要重写state的获取与设置函数。
redirect_uri的设置使用上文提到的重新实现AccessTokenRequest的方式,在AccessTokenRequest实例化的时候将值设置进AccessTokenRequest中:
//在这里将redirect_uri替换为你自己的回调地址
request.set("redirect_uri", "http://127.0.0.1:8080/login/oauth2");
该函数通常放在EnableOAuth2Client注解的类中。
我碰到的情况是服务器返回json,所以根据需要重写了converter:
public class MyOauth2AccessTokenMessageConverter extends AbstractHttpMessageConverter {
private final StringHttpMessageConverter delegateMessageConverter = new StringHttpMessageConverter();
public MyOauth2AccessTokenMessageConverter() {
super(new MediaType[]{MediaType.APPLICATION_FORM_URLENCODED,
MediaType.TEXT_PLAIN,
MediaType.TEXT_HTML,
MediaType.TEXT_XML});
}
protected boolean supports(Class> clazz) {
return OAuth2AccessToken.class.equals(clazz);
}
protected OAuth2AccessToken readInternal(Class extends OAuth2AccessToken> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
String raw = this.delegateMessageConverter.read((Class)null, inputMessage);
JSONObject o = JSON.parseObject(raw);
Map m = new HashMap();
for (Map.Entry entry : o.entrySet()) {
m.put(entry.getKey(), String.valueOf(entry.getValue()));
}
//如果重写OAuth2AccessToken,在这里进行实例化
//吐槽一下:不太明白valueof为啥用,其实函数内部都做了强转,为毛不用?
return DefaultOAuth2AccessToken.valueOf(m);
}
protected void writeInternal(OAuth2AccessToken accessToken, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
//根据需要实现
//不需要的直接抛异常即可
}
}
可以根据需要重写该converter。
ResourceServerTokenServices比较简单,只有两个接口,不需要的接口直接抛异常就行。
public class MyUserInfoTokenService implements ResourceServerTokenServices {
private String userInfoEndpointUrl;
private String clientId;
public MyUserInfoTokenService(String userInfoUrl, String appId) {
this.userInfoEndpointUrl = userInfoUrl;
this.clientId = appId;
}
@Override
public OAuth2Authentication loadAuthentication(String accessToken)
throws AuthenticationException, InvalidTokenException {
...
//根据access token获取用户信息
}
}
根据项目需要,state可能需要按照自己的规则生成,很简单,重新实现StateKeyGenerator即可:
public class MyStateKeyGenerateor implements StateKeyGenerator {
@Override
public String generateKey(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails) {
//实现自己的state生成函数
return "123456";
}
}
上面各种适配,总要在一个地方组装起来(示例代码只涉及改动的地方,缺失部分参考附录[3]的源码):
@SpringBootApplication
@EnableOAuth2Client
public class SocialApplication extends WebSecurityConfigurerAdapter {
//自定义AccessTokenRequest的设置,以及redirect_uri的设置
@Bean(name="accessTokenRequest")
@Scope(value = "request", proxyMode = ScopedProxyMode.INTERFACES)
protected AccessTokenRequest accessTokenRequest(@Value("#{request.parameterMap}")
Map parameters, @Value("#{request.getAttribute('currentUri')}")
String currentUri) {
MyAccessTokenRequest request = new MyAccessTokenRequest(parameters);
request.setCurrentUri(currentUri);
//该处可以设置你自己的回调地址
request.set("redirect_uri", "http://127.0.0.1:8080/login/oauth2");
return request;
}
private Filter ssoFilter() {
//可以将/login/oauth2设置为你自己的回调接口,对应的是上文中提到的filter设置回调。
//注意这个地方,使用filter方式,前端设置的访问接口也需要一致
//(强校验场景下,localhost和127.0.0.1也是有区别的)
OAuth2ClientAuthenticationProcessingFilter facebookFilter = new OAuth2ClientAuthenticationProcessingFilter(
"/login/oauth2");
OAuth2RestTemplate facebookTemplate = new OAuth2RestTemplate(facebook(), oauth2ClientContext);
AuthorizationCodeAccessTokenProvider tokenProvider = new AuthorizationCodeAccessTokenProvider();
//设置state生成器
tokenProvider.setStateKeyGenerator(new MyStateKeyGenerateor());
List> messageConverters = new ArrayList<>();
messageConverters.add(new MyOauth2AccessTokenMessageConverter());
//设置access token解析器
tokenProvider.setMessageConverters(messageConverters);
facebookTemplate.setAccessTokenProvider(tokenProvider);
facebookTemplate.setRetryBadAccessTokens(false);
facebookFilter.setRestTemplate(facebookTemplate);
//设置用户信息的获取方式
facebookFilter.setTokenServices(new MyUserInfoTokenService(facebookResource().getUserInfoUri(), facebook().getClientId()));
return facebookFilter;
}
@Bean
@ConfigurationProperties("facebook.client")
//如果使用上文提到的重新实现AuthorizationCodeResourceDetails方式实现获取redirect_uri,可以在这里进行实例化
public AuthorizationCodeResourceDetails facebook() {
return new AuthorizationCodeResourceDetails();
}
@Bean
@ConfigurationProperties("facebook.resource")
public ResourceServerProperties facebookResource() {
return new ResourceServerProperties();
}
}
debug了几天,基本搞清楚了spring-security-oauth2的运转,有点不太明白有些参数名为啥被硬编码在代码中,这样做确实在协议兼容性上不太友好;另外,有时间需要看下原版oauth2协议,目前所知太片面。
参考:
[1] spring-security-oauth2源码
[2] spring boot使用oauth2教程
[3] spring boot使用oauth2教程源码