基於flink的電商使用者行為資料分析【4】| 惡意登入監控

Alice菌發表於2020-11-28

前言

        在上一期內容中,菌哥已經為大家介紹了實時熱門商品統計模組的功能開發的過程(?基於flink的電商使用者行為資料分析【3】| 實時流量統計)。本期文章,我們需要學習的是惡意登入監控模組功能的開發過程。

在這裡插入圖片描述

模組建立和資料準備

        繼續在UserBehaviorAnalysis下新建一個 maven module作為子專案,命名為LoginFailDetect。在這個子模組中,我們將會用到flink的CEP庫來實現事件流的模式匹配,所以需要在pom檔案中引入CEP的相關依賴:

<dependency>
        <groupId>org.apache.flink</groupId>
<artifactId>flink-cep-scala_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

        同樣,在src/main/目錄下,將預設原始檔目錄java改名為scala。

        

程式碼實現

        對於網站而言,使用者登入並不是頻繁的業務操作。如果一個使用者短時間內頻繁登入失敗,就有可能是出現了程式的惡意攻擊,比如密碼暴力破解。因此我們考慮,應該對使用者的登入失敗動作進行統計,具體來說,如果同一使用者(可以是不同IP)在2秒之內連續兩次登入失敗,就認為存在惡意登入的風險,輸出相關的資訊進行報警提示。這是電商網站、也是幾乎所有網站風控的基本一環。

        所以我們可以思考一下解決方案:

  • 基本需求
    – 使用者在短時間內頻繁登入失敗,有程式惡意攻擊的可能
    – 同一使用者(可以是不同IP)在2秒內連續兩次登入失敗,需要報警

  • 解決思路
    – 將使用者的登入失敗行為存入 ListState,設定定時器2秒後觸發,檢視 ListState 中有幾次失敗登入
    – 更加準確的檢測,可以使用 CEP 庫實現事件流的模式匹配

        既然現在思路清楚了,那我們就嘗試將方案落地。

狀態程式設計

        由於同樣引入了時間,我們可以想到,最簡單的方法其實與之前的熱門統計類似,只需要按照使用者ID分流,然後遇到登入失敗的事件時將其儲存在ListState中,然後設定一個定時器,2秒後觸發。定時器觸發時檢查狀態中的登入失敗事件個數,如果大於等於2,那麼就輸出報警資訊

        在src/main/scala下建立LoginFail.scala檔案,新建一個單例物件。定義樣例類LoginEvent,這是輸入的登入事件流。登入資料本應該從UserBehavior日誌裡提取,由於UserBehavior.csv中沒有做相關埋點,我們從另一個檔案LoginLog.csv中讀取登入資料。

LoginLog.csv

        程式碼如下:

object LoginFailOne {

  // 輸入的登入事件樣例類
  case class LoginEvent( userId:Long,ip:String,eventType:String,eventTime:Long)

  // 輸出的報警資訊樣例類
  case class Warning( userId:Long,firstFailTime:Long,lastFailTime:Long,warningMsg:String)

  def main(args: Array[String]): Unit = {

    // 建立流環境
    val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    // 設定並行度
    env.setParallelism(1)
    // 設定時間特徵為事件時間
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

    // 讀取csv檔案
    env.readTextFile("G:\\LoginLog.csv")
       .map(data => {
          // 將檔案中的資料封裝成樣例類
          val dataArray: Array[String] = data.split(",")
          LoginEvent(dataArray(0).toLong, dataArray(1), dataArray(2), dataArray(3).toLong)
        })
        // 設定 WaterMark 水印
      .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[LoginEvent](Time.seconds(5)) {
        override def extractTimestamp(element: LoginEvent): Long = element.eventTime * 1000
      })
      // 以使用者id為key,進行分組
      .keyBy(_.userId)
      // 計算出同一個使用者2秒內連續登入失敗超過2次的報警資訊
      .process(new LoginWarning(2))
      .print()

    //  執行程式
    env.execute("login fail job")


  }

  // 自定義處理函式,保留上一次登入失敗的事件,並可以註冊定時器    [鍵的型別,輸入元素的型別,輸出元素的型別]
  class LoginWarning(maxFailTimes:Int) extends KeyedProcessFunction[Long,LoginEvent,Warning]{

    // 定義  儲存登入失敗事件的狀態
    lazy val loginFailState: ListState[LoginEvent] = getRuntimeContext.getListState( new ListStateDescriptor[LoginEvent]("loginfail-state", classOf[LoginEvent]) )

    override def processElement(value: LoginEvent, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#Context, out: Collector[Warning]): Unit = {

      // 判斷當前登入狀態是否為 fail
      if (value.eventType == "fail"){
        // 判斷存放失敗事件的state是否有值,沒有值則建立一個2秒後的定時器
        if (!loginFailState.get().iterator().hasNext){
          // 註冊一個定時器,設定在 2秒 之後
          ctx.timerService().registerEventTimeTimer((value.eventTime + 2) * 1000L)
        }
        // 把新的失敗事件新增到  state
        loginFailState.add(value)
      }else{
        // 如果登入成功,清空狀態重新開始
        loginFailState.clear()
      }
    }

    override def onTimer(timestamp: Long, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#OnTimerContext, out: Collector[Warning]): Unit = {
      // 觸發定時器的時候,根據狀態的失敗個數決定是否輸出報警
      val allLoginFailEvents: ListBuffer[LoginEvent] = new ListBuffer[LoginEvent]()

      val iter: util.Iterator[LoginEvent] = loginFailState.get().iterator()

      // 遍歷狀態中的資料,將資料存放至 ListBuffer
      while ( iter.hasNext ){
        allLoginFailEvents += iter.next()
        }

      //判斷登入失敗事件個數,如果大於等於 maxFailTimes ,輸出報警資訊
      if (allLoginFailEvents.length >= maxFailTimes){
        out.collect(Warning(allLoginFailEvents.head.userId,
          allLoginFailEvents.head.eventTime,
          allLoginFailEvents.last.eventTime,
          "在2秒之內連續登入失敗" + allLoginFailEvents.length + "次"))
      }

      // 清空狀態
      loginFailState.clear()
    }
  }
}

程式執行結果:
在這裡插入圖片描述
我們可以到LoginLog.csv來驗證結果
在這裡插入圖片描述
貌似看到這裡感覺我們的程式寫的沒有錯,事實真的是這樣的嗎?
在這裡插入圖片描述
那好,現在我改一個資料,把1558430844秒的登入狀態改成success
在這裡插入圖片描述
然後重新執行一下程式,看看會發生什麼?
在這裡插入圖片描述
在這裡插入圖片描述
我了個乖乖,什麼情況,現在連結果都沒了?

仔細看程式碼,才發現我們的思路是沒錯的,但是還是有 邏輯Bug !
在這裡插入圖片描述

不管一個使用者之前連續登入失敗多少次,只要中間成功一次,之前的記錄就被清空了!

在這裡插入圖片描述

狀態程式設計的改進

        上一節的程式碼實現中我們可以看到,直接把每次登入失敗的資料存起來、設定定時器一段時間後再讀取,這種做法儘管簡單,但和我們開始的需求還是略有差異的。這種做法只能隔2秒之後去判斷一下這期間是否有多次失敗登入,而不是在一次登入失敗之後、再一次登入失敗時就立刻報警。這個需求如果嚴格實現起來,相當於要判斷任意緊鄰的事件,是否符合某種模式。

        於是我們可以想到,這個需求其實可以不用定時器觸發,直接在狀態中存取上一次登入失敗的事件,每次都做判斷和比對,就可以實現最初的需求。

        上節的程式碼MatchFunction中刪掉onTimer,processElement改為:

 // 自定義處理函式,保留上一次登入失敗的事件    [鍵的型別,輸入元素的型別,輸出元素的型別]
  class LoginWarning(maxFailTimes:Int) extends KeyedProcessFunction[Long, LoginEvent, Warning] {

    // 定義  儲存登入失敗事件的狀態
    lazy val loginFailState: ListState[LoginEvent] = getRuntimeContext.getListState(new ListStateDescriptor[LoginEvent]("loginfail-state", classOf[LoginEvent]))

    override def processElement(value: LoginEvent, ctx: KeyedProcessFunction[Long, LoginEvent, Warning]#Context, out: Collector[Warning]): Unit = {
      // 首先按照type做篩選,如果success直接清空,如果fail再做處理
      if(value.eventType == "fail"){
        // 先獲取之前失敗的事件
        val iter: util.Iterator[LoginEvent] = loginFailState.get().iterator()
        if (iter.hasNext){
          // 如果之前已經有失敗的事件,就做判斷,如果沒有就把當前失敗事件儲存進state
          val firstFailEvent: LoginEvent = iter.next()
          // 判斷兩次失敗事件間隔小於2秒,輸出報警資訊
          if (value.eventTime < firstFailEvent.eventTime + 2){
            out.collect(Warning( value.userId,firstFailEvent.eventTime,value.eventTime,"在2秒內連續兩次登入失敗。"))
          }

          // 更新最近一次的登入失敗事件,儲存在狀態裡
          loginFailState.clear()
          loginFailState.add(value)

        }else{
          // 如果是第一次登入失敗,之前把當前記錄 儲存至 state
          loginFailState.add(value)
        }
      }else{
        // 當前登入狀態 不為 fail,則直接清除狀態
        loginFailState.clear()
      }
    }
  }
  }

這次我們基於上述已經修改過的LoginLog.csv檔案,重新執行程式,發現此時是有結果的。
在這裡插入圖片描述
那現在的程式還會有Bug嗎?
在這裡插入圖片描述
        當然還有會,例如我們去掉了定時器,如果執行過程中資料處理亂序,同一個使用者每次登入失敗的時間相差距離過大,可能很長一段時間都不會有該使用者的報警資訊。當然,還有其他的問題,我們放在下面一小節來說!

CEP程式設計

        上一節我們通過對狀態程式設計的改進,去掉了定時器,在process function中做了更多的邏輯處理,實現了最初的需求。不過這種方法裡有很多的條件判斷,而我們目前僅僅實現的是檢測“連續2次登入失敗”,這是最簡單的情形。如果需要檢測更多次,內部邏輯顯然會變得非常複雜。那有什麼方式可以方便地實現呢?

        很幸運,flink為我們提供了CEP(Complex Event Processing,複雜事件處理)庫,用於在流中篩選符合某種複雜模式的事件

        為了擔心小夥伴們對於 CEP 這個 “新事物”感到陌生,我們先來補一補CEP的內容!

在這裡插入圖片描述

什麼是複雜事件處理CEP

  • 複雜事件處理(Complex Event Processing,CEP)
  • Flink CEP是在 Flink 中實現的複雜事件處理(CEP)庫
  • CEP 允許在無休止的事件流中檢測事件模式,讓我們有機會掌握資料中重要的部分
  • 一個或多個由簡單事件構成的事件流通過一定的規則匹配,然後輸出使用者想得到的資料 —— 滿足規則的複雜事件

CEP特點

        如果我們想從一堆圖形中找到符合預期的結果,就可以根據某個規則去進行匹配,如下圖所示:
在這裡插入圖片描述

  • 目標:從有序的簡單事件流中發現一些高階特徵
  • 輸入:一個或多個由簡單事件構成的事件流
  • 處理:識別簡單事件之間的內在聯絡,多個符合一定規則的簡單事件構成複雜事件
  • 輸出:滿足規則的複雜事件

Pattern API

  • 處理事件的規則,被叫做“模式”(Pattern)
  • Flink CEP 提供了 Pattern API,用於對輸入流資料進行復雜事件規則定義,用來提取符合規則的事件序列
    在這裡插入圖片描述
  • 個體模式(Individual Patterns)
    – 組成複雜規則的每一個單獨的模式定義,就是“個體模式”
    在這裡插入圖片描述
  • 組合模式(Combining Patterns,也叫模式序列)
    – 很多個體模式組合起來,就形成了整個的模式序列
    – 模式序列必須以一個“初始模式”開始:
    在這裡插入圖片描述
  • 模式組(Groups of patterns)
    – 將一個模式序列作為條件巢狀在個體模式裡,成為一組模式

個體模式(Individual Patterns)

  • 個體模式可以包括“單例(singleton)模式”和“迴圈(looping)模式”
  • 單例模式只接收一個事件,而迴圈模式可以接收多個

★ 量詞(Quantifier)

  • 可以在一個個體模式後追加量詞,也就是指定迴圈次數
    在這裡插入圖片描述

個體模式的條件

★ 條件(Condition)
– 每個模式都需要指定觸發條件,作為模式是否接受事件進入的判斷依據
– CEP 中的個體模式主要通過呼叫 .where() .or().until()來指定條件
– 按不同的呼叫方式,可以分成以下幾類


★簡單條件(Simple Condition)
– 通過 .where()方法對事件中的欄位進行判斷篩選,決定是否接受該事件
在這裡插入圖片描述
★組合條件(Combining Condition)
– 將簡單條件進行合併;.or() 方法表示或邏輯相連,where的直接組合就是 AND
在這裡插入圖片描述
★ 終止條件(Stop Condition)
– 如果使用了 oneOrMore 或者 oneOrMore.optional,建議使用 .until()作為終止條件,以便清理狀態


★ 迭代條件(Iterative Condition)
– 能夠對模式之前所有接收的事件進行處理
– 呼叫.where( (value, ctx) => {...} ),可以呼叫 ctx.getEventsForPattern(“name”)
提示: name可以是當前個體模式的名稱,這個方法可以將之前匹配好的事件從狀態中都拿出來,再做具體的判斷,使用。一般在比較複雜的場景才會用到。

模式序列

  • 不同的“近鄰”模式
    在這裡插入圖片描述
  • 嚴格近鄰(Strict Contiguity)
    – 所有事件按照嚴格的順序出現,中間沒有任何不匹配的事件,由 .next() 指定
    – 例如對於模式a next b,事件序列 [a, c, b1, b2]沒有匹配
  • 寬鬆近鄰( Relaxed Contiguity )
    – 允許中間出現不匹配的事件,由 .followedBy() 指定
    – 例如對於模式a followedBy b,事件序列[a, c, b1, b2] 匹配為 {a, b1}
  • 非確定性寬鬆近鄰( Non-Deterministic Relaxed Contiguity )
    – 進一步放寬條件,之前已經匹配過的事件也可以再次使用,由 .followedByAny() 指定
    – 例如對於模式a followedByAny b,事件序列 [a, c, b1, b2] 匹配為{a, b1},{a, b2}

  • 除以上模式序列外,還可以定義“不希望出現某種近鄰關係”:
    .notNext() —— 不想讓某個事件嚴格緊鄰前一個事件發生
    .notFollowedBy() —— 不想讓某個事件在兩個事件之間發生
  • 需要注意:
    – 所有模式序列必須以 .begin() 開始
    – 模式序列不能以 .notFollowedBy() 結束
    “not” 型別的模式不能被 optional 所修飾
    – 此外,還可以為模式指定時間約束,用來要求在多長時間內匹配有效
    在這裡插入圖片描述

模式的檢測

  • 指定要查詢的模式序列後,就可以將其應用於輸入流以檢測潛在匹配
  • 呼叫 CEP.pattern(),給定輸入流和模式,就能得到一個 PatternStream
    在這裡插入圖片描述

匹配事件的提取

  • 建立 PatternStream 之後,就可以應用select或者 flatselect方法,從檢測到的事件序列中提取事件了
  • select() 方法需要輸入一個 select function 作為引數,每個成功匹配的事件序列都會呼叫它
  • select() 以一個 Map[String,Iterable [IN]]來接收匹配到的事件序列,其中 key 就是每個模式的名稱,而 value 就是所有接收到的事件的 Iterable 型別
    在這裡插入圖片描述

超時事件的提取

  • 當一個模式通過 within 關鍵字定義了檢測視窗時間時,部分事件序列可能因為超過視窗長度而被丟棄;為了能夠處理這些超時的部分匹配,selectflatSelect API 呼叫允許指定超時處理程式。
  • 超時處理程式會接收到目前為止由模式匹配到的所有事件,由一個 OutputTag定義接收到的超時事件序列。
    在這裡插入圖片描述

        接下來我們就需要基於CEP來完成這個模組的實現。

在這裡插入圖片描述

        相關的pom檔案我們已經在最開始的時候到匯入了,現在在src/main/scala下繼續建立LoginFailWithCep.scala檔案,新建一個單例物件。樣例類LoginEvent由於在LoginFail.scala已經定義,我們在同一個模組中就不需要再定義。

        具體程式碼如下:

object LoginFailWithCep {
  // 輸入的登入事件樣例類
  case class LoginEvent(userId: Long, ip: String, eventType: String, eventTime: Long)

  // 輸出的報警資訊樣例類
  case class Warning(userId: Long, firstFailTime: Long, lastFailTime: Long, warningMsg: String)

  def main(args: Array[String]): Unit = {
    
    // 1、建立流環境
    val env: StreamExecutionEnvironment = StreamExecutionEnvironment.getExecutionEnvironment
    // 設定並行度
    env.setParallelism(1)
    // 設定時間特徵為事件時間
    env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime)

    // 構建資料
    val loginEventStream: KeyedStream[LoginEvent, Long] = env.readTextFile("G:\\idea arc\\BIGDATA\\project\\src\\main\\resources\\LoginLog.csv")
      .map(data => {
        // 將檔案中的資料封裝成樣例類
        val dataArray: Array[String] = data.split(",")
        LoginEvent(dataArray(0).toLong, dataArray(1), dataArray(2), dataArray(3).toLong)
      })
      // 設定水印,防止資料亂序
      .assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor[LoginEvent](Time.seconds(3)) {
        override def extractTimestamp(element: LoginEvent): Long = element.eventTime * 1000
      })
      // 以使用者id為key,進行分組
      .keyBy(_.userId)

    // 定義匹配的模式
    val loginFailPattern: Pattern[LoginEvent, LoginEvent] = Pattern.begin[LoginEvent]("begin")
      .where(_.eventType == "fail")
      .next("next")
      .where(_.eventType == "fail")
      .within(Time.seconds(2))    // 通過 within 關鍵字定義了檢測視窗時間時間

    // 將 pattern 應用到 輸入流 上,得到一個 pattern stream
    val patternStream: PatternStream[LoginEvent] = CEP.pattern(loginEventStream,loginFailPattern)

    // 用 select 方法檢出 符合模式的事件序列
    val loginFailDataStream: DataStream[Warning] = patternStream.select(new LoginFailMatch())

    // 將匹配到的符合條件的事件列印出來
    loginFailDataStream.print("warning")
    
    // 執行程式
    env.execute("login fail with cep job")

  }

  // 自定義 pattern select function
  // 當檢測到定義好的模式序列時就會呼叫,輸出報警資訊
  class LoginFailMatch() extends PatternSelectFunction[LoginEvent,Warning]{

    override def select(map: util.Map[String, util.List[LoginEvent]]): Warning = {
      // 從 map 中可以按照模式的名稱提取對應的登入失敗事件
      val firstFail: LoginEvent = map.get("begin").iterator().next()
      val secondFail: LoginEvent = map.get("next").iterator().next()
         
      Warning( firstFail.userId,firstFail.eventTime,secondFail.eventTime,"在2秒內連續2次登入失敗。")
    }
  }
}

執行結果:
在這裡插入圖片描述
可以發現也是符合我們預期的效果~

在這裡插入圖片描述

小結

        本期關於介紹惡意登入監控功能開發的文章肝了筆者近五個小時的時間,期望受益的朋友們能來發一鍵三連,多多支援一下作者。在上一期,我們介紹實時流量統計模組中,只介紹了基於伺服器log的熱門頁面瀏覽量統計,下一期我們將介紹基於埋點日誌資料的網路流量統計,分別介紹網站總瀏覽量(PV)的統計網站獨立訪客數(UV)的統計還有使用到使用布隆過濾器的UV統計,感興趣的朋友們可以關注加星標,第一時間獲取每日的大資料乾貨哦~你知道的越多,你不知道的也越多,我是Alice,我們下一期見!

         受益的朋友記得三連支援小菌!

文章持續更新,可以微信搜一搜「 猿人菌 」第一時間閱讀,思維導圖,大資料書籍,大資料高頻面試題,海量一線大廠面經…期待您的關注!

相關文章