Salesforce Advent Calendar 2025 14日目
セキュリティー強化の一環としてログイン履歴を検索するレポートを作って翌日の朝に送信するように運用してみました。
でも考えたら翌日の朝なので、タイムリーに対処できてないですね。
そこで、この履歴はLoginHistoryというオブジェクトなのでSOQLで検索してみると...
SELECT Id, LoginTime, LoginType, Status, SourceIp FROM LoginHistory WHERE Status <> 'Succes'
えええ、エラーになる。Where句ではステータスは使えない?
[object Object]: SourceIp FROM LoginHistory where Status <> 'Succes' ^ ERROR at Row:1:Column:75 field 'Status' can not be filtered in a query call
困ったねぇ。ステータスは検索条件で使えない。
レポートでは検索条件にできるんだけど...
- Invalid Password
- Multi-factor required
- Success
さてどうするか?
ユーザ数は100もないから何回もログインしても指数関数的には増えないでしょう。
検索してみると... なんか少ない。23件
レポートで今日だけ抽出すると 107件
時間がJSTでなかったようです。9時間プラスします。 おおお思った通りになったね
SELECT Id, LoginTime, LoginType, Status, SourceIp FROM LoginHistory where LoginTime > 2025-11-11T00:00:00.000+0900
それに今回は定時の間だけ1時間おきにスケジュールしたJobを流せればいいので、そんなにはレコード件数はないはず。List変数をループに回して好ましくないステータスのレコードだけをループでチェックしても問題ないでしょう。
定時外で不正にアクセスされたものは今まで通りのレポートで対処しよう。
ということでスケジュールできるApexのコードを書く方針で進めます。
実際のコード
運用では1時間ごとに過去1時間に何かしらのエラーになったログイン履歴をメールに送信することにします。
chkLoginHistory.apex
public with sharing class chkLoginHistory implements Schedulable {
// ***********************************************************************************
// LoginHistoryを監視する Apex
// K.Otsubo 2025/11/11
// https://developer.salesforce.com/docs/atlas.ja-jp.252.0.apexcode.meta/apexcode/apex_methods_system_datetime.htm#apex_System_Datetime_addHours
// ***********************************************************************************
private static Date myDate = Date.Today();
private static Integer i_year = myDate.year() + 1;
private static String s_year = String.valueOf(i_year);
public static String CRON_EXP = '0 0 0 3 9 ? ' + s_year;//ここが過去日だとエラーになる
public void execute(SchedulableContext SC) {
Boolean isSend = false;
//ここにスケジュールしたいものを書く
Map<String,Object> RetMap001 = main_rtn();
}
/**
* 検索
*/
@AuraEnabled
public static List<LoginHistory> getLoginHistory() {
List<LoginHistory> RetList = new List<LoginHistory>();
datetime myDateTime = datetime.now();
//myDateTime = myDateTime.addHours(9);
//myDateTime = myDateTime;
//system.debug('---------myDateTime-------------------->' + myDateTime);
string year = String.valueOf(myDateTime.year());
string month = String.valueOf(myDateTime.month());
string day = String.valueOf(myDateTime.day());
if (Test.isRunningTest()) day='10';//test用
string hour = String.valueOf(myDateTime.hour());
string minute = '00';
string second = '00.000';
string stringDate = year + '-' + month + '-' + day + ' ' + hour + ':'
+ minute + ':' + second;
//Datetime xxx = Datetime.valueOf('2025-11-01 00:00:00.000');
Datetime xxx = Datetime.valueOf(stringDate);
//system.debug('---------変換前xxx-------------------->' + xxx);
xxx = xxx.addHours(-1);
//system.debug('---------検索に使うxxx-------------------->' + xxx);
//Datetime xxx = DateTime.newInstance(2025, 11, 01, 0, 0, 0);
List<LoginHistory> LHList = [SELECT Id, LoginTime, LoginType, Status, SourceIp,UserId FROM LoginHistory WHERE LoginTime > :xxx];
for (LoginHistory LH : LHList){
if (LH.Status =='Success'){
if (Test.isRunningTest()) RetList.add(LH);//test 用
//RetList.add(LH);//test 用
} else if (LH.Status =='Multi-factor required' ){
} else {
RetList.add(LH);
}
}
return RetList;
}
@AuraEnabled
public static Map<String,Object> main_rtn() {
Map<String,Object> RetMap = new Map<String,Object>();
try {
boolean isSend = false;
List<LoginHistory> errHistoryList = getLoginHistory();
//system.debug('----------------------------->' + errHistoryList.size());
if (errHistoryList.size() > 0) {
isSend = true;
} else {
//system.debug('-----------------------------> エラーの対象なし' );
return RetMap;
}
//共通の設定
String common_style1 ='style="border:1px solid rgb(216,221,230);padding:4px;line-height:1.25;max-width:300px;white-space:normal;font-weight:400;';
String td_style001 =common_style1 + 'vertical-align:top;color:rgb(22,50,92);empty-cells:show;vertical-align:top"';
String td_style002 =common_style1 + 'vertical-align:top;text-align:left;vertical-align:bottom';
String td_style003 ='style="white-space:nowrap;text-align:right;border:1px solid rgb(216,221,230);padding:4px;line-height:1.25;max-width:300px;white-space:normal;font-weight:400;vertical-align:top;color:rgb(22,50,92);empty-cells:show;vertical-align:top"';
String stHtml = '<table style="border-collapse:collapse;font-size:12px;font-weight:normal;border-spacing:0px;background-color:rgb(255,255,255);width:100%">';
stHtml= stHtml + '<tbody>' +
'<tr style="background-color:rgb(244,246,249);color:rgb(84,105,141)">' +
'<th scope="col" ' + td_style002 + '">' +
'<div style="padding-right:0px"><span>LoginTime</span><span">↑</span></div></th>' +
'<th scope="col" ' + td_style002 + '">' +
'<div style="padding-right:0px"><span>LoginType</span></div></th>' +
'<th scope="col" ' + td_style002 + '">' +
'<div style="padding-right:0px"><span>Status</span></div></th>' +
'<th scope="col" ' + td_style002 + '">' +
'<div style="padding-right:0px"><span"> SourceIp</span></div></th>' +
'<th scope="col" ' + td_style002 + '">' +
'<div style="padding-right:0px"><span>ユーザ</span></div></th>' +
'</tr>';
Set<String> userIdset = new Set<String>();
for (LoginHistory p : errHistoryList) {
userIdset.add(p.UserId );
}//END OF FOR
List<User> UserList =[SELECT Id,Name FROM User WHERE Id =: userIdset];
Map<String,User> userMap = new Map<String,User>();
for (user u: UserList){
userMap.put(u.Id,u);
}
for (LoginHistory p : errHistoryList) {
//sum_amount_estimate = sum_amount_estimate + p.amount_estimate__c;
User u = userMap.get(p.UserId);
String u_name ='';
if (u != null ) u_name = u.Name;
stHtml= stHtml + '<tr>' +
'<td ' + td_style001 + '>' + p.LoginTime.format() + '</td>' +
'<td ' + td_style001 + '>' + p.LoginType + '</td>' +
'<td ' + td_style001 + '>' + p.Status + '</td>' +
'<td ' + td_style001 + '>' + p.SourceIp + '</td>' +
'<td ' + td_style001 + '>' + u_name + '</td>' +
'</tr>';
}//END OF FOR
stHtml= stHtml + '<tr style="background-color:rgb(240,248,252)">' +
'<td ' + td_style001 + '">合計<br><span style="font-size:12px;color:rgb(84,105,141);line-height:1.5;display:block">(' + errHistoryList.size() + ' 件のレコード)</span></td>' +
'<td ' + td_style001 + '"> </td>' +
'<td ' + td_style001 + '"> </td>' +
'<td ' + td_style001 + '"> </td>' +
'<td ' + td_style001 + '"> </td>' +
'</tr>';
stHtml= stHtml + '</tbody>';
stHtml= stHtml + '</table>';
string toAddresses= 'xxxx@example.com';//送信先のアドレス
List<string> toAddressesList = new List<string>();
toAddressesList.add(toAddresses);
Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
EmailService service = new EmailService(email);
service.body = stHtml;
service.isHtml=true;
service.toAddresses=toAddressesList;
service.displayName='ログイン履歴の監視';
service.subject='Login History Error レポート';//件名
if (isSend) service.sendMail();
} catch(Exception ex) {
RetMap.put('ErrorMsg' , ex.getMessage());
system.debug(ex.getMessage());
}
return RetMap;
}
}
chkLoginHistory_test.apex
@istest
public class chkLoginHistory_test {
// ***********************************************************************************
// LoginHistoryを監視する Apex test
// K.Otsubo 2025/11/11
//
// ***********************************************************************************
static testmethod void test() {
User u = fkd_User2.createTestUser();
Test.startTest();
// 作成したユーザで処理を実行
System.runAs(u){
Date myDate = Date.TODAY();
// Schedule the test job
String jobId = System.schedule('testBasicScheduledApex',chkLoginHistory.CRON_EXP, new chkLoginHistory());
// Get the information from the CronTrigger API object
CronTrigger ct = [SELECT Id, CronExpression, TimesTriggered,
NextFireTime
FROM CronTrigger WHERE id = :jobId];
// Verify the expressions are the same
System.assertEquals(chkLoginHistory.CRON_EXP,ct.CronExpression);
// Verify the job has not run
System.assertEquals(0, ct.TimesTriggered);
Date myDate2 = Date.Today();
Integer i_year = myDate2.year() + 1;
String s_year = String.valueOf(i_year);
String CRON_EXP = s_year + '-09-03 00:00:00';//ここが過去日だとエラーになる
// Verify the next time the job will run
System.assertEquals(CRON_EXP,String.valueOf(ct.NextFireTime));
//System.assertNotEquals('testScheduledApexFromTestMethodUpdated',
// [SELECT id, name FROM account WHERE id = :a.id].name);
}
Test.stopTest();
//System.assertEquals('testScheduledApexFromTestMethodUpdated',
// [SELECT Id, Name FROM Account WHERE Id = :a.Id].Name);
}
}
結果
デプロイの失敗
あああ、スケジュールJobを落としておく必要があるんですね。
System.abortJob('08eRA00000mDFbuYAG');
何で?
微妙に本番環境でのログイン数が少なくてコードの実行箇所が少なかったようですね。
テストの時だけは強制的に1時間前ではなく、10日からのログイン履歴を拾えるようにする。
if (Test.isRunningTest()) day='10';//test用
1時間おきに流す
00を避けて毎時02分にしておきます
chkLoginHistory s = new chkLoginHistory();
String sch = '0 02 * * * ?';
String jobID = System.schedule('chkLoginHistory', sch, s);
起動できたかを確かめます
SELECT Id,TimesTriggered, NextFireTime,CronExpression,State,CronJobDetail.Name,CronJobDetail.jobType FROM CronTrigger
運用してみた結果
思ったよりパスワードを間違っているユーザが多いということがわかりました。1時間毎に何かあれば通知が来るので、異常が発見できて思ったより良かったです。もっと早く仕込んでいればよかったかもしれない。









