匿名访问 & 社交登录

像在线商店类的应用程序一般允许用户在不登录的情况下浏览产品列表、查看产品信息或者对产品进行比较。但是用户如果要购买产品,那就必须注册,使用社交账户注册会给用户带来很大的方便性。

将要构建什么

本指南对 CUBA 宠物商店示例进行了增强,借此演示如何使应用程序可以公开访问,并且允许用户使用社交服务注册用户。

特别是会涉及到以下主题:

  • 匿名访问

  • 自定义登录对话框

  • OAuth Web Flow

  • 社交登录

  • 自动注册

开发环境要求

你的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

示例: CUBA 宠物诊所

这个示例是以 CUBA 宠物诊所项目为基础,而这个项目的基础是众所周知的 Spring 宠物诊所项目。CUBA 宠物诊所应用程序涉及到了宠物诊所的领域模型及与管理一家宠物诊所相关的业务流程。

这个应用程序的领域模型看起来像这样:

领域模型

主要实体是 PetVisit。 一个 Pet 到一家诊所就诊,在诊疗过程(Vist) 会有一名兽医(Vet)负责照顾它。每个宠物都有主人,一个主人可以有多个宠物。一个诊疗过程(Vist)即是一个宠物在主人的帮助下到诊所就诊的活动。

公开访问

一般情况下 CRM 类的应用程序会有一些信息或者功能对于已登录或匿名用户都应该可以访问,比如产品、服务等。 这些场景一般与仪表板、通讯录或支持页有关。在本指南中我们将提供查看诊所中兽医列表的功能和查看可带来就诊的宠物列表的功能, 并且不需要登录应用程序。也就是说应用程序以匿名的方式运行。

从 7.1 开始, CUBA 提供了更灵活的方式去创建公开界面,并且这些公开界面也受内建安全子系统的管理。我们将使用它为匿名用户提供访问权限。

开始吧,

配置匿名访问

第一件事是通过应用程序属性启用匿名访问支持:

web-app.properties
cuba.web.allowAnonymousAccess = true

启用了这个设置后,如果当前用户会话没有经过认证(即没有登录),应用程序将检测匿名用户对当前界面的访问权限,而不是重定向到登录界面。

初始界面

下一步是配置要默认打开的界面:

web-app.properties
cuba.web.initialScreenId = main

现在我们得到第一个结果:

initial screen

主界面有侧边菜单, 但是是空的。但是只显示空的界面没有任何意义,我们应该给匿名用户设置权限。

匿名权限

点击侧边菜单的登录按钮,以管理员身份登录系统。打开"角色"浏览界面,编辑 “Anonymous” 角色。

允许访问所有必要的菜单项和界面:

screen permissions

赋予读取相应实体的权限:

entity permissions

重启应用程序。现在用户匿名用户可以看到兽医列表了。

pemitted screens

界面路由

CUBA 导航功能也支持匿名访问,所以可以给我们的界面注册路由, 这样匿名用户就可以直接打开指定的界面。可以使用 @Route 注解实现路由。

VetBrowse.java
@Route("vets")
@UiController("petclinic_Vet.browse")
@UiDescriptor("vet-browse.xml")
@LookupComponent("vetsTable")
@LoadDataBeforeShow
public class VetBrowse extends StandardLookup<Vet> {
}

以相同的方式给其它界面添加路由:

  • @Route("pet-types") for PetTypeBrowse

  • @Route("specs") for SpecialtyBrowse

重启应用程序并且尝试使用以下链接打开兽医(Vets)界面:

http://localhost:8080/petclinic/#main/vets
screen routes

现在用户可以查看兽医列表、可以页面添加到书签或者与好友分享链接,这些都不需要登录 。其它的公开页面也可以导航,但是如果用户尝试打开一个没有授权的界面,系统会重定向到登录界面。

登录对话框

CUBA 中的默认登录处理要求重定向到另外一个界面。在本节,我们将演示如何使用模式对话框登录。接着我们会把社交网络按钮放到这个对话框上。

扩展主界面

我们从扩展默认的主界面开始。在 Sutdio 中打开 “New Screen” 对话框,选择名称为 “Main screen with side menu” 的模板。你可以注意到这个界面布局有一个新的组件 - UserActionsButton 。这个组件会为匿名用户显示 “log in” 操作,同时允许已登录用户打开 "Settings" 界面或者登出系统。

UserActionsButton 组件有几个扩展点,利用这些扩展点可以覆盖登录或登出的默认行为。它允许我们自定义逻辑来打开对话框,使用 @Install 注解来添加登录处理器:

ExtMainScreen.java
@UiController("main")
@UiDescriptor("ext-main-screen.xml")
public class ExtMainScreen extends MainScreen {

    @Install(to = "userActionsButton", subject = "loginHandler")
    private void loginHandler(UserActionsButton.LoginHandlerContext ctx) {
        // will open login dialog later
    }
}

登录对话框

新的登录对话框是默认登录界面的简化版,所以我们从创建一个空界面开始。使 LoginDialog 扩展 LoginScreen 以重用登录处理逻辑。

LoginDialog.java
@UiDescriptor("login-dialog.xml")
@UiController("LoginDialog")
public class LoginDialog extends LoginScreen {
}

对话框布局只是一个登录表单,从默认登录界面拷贝过来。

login-dialog.xml
<window xmlns="http://schemas.haulmont.com/cuba/screen/window.xsd"
        caption="mainMsg://loginWindow.loginField">

    <actions>
        <action id="submit"
                caption="mainMsg://loginWindow.okButton"
                icon="app/images/login-button.png"
                invoke="performLogin" shortcut="ENTER"/>
    </actions>

    <layout>
        <vbox id="loginMainBox"
              align="MIDDLE_CENTER"
              margin="true"
              width="320">
            <hbox id="loginTitleBox"
                  align="MIDDLE_CENTER"
                  spacing="true"
                  stylename="c-login-title">
                <image id="logoImage"
                       align="MIDDLE_LEFT"
                       height="AUTO"
                       scaleMode="SCALE_DOWN"
                       stylename="c-login-icon"
                       width="AUTO"/>

                <label id="welcomeLabel"
                       align="MIDDLE_LEFT"
                       stylename="c-login-caption"
                       value="mainMsg://loginDialog.label"/>
            </hbox>

            <capsLockIndicator id="capsLockIndicator"
                               align="MIDDLE_CENTER"
                               stylename="c-login-capslockindicator"/>
            <vbox id="loginForm"
                  spacing="true"
                  stylename="c-login-form">
                <cssLayout id="loginCredentials"
                           stylename="c-login-credentials">
                    <textField id="loginField"
                               htmlName="loginField"
                               inputPrompt="mainMsg://loginWindow.loginPlaceholder"
                               stylename="c-login-username"/>
                    <passwordField id="passwordField"
                                   autocomplete="true"
                                   htmlName="passwordField"
                                   inputPrompt="mainMsg://loginWindow.passwordPlaceholder"
                                   capsLockIndicator="capsLockIndicator"
                                   stylename="c-login-password"/>
                </cssLayout>
                <hbox id="rememberLocalesBox"
                      stylename="c-login-remember-locales">
                    <checkBox id="rememberMeCheckBox"
                              caption="mainMsg://loginWindow.rememberMe"
                              stylename="c-login-remember-me"/>
                    <lookupField id="localesSelect"
                                 nullOptionVisible="false"
                                 stylename="c-login-locale"
                                 textInputAllowed="false"/>
                </hbox>
                <button id="loginButton"
                        align="MIDDLE_CENTER"
                        action="submit"
                        stylename="c-login-submit-button"/>
            </vbox>
        </vbox>
    </layout>
</window>

设置对话框宽度并且添加登录按钮的 click 监听器:

LoginDialog.java
@Route
@DialogMode(width = "430")
@UiDescriptor("login-dialog.xml")
@UiController("LoginDialog")
public class LoginDialog extends LoginScreen {

    @Subscribe("loginButton")
    private void onLoginButtonClick(Button.ClickEvent event) {
        login();

        if (connection.isAuthenticated()) {
            close(WINDOW_CLOSE_ACTION);
        }
    }
}

新的对话框应该对于所有用户都是可用的,不管用户是什么角色,所以我们将使用默认权限机制去启用它。在 core 模拟的根包下创建 default-permission-values.xml 文件,文件内容如下:

default-permission-values.xml
<?xml version="1.0" encoding="UTF-8"?>
<default-permission-values xmlns="http://schemas.haulmont.com/cuba/default-permission-values.xsd">
    <!-- Permit to open LoginDialog for all roles by default -->
    <permission target="LoginDialog" value="1" type="10"/>
</default-permission-values>

app.properties 文件中将这个配置文件到默认权限配置:

app.properties
cuba.defaultPermissionValuesConfig = +com/haulmont/sample/petclinic/default-permission-values.xml

现在我可以在 UserActionsButton 登录处理器中打开新的对话框:

ExtMainScreen.java
@UiController("main")
@UiDescriptor("ext-main-screen.xml")
public class ExtMainScreen extends MainScreen {

    @Inject
    private Screens screens;

    @Install(to = "userActionsButton", subject = "loginHandler")
    private void loginHandler(UserActionsButton.LoginHandlerContext ctx) {
        screens.create(LoginDialog.class, OpenMode.DIALOG)
                .show();
    }
}

重启应用程序并且尝试登录:

login dialog init

社交登录

在部分情况下,应用程序都会要求注册一个账户以使用应用程序的功能。注册过程常常会要求填写一个冗长的表单,然后确认Email地址。 简化这个过程的一种广泛使用的方法是通过社交服务注册,比如Google或Facebook。

流程一般是这样:应用程序将用户引起到社交网络登录页面,在用户允许了请求的权限后,会再次返回应用程序。由于应用程序自动注册新的账户,这样就不需要用户填写表单,也可以减少用户访问服务需要的时间。

这种方式被称作 "OAuth Web Flow",我们将使用这种方式集成社交登录到宠物诊所应用程序。

OAuth Web Flow

应用程序的主要任务之一是为用户注册新账户。这里需要基础的信息,比如名称、email等。流行的社交网络服务,比如 Facebook ,会提供API端点去访问这些信息。保护这些API端点的常用方式是使用 OAuth 令牌,或 “访问令牌”。

首先你需要在社交服务上注册你的应用程序:

你会得到称作 client idclient secret 的凭证,用于认证过程:

  1. 应用程序使用客户端id给认证服务端点发送一个请求。

  2. 服务返回一个授权码。

  3. 应用程序使用 client id、client secret和授权码给服务发送一个请求

  4. 如果所有凭证信息都正确,服务会在响应中带上访问令牌。

web auth flow

社交按钮

在UI中集成社交登录的最常用途径之一是注册对话框上的按钮,如下图:

pinterest login

LinkButton 组件放到一个水平 box 布局,这样可以将社交按钮到登录对话框

login-dialog.xml
<hbox align="TOP_CENTER"
      margin="true;false;false;false"
      spacing="true"
      width="AUTO">
    <linkButton id="googleLogin"
                icon="GOOGLE"
                stylename="social-button"/>
    <linkButton id="facebookLogin"
                icon="FACEBOOK"
                stylename="social-button"/>
    <linkButton id="githubLogin"
                icon="GITHUB"
                stylename="social-button"/>
</hbox>

我们使用自定义样式名来使按钮更大一些。打开 hover-ext.scss 文件,添加以下规css 规则:

hover-ext.scss
.v-button-link.social-button {
  font-size: round($v-unit-size * 0.8);
}

效果:

social buttons

后续我们将使用这些按钮触发社交登录过程。

初步准备

不是所有的服务都支持 localhost 作为应用程序主机。你可以添加一个主机别名到操作系统hosts文件,并且在应用程序属性文件里使用这个别名:

app.properties
cuba.webAppUrl = https://petclinic.com:8080/petclinic

此外,多数社交服务要求使用 HTTPS -,你可以在 https://tomcat.apache.org/tomcat-9.0-doc/ssl-howto.html 找到关于如何为Tomcat容器启用 SSL的指南。

社交服务配置

假设应用已经在社交服务上注册,已经有了必要的凭据 (client idclient secret)。

我们使用配置接口机制来存储服务凭据。我们引入以下配置:

  • GoogleConfig

  • FacebookConfig

  • GitHubConfig

由于所有的服务具有一组相同的凭据,所以我们创建一个通用接口 SocialServiceConfig:

SocialServiceConfig.java
public interface SocialServiceConfig {

    String getClientId();

    String getClientSecret();
}

这时,举例来说,GoogleConfig 将是:

GoogleConfig.java
@Source(type = SourceType.APP)
public interface GoogleConfig extends Config, SocialServiceConfig {

    @Property("google.clientId")
    String getClientId();

    @Property("google.clientSecret")
    String getClientSecret();
}

从社交服务获取到 client idclient secret 之后,将他们定入 app.properties 文件并且重启应用程序。

app.properties
google.clientId = <APP_CLIENT_ID>
google.clientSecret = <APP_CLIENT_SECRET>

获取授权码 (Auth Code)

认证的第一步是获取一个获取授权码(Auth Code) - 是一个临时的代码,用于换取访问令牌。要获取一个授权码,我们应该将用户访问重定向到服务的认证端点,并且处理响应。

所有社交服务的认证过程几乎都一样,所以我们可以写一段通用的代码。主要不同在于连接的端点URL、参数等。所以我们先引入下列枚举:

SocialService.java
public enum SocialService {

    GOOGLE,
    FACEBOOK,
    GITHUB
}

我们创建一个服务, 用于生成一个认证端点地址:

SocialLoginService.java
public interface SocialLoginService {

    String NAME = "petclinic_SocialLoginService";

    String getLoginUrl(SocialService socialService);
}

要构造一个登录地址,我们应该组合端点 URL 和必要的参数:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    @Override
    public String getLoginUrl(SocialService socialService) {
        String authEndpoint = SocialLoginHelper.getAuthEndpoint(socialService);
        String params = SocialLoginHelper.getAuthParams(
                socialService,
                getClientId(socialService),
                getRedirectUri());
        return authEndpoint + params;
    }

    private String getClientId(SocialService socialService) {
        return getSocialServiceConfig(socialService).getClientId();
    }

    private SocialServiceConfig getSocialServiceConfig(SocialService socialService) {
        switch (socialService) {
            case GOOGLE:
                return configuration.getConfig(GoogleConfig.class);
            case FACEBOOK:
                return configuration.getConfig(FacebookConfig.class);
            case GITHUB:
                return configuration.getConfig(GitHubConfig.class);
            default:
                throw new IllegalArgumentException(
                        "No config found for service: " + socialService);
            }
    }

    private String getRedirectUri() {
        return configuration.getConfig(GlobalConfig.class).getWebAppUrl();
    }
}

SocialLoginHelper 是一个工具类,包含认证 URL和生成参数部分:

SocialLoginHelper.java
public final class SocialLoginHelper {

    private static final String GOOGLE_AUTH_ENDPOINT =
            "https://accounts.google.com/o/oauth2/v2/auth?";
    private static final String FACEBOOK_AUTH_ENDPOINT =
            "https://www.facebook.com/v3.3/dialog/oauth?";
    private static final String GITHUB_AUTH_ENDPOINT =
            "https://github.com/login/oauth/authorize?";

    public static String getAuthEndpoint(SocialService socialService) {
        switch (socialService) {
            case GOOGLE:
                return GOOGLE_AUTH_ENDPOINT;
            case FACEBOOK:
                return FACEBOOK_AUTH_ENDPOINT;
            case GITHUB:
                return GITHUB_AUTH_ENDPOINT;
        }
        throw new IllegalArgumentException(
                "No auth endpoint found for service: " + socialService);
    }

    // ...
}

添加社交按钮点击事件监听器,重定向用户请求到社交服务登录页面:

LoginDialog.java
public class LoginDialog extends LoginScreen {

    @Subscribe("googleLogin")
    private void onGoogleLoginClick(Button.ClickEvent event) {
        performSocialLogin(SocialService.GOOGLE);
    }

    private void performSocialLogin(SocialService socialService) {
        String loginUrl = socialLoginService.getLoginUrl(socialService);

        Page.getCurrent()
                .setLocation(loginUrl);
    }
}

登录后,服务会再次跳转回我们的应用,这时我们需要处理响应。

处理社交服务响应

要使用授权码处理社交服务响应,我们可以使用 Vaadin Request Handlers 机制 - 它允许我们使用函数式接口处理请求回调。

我们的回调处理器将使用 SocialLoginService 获取用户数据,所以它应该是一个 Bean。 请求处理器应该在请求前添加到当前 session, 并且在请求结束后移除。这表示我们可以将处理器实现为 prototype Bean:

SocialServiceCallbackHandler.java
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
@Component(SocialServiceCallbackHandler.NAME)
public class SocialServiceCallbackHandler implements RequestHandler {

    public static final String NAME = "petclinic_SocialServiceCallbackHandler";

    private final SocialService service;
    private final URI redirectUri;

    public SocialServiceCallbackHandler(SocialService service) {
        this.service = service;
        redirectUri = Page.getCurrent().getLocation();
    }

    @Override
    public boolean handleRequest(VaadinSession session,
                                 VaadinRequest request,
                                 VaadinResponse response) throws IOException {
        return true; // to be implemented
    }
}

我们强调一下这个处理器的主要职责:

  • 从响应中提取授权码(auth code),并且通过 SocialLoginService 获取用户数据

  • 基于用户数据创建 Credentials 实例

  • 触发登录过程并且重定向回应用

首先,我们使用 UIAccessor 实例锁定UI,直到登录请求处理完成:

SocialServiceCallbackHandler.java
public class SocialServiceCallbackHandler implements RequestHandler, InitializingBean {

    @Override
    public boolean handleRequest(VaadinSession session, VaadinRequest request,
                                 VaadinResponse response) throws IOException {
        if (request.getParameter("code") == null) {
            return false;
        }

        uiAccessor.accessSynchronously(() -> {
            try {
                Credentials credentials = getCredentials(request.getParameter("code"),
                        service);
                app.getConnection().login(credentials);
            } catch (Exception e) {
                log.error("Unable to login using service: " + service, e);
            } finally {
                session.removeRequestHandler(this);
            }
        });

        ((VaadinServletResponse) response).getHttpServletResponse().
                sendRedirect(ControllerUtils.getLocationWithoutParams(redirectUri));

        return true;
    }

    @Override
    public void afterPropertiesSet() {
        uiAccessor = backgroundWorker.getUIAccessor();
    }

    private Credentials getCredentials(String authCode, SocialService socialService) {
        return null; // to be implemented
    }
}

回到 LoginDialog 来使用回调处理器:

LoginDialog.java
public class LoginDialog extends LoginScreen {

    private void performSocialLogin(SocialService socialService) {
        String loginUrl = socialLoginService.getLoginUrl(socialService);

        VaadinSession.getCurrent()
                .addRequestHandler(getCallbackHandler(socialService));

        close(WINDOW_CLOSE_ACTION);

        Page.getCurrent()
                .setLocation(loginUrl);
    }

    private RequestHandler getCallbackHandler(SocialService socialService) {
        return getBeanLocator()
                .getPrototype(SocialServiceCallbackHandler.NAME, socialService);
    }
}

使用授权码(Auth Code) 换取访问令牌

当授权码可用时,我们可以使用它来获取访问令牌。 构造一个请求,请求参数需要根据社交网络服务来确定:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    private HttpRequestBase getAccessTokenRequest(SocialService socialService,
            String authCode) {
        switch (socialService) {
            case GOOGLE: {
                HttpPost tokenRequest = new HttpPost(
                        getAccessTokenPath(socialService, authCode));
                tokenRequest.setEntity(getGoogleAccessTokenParams(authCode));
                return tokenRequest;
            }
            case FACEBOOK:
            case GITHUB: {
                HttpGet tokenRequest = new HttpGet(
                        getAccessTokenPath(socialService, authCode));
                tokenRequest.setHeader(HttpHeaders.ACCEPT,
                MediaType.APPLICATION_JSON_VALUE);
                return tokenRequest;
            }
            default:
                throw new IllegalArgumentException(
                        "Unable to create request for social service: " + socialService);
        }
    }

    private String getAccessTokenPath(SocialService socialService, String authCode) {
        String clientId = getClientId(socialService);
        String clientSecret = getClientSecret(socialService);
        String redirectUri = getRedirectUri();
        return SocialLoginHelper.getAccessTokenPath(socialService, clientId,
                clientSecret, redirectUri, authCode);
    }

    private UrlEncodedFormEntity getGoogleAccessTokenParams(String authCode) {
        Map<String, String> params = SocialLoginHelper.getGoogleAccessTokenParams(
                getClientId(SocialService.GOOGLE),
                getClientSecret(SocialService.GOOGLE),
                getRedirectUri(),
                authCode);

        List<BasicNameValuePair> requestParams = params.entrySet().stream()
                .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue()))
                .collect(Collectors.toList());

        return new UrlEncodedFormEntity(requestParams, StandardCharsets.UTF_8);
    }

    private String getClientSecret(SocialService socialService) {
        return getSocialServiceConfig(socialService).getClientSecret();
    }

    // ...
}

然后使用 Apache HttpClient 库来执行请求:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    private String requestAccessToken(HttpRequestBase accessTokenRequest) {
        HttpClientConnectionManager cm = new BasicHttpClientConnectionManager();
        HttpClient httpClient = HttpClientBuilder.create().setConnectionManager(cm)
                .build();

        try {
            HttpResponse httpResponse = httpClient.execute(accessTokenRequest);
            if (httpResponse.getStatusLine().getStatusCode() != 200) {
                throw new RuntimeException(
                        "Unable to get access token. Response HTTP status: " +
                        httpResponse.getStatusLine().getStatusCode());
            }
            return EntityUtils.toString(httpResponse.getEntity());
            } catch (IOException e) {
                throw new RuntimeException(e.getMessage());
            } finally {
                accessTokenRequest.releaseConnection();
            }
    }

    // ...
}

使用 Google Gson 解析访问令牌:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    private String extractAccessToken(String response) {
        JsonParser parser = new JsonParser();
        JsonObject asJsonObject = parser.parse(response)
                .getAsJsonObject();

        return asJsonObject.get("access_token").getAsString();
    }

    // ...
}

整体调用:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    private String getAccessToken(SocialService socialService, String authCode) {
        HttpRequestBase accessTokenRequest = getAccessTokenRequest(socialService,
                authCode);
        String response = requestAccessToken(accessTokenRequest);
        return extractAccessToken(response);
    }

    // ...
}

自动注册

这部分会描述如何使用访问令牌去获取用户的个人资料。现在注册一个新账户并登录。

获取用户数据

一般情况下社交网络服务 API 端点会允许你指定要获取的字段。我们给配置接口添加一个配置:

SocialServiceConfig.java
public interface SocialServiceConfig {

    String getUserDataFields();

    // ...
}

比如, GoogleConfig:

GoogleConfig.java
@Source(type = SourceType.APP)
public interface GoogleConfig extends Config, SocialServiceConfig {

    @Property("google.clientId")
    String getClientId();

    @Property("google.clientSecret")
    String getClientSecret();

    @Default("id,name,email")
    @Property("google.userDataFields")
    String getUserDataFields();
}

创建一个简单的只读 POJO 来存储加载到用户资料信息:

SocialUserData.java
class SocialUserData implements Serializable {

    private String id;
    private String login;
    private String name;

    public SocialUserData(String id, String login, String name) {
        this.id = id;
        this.login = login;
        this.name = name;
    }

    public String getId() {
        return id;
    }

    public String getLogin() {
        return login;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return "SocialUserData{" +
                "id='" + id + '\'' +
                ", login='" + login + '\'' +
                ", name='" + name + '\'' +
                '}';
    }
}

SocialLoginService 接口添加一个新的方法,接受授权码并返回相应的用户数据。

SocialLoginService.java
public interface SocialLoginService {

    SocialUserData getUserData(SocialService socialService, String authCode);

    // ...
}

在这个方法中进行以下处理:

  • 使用授权码(auth code) 获取访问令牌

  • 使用访问令牌获取用户数据

  • 解析响应并创建一个 SocialUserData 接口

我们已经介绍过如何使用授权码(auth code)换取访问令牌,现在我们可以获取用户数据:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    private String getUserDataAsJson(SocialService socialService, String accessToken) {
        String userDataEndpoint = SocialLoginHelper.getUserDataEndpoint(socialService);
        String params = SocialLoginHelper.getUserDataEndpointParams(
                socialService,
                accessToken,
                getUserDataFields(socialService));
        String url = userDataEndpoint + params;

        return requestUserData(url);
    }

    private String requestUserData(String url) {
        HttpClientConnectionManager cm = new BasicHttpClientConnectionManager();
        HttpClient httpClient = HttpClientBuilder.create().setConnectionManager(cm)
                .build();

        HttpGet getRequest = new HttpGet(url);
        try {
            HttpResponse httpResponse = httpClient.execute(getRequest);
            if (httpResponse.getStatusLine().getStatusCode() != 200) {
                throw new RuntimeException(
                        "Unable to access Google API. Response HTTP status: " +
                        httpResponse.getStatusLine().getStatusCode());
            }
            return EntityUtils.toString(httpResponse.getEntity());
        } catch (IOException e) {
            throw new RuntimeException(e.getMessage());
        } finally {
            getRequest.releaseConnection();
        }
    }

    // ...
}

从响应中解析用户数据为`SocialUserData` POJO:

SocialLoginServiceBean.java
public class SocialLoginServiceBean implements SocialLoginService {

    @Override
    public SocialUserData getUserData(SocialService socialService, String authCode) {
        String accessToken = getAccessToken(socialService, authCode);
        String userDataJson = getUserDataAsJson(socialService, accessToken);
        return parseUserData(userDataJson);
    }

    private SocialUserData parseUserData(String userDataJson) {
        JsonParser parser = new JsonParser();

        JsonObject response = parser.parse(userDataJson)
                .getAsJsonObject();

        String id = Strings.nullToEmpty(response.get("id").getAsString());
        String name = Strings.nullToEmpty(response.get("name").getAsString());

        String login = Strings.nullToEmpty(response.get("email").getAsString());
        if (StringUtils.isEmpty(login)) {
            login = Strings.nullToEmpty(response.get("login").getAsString());
        }

        return new SocialUserData(id, login, name);
    }

    // ...
}

社交凭据

现在我们可以通过 CUBA 安全系统登录,一般的流程是这样的:

  1. Credentials 实例传递给 Connection

  2. Connection 遍历可用的 LoginProviders ,检查是否支持传递的凭据

  3. 如果找到了合适的提供者(Provider),Connection 会将登录调用委托给这个提供者

要支持自定义登录,你应该创建自己的 Credentials 和相应的 LoginProvider

web 模块创建一个类 SocialCredentials

SocialCredentials.java
public class SocialCredentials extends AbstractClientCredentials {

    private final SocialUserData userData;
    private final SocialService socialService;

    public SocialCredentials(SocialUserData userData,
                             SocialService socialService,
                             Locale locale) {
        super(locale, Collections.emptyMap());
        this.userData = userData;
        this.socialService = socialService;
    }

    @Override
    public String getUserIdentifier() {
        return userData.getId();
    }

    // ...
}

现在回到 SocialServiceCallbackHandler 来完成它的实现:

SocialServiceCallbackHandler.java
public class SocialServiceCallbackHandler implements RequestHandler, InitializingBean {

    private Credentials getCredentials(String authCode, SocialService socialService) {
        SocialLoginService.SocialUserData userData = socialLoginService
                .getUserData(socialService, authCode);

        Locale defaultLocale = messages.getTools()
                .getDefaultLocale();

         return new SocialCredentials(userData, socialService, defaultLocale);
    }

    // ...
}

登录提供者

Connection 组件使用所有可用的 LoginProviders 去获取一个新的认证信息。 LoginProviders 机制允许你使用有序的 Spring Bean 对不同类型的凭据执行用户认证。我们将使用这个扩展点来创建一个社交登录提供者:

SocialLoginProvider.java
@Component(SocialLoginProvider.NAME)
public class SocialLoginProvider implements LoginProvider {

    public static final String NAME = "petclinic_SocialLoginProvider";

    @Nullable
    @Override
    public AuthenticationDetails login(Credentials credentials) throws LoginException {
        SocialCredentials socialCredentials = (SocialCredentials) credentials;
        SocialLoginService.SocialUserData userData = socialCredentials.getUserData();

        // to be implemented

        return null;
    }

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

我们扩展内置的 ExternalUserLoginProvider 来重用它的逻辑。基于可用信息创建一个新的 ExternalUserCredentials 实例,并将其传递给父类方法:

SocialLoginProvider.java
@Component(SocialLoginProvider.NAME)
public class SocialLoginProvider extends ExternalUserLoginProvider implements LoginProvider {

    public static final String NAME = "petclinic_SocialLoginProvider";

    @Inject
    private SocialRegistrationService socialRegistrationService;

    @Nullable
    @Override
    public AuthenticationDetails login(Credentials credentials) throws LoginException {
        SocialCredentials socialCredentials = (SocialCredentials) credentials;

        SocialLoginService.SocialUserData userData = socialCredentials.getUserData();

        // to be implemented;
        User user = null;

        Locale defaultLocale = socialCredentials.getLocale();

        return super.login(new ExternalUserCredentials(user.getLogin(), defaultLocale));
    }

    // ...
}

要构造一个凭据,我们必须找到一个已有的或新建一个用户.

用户注册

创建一个新的 继承自 User 的实体 SocialUser,并给它添加三个字段:

  • googleId

  • facebookId

  • githubId

这些字段用于在社交网络资料和用户之间建立关联,方便后续查找。我们应该给用户设置一个默认组 - 创建一个新的配置接口:

SocialRegistrationConfig.java
@Source(type = SourceType.APP)
public interface SocialRegistrationConfig extends Config {

    @Default("0fa2b1a5-1d68-4d69-9fbd-dff348347f93")
    @Property("social.defaultGroupId")
    @Factory(factory = UuidTypeFactory.class)
    UUID getDefaultGroupId();
}

创建一个新的服务 SocialRegistrationService ,用于查找可注册新的用户:

SocialRegistrationService.java
public interface SocialRegistrationService {

    String NAME = "petclinic_SocialRegistrationService";

    User findOrRegisterUser(String socialServiceId, String login, String name,
                            SocialService socialService);

    // ...
}

这个接口的实现非常简单:

SocialRegistrationServiceBean.java
public class SocialRegistrationServiceBean implements SocialRegistrationService {

    private static final Pattern EMAIL_PATTERN = Pattern.compile("[^@]+@[^.]+\\..+");

    @Inject
    private DataManager dataManager;
    @Inject
    private Configuration configuration;

    @Override
    public User findOrRegisterUser(String socialServiceId, String login, String name,
                                   SocialService socialService) {
        User existingUser = findExistingUser(socialService, socialServiceId);
        if (existingUser != null) {
            return existingUser;
        }

        SocialUser user = createNewUser(socialServiceId, login, name, socialService);

        return dataManager.commit(user);
    }

    @Nullable
    private User findExistingUser(SocialService socialService, String socialServiceId) {
        String socialServiceField = getSocialIdParamName(socialService);

        return dataManager.load(User.class)
                .query("select u from sec$User u where " +
                        String.format("u.%s = :socialServiceId", socialServiceField))
                .parameter("socialServiceId", socialServiceId)
                .one();
    }

    private SocialUser createNewUser(String socialServiceId, String login,
                                     String name, SocialService socialService) {
        SocialUser user = dataManager.create(SocialUser.class);

        user.setLogin(login);
        user.setName(name);
        user.setGroup(getDefaultGroup());
        user.setActive(true);

        if (isEmail(login)) {
            user.setEmail(login);
        }

        switch (socialService) {
            case GOOGLE:
                user.setGoogleId(socialServiceId);
                break;
            case FACEBOOK:
                user.setFacebookId(socialServiceId);
                break;
            case GITHUB:
                user.setGithubId(socialServiceId);
                break;
        }

        return user;
    }

    private Group getDefaultGroup() {
        SocialRegistrationConfig config = configuration.getConfig(SocialRegistrationConfig.class);

        return dataManager.load(Group.class)
                .query("select g from sec$Group g where g.id = :defaultGroupId")
                .parameter("defaultGroupId", config.getDefaultGroupId())
                .one();
    }

    private String getSocialIdParamName(SocialService socialService) {
        switch (socialService) {
            case GOOGLE:
                return "googleId";
            case FACEBOOK:
                return "facebookId";
            case GITHUB:
                return "githubId";
        }
        throw new IllegalArgumentException(
                "No social id param found for service: " + socialService);
    }

    private boolean isEmail(String s) {
        return EMAIL_PATTERN.matcher(s).matches();
    }
}

返回到 SocialLoginProvider ,并且使用 SocialRegistrationService 来获取用户:

SocialLoginProvider
@Component(SocialLoginProvider.NAME)
public class SocialLoginProvider extends ExternalUserLoginProvider implements LoginProvider {

    @Nullable
    @Override
    public AuthenticationDetails login(Credentials credentials) throws LoginException {
        SocialCredentials socialCredentials = (SocialCredentials) credentials;

        SocialLoginService.SocialUserData userData = socialCredentials.getUserData();

        User user = socialRegistrationService.findOrRegisterUser(
                userData.getId(),
                userData.getLogin(),
                userData.getName(),
                socialCredentials.getSocialService());

        Locale defaultLocale = socialCredentials.getLocale();

        return super.login(new ExternalUserCredentials(user.getLogin(), defaultLocale));
    }

    // ...
}

总结

匿名访问允许给应用程序提供可公开访问的功能,比如仪表板、新闻或者反馈页。但是有也一些功能只有在登录后可用, 社交登录是一个很方便的登录方式,可以避免用户填写繁琐的注册表单。在本指南中我们介绍了如何在 CUBA 应用程序中使用这种方式。