转载

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

准备工作:申请appId和appSecret,详见准备工作_oauth2-0

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

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

ServiceProvider

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

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 ,因此无需我们自己实现,后面直接使用该组件即可

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

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返回的用户信息

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 ,将不同的第三方应用返回的不同用户信息数据格式转换成统一的用户视图

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 到用户表中查询用户

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

因此我们还需要启用 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登录 后响应如下

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

提示我们回调地址是非法的,我们可以看一下地址栏中的 redirect_url 参数

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

转码后其实就是 http://localhost:8080/auth/qq ,也就是说如果用户同意授权那么浏览器将会重定向到联合登录的URL上。

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

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

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

由于是本地服务器,因此我们需要修改本地 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登录 发现跳转如下

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

授权跳转逻辑走通!该阶段代码可参见: 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

为什么同意授权后响应如下

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

我们扫描二维码同意授权,浏览器重定向到 /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 的转换器。

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

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

发现响应结果是一个字符串,以 & 分割三个键值对,而 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("用户未登录,请引导用户至登录页");
    }
}
复制代码

于是最终得到了如下响应:

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

@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";
  }
}                    
复制代码
Spring Security 技术栈开发企业级认证授权(3)

绑定/解绑场景支持

有时我们的系统的账号管理模块需要允许用户关联或取消关联一些社交账号,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

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

从本章开始,我们将采用 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-demo API获取 token 的其他应用可视为第三方应用或客户端了。

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

接下来我们可以对照 OAuth2 的官网上的参考文档来验证 @EnableAuthorizationServer 提供的4种授权模式并获取 token

测试授权码模式

参见请求标准

授权码模式有两步:

  1. 获取授权码

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

    Spring Security 技术栈开发企业级认证授权(3)
    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 等):

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

    我们点击同意 Approve 后点击授权 Authorize ,然后跳转到回调URL并附带了授权码

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

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

  2. 获取 token

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

    我们可以通过 Chrome 插件 Restlet Client 来完成此次请求

    1. 点击 Add authorization 输入 client-idclient-secret ,工具会帮我们自动加密并附在请求头 Authorizatin
    2. 填写请求参数
    Spring Security 技术栈开发企业级认证授权(3)

    如果使用 PostmanAuthorization 设置如下:

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

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

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

密码模式

密码模式只需一步,无需授权码,可以直接获取 token

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

使用密码模式相当于用户告诉了客户端 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 拦截它会弹出登录框,而这里并没有

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

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

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

使用 Postman 更加方便:

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

Spring Security Oauth核心源码剖析

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

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

令牌颁发服务——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 中并返回。

小结

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

集成用户名密码获取token

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

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

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

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

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. 请求头还是要附带客户端信息

    Spring Security 技术栈开发企业级认证授权(3)
  2. 请求参数传用户名密码登录所需参数即可

    Spring Security 技术栈开发企业级认证授权(3)
  3. 登录成功即获取 token

    Spring Security 技术栈开发企业级认证授权(3)
  4. 通过 token 访问服务

    由于 Postman 仍支持服务端写入和读取 Cookie

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

    为了避免 Session/Cookie 登录方式的影响,每次我们需要清除 cookie 再发送请求。

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

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

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

    然后附带 token 访问请求:

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

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

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

集成社交登录获取token

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

简易模式

流程分析

如果内部应用采取的是 简易模式 ,用户同意授权后直接获取到外部服务提供商发放的 token ,这时我们是没有办法拿这个 token 去访问内部资源服务器的,需要拿这个 token 去内部认证服务器换取我们系统内部通行的 token

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

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

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

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

其中 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

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

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

授权码模式

如果内部应用采用的是授权码模式,那么在外部服务提供商带着授权码回调时,内部应用直接将该回调请求转发到我们的认证服务器即可,因为我们此前已经写过社交登录模块,这样能够实现无缝衔接。

还是以我们之前实现的QQ登录为例: Spring Security 技术栈开发企业级认证授权(3)

内部应只需在用户同意授权,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 ):

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

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

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

再将 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 了:

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

这里认证服务器在拿授权码获取 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 中),而不应该向之前设置的那样跳转到社交账号关联页面。返回信息格式参考如下:

Spring Security 技术栈开发企业级认证授权(3)
原文  https://juejin.im/post/5d7f4358f265da03de3b34ad
正文到此结束
Loading...