Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

みなさん、こんにちは!

あっちこっちの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/

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした