Salesforce に LINE アカウントでログインする方法

> LINE Login V2.1 では Open ID Connect がサポートされたようですが、うまくログイン出来ていません。。
> 成功したか方がいれば教えてください！
> ~ 2017/10/11 現在


- Salesforce ではソーシャルアカウントの認証で Salesforce にログインするための機能として、外部認証プロバイダという設定項目が提供されています。
- 今回は LINE Login を使って、 LINE アカウントで Salesforce にログインする方法を試してました。
- 外部認証プロバイダの設定では、Facebook や Twitter といった世界的に主だった SNS や OpenID Connect 対応サービスには比較的簡単に認証設定が追加できるようになっています。しかし、現在のところ - LINE は非対応のため独自に認証プロバイダプラグインや登録ハンドラーを作成する必要があります。
- では、早速作成してみましょう。

> 外部認証プロバイダとは？
https://help.salesforce.com/articleView?id=sso_authentication_providers.htm&language=ja&type=0

> OAuth をサポートしているが OpenID Connect プロトコルをサポートしていないサービスプロバイダ
https://help.salesforce.com/articleView?id=sso_provider_plugin_custom.htm&language=ja


#外部認証プロバイダの作成
##カスタム項目の作成
- LINE アカウントから取得した値の格納先となるカスタム項目をユーザーオブジェクトに作成します。
- `設定 > ビルド > カスタマイズ > ユーザー > 項目` から以下の 3 項目を作成しておきます。

![スクリーンショット 2017-07-23 19.15.24.png](https://qiita-image-store.s3.amazonaws.com/0/26378/8abdab2f-a388-6a72-9094-a5724dbc8596.png)
 
- `設定 > ビルド > カスタマイズ > ユーザー > ページレイアウト` で作成した 3 項目をページレイアウトに追加しておきます。

![スクリーンショット 2017-07-23 19.18.22.png](https://qiita-image-store.s3.amazonaws.com/0/26378/e4bc0487-8168-d6d5-13f4-86a89aa63ccb.png)



##Apex クラスの作成
- 以下2つの Apex クラスを作成します。


```java:LineAUthProviderPlugin.apxc
global class LineAuthProviderPlugin extends Auth.AuthProviderPluginClass {
    private static final String FIRST_NAME = 'N/A';
    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   = 'LINE';
    private static final String PROVIDER_DEVELOPER_NAME = 'line';

    global String getCustomMetadataType() {
        // Make sure to create the Custom Metadata in advance.
        // # If you are not-Engish user and getting an error sayin this Metadata is invalid, 
        //   change your language to English and retry.
        return 'LineAuthMetadata__mdt';
    } 
    
    global PageReference initiate(Map<string,string> authProviderConfiguration, String stateToPropagate) { 
        String url = 'https://access.line.me/dialog/oauth/weblogin' 
            + '?response_type=' + 'code'
            + '&client_id='     + authProviderConfiguration.get('ChannelId__c')
            + '&redirect_uri='  + EncodingUtil.urlEncode(authProviderConfiguration.get('RedirectUri__c'), 'UTF-8')
            + '&state='         + stateToPropagate; 
        return new PageReference(url); 
    } 
    
    global Auth.AuthProviderTokenResponse handleCallback(Map<string,string> authProviderConfiguration, Auth.AuthProviderCallbackState callbackState) {
        // Create a reuqest for Line user token.
        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/v2/oauth/accessToken'); 
        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'); 
        return new Auth.AuthProviderTokenResponse('Line', token, refreshToken, state); 
    }
    
    global Auth.UserData getUserInfo(Map<string,string> authProviderConfiguration, Auth.AuthProviderTokenResponse response) {
        // Create a reuqest for Line user profiles.
        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(); 
        
        // Since Line does not provide user's email address, we will create a temporary one.
        // - The email address has to be unique so we can also use it as an username.
        // - If the user is authrized as an internal user, use the org's domain for the email.
        //   If the user is authrized as an external user, use the community's domain.
        String email = getValueFromResponse(responseBody, 'userId').hashCode() + '@';
        if (String.isEmpty(Site.getSiteId())) {
            email += Url.getSalesforceBaseURL().toExternalForm().substringAfter('//');
        } else {
            email += Site.getDomain();
        }

        // Set LINE sepecific attributes for creating and updating User record in org.
        Map<String,String> attributeMap  = new Map<String,String>(); 
        attributeMap .put('displayName', getValueFromResponse(responseBody, 'displayName'));
        attributeMap .put('pictureUrl', getValueFromResponse(responseBody, 'pictureUrl'));
        if (!String.isEmpty(authProviderConfiguration.get('ProfileId__c'))) 
            attributeMap .put('profileId', authProviderConfiguration.get('ProfileId__c')); 
          
        // Return the 
        return new Auth.UserData(getValueFromResponse(responseBody, 'userId'), // identifier
                                 FIRST_NAME, // firstName
                                 LAST_NAME,  // lastName
                                 FULL_NAME,  // fullName
                                 email,      // email
                                 LINK,       // link
                                 email,      // userName
                                 LOCALE,     // locale
                                 PROVIDER,   // provider
                                 null, //siteLoginUrl
                                 attributeMap); // attributeMap
    } 

    
    
    @RemoteAction
    global static String getSsoUrl() {
        // Set the Community Base Url if it is community access, or set salesforce base Url if it is internal user access.        
        String returnUrl = Site.getBaseUrl();
        if (String.isEmpty(returnUrl)) {
            returnUrl = Url.getSalesforceBaseURL().toExternalForm();
        }
        
        // If the login user already has LINE account linked and has no LINE user Id,
        // generate sso Url for the user to re-login so user info gets updated.
        Id userId = UserInfo.getUserId();
        List<ThirdPartyAccountLink> accountLinks = [SELECT Id, UserId, Provider FROM ThirdPartyAccountLink WHERE UserId =: userId AND Provider =: PROVIDER_DEVELOPER_NAME];
        User u = [SELECT Id, LineId__c FROM User WHERE Id =: userId];
        if (!accountLinks.isEmpty() && String.isEmpty(u.LineId__c)) {
            returnUrl = 'https://login.salesforce.com/services/auth/sso/' + UserInfo.getOrganizationId() + '/' + PROVIDER_DEVELOPER_NAME;
            if (!String.isEmpty(Site.getSiteId())) {
                returnUrl += '?community=' + EncodingUtil.urlEncode(Site.getBaseUrl(), 'UTF-8'); 
            } 
        }
        
        return returnUrl;
    } 
    
    private String getValueFromResponse(String response, String key) { 
        Map<String, Object> responseMap = (Map<String, Object>)JSON.deserializeUntyped(response);
        return (String)responseMap.get(key); 
    } 
}
```

```java:LineUserRegistrationHandler.apxc
global class LineUserRegistrationHandler 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){
        List<User> userList = [SELECT Id FROM User WHERE LineId__c =: data.identifier AND IsActive = true];
        User u = userList.isEmpty() ? new User() : userList[0];
       
        // Initialize User sobject with the UserData
        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;
        
        // Set Line-sepecific attributes
        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];
            if (!network.OptionsSelfRegistrationEnabled) throw new RegHandlerException('Self registration feature has to be enabled.');
            
            // If the user is an external user, a related Account and a Contact need to be created.
            Account a = new Account();
            if (isPersonAccountEnabled()) {
                // If Person Account is enabled in the org, create a person account  
                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){
        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');
        update(u);
    } 
    
    private boolean isPersonAccountEnabled() {
        return Schema.sObjectType.Account.fields.getMap().containsKey('isPersonAccount');
    }
}
```

##カスタムメタデータ型の作成
- `設定 > ビルド > 開発 > カスタムメタデータ型` から以下のメタデータ型を作成する。

![スクリーンショット 2017-07-23 19.25.31.png](https://qiita-image-store.s3.amazonaws.com/0/26378/6d97656f-6603-14a9-ebef-237792d5a450.png)

- 作成したカスタムメタデータ型にカスタム項目を追加する。

![スクリーンショット 2017-07-23 19.25.38.png](https://qiita-image-store.s3.amazonaws.com/0/26378/a782b816-9fc8-53fb-4710-ef5b3b331315.png)



##認証プロバイダを作成する
### 表示言語の変更
- ユーザーの言語を英語にしておかないと設定画面は上手く動作しないので注意しましょう。
- `私の設定 > 個人用 > 言語とタイムゾーン` から言語を English にしておきます。
![スクリーンショット 2017-07-23 19.29.48.png](https://qiita-image-store.s3.amazonaws.com/0/26378/764eb0a5-80a3-71f7-18df-ba257c2cc6e9.png)

###認証プロバイダの設定
- `設定 > 管理 > セキュリティのコントロール > 認証プロバイダ > 新規` から以下の通り設定します。
    - Channel ID と Client Secret は LINE Login の Developer Page から取得したものを設定してください。
    - Profile ID には、ユーザーがログイン時に割り当てられるプロファイルを指定します。今回はこの組織の Chatter External User の ID を設定しています。
    - Execute As にはシステム管理者ユーザーを割り当てています。

![スクリーンショット 2017-07-23 19.29.48.png](https://qiita-image-store.s3.amazonaws.com/0/26378/3ba7d129-069c-2d10-a83a-f32742ed36c6.png)



- 保存後、各URLが表示されるので Callback URL を Redirect URL にコピーし保存し直します。

![スクリーンショット_2017-07-23_19_46_12.png](https://qiita-image-store.s3.amazonaws.com/0/26378/1ea8a795-9d92-26c5-4431-1db2ca5eab41.png)

- LINE Login の Technical configuration にも Callback URL をコピーしておきます。

![スクリーンショット_2017-07-23_19_50_54 (1).png](https://qiita-image-store.s3.amazonaws.com/0/26378/974dcf72-1320-a09b-85cb-c640ad303cbe.png)

##リモートサイトの追加
- `設定 > 管理 > セキュリティのコントロール > リモートサイトの設定 > 新規` から以下の通り設定します。

![スクリーンショット 2017-07-23 20.15.16.png](https://qiita-image-store.s3.amazonaws.com/0/26378/590a5716-1895-900c-1d06-108ed4f2924a.png)


#外部認証プロバイダの利用
##接続テスト
- 作成した認証プロバイダの`テスト専用初期化 URL`にアクセスし、LINE にログインすると正しく認証出来たか確認出来ます。
- 認証に成功した場合は以下のXMLが表示されます。

```xml:response
<user xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <full_name>N/A</full_name>
  <provider>LINE</provider>
  <org_id>00Dhfbl8e23hfilqf</org_id>
  <link>N/A</link>
  <last_name>N/A</last_name>
  <id>U02easdffghjkqwertyui</id>
  <portal_id>000000000000000</portal_id>
  <locale xsi:nil="true"/>
  <first_name>N/A</first_name>
  <email>12345678@xxx-dev-ed.my.salesforce.com</email>
  <username>12345678@xxx-dev-ed.my.salesforce.com</username>
</user>
```

##シングルサインオン
- ユーザーにLINEアカウントで組織にログインさせたい場合は、`シングルサインオン初期化 URL`にアクセスさせます。
- また、コミュニティにアクセスさせたい場合は、`シングルサインオン初期化 URL + '?community=' + (コミュニティのURL)` にアクセスさせます。
    - 例: `https://login.salesforce.com/services/auth/sso/00D1234567890asdfg/line?community=https://www.xxxxx.com/support`
    - コミュニティユーザーの場合、セルフレジスターの機能を有効化しておきましょう。

##既存ユーザーにLINEアカウントを紐付ける
- `既存ユーザをリンクする URL`にアクセスさせます。
- この時、なぜかユーザーオブジェクトのカスタム項目が更新されないので、以下の Visualforce ページを作成し startUrl に指定する事で再度 SSO させる事にしました。
    - 例： `https://login.salesforce.com/services/auth/link/00D1234567890asdfg/line?startURL=%2Fapex%2FRedirectToSso`
    - ※リンクするプロファイルに、Visualforceページへの権限不要が必要

```html:RedirectToSso.vfp
<apex:page showHeader="false" standardStylesheets="false" controller="LineAuthProviderPlugin">
    <meta name="viewport" content="width=device-width,initial-scale=1"/>
    <div id="wrapper">
        <div style="font-size: 24px;">Please wait...</div> 
    </div>
    
    <script>
    (function() {
        getSsoUrl()
        .then(function(ssoUrl) {
            if (ssoUrl) window.location.href = ssoUrl;
        })
        .catch(function(err) {
            alert(err.message);
        });
    })();
    
    var REMOTEACTION_OPTIONS = {buffer: true, escape: true, timeout: 120000};
    function getSsoUrl() {
        return new Promise(function(resolve, reject) {
            Visualforce.remoting.Manager.invokeAction(
                '{!$RemoteAction.LineAuthProviderPlugin.getSsoUrl}',
                function (result, event) {
                    if (event.status) resolve(result);
                    else reject(event);
                }, this.REMOTEACTION_OPTIONS); 
        });
    }
    </script>
    <style>
        #wrapper {
        position: absolute;
        top: 50vh;
        left: 50vw;
        transform: translate(-50%, -50%);
        text-align: center;
        }
    </style>
</apex:page>
```