spring-security-oauth2適配總結

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教程源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章