• 三方登录组件简介

    三方登录目前 HZERO 支持 微信、QQ、新浪微博、企业微信,同时支持项目上开发特定的三方登录,只需按规范开发相应的实现,然后在 oauth 服务中引入依赖即可。

    1. 组件依赖

    如果想使用某个组件,需自行在 oauth 服务中引入相关依赖:

    2. 三方登录组件

    hzero-starter-social 三方登录组件基于 spring-social、spring-security、oauth2.0 扩展开发,hzero 三方组件如下:

    三方登录流程

    Spring Social 三方登录流程是基于 oauth2.0 标准的授权码模式来完成的,所以 hzero-starter-social 组件只能在三方应用平台的授权方式是授权码模式才可以使用。具体的流程可以参考如下流程图。

    三方应用管理

    1. 申请授权信息

    在使用某种三方登录方式时,首先需要到对应三方开放平台上申请三方应用的授权信息。

    在申请三方应用授权信息时,需要填入网站回调地址,回调地址在 oauth 服务中,且回调地址必须能让外网访问,否则三方平台无法回调。
    回调地址格式为:http://{domain}/oauth/open/{appCode}/callback
    其中 domain 为网站网关域名,appCode 为三方应用编码。

    申请成功后,将得到三方应用平台的 APP ID 以及 APP Key,例如 QQ 开放平台申请的应用:

    2. 配置三方应用

    首先需要在 三方应用管理 功能下配置系统的三方应用信息,维护好之后,才可以在个人中心三方账号及oauth登录页面看到三方应用的图标。

    三方登录接口

    下面以 QQ 三方登录为例介绍三方授权相关的一些接口。

    1. 获取三方登录方式

    调用 /oauth/login/init-params?client_id={clientId} 获取三方登录方式

    openLoginWays: 三方登录方式
    isNeedCaptcha: 是否需要输入图形验证码
    

    2. PC端跳转三方授权平台

    PC 端需跳转到三方平台让三方用户授权登录,APP 端则直接使用 SDK 拉起本地应用授权。

    访问 http://domain/oauth/open/qq,后台自动跳转到 QQ 授权页面

    3. 用户授权回调

    用户授权后,三方平台将回调 http://domain/oauth/open/qq/callback?code=XXXXX&state=xxx,并带上授权码返回。移动端则会将授权码返回本地应用。之后的获取 access_token、认证用户是否已绑定,都是在后端自动进行,无需特别处理。

    用户如果未绑定,默认会跳转到绑定账号页面,用户输入用户名、密码即可完成账号绑定。

    如果不需要跳转到绑定账号页面,可更改如下配置。设置为 false 时,如果三方用户未绑定系统用户,会跳转到登录页面,并提示未绑定系统用户。

    hzero:
      oauth:
        social:
          # 如果三方用户未绑定系统用户,是否跳转到绑定账号页面
          attempt-bind: true
    

    4. PC端用户绑定三方账号

    用户登录后,可在个人中心绑定三方账号。

    绑定账号访问 http://domain/oauth/open/qq?access_token=xxxxx&bind_redirect_uri=redirectUrl

    access_token: 用户登录后的 access_token
    bind_redirect_uri: 绑定成功或失败的重定向地址,绑定失败将在重定向地址后通过 `social_error_message` 参数返回。
    

    移动端三方认证

    由于移动端三方授权都是在移动设备上进行,oauth 服务提供 绑定三方账号、通过三方账号获取 access_token 的接口。

    1.三方认证接口

    正常情况下,移动端首先引导用户授权得到三方应用的 open_id(或 union_id),然后调用 /oauth/token/open [POST] 接口认证并获取系统的 access_token。

    2. 绑定系统账号

    三方用户未绑定系统账号时,移动端引导用户到绑定账号页面,首先认证系统用户获取 access_token,接着调用 /oauth/open-bind [POST] 绑定系统用户。

    接口返回码

    返回编码 说明
    hoth.social.providerUserNotFound 未查询到您的三方用户信息
    hoth.social.openIdNotFound 无法获取到您的三方账号
    hoth.social.userAlreadyBind 您已绑定三方账户
    hoth.social.openIdAlreadyBindOtherUser 您的三方账户已绑定其他用户,您可以先解绑再绑定当前用户
    hoth.social.providerNotBindUser 您的三方账号未绑定系统用户,可先到个人中心绑定
    hoth.social.userNotFound 系统用户不存在
    hoth.social.userNotAuthorized 三方用户未授权

    开发三方登录

    HZERO 目前已支持 微信、QQ、新浪微博 三方登录方式,如果项目上需要开发其它的三方登录,可按照如下步骤开发。三方应用平台相关的接口、参数、返回内容等请到对应三方开放平台查找。

    1. 创建三方组件

    开发三方登录时,建议新建一个项目或模块,开发完成后在 oauth 服务中依赖该组件即可。parent 依赖 hzero-starter-social-parent,引入 hzero-starter-social-core 组件。下面以QQ开发的流程为例讲解如何基于 hzero-starter-social-core 开发三方登录。

    2. 三方API封装

    ① 三方用户信息类:实现 org.hzero.starter.social.core.common.api.SocialUser 接口,根据三方开放平台文档,封装三方用户信息

    public class QQUser implements SocialUser {
    
        private String ret;
    
        private String msg;
    
        private String openId;
    
        private String nickname;
    
        private String figureurl;
    
        private String gender;
    
        private String unionId;
    
        // getter/setter...
    }
    

    ② 三方接口类:继承 org.hzero.starter.social.core.common.api.SocialApi 接口,该接口类有一个默认的 getUser 接口方法,用于向三方平台获取用户信息。

    public interface QQApi extends SocialApi {
    
    }
    

    ③ 三方接口默认实现:继承 org.hzero.starter.social.core.common.api.AbstractSocialApi 抽象类,并实现 QQApi 三方接口。

    在构造方法中,必须包含 access_token 参数,Provider 则封装了三方平台的信息,包括 APP ID、APP Key、Token 地址、用户地址等等。

    实现 getUser 方法,调用三方平台获取 open_id 的接口,根据 access_token 查询 open_id。有些三方登录在返回 access_token 时会将 open_id 直接返回,这时可以不用查询 open_id;有些则需要一次接口调用。接着调用三方平台用户信息查询接口,根据 APP ID 及 openId 查询用户信息

    public class DefaultQQApi extends AbstractSocialApi implements QQApi {
    
        private static final Logger LOGGER = LoggerFactory.getLogger(DefaultQQApi.class);
    
        private String userInfoUrl;
        private String openIdUrl;
    
        /**
         * 客户端 appId
         */
        private String appId;
        /**
         * openId
         */
        private String openId;
        /**
         * openId
         */
        private String unionId;
    
        private static final ObjectMapper mapper = new ObjectMapper();
    
        public DefaultQQApi(String accessToken, Provider provider) {
            super(accessToken);
            this.appId = provider.getAppId();
            this.userInfoUrl = provider.getUserInfoUrl() + "?oauth_consumer_key={appId}&openid={openId}";
            this.openIdUrl = provider.getOpenIdUrl();
            if (isGetUnionId()) {
                this.openIdUrl += "?unionid=1";
            }
        }
    
        @Override
        public synchronized QQUser getUser() {
            if (!isAuthorized()) {
                throw new CommonSocialException(SocialErrorCode.SOCIAL_USER_NOT_AUTHORIZED);
            }
            if (StringUtils.isBlank(openId)) {
                QQMe me = getMe();
                this.openId = me.getOpenId();
                this.unionId = me.getUnionId();
            }
    
            String result = getRestTemplate().getForObject(userInfoUrl, String.class, this.appId, this.openId);
    
            QQUser user = null;
            try {
                user = mapper.readValue(result, QQUser.class);
            } catch (Exception e) {
                LOGGER.error("parse qq UserInfo error. result : {}", result);
            }
            if (user == null || StringUtils.isBlank(user.getNickname())) {
                LOGGER.info("not found provider user, result user={}", user);
                throw new ProviderUserNotFoundException(SocialErrorCode.PROVIDER_USER_NOT_FOUND);
            }
            user.setOpenId(openId);
            user.setUnionId(unionId);
            return user;
        }
    
        /**
         * 获取用户 OpenId
         */
        private QQMe getMe() {
            // 返回结构:callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID","unionid":"YOUR_UNIONID"} );
            String openIdResult = getRestTemplate().getForObject(openIdUrl, String.class);
            if (StringUtils.isBlank(openIdResult) || openIdResult.contains("error")) {
                LOGGER.warn("request social user's openId return error, result={}", openIdResult);
                throw new CommonSocialException(SocialErrorCode.OPEN_ID_NOT_FOUND);
            }
            // 解析 openId
            String[] arr = StringUtils.substringBetween(openIdResult, "{", "}").replace("\"", "").split(",");
    
            String openid = null;
            String unionid = null;
            for (String s : arr) {
                if (s.contains("openid")) {
                    openid = s.split(":")[1];
                } else if (s.contains("unionid")) {
                    unionid = s.split(":")[1];
                }
            }
            return new QQMe(openid, unionid);
        }
    
        private boolean isGetUnionId() {
            SocialProperties socialProperties = ApplicationContextHelper.getContext().getBean(SocialProperties.class);
            return socialProperties.getQq().isGetUnionId();
        }
    
        private class QQMe {
            private String openId;
            private String unionId;
    
            public QQMe(String openId, String unionId) {
                this.openId = openId;
                this.unionId = unionId;
            }
    
            public String getOpenId() {
                return openId;
            }
    
            public String getUnionId() {
                return unionId;
            }
        }
    }
    

    3. API 适配器

    开发三方应用与本地应用用户之间的适配器,继承 org.hzero.starter.social.core.common.connect.SocialApiAdapter 抽象类,覆盖 setConnectionValues ,在方法中,首先调用 api 获取用户信息,然后向 ConnectionValues 中设置用户昵称、open_id 等。

    public class QQApiAdapter extends SocialApiAdapter {
        /**
         * QQApi 与 Connection 做适配
         * @param api QQApi
         * @param values Connection
         */
        @Override
        public void setConnectionValues(SocialApi api, ConnectionValues values) {
            // 调用三方接口获取用户信息
            QQUser user = (QQUser) api.getUser();
            // 设置昵称
            values.setDisplayName(user.getNickname());
            values.setImageUrl(user.getFigureurl());
            // 设置 open_id
            values.setProviderUserId(user.getOpenId());
        }
    }
    

    4. 三方服务提供商

    服务提供商用于提供具体的 API,需继承 org.hzero.starter.social.core.common.connect.SocialServiceProvider 抽象类,在 getSocialApi 方法中,返回三方API的具体实现类。

    public class QQServiceProvider extends SocialServiceProvider {
    
        private Provider provider;
    
        public QQServiceProvider(Provider provider, SocialTemplate template) {
            super(provider, template);
            this.provider = provider;
        }
    
        @Override
        public QQApi getSocialApi(String accessToken) {
            // 构造服务提供商API
            return new DefaultQQApi(accessToken, provider);
        }
    }
    

    5. OAuth token 模板类

    OAuth token 模板类用于获取三方应用 access_token,刷新 token 等等,需继承 org.hzero.starter.social.core.common.connect.SocialTemplate 抽象类,根据实际的API情况获取授权信息。

    public class QQTemplate extends SocialTemplate {
    
        private static final Logger LOGGER = LoggerFactory.getLogger(QQTemplate.class);
    
        public QQTemplate(Provider provider) {
            super(provider);
            // 设置带上 client_id、client_secret
            setUseParametersForClientAuthentication(true);
        }
    
        /**
         * 解析 QQ 返回的令牌
         */
        @Override
        protected AccessGrant postForAccessGrant(String accessTokenUrl, MultiValueMap<String, String> parameters) {
            // 返回格式:access_token=FE04********CCE2&expires_in=7776000&refresh_token=88E4***********BE14
            String result = getRestTemplate().postForObject(accessTokenUrl, parameters, String.class);
            if (StringUtils.isBlank(result)) {
                throw new RestClientException("access token endpoint returned empty result");
            }
            LOGGER.debug("==> get qq access_token: " + result);
            String[] arr = StringUtils.split(result, "&");
            String accessToken = "", expireIn = "", refreshToken = "";
            for (String s : arr) {
                if (s.contains("access_token")) {
                    accessToken = s.split("=")[1];
                } else if (s.contains("expires_in")) {
                    expireIn = s.split("=")[1];
                } else if (s.contains("refresh_token")) {
                    refreshToken = s.split("=")[1];
                }
            }
            return createAccessGrant(accessToken, null, refreshToken, Long.valueOf(expireIn), null);
        }
    
        /**
         * QQ 响应 ContentType=text/html;因此需要加入 text/html; 的处理器
         */
        @Override
        protected RestTemplate createRestTemplate() {
            RestTemplate restTemplate = super.createRestTemplate();
            restTemplate.getMessageConverters().add(new StringHttpMessageConverter(Charsets.UTF_8));
            return restTemplate;
        }
    }
    

    6. 连接工厂

    连接工厂用于创建 Connection 连接信息,需继承 org.hzero.starter.social.core.common.connect.SocialConnectionFactory 类。

    public class QQConnectionFactory extends SocialConnectionFactory {
    
        public QQConnectionFactory(Provider provider, SocialServiceProvider serviceProvider, SocialApiAdapter apiAdapter) {
            super(provider, serviceProvider, apiAdapter);
        }
    }
    

    7. 连接工厂构造器

    连接工厂构造器用于创建连接工厂,需实现 org.hzero.starter.social.core.common.configurer.SocialConnectionFactoryBuilder接口
    并实现两个方法:

    @Configuration
    public class QQSocialBuilder implements SocialConnectionFactoryBuilder {
    
        @Override
        public String getProviderId() {
            return ProviderEnum.qq.name();
        }
    
        @Override
        public SocialConnectionFactory buildConnectionFactory(Provider provider) {
            // 获取授权码地址
            final String URL_AUTHORIZE = "https://graph.qq.com/oauth2.0/authorize";
            // 获取令牌地址
            final String URL_GET_ACCESS_TOKEN = "https://graph.qq.com/oauth2.0/token";
            // 获取 openId 的地址
            final String URL_GET_OPEN_ID = "https://graph.qq.com/oauth2.0/me";
            // 获取用户信息的地址
            final String URL_GET_USER_INFO = "https://graph.qq.com/user/get_user_info";
    
            provider.setAuthorizeUrl(URL_AUTHORIZE);
            provider.setAccessTokenUrl(URL_GET_ACCESS_TOKEN);
            provider.setOpenIdUrl(URL_GET_OPEN_ID);
            provider.setUserInfoUrl(URL_GET_USER_INFO);
            // 创建适配器
            QQApiAdapter apiAdapter = new QQApiAdapter();
            // 创建三方模板
            QQTemplate template = new QQTemplate(provider);
            // 创建服务提供商
            QQServiceProvider serviceProvider = new QQServiceProvider(provider, template);
            // 创建连接工厂
            return new QQConnectionFactory(provider, serviceProvider, apiAdapter);
        }
    }
    

    8. 添加配置

    在 resources 资源目录下,新建 META-INF 目录,添加 spring.factories 文件,并将 QQSocialBuilder 添加到自动配置。内容如下:

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    org.hzero.starter.social.qq.config.QQSocialBuilder
    

    9. API 测试

    开发完成后,就可以打包发布,然后在 oauth 服务中引入依赖即可使用。

    正常情况下,个人中心或登录页面,我们可以看到在三方应用管理配置的三方登录方式。

    点击QQ图标会访问 http://domain/oauth/open/qq,接着会跳转到三方应用平台,后续的流程可参考三方登录流程图。