歡迎關注我的博客,此文爲《Spring Security 技術棧開發企業級認證受權(2)》的後續html
準備工做:申請appId和appSecret,詳見準備工做_oauth2-0前端
回調域:www.zhenganwen.top/socialLogin…java
要開發一個第三方接入功能其實就是對上圖一套組件逐個進行實現一下,本節咱們將開發QQ登陸功能,首先從上圖的左半部分開始實現。node
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
,集成OAuth2Operations
和Api
,使用前者來完成受權獲取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);
}
}
複製代碼
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);
}
}
複製代碼
咱們須要重寫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();
}
複製代碼
咱們須要一張表來維護當前系統用戶表與用戶在第三方應用註冊的信息之間的對應關係,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());
}
}
複製代碼
萬變不離其中,使用第三方登陸的流程和用戶名密碼的認證流程實際上是同樣的。只不事後者是根據用戶輸入的用戶名到用戶表中查找用戶;而前者是先走OAtuh流程拿到用戶在第三方應用中的providerUserId
,再根據providerId
和providerUserId
到UserConnection
表中查詢對應的userId
,最後根據userId
到用戶表中查詢用戶
所以咱們還須要啓用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
都不一樣,因此咱們將其抽取到了配置文件中
demo.security.qq.appId=YOUR_APP_ID #替換成你的appId
demo.security.qq.appSecret=YOUR_APP_SECRET #替換成你的appSecret
demo.security.qq.providerId=qq
複製代碼
咱們須要在登陸頁提供一個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-id
爲qq
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;
}
複製代碼
如今SpringSocial的各個組件咱們算是實現了,可是可否串起來走通整個流程,咱們能夠來試一下,並在逐步排錯的過程當中進一步理解Social認證的流程
訪問/login.html
,點擊qq登陸
後響應以下
提示咱們回調地址是非法的,咱們能夠看一下地址欄中的redirect_url
參數
轉碼後其實就是http://localhost:8080/auth/qq
,也就是說若是用戶贊成受權那麼瀏覽器將會重定向到聯合登陸的URL上。
而我在QQ互聯中申請時填寫的回調域是www.zhenganwen.top/socialLogin/qq
(以下圖),QQ聯合登陸要求用戶贊成受權以後重定向到的URL必須和申請appId時填寫的回調域保持一致,也就是說頁面上聯合登陸的URL必須和回調域保持一致。
首先域名和端口須要保持一致:
因爲是本地服務器,所以咱們須要修改本地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
,須要自定義SocialAuthenticationFilter
的filterProcessesUrl
屬性值:
新增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()
是一個鉤子函數,在SecurityConfigurerAdapter
的config
方法中,在將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登陸
發現跳轉以下
受權跳轉邏輯走通!該階段代碼可參見: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
服務
直接上源碼:
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
還會將容器中的UsersConnectionRepository
和SocialAuthenticationServiceLocator
關聯到SoicalAuthenticationFilter
中,SoicalAuthenticationFilter
經過前者可以根據OAuth流程獲取的社交信息(providerId
和providerUserId
)查詢到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
是對ConnectionFactory
的一個封裝,對SocialAuthenticationFilter
隱藏OAuth以及OpenAPI調用細節
由於咱們在SocialConfig
中添加了@EnableSocial
,因此在系統啓動時會根據SocialAutoConfigurerAdapter
實現類中的createConnectionFactory
建立對應不一樣社交系統的ConnectionFactory
並將其包裝成SocialAuthenticationService
,而後將全部的SocialAuthenticationService
以providerId
爲key
緩存在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路徑中的providerId
到SocialAuthenticationLocator
中查找對應的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;
}
}
複製代碼
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
行,拿受權碼獲取accessToken
(AccessGrant
)、調用OpenAPI獲取用戶信息並適配成Connection
咱們掃描二維碼贊成受權,瀏覽器重定向到/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]
複製代碼
說明是在調用咱們的OAuth2Template
的exchangeForAccess
拿受權碼獲取accessToken
時報錯了,錯誤緣由是在轉換響應結果爲AccessGrant
時沒有處理text/html
的轉換器。
首先咱們看一下響應結果是什麼:
發現響應結果是一個字符串,以&
分割三個鍵值對,而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個轉換器,FormHttpMessageConverter
、FormMapHttpMessageConverter
、MappingJackson2HttpMessageConverter
分別對應解析Content-Type
爲application/x-www-form-urlencoded
、multipart/form-data
、application/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);
}
}
複製代碼
如今咱們可以拿到封裝accessToken
的AccessGrant
了,再繼續端點調試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;
}
}
複製代碼
發現QQApiImpl
的getUserInfo
存在同一的問題,調用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;
}
}
複製代碼
這時會調用ProviderManager
的authenticate
對SocialAuthenticationToken
進行校驗,ProviderManager
又會委託SocialAuthenticationProvider
SocialAuthenticationProvider
會調用咱們注入的JdbcUsersConnectionRepository
到UserConnection
表中根據Connection
的providerId
和providerUserId
查找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;
}
}
複製代碼
而SocialAuthenticationFilter
的signupUrl
默認爲/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("用戶未登陸,請引導用戶至登陸頁");
}
}
複製代碼
因而最終獲得了以下響應:
它會加載一個配置類SocialConfiguration
,該類會讀取容器中SocialConfigure
實例,如咱們所寫的擴展SocialAutoConfigureAdapter
的QQLoginAutoConfig
和擴展了SocialConfigureAdapter
的SocialConfig
,將咱們實現的ConnectionFactory
和UsersConnectionRepository
與SpringSecurity
的認證流程串起來
/** * 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>
複製代碼
註冊服務:雖然由於在UserConnection
表中沒有和本地用戶關聯的記錄而跳轉到了註冊頁,可是獲取的Connection
或保存在Session
中,若是你想在用戶點擊註冊本地帳號時自動爲其關聯QQ帳號或用戶已有本地帳號本身手動關聯QQ帳號,那麼可使用ProviderSignInUtils
這個工具類,你只須要告訴其須要關聯的本地帳戶userId
,它會自動取出Session
中保存的Connection
,並將userId
、Connection.getProviderId
、Connection.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";
}
}
複製代碼
有時咱們的系統的帳號管理模塊須要容許用戶關聯或取消關聯一些社交帳號,SpringSocial對這一場景也提供了支持(見ConnectController
)。你只需自定義相關的視圖組件(可擴展AbstractView
)即可實現「綁定/解綁」功能。
事實上,咱們所自定義的登陸流程只會在登陸時被執行一次,登陸成功後會生成一個封裝認證信息的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時調用),可經過回調參數獲取request
和response
經過.maxSessionsPreventsLogin(true)
可設置若用戶已登陸,則在其餘會話沒法再次登陸,Session因爲timeout
的設置失效或二次登陸被阻止,均可以經過.invalidSessionStrategy()
配置一個處理策略
爲了實現高可用和高併發,企業級應用一般會採用集羣的方式部署服務,經過網關或代理將請求根據輪詢算法轉發的到特定的服務,這時若是每一個服務單獨管理本身的Session,那麼就會出現重複要求用戶登陸的狀況。咱們能夠將Session的管理抽離出來存儲到一個單獨的系統中,spring-session
項目能夠幫咱們完成這份工做,咱們只需告訴它用什麼存儲系統來存儲Session便可。
一般咱們使用Redis
來存儲Session而不使用Mysql
,緣由以下:
SpringSecurity
針對每次請求都會從Session
中讀取認證信息,所以讀取比較頻繁,使用緩存系統速度較快Session
是有有效時間的,若是存儲在Mysql
中本身還需定時清理,而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 &
在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
中。
使用集羣模式後,以前配置的timeout
和http.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()
,指定註銷操做請求的URLlogoutSuccessUrl()
,註銷完成後跳轉到的URLlogoutSuccessHandler()
,註銷完成後調用的處理器,可根據用戶請求類型動態響應頁面或JSONdeleteCookies()
,根據key
刪除Cookie
中的item
咱們以前所講的一切都是基於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
。
從本章開始,咱們將採用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中受權模式中的受權碼
模式和密碼
模式
首先認證服務器端要有用戶,爲了方便這裏就再也不編寫DAO
和UserDetailsService
了,咱們能夠經過配置添加一個用戶:
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-client
和appSecret:123
。如今咱們的security-demo
也成爲了認證服務器,任何調用security-demo
API獲取token
的其餘應用可視爲第三方應用或客戶端了。
security.oauth2.client.client-id=test-client
security.oauth2.client.client-secret=123
複製代碼
接下來咱們能夠對照OAuth2
的官網上的 參考文檔來驗證@EnableAuthorizationServer
提供的4種受權模式並獲取token
參見 請求標準
受權碼模式有兩步:
獲取受權碼
觀察boot啓動日誌,發現框架爲咱們添加若干接口,其中就包含了/oauth/authorize
,這個就是受權碼獲取的接口。咱們對照OAuth2
中獲取受權碼的請求標準來嘗試獲取受權碼
http://localhost/oauth/authorize?
response_type=code
&client_id=test-client
&redirect_uri=http://example.com
&scope=all
複製代碼
其中response_type
固定爲code
表示獲取受權碼,client_id
爲客戶端的appId
,redirect_uri
爲客戶端接收受權碼從而進一步獲取token
的回調URL(這裏咱們暫且隨便寫一個,到時候受權成功跳轉到的URL上會附帶受權碼),scope
表示這次受權須要獲取的權限範圍(鍵值和鍵值的意義應由認證服務器來定,這裏咱們暫且隨便寫一個)。訪問該URL後,會彈出一個basic
認證的登陸框,咱們輸入用戶名test
密碼test
登陸以後跳轉到受權頁,詢問咱們是否授予all
權限(實際開發中咱們能夠將權限按操做類型分爲create
、delete
、update
、read
,也可按角色劃分爲user
、admin
、guest
等):
咱們點擊贊成Approve
後點擊受權Authorize
,而後跳轉到回調URL並附帶了受權碼
記下該受權碼yO4Y6q
用於後續的token
獲取
獲取token
咱們能夠經過Chrome
插件Restlet Client
來完成這次請求
Add authorization
輸入client-id
和client-secret
,工具會幫咱們自動加密並附在請求頭Authorizatin
中若是使用Postman
則Authorization
設置以下:
點擊Send
發送請求,響應以下:
密碼模式只需一步,無需受權碼,能夠直接獲取token
使用密碼模式至關於用戶告訴了客戶端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
攔截它會彈出登陸框,而這裏並無
而後咱們使用密碼模式從新生成一次token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c
,並在訪問接口時附帶token
(添加請求頭Authorization
值爲token_type access_token
)
使用Postman
更加方便:
框架核心組件以下,方框爲綠色表示是具體類,爲藍色則表示是接口/抽象,括號中的類爲運行時實際調用的類。下面咱們將以密碼模式
爲例來對源碼進行剖析,你也能夠打斷點逐步進行驗證。
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);
}
複製代碼
首先入參包含了兩個部分:principal
和parameters
,對應咱們密碼模式請求參數的兩個部分:請求頭Authorization
和請求體(grant_type
、username
、password
、scope
)。
String clientId = getClientId(principal);
principal
傳入的其實是一個UsernamePasswordToken
,對應邏輯在BasicAuthenticationFilter
的doFilterInternal
方法中:
@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
,這其實是咱們傳入的clientId
和clientSecret
使用冒號鏈接在一塊兒以後再用base64
加密算法獲得的,所以在extractAndDecodeHeader
方法中會對xxx
進行base64
解密獲得由冒號分隔的clientId
和clientSecret
組成的密文(借用以前的clientId=test-client
和clientSecret=123
的例子,這裏獲得的密文就是test-client:123
),最後將client-id
做爲username
、clientSecret
做爲password
構建了一個UsernamePasswordToken
並返回,所以在postAccessToken
中的principal
可以獲得請求頭中的clientId
。
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
接着調用ClientDetailsService
根據clientId
查詢已註冊的客戶端詳情,即ClientDetails
,這是外部應用在註冊security-demo
這個開放平臺時填寫並通過審覈的信息,包含若干項,咱們這裏只有clientId
和clientSecret
兩項。(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(""))
接着對傳入的clientId
和authenticatedClient
的clientId
進行校驗。也許你會問,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"]
,經過審覈後該客戶端請求獲取token
(token
可以代表: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
的請求。其實可以請求token
的grant_type
除了oauth
標準中的4中受權模式authorization_code
、implicit
、password
、client_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";
}
複製代碼
因爲是以密碼模式
爲例,所以流程走到了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
返回。
在收到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
,這時有兩種狀況:
token
過時了,這時要將該token
移除,若是該token
的refresh_token
還在則也要移除(請求刷新某token
時須要其對應的refresh_token
,若是token
失效了則其伴隨的refresh_token
也應該不可用)token
沒有過時,從新保存一下該token
(由於先後多是經過不一樣受權模式生成token
的,對應保存的邏輯也會有差異),並直接返回該token
,方法結束。若是沒有從tokenStore
中發現舊token
,那麼就新生成一個token
,保存到tokenStore
中並返回。
雖然框架已經幫咱們封裝好了認證服務器所需的4中受權模式,可是這這通常是對外的(外部應用沒法讀取咱們系統的用戶信息),用於構建開放平臺。對於內部應用,咱們仍是須要提供用戶名密碼登陸、手機號驗證碼登陸等方式來獲取token
。首先,框架流程一直到TokenGranter
組件這一部分咱們是不能沿用了,由於已被OAuth
流程固化了。咱們所能用的就是令牌生成服務AuthorizationServerTokenServices
,但它須要一個OAuth2Authentication
,而咱們構建OAuth2Authentication
又須要tokenRequest
和authentication
。
咱們能夠在原有登陸邏輯的基礎之上,修改登陸成功處理器,在該處理器中咱們能獲取到認證成功的authentication
,而且從請求頭Authorization
中獲取到clientId
調用注入的ClientDetailsService
查出clientDetails
並構建tokenRequest
,這樣就能調用令牌生成服務來生成令牌並響應了。
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) };
}
}
複製代碼
咱們將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
了:
請求頭仍是要附帶客戶端信息
請求參數傳用戶名密碼登陸所需參數便可
登陸成功即獲取token
經過token
訪問服務
因爲Postman
仍支持服務端寫入和讀取Cookie
爲了不Session/Cookie
登陸方式的影響,每次咱們須要清除cookie
再發送請求。
首先是不附帶token
的請求,發現請求被攔截了:
而後附帶token
訪問請求:
至此,用戶名密碼登陸獲取token
集成成功!
驗證碼和短信登陸的集成流程相似,在此再也不贅述。值得注意的是基於
token
的方式要摒棄對Session/Cookie
的操做,能夠將要保存在服務端的信息放入如Redis
等持久層中。
在本節,咱們將實現內部應用使用社交登陸的方式向內部認證服務器獲取token
。
若是內部應用採起的是簡易模式
,用戶贊成受權後直接獲取到外部服務提供商發放的token
,這時咱們是沒有辦法拿這個token
去訪問內部資源服務器的,須要拿這個token
去內部認證服務器換取咱們系統內部通行的token
。
換取思路是,若是用戶進行社交登陸成功,那麼內部應用就可以獲取到用戶的providerUserId
(在外部服務提供商中稱爲openId
),而且UserConnection
表應該有一條記錄(userId,providerId,providerUserId
),內部應用只需將providerId
和providerUserId
傳給內部認證服務器,內部認證服務器查UserConnection
表進行校驗並根據userId
構建Authentication
便可生成accessToken
。
爲此咱們須要在內部認證服務器上寫一套providerId+openId
的認證流程:
其中UserConnectionRepository
、CustomUserDetailsService
、AppAuthenticationSuccessHandler
都是現成的,能夠直接拿來用。
SecurityProperties
增長處理根據openId
拿token
的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;
}
}
複製代碼
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
:
並訪問/user
測試token
有效性,訪問成功!集成社交登陸成功!
若是內部應用採用的是受權碼模式,那麼在外部服務提供商帶着受權碼回調時,內部應用直接將該回調請求轉發到咱們的認證服務器便可,由於咱們此前已經寫過社交登陸模塊,這樣可以實現無縫銜接。
仍是以咱們以前實現的QQ登陸爲例:
內部應只需在用戶贊成受權,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
中,由於browser
和app
都有用到:
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
):
訪問www.zhenganwen.top/login.html
進行QQ受權登陸(同時打開瀏覽器控制檯),贊成受權進行跳轉,停在斷點後停掉服務,在瀏覽器控制檯中找到回調URL並複製它:
再將security-demo
的pom
切換爲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
了:
這裏認證服務器在拿受權碼獲取token
時返回異常信息code is reused error
(受權碼被重複使用),按理來講前一次咱們打了斷點並及時停掉了服務,該受權碼沒拿去請求token
過纔對,這裏的錯誤還有待排查。
其實就算token
獲取成功,也不會響應咱們想要的accessToken
,由於此前在配置SocialAuthenticationFilter
時並無爲其制定認證成功處理器,所以咱們要將AppAuthenticationSuccessHandler
設置到其中,這樣社交登陸成功後纔會生成並返回咱們要向的token
。
下面咱們就用簡單但實用的處理器重構手法來再security-app
中爲security-core
的SocialAuthenticationFilter
作一個加強:
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
初始化完成以前都調用postProcessBeforeInitialization
,bean
初始化完畢後調用postProcessAfterInitialization
,若不想進行加強則能夠返回傳入的bean
,若想有針對性的加強則可根據傳入的beanName
進行篩選。
public interface BeanPostProcessor {
Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}
複製代碼
咱們能夠該接口的一個實現類SpringSocialConfigurerPostProcessor
在QQSpringSocialConfigurer bean
初始化完成後重設configure.signupUrl
,當UserConnection
沒有對應Connection
關聯記錄時跳轉到signupUrl
對應的服務。
在這個服務中應該返回一個JSON提示前端須要關聯社交帳號(並將以前走OAuth
獲取到的第三方用戶信息由ProviderSignInUtils
從Session
中取出並使用RedisProviderSignInUtils
暫存到Redis
中),而不該該向以前設置的那樣跳轉到社交帳號關聯頁面。返回信息格式參考以下: