2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LoginHistoryオブジェクトを使ってログインエラーを監視してみます

2
Last updated at Posted at 2025-12-13

Salesforce Advent Calendar 2025 14日目

セキュリティー強化の一環としてログイン履歴を検索するレポートを作って翌日の朝に送信するように運用してみました。

image.png

でも考えたら翌日の朝なので、タイムリーに対処できてないですね。

そこで、この履歴はLoginHistoryというオブジェクトなのでSOQLで検索してみると...

SELECT Id, LoginTime, LoginType, Status, SourceIp FROM LoginHistory WHERE Status <> 'Succes'

えええ、エラーになる。Where句ではステータスは使えない?

image.png

[object Object]: SourceIp FROM LoginHistory where Status <> 'Succes' ^ ERROR at Row:1:Column:75 field 'Status' can not be filtered in a query call

困ったねぇ。ステータスは検索条件で使えない。

image.png

レポートでは検索条件にできるんだけど...

image.png

  • Invalid Password
  • Multi-factor required
  • Success

さてどうするか?

ユーザ数は100もないから何回もログインしても指数関数的には増えないでしょう。

検索してみると... なんか少ない。23件

image.png

レポートで今日だけ抽出すると 107件

image.png

時間がJSTでなかったようです。9時間プラスします。 おおお思った通りになったね

image.png

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 + '">&nbsp;</td>' +
                '<td ' + td_style001 + '">&nbsp;</td>' +
                '<td ' + td_style001 + '">&nbsp;</td>' +
                '<td ' + td_style001 + '">&nbsp;</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);
        
    }
    
}

結果

image.png

デプロイの失敗

image.png

あああ、スケジュールJobを落としておく必要があるんですね。

System.abortJob('08eRA00000mDFbuYAG');   

何で?

image.png

微妙に本番環境でのログイン数が少なくてコードの実行箇所が少なかったようですね。

テストの時だけは強制的に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時間毎に何かあれば通知が来るので、異常が発見できて思ったより良かったです。もっと早く仕込んでいればよかったかもしれない。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?