みなさん、こんにちは!
あっちこっちの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
##LINE Social API でカスタム認証プロバイダプラグインクラス作成
Apexでカスタム認証プロバイダクラスを作成するには、Auth.AuthProviderPluginClassを拡張して以下の関数を実装する必要です。
- getCustomMetadataType()
- initiate(authProviderConfiguration, stateToPropagate)
- handleCallback(authProviderConfiguration, callbackState)
- getUserInfo(authProviderConfiguration, response)
LINE Social APIのバージョンは v2.1、ソースコードは以下となります。
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)
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でプロバイダーとチャンネルの作成が必要です。
####カスタムメタデータ型の作成
カスタムプロバイダとハンドルに必要な情報を格納するため、カスタムメタデータ型「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)
####SFDC外部認証プロバイダの新規作成
必要な項目を入力して保存します。
※ 現時点 (2018/12/13) の最新バージョンでも英語版に切り替えないと保存時にエラーになります。
####LINEログインチャンネルCallback URLの更新・公開
LINEログインチャンネルのCallback URLをSFDC外部認証プロバイダのCallback URLに更新し、チャンネルを公開します。
##カスタマサービスサイト作成
####コミュニティの有効化と新規作成
コミュニティ設定でコミュニティの有効化にして、コミュニティを新規作成します。
####管理設定
#####メンバー設定
コミュニティワークスペースの管理 → メンバーで顧客用のプロファイルCustomer Community Userを選択済みプロファイルに追加します。
#####ログインページの認証プロバイダの設定
ログイン&登録 → ログインページ設定の「LINEV21」にチェックを入れます。
##リモートサイトの設定
LINEのAPIサイトをSFDCのリモートサイトに追加する必要です。
##ログインしてみる
#####顧客カスタマサービスサイトログイン
##さいごに
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/