Spring Security 技术栈开发企业级认证受权(3)

欢迎关注我的博客,此文为《Spring Security 技术栈开发企业级认证受权(2)》的后续html

开发QQ登陆功能

准备工做:申请appId和appSecret,详见准备工做_oauth2-0前端

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

要开发一个第三方接入功能其实就是对上图一套组件逐个进行实现一下,本节咱们将开发QQ登陆功能,首先从上图的左半部分开始实现。node

ServiceProvider

Api,声明一个对应OpenAPI的方法,用来调用该API并将响应结果转成POJO返回,对应受权码模式时序图中的第7步c++

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

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

/** * @author zhenganwen * @date 2019/9/4 * @desc QQApi 封装对QQ开放平台接口的调用 */
public interface QQApi {

    QQUserInfo getUserInfo();
}

复制代码
package top.zhenganwen.security.core.social.qq.api;

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

/** * @author zhenganwen * @date 2019/9/3 * @desc QQApiImpl 拿token调用开放接口获取用户信息 * 1.首先要根据 https://graph.qq.com/oauth2.0/me/{token} 获取用户在社交平台上的id => {@code openId} * 2.调用QQ OpenAPI https://graph.qq.com/user/get_user_info?access_token=YOUR_ACCESS_TOKEN&oauth_consumer_key=YOUR_APP_ID&openid=YOUR_OPENID * 获取用户在社交平台上的信息 => {@link QQApiImpl#getUserInfo()} * <p> * {@link AbstractOAuth2ApiBinding} * 帮咱们完成了调用OpenAPI时附带{@code token}参数, 见其成员变量{@code accessToken} * 帮咱们完成了HTTP调用, 见其成员变量{@code restTemplate} * <p> * 注意:该组件应是多例的,由于每一个用户对应有不一样的OpenAPI,每次不一样的用户进行QQ联合登陆都应该建立一个新的 {@link QQApiImpl} */
@Data
public class QQApiImpl extends AbstractOAuth2ApiBinding implements QQApi {

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

    // 由于父类会帮咱们附带token参数,所以这里URL忽略了token参数
    private static final String URL_TO_GET_USER_INFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

    private String openId;

    private String appId;

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

    public QQApiImpl(String accessToken,String appId) {
        // 调用OpenAPI时将须要传递的参数附在URL路径上
        super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);
        this.appId = appId;

        // 获取用户openId, 响应结果格式:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} );
        String responseForGetOpenId = getRestTemplate().getForObject(String.format(URL_TO_GET_OPEN_ID, accessToken), String.class);
        logger.info("获取用户对应的openId:{}", responseForGetOpenId);

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

    @Override
    public QQUserInfo getUserInfo() {
        QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
        logger.info("调用QQ OpenAPI获取用户信息: {}", qqUserInfo);
        return qqUserInfo;
    }
}
复制代码

而后是OAuth2Operations,用来封装将用户导入受权页面、获取用户受权后传入的受权码、获取访问OpenAPI的token,对应受权码模式时序图中的第2~6步。因为这几步模式是固定的,因此Spring Social帮咱们作了强封装,即OAuth2Template,所以无需咱们本身实现,后面直接使用该组件便可git

ServiceProvider,集成OAuth2OperationsApi,使用前者来完成受权获取token,使用后者携带token调用OpenAPI获取用户信息web

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

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

/** * @author zhenganwen * @date 2019/9/4 * @desc QQServiceProvider 对接服务提供商,封装一整套受权登陆流程, 从用户点击第三方登陆按钮到掉第三方应用OpenAPI获取Connection(用户信息) * 委托 {@link OAuth2Operations} 和 {@link org.springframework.social.oauth2.AbstractOAuth2ApiBinding}来完成整个流程 */
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQApiImpl> {

    /** * 当前应用在服务提供商注册的应用id */
    private String appId;

    /** * @param oauth2Operations 封装逻辑: 跳转到认证服务器、用户受权、获取受权码、获取token * @param appId 当前应用的appId */
    public QQServiceProvider(OAuth2Operations oauth2Operations, String appId) {
        super(oauth2Operations);
        this.appId = appId;
    }

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

复制代码

ConnectionFactory

UserInfo,封装OpenAPI返回的用户信息ajax

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

import lombok.Data;

import java.io.Serializable;

/** * @author zhenganwen * @date 2019/9/4 * @desc QQUserInfo 用户在QQ应用注册的信息 */
@Data
public class QQUserInfo implements Serializable {
    /** * 返回码 */
    private String ret;
    /** * 若是ret<0,会有相应的错误信息提示,返回数据所有用UTF-8编码。 */
    private String msg;
    /** * */
    private String openId;
    /** * 不知道什么东西,文档上没写,可是实际api返回里有。 */
    private String is_lost;
    /** * 省(直辖市) */
    private String province;
    /** * 市(直辖市区) */
    private String city;
    /** * 出生年月 */
    private String year;
    /** * 用户在QQ空间的昵称。 */
    private String nickname;
    /** * 大小为30×30像素的QQ空间头像URL。 */
    private String figureurl;
    /** * 大小为50×50像素的QQ空间头像URL。 */
    private String figureurl_1;
    /** * 大小为100×100像素的QQ空间头像URL。 */
    private String figureurl_2;
    /** * 大小为40×40像素的QQ头像URL。 */
    private String figureurl_qq_1;
    /** * 大小为100×100像素的QQ头像URL。须要注意,不是全部的用户都拥有QQ的100×100的头像,但40×40像素则是必定会有。 */
    private String figureurl_qq_2;
    /** * 性别。 若是获取不到则默认返回”男” */
    private String gender;
    /** * 标识用户是否为黄钻用户(0:不是;1:是)。 */
    private String is_yellow_vip;
    /** * 标识用户是否为黄钻用户(0:不是;1:是) */
    private String vip;
    /** * 黄钻等级 */
    private String yellow_vip_level;
    /** * 黄钻等级 */
    private String level;
    /** * 标识是否为年费黄钻用户(0:不是; 1:是) */
    private String is_yellow_year_vip;
}
复制代码

ApiAdapter,将不一样的第三方应用返回的不一样用户信息数据格式转换成统一的用户视图redis

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

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

/** * @author zhenganwen * @date 2019/9/4 * @desc QQConnectionAdapter 从不一样第三方应用返回的不一样用户信息到统一用户视图{@link org.springframework.social.connect.Connection}的适配 */
@Component
public class QQConnectionAdapter implements ApiAdapter<QQApiImpl> {

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

    /** * 调用OpenAPI获取用户信息并适配成{@link org.springframework.social.connect.Connection} * 注意: 不是全部的社交应用都对应有{@link org.springframework.social.connect.Connection}中的属性,例如QQ就不像微博那样有我的主页 * @param api * @param values */
    @Override
    public void setConnectionValues(QQApiImpl api, ConnectionValues values) {
        QQUserInfo userInfo = api.getUserInfo();
        // 用户昵称
        values.setDisplayName(userInfo.getNickname());
        // 用户头像
        values.setImageUrl(userInfo.getFigureurl_2());
        // 用户我的主页
        values.setProfileUrl(null);
        // 用户在社交平台上的id
        values.setProviderUserId(userInfo.getOpenId());
    }

    // 此方法做用和 setConnectionValues 相似,在后续开发社交帐号绑定、解绑时再说
    @Override
    public UserProfile fetchUserProfile(QQApiImpl api) {
        return null;
    }

    /** * 调用OpenAPI更新用户动态 * 因为QQ OpenAPI没有此功能,所以不用管(若是接入微博则可能须要重写此方法) * @param api * @param message */
    @Override
    public void updateStatus(QQApiImpl api, String message) {

    }
}
复制代码

ConnectionFactory算法

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

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

public class QQConnectionFactory extends OAuth2ConnectionFactory<QQApiImpl> {

    public QQConnectionFactory(String providerId,OAuth2ServiceProvider<QQApiImpl> serviceProvider, ApiAdapter<QQApiImpl> apiAdapter) {
        super(providerId, serviceProvider, apiAdapter);
    }
}
复制代码

createConnectionFactory

咱们须要重写SocialAutoConfigurerAdapter中的createConnectionFactory方法注入咱们自定义的ConnectionFacory,SpringSoical将使用它来完成受权码模式的第2~7步

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

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

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

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

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

}
复制代码

QQSecurityProperties,QQ登陆相关配置项

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

import lombok.Data;

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

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

UsersConnectionRepository

咱们须要一张表来维护当前系统用户表与用户在第三方应用注册的信息之间的对应关系,SpringSocial为咱们提供了该表(在JdbcUsersConnectionRepository.java文件同一目录下)

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

CREATE UNIQUE INDEX UserConnectionRank ON UserConnection (userId, providerId, rank);
复制代码

其中userId为当前系统用户的惟一标识(不必定是用户表主键,也能够是用户名,只要是用户表中能惟一标识用户的字段就行),providerId用来标识第三方应用,providerUserId是用户在该第三方应用中的用户标识。这三个字段可以标识第三方应用(providerId)用户(providerUserId)在当前系统中对应的用户(userId)。咱们将此SQL在Datasource对应的数据库中执行如下。

SpringSocial为咱们提供了JdbcUsersConnectionRepository做为该张表的DAO,咱们须要将当前系统的数据源注入给它,并继承SocialConfigurerAdapter和添加@EnableSocial来启用SpringSocial的一些自动化配置

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

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

import javax.sql.DataSource;

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Bean
	  @Primary	// 父类会默认使用InMemoryUsersConnectionRepository做为实现,咱们要使用@Primary告诉容器只使用咱们这个 
    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三个参数能够对 token 进行加密存储
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

}
复制代码

SocialAuthenticationFilter

万变不离其中,使用第三方登陆的流程和用户名密码的认证流程实际上是同样的。只不事后者是根据用户输入的用户名到用户表中查找用户;而前者是先走OAtuh流程拿到用户在第三方应用中的providerUserId,再根据providerIdproviderUserIdUserConnection表中查询对应的userId,最后根据userId到用户表中查询用户

image.png

所以咱们还须要启用SocialAuthenticationFilter

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

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三个参数能够对 token 进行加密存储
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

    // 该bean是联合登陆配置类,和咱们以前所写的SmsLoginConfig和VerifyCodeValidatorConfig的
	  // 的做用是同样的,只不过它是增长一个SocialAuthenticationFilter到过滤器链中 
    @Bean
    public SpringSocialConfigurer springSocialConfigurer() {
        return new SpringSocialConfigurer();
    }
}
复制代码

SecurityBrowserConfig

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

        // 启用验证码校验过滤器
        http.apply(verifyCodeValidatorConfig);
        // 启用短信登陆过滤器
        http.apply(smsLoginConfig);
        // 启用QQ登陆(将SocialAuthenticationFilter加入到Security过滤器链中)
        http.apply(springSocialConfigurer);
        ...
复制代码

appId & appSecret & providerId

因为每一个系统申请的appIdappSecret都不一样,因此咱们将其抽取到了配置文件中

demo.security.qq.appId=YOUR_APP_ID #替换成你的appId
demo.security.qq.appSecret=YOUR_APP_SECRET #替换成你的appSecret
demo.security.qq.providerId=qq
复制代码

联合登陆URL设置规则

咱们须要在登陆页提供一个QQ联合登陆的连接,请求为/auth/qq

<a href="/auth/qq">qq登陆</a>
复制代码

第一个路径/auth是应为SocialAuthenticationFilter默认拦截/auth开头的请求

SocialAuthenticationFilter

private static final String DEFAULT_FILTER_PROCESSES_URL = "/auth";
复制代码

第二个路径须要和providerId保持一致,而咱们配置的demo.security.qq.provider-idqq

SocialAuthenticationFilter

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

联合登陆URL需和回调域保持一致

如今SpringSocial的各个组件咱们算是实现了,可是可否串起来走通整个流程,咱们能够来试一下,并在逐步排错的过程当中进一步理解Social认证的流程

访问/login.html,点击qq登陆后响应以下

image.png

提示咱们回调地址是非法的,咱们能够看一下地址栏中的redirect_url参数

image.png

转码后其实就是http://localhost:8080/auth/qq,也就是说若是用户赞成受权那么浏览器将会重定向到联合登陆的URL上。

而我在QQ互联中申请时填写的回调域是www.zhenganwen.top/socialLogin/qq(以下图),QQ联合登陆要求用户赞成受权以后重定向到的URL必须和申请appId时填写的回调域保持一致,也就是说页面上联合登陆的URL必须和回调域保持一致。

image.png

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

因为是本地服务器,所以咱们须要修改本地hosts文件,让浏览器解析www.zhenganwen.top时解析到172.0.0.1

127.0.0.1 www.zhenganwen.top
复制代码

而且将服务端口改成80

server.port=80
复制代码

这样域名和端口能对应上了,可以经过www.zhenganwen.top/login.html访问登陆页。

其次,还须要将联合登陆URI和咱们在设置的回调域对应上,/auth改成/socialLogin,须要自定义SocialAuthenticationFilterfilterProcessesUrl属性值:

新增SocialProperties

package top.zhenganwen.security.core.properties;

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

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

修改SecurityProperties

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

application.properties同步修改:

#demo.security.qq.appId=***
#demo.security.qq.appSecret=***
#demo.security.qq.providerId=qq
demo.security.social.qq.appId=***
demo.security.social.qq.appSecret=***
demo.security.social.qq.providerId=qq
复制代码

QQLoginAutoConfig同步修改

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

扩展SpringSocialConfigurer,经过钩子函数postProcess来实现对SocialAuthenticationFilter的一些自定义配置,如filterProcessingUrl

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

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

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

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

SocialConfig注入扩展后的SpringSocialConfigurer

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Override
    public JdbcUsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {
        // 使用第三个参数能够对 token 进行加密存储
        return new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());
    }

// @Bean
// public SpringSocialConfigurer springSocialConfigurer() {
// return new SpringSocialConfigurer();
// }
                    
    @Bean
    public SpringSocialConfigurer qqSpringSocialConfigurer() {
        QQSpringSocialConfigurer qqSpringSocialConfigurer = new QQSpringSocialConfigurer();
        return qqSpringSocialConfigurer;
    }
}
复制代码

这样作的缘由是postProcess()是一个钩子函数,在SecurityConfigurerAdapterconfig方法中,在将SocialAuthenticationFilter加入到过滤器链中时会调用postProcess,容许子类重写该方法从而对SocialAuthenticationFilter进行一些自定义配置:

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

同步修改登陆页

<a href="/socialLogin/qq">qq登陆</a>
复制代码

同时要在联合登陆配置类中将该联合登陆URL的拦截放开

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

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

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

访问www.zhenganwen.top/login.html,点击qq登陆发现跳转以下

image.png

受权跳转逻辑走通!该阶段代码可参见:gitee.com/zhenganwen/…

阶段性小结

回调域解析

你是在本地80端口跑的服务,为何认证服务器可以解析回调域www.zhenganwen.top/socialLogin/qq中的域名从而跳转到你的本地

注意上面受权登陆页面的地址栏,URL附带了redirect_url这一参数,所以当你赞成受权登录后,跳转到redirect_url参数值这一操做是在你浏览器中进行的,而你在hosts中配置了127.0.0.1 www.zhenganwen.top,所以浏览器没有进行域名解析直接将请求/socialLogin/qq发送到了127.0.0.1:80上,也就是咱们正在运行的security-demo服务

SpringSoicalConfigure的做用是什么?

直接上源码:

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

若是咱们想将以前所写的SpringSoical组件都应用上,那就要遵循SpringSecurity的认证机制,即添加一个新的认证方式就须要添加一个XxxAuthenticationFilter,而SpringSoical已经帮咱们实现了SocialAuthenticationFilter,所以咱们只须要在过滤器中添加它就行。与咱们以前将短信登陆封装到SmsLoginConfig中同样,SpringSocial帮咱们将社交登陆封装到了SpringSocialConfigure中,这样只要业务系统(即依赖SpringSocial的应用)只需调用httpSecurity.apply(springSocialConfigure)便可启用社交登陆功能。

而且除了将SoicalAuthenticationFilter添加到过滤器链中以外,SpringSocialConfigure还会将容器中的UsersConnectionRepositorySocialAuthenticationServiceLocator关联到SoicalAuthenticationFilter中,SoicalAuthenticationFilter经过前者可以根据OAuth流程获取的社交信息(providerIdproviderUserId)查询到userId,经过后者可以根据providerId获取对应的SocialAuthenticationService并从中获取到ConnectionFactory进行获取受权码、获取accessToken、获取用户社交信息等操做

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

为何要有SocialAuthenticationService,是在何时产生的?

SocialAuthenticationService是对ConnectionFactory的一个封装,对SocialAuthenticationFilter隐藏OAuth以及OpenAPI调用细节

由于咱们在SocialConfig中添加了@EnableSocial,因此在系统启动时会根据SocialAutoConfigurerAdapter实现类中的createConnectionFactory建立对应不一样社交系统的ConnectionFactory并将其包装成SocialAuthenticationService,而后将全部的SocialAuthenticationServiceproviderIdkey缓存在SocialAuthenticationLocator

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

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

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

}
复制代码
class SecurityEnabledConnectionFactoryConfigurer implements ConnectionFactoryConfigurer {

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

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

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

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

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

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

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

}
复制代码

因此当SocialAuthenticationFilter拦截到/{filterProcessingUrl}/{providerId}以后,会根据出URL路径中的providerIdSocialAuthenticationLocator中查找对应的SocialAuthenticationService获取authRequest

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

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

为何社交登陆URL和回调域要保持一致

SocialAuthenticationFilter#attemptAuthService

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

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

OAuth2AuthenticationService#getAuthToken

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

能够发现,用户在登陆也上点击qq登陆时被SocialAuthenticationFilter拦截,进入到上述的getAuthToken方法,请求参数是不带受权码的,所以第9行会抛出异常,该异常会被认证失败处理器截获并将用户导向社交系统认证服务器

public class SocialAuthenticationFailureHandler implements AuthenticationFailureHandler {
    private AuthenticationFailureHandler delegate;

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

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

在用户赞成受权后,认证服务器跳转到回调域并带入受权码,这时就会进入getAuthToken的第11行,拿受权码获取accessTokenAccessGrant)、调用OpenAPI获取用户信息并适配成Connection

为何赞成受权后响应以下

image.png

咱们扫描二维码赞成受权,浏览器重定向到/socialLogin/qq以后,发生了什么

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

在上述带啊的第12行打断点进行跟踪一下,发现执行13行时抛出异常跳转到了18行,异常信息以下:

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

说明是在调用咱们的OAuth2TemplateexchangeForAccess拿受权码获取accessToken时报错了,错误缘由是在转换响应结果为AccessGrant时没有处理text/html的转换器。

首先咱们看一下响应结果是什么:

image.png

发现响应结果是一个字符串,以&分割三个键值对,而OAuth2Template默认提供的转换器以下:

OAuth2Template

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

查看上述5~7行的3个转换器,FormHttpMessageConverterFormMapHttpMessageConverterMappingJackson2HttpMessageConverter分别对应解析Content-Typeapplication/x-www-form-urlencodedmultipart/form-dataapplication/json的响应体,所以报错提示

no suitable HttpMessageConverter found for response type [interface java.util.Map] and content type [text/html]
复制代码

这时咱们须要在原有的OAuth2Template的基础上在增长一个处理text/html的转换器:

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

    /** * 添加消息转换器以使可以解析 Content-Type 为 text/html 的响应体 * StringHttpMessageConverter 可解析任何 Content-Type的响应体,见其构造函数 * @return */
    @Override
    protected RestTemplate createRestTemplate() {
        RestTemplate restTemplate = super.createRestTemplate();
        restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));
        return restTemplate;
    }

    /** * 若是响应体是json,OAuth2Template会帮咱们构建, 但QQ互联的OpenAPI返回包都是 text/html 字符串 * 响应体 : "access_token=FE04***********CCE2&expires_in=7776000&refresh_token=88E4********BE14" * 使用 StringHttpMessageConverter 将请求的响应体转成 String ,并手动构建 AccessGrant * @param accessTokenUrl 拿受权码获取accessToken的URL * @param parameters 请求 accessToken 须要附带的参数 * @return */
    @Override
    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
        String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters,String.class);
        if (StringUtils.isEmpty(responseStr)) {
            return null;
        }
        // 0 -> access_token=FE04***********CCE
        // 1 -> expires_in=7776000
        // 2 -> refresh_token=88E4********BE14
        String[] strings = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");
        // accessToken scope refreshToken expiresIn
        AccessGrant accessGrant = new AccessGrant(
                StringUtils.substringAfterLast(strings[0], "="),
                null,
                StringUtils.substringAfterLast(strings[2], "="),
                Long.valueOf(StringUtils.substringAfterLast(strings[1], "=")));
        return accessGrant;
    }
}
复制代码

使用该QQOAuth2Template替换以前注入的OAuth2Template

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

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

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

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

}
复制代码

如今咱们可以拿到封装accessTokenAccessGrant了,再继续端点调试Connection的获取(下述第15行)

OAuth2AuthenticationService

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

发现QQApiImplgetUserInfo存在同一的问题,调用QQ互联API响应类型都是text/html,所以咱们不能直接转成POJO,而要先获取响应串,在经过JSON转换工具类ObjectMapper来转换:

QQApiImpl

@Override
    public QQUserInfo getUserInfo() {
        // QQ互联的响应 Content-Type 都是 text/html,所以不能直接转为 QQUserInfo
// QQUserInfo qqUserInfo = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), QQUserInfo.class);
        String responseStr = getRestTemplate().getForObject(String.format(URL_TO_GET_USER_INFO, appId, openId), String.class);
        logger.info("调用QQ OpenAPI获取用户信息: {}", responseStr);
        try {
            QQUserInfo qqUserInfo = objectMapper.readValue(responseStr, QQUserInfo.class);
            qqUserInfo.setOpenId(openId);
            return qqUserInfo;
        } catch (Exception e) {
            logger.error("获取用户信息转成 QQUserInfo 失败,响应信息:{}", responseStr);
            return null;
        }
    }
复制代码

再次扫码登陆进行断点调试,发现Connection也能成功拿到了,而且封装成SocialAuthenticationToken返回,因而getAuthToken终于成功返回了,走到了doAuthentication

SocialAuthenticationFilter

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

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

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

这时会调用ProviderManagerauthenticateSocialAuthenticationToken进行校验,ProviderManager又会委托SocialAuthenticationProvider

SocialAuthenticationProvider会调用咱们注入的JdbcUsersConnectionRepositoryUserConnection表中根据ConnectionproviderIdproviderUserId查找userId

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

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

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

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

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

JdbcUsersConnectionRepository

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

因为找不到(由于这时咱们的UserConnection表压根就没数据),toUserId会返回null,接着抛出BadCredentialsException("Unknown access token"),该异常会被SocialAuthenticationFilter捕获,并根据其signupUrl属性进行重定向(SpringSocial认为该用户在本系统没有注册,或者注册了但没有将本地用户和QQ登陆关联,所以跳转到注册页)

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

SocialAuthenticationFiltersignupUrl默认为/signup

public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	private String signupUrl = "/signup";
}                    
复制代码

跳转到/signup时,被SpringSecurity拦截,并重定向到loginPage(),最后到了BrowserSecurityController

SecurityBrowserConfig

.formLogin()
		.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
复制代码

SecurityConstants

/** * 未登陆访问受保护URL则跳转路径到 此 */
String FORWARD_TO_LOGIN_PAGE_URL = "/auth/require";
复制代码

BrowserSecurityController

@RestController
public class BrowserSecurityController {

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

    // security会将跳转前的请求存储在session中
    private RequestCache requestCache = new HttpSessionRequestCache();

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Autowired
    SecurityProperties securityProperties;

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

        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (savedRequest != null) {
            String redirectUrl = savedRequest.getRedirectUrl();
            logger.info("引起跳转到/auth/login的请求是: {}", redirectUrl);
            if (StringUtils.endsWithIgnoreCase(redirectUrl, ".html")) {
                // 若是用户是访问html页面被FilterSecurityInterceptor拦截从而跳转到了/auth/login,那么就重定向到登陆页面
                redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
            }
        }

        // 若是不是访问html而被拦截跳转到了/auth/login,则返回JSON提示
        return new SimpleResponseResult("用户未登陆,请引导用户至登陆页");
    }
}
复制代码

因而最终获得了以下响应:

image.png

@EnableSocial作了些什么

它会加载一个配置类SocialConfiguration,该类会读取容器中SocialConfigure实例,如咱们所写的扩展SocialAutoConfigureAdapterQQLoginAutoConfig和扩展了SocialConfigureAdapterSocialConfig,将咱们实现的ConnectionFactoryUsersConnectionRepositorySpringSecurity的认证流程串起来

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

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

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

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

复制代码

注册页 & 关联社交帐号

首先将注册页的URL可配置化,默认设为/sign-up.html,以及处理注册的服务接口/user/register

@Data
public class SocialProperties {

  private QQSecurityPropertie qq = new QQSecurityPropertie();

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

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

  public static final String DEFAULT_SING_UP_PROCESSING_URL = "/user/register";
  private String signUpProcessingUrl = DEFAULT_SING_UP_PROCESSING_URL;                    
}
复制代码

而后在浏览器配置类中将此路径放开:

@Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

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

        // 启用验证码校验过滤器
        http.apply(verifyCodeValidatorConfig).and()
        // 启用短信登陆过滤器
            .apply(smsLoginConfig).and()
        // 启用QQ登陆
            .apply(qqSpringSocialConfigurer).and()
            // 启用表单密码登陆过滤器
            .formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
            // 浏览器应用特有的配置,将登陆后生成的token保存在cookie中
            .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(customUserDetailsService)
                .and()
            // 浏览器应用特有的配置
            .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl()).permitAll()
                .anyRequest().authenticated().and()
            .csrf().disable();
    }
复制代码

最后编写注册页:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>标准注册页</h1>
    <a href="/social">QQ帐号信息</a>
    <form action="/user/register" method="post">
      用户名: <input type="text" name="username" value="admin">
      密码: <input type="password" name="password" value="123">
      <button type="submit" name="type" value="register">注册并关联QQ登陆</button>
      <button type="submit" name="type" value="binding">已有帐号关联QQ登陆</button>
    </form>

  </body>
</html>
复制代码

ProviderSignInUtils

注册服务:虽然由于在UserConnection表中没有和本地用户关联的记录而跳转到了注册页,可是获取的Connection或保存在Session中,若是你想在用户点击注册本地帐号时自动为其关联QQ帐号或用户已有本地帐号本身手动关联QQ帐号,那么可使用ProviderSignInUtils这个工具类,你只须要告诉其须要关联的本地帐户userId,它会自动取出Session中保存的Connection,并将userIdConnection.getProviderIdConnection.getProviderUserId做为一条记录插入到数据库中,这样该用户下次再进行QQ登陆时就不会跳转到本地帐号注册页了

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

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

  @Autowired
  private UserService userService;

  @Autowired
  private ProviderSignInUtils providerSignInUtils;

  @PostMapping("/register")
  public String register(String username, String password, String type, HttpServletRequest request) {
    if ("register".equalsIgnoreCase(type)) {
      logger.info("新增用户并关联QQ登陆, 用户名:{}", username);
      userService.insertUser();
    } else if ("binding".equalsIgnoreCase(type)) {
      logger.info("给用户关联QQ登陆, 用户名:{}", username);
    }
    providerSignInUtils.doPostSignUp(username, new ServletWebRequest(request));
    return "success";
  }
}                    
复制代码

关联QQ帐号.gif

绑定/解绑场景支持

有时咱们的系统的帐号管理模块须要容许用户关联或取消关联一些社交帐号,SpringSocial对这一场景也提供了支持(见ConnectController)。你只需自定义相关的视图组件(可扩展AbstractView)即可实现“绑定/解绑”功能。

Session管理

单机Session管理

事实上,咱们所自定义的登陆流程只会在登陆时被执行一次,登陆成功后会生成一个封装认证信息的Authentication保存在本地线程保险箱中,而在后续的用户访问受保护URL等操做时就不会在涉及到这些登陆流程中的组件了。

让咱们再回想一下Spring Security的过滤器链,位于首位的是SecurityContextPersistenceFilter,它用于在收到请求时试图从Session中读取登陆成功后生成的认证信息放入当前线程保险箱中,在响应请求时再取出来放入Session中,而位于过滤器链末尾的FilterSecurityInterceptor会在访问Controller服务以前校验线程保险箱中的认证信息,所以Session的管理会直接影响到用户此刻可否继续访问受保护URL。

在SpringBoot中,咱们能够经过配置项server.session.timeout(单位秒)来设置Session的有效时长,从而实现用户登陆一段时间以后若是还在访问受保护URL则须要从新登录。

相关代码位于TomcatEmbeddedServletContainerFactory

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

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

SpringBoot会将你配置的秒数转为分钟数,所以你会发现设置了server.session.timeout=10却发现1分钟后Session才失效致使须要从新登录的状况。

application.properties

server.session.timeout=10 	#设置Session 10秒后过时
复制代码

不过咱们通常设置为几个小时

与未登录而访问受保护URL不一样,Session失效致使没法访问受保护URL应该有不同的提示(例如:由于长时间没有操做,您登录的会话已过时,请从新登录;而不该该提示您还未登陆,请先登陆),这时咱们能够配置http.sessionManage().invalidSessionUrl()来指定用户登陆时间超过server.session.timeout设定的时长以后用户再访问受保护URL会跳转到的URL,你能够为其配置一个页面或者Controller来提示用户并引导用户到登陆页

SecurityBrowserConfig

protected void configure(HttpSecurity http) throws Exception {

        // 启用验证码校验过滤器
        http.apply(verifyCodeValidatorConfig).and()
        // 启用短信登陆过滤器
            .apply(smsLoginConfig).and()
        // 启用QQ登陆
            .apply(qqSpringSocialConfigurer).and()
            // 启用表单密码登陆过滤器
            .formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .and()
            // 浏览器应用特有的配置,将登陆后生成的token保存在cookie中
            .rememberMe()
                .tokenRepository(persistentTokenRepository())
                .tokenValiditySeconds(3600)
                .userDetailsService(customUserDetailsService)
                .and()
            .sessionManagement()
                .invalidSessionUrl("/session-invalid.html")
                .and()
            // 浏览器应用特有的配置
            .authorizeRequests()
                .antMatchers(
                        SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                        securityProperties.getBrowser().getLoginPage(),
                        SecurityConstants.VERIFY_CODE_SEND_URL,
                        securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                        securityProperties.getSocial().getSignUpUrl(),
                        securityProperties.getSocial().getSignUpProcessingUrl(),
                        "/session-invalid.html").permitAll()
                .anyRequest().authenticated().and()
            .csrf().disable();
    }
复制代码

.sessionManagement()配置下:

经过.maximumSessions能够控制一个用户同时可登陆的会话数,若是设置为1则可实现后一个登陆的人会踢掉前一个登陆的人。,经过expiredSessionStrategy能够为该事件设置一个回调方法(前一我的被挤掉后再访问受保护URL时调用),可经过回调参数获取requestresponse

经过.maxSessionsPreventsLogin(true)可设置若用户已登陆,则在其余会话没法再次登陆,Session因为timeout的设置失效或二次登陆被阻止,均可以经过.invalidSessionStrategy()配置一个处理策略

集群Session管理

为了实现高可用和高并发,企业级应用一般会采用集群的方式部署服务,经过网关或代理将请求根据轮询算法转发的到特定的服务,这时若是每一个服务单独管理本身的Session,那么就会出现重复要求用户登陆的状况。咱们能够将Session的管理抽离出来存储到一个单独的系统中,spring-session项目能够帮咱们完成这份工做,咱们只需告诉它用什么存储系统来存储Session便可。

一般咱们使用Redis来存储Session而不使用Mysql,缘由以下:

  • SpringSecurity针对每次请求都会从Session中读取认证信息,所以读取比较频繁,使用缓存系统速度较快
  • Session是有有效时间的,若是存储在Mysql中本身还需定时清理,而Redis自己就自带缓存数据时效性

安装Redis

官网,下载编译

$ wget http://download.redis.io/releases/redis-5.0.5.tar.gz
$ tar xzf redis-5.0.5.tar.gz
$ cd redis-5.0.5
$ make MALLOC=libc
复制代码

若是提示找不到相关命令则需安装相关依赖,yum install -y gcc g++ gcc-c++ make

启动服务:

./src/redis-server

因为我是在虚拟机CentOS6.5中安装的,而Redis默认的保护机制只容许本地访问,要想宿主机或外网访问则需配置./redis.conf,新增bind 192.168.102.2(个人宿主机局域网IP)可以让宿主机访问IP,这至关于增长一个IP白名单,若是想全部主机都能访问该服务,则可配置bind 0.0.0.0

修改配置后,须要再启动时指定读取该配置文件以使配置项生效:./src/redis-server ./redis.conf &

SpringBoot配置文件

application.properties中新增spring.redis.host=192.168.102.101,可指定SpringBoot启动时链接该主机的Redis(默认端口6379),并将以前的排除Redis自动集成注解去掉

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

    @RequestMapping("/hello")
    public String hello() {
        return "hello spring security";
    }
}
复制代码

在配置文件总指定将Session托管给Redis

spring.session.store-type=redis
spring.redis.host=192.168.102.101
复制代码

可支持的托管类型封装在了org.springframework.boot.autoconfigure.session.StoreType中。

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

注意:将Session托管给存储系统以后,要确保写入Session中的Bean是可序列化的,即实现了Serializable接口,若是Bean中的属性没法序列化,例如ImageCode中的BufferedImage image,若是不须要存储到Session中,则能够在写入Session时将该属性置为null

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

退出登陆

如何退出登陆

Security为咱们提供了一个默认注销当前用户的服务/logout,默认会作以下3件事:

  • 使当前Session失效
  • 清除remember-me功能的相关信息
  • 清除SecurityContext中的内容

咱们能够经过http.logout()来自定义注销登陆逻辑

  • logoutUrl(),指定注销操做请求的URL
  • logoutSuccessUrl(),注销完成后跳转到的URL
  • logoutSuccessHandler(),注销完成后调用的处理器,可根据用户请求类型动态响应页面或JSON
  • deleteCookies(),根据key删除Cookie中的item

Spring Security OAuth开发APP认证框架

咱们以前所讲的一切都是基于B/S架构的,即用户经过浏览器直接访问咱们的服务,是基于Session/Cookie的。可是如今先后端分离架构愈发流行,用户多是直接访问APP或WebServer(如nodejs),而APP和WebServer再经过ajax调用后端的服务,这一场景下Session/Cookie模式会有不少缺点

  • 开发繁琐,须要频繁针对Session/Cookie进行读写操做,请求从浏览器发出会附带存储在Cookie中的JSESSIONID,后端根据这个可以找到对应的Session,响应时又会将JSESSIONID写入Cookie。若是浏览器禁用Cookie则需在每次的URL上附带JSESSIONID参数
  • 安全性和客户体验差,敏感数据保存在客户端的Cookie中不太安全,Session时效管理、分布式管理等设置不当会致使用户的频繁从新登录,形成很差的用户体验
  • 有些前端技术根本就不支持Cookie,如App、小程序

如此而言,Spring Security OAuth提供了一种基于token的认证机制,认证再也不是每次请求读取存储在Session中的认证信息,而是对受权的用户发放一个token,访问服务时只需带上token参数便可。相比较于基于Session的方式,token更加灵活和安全,不会向Session同样SESSIONID的分配以及参数附带都是固化了的,token以怎样的形式呈现以及包含哪些信息以及可经过token刷新机制透明地延长受权时长(用户感知不到)来避免重复登陆等,都是能够被咱们自定义的。

提到OAuth,可能很容易联想到以前所开发的第三方登陆功能,其实Spring Social是封装了OAuth客户端所要走的流程,而Spring Security OAuth则是封装了OAuth认证服务器的相关功能。

就咱们本身开发的系统而言,后端就是认证服务器和资源服务器,而前端APP以及WebServer等就至关于OAuth客户端。

认证服务器须要作的事就是提供4中受权模式以及token的生成和存储,资源服务器就是保护REST服务,经过过滤器的方式在调用服务前校验请求中的token。而咱们须要作的就是将咱们自定义的认证逻辑(用户名密码登陆、短信验证码登陆、第三方登陆)集成到认证服务器中,并对接生成和存储token

image.png

从本章开始,咱们将采用Spring Security OAuth开发security-app项目,基于纯OAuth的认证方式,而不依赖于Session/Cookie

准备工做

首先咱们在security-demo中将引入的security-browser依赖注释掉,并引入security-app,忘掉以前基于Session/Cookie开发的认证代码,从头开始基于OAuth来开发认证受权。

因为在security-core中的验证码校验过滤器VerifyCodeValidateFilter须要注入认证成功/失败处理器,因此咱们将security-demo中的复制一份到security-app中,并将处理结果以JSON的方式响应(security-browser的处理结果能够是一个页面,但security-app只能响应JSON),并将SimpleResponseResult移入security-core中。

package top.zhenganwen.securitydemo.app.handler;

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

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

    @Autowired
    private ObjectMapper objectMapper;

// @Autowired
// private SecurityProperties securityProperties;

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

复制代码
package top.zhenganwen.securitydemo.app.handler;

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

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

    @Autowired
    private ObjectMapper objectMapper;

// @Autowired
// private SecurityProperties securityProperties;

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

复制代码

重启服务,查看在去掉security-browser而引入security-app以后项目是否能正常跑起来。

启用认证服务器

只需使用一个注解@EnableAuthorizationServer便可使当前服务成为一个认证服务器,starter-oauth2已经帮咱们封装好了认证服务器须要提供的4种受权模式和token的管理。

package top.zhenganwen.securitydemo.app.config;

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

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

复制代码

如今咱们能够来测试一下4中受权模式中的受权码模式和密码模式

首先认证服务器端要有用户,为了方便这里就再也不编写DAOUserDetailsService了,咱们能够经过配置添加一个用户:

security.user.name=test
security.user.password=test
security.user.role=user			# 要使用OAuth,用户须要有user角色,数据库中需存储为ROLE_USER
复制代码

而后配置一个clientId/clientSecret,这至关于别的应用调用security-demo进行第三方登陆以前须要在security-demo的互联开发平台上申请注册的appId/appSecret。例如如今有一个应用在security-demo的开发平台上注册审核经过了,security-demo会为其分配一个appId:test-clientappSecret:123。如今咱们的security-demo也成为了认证服务器,任何调用security-demoAPI获取token的其余应用可视为第三方应用或客户端了。

security.oauth2.client.client-id=test-client
security.oauth2.client.client-secret=123
复制代码

接下来咱们能够对照OAuth2的官网上的 参考文档来验证@EnableAuthorizationServer提供的4种受权模式并获取token

测试受权码模式

参见 请求标准

受权码模式有两步:

  1. 获取受权码

    观察boot启动日志,发现框架为咱们添加若干接口,其中就包含了/oauth/authorize,这个就是受权码获取的接口。咱们对照OAuth2中获取受权码的请求标准来尝试获取受权码

    image.png

    http://localhost/oauth/authorize?
    response_type=code
    &client_id=test-client
    &redirect_uri=http://example.com
    &scope=all
    复制代码

    其中response_type固定为code表示获取受权码,client_id为客户端的appIdredirect_uri为客户端接收受权码从而进一步获取token的回调URL(这里咱们暂且随便写一个,到时候受权成功跳转到的URL上会附带受权码),scope表示这次受权须要获取的权限范围(键值和键值的意义应由认证服务器来定,这里咱们暂且随便写一个)。访问该URL后,会弹出一个basic认证的登陆框,咱们输入用户名test密码test登陆以后跳转到受权页,询问咱们是否授予all权限(实际开发中咱们能够将权限按操做类型分为createdeleteupdateread,也可按角色划分为useradminguest等):

    image.png

    咱们点击赞成Approve后点击受权Authorize,而后跳转到回调URL并附带了受权码

    image.png

    记下该受权码yO4Y6q用于后续的token获取

  2. 获取token

    image.png

    咱们能够经过Chrome插件Restlet Client来完成这次请求

    1. 点击Add authorization输入client-idclient-secret,工具会帮咱们自动加密并附在请求头Authorizatin
    2. 填写请求参数

    image.png

    若是使用PostmanAuthorization设置以下:

    image.png

    点击Send发送请求,响应以下:

    image.png

密码模式

密码模式只需一步,无需受权码,能够直接获取token

image.png

使用密码模式至关于用户告诉了客户端test-client用户在security-demo上注册用户名密码,客户端直接拿这个去获取token,认证服务器并不知道客户端是经用户受权赞成后请求token仍是偷偷拿已知的用户名密码 来获取token,可是若是这个客户端应用是公司内部应用,可无需担忧这一点

这里还有一个细节:由于以前经过受权码模式发放了一个对应该用户的token,因此这里再经过密码模式获取token时返回的还是以前生成的token,而且过时时间expire_in在逐渐缩短

目前没有指定token的存储方式,所以默认是存储在内存中的,若是你重启了服务,那么就须要从新申请token

启用资源服务器

一样的,使用一个@EnableResourceServer注解就可使服务成为资源服务器(在调用服务前校验token

package top.zhenganwen.securitydemo.app.config;

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

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

复制代码

重启服务服务后直接访问查询用户接口/user响应401说明资源服务器起做用了(没有附带token访问受保护服务会被拦截),这也不是security默认的basic认证在起做用,由于若是是basic拦截它会弹出登陆框,而这里并无

image.png

而后咱们使用密码模式从新生成一次token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c,并在访问接口时附带token(添加请求头Authorization值为token_type access_token

image.png

使用Postman更加方便:

image.png

Spring Security Oauth核心源码剖析

框架核心组件以下,方框为绿色表示是具体类,为蓝色则表示是接口/抽象,括号中的类为运行时实际调用的类。下面咱们将以密码模式为例来对源码进行剖析,你也能够打断点逐步进行验证。

image.png

令牌颁发服务——TokenEndpoint

TokenEndpoint能够看作是一个Controller,它会受理咱们申请token的请求,见postAccessToken方法:

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

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

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

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

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

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

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

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

    return getResponse(token);

}
复制代码

首先入参包含了两个部分:principalparameters,对应咱们密码模式请求参数的两个部分:请求头Authorization和请求体(grant_typeusernamepasswordscope)。

String clientId = getClientId(principal);

principal传入的其实是一个UsernamePasswordToken,对应逻辑在BasicAuthenticationFilterdoFilterInternal方法中:

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

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

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

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

        String username = tokens[0];

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

        }

    }
    catch (AuthenticationException failed) {

    }

    chain.doFilter(request, response);
}

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

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

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

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

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

BasicAuthenticationFilter会拦截/oauth/token并尝试解析请求头Authorization,拿到对应的Basic xxx字符串,去掉前6个字符Basic,获取xxx,这其实是咱们传入的clientIdclientSecret使用冒号链接在一块儿以后再用base64加密算法获得的,所以在extractAndDecodeHeader方法中会对xxx进行base64解密获得由冒号分隔的clientIdclientSecret组成的密文(借用以前的clientId=test-clientclientSecret=123的例子,这里获得的密文就是test-client:123),最后将client-id做为usernameclientSecret做为password构建了一个UsernamePasswordToken并返回,所以在postAccessToken中的principal可以获得请求头中的clientId

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

接着调用ClientDetailsService根据clientId查询已注册的客户端详情,即ClientDetails,这是外部应用在注册security-demo这个开放平台时填写并通过审核的信息,包含若干项,咱们这里只有clientIdclientSecret两项。(authenticatedClient表示这个client是经咱们审核过的容许接入咱们开放平台的client

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

接着根据请求体参数parameters和客户端详情clientDetails构建了一个TokenRequest,这个tokenRequest代表当前这个获取token的请求是哪一个客户端(clientDetails)要获取哪一个用户(parameters.username)的访问权限、受权模式是什么(parameters.grant_type)、要获取哪些权限(parameters.scope)。

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

接着对传入的clientIdauthenticatedClientclientId进行校验。也许你会问,authenticatedClient不就是根据传入的clientId查出来的吗,再校验岂不是画蛇添足。其实否则,虽然查询的方法叫作loadClientByClientId,可是只能理解为是根据client惟一标识查询审核过的client,也许这个惟一标识是咱们数据库中client表的无关主键id,也多是clientId字段的值。也就是说咱们要从宏观上理解方法名loadClientByClientId。所以这里对clientId进行校验是无可厚非的。

if (authenticatedClient != null)

接着判断若是authenticatedClient不为空则校验请求的权限范围scope

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

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

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

能够联想这样一个场景:外部应用请求接入咱们的开放平台以读取咱们平台的用户信息,那么就对应clientScopes["read"],经过审核后该客户端请求获取tokentoken可以代表:1.你是谁;2.你能干些什么;3.访问时效)时请求参数scope就只能为["read"],而不能为["read","write"]等。这里就是校验请求token时传入的scope是否都包含在该客户端注册的scopes中。

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

接着校验grant_type参数不能为空,这也是oauth协议所规定的。

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

接着判断传入的grant_type是否为implicit,也就是说客户端是不是采用简易模式获取token,由于简易模式在用户赞成受权后就直接获取token了,所以不该该再调用获取token接口。

if (isAuthCodeRequest(parameters))

接着根据请求参数判断客户端是不是采用受权码模式,若是是,就将tokenRequest中的scope置为空,由于客户端的权限有哪些不该该是它本身传入的scope来决定,而是由其注册时咱们审核经过的scopes来决定,该属性后续会被从客户端详情中读取的scope覆盖。

if (isRefreshTokenRequest(parameters))

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

判断是不是刷新token的请求。其实可以请求tokengrant_type除了oauth标准中的4中受权模式authorization_codeimplicitpasswordclient_credential,还有一个refresh_token,为了改善用户体验(传统登陆方式一段时间后须要从新登录),token刷新机制可以在用户感知不到的状况下实现token时效的延长。若是是刷新token的请求,一如注释所写,refresh_token方式也有它本身默认的scopes,所以不该该使用请求中附带的。

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

这才是最重要的一步,前面都是对请求参数的封装和校验。这一步会调用TokenGranter令牌授与者生成token,后面的getResponse(token)就是将生成的token直接响应了。根据传入的受权类型grant_type及其对应的须要传入的参数,会调不一样的TokenGranter实现类进行token的构建,这一逻辑在CompositeTokenGranter中:

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

它会依次调用4中受权模式对应TokenGranter的实现类的grant方法,只有和请求参数grant_type对应的TokenGranter会被调用,这一逻辑在AbstractTokenGranter中:

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

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

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

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

    return getAccessToken(client, tokenRequest);

}
复制代码
public class AuthorizationCodeTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "authorization_code";
}

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

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

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

public class RefreshTokenGranter extends AbstractTokenGranter {
	private static final String GRANT_TYPE = "refresh_token";
}
复制代码

令牌授予者——TokenGranter

因为是以密码模式为例,所以流程走到了ResourceOwnerPasswordTokenGranter.grant中,它没有重写grant方法,所以调用的是父类的grant方法:

AbstractTokenGranter

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

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

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

    return getAccessToken(client, tokenRequest);

}

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

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

重点在第20行,调用子类的getOAuth2Authentication获取OAuth2Authentication,并传给调用认证服务器token服务AuthorizationServerTokenServices生成token。对于这里的getOAuth2Authentication,各TokenGranter子类又有不一样的实现,由于不一样受权模式的校验逻辑是不一样的,例如受权码模式这一环节须要校验请求传入的受权码(tokenRequest.parameters.code)是不是我以前发给对应客户端(clientDetails)的受权码;而密码模式则是校验请求传入的用户名密码在我当前系统是否存在该用户以及密码是否正确等。在经过校验后,会返回一个OAuth2Authentication,包含了oauth相关信息和系统用户的相关信息。

AuthorizationServerTokenServices

OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException;
复制代码

ResourceOwnerPasswordTokenGranter

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

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

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

    OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);		
    return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
复制代码

能够发现,ResourceOwnerPasswordTokenGranter的校验逻辑和咱们以前所写的用户名密码认证过滤器的逻辑几乎一致:从请求中获取用户名密码,而后构建authRequest传给ProviderManager进行校验,ProviderManager委托给DaoAuthenticationProvider天然又会调用咱们的UserDetailsService自定义实现类CustomUserDetailsService查询用户并校验。

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

校验经过返回认证成功的Authentication后,会调用工厂方法根据客户端详情以及tokenRequest构建AuthenticationServerTokenServices所需的OAuth2Authentication返回。

认证服务器令牌服务——AuthorizationServerTokenServices

在收到OAuth2Authentication以后,令牌服务就能生成token了,接着来看一下令牌服务的实现类DefaultTokenServices是如何生成token的:

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

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

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

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

}
复制代码

首先会试图从令牌仓库tokenStore中获取token,由于每次生成token以后响应以前会调tokenStore保存生成的token,这样后续客户端拿token访问资源的时候就有据可依。

if (existingAccessToken != null)

若是从tokenStore获取到了token,说明以前生成过token,这时有两种状况:

  1. 旧的token过时了,这时要将该token移除,若是该tokenrefresh_token还在则也要移除(请求刷新某token时须要其对应的refresh_token,若是token失效了则其伴随的refresh_token也应该不可用)
  2. 旧的token没有过时,从新保存一下该token(由于先后多是经过不一样受权模式生成token的,对应保存的逻辑也会有差异),并直接返回该token,方法结束。

若是没有从tokenStore中发现旧token,那么就新生成一个token,保存到tokenStore中并返回。

小结

image.png

集成用户名密码获取token

虽然框架已经帮咱们封装好了认证服务器所需的4中受权模式,可是这这通常是对外的(外部应用没法读取咱们系统的用户信息),用于构建开放平台。对于内部应用,咱们仍是须要提供用户名密码登陆、手机号验证码登陆等方式来获取token。首先,框架流程一直到TokenGranter组件这一部分咱们是不能沿用了,由于已被OAuth流程固化了。咱们所能用的就是令牌生成服务AuthorizationServerTokenServices,但它须要一个OAuth2Authentication,而咱们构建OAuth2Authentication又须要tokenRequestauthentication

咱们能够在原有登陆逻辑的基础之上,修改登陆成功处理器,在该处理器中咱们能获取到认证成功的authentication,而且从请求头Authorization中获取到clientId调用注入的ClientDetailsService查出clientDetails并构建tokenRequest,这样就能调用令牌生成服务来生成令牌并响应了。

image.png

在登陆成功处理器中调用令牌服务

AppAuthenticationSuccessHandler

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

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

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

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

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

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private ClientDetailsService clientDetailsService;

    @Autowired
    private AuthorizationServerTokenServices authorizationServerTokenServices;

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

        // Authentication
        Authentication userAuthentication = authentication;

        // ClientDetails
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Basic ")) {
            throw new UnapprovedClientAuthenticationException("请求头中必须附带 oauth client 相关信息");
        }
        String[] clientIdAndSecret = extractAndDecodeHeader(authHeader);
        String clientId = clientIdAndSecret[0];
        String clientSecret = clientIdAndSecret[1];
        ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientIdAndSecret[0]);
        if (clientDetails == null) {
            throw new UnapprovedClientAuthenticationException("无效的clientId");
        } else if (!StringUtils.equals(clientSecret, clientDetails.getClientSecret())) {
            throw new UnapprovedClientAuthenticationException("错误的clientSecret");
        }

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

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

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

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

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

    private String[] extractAndDecodeHeader(String header){

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

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

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

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

复制代码

继承ResourceServerConfigurerAdapter实现Security配置

咱们将BrowserSecurityConfig中对于security的配置拷到ResourceServerConfig中,仅启用表单密码登陆:

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

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

import javax.sql.DataSource;

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService customUserDetailsService;

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

    @Autowired
    SmsLoginConfig smsLoginConfig;

    @Autowired
    private VerifyCodeValidatorConfig verifyCodeValidatorConfig;

    @Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

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

        // 启用表单密码登陆过滤器
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        http
// // 启用验证码校验过滤器
// .apply(verifyCodeValidatorConfig).and()
// // 启用短信登陆过滤器
// .apply(smsLoginConfig).and()
// // 启用QQ登陆
// .apply(qqSpringSocialConfigurer).and()
// // 浏览器应用特有的配置,将登陆后生成的token保存在cookie中
// .rememberMe()
// .tokenRepository(persistentTokenRepository())
// .tokenValiditySeconds(3600)
// .userDetailsService(customUserDetailsService)
// .and()
// .sessionManagement()
// .invalidSessionUrl("/session-invalid.html")
// .invalidSessionStrategy((request, response) -> {})
// .maximumSessions(1)
// .expiredSessionStrategy(eventØ -> {})
// .maxSessionsPreventsLogin(true)
// .and()
// .and()
                // 浏览器应用特有的配置
                .authorizeRequests()
                    .antMatchers(
                            SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL,
                            securityProperties.getBrowser().getLoginPage(),
                            SecurityConstants.VERIFY_CODE_SEND_URL,
                            securityProperties.getSocial().getFilterProcessingUrl() + securityProperties.getSocial().getQq().getProviderId(),
                            securityProperties.getSocial().getSignUpUrl(),
                            securityProperties.getSocial().getSignUpProcessingUrl(),
                            "/session-invalid.html").permitAll()
                    .anyRequest().authenticated()
                    .and()
                // 基于token的受权机制没有登陆/注销的概念,只有token申请和过时的概念
                .csrf().disable();
    }
}
复制代码

如此,内部应用客户端就能够经过用户的用户名密码获取token了:

  1. 请求头仍是要附带客户端信息

    image.png
  2. 请求参数传用户名密码登陆所需参数便可

    image.png
  3. 登陆成功即获取token

    image.png
  4. 经过token访问服务

    因为Postman仍支持服务端写入和读取Cookie

    image.png

    为了不Session/Cookie登陆方式的影响,每次咱们须要清除cookie再发送请求。

    image.png

    image.png

    首先是不附带token的请求,发现请求被拦截了:

    image.png

    而后附带token访问请求:

    image.png

至此,用户名密码登陆获取token集成成功!

验证码和短信登陆的集成流程相似,在此再也不赘述。值得注意的是基于token的方式要摒弃对Session/Cookie的操做,能够将要保存在服务端的信息放入如Redis等持久层中。

集成社交登陆获取token

在本节,咱们将实现内部应用使用社交登陆的方式向内部认证服务器获取token

简易模式

流程分析

若是内部应用采起的是简易模式,用户赞成受权后直接获取到外部服务提供商发放的token,这时咱们是没有办法拿这个token去访问内部资源服务器的,须要拿这个token去内部认证服务器换取咱们系统内部通行的token

换取思路是,若是用户进行社交登陆成功,那么内部应用就可以获取到用户的providerUserId(在外部服务提供商中称为openId),而且UserConnection表应该有一条记录(userId,providerId,providerUserId),内部应用只需将providerIdproviderUserId传给内部认证服务器,内部认证服务器查UserConnection表进行校验并根据userId构建Authentication便可生成accessToken

image.png

为此咱们须要在内部认证服务器上写一套providerId+openId的认证流程:

image.png

其中UserConnectionRepositoryCustomUserDetailsServiceAppAuthenticationSuccessHandler都是现成的,能够直接拿来用。

SecurityProperties增长处理根据openIdtoken的URL:

package top.zhenganwen.security.core.properties;

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

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

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

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

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

    public static final String DEFAULT_OPEN_ID_FILTER_PROCESSING_URL = "/auth/openId";
    private String openIdFilterProcessingUrl = DEFAULT_OPEN_ID_FILTER_PROCESSING_URL;
}
复制代码

自定义请求AuthenticationToken

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

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

import java.util.Collection;

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

    // 做为请求认证的token时存储providerId,做为认证成功的token时存储用户信息
    private final Object principal;
    // 做为请求认证的token时存储openId,做为认证成功的token时存储用户密码
    private Object credentials;

    // 请求认证时调用
    public OpenIdAuthenticationToken(Object providerId, Object openId) {
        super(null);
        this.principal = providerId;
        this.credentials = openId;
        setAuthenticated(false);
    }

    // 认证经过后调用
    public OpenIdAuthenticationToken(Object userInfo, Object password, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = userInfo;
        this.credentials = password;
        super.setAuthenticated(true);
    }


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

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

}
复制代码

认证拦截器OpenIdAuthenticationFilter

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

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

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

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

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

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

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

        // authenticate
        return getAuthenticationManager().authenticate(authRequest);
    }
}
复制代码

实际认证官OpenIdAuthenticationProvider

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

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

import java.util.Set;

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

    private UsersConnectionRepository usersConnectionRepository;

    private UserDetailsService userDetailsService;

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

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

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

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

        return authenticationToken;
    }

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

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

    public UsersConnectionRepository getUsersConnectionRepository() {
        return usersConnectionRepository;
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}
复制代码

OpenId认证流程配置类OpenIdAuthenticationConfig

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private UsersConnectionRepository usersConnectionRepository;

    @Autowired
    private UserDetailsService customUserDetailsService;

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

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

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

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

    }
}
复制代码

apply应用到Security主配置类中

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

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

        // 启用表单密码登陆获取token
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        // 启用社交登陆获取token
        http.apply(openIdAuthenticationConfig);

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

测试

现用Postman模拟内部应用访问/auth/openId请求token

image.png

并访问/user测试token有效性,访问成功!集成社交登陆成功!

受权码模式

若是内部应用采用的是受权码模式,那么在外部服务提供商带着受权码回调时,内部应用直接将该回调请求转发到咱们的认证服务器便可,由于咱们此前已经写过社交登陆模块,这样可以实现无缝衔接。

仍是以咱们以前实现的QQ登陆为例:image.png

内部应只需在用户赞成受权,QQ认证服务器重定向到内部应用回调域时,将该回调请求原封不动转发给认证服务器便可,由于咱们以前已开发过/socialLogin接口处理社交登陆。

这里测试,咱们不可能真的去开发一个App,能够采用原先开发的security-browser项目,再获取到受权码的地方打个断点,获取到受权码后停掉服务(避免后面拿受权码请求token致使受权码失效)。而后再在Postman中拿受权码请求token(模拟App转发回调域到/socialLogin/qq

首先在security-demo中注释security-app而启用security-browser

<dependency>
    <groupId>top.zhenganwen</groupId>
    <artifactId>security-browser</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>top.zhenganwen</groupId>-->
<!-- <artifactId>security-app</artifactId>-->
<!-- <version>1.0-SNAPSHOT</version>-->
<!-- </dependency>-->
复制代码

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

package top.zhenganwen.security.core.service;

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

import java.util.Objects;

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

    @Autowired
    BCryptPasswordEncoder passwordEncoder;

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

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

    private SocialUser buildUser(@NotBlank String username) {
        logger.info("登陆用户名: " + username);
        // 实际项目中你能够调用Dao或Repository来查询用户是否存在
        if (Objects.equals(username, "admin") == false) {
            throw new UsernameNotFoundException("用户名不存在");
        }
        // 假设查出来的密码以下
        String pwd = passwordEncoder.encode("123");

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

    // 根据用户惟一标识查询用户, 你能够灵活地根据用户表主键、用户名等内容惟一的字段来查询
    @Override
    public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
        return buildUser(userId);
    }
}
复制代码

接着设置端口80启动服务并在以下拿受权码获取token前设置断点(OAuth2AuthenticationService):

image.png

访问www.zhenganwen.top/login.html进行QQ受权登陆(同时打开浏览器控制台),赞成受权进行跳转,停在断点后停掉服务,在浏览器控制台中找到回调URL并复制它:

image.png

再将security-demopom切换为app

<!-- <dependency>-->
<!-- <groupId>top.zhenganwen</groupId>-->
<!-- <artifactId>security-browser</artifactId>-->
<!-- <version>1.0-SNAPSHOT</version>-->
<!-- </dependency>-->
<dependency>
    <groupId>top.zhenganwen</groupId>
    <artifactId>security-app</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>
复制代码

Security主配置文件中启用QQ登陆:

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Autowired
    private QQSpringSocialConfigurer qqSpringSocialConfigurer;

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

        // 启用表单密码登陆获取token
        http.formLogin()
                .loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)
                .loginProcessingUrl(SecurityConstants.DEFAULT_FORM_LOGIN_URL)
                .successHandler(appAuthenticationSuccessHandler)
                .failureHandler(appAuthenticationFailureHandler);

        // 启用社交登陆获取token
        http.apply(openIdAuthenticationConfig);
        http.apply(qqSpringSocialConfigurer);

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

而后咱们就能够用Postman模拟App将收到受权码回调转发给认证服务器获取token了:

image.png

这里认证服务器在拿受权码获取token时返回异常信息code is reused error(受权码被重复使用),按理来讲前一次咱们打了断点并及时停掉了服务,该受权码没拿去请求token过才对,这里的错误还有待排查。

处理器模式

其实就算token获取成功,也不会响应咱们想要的accessToken,由于此前在配置SocialAuthenticationFilter时并无为其制定认证成功处理器,所以咱们要将AppAuthenticationSuccessHandler设置到其中,这样社交登陆成功后才会生成并返回咱们要向的token

下面咱们就用简单但实用的处理器重构手法来再security-app中为security-coreSocialAuthenticationFilter作一个加强:

package top.zhenganwen.security.core.social;

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

/** * @author zhenganwen * @date 2019/9/15 * @desc 认证过滤器后置处理器 */
public interface AuthenticationFilterPostProcessor<T extends AbstractAuthenticationProcessingFilter> {
    /** * 对认证过滤器作一个加强,例如替换默认的认证成功处理器等 * @param filter */
    void process(T filter);
}
复制代码
package top.zhenganwen.security.core.social.qq.connect;

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

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

}
复制代码
package top.zhenganwen.securitydemo.app.security.social;

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

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

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Override
    public void process(SocialAuthenticationFilter filter) {
        filter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler);
    }
}
复制代码

集成关联社交帐号功能

第三方用户信息暂存

以前,当用户第一次使用社交登陆时,UserConnection中是没有对应的关联记录的(userId->providerId-providerUserId),当时的逻辑是将查询到的第三方用户信息放入Session中,而后跳转到社交帐号管理页面引导用户对社交帐号作一个关联,后台能够经过ProviderSignInUtils工具类从Session中取出第三方用户信息和用户确认关联时传入的userId作一个关联(插入到UserConnection)中。可是Security提供的ProviderSignInUtils是基于Session的,在基于token认证机制中是行不通的。

这时咱们能够将OAuth流程走完后获取到的第三方用户信息以用户设备deviceId做为key缓存到Redis中,在用户确认关联时再从Redis中取出并和userId做为一条记录插入UserConnection中。其实就是换一个存储方式的过程(由内存Session换成缓存redis)。

对应ProviderSignInUtils咱们封装一个RedisProviderSignInUtils将其替换就好。

引导用户关联社交帐号

以下接口能够实如今全部bean初始化完成以前都调用postProcessBeforeInitializationbean初始化完毕后调用postProcessAfterInitialization,若不想进行加强则能够返回传入的bean,若想有针对性的加强则可根据传入的beanName进行筛选。

public interface BeanPostProcessor {
	Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
	Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}
复制代码

咱们能够该接口的一个实现类SpringSocialConfigurerPostProcessorQQSpringSocialConfigurer bean初始化完成后重设configure.signupUrl,当UserConnection没有对应Connection关联记录时跳转到signupUrl对应的服务。

在这个服务中应该返回一个JSON提示前端须要关联社交帐号(并将以前走OAuth获取到的第三方用户信息由ProviderSignInUtilsSession中取出并使用RedisProviderSignInUtils暂存到Redis中),而不该该向以前设置的那样跳转到社交帐号关联页面。返回信息格式参考以下:

image.png
相关文章
相关标签/搜索