Spring Security 技術棧開發企業級認證受權(3)

歡迎關注我的博客,此文爲《Spring Security 技術棧開發企業級認證受權(2)》的後續html

開發QQ登陸功能

準備工做:申請appId和appSecret,詳見準備工做_oauth2-0前端

回調域:www.zhenganwen.top/socialLogin…java

要開發一個第三方接入功能其實就是對上圖一套組件逐個進行實現一下,本節咱們將開發QQ登陸功能,首先從上圖的左半部分開始實現。node

ServiceProvider

Api,聲明一個對應OpenAPI的方法,用來調用該API並將響應結果轉成POJO返回,對應受權碼模式時序圖中的第7步c++

package top.zhenganwen.security.core.social.qq.api;

import top.zhenganwen.security.core.social.qq.QQUserInfo;

/** * @author zhenganwen * @date 2019/9/4 * @desc QQApi 封裝對QQ開放平臺接口的調用 */
public interface QQApi {

    QQUserInfo getUserInfo();
}

複製代碼
package top.zhenganwen.security.core.social.qq.api;

import lombok.Data;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.social.oauth2.AbstractOAuth2ApiBinding;
import org.springframework.social.oauth2.TokenStrategy;
import top.zhenganwen.security.core.social.qq.QQUserInfo;

/** * @author zhenganwen * @date 2019/9/3 * @desc QQApiImpl 拿token調用開放接口獲取用戶信息 * 1.首先要根據 https://graph.qq.com/oauth2.0/me/{token} 獲取用戶在社交平臺上的id => {@code openId} * 2.調用QQ OpenAPI https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID * 獲取用戶在社交平臺上的信息 => {@link QQApiImpl#getUserInfo()} * <p> * {@link AbstractOAuth2ApiBinding} * 幫咱們完成了調用OpenAPI時附帶{@code token}參數, 見其成員變量{@code accessToken} * 幫咱們完成了HTTP調用, 見其成員變量{@code restTemplate} * <p> * 注意:該組件應是多例的,由於每一個用戶對應有不一樣的OpenAPI,每次不一樣的用戶進行QQ聯合登陸都應該建立一個新的 {@link QQApiImpl} */
@Data
public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi {

    private static final String URL_TO_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me?access_token=%s";

    // 由於父類會幫咱們附帶token參數,所以這裏URL忽略了token參數
    private static final String URL_TO_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

    private String openId;

    private String appId;

    private Logger logger = LoggerFactory.getLogger(getClass());

    public QQApiImpl(String accessToken,String appId) {
        // 調用OpenAPI時將須要傳遞的參數附在URL路徑上
        super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
        this.appId = appId;

        // 獲取用戶openId, 響應結果格式:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
        String responseForGetOpenId = getRestTemplate().getForObject(String.format(URL_TO_GET_OPEN_ID, accessToken), String.class);
        logger.info("獲取用戶對應的openId:{}", responseForGetOpenId);

        this.openId = StringUtils.substringBetween(responseForGetOpenId, "\"openid\":\"", "\"}");
    }

    @Override
    public QQUserInfo getUserInfo() {
        QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
        logger.info("調用QQ OpenAPI獲取用戶信息: {}", qqUserInfo);
        return qqUserInfo;
    }
}
複製代碼

而後是OAuth2Operations,用來封裝將用戶導入受權頁面、獲取用戶受權後傳入的受權碼、獲取訪問OpenAPI的token,對應受權碼模式時序圖中的第2~6步。因爲這幾步模式是固定的,因此Spring Social幫咱們作了強封裝,即OAuth2Template,所以無需咱們本身實現,後面直接使用該組件便可git

ServiceProvider,集成OAuth2OperationsApi,使用前者來完成受權獲取token,使用後者攜帶token調用OpenAPI獲取用戶信息web

package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.oauth2.AbstractOAuth2ServiceProvider;
import org.springframework.social.oauth2.OAuth2Operations;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

/** * @author zhenganwen * @date 2019/9/4 * @desc QQServiceProvider 對接服務提供商,封裝一整套受權登陸流程, 從用戶點擊第三方登陸按鈕到掉第三方應用OpenAPI獲取Connection(用戶信息) * 委託 {@link OAuth2Operations} 和 {@link org.springframework.social.oauth2.AbstractOAuth2ApiBinding}來完成整個流程 */
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApiImpl> {

    /** * 當前應用在服務提供商註冊的應用id */
    private String appId;

    /** * @param oauth2Operations 封裝邏輯: 跳轉到認證服務器、用戶受權、獲取受權碼、獲取token * @param appId 當前應用的appId */
    public QQServiceProvider(OAuth2Operations oauth2Operations, String appId) {
        super(oauth2Operations);
        this.appId = appId;
    }

    @Override
    public QQApiImpl getApi(String accessToken) {
        return new QQApiImpl(accessToken,appId);
    }
}

複製代碼

ConnectionFactory

UserInfo,封裝OpenAPI返回的用戶信息ajax

package top.zhenganwen.security.core.social.qq;

import lombok.Data;

import java.io.Serializable;

/** * @author zhenganwen * @date 2019/9/4 * @desc QQUserInfo 用戶在QQ應用註冊的信息 */
@Data
public class QQUserInfo implements Serializable {
    /** * 返回碼 */
    private String ret;
    /** * 若是ret<0,會有相應的錯誤信息提示,返回數據所有用UTF-8編碼。 */
    private String msg;
    /** * */
    private String openId;
    /** * 不知道什麼東西,文檔上沒寫,可是實際api返回裏有。 */
    private String is_lost;
    /** * 省(直轄市) */
    private String province;
    /** * 市(直轄市區) */
    private String city;
    /** * 出生年月 */
    private String year;
    /** * 用戶在QQ空間的暱稱。 */
    private String nickname;
    /** * 大小爲30×30像素的QQ空間頭像URL。 */
    private String figureurl;
    /** * 大小爲50×50像素的QQ空間頭像URL。 */
    private String figureurl_1;
    /** * 大小爲100×100像素的QQ空間頭像URL。 */
    private String figureurl_2;
    /** * 大小爲40×40像素的QQ頭像URL。 */
    private String figureurl_qq_1;
    /** * 大小爲100×100像素的QQ頭像URL。須要注意,不是全部的用戶都擁有QQ的100×100的頭像,但40×40像素則是必定會有。 */
    private String figureurl_qq_2;
    /** * 性別。 若是獲取不到則默認返回」男」 */
    private String gender;
    /** * 標識用戶是否爲黃鑽用戶(0:不是;1:是)。 */
    private String is_yellow_vip;
    /** * 標識用戶是否爲黃鑽用戶(0:不是;1:是) */
    private String vip;
    /** * 黃鑽等級 */
    private String yellow_vip_level;
    /** * 黃鑽等級 */
    private String level;
    /** * 標識是否爲年費黃鑽用戶(0:不是; 1:是) */
    private String is_yellow_year_vip;
}
複製代碼

ApiAdapter,將不一樣的第三方應用返回的不一樣用戶信息數據格式轉換成統一的用戶視圖redis

package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.ConnectionValues;
import org.springframework.social.connect.UserProfile;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.social.qq.QQUserInfo;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

/** * @author zhenganwen * @date 2019/9/4 * @desc QQConnectionAdapter 從不一樣第三方應用返回的不一樣用戶信息到統一用戶視圖{@link org.springframework.social.connect.Connection}的適配 */
@Component
public class QQConnectionAdapter implements ApiAdapter<QQApiImpl> {

    // 測試OpenAPI接口是否可用
    @Override
    public boolean test(QQApiImpl api) {
        return true;
    }

    /** * 調用OpenAPI獲取用戶信息並適配成{@link org.springframework.social.connect.Connection} * 注意: 不是全部的社交應用都對應有{@link org.springframework.social.connect.Connection}中的屬性,例如QQ就不像微博那樣有我的主頁 * @param api * @param values */
    @Override
    public void setConnectionValues(QQApiImpl api, ConnectionValues values) {
        QQUserInfo userInfo = api.getUserInfo();
        // 用戶暱稱
        values.setDisplayName(userInfo.getNickname());
        // 用戶頭像
        values.setImageUrl(userInfo.getFigureurl_2());
        // 用戶我的主頁
        values.setProfileUrl(null);
        // 用戶在社交平臺上的id
        values.setProviderUserId(userInfo.getOpenId());
    }

    // 此方法做用和 setConnectionValues 相似,在後續開發社交帳號綁定、解綁時再說
    @Override
    public UserProfile fetchUserProfile(QQApiImpl api) {
        return null;
    }

    /** * 調用OpenAPI更新用戶動態 * 因爲QQ OpenAPI沒有此功能,所以不用管(若是接入微博則可能須要重寫此方法) * @param api * @param message */
    @Override
    public void updateStatus(QQApiImpl api, String message) {

    }
}
複製代碼

ConnectionFactory算法

package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.social.connect.ApiAdapter;
import org.springframework.social.connect.support.OAuth2ConnectionFactory;
import org.springframework.social.oauth2.OAuth2ServiceProvider;
import top.zhenganwen.security.core.social.qq.api.QQApiImpl;

public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApiImpl> {

    public QQConnectionFactory(String providerId,OAuth2ServiceProvider<QQApiImpl> serviceProvider, ApiAdapter<QQApiImpl> apiAdapter) {
        super(providerId, serviceProvider, apiAdapter);
    }
}
複製代碼

createConnectionFactory

咱們須要重寫SocialAutoConfigurerAdapter中的createConnectionFactory方法注入咱們自定義的ConnectionFacory,SpringSoical將使用它來完成受權碼模式的第2~7步

package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.social.SocialAutoConfigurerAdapter;
import org.springframework.context.annotation.Bean;
import org.springframework.social.connect.ConnectionFactory;
import org.springframework.social.oauth2.OAuth2Operations;
import org.springframework.social.oauth2.OAuth2Template;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.SecurityProperties;

@Component
@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

    public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

    public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

    @Override
    protected ConnectionFactory<?> createConnectionFactory() {
        return new QQConnectionFactory(
                securityProperties.getQq().getProviderId(),
                new QQServiceProvider(oAuth2Operations(), securityProperties.getQq().getAppId()), 
                qqConnectionAdapter);
    }

    @Bean
    public OAuth2Operations oAuth2Operations() {
        return new OAuth2Template(
                securityProperties.getQq().getAppId(),
                securityProperties.getQq().getAppSecret(),
                URL_TO_GET_AUTHORIZATION_CODE,
                URL_TO_GET_TOKEN);
    }

}
複製代碼

QQSecurityProperties,QQ登陸相關配置項

package top.zhenganwen.security.core.social.qq.connect;

import lombok.Data;

@Data
public class QQSecurityPropertie {
    private String appId;
    private String appSecret;
    private String providerId = "qq";
}
複製代碼
package top.zhenganwen.security.core.properties;

@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
    private BrowserProperties browser = new BrowserProperties();
    private VerifyCodeProperties code = new VerifyCodeProperties();
    private QQSecurityPropertie qq = new QQSecurityPropertie();
}
複製代碼

UsersConnectionRepository

咱們須要一張表來維護當前系統用戶表與用戶在第三方應用註冊的信息之間的對應關係,SpringSocial爲咱們提供了該表(在JdbcUsersConnectionRepository.java文件同一目錄下)

CREATE TABLE UserConnection (
	userId VARCHAR (255) NOT NULL,
	providerId VARCHAR (255) NOT NULL,
	providerUserId VARCHAR (255),
	rank INT NOT NULL,
	displayName VARCHAR (255),
	profileUrl VARCHAR (512),
	imageUrl VARCHAR (512),
	accessToken VARCHAR (512) NOT NULL,
	secret VARCHAR (512),
	refreshToken VARCHAR (512),
	expireTime BIGINT,
	PRIMARY KEY (
		userId,
		providerId,
		providerUserId
	)
);

CREATE UNIQUE INDEX UserConnectionRank ON UserConnection (userId, providerId, rank);
複製代碼

其中userId爲當前系統用戶的惟一標識(不必定是用戶表主鍵,也能夠是用戶名,只要是用戶表中能惟一標識用戶的字段就行),providerId用來標識第三方應用,providerUserId是用戶在該第三方應用中的用戶標識。這三個字段可以標識第三方應用(providerId)用戶(providerUserId)在當前系統中對應的用戶(userId)。咱們將此SQL在Datasource對應的數據庫中執行如下。

SpringSocial爲咱們提供了JdbcUsersConnectionRepository做爲該張表的DAO,咱們須要將當前系統的數據源注入給它,並繼承SocialConfigurerAdapter和添加@EnableSocial來啓用SpringSocial的一些自動化配置

package top.zhenganwen.security.core.social.qq;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.social.config.annotation.EnableSocial;
import org.springframework.social.config.annotation.SocialConfigurerAdapter;
import org.springframework.social.connect.ConnectionFactoryLocator;
import org.springframework.social.connect.jdbc.JdbcUsersConnectionRepository;
import org.springframework.social.security.SpringSocialConfigurer;

import javax.sql.DataSource;

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Bean
	  @Primary	// 父類會默認使用InMemoryUsersConnectionRepository做爲實現,咱們要使用@Primary告訴容器只使用咱們這個 
    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三個參數能夠對 token 進行加密存儲
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

}
複製代碼

SocialAuthenticationFilter

萬變不離其中,使用第三方登陸的流程和用戶名密碼的認證流程實際上是同樣的。只不事後者是根據用戶輸入的用戶名到用戶表中查找用戶;而前者是先走OAtuh流程拿到用戶在第三方應用中的providerUserId,再根據providerIdproviderUserIdUserConnection表中查詢對應的userId,最後根據userId到用戶表中查詢用戶

image.png

所以咱們還須要啓用SocialAuthenticationFilter

package top.zhenganwen.security.core.social.qq;

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三個參數能夠對 token 進行加密存儲
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

    // 該bean是聯合登陸配置類,和咱們以前所寫的SmsLoginConfig和VerifyCodeValidatorConfig的
	  // 的做用是同樣的,只不過它是增長一個SocialAuthenticationFilter到過濾器鏈中 
    @Bean
    public SpringSocialConfigurer springSocialConfigurer() {
        return new SpringSocialConfigurer();
    }
}
複製代碼

SecurityBrowserConfig

@Override
    protected void configure(HttpSecurity http) throws Exception {

        // 啓用驗證碼校驗過濾器
        http.apply(verifyCodeValidatorConfig);
        // 啓用短信登陸過濾器
        http.apply(smsLoginConfig);
        // 啓用QQ登陸(將SocialAuthenticationFilter加入到Security過濾器鏈中)
        http.apply(springSocialConfigurer);
        ...
複製代碼

appId & appSecret & providerId

因爲每一個系統申請的appIdappSecret都不一樣,因此咱們將其抽取到了配置文件中

demo.security.qq.appId=YOUR_APP_ID #替換成你的appId
demo.security.qq.appSecret=YOUR_APP_SECRET #替換成你的appSecret
demo.security.qq.providerId=qq
複製代碼

聯合登陸URL設置規則

咱們須要在登陸頁提供一個QQ聯合登陸的連接,請求爲/auth/qq

<a href="/auth/qq">qq登陸</a>
複製代碼

第一個路徑/auth是應爲SocialAuthenticationFilter默認攔截/auth開頭的請求

SocialAuthenticationFilter

private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";
複製代碼

第二個路徑須要和providerId保持一致,而咱們配置的demo.security.qq.provider-idqq

SocialAuthenticationFilter

@Deprecated
	protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
		String providerId = getRequestedProviderId(request);
		if (providerId != null){
			Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
			return authProviders.contains(providerId);
		}
		return false;
	}
複製代碼

聯合登陸URL需和回調域保持一致

如今SpringSocial的各個組件咱們算是實現了,可是可否串起來走通整個流程,咱們能夠來試一下,並在逐步排錯的過程當中進一步理解Social認證的流程

訪問/login.html,點擊qq登陸後響應以下

image.png

提示咱們回調地址是非法的,咱們能夠看一下地址欄中的redirect_url參數

image.png

轉碼後其實就是http://localhost:8080/auth/qq,也就是說若是用戶贊成受權那麼瀏覽器將會重定向到聯合登陸的URL上。

而我在QQ互聯中申請時填寫的回調域是www.zhenganwen.top/socialLogin/qq(以下圖),QQ聯合登陸要求用戶贊成受權以後重定向到的URL必須和申請appId時填寫的回調域保持一致,也就是說頁面上聯合登陸的URL必須和回調域保持一致。

image.png

首先域名和端口須要保持一致:

因爲是本地服務器,所以咱們須要修改本地hosts文件,讓瀏覽器解析www.zhenganwen.top時解析到172.0.0.1

127.0.0.1 www.zhenganwen.top
複製代碼

而且將服務端口改成80

server.port=80
複製代碼

這樣域名和端口能對應上了,可以經過www.zhenganwen.top/login.html訪問登陸頁。

其次,還須要將聯合登陸URI和咱們在設置的回調域對應上,/auth改成/socialLogin,須要自定義SocialAuthenticationFilterfilterProcessesUrl屬性值:

新增SocialProperties

package top.zhenganwen.security.core.properties;

import lombok.Data;
import top.zhenganwen.security.core.social.qq.connect.QQSecurityPropertie;

@Data
public class SocialProperties {
    public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin";
    private QQSecurityPropertie qq = new QQSecurityPropertie();
    private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL;
}
複製代碼

修改SecurityProperties

@Data
@ConfigurationProperties(prefix = "demo.security")
public class SecurityProperties {
    private BrowserProperties browser = new BrowserProperties();
    private VerifyCodeProperties code = new VerifyCodeProperties();
	  // private QQSecurityPropertie qq = new QQSecurityPropertie(); 
    private SocialProperties social = new SocialProperties();
}
複製代碼

application.properties同步修改:

#demo.security.qq.appId=***
#demo.security.qq.appSecret=***
#demo.security.qq.providerId=qq
demo.security.social.qq.appId=***
demo.security.social.qq.appSecret=***
demo.security.social.qq.providerId=qq
複製代碼

QQLoginAutoConfig同步修改

@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {
複製代碼

擴展SpringSocialConfigurer,經過鉤子函數postProcess來實現對SocialAuthenticationFilter的一些自定義配置,如filterProcessingUrl

package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
import top.zhenganwen.security.core.properties.SecurityProperties;

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected <T> T postProcess(T object) {
        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object;
        filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl());
        return (T) filter;
    }
}
複製代碼

SocialConfig注入擴展後的SpringSocialConfigurer

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三個參數能夠對 token 進行加密存儲
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

// @Bean
// public SpringSocialConfigurer springSocialConfigurer() {
// return new SpringSocialConfigurer();
// }
                    
    @Bean
    public SpringSocialConfigurer qqSpringSocialConfigurer() {
        QQSpringSocialConfigurer qqSpringSocialConfigurer = new QQSpringSocialConfigurer();
        return qqSpringSocialConfigurer;
    }
}
複製代碼

這樣作的緣由是postProcess()是一個鉤子函數,在SecurityConfigurerAdapterconfig方法中,在將SocialAuthenticationFilter加入到過濾器鏈中時會調用postProcess,容許子類重寫該方法從而對SocialAuthenticationFilter進行一些自定義配置:

public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
	@Override
	public void configure(HttpSecurity http) throws Exception {		
		ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
		UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
		SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
		SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);
		
		SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
				http.getSharedObject(AuthenticationManager.class), 
				userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), 
				usersConnectionRepository, 
				authServiceLocator);
		
		...
		
		http.authenticationProvider(
				new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
			.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
	}
                    
	protected <T> T postProcess(T object) {
		return (T) this.objectPostProcessor.postProcess(object);
	}                    
}                    
複製代碼

同步修改登陸頁

<a href="/socialLogin/qq">qq登陸</a>
複製代碼

同時要在聯合登陸配置類中將該聯合登陸URL的攔截放開

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected <T> T postProcess(T object) {
        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object;
        filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl());
        return (T) filter;
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http.authorizeRequests()
                .mvcMatchers(securityProperties.getSocial().getFilterProcessingUrl() +
                        securityProperties.getSocial().getQq().getProviderId())
                .permitAll();
    }
}
複製代碼

訪問www.zhenganwen.top/login.html,點擊qq登陸發現跳轉以下

image.png

受權跳轉邏輯走通!該階段代碼可參見:gitee.com/zhenganwen/…

階段性小結

回調域解析

你是在本地80端口跑的服務,爲何認證服務器可以解析回調域www.zhenganwen.top/socialLogin/qq中的域名從而跳轉到你的本地

注意上面受權登陸頁面的地址欄,URL附帶了redirect_url這一參數,所以當你贊成受權登錄後,跳轉到redirect_url參數值這一操做是在你瀏覽器中進行的,而你在hosts中配置了127.0.0.1 www.zhenganwen.top,所以瀏覽器沒有進行域名解析直接將請求/socialLogin/qq發送到了127.0.0.1:80上,也就是咱們正在運行的security-demo服務

SpringSoicalConfigure的做用是什麼?

直接上源碼:

public class SpringSocialConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
	@Override
	public void configure(HttpSecurity http) throws Exception {		
		ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
		UsersConnectionRepository usersConnectionRepository = getDependency(applicationContext, UsersConnectionRepository.class);
		SocialAuthenticationServiceLocator authServiceLocator = getDependency(applicationContext, SocialAuthenticationServiceLocator.class);
		SocialUserDetailsService socialUsersDetailsService = getDependency(applicationContext, SocialUserDetailsService.class);
		
		SocialAuthenticationFilter filter = new SocialAuthenticationFilter(
				http.getSharedObject(AuthenticationManager.class), 
				userIdSource != null ? userIdSource : new AuthenticationNameUserIdSource(), 
				usersConnectionRepository, 
				authServiceLocator);
		
		...
                                          
		http.authenticationProvider(
				new SocialAuthenticationProvider(usersConnectionRepository, socialUsersDetailsService))
			.addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class);
	}
}                    
複製代碼

若是咱們想將以前所寫的SpringSoical組件都應用上,那就要遵循SpringSecurity的認證機制,即添加一個新的認證方式就須要添加一個XxxAuthenticationFilter,而SpringSoical已經幫咱們實現了SocialAuthenticationFilter,所以咱們只須要在過濾器中添加它就行。與咱們以前將短信登陸封裝到SmsLoginConfig中同樣,SpringSocial幫咱們將社交登陸封裝到了SpringSocialConfigure中,這樣只要業務系統(即依賴SpringSocial的應用)只需調用httpSecurity.apply(springSocialConfigure)便可啓用社交登陸功能。

而且除了將SoicalAuthenticationFilter添加到過濾器鏈中以外,SpringSocialConfigure還會將容器中的UsersConnectionRepositorySocialAuthenticationServiceLocator關聯到SoicalAuthenticationFilter中,SoicalAuthenticationFilter經過前者可以根據OAuth流程獲取的社交信息(providerIdproviderUserId)查詢到userId,經過後者可以根據providerId獲取對應的SocialAuthenticationService並從中獲取到ConnectionFactory進行獲取受權碼、獲取accessToken、獲取用戶社交信息等操做

public interface UsersConnectionRepository {
	List<String> findUserIdsWithConnection(Connection<?> connection);
}
複製代碼
public interface SocialAuthenticationServiceLocator extends ConnectionFactoryLocator {
	SocialAuthenticationService<?> getAuthenticationService(String providerId);
}                    
複製代碼
public interface SocialAuthenticationService<S> {
	ConnectionFactory<S> getConnectionFactory();
	SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException;
}
複製代碼

爲何要有SocialAuthenticationService,是在何時產生的?

SocialAuthenticationService是對ConnectionFactory的一個封裝,對SocialAuthenticationFilter隱藏OAuth以及OpenAPI調用細節

由於咱們在SocialConfig中添加了@EnableSocial,因此在系統啓動時會根據SocialAutoConfigurerAdapter實現類中的createConnectionFactory建立對應不一樣社交系統的ConnectionFactory並將其包裝成SocialAuthenticationService,而後將全部的SocialAuthenticationServiceproviderIdkey緩存在SocialAuthenticationLocator

@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

    public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

    public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

    @Override
    protected ConnectionFactory<?> createConnectionFactory() {
        return new QQConnectionFactory(
                securityProperties.getSocial().getQq().getProviderId(),
                new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()),
                qqConnectionAdapter);
    }

    @Bean
    public OAuth2Operations oAuth2Operations() {
        return new OAuth2Template(
                securityProperties.getSocial().getQq().getAppId(),
                securityProperties.getSocial().getQq().getAppSecret(),
                URL_TO_GET_AUTHORIZATION_CODE,
                URL_TO_GET_TOKEN);
    }

}
複製代碼
class SecurityEnabledConnectionFactoryConfigurer implements ConnectionFactoryConfigurer {

	private SocialAuthenticationServiceRegistry registry;
	
	public SecurityEnabledConnectionFactoryConfigurer() {
		registry = new SocialAuthenticationServiceRegistry();
	}
	
	public void addConnectionFactory(ConnectionFactory<?> connectionFactory) {
		registry.addAuthenticationService(wrapAsSocialAuthenticationService(connectionFactory));
	}
	
	public ConnectionFactoryRegistry getConnectionFactoryLocator() {
		return registry;
	}

	private <A> SocialAuthenticationService<A> wrapAsSocialAuthenticationService(ConnectionFactory<A> cf) {
		if (cf instanceof OAuth1ConnectionFactory) {
			return new OAuth1AuthenticationService<A>((OAuth1ConnectionFactory<A>) cf);
		} else if (cf instanceof OAuth2ConnectionFactory) {
			final OAuth2AuthenticationService<A> authService = new OAuth2AuthenticationService<A>((OAuth2ConnectionFactory<A>) cf);
			authService.setDefaultScope(((OAuth2ConnectionFactory<A>) cf).getScope());
			return authService;
		}
		throw new IllegalArgumentException("The connection factory must be one of OAuth1ConnectionFactory or OAuth2ConnectionFactory");
	}
	
}
複製代碼
public class SocialAuthenticationServiceRegistry extends ConnectionFactoryRegistry implements SocialAuthenticationServiceLocator {

	private Map<String, SocialAuthenticationService<?>> authenticationServices = new HashMap<String, SocialAuthenticationService<?>>();

	public SocialAuthenticationService<?> getAuthenticationService(String providerId) {
		SocialAuthenticationService<?> authenticationService = authenticationServices.get(providerId);
		if (authenticationService == null) {
			throw new IllegalArgumentException("No authentication service for service provider '" + providerId + "' is registered");
		}
		return authenticationService;
	}

	public void addAuthenticationService(SocialAuthenticationService<?> authenticationService) {
		addConnectionFactory(authenticationService.getConnectionFactory());
		authenticationServices.put(authenticationService.getConnectionFactory().getProviderId(), authenticationService);
	}

	public void setAuthenticationServices(Iterable<SocialAuthenticationService<?>> authenticationServices) {
		for (SocialAuthenticationService<?> authenticationService : authenticationServices) {
			addAuthenticationService(authenticationService);
		}
	}

	public Set<String> registeredAuthenticationProviderIds() {
		return authenticationServices.keySet();
	}

}
複製代碼

因此當SocialAuthenticationFilter攔截到/{filterProcessingUrl}/{providerId}以後,會根據出URL路徑中的providerIdSocialAuthenticationLocator中查找對應的SocialAuthenticationService獲取authRequest

public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	@Deprecated
	protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
		String providerId = getRequestedProviderId(request);
		if (providerId != null){
			Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
			return authProviders.contains(providerId);
		}
		return false;
	}     

	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
		if (detectRejection(request)) {
			if (logger.isDebugEnabled()) {
				logger.debug("A rejection was detected. Failing authentication.");
			}
			throw new SocialAuthenticationException("Authentication failed because user rejected authorization.");
		}
		
		Authentication auth = null;
		Set<String> authProviders = authServiceLocator.registeredAuthenticationProviderIds();
		String authProviderId = getRequestedProviderId(request);
		if (!authProviders.isEmpty() && authProviderId != null && authProviders.contains(authProviderId)) {
			SocialAuthenticationService<?> authService = authServiceLocator.getAuthenticationService(authProviderId);
			auth = attemptAuthService(authService, request, response);
			if (auth == null) {
				throw new AuthenticationServiceException("authentication failed");
			}
		}
		return auth;
	}    
                    
}                    
複製代碼

爲何社交登陸URL和回調域要保持一致

SocialAuthenticationFilter#attemptAuthService

private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException, AuthenticationException {

		final SocialAuthenticationToken token = authService.getAuthToken(request, response);
		if (token == null) return null;
		
		Assert.notNull(token.getConnection());
		
		Authentication auth = getAuthentication();
		if (auth == null || !auth.isAuthenticated()) {
			return doAuthentication(authService, request, token);
		} else {
			addConnection(authService, request, token, auth);
			return null;
		}		
	}	
複製代碼

OAuth2AuthenticationService#getAuthToken

public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
		String code = request.getParameter("code");
		if (!StringUtils.hasText(code)) {
			OAuth2Parameters params =  new OAuth2Parameters();
			params.setRedirectUri(buildReturnToUrl(request));
			setScope(request, params);
			params.add("state", generateState(connectionFactory, request));
			addCustomParameters(params);
			throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
		} else if (StringUtils.hasText(code)) {
			try {
				String returnToUrl = buildReturnToUrl(request);
				AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
				// TODO avoid API call if possible (auth using token would be fine)
				Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
				return new SocialAuthenticationToken(connection, null);
			} catch (RestClientException e) {
				logger.debug("failed to exchange for access", e);
				return null;
			}
		} else {
			return null;
		}
	}
複製代碼

能夠發現,用戶在登陸也上點擊qq登陸時被SocialAuthenticationFilter攔截,進入到上述的getAuthToken方法,請求參數是不帶受權碼的,所以第9行會拋出異常,該異常會被認證失敗處理器截獲並將用戶導向社交系統認證服務器

public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private AuthenticationFailureHandler delegate;

    public SocialAuthenticationFailureHandler(AuthenticationFailureHandler delegate) {
        this.delegate = delegate;
    }

    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        if (failed instanceof SocialAuthenticationRedirectException) {
            response.sendRedirect(((SocialAuthenticationRedirectException)failed).getRedirectUrl());
        } else {
            this.delegate.onAuthenticationFailure(request, response, failed);
        }
    }
}
複製代碼

在用戶贊成受權後,認證服務器跳轉到回調域並帶入受權碼,這時就會進入getAuthToken的第11行,拿受權碼獲取accessTokenAccessGrant)、調用OpenAPI獲取用戶信息並適配成Connection

爲何贊成受權後響應以下

image.png

咱們掃描二維碼贊成受權,瀏覽器重定向到/socialLogin/qq以後,發生了什麼

public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
		String code = request.getParameter("code");
		if (!StringUtils.hasText(code)) {
			OAuth2Parameters params =  new OAuth2Parameters();
			params.setRedirectUri(buildReturnToUrl(request));
			setScope(request, params);
			params.add("state", generateState(connectionFactory, request));
			addCustomParameters(params);
			throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
		} else if (StringUtils.hasText(code)) {
			try {
				String returnToUrl = buildReturnToUrl(request);
				AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
				// TODO avoid API call if possible (auth using token would be fine)
				Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
				return new SocialAuthenticationToken(connection, null);
			} catch (RestClientException e) {
				logger.debug("failed to exchange for access", e);
				return null;
			}
		} else {
			return null;
		}
}
複製代碼

在上述帶啊的第12行打斷點進行跟蹤一下,發現執行13行時拋出異常跳轉到了18行,異常信息以下:

org.springframework.web.client.RestClientException: Could not extract response: no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
複製代碼

說明是在調用咱們的OAuth2TemplateexchangeForAccess拿受權碼獲取accessToken時報錯了,錯誤緣由是在轉換響應結果爲AccessGrant時沒有處理text/html的轉換器。

首先咱們看一下響應結果是什麼:

image.png

發現響應結果是一個字符串,以&分割三個鍵值對,而OAuth2Template默認提供的轉換器以下:

OAuth2Template

protected RestTemplate createRestTemplate() {
		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactorySelector.getRequestFactory();
		RestTemplate restTemplate = new RestTemplate(requestFactory);
		List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>(2);
		converters.add(new FormHttpMessageConverter());
		converters.add(new FormMapHttpMessageConverter());
		converters.add(new MappingJackson2HttpMessageConverter());
		restTemplate.setMessageConverters(converters);
		restTemplate.setErrorHandler(new LoggingErrorHandler());
		if (!useParametersForClientAuthentication) {
			List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors();
			if (interceptors == null) {   // defensively initialize list if it is null. (See SOCIAL-430)
				interceptors = new ArrayList<ClientHttpRequestInterceptor>();
				restTemplate.setInterceptors(interceptors);
			}
			interceptors.add(new PreemptiveBasicAuthClientHttpRequestInterceptor(clientId, clientSecret));
		}
		return restTemplate;
}	
複製代碼

查看上述5~7行的3個轉換器,FormHttpMessageConverterFormMapHttpMessageConverterMappingJackson2HttpMessageConverter分別對應解析Content-Typeapplication/x-www-form-urlencodedmultipart/form-dataapplication/json的響應體,所以報錯提示

no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
複製代碼

這時咱們須要在原有的OAuth2Template的基礎上在增長一個處理text/html的轉換器:

public class QQOAuth2Template extends OAuth2Template {
    public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
        super(clientId, clientSecret, authorizeUrl, accessTokenUrl);
        setUseParametersForClientAuthentication(true);
    }

    /** * 添加消息轉換器以使可以解析 Content-Type 爲 text/html 的響應體 * StringHttpMessageConverter 可解析任何 Content-Type的響應體,見其構造函數 * @return */
    @Override
    protected RestTemplate createRestTemplate() {
        RestTemplate restTemplate = super.createRestTemplate();
        restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
        return restTemplate;
    }

    /** * 若是響應體是json,OAuth2Template會幫咱們構建, 但QQ互聯的OpenAPI返回包都是 text/html 字符串 * 響應體 : "access_token=FE04***********CCE2&expires_in=7776000&refresh_token=88E4********BE14" * 使用 StringHttpMessageConverter 將請求的響應體轉成 String ,並手動構建 AccessGrant * @param accessTokenUrl 拿受權碼獲取accessToken的URL * @param parameters 請求 accessToken 須要附帶的參數 * @return */
    @Override
    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
        String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters,String.class);
        if (StringUtils.isEmpty(responseStr)) {
            return null;
        }
        // 0 -> access_token=FE04***********CCE
        // 1 -> expires_in=7776000
        // 2 -> refresh_token=88E4********BE14
        String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");
        // accessToken scope refreshToken expiresIn
        AccessGrant accessGrant = new AccessGrant(
                StringUtils.substringAfterLast(strings[0], "="),
                null,
                StringUtils.substringAfterLast(strings[2], "="),
                Long.valueOf(StringUtils.substringAfterLast(strings[1], "=")));
        return accessGrant;
    }
}
複製代碼

使用該QQOAuth2Template替換以前注入的OAuth2Template

@Component
//@ConditionalOnProperty(prefix = "demo.security.qq",name = "appId")
@ConditionalOnProperty(prefix = "demo.security.social.qq",name = "appId")
public class QQLoginAutoConfig extends SocialAutoConfigurerAdapter {

    public static final String URL_TO_GET_AUTHORIZATION_CODE = "https://graph.qq.com/oauth2.0/authorize";

    public static final String URL_TO_GET_TOKEN = "https://graph.qq.com/oauth2.0/token";

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

    @Override
    protected ConnectionFactory<?> createConnectionFactory() {
        return new QQConnectionFactory(
                securityProperties.getSocial().getQq().getProviderId(),
                new QQServiceProvider(oAuth2Operations(), securityProperties.getSocial().getQq().getAppId()),
                qqConnectionAdapter);
    }

// @Bean
// public OAuth2Operations oAuth2Operations() {
// return new OAuth2Template(
// securityProperties.getSocial().getQq().getAppId(),
// securityProperties.getSocial().getQq().getAppSecret(),
// URL_TO_GET_AUTHORIZATION_CODE,
// URL_TO_GET_TOKEN);
// }

    @Bean
    public OAuth2Operations oAuth2Operations() {
        return new QQOAuth2Template(
                securityProperties.getSocial().getQq().getAppId(),
                securityProperties.getSocial().getQq().getAppSecret(),
                URL_TO_GET_AUTHORIZATION_CODE,
                URL_TO_GET_TOKEN);
    }

}
複製代碼

如今咱們可以拿到封裝accessTokenAccessGrant了,再繼續端點調試Connection的獲取(下述第15行)

OAuth2AuthenticationService

public SocialAuthenticationToken getAuthToken(HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException {
		String code = request.getParameter("code");
		if (!StringUtils.hasText(code)) {
			OAuth2Parameters params =  new OAuth2Parameters();
			params.setRedirectUri(buildReturnToUrl(request));
			setScope(request, params);
			params.add("state", generateState(connectionFactory, request));
			addCustomParameters(params);
			throw new SocialAuthenticationRedirectException(getConnectionFactory().getOAuthOperations().buildAuthenticateUrl(params));
		} else if (StringUtils.hasText(code)) {
			try {
				String returnToUrl = buildReturnToUrl(request);
				AccessGrant accessGrant = getConnectionFactory().getOAuthOperations().exchangeForAccess(code, returnToUrl, null);
				// TODO avoid API call if possible (auth using token would be fine)
				Connection<S> connection = getConnectionFactory().createConnection(accessGrant);
				return new SocialAuthenticationToken(connection, null);
			} catch (RestClientException e) {
				logger.debug("failed to exchange for access", e);
				return null;
			}
		} else {
			return null;
		}
}
複製代碼

發現QQApiImplgetUserInfo存在同一的問題,調用QQ互聯API響應類型都是text/html,所以咱們不能直接轉成POJO,而要先獲取響應串,在經過JSON轉換工具類ObjectMapper來轉換:

QQApiImpl

@Override
    public QQUserInfo getUserInfo() {
        // QQ互聯的響應 Content-Type 都是 text/html,所以不能直接轉爲 QQUserInfo
// QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
        String responseStr = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), String.class);
        logger.info("調用QQ OpenAPI獲取用戶信息: {}", responseStr);
        try {
            QQUserInfo qqUserInfo = objectMapper.readValue(responseStr, QQUserInfo.class);
            qqUserInfo.setOpenId(openId);
            return qqUserInfo;
        } catch (Exception e) {
            logger.error("獲取用戶信息轉成 QQUserInfo 失敗,響應信息:{}", responseStr);
            return null;
        }
    }
複製代碼

再次掃碼登陸進行斷點調試,發現Connection也能成功拿到了,而且封裝成SocialAuthenticationToken返回,因而getAuthToken終於成功返回了,走到了doAuthentication

SocialAuthenticationFilter

private Authentication attemptAuthService(final SocialAuthenticationService<?> authService, final HttpServletRequest request, HttpServletResponse response) throws SocialAuthenticationRedirectException, AuthenticationException {

		final SocialAuthenticationToken token = authService.getAuthToken(request, response);
		if (token == null) return null;
		
		Assert.notNull(token.getConnection());
		
		Authentication auth = getAuthentication();
		if (auth == null || !auth.isAuthenticated()) {
			return doAuthentication(authService, request, token);
		} else {
			addConnection(authService, request, token, auth);
			return null;
		}		
}

private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
		try {
			if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
			token.setDetails(authenticationDetailsSource.buildDetails(request));
			Authentication success = getAuthenticationManager().authenticate(token);
			Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type");
			updateConnections(authService, token, success);			
			return success;
		} catch (BadCredentialsException e) {
			// connection unknown, register new user?
			if (signupUrl != null) {
				// store ConnectionData in session and redirect to register page
				sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
				throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
			}
			throw e;
		}
}
複製代碼

這時會調用ProviderManagerauthenticateSocialAuthenticationToken進行校驗,ProviderManager又會委託SocialAuthenticationProvider

SocialAuthenticationProvider會調用咱們注入的JdbcUsersConnectionRepositoryUserConnection表中根據ConnectionproviderIdproviderUserId查找userId

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(SocialAuthenticationToken.class, authentication, "unsupported authentication type");
		Assert.isTrue(!authentication.isAuthenticated(), "already authenticated");
		SocialAuthenticationToken authToken = (SocialAuthenticationToken) authentication;
		String providerId = authToken.getProviderId();
		Connection<?> connection = authToken.getConnection();

		String userId = toUserId(connection);
		if (userId == null) {
			throw new BadCredentialsException("Unknown access token");
		}

		UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
		if (userDetails == null) {
			throw new UsernameNotFoundException("Unknown connected account id");
		}

		return new SocialAuthenticationToken(connection, userDetails, authToken.getProviderAccountData(), getAuthorities(providerId, userDetails));
}

protected String toUserId(Connection<?> connection) {
		List<String> userIds = usersConnectionRepository.findUserIdsWithConnection(connection);
		// only if a single userId is connected to this providerUserId
		return (userIds.size() == 1) ? userIds.iterator().next() : null;
}
複製代碼

JdbcUsersConnectionRepository

public List<String> findUserIdsWithConnection(Connection<?> connection) {
		ConnectionKey key = connection.getKey();
		List<String> localUserIds = jdbcTemplate.queryForList("select userId from " + tablePrefix + "UserConnection where providerId = ? and providerUserId = ?", String.class, key.getProviderId(), key.getProviderUserId());		
		if (localUserIds.size() == 0 && connectionSignUp != null) {
			String newUserId = connectionSignUp.execute(connection);
			if (newUserId != null)
			{
				createConnectionRepository(newUserId).addConnection(connection);
				return Arrays.asList(newUserId);
			}
		}
		return localUserIds;
}
複製代碼

因爲找不到(由於這時咱們的UserConnection表壓根就沒數據),toUserId會返回null,接着拋出BadCredentialsException("Unknown access token"),該異常會被SocialAuthenticationFilter捕獲,並根據其signupUrl屬性進行重定向(SpringSocial認爲該用戶在本系統沒有註冊,或者註冊了但沒有將本地用戶和QQ登陸關聯,所以跳轉到註冊頁)

private Authentication doAuthentication(SocialAuthenticationService<?> authService, HttpServletRequest request, SocialAuthenticationToken token) {
		try {
			if (!authService.getConnectionCardinality().isAuthenticatePossible()) return null;
			token.setDetails(authenticationDetailsSource.buildDetails(request));
			Authentication success = getAuthenticationManager().authenticate(token);
			Assert.isInstanceOf(SocialUserDetails.class, success.getPrincipal(), "unexpected principal type");
			updateConnections(authService, token, success);			
			return success;
		} catch (BadCredentialsException e) {
			// connection unknown, register new user?
			if (signupUrl != null) {
				// store ConnectionData in session and redirect to register page
				sessionStrategy.setAttribute(new ServletWebRequest(request), ProviderSignInAttempt.SESSION_ATTRIBUTE, new ProviderSignInAttempt(token.getConnection()));
				throw new SocialAuthenticationRedirectException(buildSignupUrl(request));
			}
			throw e;
		}
}
複製代碼

SocialAuthenticationFiltersignupUrl默認爲/signup

public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	private String signupUrl = "/signup";
}                    
複製代碼

跳轉到/signup時,被SpringSecurity攔截,並重定向到loginPage(),最後到了BrowserSecurityController

SecurityBrowserConfig

.formLogin()
		.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
複製代碼

SecurityConstants

/** * 未登陸訪問受保護URL則跳轉路徑到 此 */
String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";
複製代碼

BrowserSecurityController

@RestController
public class BrowserSecurityController {

    private Logger logger = LoggerFactory.getLogger(getClass());

    // security會將跳轉前的請求存儲在session中
    private RequestCache requestCache = new HttpSessionRequestCache();

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Autowired
    SecurityProperties securityProperties;

    @RequestMapping("/auth/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponseResult requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {

        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String redirectUrl = savedRequest.getRedirectUrl();
            logger.info("引起跳轉到/auth/login的請求是: {}", redirectUrl);
            if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
                // 若是用戶是訪問html頁面被FilterSecurityInterceptor攔截從而跳轉到了/auth/login,那麼就重定向到登陸頁面
                redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
            }
        }

        // 若是不是訪問html而被攔截跳轉到了/auth/login,則返回JSON提示
        return new SimpleResponseResult("用戶未登陸,請引導用戶至登陸頁");
    }
}
複製代碼

因而最終獲得了以下響應:

image.png

@EnableSocial作了些什麼

它會加載一個配置類SocialConfiguration,該類會讀取容器中SocialConfigure實例,如咱們所寫的擴展SocialAutoConfigureAdapterQQLoginAutoConfig和擴展了SocialConfigureAdapterSocialConfig,將咱們實現的ConnectionFactoryUsersConnectionRepositorySpringSecurity的認證流程串起來

/** * Configuration class imported by {@link EnableSocial}. * @author Craig Walls */
@Configuration
public class SocialConfiguration {

	private static boolean securityEnabled = isSocialSecurityAvailable();
	
	@Autowired
	private Environment environment;
	
	private List<SocialConfigurer> socialConfigurers;

	@Autowired
	public void setSocialConfigurers(List<SocialConfigurer> socialConfigurers) {
		Assert.notNull(socialConfigurers, "At least one configuration class must implement SocialConfigurer (or subclass SocialConfigurerAdapter)");
		Assert.notEmpty(socialConfigurers, "At least one configuration class must implement SocialConfigurer (or subclass SocialConfigurerAdapter)");
		this.socialConfigurers = socialConfigurers;
	}

	@Bean
	public ConnectionFactoryLocator connectionFactoryLocator() {
		if (securityEnabled) {
			SecurityEnabledConnectionFactoryConfigurer cfConfig = new SecurityEnabledConnectionFactoryConfigurer();
			for (SocialConfigurer socialConfigurer : socialConfigurers) {
				socialConfigurer.addConnectionFactories(cfConfig, environment);
			}
			return cfConfig.getConnectionFactoryLocator();
		} else {
			DefaultConnectionFactoryConfigurer cfConfig = new DefaultConnectionFactoryConfigurer();
			for (SocialConfigurer socialConfigurer : socialConfigurers) {
				socialConfigurer.addConnectionFactories(cfConfig, environment);
			}
			return cfConfig.getConnectionFactoryLocator();
		}
	}
	
	@Bean
	public UsersConnectionRepository usersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
		UsersConnectionRepository usersConnectionRepository = null;
		for (SocialConfigurer socialConfigurer : socialConfigurers) {
			UsersConnectionRepository ucrCandidate = socialConfigurer.getUsersConnectionRepository(connectionFactoryLocator);
			if (ucrCandidate != null) {
				usersConnectionRepository = ucrCandidate;
				break;
			}
		}
		Assert.notNull(usersConnectionRepository, "One configuration class must implement getUsersConnectionRepository from SocialConfigurer.");
		return usersConnectionRepository;
	}
}

複製代碼

註冊頁 & 關聯社交帳號

首先將註冊頁的URL可配置化,默認設爲/sign-up.html,以及處理註冊的服務接口/user/register

@Data
public class SocialProperties {

  private QQSecurityPropertie qq = new QQSecurityPropertie();

  public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin";                    
  private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL;

  public static final String DEFAULT_SIGN_UP_URL = "/sign-up.html";                    
  private String signUpUrl = DEFAULT_SIGN_UP_URL;

  public static final String DEFAULT_SING_UP_PROCESSING_URL = "/user/register";
  private String signUpProcessingUrl = DEFAULT_SING_UP_PROCESSING_URL;                    
}
複製代碼

而後在瀏覽器配置類中將此路徑放開:

@Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 啓用驗證碼校驗過濾器
        http.apply(verifyCodeValidatorConfig).and()
        // 啓用短信登陸過濾器
            .apply(smsLoginConfig).and()
        // 啓用QQ登陸
            .apply(qqSpringSocialConfigurer).and()
            // 啓用表單密碼登陸過濾器
            .formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
            // 瀏覽器應用特有的配置,將登陸後生成的token保存在cookie中
            .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(customUserDetailsService)
                .and()
            // 瀏覽器應用特有的配置
            .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl()).permitAll()
                .anyRequest().authenticated().and()
            .csrf().disable();
    }
複製代碼

最後編寫註冊頁:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>標準註冊頁</h1>
    <a href="/social">QQ帳號信息</a>
    <form action="/user/register" method="post">
      用戶名: <input type="text" name="username" value="admin">
      密碼: <input type="password" name="password" value="123">
      <button type="submit" name="type" value="register">註冊並關聯QQ登陸</button>
      <button type="submit" name="type" value="binding">已有帳號關聯QQ登陸</button>
    </form>

  </body>
</html>
複製代碼

ProviderSignInUtils

註冊服務:雖然由於在UserConnection表中沒有和本地用戶關聯的記錄而跳轉到了註冊頁,可是獲取的Connection或保存在Session中,若是你想在用戶點擊註冊本地帳號時自動爲其關聯QQ帳號或用戶已有本地帳號本身手動關聯QQ帳號,那麼可使用ProviderSignInUtils這個工具類,你只須要告訴其須要關聯的本地帳戶userId,它會自動取出Session中保存的Connection,並將userIdConnection.getProviderIdConnection.getProviderUserId做爲一條記錄插入到數據庫中,這樣該用戶下次再進行QQ登陸時就不會跳轉到本地帳號註冊頁了

@RestController
@RequestMapping("/user")
public class UserController {

  private Logger logger = LoggerFactory.getLogger(getClass());

  @Autowired
  private UserService userService;

  @Autowired
  private ProviderSignInUtils providerSignInUtils;

  @PostMapping("/register")
  public String register(String username, String password, String type, HttpServletRequest request) {
    if ("register".equalsIgnoreCase(type)) {
      logger.info("新增用戶並關聯QQ登陸, 用戶名:{}", username);
      userService.insertUser();
    } else if ("binding".equalsIgnoreCase(type)) {
      logger.info("給用戶關聯QQ登陸, 用戶名:{}", username);
    }
    providerSignInUtils.doPostSignUp(username, new ServletWebRequest(request));
    return "success";
  }
}                    
複製代碼

關聯QQ帳號.gif

綁定/解綁場景支持

有時咱們的系統的帳號管理模塊須要容許用戶關聯或取消關聯一些社交帳號,SpringSocial對這一場景也提供了支持(見ConnectController)。你只需自定義相關的視圖組件(可擴展AbstractView)即可實現「綁定/解綁」功能。

Session管理

單機Session管理

事實上,咱們所自定義的登陸流程只會在登陸時被執行一次,登陸成功後會生成一個封裝認證信息的Authentication保存在本地線程保險箱中,而在後續的用戶訪問受保護URL等操做時就不會在涉及到這些登陸流程中的組件了。

讓咱們再回想一下Spring Security的過濾器鏈,位於首位的是SecurityContextPersistenceFilter,它用於在收到請求時試圖從Session中讀取登陸成功後生成的認證信息放入當前線程保險箱中,在響應請求時再取出來放入Session中,而位於過濾器鏈末尾的FilterSecurityInterceptor會在訪問Controller服務以前校驗線程保險箱中的認證信息,所以Session的管理會直接影響到用戶此刻可否繼續訪問受保護URL。

在SpringBoot中,咱們能夠經過配置項server.session.timeout(單位秒)來設置Session的有效時長,從而實現用戶登陸一段時間以後若是還在訪問受保護URL則須要從新登錄。

相關代碼位於TomcatEmbeddedServletContainerFactory

private void configureSession(Context context) {
		long sessionTimeout = getSessionTimeoutInMinutes();
		context.setSessionTimeout((int) sessionTimeout);
		if (isPersistSession()) {
			Manager manager = context.getManager();
			if (manager == null) {
				manager = new StandardManager();
				context.setManager(manager);
			}
			configurePersistSession(manager);
		}
		else {
			context.addLifecycleListener(new DisablePersistSessionListener());
		}
	}

private long getSessionTimeoutInMinutes() {
		long sessionTimeout = getSessionTimeout();
		if (sessionTimeout > 0) {
			sessionTimeout = Math.max(TimeUnit.SECONDS.toMinutes(sessionTimeout), 1L);
		}
		return sessionTimeout;
	}
複製代碼

SpringBoot會將你配置的秒數轉爲分鐘數,所以你會發現設置了server.session.timeout=10卻發現1分鐘後Session才失效致使須要從新登錄的狀況。

application.properties

server.session.timeout=10 	#設置Session 10秒後過時
複製代碼

不過咱們通常設置爲幾個小時

與未登錄而訪問受保護URL不一樣,Session失效致使沒法訪問受保護URL應該有不同的提示(例如:由於長時間沒有操做,您登錄的會話已過時,請從新登錄;而不該該提示您還未登陸,請先登陸),這時咱們能夠配置http.sessionManage().invalidSessionUrl()來指定用戶登陸時間超過server.session.timeout設定的時長以後用戶再訪問受保護URL會跳轉到的URL,你能夠爲其配置一個頁面或者Controller來提示用戶並引導用戶到登陸頁

SecurityBrowserConfig

protected void configure(HttpSecurity http) throws Exception {

        // 啓用驗證碼校驗過濾器
        http.apply(verifyCodeValidatorConfig).and()
        // 啓用短信登陸過濾器
            .apply(smsLoginConfig).and()
        // 啓用QQ登陸
            .apply(qqSpringSocialConfigurer).and()
            // 啓用表單密碼登陸過濾器
            .formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
            // 瀏覽器應用特有的配置,將登陸後生成的token保存在cookie中
            .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(customUserDetailsService)
                .and()
            .sessionManagement()
                .invalidSessionUrl("/session-invalid.html")
                .and()
            // 瀏覽器應用特有的配置
            .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl(),
                        "/session-invalid.html").permitAll()
                .anyRequest().authenticated().and()
            .csrf().disable();
    }
複製代碼

.sessionManagement()配置下:

經過.maximumSessions能夠控制一個用戶同時可登陸的會話數,若是設置爲1則可實現後一個登陸的人會踢掉前一個登陸的人。,經過expiredSessionStrategy能夠爲該事件設置一個回調方法(前一我的被擠掉後再訪問受保護URL時調用),可經過回調參數獲取requestresponse

經過.maxSessionsPreventsLogin(true)可設置若用戶已登陸,則在其餘會話沒法再次登陸,Session因爲timeout的設置失效或二次登陸被阻止,均可以經過.invalidSessionStrategy()配置一個處理策略

集羣Session管理

爲了實現高可用和高併發,企業級應用一般會採用集羣的方式部署服務,經過網關或代理將請求根據輪詢算法轉發的到特定的服務,這時若是每一個服務單獨管理本身的Session,那麼就會出現重複要求用戶登陸的狀況。咱們能夠將Session的管理抽離出來存儲到一個單獨的系統中,spring-session項目能夠幫咱們完成這份工做,咱們只需告訴它用什麼存儲系統來存儲Session便可。

一般咱們使用Redis來存儲Session而不使用Mysql,緣由以下:

  • SpringSecurity針對每次請求都會從Session中讀取認證信息,所以讀取比較頻繁,使用緩存系統速度較快
  • Session是有有效時間的,若是存儲在Mysql中本身還需定時清理,而Redis自己就自帶緩存數據時效性

安裝Redis

官網,下載編譯

$ wget http://download.redis.io/releases/redis-5.0.5.tar.gz
$ tar xzf redis-5.0.5.tar.gz
$ cd redis-5.0.5
$ make MALLOC=libc
複製代碼

若是提示找不到相關命令則需安裝相關依賴,yum install -y gcc g++ gcc-c++ make

啓動服務:

./src/redis-server

因爲我是在虛擬機CentOS6.5中安裝的,而Redis默認的保護機制只容許本地訪問,要想宿主機或外網訪問則需配置./redis.conf,新增bind 192.168.102.2(個人宿主機局域網IP)可以讓宿主機訪問IP,這至關於增長一個IP白名單,若是想全部主機都能訪問該服務,則可配置bind 0.0.0.0

修改配置後,須要再啓動時指定讀取該配置文件以使配置項生效:./src/redis-server ./redis.conf &

SpringBoot配置文件

application.properties中新增spring.redis.host=192.168.102.101,可指定SpringBoot啓動時鏈接該主機的Redis(默認端口6379),並將以前的排除Redis自動集成註解去掉

//@SpringBootApplication(exclude = {RedisAutoConfiguration.class,RedisRepositoriesAutoConfiguration.class})
@SpringBootApplication
@RestController
@EnableSwagger2
public class SecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityDemoApplication.class, args);
    }

    @RequestMapping("/hello")
    public String hello() {
        return "hello spring security";
    }
}
複製代碼

在配置文件總指定將Session託管給Redis

spring.session.store-type=redis
spring.redis.host=192.168.102.101
複製代碼

可支持的託管類型封裝在了org.springframework.boot.autoconfigure.session.StoreType中。

使用集羣模式後,以前配置的timeouthttp.sessionManagement()依然生效。

注意:將Session託管給存儲系統以後,要確保寫入Session中的Bean是可序列化的,即實現了Serializable接口,若是Bean中的屬性沒法序列化,例如ImageCode中的BufferedImage image,若是不須要存儲到Session中,則能夠在寫入Session時將該屬性置爲null

@Override
public void save(ServletWebRequest request, ImageCode imageCode) {
    ImageCode ic = new ImageCode(imageCode.getCode(), null, imageCode.getExpireTime());
    sessionStrategy.setAttribute(request, SecurityConstants.IMAGE_CODE_SESSION_KEY, ic);
}
複製代碼

退出登陸

如何退出登陸

Security爲咱們提供了一個默認註銷當前用戶的服務/logout,默認會作以下3件事:

  • 使當前Session失效
  • 清除remember-me功能的相關信息
  • 清除SecurityContext中的內容

咱們能夠經過http.logout()來自定義註銷登陸邏輯

  • logoutUrl(),指定註銷操做請求的URL
  • logoutSuccessUrl(),註銷完成後跳轉到的URL
  • logoutSuccessHandler(),註銷完成後調用的處理器,可根據用戶請求類型動態響應頁面或JSON
  • deleteCookies(),根據key刪除Cookie中的item

Spring Security OAuth開發APP認證框架

咱們以前所講的一切都是基於B/S架構的,即用戶經過瀏覽器直接訪問咱們的服務,是基於Session/Cookie的。可是如今先後端分離架構愈發流行,用戶多是直接訪問APP或WebServer(如nodejs),而APP和WebServer再經過ajax調用後端的服務,這一場景下Session/Cookie模式會有不少缺點

  • 開發繁瑣,須要頻繁針對Session/Cookie進行讀寫操做,請求從瀏覽器發出會附帶存儲在Cookie中的JSESSIONID,後端根據這個可以找到對應的Session,響應時又會將JSESSIONID寫入Cookie。若是瀏覽器禁用Cookie則需在每次的URL上附帶JSESSIONID參數
  • 安全性和客戶體驗差,敏感數據保存在客戶端的Cookie中不太安全,Session時效管理、分佈式管理等設置不當會致使用戶的頻繁從新登錄,形成很差的用戶體驗
  • 有些前端技術根本就不支持Cookie,如App、小程序

如此而言,Spring Security OAuth提供了一種基於token的認證機制,認證再也不是每次請求讀取存儲在Session中的認證信息,而是對受權的用戶發放一個token,訪問服務時只需帶上token參數便可。相比較於基於Session的方式,token更加靈活和安全,不會向Session同樣SESSIONID的分配以及參數附帶都是固化了的,token以怎樣的形式呈現以及包含哪些信息以及可經過token刷新機制透明地延長受權時長(用戶感知不到)來避免重複登陸等,都是能夠被咱們自定義的。

提到OAuth,可能很容易聯想到以前所開發的第三方登陸功能,其實Spring Social是封裝了OAuth客戶端所要走的流程,而Spring Security OAuth則是封裝了OAuth認證服務器的相關功能。

就咱們本身開發的系統而言,後端就是認證服務器和資源服務器,而前端APP以及WebServer等就至關於OAuth客戶端。

認證服務器須要作的事就是提供4中受權模式以及token的生成和存儲,資源服務器就是保護REST服務,經過過濾器的方式在調用服務前校驗請求中的token。而咱們須要作的就是將咱們自定義的認證邏輯(用戶名密碼登陸、短信驗證碼登陸、第三方登陸)集成到認證服務器中,並對接生成和存儲token

image.png

從本章開始,咱們將採用Spring Security OAuth開發security-app項目,基於純OAuth的認證方式,而不依賴於Session/Cookie

準備工做

首先咱們在security-demo中將引入的security-browser依賴註釋掉,並引入security-app,忘掉以前基於Session/Cookie開發的認證代碼,從頭開始基於OAuth來開發認證受權。

因爲在security-core中的驗證碼校驗過濾器VerifyCodeValidateFilter須要注入認證成功/失敗處理器,因此咱們將security-demo中的複製一份到security-app中,並將處理結果以JSON的方式響應(security-browser的處理結果能夠是一個頁面,但security-app只能響應JSON),並將SimpleResponseResult移入security-core中。

package top.zhenganwen.securitydemo.app.handler;

@Component("appAuthenticationFailureHandler")
public class AppAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

// @Autowired
// private SecurityProperties securityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
// super.onAuthenticationFailure(request, response, exception);
// return;
// }
        logger.info("登陸失敗=>{}", exception.getMessage());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponseResult(exception.getMessage())));
        response.getWriter().flush();
    }
}

複製代碼
package top.zhenganwen.securitydemo.app.handler;

@Component("appAuthenticationSuccessHandler")
public class AppAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

// @Autowired
// private SecurityProperties securityProperties;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException , ServletException {
// if (securityProperties.getBrowser().getLoginProcessType() == LoginProcessTypeEnum.REDIRECT) {
// // 重定向到緩存在session中的登陸前請求的URL
// super.onAuthenticationSuccess(request, response, authentication);
// }
        logger.info("用戶{}登陸成功", authentication.getName());
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
        response.getWriter().flush();
    }
}

複製代碼

重啓服務,查看在去掉security-browser而引入security-app以後項目是否能正常跑起來。

啓用認證服務器

只需使用一個註解@EnableAuthorizationServer便可使當前服務成爲一個認證服務器,starter-oauth2已經幫咱們封裝好了認證服務器須要提供的4種受權模式和token的管理。

package top.zhenganwen.securitydemo.app.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;

/**
 * @author zhenganwen
 * @date 2019/9/11
 * @desc AuthorizationServerConfig
 */
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig {
}

複製代碼

如今咱們能夠來測試一下4中受權模式中的受權碼模式和密碼模式

首先認證服務器端要有用戶,爲了方便這裏就再也不編寫DAOUserDetailsService了,咱們能夠經過配置添加一個用戶:

security.user.name=test
security.user.password=test
security.user.role=user			# 要使用OAuth,用戶須要有user角色,數據庫中需存儲爲ROLE_USER
複製代碼

而後配置一個clientId/clientSecret,這至關於別的應用調用security-demo進行第三方登陸以前須要在security-demo的互聯開發平臺上申請註冊的appId/appSecret。例如如今有一個應用在security-demo的開發平臺上註冊審覈經過了,security-demo會爲其分配一個appId:test-clientappSecret:123。如今咱們的security-demo也成爲了認證服務器,任何調用security-demoAPI獲取token的其餘應用可視爲第三方應用或客戶端了。

security.oauth2.client.client-id=test-client
security.oauth2.client.client-secret=123
複製代碼

接下來咱們能夠對照OAuth2的官網上的 參考文檔來驗證@EnableAuthorizationServer提供的4種受權模式並獲取token

測試受權碼模式

參見 請求標準

受權碼模式有兩步:

  1. 獲取受權碼

    觀察boot啓動日誌,發現框架爲咱們添加若干接口,其中就包含了/oauth/authorize,這個就是受權碼獲取的接口。咱們對照OAuth2中獲取受權碼的請求標準來嘗試獲取受權碼

    image.png

    http://localhost/oauth/authorize?
    response_type=code
    &client_id=test-client
    &redirect_uri=http://example.com
    &scope=all
    複製代碼

    其中response_type固定爲code表示獲取受權碼,client_id爲客戶端的appIdredirect_uri爲客戶端接收受權碼從而進一步獲取token的回調URL(這裏咱們暫且隨便寫一個,到時候受權成功跳轉到的URL上會附帶受權碼),scope表示這次受權須要獲取的權限範圍(鍵值和鍵值的意義應由認證服務器來定,這裏咱們暫且隨便寫一個)。訪問該URL後,會彈出一個basic認證的登陸框,咱們輸入用戶名test密碼test登陸以後跳轉到受權頁,詢問咱們是否授予all權限(實際開發中咱們能夠將權限按操做類型分爲createdeleteupdateread,也可按角色劃分爲useradminguest等):

    image.png

    咱們點擊贊成Approve後點擊受權Authorize,而後跳轉到回調URL並附帶了受權碼

    image.png

    記下該受權碼yO4Y6q用於後續的token獲取

  2. 獲取token

    image.png

    咱們能夠經過Chrome插件Restlet Client來完成這次請求

    1. 點擊Add authorization輸入client-idclient-secret,工具會幫咱們自動加密並附在請求頭Authorizatin
    2. 填寫請求參數

    image.png

    若是使用PostmanAuthorization設置以下:

    image.png

    點擊Send發送請求,響應以下:

    image.png

密碼模式

密碼模式只需一步,無需受權碼,能夠直接獲取token

image.png

使用密碼模式至關於用戶告訴了客戶端test-client用戶在security-demo上註冊用戶名密碼,客戶端直接拿這個去獲取token,認證服務器並不知道客戶端是經用戶受權贊成後請求token仍是偷偷拿已知的用戶名密碼 來獲取token,可是若是這個客戶端應用是公司內部應用,可無需擔憂這一點

這裏還有一個細節:由於以前經過受權碼模式發放了一個對應該用戶的token,因此這裏再經過密碼模式獲取token時返回的還是以前生成的token,而且過時時間expire_in在逐漸縮短

目前沒有指定token的存儲方式,所以默認是存儲在內存中的,若是你重啓了服務,那麼就須要從新申請token

啓用資源服務器

一樣的,使用一個@EnableResourceServer註解就可使服務成爲資源服務器(在調用服務前校驗token

package top.zhenganwen.securitydemo.app.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;

/** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */
@Configuration
@EnableResourceServer
public class ResourceServerConfig {
}

複製代碼

重啓服務服務後直接訪問查詢用戶接口/user響應401說明資源服務器起做用了(沒有附帶token訪問受保護服務會被攔截),這也不是security默認的basic認證在起做用,由於若是是basic攔截它會彈出登陸框,而這裏並無

image.png

而後咱們使用密碼模式從新生成一次token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c,並在訪問接口時附帶token(添加請求頭Authorization值爲token_type access_token

image.png

使用Postman更加方便:

image.png

Spring Security Oauth核心源碼剖析

框架核心組件以下,方框爲綠色表示是具體類,爲藍色則表示是接口/抽象,括號中的類爲運行時實際調用的類。下面咱們將以密碼模式爲例來對源碼進行剖析,你也能夠打斷點逐步進行驗證。

image.png

令牌頒發服務——TokenEndpoint

TokenEndpoint能夠看作是一個Controller,它會受理咱們申請token的請求,見postAccessToken方法:

@RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {

    if (!(principal instanceof Authentication)) {
        throw new InsufficientAuthenticationException(
            "There is no client authentication. Try adding an appropriate authentication filter.");
    }

    String clientId = getClientId(principal);
    ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

    TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

    if (clientId != null && !clientId.equals("")) {
        // Only validate the client details if a client authenticated during this
        // request.
        if (!clientId.equals(tokenRequest.getClientId())) {
            // double check to make sure that the client ID in the token request is the same as that in the
            // authenticated client
            throw new InvalidClientException("Given client ID does not match authenticated client");
        }
    }
    if (authenticatedClient != null) {
        oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
    }
    if (!StringUtils.hasText(tokenRequest.getGrantType())) {
        throw new InvalidRequestException("Missing grant type");
    }
    if (tokenRequest.getGrantType().equals("implicit")) {
        throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
    }

    if (isAuthCodeRequest(parameters)) {
        // The scope was requested or determined during the authorization step
        if (!tokenRequest.getScope().isEmpty()) {
            logger.debug("Clearing scope of incoming token request");
            tokenRequest.setScope(Collections.<String> emptySet());
        }
    }

    if (isRefreshTokenRequest(parameters)) {
        // A refresh token has its own default scopes, so we should ignore any added by the factory here.
        tokenRequest.setScope(OAuth2Utils.parseParameterList(parameters.get(OAuth2Utils.SCOPE)));
    }

    OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
    if (token == null) {
        throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
    }

    return getResponse(token);

}
複製代碼

首先入參包含了兩個部分:principalparameters,對應咱們密碼模式請求參數的兩個部分:請求頭Authorization和請求體(grant_typeusernamepasswordscope)。

String clientId = getClientId(principal);

principal傳入的其實是一個UsernamePasswordToken,對應邏輯在BasicAuthenticationFilterdoFilterInternal方法中:

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    final boolean debug = this.logger.isDebugEnabled();

    String header = request.getHeader("Authorization");

    if (header == null || !header.startsWith("Basic ")) {
        chain.doFilter(request, response);
        return;
    }

    try {
        String[] tokens = extractAndDecodeHeader(header, request);
        assert tokens.length == 2;

        String username = tokens[0];

        if (authenticationIsRequired(username)) {
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, tokens[1]);

        }

    }
    catch (AuthenticationException failed) {

    }

    chain.doFilter(request, response);
}

private String[] extractAndDecodeHeader(String header, HttpServletRequest request)
    throws IOException {

    byte[] base64Token = header.substring(6).getBytes("UTF-8");
    byte[] decoded;
    try {
        decoded = Base64.decode(base64Token);
    }
    catch (IllegalArgumentException e) {
        throw new BadCredentialsException(
            "Failed to decode basic authentication token");
    }

    String token = new String(decoded, getCredentialsCharset(request));

    int delim = token.indexOf(":");

    if (delim == -1) {
        throw new BadCredentialsException("Invalid basic authentication token");
    }
    return new String[] { token.substring(0, delim), token.substring(delim + 1) };
}
複製代碼

BasicAuthenticationFilter會攔截/oauth/token並嘗試解析請求頭Authorization,拿到對應的Basic xxx字符串,去掉前6個字符Basic,獲取xxx,這其實是咱們傳入的clientIdclientSecret使用冒號鏈接在一塊兒以後再用base64加密算法獲得的,所以在extractAndDecodeHeader方法中會對xxx進行base64解密獲得由冒號分隔的clientIdclientSecret組成的密文(借用以前的clientId=test-clientclientSecret=123的例子,這裏獲得的密文就是test-client:123),最後將client-id做爲usernameclientSecret做爲password構建了一個UsernamePasswordToken並返回,所以在postAccessToken中的principal可以獲得請求頭中的clientId

ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

接着調用ClientDetailsService根據clientId查詢已註冊的客戶端詳情,即ClientDetails,這是外部應用在註冊security-demo這個開放平臺時填寫並通過審覈的信息,包含若干項,咱們這裏只有clientIdclientSecret兩項。(authenticatedClient表示這個client是經咱們審覈過的容許接入咱們開放平臺的client

TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

接着根據請求體參數parameters和客戶端詳情clientDetails構建了一個TokenRequest,這個tokenRequest代表當前這個獲取token的請求是哪一個客戶端(clientDetails)要獲取哪一個用戶(parameters.username)的訪問權限、受權模式是什麼(parameters.grant_type)、要獲取哪些權限(parameters.scope)。

if (clientId != null && !clientId.equals(""))

接着對傳入的clientIdauthenticatedClientclientId進行校驗。也許你會問,authenticatedClient不就是根據傳入的clientId查出來的嗎,再校驗豈不是畫蛇添足。其實否則,雖然查詢的方法叫作loadClientByClientId,可是隻能理解爲是根據client惟一標識查詢審覈過的client,也許這個惟一標識是咱們數據庫中client表的無關主鍵id,也多是clientId字段的值。也就是說咱們要從宏觀上理解方法名loadClientByClientId。所以這裏對clientId進行校驗是無可厚非的。

if (authenticatedClient != null)

接着判斷若是authenticatedClient不爲空則校驗請求的權限範圍scope

private void validateScope(Set<String> requestScopes, Set<String> clientScopes) {

    if (clientScopes != null && !clientScopes.isEmpty()) {
        for (String scope : requestScopes) {
            if (!clientScopes.contains(scope)) {
                throw new InvalidScopeException("Invalid scope: " + scope, clientScopes);
            }
        }
    }

    if (requestScopes.isEmpty()) {
        throw new InvalidScopeException("Empty scope (either the client or the user is not allowed the requested scopes)");
    }
}
複製代碼

能夠聯想這樣一個場景:外部應用請求接入咱們的開放平臺以讀取咱們平臺的用戶信息,那麼就對應clientScopes["read"],經過審覈後該客戶端請求獲取tokentoken可以代表:1.你是誰;2.你能幹些什麼;3.訪問時效)時請求參數scope就只能爲["read"],而不能爲["read","write"]等。這裏就是校驗請求token時傳入的scope是否都包含在該客戶端註冊的scopes中。

if (!StringUtils.hasText(tokenRequest.getGrantType()))

接着校驗grant_type參數不能爲空,這也是oauth協議所規定的。

if (tokenRequest.getGrantType().equals("implicit"))

接着判斷傳入的grant_type是否爲implicit,也就是說客戶端是不是採用簡易模式獲取token,由於簡易模式在用戶贊成受權後就直接獲取token了,所以不該該再調用獲取token接口。

if (isAuthCodeRequest(parameters))

接着根據請求參數判斷客戶端是不是採用受權碼模式,若是是,就將tokenRequest中的scope置爲空,由於客戶端的權限有哪些不該該是它本身傳入的scope來決定,而是由其註冊時咱們審覈經過的scopes來決定,該屬性後續會被從客戶端詳情中讀取的scope覆蓋。

if (isRefreshTokenRequest(parameters))

private boolean isRefreshTokenRequest(Map<String, String> parameters) {
    return "refresh_token".equals(parameters.get("grant_type")) && parameters.get("refresh_token") != null;
}
複製代碼

判斷是不是刷新token的請求。其實可以請求tokengrant_type除了oauth標準中的4中受權模式authorization_codeimplicitpasswordclient_credential,還有一個refresh_token,爲了改善用戶體驗(傳統登陸方式一段時間後須要從新登錄),token刷新機制可以在用戶感知不到的狀況下實現token時效的延長。若是是刷新token的請求,一如註釋所寫,refresh_token方式也有它本身默認的scopes,所以不該該使用請求中附帶的。

OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);

這纔是最重要的一步,前面都是對請求參數的封裝和校驗。這一步會調用TokenGranter令牌授與者生成token,後面的getResponse(token)就是將生成的token直接響應了。根據傳入的受權類型grant_type及其對應的須要傳入的參數,會調不一樣的TokenGranter實現類進行token的構建,這一邏輯在CompositeTokenGranter中:

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
    for (TokenGranter granter : tokenGranters) {
        OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
        if (grant!=null) {
            return grant;
        }
    }
    return null;
}
複製代碼

它會依次調用4中受權模式對應TokenGranter的實現類的grant方法,只有和請求參數grant_type對應的TokenGranter會被調用,這一邏輯在AbstractTokenGranter中:

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

    if (!this.grantType.equals(grantType)) {
        return null;
    }

    String clientId = tokenRequest.getClientId();
    ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
    validateGrantType(grantType, client);

    logger.debug("Getting access token for: " + clientId);

    return getAccessToken(client, tokenRequest);

}
複製代碼
public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "authorization_code";
}

public class ClientCredentialsTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "client_credentials";
}

public class ImplicitTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "implicit";
}

public class ResourceOwnerPasswordTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "password";
}

public class RefreshTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "refresh_token";
}
複製代碼

令牌授予者——TokenGranter

因爲是以密碼模式爲例,所以流程走到了ResourceOwnerPasswordTokenGranter.grant中,它沒有重寫grant方法,所以調用的是父類的grant方法:

AbstractTokenGranter

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

    if (!this.grantType.equals(grantType)) {
        return null;
    }

    String clientId = tokenRequest.getClientId();
    ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
    validateGrantType(grantType, client);

    return getAccessToken(client, tokenRequest);

}

protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
    return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}

protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
    return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}
複製代碼

重點在第20行,調用子類的getOAuth2Authentication獲取OAuth2Authentication,並傳給調用認證服務器token服務AuthorizationServerTokenServices生成token。對於這裏的getOAuth2Authentication,各TokenGranter子類又有不一樣的實現,由於不一樣受權模式的校驗邏輯是不一樣的,例如受權碼模式這一環節須要校驗請求傳入的受權碼(tokenRequest.parameters.code)是不是我以前發給對應客戶端(clientDetails)的受權碼;而密碼模式則是校驗請求傳入的用戶名密碼在我當前系統是否存在該用戶以及密碼是否正確等。在經過校驗後,會返回一個OAuth2Authentication,包含了oauth相關信息和系統用戶的相關信息。

AuthorizationServerTokenServices

OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
複製代碼

ResourceOwnerPasswordTokenGranter

@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

    Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters());
    String username = parameters.get("username");
    String password = parameters.get("password");
    // Protect from downstream leaks of password
    parameters.remove("password");

    Authentication userAuth = new UsernamePasswordAuthenticationToken(username, password);
    ((AbstractAuthenticationToken) userAuth).setDetails(parameters);
    try {
        userAuth = authenticationManager.authenticate(userAuth);
    }
    catch (AccountStatusException ase) {
        //covers expired, locked, disabled cases (mentioned in section 5.2, draft 31)
        throw new InvalidGrantException(ase.getMessage());
    }
    catch (BadCredentialsException e) {
        // If the username/password are wrong the spec says we should send 400/invalid grant
        throw new InvalidGrantException(e.getMessage());
    }
    if (userAuth == null || !userAuth.isAuthenticated()) {
        throw new InvalidGrantException("Could not authenticate user: " + username);
    }

    OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);		
    return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
複製代碼

能夠發現,ResourceOwnerPasswordTokenGranter的校驗邏輯和咱們以前所寫的用戶名密碼認證過濾器的邏輯幾乎一致:從請求中獲取用戶名密碼,而後構建authRequest傳給ProviderManager進行校驗,ProviderManager委託給DaoAuthenticationProvider天然又會調用咱們的UserDetailsService自定義實現類CustomUserDetailsService查詢用戶並校驗。

OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);

校驗經過返回認證成功的Authentication後,會調用工廠方法根據客戶端詳情以及tokenRequest構建AuthenticationServerTokenServices所需的OAuth2Authentication返回。

認證服務器令牌服務——AuthorizationServerTokenServices

在收到OAuth2Authentication以後,令牌服務就能生成token了,接着來看一下令牌服務的實現類DefaultTokenServices是如何生成token的:

@Transactional
public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {

    OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
    OAuth2RefreshToken refreshToken = null;
    if (existingAccessToken != null) {
        if (existingAccessToken.isExpired()) {
            if (existingAccessToken.getRefreshToken() != null) {
                refreshToken = existingAccessToken.getRefreshToken();
                // The token store could remove the refresh token when the
                // access token is removed, but we want to
                // be sure...
                tokenStore.removeRefreshToken(refreshToken);
            }
            tokenStore.removeAccessToken(existingAccessToken);
        }
        else {
            // Re-store the access token in case the authentication has changed
            tokenStore.storeAccessToken(existingAccessToken, authentication);
            return existingAccessToken;
        }
    }

    // Only create a new refresh token if there wasn't an existing one
    // associated with an expired access token.
    // Clients might be holding existing refresh tokens, so we re-use it in
    // the case that the old access token
    // expired.
    if (refreshToken == null) {
        refreshToken = createRefreshToken(authentication);
    }
    // But the refresh token itself might need to be re-issued if it has
    // expired.
    else if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
        ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken;
        if (System.currentTimeMillis() > expiring.getExpiration().getTime()) {
            refreshToken = createRefreshToken(authentication);
        }
    }

    OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken);
    tokenStore.storeAccessToken(accessToken, authentication);
    // In case it was modified
    refreshToken = accessToken.getRefreshToken();
    if (refreshToken != null) {
        tokenStore.storeRefreshToken(refreshToken, authentication);
    }
    return accessToken;

}
複製代碼

首先會試圖從令牌倉庫tokenStore中獲取token,由於每次生成token以後響應以前會調tokenStore保存生成的token,這樣後續客戶端拿token訪問資源的時候就有據可依。

if (existingAccessToken != null)

若是從tokenStore獲取到了token,說明以前生成過token,這時有兩種狀況:

  1. 舊的token過時了,這時要將該token移除,若是該tokenrefresh_token還在則也要移除(請求刷新某token時須要其對應的refresh_token,若是token失效了則其伴隨的refresh_token也應該不可用)
  2. 舊的token沒有過時,從新保存一下該token(由於先後多是經過不一樣受權模式生成token的,對應保存的邏輯也會有差異),並直接返回該token,方法結束。

若是沒有從tokenStore中發現舊token,那麼就新生成一個token,保存到tokenStore中並返回。

小結

image.png

集成用戶名密碼獲取token

雖然框架已經幫咱們封裝好了認證服務器所需的4中受權模式,可是這這通常是對外的(外部應用沒法讀取咱們系統的用戶信息),用於構建開放平臺。對於內部應用,咱們仍是須要提供用戶名密碼登陸、手機號驗證碼登陸等方式來獲取token。首先,框架流程一直到TokenGranter組件這一部分咱們是不能沿用了,由於已被OAuth流程固化了。咱們所能用的就是令牌生成服務AuthorizationServerTokenServices,但它須要一個OAuth2Authentication,而咱們構建OAuth2Authentication又須要tokenRequestauthentication

咱們能夠在原有登陸邏輯的基礎之上,修改登陸成功處理器,在該處理器中咱們能獲取到認證成功的authentication,而且從請求頭Authorization中獲取到clientId調用注入的ClientDetailsService查出clientDetails並構建tokenRequest,這樣就能調用令牌生成服務來生成令牌並響應了。

image.png

在登陸成功處理器中調用令牌服務

AppAuthenticationSuccessHandler

package top.zhenganwen.securitydemo.app.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.codec.Base64;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.UnapprovedClientAuthenticationException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component("appAuthenticationSuccessHandler")
public class AppAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private ClientDetailsService clientDetailsService;

    @Autowired
    private AuthorizationServerTokenServices authorizationServerTokenServices;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

        // Authentication
        Authentication userAuthentication = authentication;

        // ClientDetails
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Basic ")) {
            throw new UnapprovedClientAuthenticationException("請求頭中必須附帶 oauth client 相關信息");
        }
        String[] clientIdAndSecret = extractAndDecodeHeader(authHeader);
        String clientId = clientIdAndSecret[0];
        String clientSecret = clientIdAndSecret[1];
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientIdAndSecret[0]);
        if (clientDetails == null) {
            throw new UnapprovedClientAuthenticationException("無效的clientId");
        } else if (!StringUtils.equals(clientSecret, clientDetails.getClientSecret())) {
            throw new UnapprovedClientAuthenticationException("錯誤的clientSecret");
        }

        // TokenRequest
        TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom");

        // OAuth2Request
        OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);

        // OAuth2Authentication
        OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, userAuthentication);

        // AccessToken
        OAuth2AccessToken accessToken = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);

        // response
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(accessToken));
    }

    private String[] extractAndDecodeHeader(String header){

        byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
        byte[] decoded;
        try {
            decoded = Base64.decode(base64Token);
        }
        catch (IllegalArgumentException e) {
            throw new BadCredentialsException(
                    "Failed to decode basic authentication token");
        }

        String token = new String(decoded, StandardCharsets.UTF_8);

        int delim = token.indexOf(":");

        if (delim == -1) {
            throw new BadCredentialsException("Invalid basic authentication token");
        }
        return new String[] { token.substring(0, delim), token.substring(delim + 1) };
    }
}

複製代碼

繼承ResourceServerConfigurerAdapter實現Security配置

咱們將BrowserSecurityConfig中對於security的配置拷到ResourceServerConfig中,僅啓用表單密碼登陸:

package top.zhenganwen.securitydemo.app.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.social.security.SpringSocialConfigurer;
import top.zhenganwen.security.core.SecurityConstants;
import top.zhenganwen.security.core.config.SmsLoginConfig;
import top.zhenganwen.security.core.config.VerifyCodeValidatorConfig;
import top.zhenganwen.security.core.properties.SecurityProperties;

import javax.sql.DataSource;

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService customUserDetailsService;

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }

    @Autowired
    SmsLoginConfig smsLoginConfig;

    @Autowired
    private VerifyCodeValidatorConfig verifyCodeValidatorConfig;

    @Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        // 啓用表單密碼登陸過濾器
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        http
// // 啓用驗證碼校驗過濾器
// .apply(verifyCodeValidatorConfig).and()
// // 啓用短信登陸過濾器
// .apply(smsLoginConfig).and()
// // 啓用QQ登陸
// .apply(qqSpringSocialConfigurer).and()
// // 瀏覽器應用特有的配置,將登陸後生成的token保存在cookie中
// .rememberMe()
// .tokenRepository(persistentTokenRepository())
// .tokenValiditySeconds(3600)
// .userDetailsService(customUserDetailsService)
// .and()
// .sessionManagement()
// .invalidSessionUrl("/session-invalid.html")
// .invalidSessionStrategy((request, response) -> {})
// .maximumSessions(1)
// .expiredSessionStrategy(eventØ -> {})
// .maxSessionsPreventsLogin(true)
// .and()
// .and()
                // 瀏覽器應用特有的配置
                .authorizeRequests()
                    .antMatchers(
                            SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                            securityProperties.getBrowser().getLoginPage(),
                            SecurityConstants.VERIFY_CODE_SEND_URL,
                            securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                            securityProperties.getSocial().getSignUpUrl(),
                            securityProperties.getSocial().getSignUpProcessingUrl(),
                            "/session-invalid.html").permitAll()
                    .anyRequest().authenticated()
                    .and()
                // 基於token的受權機制沒有登陸/註銷的概念,只有token申請和過時的概念
                .csrf().disable();
    }
}
複製代碼

如此,內部應用客戶端就能夠經過用戶的用戶名密碼獲取token了:

  1. 請求頭仍是要附帶客戶端信息

    image.png
  2. 請求參數傳用戶名密碼登陸所需參數便可

    image.png
  3. 登陸成功即獲取token

    image.png
  4. 經過token訪問服務

    因爲Postman仍支持服務端寫入和讀取Cookie

    image.png

    爲了不Session/Cookie登陸方式的影響,每次咱們須要清除cookie再發送請求。

    image.png

    image.png

    首先是不附帶token的請求,發現請求被攔截了:

    image.png

    而後附帶token訪問請求:

    image.png

至此,用戶名密碼登陸獲取token集成成功!

驗證碼和短信登陸的集成流程相似,在此再也不贅述。值得注意的是基於token的方式要摒棄對Session/Cookie的操做,能夠將要保存在服務端的信息放入如Redis等持久層中。

集成社交登陸獲取token

在本節,咱們將實現內部應用使用社交登陸的方式向內部認證服務器獲取token

簡易模式

流程分析

若是內部應用採起的是簡易模式,用戶贊成受權後直接獲取到外部服務提供商發放的token,這時咱們是沒有辦法拿這個token去訪問內部資源服務器的,須要拿這個token去內部認證服務器換取咱們系統內部通行的token

換取思路是,若是用戶進行社交登陸成功,那麼內部應用就可以獲取到用戶的providerUserId(在外部服務提供商中稱爲openId),而且UserConnection表應該有一條記錄(userId,providerId,providerUserId),內部應用只需將providerIdproviderUserId傳給內部認證服務器,內部認證服務器查UserConnection表進行校驗並根據userId構建Authentication便可生成accessToken

image.png

爲此咱們須要在內部認證服務器上寫一套providerId+openId的認證流程:

image.png

其中UserConnectionRepositoryCustomUserDetailsServiceAppAuthenticationSuccessHandler都是現成的,能夠直接拿來用。

SecurityProperties增長處理根據openIdtoken的URL:

package top.zhenganwen.security.core.properties;

import lombok.Data;
import top.zhenganwen.security.core.social.qq.connect.QQSecurityPropertie;

/** * @author zhenganwen * @date 2019/9/5 * @desc SocialProperties */
@Data
public class SocialProperties {
    private QQSecurityPropertie qq = new QQSecurityPropertie();

    public static final String DEFAULT_FILTER_PROCESSING_URL = "/socialLogin";
    private String filterProcessingUrl = DEFAULT_FILTER_PROCESSING_URL;

    public static final String DEFAULT_SIGN_UP_URL = "/sign-up.html";
    private String signUpUrl = DEFAULT_SIGN_UP_URL;

    public static final String DEFAULT_SING_UP_PROCESSING_URL = "/user/register";
    private String signUpProcessingUrl = DEFAULT_SING_UP_PROCESSING_URL;

    public static final String DEFAULT_OPEN_ID_FILTER_PROCESSING_URL = "/auth/openId";
    private String openIdFilterProcessingUrl = DEFAULT_OPEN_ID_FILTER_PROCESSING_URL;
}
複製代碼

自定義請求AuthenticationToken

package top.zhenganwen.securitydemo.app.security.openId;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

/** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationToken */
public class OpenIdAuthenticationToken extends AbstractAuthenticationToken {

    // 做爲請求認證的token時存儲providerId,做爲認證成功的token時存儲用戶信息
    private final Object principal;
    // 做爲請求認證的token時存儲openId,做爲認證成功的token時存儲用戶密碼
    private Object credentials;

    // 請求認證時調用
    public OpenIdAuthenticationToken(Object providerId, Object openId) {
        super(null);
        this.principal = providerId;
        this.credentials = openId;
        setAuthenticated(false);
    }

    // 認證經過後調用
    public OpenIdAuthenticationToken(Object userInfo, Object password, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = userInfo;
        this.credentials = password;
        super.setAuthenticated(true);
    }


    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

}
複製代碼

認證攔截器OpenIdAuthenticationFilter

package top.zhenganwen.securitydemo.app.security.openId;

import org.apache.commons.lang.StringUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.web.bind.ServletRequestUtils;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationFilter */
public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    protected OpenIdAuthenticationFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

        // authRequest
        String providerId = ServletRequestUtils.getStringParameter(request, "providerId");
        if (StringUtils.isBlank(providerId)) {
            throw new BadCredentialsException("providerId is required");
        }
        String openId = ServletRequestUtils.getStringParameter(request,"openId");
        if (StringUtils.isBlank(openId)) {
            throw new BadCredentialsException("openId is required");
        }
        OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(providerId, openId);

        // authenticate
        return getAuthenticationManager().authenticate(authRequest);
    }
}
複製代碼

實際認證官OpenIdAuthenticationProvider

package top.zhenganwen.securitydemo.app.security.openId;

import org.hibernate.validator.internal.util.CollectionHelper;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.util.CollectionUtils;

import java.util.Set;

/** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationProvider */
public class OpenIdAuthenticationProvider implements AuthenticationProvider {

    private UsersConnectionRepository usersConnectionRepository;

    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (!(authentication instanceof OpenIdAuthenticationToken)) {
            throw new IllegalArgumentException("不支持的token認證類型:" + authentication.getClass());
        }

        // userId
        OpenIdAuthenticationToken authRequest = (OpenIdAuthenticationToken) authentication;
        Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(authRequest.getPrincipal().toString(), CollectionHelper.asSet(authRequest.getCredentials().toString()));
        if (CollectionUtils.isEmpty(userIds)) {
            throw new BadCredentialsException("無效的providerId和openId");
        }

        // userDetails
        String useId = userIds.stream().findFirst().get();
        UserDetails userDetails = userDetailsService.loadUserByUsername(useId);

        // authenticated authentication
        OpenIdAuthenticationToken authenticationToken = new OpenIdAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());

        return authenticationToken;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return OpenIdAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public void setUsersConnectionRepository(UsersConnectionRepository usersConnectionRepository) {
        this.usersConnectionRepository = usersConnectionRepository;
    }

    public UsersConnectionRepository getUsersConnectionRepository() {
        return usersConnectionRepository;
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}
複製代碼

OpenId認證流程配置類OpenIdAuthenticationConfig

package top.zhenganwen.securitydemo.app.security.openId;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.social.connect.UsersConnectionRepository;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.properties.SecurityProperties;

/** * @author zhenganwen * @date 2019/9/15 * @desc OpenIdAuthenticationConfig */
@Component
public class OpenIdAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private UsersConnectionRepository usersConnectionRepository;

    @Autowired
    private UserDetailsService customUserDetailsService;

    @Override
    public void configure(HttpSecurity builder) throws Exception {

        OpenIdAuthenticationFilter openIdAuthenticationFilter = new OpenIdAuthenticationFilter(securityProperties.getSocial().getOpenIdFilterProcessingUrl());
        openIdAuthenticationFilter.setAuthenticationFailureHandler(appAuthenticationFailureHandler);
        openIdAuthenticationFilter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler);
        openIdAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));

        OpenIdAuthenticationProvider openIdAuthenticationProvider = new OpenIdAuthenticationProvider();
        openIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository);
        openIdAuthenticationProvider.setUserDetailsService(customUserDetailsService);

        builder
                .authenticationProvider(openIdAuthenticationProvider)
                .addFilterBefore(openIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    }
}
複製代碼

apply應用到Security主配置類中

package top.zhenganwen.securitydemo.app.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import top.zhenganwen.security.core.SecurityConstants;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.securitydemo.app.security.openId.OpenIdAuthenticationConfig;

/** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        // 啓用表單密碼登陸獲取token
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        // 啓用社交登陸獲取token
        http.apply(openIdAuthenticationConfig);

        http
                .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl(),
                        "/session-invalid.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}
複製代碼

測試

現用Postman模擬內部應用訪問/auth/openId請求token

image.png

並訪問/user測試token有效性,訪問成功!集成社交登陸成功!

受權碼模式

若是內部應用採用的是受權碼模式,那麼在外部服務提供商帶着受權碼回調時,內部應用直接將該回調請求轉發到咱們的認證服務器便可,由於咱們此前已經寫過社交登陸模塊,這樣可以實現無縫銜接。

仍是以咱們以前實現的QQ登陸爲例:image.png

內部應只需在用戶贊成受權,QQ認證服務器重定向到內部應用回調域時,將該回調請求原封不動轉發給認證服務器便可,由於咱們以前已開發過/socialLogin接口處理社交登陸。

這裏測試,咱們不可能真的去開發一個App,能夠採用原先開發的security-browser項目,再獲取到受權碼的地方打個斷點,獲取到受權碼後停掉服務(避免後面拿受權碼請求token致使受權碼失效)。而後再在Postman中拿受權碼請求token(模擬App轉發回調域到/socialLogin/qq

首先在security-demo中註釋security-app而啓用security-browser

<dependency>
    <groupId>top.zhenganwen</groupId>
    <artifactId>security-browser</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>top.zhenganwen</groupId>-->
<!-- <artifactId>security-app</artifactId>-->
<!-- <version>1.0-SNAPSHOT</version>-->
<!-- </dependency>-->
複製代碼

CustomUserDetailsService移至security-core中,由於browserapp都有用到:

package top.zhenganwen.security.core.service;

import org.hibernate.validator.constraints.NotBlank;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.social.security.SocialUser;
import org.springframework.social.security.SocialUserDetails;
import org.springframework.social.security.SocialUserDetailsService;
import org.springframework.stereotype.Component;

import java.util.Objects;

/** * @author zhenganwen * @date 2019/8/23 * @desc CustomUserDetailsService */
@Component
public class CustomUserDetailsService implements UserDetailsService, SocialUserDetailsService {

    @Autowired
    BCryptPasswordEncoder passwordEncoder;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public UserDetails loadUserByUsername(@NotBlank String username) throws UsernameNotFoundException {
        return buildUser(username);
    }

    private SocialUser buildUser(@NotBlank String username) {
        logger.info("登陸用戶名: " + username);
        // 實際項目中你能夠調用Dao或Repository來查詢用戶是否存在
        if (Objects.equals(username, "admin") == false) {
            throw new UsernameNotFoundException("用戶名不存在");
        }
        // 假設查出來的密碼以下
        String pwd = passwordEncoder.encode("123");

        return new SocialUser(
                "admin", pwd, AuthorityUtils.commaSeparatedStringToAuthorityList("user,admin")
        );
    }

    // 根據用戶惟一標識查詢用戶, 你能夠靈活地根據用戶表主鍵、用戶名等內容惟一的字段來查詢
    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        return buildUser(userId);
    }
}
複製代碼

接着設置端口80啓動服務並在以下拿受權碼獲取token前設置斷點(OAuth2AuthenticationService):

image.png

訪問www.zhenganwen.top/login.html進行QQ受權登陸(同時打開瀏覽器控制檯),贊成受權進行跳轉,停在斷點後停掉服務,在瀏覽器控制檯中找到回調URL並複製它:

image.png

再將security-demopom切換爲app

<!-- <dependency>-->
<!-- <groupId>top.zhenganwen</groupId>-->
<!-- <artifactId>security-browser</artifactId>-->
<!-- <version>1.0-SNAPSHOT</version>-->
<!-- </dependency>-->
<dependency>
    <groupId>top.zhenganwen</groupId>
    <artifactId>security-app</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
複製代碼

Security主配置文件中啓用QQ登陸:

package top.zhenganwen.securitydemo.app.security.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import top.zhenganwen.security.core.SecurityConstants;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.social.qq.connect.QQSpringSocialConfigurer;
import top.zhenganwen.securitydemo.app.security.openId.OpenIdAuthenticationConfig;

/** * @author zhenganwen * @date 2019/9/11 * @desc ResourceServerConfig */
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Autowired
    private QQSpringSocialConfigurer qqSpringSocialConfigurer;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        // 啓用表單密碼登陸獲取token
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        // 啓用社交登陸獲取token
        http.apply(openIdAuthenticationConfig);
        http.apply(qqSpringSocialConfigurer);

        http
                .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl(),
                        "/session-invalid.html").permitAll()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}
複製代碼

而後咱們就能夠用Postman模擬App將收到受權碼回調轉發給認證服務器獲取token了:

image.png

這裏認證服務器在拿受權碼獲取token時返回異常信息code is reused error(受權碼被重複使用),按理來講前一次咱們打了斷點並及時停掉了服務,該受權碼沒拿去請求token過纔對,這裏的錯誤還有待排查。

處理器模式

其實就算token獲取成功,也不會響應咱們想要的accessToken,由於此前在配置SocialAuthenticationFilter時並無爲其制定認證成功處理器,所以咱們要將AppAuthenticationSuccessHandler設置到其中,這樣社交登陸成功後纔會生成並返回咱們要向的token

下面咱們就用簡單但實用的處理器重構手法來再security-app中爲security-coreSocialAuthenticationFilter作一個加強:

package top.zhenganwen.security.core.social;

import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;

/** * @author zhenganwen * @date 2019/9/15 * @desc 認證過濾器後置處理器 */
public interface AuthenticationFilterPostProcessor<T extends AbstractAuthenticationProcessingFilter> {
    /** * 對認證過濾器作一個加強,例如替換默認的認證成功處理器等 * @param filter */
    void process(T filter);
}
複製代碼
package top.zhenganwen.security.core.social.qq.connect;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.social.security.SpringSocialConfigurer;
import top.zhenganwen.security.core.properties.SecurityProperties;
import top.zhenganwen.security.core.social.AuthenticationFilterPostProcessor;

/** * @author zhenganwen * @date 2019/9/5 * @desc QQSpringSocialConfigurer */
public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired(required = false)    // 不是必需的
    private AuthenticationFilterPostProcessor<SocialAuthenticationFilter> processor;

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    protected <T> T postProcess(T object) {
        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) object;
        filter.setFilterProcessesUrl(securityProperties.getSocial().getFilterProcessingUrl());
        filter.setSignupUrl(securityProperties.getSocial().getSignUpUrl());
        processor.process(filter);
        return (T) filter;
    }

}
複製代碼
package top.zhenganwen.securitydemo.app.security.social;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.social.security.SocialAuthenticationFilter;
import org.springframework.stereotype.Component;
import top.zhenganwen.security.core.social.AuthenticationFilterPostProcessor;

/** * @author zhenganwen * @date 2019/9/15 * @desc SocialAuthenticationFilterProcessor */
@Component
public class SocialAuthenticationFilterProcessor implements AuthenticationFilterPostProcessor<SocialAuthenticationFilter> {

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Override
    public void process(SocialAuthenticationFilter filter) {
        filter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler);
    }
}
複製代碼

集成關聯社交帳號功能

第三方用戶信息暫存

以前,當用戶第一次使用社交登陸時,UserConnection中是沒有對應的關聯記錄的(userId->providerId-providerUserId),當時的邏輯是將查詢到的第三方用戶信息放入Session中,而後跳轉到社交帳號管理頁面引導用戶對社交帳號作一個關聯,後臺能夠經過ProviderSignInUtils工具類從Session中取出第三方用戶信息和用戶確認關聯時傳入的userId作一個關聯(插入到UserConnection)中。可是Security提供的ProviderSignInUtils是基於Session的,在基於token認證機制中是行不通的。

這時咱們能夠將OAuth流程走完後獲取到的第三方用戶信息以用戶設備deviceId做爲key緩存到Redis中,在用戶確認關聯時再從Redis中取出並和userId做爲一條記錄插入UserConnection中。其實就是換一個存儲方式的過程(由內存Session換成緩存redis)。

對應ProviderSignInUtils咱們封裝一個RedisProviderSignInUtils將其替換就好。

引導用戶關聯社交帳號

以下接口能夠實如今全部bean初始化完成以前都調用postProcessBeforeInitializationbean初始化完畢後調用postProcessAfterInitialization,若不想進行加強則能夠返回傳入的bean,若想有針對性的加強則可根據傳入的beanName進行篩選。

public interface BeanPostProcessor {
	Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
	Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}
複製代碼

咱們能夠該接口的一個實現類SpringSocialConfigurerPostProcessorQQSpringSocialConfigurer bean初始化完成後重設configure.signupUrl,當UserConnection沒有對應Connection關聯記錄時跳轉到signupUrl對應的服務。

在這個服務中應該返回一個JSON提示前端須要關聯社交帳號(並將以前走OAuth獲取到的第三方用戶信息由ProviderSignInUtilsSession中取出並使用RedisProviderSignInUtils暫存到Redis中),而不該該向以前設置的那樣跳轉到社交帳號關聯頁面。返回信息格式參考以下:

image.png
相關文章
相關標籤/搜索