Spring Boot整合Security系列步骤及问题排查(十一)—— 集成QQ登录

工具类准备:
Repository;
Connection;
ConnectionFactory(ServiceProvider、ApiAdapter);
ServiceProvider(OAuth2Operations、Api);

Api:

/**
 * QQ接口
 *
 * @author zhaohaibin
 */
public interface QQ {

    /**
     * 获取用户信息
     *
     * @return
     */
    QQUserInfo getUserInfo();

}
/**
 * QQ API 实现
 *
 * @author zhaohaibin
 */
@Slf4j
public class QQImpl extends AbstractOAuth2ApiBinding implements QQ {

    /**
     * 获取openId
     */
    private static final String URL_GET_OPENID = "https://graph.qq.com/oauth2.0/me?access_token=%s";

    /**
     * 获取用户信息
     * <p>
     * access_token=YOUR_ACCESS_TOKEN&由父类传递
     */
    private static final String URL_GET_USERINFO = "https://graph.qq.com/user/get_user_info?oauth_consumer_key=%s&openid=%s";

    private String appId;
    private String openId;

    private ObjectMapper objectMapper = new ObjectMapper();

    public QQImpl(String accessToken, String appId) {
        // 改变默认策略满足接口传参类型要求
        super(accessToken, TokenStrategy.ACCESS_TOKEN_PARAMETER);

        this.appId = appId;

        String url = String.format(URL_GET_OPENID, accessToken);
        String result = getRestTemplate().getForObject(url, String.class);

        log.info(result);

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

    }

    @Override
    public QQUserInfo getUserInfo() {

        String url = String.format(URL_GET_USERINFO, appId, openId);

        String result = getRestTemplate().getForObject(url, String.class);

        log.info(result);

        QQUserInfo userInfo = null;

        try {

            userInfo = objectMapper.readValue(result, QQUserInfo.class);
            userInfo.setOpenId(openId);
            return objectMapper.readValue(result, QQUserInfo.class);
        } catch (IOException e) {
            throw new RuntimeException("获取用户信息失败", e);
        }

    }

}
/**
 * QQ 用户信息
 *
 * @author zhaohaibin
 */
@Data
public class QQUserInfo {

    /**
     * 返回码
     */
    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;

}

ServiceProvider:

/**
 * ServiceProvider
 *
 * @author zhaohaibin
 */
public class QQServiceProvider extends AbstractOAuth2ServiceProvider<QQ> {

    /**
     * QQ获取授权码的url
     */
    private static final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
    
    /**
     * QQ获取accessToken的url
     */
    private static final String URL_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";

    private String appId;

    public QQServiceProvider(String appId, String appSecret) {
        super(new QQOAuth2Template(appId, appSecret, URL_AUTHORIZE, URL_ACCESS_TOKEN));
        this.appId = appId;
    }

    @Override
    public QQ getApi(String accessToken) {

        return new QQImpl(accessToken, appId);
    }
}

/**
 * 自定义返回接收处理
 * QQ 认证返回数据非JSON格式,自定义接收处理逻辑
 *
 * @author zhaohaibin
 */
@Slf4j
public class QQOAuth2Template extends OAuth2Template {

    public QQOAuth2Template(String clientId, String clientSecret, String authorizeUrl, String accessTokenUrl) {
        super(clientId, clientSecret, authorizeUrl, accessTokenUrl);

        // 默认true来携带client_id和client_secret
        setUseParametersForClientAuthentication(true);

    }

    @Override
    protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {

        String responseStr = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);

        log.info("获取accessToken的响应" + responseStr);

        String[] items = StringUtils.splitByWholeSeparatorPreserveAllTokens(responseStr, "&");

        String accessToken = StringUtils.substringAfterLast(items[0], "=");
        Long expiresIn = new Long(StringUtils.substringAfterLast(items[1], "="));
        String refreshToken = StringUtils.substringAfterLast(items[2], "=");

        return new AccessGrant(accessToken, null, refreshToken, expiresIn);
    }

    @Override
    protected RestTemplate createRestTemplate() {

        RestTemplate restTemplate = super.createRestTemplate();
        restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charset.forName("UTF-8")));

        return restTemplate;
    }
}

ApiAdapter:

/**
 * ApiAdapter
 * 第三方数据和框架数据适配
 *
 * @author zhaohaibin
 */
public class QQAdapter implements ApiAdapter<QQ> {

    /**
     * QQ服务默认一直可用
     *
     * @param qq
     * @return
     */
    @Override
    public boolean test(QQ qq) {
        return true;
    }

    @Override
    public void setConnectionValues(QQ qq, ConnectionValues connectionValues) {

        QQUserInfo userInfo = qq.getUserInfo();

        connectionValues.setDisplayName(userInfo.getNickname());

        connectionValues.setImageUrl(userInfo.getFigureurl_qq_1());

        // QQ无个人主页
        connectionValues.setProfileUrl(null);

        connectionValues.setProviderUserId(userInfo.getOpenId());

    }

    @Override
    public UserProfile fetchUserProfile(QQ qq) {
        return null;
    }

    @Override
    public void updateStatus(QQ qq, String s) {

        // QQ无个人主页,不做任何处理

    }

}

ConnectionFactory:

/**
 * ConnectionFactory
 *
 * @author zhaohaibin
 */
public class QQConnectionFactory extends OAuth2ConnectionFactory<QQ> {

    public QQConnectionFactory(String providerId, String appId, String appSecret) {
        super(providerId, new QQServiceProvider(appId, appSecret), new QQAdapter());
    }

}

Repository:

/**
 * 社交配置适配基础类
 *
 * @author zhaohaibin
 */
@Configuration
@EnableSocial
public class SocialConfig extends SocialConfigurerAdapter {

    @Autowired
    private DataSource dataSource;

    @Autowired
    private SecurityProperties securityProperties;

    /**
     * 自动注册处理逻辑(不一定实现),非必要加载
     */
    @Autowired(required = false)
    private ConnectionSignUp connectionSignUp;

    /**
     * connectionFactoryLocator:QQ、微信等connectionFactory
     *
     * @param connectionFactoryLocator
     * @return
     */
    @Override
    public UsersConnectionRepository getUsersConnectionRepository(ConnectionFactoryLocator connectionFactoryLocator) {

        JdbcUsersConnectionRepository repository = new JdbcUsersConnectionRepository(dataSource, connectionFactoryLocator, Encryptors.noOpText());

        // 调用自动注册逻辑
        if (null != connectionSignUp) {
            repository.setConnectionSignUp(connectionSignUp);
        }

        // Encryptors.noOpText() 不需要加解密
        return repository;

    }

    /**
     * 解决启动报错Error creating bean with name 'userIdSource' defined in class path resource
     *
     * @return
     */
    @Override
    public UserIdSource getUserIdSource() {
        // TODO Auto-generated method stub
        return new AuthenticationNameUserIdSource();
    }

    /**
     * 自定义拦截配置
     * 注册服务后修改配置文件端口,地址,请求等与申请一致即可
     *
     * @return
     */
    @Bean
    public SpringSocialConfigurer demoSocialSecurityConfig() {

        String filterProcessesUrl = securityProperties.getSocial().getFilterProcessesUrl();
        DemoSpringSocialConfigurer configurer = new DemoSpringSocialConfigurer(filterProcessesUrl);
        // 找不到用户时跳转到自定义注册页
        configurer.signupUrl(securityProperties.getBrowser().getSignUpUrl());
        return configurer;
    }

    /**
     * 注册过程中拿到SpringSocial信息,注册完成把userId给SpringSocial
     *
     * @param connectionFactoryLocator
     * @return
     */
    @Bean
    public ProviderSignInUtils providerSignInUtils(ConnectionFactoryLocator connectionFactoryLocator) {
        return new ProviderSignInUtils(connectionFactoryLocator, getUsersConnectionRepository(connectionFactoryLocator));
    }

}
/**
 * 社交配置适配:QQ配置默认实现
 * 
 * ConditionalOnProperty 只有配置了相关属性("app-id")才生效
 *
 * @author zhaohaibin
 */
@Configuration
@ConditionalOnProperty(prefix = "demo.security.social.qq", name = "app-id")
public class QQAutoConfig extends SocialConfigurerAdapter {

    @Autowired
    private SecurityProperties securityProperties;

//    /**
//     * extends SocialAutoConfigurerAdapter 而重写的方法
//     * 但SocialAutoConfigurerAdapter因版本升级而删除,重新手写实现registeredAuthenticationProviderIds获取仍为空
//     * 直接extends SocialAutoConfigurerAdapter的父类SocialConfigurerAdapter,重写addConnectionFactories方法
//     * @return
//     */
//    @Autowired
//    protected ConnectionFactory<?> createConnectionFactory() {
//
//        QQProperties qqConfig = securityProperties.getSocial().getQq();
//
//        // 将QQAutoConfig配置传到QQConnectionFactory供后续调用
//        return new QQConnectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(), qqConfig.getAppSecret());
//    }

    @Override
    public void addConnectionFactories(ConnectionFactoryConfigurer connectionFactoryConfigurer, Environment environment) {
        QQProperties qqConfig = securityProperties.getSocial().getQq();
        connectionFactoryConfigurer.addConnectionFactory(new QQConnectionFactory(qqConfig.getProviderId(), qqConfig.getAppId(), qqConfig.getAppSecret()));

    }

}
/**
 * 自定义拦截规则
 *
 * @author zhaohaibin
 */
public class DemoSpringSocialConfigurer extends SpringSocialConfigurer {

    private String filterProcessesUrl;

    public DemoSpringSocialConfigurer(String filterProcessesUrl) {
        this.filterProcessesUrl = filterProcessesUrl;
    }

    @Override
    protected <T> T postProcess(T object) {

        SocialAuthenticationFilter filter = (SocialAuthenticationFilter) super.postProcess(object);
        filter.setFilterProcessesUrl(filterProcessesUrl);
        return (T) filter;

    }
}

配置:

/**
 * @author zhaohaibin
 */
@Data
public class QQProperties /*extends SocialProperties*/ {

    /**
     * 服务提供者标识——QQ
     */
    private String providerId = "qq";

    /**
     * 问题:遇到SocialAutoConfigurerAdapter,SocialProperties和SocialWebAutoConfigurerAdapter类不存在
     * <p>
     * 解决import org.springframework.boot.autoconfigure.social.SocialProperties;
     * 因Springboot 版本升级(1.x-2.x)删除问题(自己手动重写)
     * <p>
     */
    private String appId;
    private String appSecret;

}
/**
 * Social 相关配置基础类
 *
 * @author zhaohaibin
 */
@Data
public class SocialProperties {

    /**
     * 第三方认证默认拦截url
     */
    private String filterProcessesUrl = "/auth";

    /**
     * QQ认证配置
     */
    private QQProperties qq = new QQProperties();

}

更新:
SecurityProperties增加social属性配置:

/**
 * 第三方验证配置
 */
private SocialProperties social = new SocialProperties();

BrowserProperties增加signUpUrl属性配置:

/**
 * 默认注册页
 */
private String signUpUrl= DEFAULT_PROJECT_NAME_URL + "signUp.html";

WebSecurityConfig部分更新代码如下:

/**
 * 拦截路径在类SocialAuthenticationFilter中
 */
@Autowired
private SpringSocialConfigurer demoSocialSecurityConfig;

...

    .and()
    // 第三方登录拦截配置
    .apply(demoSocialSecurityConfig)
    .and()
    // 记住我相关配置
    .rememberMe()
    .tokenRepository(persistentTokenRepository())
    .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())
    .userDetailsService(authenticationBeanConfig.userDetailsService())
    .and()
    // 对任何请求授权
    .authorizeRequests()
    // 匹配页面授权所有权限
    .antMatchers(
            // API
            "/swagger-ui.html",
            // 默认登录页
            SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
            // 自定义登录页(demoLogin)
            securityProperties.getBrowser().getLoginPage(),
            // 验证码
            SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX + "/*",
            securityProperties.getBrowser().getSignUpUrl(),
            "/user/regist")
    .permitAll()
# security 默认登录页面配置
demo:
  security:
    browser:
      loginPage: "/demoLogin.html"
#      loginType: "REDIRECT"
#    code:
#      image:
#        # 图形验证码长度
#        length: 6
#        # 图形验证码图形宽
#        width: 100
#        url: "/demo/user/1,/demo/user/3"
      signUpUrl: "/demoSignUp.html"

    social:
      qq:
        app-id:
        app-secret:
          # 注册服务时的请求,如callback.do
        providerId: "callback.do"
        # 注册服务时的过滤地址,如/qqLogin
      filterProcessesUrl: "/qqLogin"
<h2>社交登录</h2>
<h3>QQ登录</h3>
<a href="qqLogin/callback.do">QQ登录</a>

首次登录会跳转注册页,对应代码更新如下:
BrowserSecurityController:

/**
 * 获取第三方注册的用户信息
 *
 * @param request
 * @return
 */
@GetMapping("/social/user")
public SocialUserInfo getSocialUserInfo(HttpServletRequest request) {

    SocialUserInfo socialUserInfo = new SocialUserInfo();
    Connection<?> connection = providerSignInUtils.getConnectionFromSession(new ServletWebRequest(request));
    socialUserInfo.setProviderId(connection.getKey().getProviderId());
    socialUserInfo.setProviderUserId(connection.getKey().getProviderUserId());
    socialUserInfo.setNickname(connection.getDisplayName());
    socialUserInfo.setHeadimg(connection.getImageUrl());

    return socialUserInfo;

}

demoSignUp.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>SocialDemo注册页</title>
</head>
<body>
<h2>Demo注册页面</h2>
<form action="user/regist" method="post">
    <div><label>用户名</label><input type="text" name="username" placeholder="请输入用户名"/></div>
    <div><label>&nbsp;&nbsp;&nbsp;&nbsp;</label><input type="password" name="password" placeholder="请输入密码"/></div>
    <div><input type="submit" value="regist"/></div>
    <div><input type="submit" value="binding"/></div>
</form>
</body>
</html>

Controller用户注册逻辑:

@Autowired
    private ProviderSignInUtils providerSignInUtils;

    @PostMapping("/regist")
    public void regist(User user, HttpServletRequest request) {

        // 不管注册用户还是绑定用户,都会拿到一个用户唯一标识
        String userId = user.getUsername();
        providerSignInUtils.doPostSignUp(userId, new ServletWebRequest(request));

    }

跳过首次登录手动注册,默认注册需实现之前的配置ConnectionSignUp:

/**
 * 自定义第三方注册逻辑
 *
 * @author zhaohaibin
 */
@Component
public class DemoConnectionSignUp implements ConnectionSignUp {
    @Override
    public String execute(Connection<?> connection) {

        // 根据社交用户信息默认创建用户并返回用户唯一标识
        return connection.getDisplayName();
    }
}

问题排查:

SpringBoot2.x:
1.QQProperties /*extends SocialProperties*/中SocialProperties引入Maven依赖仍找不到该类,是因为资源包升级后(1.x-2.x)被删掉了,所以需要降低版本(1.x);
2.继续启动后报错java.lang.IllegalStateException,改为手动copy,而实际就为了appId和appSecret两个属性,所以取消extends直接加入两个属性;
3.继续启动,跳转/auth/qq总是跳过拦截进入之前开发的登录页引导提示,根据之前经验怀疑是代码出现了异常重定向导致,debug后最终发现是QQAutoConfig继承SocialAutoConfigurerAdapter时重写的createConnectionFactory未执行,导致服务未加载,后续匹配失败后重定向所致,
所以直接继承其父类SocialConfigurerAdapter,重写addConnectionFactories方法;

发布了81 篇原创文章 · 获赞 12 · 访问量 17万+

猜你喜欢

转载自blog.csdn.net/u012382791/article/details/105263400