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](https://qiita-image-store.s3.amazonaws.com/0/309113/aa4baebd-c148-7a8a-1de4-325ec2226acd.png)


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

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

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

```javascript: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)

```javascript: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](https://qiita-image-store.s3.amazonaws.com/0/309113/8ca0a0b4-684f-6392-3b16-417a272036cd.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/9209d0c3-ba41-7055-6b28-43ea912ad731.png)

#####メール取得権限の申請
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/4787c81d-1dbc-1305-41c5-cb04ab5fbbd4.png)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/faac4cda-0b15-cf3c-ad5e-f8e55f393f76.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](https://qiita-image-store.s3.amazonaws.com/0/309113/bea9f105-f348-8875-d2c9-234f26b96680.png)

####SFDC外部認証プロバイダの新規作成
必要な項目を入力して保存します。
**<font color="Red">※ 現時点 (2018/12/13) の最新バージョンでも英語版に切り替えないと保存時にエラーになります。</font>**
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/2701489c-6db9-455a-2bc9-3a7a889876e0.png)

####LINEログインチャンネルCallback URLの更新・公開
LINEログインチャンネルのCallback URLをSFDC外部認証プロバイダのCallback URLに更新し、チャンネルを公開します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/dedf4b50-a6b9-eb5a-0971-be687afcf1f4.png)


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

####コミュニティの有効化と新規作成
コミュニティ設定でコミュニティの有効化にして、コミュニティを新規作成します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/06da6cf9-9189-6e60-6b89-8f463582fe6e.png)

####管理設定

#####メンバー設定
コミュニティワークスペースの管理 → メンバーで顧客用のプロファイルCustomer Community Userを選択済みプロファイルに追加します。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/ddf70bb8-40a1-846d-e543-dac1a926204b.png)


#####ログインページの認証プロバイダの設定
ログイン&登録 → ログインページ設定の「LINEV21」にチェックを入れます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/a5946565-f925-4fe0-8a63-c8b15fb45505.png)

##リモートサイトの設定
LINEのAPIサイトをSFDCのリモートサイトに追加する必要です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/390e51f1-4be1-70cb-8206-9892cd32e4ec.png)

##ログインしてみる
#####顧客カスタマサービスサイトログイン
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/ca920082-d810-5889-49e6-1404f433a334.png)

#####LINEアカウントログイン画面
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/55cf84f3-cf82-985e-a8eb-d6299cd01b3c.png)

#####権限確認
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/7e92a7f8-be1b-66fb-b419-dc76c8270198.png)

#####ログイン後のトップページ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/309113/d74342ed-2d4f-8ab4-96e3-f334fc3474be.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/