spring-security-oauth2適配總結
前言
最近項目需要使用oauth2做第三方登錄驗證,原本以爲在spring boot項目中,使用oauth2是個很簡單的事情,畢竟spring security也支持oauth2 客戶端模式嘛,誰知畢竟too naive,最後兜兜轉轉花了不少時間,纔算搞定。
搜spring boot關於oauth2的使用,首先都是講@EnableOAuth2Sso,一個註解加上配置文件就能搞定,我也建議都先用這種方式嘗試一下,能搞定當然是最好的,搞不定的可以瀏覽下本篇文章,說不定能有幫到你的地方。
本文主要總結一下使用spring-security-oauth2適配三方登錄驗證的過程,authorization code模式。代碼示例基於spring boot使用oauth2教程源碼。
1、redirect_uri的設置
在服務端強校驗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:
1) 重新實現AuthorizationCodeResourceDetails,重寫getRedirectUri函數,使之返回需要的redirect_uri。
2)重新實現AccessTokenRequest,使之能夠設置redirect_uri。
3)設置OAuth2ClientAuthenticationProcessingFilter的捕獲接口,使之與redirect_uri完全匹配, 因爲該接口的定義間體現在上文代碼中的request.getCurrentUri()。
這三種修改將在最後的代碼實例中體現。
2 code的獲取
驗證碼模式中,三方驗證通過後,會通過回調地址傳回驗證碼,一般包含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,使之能獲取到正確的驗證碼。
3 access token的獲取
同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<String, String> tokenParams) {
DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken((String)tokenParams.get("access_token"));
...
}
所以,如果遇到access token的返回不是默認參數名稱access_token的時候,在重寫converter的時候,OAuth2AccessToken也需要重寫。
4 用戶信息的獲取
默認的用戶信息的獲取功能是在UserInfoTokenServices中定義,同上面幾種情況一樣,如果在請求用戶信息的時候需要額外的參數設置,或者返回內容無法使用默認函數解析的時候,只有重寫實現ResourceServerTokenServices接口了。
5 代碼實例
1) code的獲取
重新實現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的獲取與設置函數。
2)redirect_uri的設置
redirect_uri的設置使用上文提到的重新實現AccessTokenRequest的方式,在AccessTokenRequest實例化的時候將值設置進AccessTokenRequest中:
//在這裏將redirect_uri替換爲你自己的回調地址
request.set("redirect_uri", "http://127.0.0.1:8080/login/oauth2");
該函數通常放在EnableOAuth2Client註解的類中。
3)access token的獲取
我碰到的情況是服務器返回json,所以根據需要重寫了converter:
public class MyOauth2AccessTokenMessageConverter extends AbstractHttpMessageConverter<OAuth2AccessToken> {
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<String, String> m = new HashMap<String, String>();
for (Map.Entry<String, Object> entry : o.entrySet()) {
m.put(entry.getKey(), String.valueOf(entry.getValue()));
}
//如果重寫OAuth2AccessToken,在這裏進行實例化
//吐槽一下:不太明白valueof爲啥用<String, String>,其實函數內部都做了強轉,爲毛不用<String, Object>?
return DefaultOAuth2AccessToken.valueOf(m);
}
protected void writeInternal(OAuth2AccessToken accessToken, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
//根據需要實現
//不需要的直接拋異常即可
}
}
可以根據需要重寫該converter。
4)用戶信息的獲取.
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獲取用戶信息
}
}
5) state的生成
根據項目需要,state可能需要按照自己的規則生成,很簡單,重新實現StateKeyGenerator即可:
public class MyStateKeyGenerateor implements StateKeyGenerator {
@Override
public String generateKey(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails) {
//實現自己的state生成函數
return "123456";
}
}
6) 適配代碼的組裝
上面各種適配,總要在一個地方組裝起來(示例代碼只涉及改動的地方,缺失部分參考附錄[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<String, String[]> 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<HttpMessageConverter<?>> 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教程源碼