Авторизация аутентификации на уровне предприятия при разработке стека технологий Spring Security (3)

Spring

Добро пожаловать, чтобы следоватьличный блог, этот текст«Разработка стека технологий Spring Security Аутентификация и авторизация на уровне предприятия (2)»следовать за

Разработать функцию входа в QQ

Подготовка: подайте заявку на получение appId и appSecret, см. подробностиПодготовка_oauth2-0

Домен обратного вызова:Woohoo. Работает стабильно. Верх/социальный вход…

Разработка сторонней функции доступа фактически означает реализацию вышеуказанного набора компонентов один за другим.В этом разделе мы разработаем функцию входа в систему 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, что соответствует шагам 2–6 на диаграмме последовательности режима кода авторизации. Так как мода этих шагов фиксирована, тоSpring SocialПомогите нам сделать сильную инкапсуляцию, то естьOAuth2Template, поэтому нам не нужно реализовывать его самостоятельно, мы можем использовать этот компонент непосредственно позже

ServiceProvider, интегрированныйOAuth2Operationsа такжеApi, используйте первый для завершения авторизации для получения токена и используйте второй для переноса токена для вызова 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является идентификатором пользователя в стороннем приложении. Эти три поля могут идентифицировать пользователя (userId), соответствующего пользователю стороннего приложения (providerId) (providerUserId) в текущей системе. Мы выполняем следующий SQL в базе данных, соответствующей источнику данных.

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, то согласноproviderIdа такжеproviderUserIdприбытьUserConnectionзапрос, соответствующий таблицеuserId, и, наконец, согласноuserIdЗапросить пользователей в таблице пользователей

image.png

Так что нам также нужно включитьSocialAuthenticationFilter:

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

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

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

    // 该bean是联合登录配置类,和我们之前所写的SmsLoginConfig和VerifyCodeValidatorConfig的
	  // 的作用是一样的,只不过它是增加一个SocialAuthenticationFilter到过滤器链中                    
    @Bean
    public SpringSocialConfigurer springSocialConfigurer() {
        return new SpringSocialConfigurer();
    }
}

SecurityBrowserConfig

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

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

appId & appSecret & providerId

Поскольку каждая система применяетсяappIdа такжеappSecretотличаются, поэтому мы извлекли его в файл конфигурации

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-idдляqq

SocialAuthenticationFilter

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

URL-адрес федеративного входа должен совпадать с доменом обратного вызова.

Теперь мы осознали различные компоненты SpringSocial, но можно ли их связать вместе, чтобы пройти весь процесс, мы можем попробовать его и дополнительно понять процесс социальной сертификации в процессе пошагового устранения неполадок.

доступ/login.html, нажмитеqq登录Ответ на пост такой

image.png

Подскажите что адрес обратного звонка неправильный, можем посмотреть в адресной строкеredirect_urlпараметр

image.png

После перекодирования это фактическиhttp://localhost:8080/auth/qq, то есть, если пользователь соглашается на авторизацию, браузер будет перенаправлен на URL-адрес федеративного входа.

И домен обратного вызова, который я заполнил при подаче заявки в QQ Internet,www.zhenganwen.top/socialLogin/qq(Как показано на рисунке ниже), для совместного входа в QQ требуется, чтобы URL-адрес, на который перенаправляются после того, как пользователь соглашается авторизоваться, должен соответствовать домену обратного вызова, указанному при подаче заявки на appId, то есть URL-адресу совместного входа на странице. должен соответствовать домену обратного вызова.

image.png

Во-первых, доменное имя и порт должны быть согласованы:

Поскольку это локальный сервер, нам нужно изменить локальныйhostsФайл, чтобы браузер разобралwww.zhenganwen.topПри анализе172.0.0.1:

127.0.0.1 www.zhenganwen.top

и измените порт службы на80

server.port=80

Таким образом, доменное имя и порт могут быть сопоставлены и могут быть переданы черезwww.zhenganwen.top/login.htmlПосетите страницу входа.

Во-вторых, также необходимо сопоставить URI совместного входа с установленным нами доменом обратного вызова,/authизменить на/socialLogin, Вам нужно настроитьSocialAuthenticationFilterизfilterProcessesUrlСтоимость имущества:

новыйSocialProperties

package top.zhenganwen.security.core.properties;

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

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

ИсправлятьSecurityProperties

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

application.propertiesСинхронная модификация:

#demo.security.qq.appId=***
#demo.security.qq.appSecret=***
#demo.security.qq.providerId=qq
demo.security.social.qq.appId=***
demo.security.social.qq.appSecret=***
demo.security.social.qq.providerId=qq

QQLoginAutoConfigСинхронная модификация

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

расширятьSpringSocialConfigurer, через функцию ловушкиpostProcessдля достиженияSocialAuthenticationFilterнекоторые пользовательские конфигурации, такие какfilterProcessingUrl

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

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

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

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

существуетSocialConfigВведите расширенныйSpringSocialConfigurer

@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

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

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

Причина этогоpostProcess()является функцией ловушки, вSecurityConfigurerAdapterизconfigметод, вSocialAuthenticationFilterВызывается при добавлении в цепочку фильтровpostProcess, позволяя подклассам переопределять этот метод дляSocialAuthenticationFilterВыполните пользовательскую настройку:

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

Одновременно изменить страницу входа

<a href="/socialLogin/qq">qq登录</a>

В то же время необходимо отключить перехват URL-адреса совместного входа в класс конфигурации совместного входа.

public class QQSpringSocialConfigurer extends SpringSocialConfigurer {

    @Autowired
    private SecurityProperties securityProperties;

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

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

доступwww.zhenganwen.top/login.html, нажмитеqq登录Найдите прыжок следующим образом

image.png

Логика скачка авторизации проходит! Код этого этапа можно найти в:git ee.com/beginning/…

Периодическая сводка

Разрешение домена обратного вызова

Вы используете службу на локальном порту 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, поэтому нам просто нужно добавить его в фильтр. Поскольку мы ранее упаковали SMS-вход вSmsLoginConfigНапример, SpringSocial помогает нам инкапсулировать вход через социальные сети вSpringSocialConfigure, так что до тех пор, пока бизнес-система (то есть приложение, которое зависит от SpringSocial) должна вызывать толькоhttpSecurity.apply(springSocialConfigure)чтобы включить социальный вход.

и в дополнение кSoicalAuthenticationFilterдобавлен в цепочку фильтров,SpringSocialConfigureтакже поместится в контейнерUsersConnectionRepositoryа такжеSocialAuthenticationServiceLocatorотносится кSoicalAuthenticationFilterсередина,SoicalAuthenticationFilterСоциальная информация, которую можно получить в соответствии с процессом OAuth через первый (providerIdа такжеproviderUserId)ЗапрошеноuserId, через который последний может бытьproviderIdполучить соответствующийSocialAuthenticationServiceИ получить его от этогоConnectionFactoryЧтобы получить код авторизации, получитеaccessToken, получить социальную информацию пользователя и т. д.

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

Почему у вас есть SocialAuthenticationService и когда он появился?

SocialAuthenticationServiceправдаConnectionFactoryпакет, правильноSocialAuthenticationFilterСкрыть детали вызовов OAuth и OpenAPI

потому что мыSocialConfigдобавлено в@EnableSocial, поэтому при запуске системы будетSocialAutoConfigurerAdapterв классе реализацииcreateConnectionFactoryСоздавайте соответствующие разным социальным системамConnectionFactoryи упаковать какSocialAuthenticationService, то поставить всеSocialAuthenticationServiceкproviderIdдляkeyкэшируется вSocialAuthenticationLocatorсередина

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

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

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

}
class SecurityEnabledConnectionFactoryConfigurer implements ConnectionFactoryConfigurer {

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

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

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

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

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

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

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

}

так когдаSocialAuthenticationFilterперехвачен/{filterProcessingUrl}/{providerId}После этого он будет основан на URL-адресе в путиproviderIdприбытьSocialAuthenticationLocatorнайти соответствующийSocialAuthenticationServiceПолучатьauthRequest

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

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

Почему 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Хорошо, получите код авторизацииaccessToken(AccessGrant), вызовите OpenAPI, чтобы получить информацию о пользователе и адаптировать ее кConnection

Почему ответ после согласия на авторизацию выглядит следующим образом

image.png

Сканируем QR-код для согласия на авторизацию, браузер перенаправляет на/socialLogin/qqПосле этого что случилось

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

В первой части вышеуказанного пояса12Точка останова строки для трассировки, найти выполнение13бросать исключение при переходе к18Хорошо, информация об исключении выглядит следующим образом:

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

Описание зовет насOAuth2TemplateизexchangeForAccessПолучить код авторизацииaccessTokenСообщается об ошибке, причина ошибки в том, что результат ответа преобразованияAccessGrantне обработаноtext/htmlпреобразователь.

Сначала давайте посмотрим, что ответит:

image.png

найти, что результатом ответа является строка, начинающаяся с&разбивает три пары ключевых значений, аOAuth2TemplateПреобразователи, предоставляемые по умолчанию, следующие:

OAuth2Template

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

см. выше5~7линия из 3 преобразователей,FormHttpMessageConverter,FormMapHttpMessageConverter,MappingJackson2HttpMessageConverterсоответствующий анализContent-Typeдляapplication/x-www-form-urlencoded,multipart/form-data,application/jsonТело ответа , поэтому сообщается сообщение об ошибке

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

В это время нам необходимоOAuth2TemplateНа основе добавления обработкиtext/htmlпреобразователь:

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

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

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

использовать этотQQOAuth2Templateзаменить ранее введенныйOAuth2Template

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private QQConnectionAdapter qqConnectionAdapter;

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

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

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

}

Теперь мы можем получить пакетaccessTokenизAccessGrant, а затем продолжить отладку конечной точкиConnectionприобретение (см. ниже15Ряд)

OAuth2AuthenticationService

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

ОбнаружитьQQApiImplизgetUserInfoЕсть та же проблема, тип ответа на вызов API взаимосвязи QQtext/html, поэтому мы не можем напрямую конвертировать в POJO, но сначала получаем строку ответа, а затем передаем класс инструмента преобразования JSONObjectMapperдля преобразования:

QQApiImpl

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

Просканируйте код еще раз, чтобы войти в систему для отладки точки останова, и обнаружите, чтоConnectionтакже могут быть успешно получены и упакованы вSocialAuthenticationTokenвернуться, так чтоgetAuthTokenНаконец благополучно вернулся, пошелdoAuthentication

SocialAuthenticationFilter

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

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

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

позвонюProviderManagerизauthenticateправильноSocialAuthenticationTokenпроверить,ProviderManagerснова будет введена в эксплуатациюSocialAuthenticationProvider

SocialAuthenticationProviderпозвонит нашему инъецированномуJdbcUsersConnectionRepositoryприбытьUserConnectionПо таблицеConnectionизproviderIdа такжеproviderUserIdнайтиuserId

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

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

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

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

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

JdbcUsersConnectionRepository

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

Поскольку его невозможно найти (потому что в это время нашUserConnectionВ таблице вообще нет данных),toUserIdВернусьnull, затем бросаетBadCredentialsException("Unknown access token"), исключение будетSocialAuthenticationFilterзахват и по егоsignupUrlАтрибут для перенаправления (SpringSocial считает, что пользователь не зарегистрирован в системе или зарегистрирован, но не связывает локального пользователя с логином QQ, поэтому переходите на страницу регистрации)

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

а такжеSocialAuthenticationFilterизsignupUrlПо умолчанию/signup

public class SocialAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
	private String signupUrl = "/signup";
}                    

перенаправить на/signupПри перехвате SpringSecurity и перенаправлении наloginPage(), наконец, прибылBrowserSecurityController

SecurityBrowserConfig

.formLogin()
		.loginPage(SecurityConstants.FORWARD_TO_LOGIN_PAGE_URL)

SecurityConstants

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

BrowserSecurityController

@RestController
public class BrowserSecurityController {

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

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

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    @Autowired
    SecurityProperties securityProperties;

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

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

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

В итоге получил следующий ответ:

image.png

что сделал @EnableSocial

он загружает класс конфигурацииSocialConfiguration, который читает контейнерSocialConfigureнапример, как мы написали расширениеSocialAutoConfigureAdapterизQQLoginAutoConfigи расширилSocialConfigureAdapterизSocialConfig, мы достигнемConnectionFactoryа такжеUsersConnectionRepositoryа такжеSpringSecurityСтрока процесса сертификации вместе

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

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

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

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

Страница регистрации и привязка социальных аккаунтов

Во-первых, можно настроить URL-адрес страницы регистрации, и по умолчанию установлено значение/sign-up.html, и интерфейс службы, который обрабатывает регистрацию/user/register

@Data
public class SocialProperties {

  private QQSecurityPropertie qq = new QQSecurityPropertie();

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

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

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

Затем отпустите этот путь в классе конфигурации браузера:

@Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

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

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

Наконец напишите страницу регистрации:

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

  </body>
</html>

ProviderSignInUtils

Регистрационная служба: Хотя потому что вUserConnectionВ таблице нет записи, связанной с локальным пользователем и переход на страницу регистрации, но полученныйConnectionили сохранить вSession, если вы хотите автоматически связать учетную запись QQ, когда пользователь щелкает, чтобы зарегистрировать локальную учетную запись, или если у пользователя уже есть локальная учетная запись и вручную связать учетную запись QQ, вы можете использоватьProviderSignInUtilsЭтот класс инструментов, вам нужно только указать локальную учетную запись, которая должна быть связанаuserId, он автоматически вытащитSessionсохранено вConnection, и воляuserId,Connection.getProviderId,Connection.getProviderUserIdВставьте его в базу данных как запись, чтобы пользователь не перескакивал на страницу регистрации локальной учетной записи при следующем входе в QQ.

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

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

  @Autowired
  private UserService userService;

  @Autowired
  private ProviderSignInUtils providerSignInUtils;

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

关联QQ账号.gif

Поддержка привязки/отвязки сцены

Иногда модулю управления учетными записями нашей системы необходимо разрешить пользователям связывать или отвязывать некоторые учетные записи социальных сетей, и SpringSocial также поддерживает этот сценарий (см.ConnectController). Вам нужно только настроить связанные с представлением компоненты (ExtensibleAbstractView"Функция "Привязать/Решение" может быть реализована.

Управление сеансом

Автономное управление сессиями

Фактически, настроенный нами процесс входа в систему будет выполняться только один раз при входе в систему. После успешного входа в систему будет сгенерирована информация аутентификации инкапсуляции.AuthenticationХранится в локальном безопасном потоке, и последующие операции, такие как доступ пользователя к защищенным URL-адресам, не будут включать эти компоненты в процесс входа в систему.

Давайте еще раз вспомним цепочку фильтров Spring Security, первая из нихSecurityContextPersistenceFilter, который используется, чтобы попытаться прочитать информацию об аутентификации, сгенерированную после успешного входа в сеанс из сеанса при получении запроса, и поместить его в текущий безопасный поток, а затем извлечь его и поместить в сеанс при ответе на запрос, в то время как один расположен в конце цепочки фильтровFilterSecurityInterceptorбудет в гостяхControllerИнформация об аутентификации в потокобезопасной версии проверяется перед службой, поэтому управление сеансом будет напрямую влиять на то, сможет ли пользователь продолжать доступ к защищенному URL-адресу в этот момент.

В SpringBoot мы можем передать элемент конфигурацииserver.session.timeout(в секундах), чтобы установить эффективную продолжительность сеанса, чтобы, если пользователь все еще обращался к защищенному 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 минуту Сбой сеанса привел к необходимости повторного входа в систему.

application.properties

server.session.timeout=10 	#设置Session 10秒后过期

Но мы обычно настроены на несколько часов.

В отличие от доступа к защищенному URL-адресу без входа в систему, должно быть другое приглашение, когда сеанс недействителен и доступ к защищенному URL-адресу невозможен (например: срок действия сеанса, в который вы вошли, истек из-за бездействия в течение длительного времени, пожалуйста, войдите в систему еще раз; вам не следует предлагать Если вы не вошли в систему, сначала войдите), затем мы можем настроитьhttp.sessionManage().invalidSessionUrl()чтобы указать, что пользователь вошел в систему болееserver.session.timeoutПо истечении заданного периода времени пользователь будет перенаправлен на защищенный URL-адрес при посещении защищенного URL-адреса.Вы можете настроить страницу илиControllerчтобы предложить пользователю и направить пользователя на страницу входа

SecurityBrowserConfig

protected void configure(HttpSecurity http) throws Exception {

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

существует.sessionManagement()В конфигурации:

пройти через.maximumSessionsОн может контролировать количество сеансов, в которые пользователь может войти одновременно.Если установлено значение 1, последний вошедший в систему человек вытеснит предыдущего вошедшего в систему человека. ,пройти черезexpiredSessionStrategyВы можете установить метод обратного вызова для этого события (вызывается, когда предыдущий человек выжимается, а затем обращается к защищенному URL-адресу), который можно получить через параметр обратного вызова.requestа такжеresponse

пройти через.maxSessionsPreventsLogin(true)Можно установить, что если пользователь уже вошел в систему, он не может войти снова в другие сеансы.timeoutЕсли настройки неверны или вторичный логин заблокирован, вы можете пройти.invalidSessionStrategy()Настройка стратегии обработки

Управление сеансом кластера

Для достижения высокой доступности и высокого параллелизма приложения корпоративного уровня обычно развертывают службы в кластерах и перенаправляют запросы к определенным службам через шлюз или прокси в соответствии с алгоритмом опроса.В настоящее время, если каждая служба управляет своим сеансом отдельно, затем пользователю неоднократно предлагается войти в систему. Мы можем извлечь управление сеансом и сохранить его в отдельной системе,spring-sessionПроект может сделать всю работу за нас, нам просто нужно указать ему, какую систему хранения использовать для хранения сеанса.

Обычно мы используемRedisсохранить сеанс без использования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середина.

После использования кластерного режима ранее настроенныйtimeoutа такжеhttp.sessionManagement()все еще в силе.

Примечание. После размещения сеанса в системе хранения убедитесь, что bean-компоненты, записанные в сеанс, сериализуемы, т. е. реализованыSerializableинтерфейс, если свойства в bean-компоненте не могут быть сериализованы, например.ImageCodeсерединаBufferedImage imageЕсли вам не нужно хранить его на сеансе, вы можете установить это свойство в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 разрабатывает инфраструктуру аутентификации приложений

Все, что мы говорили ранее, основано наB/SАрхитектурный, то есть доступ пользователей к нашим сервисам напрямую через браузер, основан наSession/Cookieиз. Но сейчас архитектура передней и задней части сепарации более популярна, пользователь может быть непосредственно доступа к приложению или веб-серверу (например,nodejs), а APP и WebServer затем передаютajaxВызов серверных служб в этом сценарииSession/CookieШаблоны имеют много недостатков

  • Разработка обременительна и требует частого таргетингаSession/CookieДля операций чтения и записи запрос, отправленный из браузера, будет сохранен вCookieсерединаJSESSIONID, серверная часть может найти соответствующийSession, в ответ наJSESSIONIDзаписыватьCookie. Если браузер отключенCookieВам нужно прикрепить его к каждому URLJSESSIONIDпараметр
  • Плохая безопасность и качество обслуживания клиентов, конфиденциальные данные хранятся на стороне клиента.Cookieне очень безопасно,SessionНеправильные настройки, такие как управление старением и распределенное управление, приведут к частым повторным входам пользователей в систему, что приведет к ухудшению взаимодействия с пользователем.
  • Некоторые интерфейсные технологии вообще не поддерживаютсяCookie, такие как приложение, апплет

В качестве таких,Spring Security OAuthобеспечивает на основеtokenМеханизм аутентификации заключается не в том, чтобы каждый раз считывать аутентификационную информацию, хранящуюся в сеансе, а в том, чтобы выдаватьtoken, просто принесиtokenпараметры. по сравнению с основанным наSessionПуть,tokenболее гибким и безопасным, неSessionТакой жеSESSIONIDРаспределение и вложения параметров затвердевают.tokenВ какой форме и какая информация включена и доступна черезtokenМеханизм обновления прозрачно продлевает время авторизации (не воспринимаемое пользователем), чтобы избежать повторных входов в систему и т. д., и все это может быть настроено нами.

упомянулOAuth, может быть легко подумать о сторонней функции входа в систему, разработанной ранее, на самом делеSpring SocialупакованOAuthПроцесс, через который хочет пройти клиент, иSpring Security OAuthупакованOAuthСвязанные функции сервера аутентификации.

Что касается нашей самостоятельно разработанной системы, то серверная часть является сервером аутентификации и сервером ресурсов, тогда как внешнее приложение и веб-сервер эквивалентныOAuthклиент.

Сервер аутентификации должен предоставить модель лицензирования и 4tokenГенерация и хранение сервера ресурсов является защитойRESTСервис, с помощью фильтра проверяйте наличие запроса в запросе перед вызовом сервисаtoken. И все, что нам нужно сделать, это интегрировать нашу пользовательскую логику аутентификации (имя пользователя и пароль для входа, вход с кодом подтверждения SMS, сторонний вход) в сервер аутентификации и подключиться для создания и храненияtoken.

image.png

Из этой главы мы будем использоватьSpring Security OAuthразвиватьsecurity-appПроект, основанный на чистомOAuthметод аутентификации, не полагаясь наSession/Cookie

Готов к работе

Сначала мыsecurity-demoбудет представлен вsecurity-browserЗависимости закомментированы и введеныsecurity-appЗабудьте об этом раньшеSession/CookieСертификационный код разработан, начиная с самого началаOAuthразработать авторизацию аутентификации.

из-заsecurity-coreФильтр проверки капчи вVerifyCodeValidateFilterОбработчики успеха/неудачи аутентификации должны быть внедрены, поэтому мыsecurity-demoскопировать вsecurity-app, и отправить результат обработки в формате JSON (security-browserРезультатом обработки может быть страница, ноsecurity-appможет отвечать только в формате JSON) и будетSimpleResponseResultвъезжатьsecurity-coreсередина.

package top.zhenganwen.securitydemo.app.handler;

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

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

    @Autowired
    private ObjectMapper objectMapper;

//    @Autowired
//    private SecurityProperties securityProperties;

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

package top.zhenganwen.securitydemo.app.handler;

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

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

    @Autowired
    private ObjectMapper objectMapper;

//    @Autowired
//    private SecurityProperties securityProperties;

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

Перезапустите службу, установите флажок, чтобы удалитьsecurity-browserпредставлятьsecurity-appМожет ли проект нормально работать после этого.

Включить сервер аутентификации

Просто используйте аннотацию@EnableAuthorizationServerВы можете сделать текущую службу сервером аутентификации,starter-oauth2Это уже помогло нам инкапсулировать 4 режима авторизации иtokenуправление.

package top.zhenganwen.securitydemo.app.config;

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

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

Теперь мы можем протестировать режим авторизации в 4授权码узор и密码модель

Во-первых, на стороне сервера аутентификации должен быть пользователь, для удобства писать здесь не будем.DAOа такжеUserDetailsServiceТеперь мы можем добавить пользователя, настроив:

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

затем настройтеclientId/clientSecret, что эквивалентно вызову других приложенийsecurity-demoПеред выполнением стороннего входа в систему необходимоsecurity-demoприменяется для регистрации на платформе интернет-разработкиappId/appSecret. Например, есть приложение вsecurity-demoПроверка регистрации на платформе разработки пройдена,security-demoназначитappId:test-clientа такжеappSecret:123. Теперь нашsecurity-demoТакже становится сервером аутентификации, любой вызовsecurity-demoПриобретение APItokenДругие приложения можно рассматривать как сторонние приложения или клиенты.

security.oauth2.client.client-id=test-client
security.oauth2.client.client-secret=123

Далее мы можем сравнитьOAuth2на официальном сайтеСправочная документацияпроверять@EnableAuthorizationServerПредусмотрено 4 режима авторизации и получитеtoken

Тестовый режим кода авторизации

видетькритерии запроса

Режим кода авторизации состоит из двух шагов:

  1. Получить код авторизации

    Просмотрите журнал запуска загрузки и обнаружите, что фреймворк добавляет для нас несколько интерфейсов, в том числе/oauth/authorize, это интерфейс для получения кода авторизации. мы сравниваемOAuth2получил код авторизации откритерии запросапопытаться получить код авторизации

    image.png

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

    вresponse_typeисправлено какcodeУказывает на получение кода авторизации,client_idдля клиентаappId,redirect_uriПолучение кода авторизации для клиента для дальнейшего полученияtokenURL-адрес обратного вызова (здесь мы будем писать случайный, а URL-адрес, на который происходит успешное перенаправление авторизации, будет сопровождаться кодом авторизации),scopeУказывает объем полномочий, который необходимо получить для данной авторизации (значение значения ключа и значение ключа должно определяться сервером аутентификации, здесь мы пока напишем случайное). После посещения URL-адреса всплывающее окноbasicОкно входа в систему аутентификации, мы вводим имя пользователяtestпарольtestПосле входа в систему перейдите на страницу авторизации и спросите нас, следует ли предоставлятьallРазрешения (в реальной разработке мы можем разделить разрешения наcreate,delete,update,read, которые также можно разделить наuser,admin,guestЖдать):

    image.png

    Нажимаем, чтобы согласитьсяApproveЗатем нажмите АвторизоватьAuthorize, затем переходит к URL-адресу обратного вызова с прикрепленным кодом авторизации

    image.png

    Запишите код авторизацииyO4Y6qдля последующихtokenПолучать

  2. Получатьtoken

    image.png

    мы можем пройтиChromeплагинRestlet Clientчтобы выполнить этот запрос

    1. нажмитеAdd authorizationвойтиclient-idа такжеclient-secret, инструмент автоматически зашифрует и прикрепит его к заголовку запроса для нас.Authorizatinсередина
    2. Заполните параметры запроса

    image.png

    При использованииPostmanноAuthorizationНастройки следующие:

    image.png

    нажмитеSendОтправьте запрос, и ответ будет следующим:

    image.png

режим пароля

Режим пароля требует только одного шага, без кода авторизации, вы можете получить его напрямуюtoken

image.png

Использование режима пароля эквивалентно тому, что пользователь сообщает клиентуtest-clientпользователь вsecurity-demoЗарегистрируйте имя пользователя и пароль, и клиент напрямую использует это для полученияtoken, сервер аутентификации не знает, что клиент запрашивает после авторизации и согласия пользователяtokenИли тайно возьмите известное имя пользователя и пароль, чтобы получитьtoken, но если клиентское приложение является внутренним приложением компании, вам не нужно беспокоиться об этом.

Здесь есть еще одна деталь: потому что режим кода авторизации для пользователя был выдан доtoken, так вот получается через режим пароляtokenвсе еще генерируется раньшеtoken, и срок годностиexpire_inпостепенное сокращение

В настоящее время не указаноtokenметод хранения, поэтому он хранится в памяти по умолчанию.Если вы перезапустите службу, вам нужно применить сноваtoken

Включить сервер ресурсов

Аналогичным образом используйте@EnableResourceServerАннотация может сделать службу сервером ресурсов (проверьте перед вызовом службыtoken)

package top.zhenganwen.securitydemo.app.config;

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

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

После перезапуска службы получите прямой доступ к пользовательскому интерфейсу запроса./userотклик401Указывает, что сервер ресурсов работает (безtokenдоступ к защищенным сервисам заблокирован), это тоже неsecurityПо умолчаниюbasicАутентификация работает, потому что если онаbasicЕсли его заблокировать, появится окно входа в систему, которого там нет.

image.png

Затем мы регенерируем его один раз, используя шаблон пароля.token:7f6c95fd-558f-4eae-93fe-1841bd06ea5c, и прикрепляется при доступе к интерфейсуtoken(добавить заголовок запросаAuthorizationзначениеtoken_type access_token)

image.png

использоватьPostmanБолее удобный:

image.png

Анализ исходного кода ядра Spring Security Oauth

Основные компоненты фреймворка следующие: зеленое поле указывает на конкретный класс, а синее поле указывает на интерфейс/абстрактный классы в круглых скобках — это классы, которые фактически вызываются во время выполнения. Ниже мы密码模式Например, чтобы проанализировать исходный код, вы также можете разбить точку для пошаговой проверки.

image.png

Служба выдачи токенов — TokenEndpoint

TokenEndpointможно рассматривать какController, он примет наше приложениеtokenпросьба, см.postAccessTokenметод:

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

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

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

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

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

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

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

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

    return getResponse(token);

}

Первая запись состоит из двух частей:principalа такжеparameters, соответствующий двум частям параметров запроса в режиме пароля: заголовку запросаAuthorizationи тело запроса (grant_type,username,password,scope).

String clientId = getClientId(principal);

principalПриход на самом делеUsernamePasswordToken, соответствующая логика находится вBasicAuthenticationFilterизdoFilterInternalВ методе:

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

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

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

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

        String username = tokens[0];

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

        }

    }
    catch (AuthenticationException failed) {

    }

    chain.doFilter(request, response);
}

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

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

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

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

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

BasicAuthenticationFilterперехватит/oauth/tokenи попробуйте разобрать заголовок запросаAuthorization, получить соответствующийBasic xxxСтрока с удаленными первыми 6 символамиBasic,Получатьxxx, что на самом деле мы и передаемclientIdа такжеclientSecretИспользуйте двоеточие, чтобы объединиться, а затем используйтеbase64полученный алгоритмом шифрования, поэтому вextractAndDecodeHeaderметод будетxxxпровестиbase64расшифровывается, чтобы отделиться двоеточиямиclientIdа такжеclientSecretсоставленный зашифрованный текст (заимствованный из предыдущегоclientId=test-clientа такжеclientSecret=123Например, полученный здесь зашифрованный текст имеет видtest-client:123), и наконецclient-idтак какusername,clientSecretтак какpasswordпостроилUsernamePasswordTokenи вернуться, так что вpostAccessTokenсерединаprincipalможет получить заголовок запросаclientId.

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

Тогда позвониClientDetailsServiceсогласно сclientIdЗапрос сведений о зарегистрированном клиенте, т.е.ClientDetails, который является внешним приложением, регистрирующимsecurity-demoИнформация, заполняемая и просматриваемая при открытии платформы, содержит несколько пунктов, у нас есть только здесьclientIdа такжеclientSecretдва. (authenticatedClientуказать этоclientМы проверили его, чтобы разрешить доступ к нашей открытой платформе.client)

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

Затем по параметрам тела запросаparametersи данные клиентаclientDetailsпостроилTokenRequest,этоtokenRequestУказывает, что текущее приобретениеtokenК какому клиенту относится запрос (clientDetails), чтобы узнать, какой пользователь (parameters.username) права доступа и режим авторизации (parameters.grant_type), какие разрешения получить (parameters.scope).

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

Затем на входящийclientIdа такжеauthenticatedClientизclientIdПроверьте это. Может быть, вы спросите,authenticatedClientне по приходуclientIdВы это выяснили, и перекалибровать было бы лишним. На самом деле нет, хотя метод запроса называетсяloadClientByClientId, но может быть понято только как основанное наclientУникально идентифицирует проверенный запросclient, возможно этот уникальный идентификатор есть в нашей базеclientнерелевантный первичный ключ таблицыid, или возможноclientIdзначение поля. То есть мы должны понимать имя метода с точки зрения макроса.loadClientByClientId. Так вотclientIdНичего страшного в проверке нет.

if (authenticatedClient != null)

Затем определяет, еслиauthenticatedClientЕсли не пусто, проверьте область разрешений запроса.scope:

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

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

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

Вы можете представить такой сценарий: внешнее приложение запрашивает доступ к нашей открытой платформе, чтобы прочитать информацию о пользователе нашей платформы, затем соответствующийclientScopesдля["read"], клиент просит получить после прохождения аудитаtoken(tokenМожет указывать: 1. Кто вы есть; 2. Что вы можете сделать; 3. Запрос параметров при доступеscopeЭто может быть только["read"], вместо["read","write"]Ждать. Вот запрос на проверкуtokenвходящийscopeВсе ли включены в зарегистрированный клиентscopesсередина.

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

Затем проверьтеgrant_typeПараметр не может быть пустым, что такжеoauthоговорено в соглашении.

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

Затем определить входящиеgrant_typeЭтоimplicit, то есть использует ли клиент简易模式Получатьtoken,потому что简易模式Получен непосредственно после того, как пользователь соглашается разрешитьtoken, поэтому get больше не следует вызыватьtokenинтерфейс.

if (isAuthCodeRequest(parameters))

Затем оцените, использует ли клиент режим кода авторизации в соответствии с параметрами запроса.tokenRequestсерединаscopeУстановите пустое значение, поскольку разрешения клиента не должны передаваться сами по себе.scopeрешать, но он утверждается нами при регистрацииscopesЧтобы решить, что это свойство будет прочитано из подробностей клиента позжеscopeпокрытие.

if (isRefreshTokenRequest(parameters))

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

Определите, является ли это обновлениемtokenзапрос. действительно может запроситьtokenизgrant_typeКромеoauth4 режима авторизации в стандартеauthorization_code,implicit,password,client_credential, есть еще одинrefresh_token, чтобы улучшить взаимодействие с пользователем (традиционные методы входа требуют повторного входа через определенный период времени),tokenМеханизм обновления может быть реализован без восприятия пользователемtokenпродление времени. если обновитьtokenзапрос, как написано в комментариях,refresh_tokenпуть тоже имеет свой дефолтscopes, поэтому тот, который включен в запрос, не должен использоваться.

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

Это самый важный шаг, впереди — инкапсуляция и проверка параметров запроса. Этот шаг вызоветTokenGranterГенерация токеновtoken,НазадgetResponse(token)будет генерироватьtokenОтветил прямо. По типу входящей авторизацииgrant_typeи соответствующие параметры, которые необходимо передать, будут настроены по-разномуTokenGranterкласс реализацииtokenпостроение этой логики вCompositeTokenGranterсередина:

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

Он вызовет соответствующий режим авторизации через 4 по очереди.TokenGranterкласса реализацииgrantтолько метод и параметры запросаgrant_typeсоответствующийTokenGranterбудет вызываться, эта логика находится вAbstractTokenGranterсередина:

public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

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

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

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

    return getAccessToken(client, tokenRequest);

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

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

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

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

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

Токен Грантер - 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удаляется, еслиtokenизrefresh_tokenЕсли он все еще там, также удалите его (запрос на обновление определенногоtokenнужен соответствующийrefresh_token,еслиtokenЕсли он терпит неудачу, его сопровождающийrefresh_tokenтоже должен быть недоступен)
  2. СтарыйtokenНе истек, сохраните его сноваtoken(Поскольку лицевая и обратная стороны могут быть сгенерированы с помощью разных режимов авторизацииtoken, соответствующая логика сохранения также будет другой), и напрямую вернутьtoken, метод заканчивается.

если не изtokenStoreнашел в старомtoken, то новыйtoken,СохранитьtokenStoreвнутрь и обратно.

резюме

image.png

Интегрируйте имя пользователя и пароль, чтобы получить токен

Хотя фреймворк помог нам инкапсулировать 4 режима авторизации, требуемых сервером аутентификации, он, как правило, является внешним (внешние приложения не могут считывать информацию о пользователе нашей системы) и используется для создания открытой платформы. Для внутренних приложений нам по-прежнему необходимо предоставить имя пользователя и пароль для входа в систему, код подтверждения номера мобильного телефона и т. д., чтобы получитьtoken. Во-первых, фреймворк проходит весь путь доTokenGranterМы не можем продолжать использовать эту часть компонента, потому что она былаOAuthПроцесс затвердевает. Все, что мы можем использовать, это сервис генерации токенов.AuthorizationServerTokenServices, Но для этого требуетсяOAuth2Authentication, пока мы строимOAuth2Authenticationнужно сноваtokenRequestа такжеauthentication.

Основываясь на исходной логике входа, мы можем изменить обработчик успеха входа, в котором мы можем получить успешную аутентификацию.authenticationи из заголовка запросаAuthorizationполучено вclientIdвведенный вызовClientDetailsServiceвыяснитьclientDetailsи построитьtokenRequest, чтобы можно было вызвать службу создания маркера для создания маркера и ответа.

image.png

Вызвать службу токенов в обработчике успешного входа в систему

AppAuthenticationSuccessHandler

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

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

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

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

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

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private ClientDetailsService clientDetailsService;

    @Autowired
    private AuthorizationServerTokenServices authorizationServerTokenServices;

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

        // Authentication
        Authentication userAuthentication = authentication;

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

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

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

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

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

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

    private String[] extractAndDecodeHeader(String header){

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

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

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

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

Унаследуйте ResourceServerConfigurerAdapter для реализации конфигурации безопасности.

мы будемBrowserSecurityConfigсреда дляsecurityскопируйте конфигурацию вResourceServerConfig, чтобы включить только форму входа с паролем:

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

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

import javax.sql.DataSource;

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService customUserDetailsService;

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

    @Autowired
    SmsLoginConfig smsLoginConfig;

    @Autowired
    private VerifyCodeValidatorConfig verifyCodeValidatorConfig;

    @Autowired
    private SpringSocialConfigurer qqSpringSocialConfigurer;

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

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

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

Таким образом, клиент внутреннего приложения может получить имя пользователя и пароль через имя пользователя и пароль пользователя.tokenсейчас:

  1. Заголовок запроса по-прежнему должен сопровождаться информацией о клиенте.

    image.png
  2. Параметры запроса передают логин и пароль для входа в требуемые параметры

    image.png
  3. Получить после успешного входаtoken

    image.png
  4. пройти черезtokenслужба доступа

    из-заPostmanЗапись и чтение на стороне сервера по-прежнему поддерживаютсяCookie

    image.png

    чтобы избежатьSession/CookieВлияние метода входа в систему, каждый раз, когда нам нужно очиститьcookieОтправьте запрос еще раз.

    image.png

    image.png

    Первый не включенtoken, обнаружил, что запрос был перехвачен:

    image.png

    а затем прикрепитьtokenЗапрос доступа:

    image.png

На этом этапе получается логин и пароль для входа.tokenИнтеграция прошла успешно!

Процесс интеграции кода подтверждения и входа по SMS аналогичен и здесь повторяться не будет. Стоит отметить, что на основеtokenспособ отказаться отSession/Cookieоперации, вы можете поместить информацию, которая будет сохранена на сервере, например,Redisв слое сохранения.

Интегрируйте социальный логин, чтобы получить токен

В этом разделе мы реализуем способ, которым внутреннее приложение использует социальный вход для получения с внутреннего сервера аутентификации.token.

легкий режим

Анализ процесса

Если внутреннее приложение принимает简易模式, после того, как пользователь соглашается авторизоваться, пользователь может напрямую получить информацию, предоставленную внешним поставщиком услуг.token, то у нас нет возможности получить этоtokenЧтобы получить доступ к серверу внутренних ресурсов, вам нужно взять этотtokenПерейти на внутренний сервер аутентификации в обмен на внутренний пропуск нашей системыtoken.

В обмен на идею, если вход пользователя в социальную сеть прошел успешно, то внутреннее приложение может получитьproviderUserId(упоминается внешними поставщиками услуг какopenId),а такжеUserConnectionВ таблице должна быть запись (userId,providerId,providerUserId), внутреннее приложение просто заменяетproviderIdа такжеproviderUserIdПередайте его внутреннему серверу аутентификации, и внутренний сервер аутентификации проверитUserConnectionтаблица проверяется иuserIdПостроитьAuthenticationгенерироватьaccessToken.

image.png

Для этого нам нужно написать набор внутренних серверов аутентификацииproviderId+openIdПроцесс сертификации:

image.png

вUserConnectionRepository,CustomUserDetailsService,AppAuthenticationSuccessHandlerВсе готовые и могут быть использованы непосредственно.

SecurityPropertiesдобавить основу обработкиopenIdбратьtokenURL-адрес:

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;
    }
}

Класс конфигурации процесса аутентификации OpenIdOpenIdAuthenticationConfig

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private UsersConnectionRepository usersConnectionRepository;

    @Autowired
    private UserDetailsService customUserDetailsService;

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

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

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

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

    }
}

applyприменимый кSecurityв основном классе конфигурации

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

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

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

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

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

тестовое задание

текущее использованиеPostmanМоделирование доступа к внутреннему приложению/auth/openIdпроситьtoken:

image.png

и посетить/userтестовое заданиеtokenДействительность, посетите успех! Интегрированный вход через социальные сети выполнен успешно!

Режим кода авторизации

Если внутреннее приложение принимает режим кода авторизации, когда внешний поставщик услуг перезванивает с кодом авторизации, внутреннее приложение может напрямую перенаправить запрос обратного вызова на наш сервер аутентификации, поскольку мы ранее написали модуль входа в социальную сеть, поэтому бесшовное соединение может быть достигнут.

Или возьмем в качестве примера логин QQ, который мы реализовали ранее:image.png

Внутри, когда пользователь соглашается на авторизацию и сервер аутентификации QQ перенаправляется на домен обратного вызова внутреннего приложения, запрос обратного вызова может быть перенаправлен на сервер аутентификации как есть, поскольку мы разработали его ранее./socialLoginИнтерфейс обрабатывает входы через социальные сети.

Для теста здесь мы не можем разработать приложение, мы можем использовать оригинальный разработанныйsecurity-browserпроект, а затем сделать точку останова, где получен код авторизации, и остановить службу после получения кода авторизации (чтобы не запрашивать код авторизации позжеtokenпривести к тому, что код авторизации станет недействительным). затем вPostmanЗапрос кода авторизации Zhongnatoken(Смоделированное приложение перенаправляет домен обратного вызова на/socialLogin/qq)

первый вsecurity-demoсредняя нотаsecurity-appпри включенииsecurity-browser

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

БудуCustomUserDetailsServiceперейти кsecurity-coreв, потому чтоbrowserа такжеappОба полезны:

package top.zhenganwen.security.core.service;

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

import java.util.Objects;

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

    @Autowired
    BCryptPasswordEncoder passwordEncoder;

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

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

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

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

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

Затем установите порт80Запустите сервис и получите код авторизации следующим образомtokenустановить точку останова перед (OAuth2AuthenticationService):

image.png

доступwww.zhenganwen.top/login.htmlВыполните авторизованный вход QQ (одновременно откройте консоль браузера), согласитесь на авторизацию для перехода, остановите службу после остановки в точке останова, найдите URL-адрес обратного вызова в консоли браузера и скопируйте его:

image.png

опять такиsecurity-demoизpomпереключиться наapp

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

существуетSecurityвключено в основном файле конфигурацииQQАвторизоваться:

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler appAuthenticationFailureHandler;

    @Autowired
    private OpenIdAuthenticationConfig openIdAuthenticationConfig;

    @Autowired
    private QQSpringSocialConfigurer qqSpringSocialConfigurer;

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

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

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

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

Тогда мы можем использоватьPostmanСмоделированное приложение перенаправит обратный вызов полученного кода авторизации на сервер аутентификации для полученияtokenсейчас:

image.png

Здесь сервер аутентификации получает код авторизацииtokenвернуть информацию об исключенииcode is reused error(Код авторизации используется повторно), само собой разумеется, что мы попали в точку останова и остановили службу вовремя в прошлый раз, но код авторизации не использовался для запросаtokenПравильно, ошибку здесь еще предстоит исследовать.

режим процессора

На самом деле, даже еслиtokenполучить успех, также не будет реагировать на то, что мы хотимaccessToken, т.к. ранее настроенныйSocialAuthenticationFilterДля него нет обработчика успешной аутентификации, поэтомуAppAuthenticationSuccessHandlerУстановите его в нем, чтобы после успешного входа в социальную сеть он генерировал и возвращалtoken.

Далее мы воспользуемся простым, но практичным методом рефакторинга процессора, чтобыsecurity-appЧжунвэйsecurity-coreизSocialAuthenticationFilterСделайте улучшение:

package top.zhenganwen.security.core.social;

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

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

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

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

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

    @Autowired
    private SecurityProperties securityProperties;

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

}
package top.zhenganwen.securitydemo.app.security.social;

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

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

    @Autowired
    private AuthenticationSuccessHandler appAuthenticationSuccessHandler;

    @Override
    public void process(SocialAuthenticationFilter filter) {
        filter.setAuthenticationSuccessHandler(appAuthenticationSuccessHandler);
    }
}

Интегрировать функцию привязки социальных аккаунтов

Временное хранение сторонней пользовательской информации

Раньше, когда пользователь впервые использовал вход через социальную сеть,UserConnectionНет соответствующей связанной записи в (userId->providerId-providerUserId), логика в то время заключалась в том, чтобы поместить запрошенную информацию о сторонних пользователях вSession, а затем перейдите на страницу управления социальной учетной записью, чтобы помочь пользователю установить связь с социальной учетной записью.ProviderSignInUtilsинструменты изSessionОн передается, когда сторонняя информация о пользователе извлекается и пользователь подтверждает ассоциацию.userIdсоставить ассоциацию (вставить вUserConnection)середина. ноSecurityкоторый предоставилProviderSignInUtilsосновывается наSession, на основеtokenМеханизм аутентификации не работает.

В этот момент мы можемOAuthСторонняя пользовательская информация, полученная после завершения процесса в виде пользовательского оборудования.deviceIdтак какkeyкешировать вRedis, когда пользователь подтверждает ассоциацию,Redisвыведены и соединены сuserIdВставить как записьUserConnectionсередина. По сути, это процесс смены способа хранения (на памятиSessionизменить на кешredis).

вести перепискуProviderSignInUtilsМы инкапсулируемRedisProviderSignInUtilsПросто замените его.

Помогите пользователям связать учетные записи социальных сетей

Следующие интерфейсы могут быть реализованы во всехbeanВызывается до завершения инициализацииpostProcessBeforeInitialization,beanВызывается после инициализацииpostProcessAfterInitialization, если вы не хотите улучшать его, вы можете вернуть входящийbean, если вы хотите целенаправленное улучшение, вы можетеbeanNameфильтровать.

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

Мы можем реализовать класс реализации этого интерфейсаSpringSocialConfigurerPostProcessorсуществуетQQSpringSocialConfigurer beanсброс после инициализацииconfigure.signupUrl,когдаUserConnectionнет перепискиConnectionПерейти, когда запись связанаsignupUrlсоответствующую службу.

В этой службе должен быть возвращен JSON, указывающий, что внешний интерфейс должен связать социальную учетную запись (и будет идти доOAuthПолученная информация о сторонних пользователях предоставленаProviderSignInUtilsотSessionвыньте и используйтеRedisProviderSignInUtilsподготовка кRedis), вместо перехода на страницу ассоциации социальной учетной записи, как было установлено ранее. Формат возвращаемой информации следующий:

image.png