웹 UI 스팸 방지 및 CAPTCHA 지원

GitLab 애플리케이션의 새로운 UI 영역에 스팸 방지 및 CAPTCHA 지원을 추가하는 접근 방식은 기존 코드가 구현된 방식에 따라 다릅니다.

요청 제출의 지원되는 시나리오

세 가지 다른 시나리오가 지원됩니다. 두 가지는 JavaScript XHR/Fetch 요청에 사용되며 Apollo나 Axios 중 하나를 사용하고, 다른 하나는 표준 HTML 폼 요청에만 사용됩니다:

  1. JavaScript 기반 제출(아마도 Vue를 통해)
    1. Apollo 사용 (Fetch/XHR 요청을 통한 GraphQL API)
    2. Axios 사용 (Fetch/XHR 요청을 통한 REST API)
  2. 표준 HTML 폼 제출 (HTML 요청)

구현의 일부는 지원해야 하는 시나리오에 따라 다릅니다.

JavaScript XHR/Fetch 요청에 특화된 구현 작업

두 가지 접근 방식은 완전히 지원됩니다:

  1. GraphQL API를 사용한 Apollo.
  2. GraphQL API를 사용한 Axios.

프론트엔드와 백엔드 간의 스팸 및 CAPTCHA 관련 데이터 통신은 모델에 추가 필드를 필요로 하지 않습니다. 대신에 통신은 다음과 같이 처리됩니다:

  • 요청에서 사용자 지정 헤더 값으로.
  • 응답에서 상위 수준의 JSON 필드로.

스팸 및 CAPTCHA 관련 로직은 재사용 가능한 모듈 및 도우미 메서드로 깔끔하게 추상화되어 있으며, 잠재적인 스팸이 감지되거나 CAPTCHA 표시가 필요한 경우 기존 흐름을 변경시키지 않고 기존 로직을 변경할 수 있습니다. 이 접근 방식을 통해 새로운 애플리케이션 영역에 스팸 및 CAPTCHA 지원을 추가할 때 기존 로직을 최소한으로 변경할 수 있습니다. 프론트엔드의 경우, 변경 사항이 없어도 될 수도 있습니다!

프론트엔드에서는 Vue를 위한 ApolloLink 및 Axios를 위한 Axios interceptor를 사용하여 추상적이고 투명하게 처리됩니다. CAPTCHA 표시는 표준 GitLab UI / Pajamas 모달 컴포넌트에서 처리됩니다. 관련 프론트엔드 코드는 app/assets/javascripts/captcha에서 찾을 수 있습니다.

그러나 실제 요청 가로채기 및 모달 처리가 투명하더라도, 폼이나 페이지에 사용된 JavaScript 또는 Vue 컴포넌트를 변경하지 않고는 요청이나 오류 처리에 변경이 필요할 수 있습니다. 기존 동작이 올바르게 작동하지 않을 수 있기 때문에 변경 사항이 필요합니다. 예를 들어, 실패한 또는 취소된 CAPTCHA 표시가 표준 요청 흐름이나 UI 업데이트를 중단하는 경우 등입니다. 잠재적인 문제를 발견하기 위해 신중한 탐사 테스트가 중요합니다.

이 순서도는 프론트엔드에서 JavaScript XHR/Fetch 요청의 표준 CAPTCHA 흐름을 보여줍니다:

sequenceDiagram participant U as User participant V as Vue/JS Application participant A as ApolloLink or Axios Interceptor participant G as GitLab API U->>V: 모델 저장 V->>A: 요청 A->>G: 요청 G--xA: 오류 및 스팸/CAPTCHA 관련 필드가 포함된 응답 A->>U: 모달에 CAPTCHA 표시 U->>A: 유효한 CAPTCHA 응답을 얻기 위한 CAPTCHA 해결 A->>G: 유효한 CAPTCHA 응답 및 헤더의 SpamLog ID가 포함된 요청 G-->>A: 성공 응답 A-->>V: 성공 응답

백엔드도 섞이지 않게 깔끔하게 추상화되어 있습니다. 관련된 백엔드 컨트롤러 동작에 대해 필요한 세 가지 주요 변경은 다음과 같습니다(일반적으로 create/update 중 하나):

  1. perform_spam_check: true를 Update Service 클래스 생성자에 전달합니다. Create Service에서는 기본값으로 true로 설정됩니다.
  2. 스팸 확인이 모델의 변경을 가리키는 경우:
    • 모델에 오류가 추가됩니다.
    • 모델의 needs_recaptcha 속성이 true로 설정됩니다.
  3. #with_captcha_check_json_format 도우미 메서드에 전달된 블록으로 랩핑된 기존 컨트롤러 동작 반환값(렌더링 또는 리디렉팅)을 확인하여 처리합니다. 이는 다음을 투명하게 처리합니다:
    1. CAPTCHA가 활성화되어 있는지 확인하고, 그렇다면 다음 단계 진행.
    2. 모델에 오류가 있는지, 그리고 needs_recaptcha 플래그가 true인지 확인합니다.
      • 그렇다면: JSON 응답에 적절한 스팸 또는 CAPTCHA 필드를 추가하고 409 - Conflict HTTP 상태 코드를 반환합니다.
      • 그렇지 않다면(만약 CAPTCHA가 비활성화되었거나 스팸이 감지되지 않은 경우): 블록 내에서 전달된 표준 요청 반환 로직이 실행됩니다.

추상화 덕분에 설명하는 것보다 구현이 더 간단합니다. 숨겨진 세부 사항을 크게 걱정할 필요가 없습니다!

다음 변경 사항을 수행하세요:

컨트롤러 동작에 지원 추가

기능의 프론트엔드가 컨트롤러 동작에 직접 제출하고 GraphQL API만 사용하는 것이 아니라면, 해당 컨트롤러에 지원을 추가해야 합니다.

이 프로젝트는 해당 컨트롤러에 직접 수정할 때와 차이가 있으며, 우리의 예제는 모듈로 추상화되어 있습니다. 컨트롤러를 직접 수정할 때의 유일한 차이점은 extend ActiveSupport::Concern가 필요하지 않습니다.

module WidgetsActions
  # 참고: 이 `extend`는 이미 있는 것일 수 있지만, 모든 `include` 문 전에 발생하도록 이동해야 합니다.
  # 그렇지 않으면, 포함된 모듈에서 메서드를 찾을 수 없는 혼란스러운 버그가 발생할 수 있습니다.
  extend ActiveSupport::Concern
  
  include SpammableActions::CaptchaCheck::JsonFormatActionsSupport
  
  def create
    widget = ::Widgets::CreateService.new(
      project: project,
      current_user: current_user,
      params: params
    ).execute
    
    respond_to do |format|
      format.json do
        with_captcha_check_json_format do
          # 동작의 기존 `render json: ...` (또는 래퍼 메서드) 및 관련 로직을 여기에 전부 래핑되어 있습니다.
          # 예를 들어:
          if widget.valid?
            render json: serializer.represent(widget)
          else
            render json: { errors: widget.errors.full_messages }, status: :unprocessable_entity
          end
        end
      end
    end
  end
end

HTML 폼 요청에 특화된 구현 작업

일부 영역은 JavaScript 클라이언트를 통해 GraphQL API를 사용하는 대신, 표준 Rails HAML 폼 제출에 의존하는 영역입니다. 이러한 경우, 동작은 응답 본문으로 사전 렌더링된 HTML (HAML) 페이지를 반환합니다. 이 경우에는 위에서 설명한 JavaScript 기반 프론트엔드 지원을 사용할 수 없습니다. 대신 HAML 템플릿을 통해 CAPTCHA 양식의 렌더링을 처리해야 합니다.

모든 것은 여전히 깔끔하게 추상화되어 있으며, 백엔드 컨트롤러의 구현은 거의 JavaScript/JSON 기반 접근 방식과 동일합니다. 모듈 이름과 도우미 메서드에서 단어 JSONHTML로 바꿔야 합니다(적절한 경우에 대문자로).

동작 메서드는 컨트롤러에 직접 있을 수도 있고, 모듈에 있을 수도 있습니다. 이 예에서는 컨트롤러에 직접 있으며 create 대신에 update 메서드를 사용합니다:

class WidgetsController < ApplicationController
  include SpammableActions::CaptchaCheck::HtmlFormatActionsSupport
  
  def update
    # `widget` 모델 인스턴스를 찾기 위한 기존 로직...
    ::Widgets::UpdateService.new(
      project: project,
      current_user: current_user,
      params: params,
      perform_spam_check: true
    ).execute(widget)
    
    respond_to do |format|
      format.html do
        if widget.valid?
          # 참고: `spammable_path`는 `SpammableActions::AkismetMarkAsSpamAction` 모듈에서 필요하며,
          # 위에서의 지시에 따라 이미 이 컨트롤러에 구현되어 있어야 합니다. 중복된 경로 도우미 호출을 피하기 위해 여기서 재사용됩니다.
          redirect_to spammable_path
        else
          # 여기까지 왔다면 모델 인스턴스에 오류가 있었으며(스팸 확인에 실패한 경우와/또는 모델의 다른 유효성 검사 오류에서),
          # 우리는 양식을 다시 렌더링할 것이며, 만약 CAPTCHA 렌더링이 필요하다면, `with_captcha_check_html_format`에서 자동으로 처리됩니다.
          with_captcha_check_html_format { render :edit }
        end
      end
    end
  end
end