【第197回】 Marketing Cloud アプリを不正アクセスから保護する技術
私がこれまで書いてきた記事の中で、3 つの自家製 Marketing Cloud アプリを紹介しました。詳しくは、以下のリンクをご確認ください。
このような自家製アプリは、今後も定期的に紹介していこうとは思うのですが、記事内のコードだけでは、セキュリティ対策が施されておらず、その作成した CloudPages は、Cloudpages URL を知っている不特定多数の人々にアクセスされる可能性があります。
そこで、今回の記事は「作成した Marketing Cloud アプリを不正アクセスから保護するにはどのようにすれば良いのか?」について解説します。
具体的には、Cloudpages にアクセスするには、そのアカウントでログインすることが必要になるというものになります。
今回の技術は、私のアイデアではなく、ポーランドの Salesforce MVP である Mateusz Dąbrowski(マテウシュ・ドンブロフスキ)さん の技術であり、それを今回皆さん用に、私が解析してまとめたものになります。

それでは、以下の手順で実装を開始してみましょう。
※今回の作業を始める前に、下の記事にアクセスして、「セキュリティーなし」のアプリを初めに実装してみてください。今回の説明では、ジャーニーの設定など一部の設定の説明が割愛されています。
監査ログ用データエクステンションの作成
マテウシュ さんの記事では、まず 2 つの監査ログ用のデータエクステンションを作成しています。一つは「AUTHENTICATION_DATA_EXTENSION」で、もう一つが「ERROR_DATA_EXTENSION」です。
私の方でこれらのデータエクステンションを簡単に作成できるようにスクリプトを組みましたので、以下を Automation Studio のスクリプトアクティビティに挿入して、それぞれ一回実行して下さい。
AUTHENTICATION_DATA_EXTENSION
<script runat="server">
Platform.Load("Core", "1");
var dataExtensionConfig = {
"CustomerKey": "",
"Name": "AUTHENTICATION_DATA_EXTENSION",
"Fields": [
{ "Name" : "session", "FieldType" : "Text", "MaxLength" : 50, "IsPrimaryKey" : true, "IsRequired" : true },
{ "Name" : "appName", "FieldType" : "Text", "MaxLength" : 100, "IsRequired" : false },
{ "Name" : "createdDate", "FieldType" : "Date", "IsRequired" : false },
{ "Name" : "token", "FieldType" : "Text", "MaxLength" : 520, "IsRequired" : false },
{ "Name" : "tokenExpire", "FieldType" : "Date", "IsRequired" : false },
{ "Name" : "userName", "FieldType" : "Text", "MaxLength" : 100, "IsRequired" : false },
{ "Name" : "userEmail", "FieldType" : "Text", "MaxLength" : 254, "IsRequired" : false },
]
};
var createdDataExtension = DataExtension.Add(dataExtensionConfig);
</script>
※ データエクステンション作成後に、「createdDate」のデフォルト値に「Current Date」を設定してください。
ERROR_DATA_EXTENSION
<script runat="server">
Platform.Load("Core", "1");
var dataExtensionConfig = {
"CustomerKey": "",
"Name": "ERROR_DATA_EXTENSION",
"Fields": [
{ "Name" : "id", "FieldType" : "Text", "MaxLength" : 36, "IsPrimaryKey" : true, "IsRequired" : true },
{ "Name" : "errorSource", "FieldType" : "Text", "MaxLength" : 100, "IsRequired" : false },
{ "Name" : "errorMessage", "FieldType" : "Text", "MaxLength" : 2000, "IsRequired" : false },
{ "Name" : "errorDescription", "FieldType" : "Text", "MaxLength" : 2000, "IsRequired" : false },
{ "Name" : "errorDate", "FieldType" : "Date", "IsRequired" : false },
]
};
var createdDataExtension = DataExtension.Add(dataExtensionConfig);
</script>
※ データエクステンション作成後に、「errorDate」のデフォルト値に「Current Date」を設定してください。
これにより、トップデータエクステンションフォルダに、2 つの監査ログ用データエクステンションが作成できたかと思います。

このスクリプトを使った新規データエクステンションの作成方法自体に興味がある場合は、以下の記事を参照してください。
Cloudpages URL の発行
続いて、新規で Cloudpages URL を発行します。Cloudpages の作成画面を開いたら、特に何も配置せずに「保存」してしまって良いです。現時点では Cloudpages URL が欲しいだけです。

「保存」が完了したら、Cloudpages URL をコピー してください。

インストール済みパッケージの設定
続いて、Marketing Cloud セットアップの インストール済みパッケージ に移動して、新規作成を行います。このパッケージ名は適当に決めてください。

ここからコンポーネントを 2 つ設定します。「コンポーネントの追加」をクリックしてください。

まずは、「API Integration」を選択します。

続いて、「Web App」を選択します。

続いて、先ほどコピーした Cloudpages URL を入力して「保存」します。スコープの設定は不要です。

以前は、いつでもクライアントシークレット(WebApp 用)を確認できましたが、現在は、この時点でのみクライアントシークレット(WebApp 用)が表示されるので、しっかりとメモしてください。

クライアントシークレットに続き、クライアント ID(WebApp 用)もコピーしたら、続いて、2 つのコンポーネントを作成するために、再度「コンポーネントの追加」をクリックします。

次は、「Marketing Cloud App」を選択します。

アプリの名前を登録して、2 つの Endpoint に先ほどコピーした Cloudpages URL を入力してください。同じものを入力してもらって OK です。これで設定を「保存」します。

これで、インストール済みパッケージの設定は完了です。
Cloudpages でのコード設定
それでは、最後に Cloudpages へのコード設定を行います。今回は、前回の記事で発表した以下のアプリで試します。
以下が、前回の記事の中でも記載したサンプルコードで、今のところは「セキュリティなし」の状態になります。
<h3>連絡先キーを入力して待機アクティビティをトリガー(セキュリティなし)</h3>
%%[
SET @submittedContactKey = RequestParameter('contactKey')
IF NOT EMPTY(@submittedContactKey) THEN
SET @lookupValue = Lookup("WaitUntilEvent", "Id", "Id", @submittedContactKey) // データエクステンション名、連絡先キー項目名
IF NOT EMPTY(@lookupValue) THEN
SET @apiTriggerMessage = "既に処理済みです"
ELSE
SET @apiTriggerMessage = "処理が完了しました"
ENDIF
ELSE
SET @apiTriggerMessage = "" /* 初期状態で空に設定 */
ENDIF
]%%
<script runat="server">
Platform.Load("Core", "1");
var submittedContactKey = Variable.GetValue("@submittedContactKey");
var authPayload = {
"grant_type": "client_credentials",
"client_id": "************************", // クライアントID
"client_secret": "************************" // クライアントシークレット
};
var authUrl = "https://************************.auth.marketingcloudapis.com/v2/token"; // 認証ベース URL
var authResult = HTTP.Post(authUrl, 'application/json', Stringify(authPayload));
if (authResult.StatusCode == 200) {
var authResponse = Platform.Function.ParseJSON(authResult.Response[0]);
var accessToken = authResponse.access_token;
var restUrl = authResponse.rest_instance_url;
} else {
throw new Error("アクセストークンの取得エラーが発生しました。");
}
var apiTriggerMessage = Variable.GetValue("@apiTriggerMessage");
// ContactKeyがデータエクステンションに存在しない場合のみ、API呼び出しを実行
if (submittedContactKey && apiTriggerMessage == "処理が完了しました") {
var validateContactKeyUrl = restUrl + '/interaction/v1/events';
var validateContactKeyPayload = {
"ContactKey": submittedContactKey,
"EventDefinitionKey": "APIEvent-************************", // イベント定義キー
"Data": { "id": submittedContactKey } // 連絡先キー項目名
};
var validateContactKeyHeaders = ["Authorization"];
var validateContactKeyHeaderValues = ["Bearer " + accessToken];
var validateContactKeyResult = HTTP.Post(validateContactKeyUrl, 'application/json', Stringify(validateContactKeyPayload), validateContactKeyHeaders, validateContactKeyHeaderValues);
}
</script>
<br>
<form method="post">
<label for="contactKey">連絡先キー:</label>
<input type="text" id="contactKey" name="contactKey" value="%%=v(@submittedContactKey)=%%">
<br><br>
<input type="submit">
</form>
<br>
<p>%%=v(@apiTriggerMessage)=%%</p>
<br>続いて、マテウシュ さんのコードを私が解析し、その中から特に必要な部分だけに絞り込んだものを、以下に記載します。
* A *
if (!submittedContactKey) {
var debugging = true;
var appName = '*********************'; // アプリの名前(監査DEに保存されます)
var appURL = '*********************'; // 上で発行したCloudpages URL
var clientID = '*********************'; // 上でWebApp用に作成したクライアントID
var clientSecret = '*********************'; // 上でWebApp用に作成したクライアントシークレット
var clientBase = '*********************'; // アカウントのクライアントベース
var authDE = 'AUTHENTICATION_DATA_EXTENSION';
var state = Platform.Request.GetQueryStringParameter('state');
var errorMessage = Platform.Request.GetQueryStringParameter('error');
var errorDescription = Platform.Request.GetQueryStringParameter('error_description');
var errorDE = 'ERROR_DATA_EXTENSION';
var errorURL = 'https://note.com/nobuyukiwatanabe';
function debugValue(description, value) {
// Write(description + ': ' + (typeof value == 'object' ? Stringify(value) : value) + '<br><br>');
}
function handleError(error) {
if (debugging) {
debugValue('Found error', error);
} else {
Platform.Function.InsertData(errorDE, ['id', 'appName', 'errorMessage', 'errorDescription'], [GUID(), appName, error.message, error.description]);
Platform.Response.Redirect(errorURL + '?error=' + error.message + '&error_description=' + error.description);
}
}
if (!state && !errorMessage) {
state = GUID();
Platform.Response.Redirect('https://' + clientBase + '.auth.marketingcloudapis.com/v2/authorize?response_type=code&client_id=' + clientID + '&redirect_uri=' + appURL + '&state=' + state);
} else if (state) {
var code = Platform.Request.GetQueryStringParameter('code');
var payload = {
grant_type: 'authorization_code',
code: code,
client_id: clientID,
client_secret: clientSecret,
redirect_uri: appURL
};
var response = HTTP.Post('https://' + clientBase + '.auth.marketingcloudapis.com/v2/token', 'application/json', Stringify(payload));
if (response.StatusCode == 200) {
var parsedResponse = Platform.Function.ParseJSON(response.Response[0]);
var accessToken = parsedResponse.access_token;
var tokenExpire = Platform.Function.SystemDateToLocalDate(Platform.Function.Now());
tokenExpire.setMinutes(tokenExpire.getMinutes() + 18);
response = HTTP.Get('https://' + clientBase + '.auth.marketingcloudapis.com/v2/userinfo', ['Authorization'], ['Bearer ' + accessToken]);
if (debugging) {
debugValue('UserInfo Response', response);
}
var userName = Platform.Function.ParseJSON(response.Content).user.name;
var userEmail = Platform.Function.ParseJSON(response.Content).user.email;
Platform.Function.UpsertData(authDE, ['session'], [state], ['appName', 'token', 'tokenExpire', 'userName', 'userEmail'], [appName, accessToken, tokenExpire, userName, userEmail]);
} else {
handleError({
message: 'Authentication Failed',
description: 'Status: ' + response.StatusCode
});
}
} else {
handleError({
message: errorMessage,
description: errorDescription
});
}
} else {
* B *
}
* C *< 注意点 >
上のコードの *A* の直後の if (!submittedContactKey) {… の部分は、今回用に用意された変数「submittedContactKey」を使用していますので、他のアプリの場合は、下の *A* の部分というコードの先頭行の変数名に置き換えて下さい。
私が以前に作った他の 2 つのアプリだと「メールアドレスから連絡先キーを検索する Marketing Cloud アプリ」や「List Detective を検証する Marketing Cloud アプリ」の場合は、if (!submittedEmail) {… とする感じです。
この マテウシュ さんのサンプルコードの中に「セキュリティなし」のサンプルコードを 3 分割して、それぞれ、上から *A*、*B*、*C* に挿入して行くことで、コード全体が完成する仕組みです。
「セキュリティなし」のコードの切り取り方は、以下の通りです。
*A* の部分
<h3>連絡先キーを入力して待機アクティビティをトリガー(セキュリティなし)</h3>
%%[
SET @submittedContactKey = RequestParameter('contactKey')
IF NOT EMPTY(@submittedContactKey) THEN
SET @lookupValue = Lookup("WaitUntilEvent", "Id", "Id", @submittedContactKey) // データエクステンション名、連絡先キー項目名
IF NOT EMPTY(@lookupValue) THEN
SET @apiTriggerMessage = "既に処理済みです"
ELSE
SET @apiTriggerMessage = "処理が完了しました"
ENDIF
ELSE
SET @apiTriggerMessage = "" /* 初期状態で空に設定 */
ENDIF
]%%
<script runat="server">
Platform.Load("Core", "1");
var submittedContactKey = Variable.GetValue("@submittedContactKey");*A*は <script runat="server"> の後の Variable.GetValue() の行までです。
*B* の部分
var authPayload = {
"grant_type": "client_credentials",
"client_id": "************************", // 「セキュリティなし」で使用したクライアントID
"client_secret": "************************" // 「セキュリティなし」で使用したクライアントシークレット
};
var authUrl = "https://************************.auth.marketingcloudapis.com/v2/token"; // 認証ベース URL
var authResult = HTTP.Post(authUrl, 'application/json', Stringify(authPayload));
if (authResult.StatusCode == 200) {
var authResponse = Platform.Function.ParseJSON(authResult.Response[0]);
var accessToken = authResponse.access_token;
var restUrl = authResponse.rest_instance_url;
} else {
throw new Error("アクセストークンの取得エラーが発生しました。");
}
var apiTriggerMessage = Variable.GetValue("@apiTriggerMessage");
// ContactKeyがデータエクステンションに存在しない場合のみ、API呼び出しを実行
if (submittedContactKey && apiTriggerMessage == "処理が完了しました") {
var validateContactKeyUrl = restUrl + '/interaction/v1/events';
var validateContactKeyPayload = {
"ContactKey": submittedContactKey,
"EventDefinitionKey": "APIEvent-************************", // イベント定義キー
"Data": { "id": submittedContactKey } // 連絡先キー項目名
};
var validateContactKeyHeaders = ["Authorization"];
var validateContactKeyHeaderValues = ["Bearer " + accessToken];
var validateContactKeyResult = HTTP.Post(validateContactKeyUrl, 'application/json', Stringify(validateContactKeyPayload), validateContactKeyHeaders, validateContactKeyHeaderValues);
}上のコード内のコメントで「セキュリティなし」でも使用したクライアント ID というような言い方をしているのは、先ほどのインストール済みパッケージの設定で作成した「WebApp 用に作成したクライアント ID」ではなく、前回の記事でも使用したスコープ設定などが設定されているクライアント ID を使用して欲しいためです。
*C* の部分
</script>
<br>
<form method="post">
<label for="contactKey">連絡先キー:</label>
<input type="text" id="contactKey" name="contactKey" value="%%=v(@submittedContactKey)=%%">
<br><br>
<input type="submit">
</form>
<br>
<p>%%=v(@apiTriggerMessage)=%%</p>
<br>そしてこれらを最終的に合体させたものが以下のコードです。このコードを先ほど何も設定せずに「保存」した Cloudpages に HTML ブロックとして挿入して下さい。そして、「パブリッシュ」に進みましょう。
<h3>連絡先キーを入力して待機アクティビティをトリガー(セキュリティあり)</h3>
%%[
SET @submittedContactKey = RequestParameter('contactKey')
IF NOT EMPTY(@submittedContactKey) THEN
SET @lookupValue = Lookup("WaitUntilEvent", "Id", "Id", @submittedContactKey) // データエクステンション名、連絡先キー項目名
IF NOT EMPTY(@lookupValue) THEN
SET @apiTriggerMessage = "既に処理済みです"
ELSE
SET @apiTriggerMessage = "処理が完了しました"
ENDIF
ELSE
SET @apiTriggerMessage = "" /* 初期状態で空に設定 */
ENDIF
]%%
<script runat="server">
Platform.Load("Core", "1");
var submittedContactKey = Variable.GetValue("@submittedContactKey");
if (!submittedContactKey) {
var debugging = true;
var appName = '*********************'; // アプリの名前(監査DEに保存されます)
var appURL = '*********************'; // 上で発行したCloudpages URL
var clientID = '*********************'; // 上でWebApp用に作成したクライアントID
var clientSecret = '*********************'; // 上でWebApp用に作成したクライアントシークレット
var clientBase = '*********************'; // アカウントのクライアントベース
var authDE = 'AUTHENTICATION_DATA_EXTENSION';
var state = Platform.Request.GetQueryStringParameter('state');
var errorMessage = Platform.Request.GetQueryStringParameter('error');
var errorDescription = Platform.Request.GetQueryStringParameter('error_description');
var errorDE = 'ERROR_DATA_EXTENSION';
var errorURL = 'https://note.com/nobuyukiwatanabe';
function debugValue(description, value) {
// Write(description + ': ' + (typeof value == 'object' ? Stringify(value) : value) + '<br><br>');
}
function handleError(error) {
if (debugging) {
debugValue('Found error', error);
} else {
Platform.Function.InsertData(errorDE, ['id', 'appName', 'errorMessage', 'errorDescription'], [GUID(), appName, error.message, error.description]);
Platform.Response.Redirect(errorURL + '?error=' + error.message + '&error_description=' + error.description);
}
}
if (!state && !errorMessage) {
state = GUID();
Platform.Response.Redirect('https://' + clientBase + '.auth.marketingcloudapis.com/v2/authorize?response_type=code&client_id=' + clientID + '&redirect_uri=' + appURL + '&state=' + state);
} else if (state) {
var code = Platform.Request.GetQueryStringParameter('code');
var payload = {
grant_type: 'authorization_code',
code: code,
client_id: clientID,
client_secret: clientSecret,
redirect_uri: appURL
};
var response = HTTP.Post('https://' + clientBase + '.auth.marketingcloudapis.com/v2/token', 'application/json', Stringify(payload));
if (response.StatusCode == 200) {
var parsedResponse = Platform.Function.ParseJSON(response.Response[0]);
var accessToken = parsedResponse.access_token;
var tokenExpire = Platform.Function.SystemDateToLocalDate(Platform.Function.Now());
tokenExpire.setMinutes(tokenExpire.getMinutes() + 18);
response = HTTP.Get('https://' + clientBase + '.auth.marketingcloudapis.com/v2/userinfo', ['Authorization'], ['Bearer ' + accessToken]);
if (debugging) {
debugValue('UserInfo Response', response);
}
var userName = Platform.Function.ParseJSON(response.Content).user.name;
var userEmail = Platform.Function.ParseJSON(response.Content).user.email;
Platform.Function.UpsertData(authDE, ['session'], [state], ['appName', 'token', 'tokenExpire', 'userName', 'userEmail'], [appName, accessToken, tokenExpire, userName, userEmail]);
} else {
handleError({
message: 'Authentication Failed',
description: 'Status: ' + response.StatusCode
});
}
} else {
handleError({
message: errorMessage,
description: errorDescription
});
}
} else {
var authPayload = {
"grant_type": "client_credentials",
"client_id": "************************", // 「セキュリティなし」で使用したクライアントID
"client_secret": "************************" // 「セキュリティなし」で使用したライアントシークレット
};
var authUrl = "https://************************.auth.marketingcloudapis.com/v2/token"; // 認証ベース URL
var authResult = HTTP.Post(authUrl, 'application/json', Stringify(authPayload));
if (authResult.StatusCode == 200) {
var authResponse = Platform.Function.ParseJSON(authResult.Response[0]);
var accessToken = authResponse.access_token;
var restUrl = authResponse.rest_instance_url;
} else {
throw new Error("アクセストークンの取得エラーが発生しました。");
}
var apiTriggerMessage = Variable.GetValue("@apiTriggerMessage");
// ContactKeyがデータエクステンションに存在しない場合のみ、API呼び出しを実行
if (submittedContactKey && apiTriggerMessage == "処理が完了しました") {
var validateContactKeyUrl = restUrl + '/interaction/v1/events';
var validateContactKeyPayload = {
"ContactKey": submittedContactKey,
"EventDefinitionKey": "APIEvent-************************", // イベント定義キー
"Data": { "id": submittedContactKey } // 連絡先キー項目名
};
var validateContactKeyHeaders = ["Authorization"];
var validateContactKeyHeaderValues = ["Bearer " + accessToken];
var validateContactKeyResult = HTTP.Post(validateContactKeyUrl, 'application/json', Stringify(validateContactKeyPayload), validateContactKeyHeaders, validateContactKeyHeaderValues);
}
}
</script>
<br>
<form method="post">
<label for="contactKey">連絡先キー:</label>
<input type="text" id="contactKey" name="contactKey" value="%%=v(@submittedContactKey)=%%">
<br><br>
<input type="submit">
</form>
<br>
<p>%%=v(@apiTriggerMessage)=%%</p>
<br>

このコードの中で、修正の検討が必要なものは以下の 9 つです。
・アプリの名前
・Cloudpages URL
・WebApp 用に作成したクライアント ID
・WebApp 用に作成したクライアントシークレット
・クライアントベース
・前回記事の「セキュリティなし」で使用したクライアントID
・前回記事の「セキュリティなし」で使用したクライアントシークレット
・認証ベース URL
・イベント定義キー(APIEvent-**************)
※クライアントベースとは、以下の ************************ の箇所です。
https://************************.auth.marketingcloudapis.com/v2/token
パブリッシュをクリックすると、プレビューが表示されると思いますが、「Error Message: "Redirect is not supported in the current context"」が表示されます。これはこの表示で問題ないです。セキュリティ対策を施しているので、プレビューでは確認できなくなります。

これですべての設定は完了しました。
アプリの確認
それでは、一度ログアウトをして、ブラウザに Cloudpages URL 直接入力して、作成した Cloudpages を開いてみて下さい。
パブリッシュをしても、パブリッシュした内容が、即時に反映しない場合がありますので、時間を置いてから確認してください。
以下のように、Marketing Cloud のログイン画面に遷移すれば成功です。

その後、ログインを行ってください。すると作成した Cloudpages の画面に遷移したかと思います。

メニュー内の AppExchange から該当のアプリを選択します。

下記の通り、アプリが作成されていました。

最後に、念のため、最初の方で作成した監査ログ用データエクステンションである「AUTHENTICATION_DATA_EXTENSION」を確認しますと、先ほど、Cloudpages URL 直接入力した分と、メニュー内の AppExchange から選択した分の 2 レコードがしっかりと格納されていました。

データエクステンションの中身を見ると、「どのアプリに」「誰が」「いつログインしたか」が記録されています。

すべて成功です。
いかがでしたでしょうか。
これで自家製アプリを作成しても、安全に活用することができますね。不正ログインを防止するだけでなく、監査ログの仕組みまでセットになっているのには、流石に、Salesforce MVP の仕事は素晴らしい!としか言いようがありません。
今回は以上です。
