OAuth
Salesforce
Apex
SSO
LineLogin

Salesforce カスタム認証プロバイダプラグインの作成 及び LINE アカウントで Community Cloud に SSO ログインする

みなさん、こんにちは!

あっちこっちのSalesforceコミュニティイベントを参加していますが、投稿ははじめてです。@takahito0508 さんに誘ってもらい、今年のアドベントカレンダーに参加しました。どんな内容で良いのかを悩んでいた最後、SSO関連にしようかなと思いました。

当記事は Salesforce Platform Advent Calendar 2018 - Day 13 の投稿になります。

はじめに

Salesforceの製品群の中、Community Cloudは外部向けカスタマサービスサイトの位置づけとなります。企業内部の情報共有や、外部ユーザーとやり取りのメッセージ(Chatter)などの機能があります。さらに、TwitterやFacebookなどソーシャルアカウントと連携してSalesforceにログインするには、集客効果も考えられます。しかし、デフォルトにアメリカで主流なSNSのプロバイダしか提供されていません。LINEとWeChatのような地域限定のSNSに対し、カスタム認証プロバイダを作成する必要です。

ユーザカスタム項目の新規作成

LINEアカウントの情報を格納するため、ユーザに以下のカスタム項目を追加します。

  • Line ID
  • Line Profile Image URL
  • Line User Name

image.png

LINE Social API でカスタム認証プロバイダプラグインクラス作成

Apexでカスタム認証プロバイダクラスを作成するには、Auth.AuthProviderPluginClassを拡張して以下の関数を実装する必要です。

  • getCustomMetadataType()
  • initiate(authProviderConfiguration, stateToPropagate)
  • handleCallback(authProviderConfiguration, callbackState)
  • getUserInfo(authProviderConfiguration, response)

LINE Social APIのバージョンは v2.1、ソースコードは以下となります。

LineAuthProviderPluginV21.cls
global class LineAuthProviderPluginV21 extends Auth.AuthProviderPluginClass {

    private static final String FIRST_NAME = 'LineLogin';
    private static final String LAST_NAME  = 'N/A';
    private static final String FULL_NAME  = 'N/A';
    private static final String LINK       = 'N/A';
    private static final String LOCALE     = null;
    private static final String PROVIDER   = 'LINEV21';
    private static final String PROVIDER_DEVELOPER_NAME = 'linev21';
    private String id_token = ''; 

    global String getIdToken() {
        return id_token;
    }

    global void setIdToken(String id_token) {
        this.id_token = id_token;
    }

    global String getCustomMetadataType() {
        return 'LineAuthMetadata__mdt';
    } 

    global PageReference initiate(Map<string,string> authProviderConfiguration, 
        String stateToPropagate) { 
        String url = 'https://access.line.me/oauth2/v2.1/authorize'
            + '?response_type=' + 'code'
            + '&client_id='     + authProviderConfiguration.get('ChannelId__c')
            + '&redirect_uri='  
            + EncodingUtil.urlEncode(authProviderConfiguration.get('RedirectUri__c'), 'UTF-8')
            + '&state='         + stateToPropagate
            + '&scope='         + 'openid%20profile%20email'; 
        return new PageReference(url); 
    } 

    global Auth.AuthProviderTokenResponse handleCallback(Map<string,string> 
        authProviderConfiguration, Auth.AuthProviderCallbackState callbackState) {
        Map<String,String> queryParams = callbackState.queryParameters; 
        String code = queryParams.get('code'); 
        String state = queryParams.get('state'); 

        HttpRequest req = new HttpRequest(); 
        req.setEndpoint('https://api.line.me/oauth2/v2.1/token'); 
        req.setHeader('Content-Type','application/x-www-form-urlencoded'); 
        req.setMethod('POST'); 
        req.setBody('grant_type=' + 'authorization_code'
                    + '&code=' + code
                    + '&client_id=' + authProviderConfiguration.get('ChannelId__c')
                    + '&client_secret=' + authProviderConfiguration.get('ClientSecret__c') 
                    + '&redirect_uri=' 
                    + EncodingUtil.urlEncode(authProviderConfiguration.get('RedirectUri__c'), 
                        'UTF-8'));

        Http http = new Http(); 
        HTTPResponse res = http.send(req); 
        String responseBody = res.getBody(); 
        String token = getValueFromResponse(responseBody, 'access_token');
        String refreshToken = getValueFromResponse(responseBody, 'refresh_token'); 
        Object expires = getObjectValueFromResponse(responsebody, 'expires_in');
        id_token = getValueFromResponse(responseBody, 'id_token');

        return new Auth.AuthProviderTokenResponse('Linev21', token, refreshToken, state); 
    }

    global Auth.UserData getUserInfo(Map<string,string> authProviderConfiguration, 
        Auth.AuthProviderTokenResponse response) {
        /**
          * ユーザープロファイル取得のAPIはV2までしかバージョンアップされていなく、
          * V2.1の場合V2のレスポンスから取得する必要がありません。
          * ただし、V2のAPIを呼び出さないとSSOログインする際にSalesforceのメンテナンス画面が
          * 表示され、LINE SSOユーザーアカウントが登録されません。
          */
        HttpRequest req = new HttpRequest(); 
        req.setEndpoint('https://api.line.me/v2/profile'); 
        req.setHeader('Authorization', 'Bearer ' + response.oauthToken); 
        req.setMethod('GET'); 

        Http http = new Http(); 
        HTTPResponse res = http.send(req); 
        String responseBody = res.getBody(); 

        if (id_token == '') {
            return null;
        }

        String[] rets = id_token.split('\\.');
        String payload = EncodingUtil.base64Decode(rets[1]).toString();
        Map<String, Object> payload_map = 
            (Map<String, Object>)JSON.deserializeUntyped(payload);
        String userId = (String)payload_map.get('sub');
        String email = (String)payload_map.get('email');
        String lastName = (String)payload_map.get('name');
        String userName = userId.hashCode() + '@';
        if (String.isEmpty(Site.getSiteId())) {
            userName += Url.getSalesforceBaseURL().toExternalForm().substringAfter('//');
        } else {
            userName += Site.getDomain();
        }

        Map<String,String> attributeMap  = new Map<String,String>(); 
        attributeMap.put('displayName', (String)payload_map.get('name'));
        attributeMap.put('pictureUrl', (String)payload_map.get('picture'));
        if (!String.isEmpty(authProviderConfiguration.get('ProfileId__c'))) 
            attributeMap.put('profileId', authProviderConfiguration.get('ProfileId__c')); 

        return new Auth.UserData(userId,
                                 FIRST_NAME,
                                 lastName,
                                 FULL_NAME,
                                 email,
                                 LINK,
                                 userName,
                                 LOCALE,
                                 PROVIDER,
                                 null,
                                 attributeMap);
    } 

    private String getValueFromResponse(String response, String key) { 
        Map<String, Object> responseMap = 
            (Map<String, Object>)JSON.deserializeUntyped(response);
        return (String)responseMap.get(key); 
    } 

    private Object getObjectValueFromResponse(String response, String key) { 
        Map<String, Object> responseMap = 
            (Map<String, Object>)JSON.deserializeUntyped(response);
        return responseMap.get(key); 
    }
}

外部認証プロバイダの登録ハンドラクラスの実装

登録ハンドラクラスを作成するには、インターフェース Auth.RegistrationHandler を実装して以下の関数を実装する必要です。

  • createUser(portalId, userData)
  • updateUser(userId, portalId, userData)
LineUserRegistrationHandlerV21.cls
global class LineUserRegistrationHandlerV21 implements Auth.RegistrationHandler {
    private static final String TIME_ZONE    = 'Asia/Tokyo';
    private static final String LANGUAGE     = 'ja';
    private static final String LOCALE       = 'ja_JP';
    private static final String EMAIL_ENCODE = 'UTF-8';
    private static final String ACCOUNT_NAME = 'LINE User';

    class RegHandlerException extends Exception {}

    global User createUser(Id portalId, Auth.UserData data){
      System.debug('Method createUser was called.');

        List<User> userList = [SELECT Id FROM User WHERE LineId__c =: data.identifier 
            AND IsActive = true];
        User u = userList.isEmpty() ? new User() : userList[0];

        u.LastName               = data.lastName;
        u.FirstName              = data.firstName;
        u.Email                  = data.email;
        u.Username               = data.username;
        u.Alias                  = data.username.substring(0, 8);
        u.ProfileId              = data.attributeMap.get('profileId');
        u.TimeZoneSidKey         = TIME_ZONE;
        u.LanguageLocaleKey      = LANGUAGE;
        u.LocaleSidKey           = LOCALE;
        u.EmailEncodingKey       = EMAIL_ENCODE;

        u.LineId__c              = data.identifier;
        u.LineUserName__c        = data.attributeMap.get('displayName');
        u.LineProfileImageURL__c = data.attributeMap.get('pictureUrl');

        String siteId = System.Site.getSiteId();
        if(String.isEmpty(u.Id) && !String.isEmpty(siteId)) {
            Site site = [SELECT Id, MasterLabel, UrlPathPrefix 
                FROM Site 
                WHERE Id =: siteId LIMIT 1];
            Network network = [SELECT Id, SelfRegProfileId, OptionsSelfRegistrationEnabled 
                FROM Network 
                WHERE Name =: site.MasterLabel AND UrlPathPrefix =:site.UrlPathPrefix LIMIT 1];

            Account a = new Account();
            if (isPersonAccountEnabled()) {
                RecordType personAccountRecordType = Database.query('SELECT Id FROM RecordType '
                    + 'WHERE SobjectType = \'Account\' AND IsPersonType = true LIMIT 1');
                a.put('RecordTypeId', personAccountRecordType.Id);
                a.put('LastName', data.lastName);
                a.put('FirstName', data.firstName); 
                a.put('PersonEmail', data.email);
                insert a;
            } else {
                a.name      = data.fullname;
                insert a;
                Contact c   = new Contact();
                c.AccountId = a.Id;
                c.FirstName = data.firstName;
                c.LastName  = data.lastName;
                c.Email     = data.email;
                insert(c);
            }
            u.ProfileId = network.SelfRegProfileId;
            u.contactId = [SELECT Id FROM Contact WHERE accountId =: a.Id][0].Id; 
            u.CommunityNickname = data.attributeMap.get('displayName');
        }      
        return u;
    }

    global void updateUser(Id userId, Id portalId, Auth.UserData data){
      System.debug('Method updateUser was called.');

        User u = [SELECT Id, ContactId, LineId__c, LineUserName__c, LineProfileImageURL__c 
            FROM User WHERE Id =: userId];
        u.LineId__c              = data.identifier;
        u.LineUserName__c        = data.attributeMap.get('displayName');
        u.LineProfileImageURL__c = data.attributeMap.get('pictureUrl');

        u.LastName               = data.lastName + '.V21.Update01';
        u.FirstName              = data.firstName;
        u.Email                  = data.email + '.V21.Update01';

        update(u);
    } 

    private boolean isPersonAccountEnabled() {
        return Schema.sObjectType.Account.fields.getMap().containsKey('isPersonAccount');
    }
}

外部認証プロバイダの設定

LINEログインプロバイダー & チャンネル作成

LINE SSO ログインするには、LINE Developersでプロバイダーとチャンネルの作成が必要です。
image.png
image.png

メール取得権限の申請

image.png
image.png

カスタムメタデータ型の作成

カスタムプロバイダとハンドルに必要な情報を格納するため、カスタムメタデータ型「LineAuthMetadata」を作成し、以下のカスタム項目を新規作成します。

  • Channel ID:LINEログインのチャンネルの識別子「Channel ID」を格納
  • Client Secret:LINEログインのチャンネルの秘密鍵「Channel Secret」を格納
  • Profile ID: SFDC外部ユーザーのプロファイルIDを格納(例:Customer Community User)
  • Redirect URI:SFDC外部認証プロバイダのCallback URLを格納(例:https://login.salesforce.com/services/authcallback/00D6F0000028GOLUA5/lineV21)

image.png

SFDC外部認証プロバイダの新規作成

必要な項目を入力して保存します。
※ 現時点 (2018/12/13) の最新バージョンでも英語版に切り替えないと保存時にエラーになります。
image.png

LINEログインチャンネルCallback URLの更新・公開

LINEログインチャンネルのCallback URLをSFDC外部認証プロバイダのCallback URLに更新し、チャンネルを公開します。
image.png

カスタマサービスサイト作成

コミュニティの有効化と新規作成

コミュニティ設定でコミュニティの有効化にして、コミュニティを新規作成します。
image.png

管理設定

メンバー設定

コミュニティワークスペースの管理 → メンバーで顧客用のプロファイルCustomer Community Userを選択済みプロファイルに追加します。
image.png

ログインページの認証プロバイダの設定

ログイン&登録 → ログインページ設定の「LINEV21」にチェックを入れます。
image.png

リモートサイトの設定

LINEのAPIサイトをSFDCのリモートサイトに追加する必要です。
image.png

ログインしてみる

顧客カスタマサービスサイトログイン

image.png

LINEアカウントログイン画面

image.png

権限確認

image.png

ログイン後のトップページ

image.png

さいごに

OAuth、OpenIDの流れを理解できたら、Salesforce のカスタム認証プロバイダの開発に非常に役立ちます。Apex では基本の枠をすでに提供してくれました。インターフェースとクラスを拡張して各関数にAPI関連のコードを書き込んで連携の形にするだけです。ただ、LINEのようなサービス提供側は連携APIのバージョンアップによって新しい機能を使いたい場合、新たなプロバイダを作成する必要です。
今回は、LINEと連携のプロバイダを作成しました。WeChatの場合も同じやり方で作成できます。以下のリンク先は中国語のページとなりますが、ソースコードをご覧いただければ、どんな仕組を理解できます。ご興味ある方は是非一度ご確認をいただければと思います。
https://www.cnblogs.com/panxuejun/p/6094711.html(OAuth2がAouth2に間違えている)

また、WeChatのWebサイトアプリケーション作成のサイトは英語版もあります。
https://open.weixin.qq.com/cgi-bin/frame?t=home/web_tmpl&lang=en
ご参考になりましたら幸いでございます。

【参考】
1. Salesforce AuthProviderPluginClass クラス
https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_class_Auth_AuthProviderPluginClass.htm#apex_class_Auth_AuthProviderPluginClass
2. Salesforce RegistrationHandler クラス
https://developer.salesforce.com/docs/atlas.ja-jp.apexcode.meta/apexcode/apex_auth_plugin.htm#apex_auth_plugin
3. LINE Social API リファレンス (最新v2.1)
https://developers.line.biz/ja/reference/social-api/