[ Spring Web ] 쉬운 Captcha 적용법 (feat. Hutool)

식빵·2024년 6월 6일
0

Spring Lab

목록 보기
34/34

작성 계기

안물안궁 하시는 분들은 바로 다음 목차로 가시면 됩니다!
중요한 내용 없습니다 😅

이번에 회사에서 사이트 기능 중에서 모의해킹 : 자동화 공격 과 관련된
문제가 제기되어서 이를 막는 작업을 했습니다.

자동화 공격은 주로 시스템(또는 DB)에 데이터가 무작위로 증가시키는 요청을
막는 것이 핵심입니다. HTTP 요청으로는 일반적으로 POST 요청에 해당하는 것들이죠.

모의해킹을 해주신 업체에서는 이에 대한 대처 방안으로 3가지를 제시했는데요.

  1. CSRF 토큰
  2. Catpcha
  3. 특정 IP(또는 사용자)의 요청을 일정시간 막기

이전에 3. 특정 IP(또는 사용자)의 요청을 일정시간 막기 와 관련해서 이미 방법을 공유
한적이 있었습니다. 실제로 동작도 잘됐구요 😊

다만 여러 예상못한 상황이 겹치다보니,
어쩔 수 없이 한 가지 방법을 더 사용하게 되었습니다.

저는 남은 1,2 번 방법 중 하나인 2번, Captcha 방식을 사용하기로 했습니다.
지금부터 제가 Captcha 를 적용했던 방식을 공유합니다.




Captcha 적용하기

참고 : 어떤 글들에서는 Captcha 적용을 위해서 Google, Naver API 를 쓰더군요.
저는 딱히 그럴 필요가 없다 생각하여 그냥 Captcha 라이브러리를 사용했습니다.


1. Captcha 라이브러리 선정

저는 어떤 라이브러리를 찾아다닐 때 무조건 maven repository 에 가서
키워드로 먼저 검색을 해보는 편입니다. captcha 로 검색해보니 아래처럼 나오더군요.

위처럼 여러가지가 나오면 2번째로는 보는 것이 Last Release Date 입니다.
Hutool Captcha 가 가장 최근에 Release 한 걸 확인할 수 있습니다.
그래서 Hutool Captcha 를 사용하기로 했습니다.

추가로 Hutool 과 관련된 개발 문서도 상당히 잘되어
있어서 여러모로 도움이 많이 됐던 라이브러리입니다.
(중국어이긴 하지만 chatgpt 와 함께라면 무섭지 않습니다 😊)

개발문서 링크: https://doc.hutool.cn/pages/captcha/



2. Captcha 프로세스 계획하기

  1. Captcha 가 필요한 POST 요청이 뭔지 파악합니다.
    이번 예시로는 문의하기 등록 (POST /qna) 라는 URL 요청이 있다고 합시다.
  1. Client 의 문의하기 화면에서 실제 POST 요청을 보내기 전에
    캡챠 코드를 먼저 서버로부터 받도록 javascript 를 수정합니다.
  1. 서버는 응답 Http Body 로는 Captcha 코드 이미지 를 반환하고,
    그와 동시에 쿠키에 Captcha 코드 를 암호화하여 같이 전송합니다.
  1. Frontend 에서는 받은 이미지를 화면에 표출하고,
    사용자들이 해당 이미지 위의 코드값을 입력하도록 합니다.

  2. 사용자가 코드값을 모두 입력하고 등록 버튼을 클릭하면
    이때 javascript 에서 POST /qna 요청을 보낼 때,
    해당 요청의 Http Header 에 해당 코드값을 넣습니다.

  3. 요청이 Tomcat 에 오게 되면 이미 등록된 Filter 들을 거치는데,
    이때 미리 만들어둔 캡챠 코드 확인용 Filter 가 해당 URL
    captcha 검사가 필요한 요청인지 확인합니다.

  4. 만약 캡챠 코드 확인이 필요한 요청이면,
    해당 요청의 Http Header 에서 사용자가 입력한 캡챠 코드를 추출합니다.
    그리고 3 번에서 생성했던 쿠키에서 암호화된 Captcha 코드 를 추출하여
    복호화를 합니다.

  5. 최종적으로 이렇게 Http HeaderCookie 에서 뽑아낸 값을 비교합니다.
    일치한다면 성공 기존 API 요청으로 그대로 수행하고,
    실패하면 검사 Filter 내부에서 실패 Response 를 생성해서 반환합니다.



3. CaptchaController

Captcha 코드/이미지 생성 Controller 입니다.
Http Body 로는 캡챠 코드 이미지를 넣고,
enc-captcha-code 라는 이름의 쿠키에 암호화된 캡챠 코드를 넣어서
같이 Http Response 로 Client 에게 반환합니다.

/*
 참고:  개발 환경
 OS : Window 10 Home
 Servlet Engine : Tomcat 10.x.x `->` jakarta 패키지 사용!
 java : azul-jdk (v.21)
*/
import cn.hutool.captcha.LineCaptcha;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import me.daily.code.util.CryptoHelper;

@RestController
@RequiredArgsConstructor
public class CaptchaController {

	// CryptoHelper 는 아주 단순한 안복호화 유틸입니다. 
    // java 암복호화라고 검색하면 금방 나오니 이건 스스로 해보시기 바랍니다!
    private final CryptoHelper cryptoHelper;

    @GetMapping("/captcha")
    public ResponseEntity<byte[]> getCaptchaImg(/*HttpServletResponse response*/) {
        // 캡챠 이미지 생성
        LineCaptcha lineCaptcha = new LineCaptcha(
                300, 		// 이미지 너비
                100,        // 이미지 높이
                6,          // 캡챠 코드의 수(=문자열 길이)
                30          // 간섭선 수(이미지 주변을 더럽히는 선의 수)
        );

        // 코드 생성
        lineCaptcha.createCode();

        // 캡챠 코드 값을 암호화한다.
        String encodedCaptchaCode = 
        	cryptoHelper.encrypt(lineCaptcha.getCode());

        // 암호화한 캡챠 코드를 쿠키에 넣고 전송한다.
        
        // 방법1: HttpServletResponse 에 적용
        // final Cookie cookie = new Cookie("enc-captcha-code", encodedCaptchaCode);
        // cookie.setSecure(true);
        // cookie.setHttpOnly(true);
        // response.addCookie(cookie);

        // 방법2: Spring 에서 제공하는 기능 사용
        ResponseCookie responseCookie = ResponseCookie
                .from("enc-captcha-code", encodedCaptchaCode)
                .httpOnly(true)
                .secure(true)
                .sameSite("Strict")
                .build();


        // HttpHeader 를 세팅한다.
        HttpHeaders headers = new HttpHeaders();
        String captchaImgName = "captcha_code.png";
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + captchaImgName + "\"");
        headers.add(HttpHeaders.SET_COOKIE, responseCookie.toString());

        // Client 에게 HTTP Body 로는 캡챠 이미지를 보내고, Cookie 로는 암호화된 캡챠코드를 준다.
        return ResponseEntity.ok()
                .headers(headers)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(lineCaptcha.getImageBytes());
    }
    
    // 캡챠 코드 테스트용 Post 요청입니다.
    @PostMapping("/test/captcha")
    public String testCaptcha() {
    	return "good job";
    }
}
  • 캡챠 코드를 추출하고, 제가 만든 암복호화 유틸을 통해서 암호화를 진행했습니다.
  • 암호화된 코드값은 enc-captcha-code 라는 이름의 Cookie 로
    Response 와 함께 반환합니다.
  • Repsonse Body 에는 이미지를 그대로 넣어서 보내줍니다.

한번 PostMan 으로 테스트를 해봤습니다.

이미지를 다운로드 요청을 보내고 나서, 실제 파일을 다운로드 받습니다.


이후에 이미지 응답과 함께 반환된 Cookie 도 확인합니다.
enc-captch-code 라는 이름의 쿠키가 정상적으로 반환된 것을 확인할 수 있습니다.
그리고 값도 암호화가 잘되어 있는 것도 확인이 됩니다.




4. CaptchaCodeCheckFilter

사용자가 입력한 캡챠코드와 쿠키에 있는 암호화된 캡챠코드를 비교하여
일치하는 지를 판단하는 필터입니다.

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.OncePerRequestFilter;
import me.daily.code.util.CryptoHelper;

import java.io.IOException;
import java.util.List;

/**
 * 캡챠 코드를 반환하고, 캡챠코드를 검토하는 필터다.<br>
 * [ mustCheckUrlList 에 명시된 URL + POST METHOD ] 요청에 대해서는 HTTP 헤더로 [X-CHECK-CAPTCHA=캡챠_코드값] 값을 받는다.<br>
 */
@Slf4j
@RequiredArgsConstructor
public class CaptchaCodeCheckFilter extends OncePerRequestFilter {

    private final List<String> mustCheckUrlList = List.of(
        "/test/captcha" // CaptchaCodeCheckFilter 에서 만든 테스트용 Post 요청
    );

    private final CryptoHelper cryptoHelper;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String requestURI = request.getRequestURI();
        String method = request.getMethod();

        // 체크해야될 URL 이면서 POST 메소드에 대한 것만 막는다.
        if (!mustCheckUrlList.contains(requestURI) || !"POST".equals(method)) {
            filterChain.doFilter(request, response);
            return;
        }

        // CaptchaController 에 의해서 생성된 암호화된 캡챠 코드를 갖는 Cookie 를 조회한다.
        Cookie[] cookies = request.getCookies();
        String findingCookieName = "enc-captcha-code";
        Cookie cookie = getCaptchaCookie(cookies, findingCookieName);

        // 만약 조회되는 쿠키가 없다면, 이는 먼저 캡챠 코드를 생성하는 [GET /captcha] 요청을 안 한 것이다.
        // ERROR 결과를 반영하여 return 한다.
        if(cookie == null) {
            log.error("ENC-CAPTCHA-CODE 쿠키가 발견되지 않았습니다! [GET /captcha] 요청을 혹시 실행 안하셨나요?");
            errorHttpResponseConfig(response, "서버에서 먼저 캡챠 코드를 받은 후 실행하시기 바랍니다.", requestURI);
            return;
        }

        // Client 가 전송 시에 입력한 캡챠값을 조회한다. 참고로 해당 값은 헤더에 있어야 한다.
        String clientInputCaptchaCode = request.getHeader("X-CHECK-CAPTCHA");
        if(clientInputCaptchaCode == null) {
            log.error("HTTP HEADER => X-CHECK-CAPTCHA 값이 발견되지 않았습니다!");
            errorHttpResponseConfig(response, "캡챠코드를 전송하지 않았습니다." , requestURI);
            return;
        }

        // 쿠키에 들어 있는 암호화된 캡챠 코드를 복호화한다.
        String encodedCaptchaCode = cookie.getValue();
        String originalCaptchaCode = 
        	cryptoHelper.decrypt(encodedCaptchaCode);


        // Client 가 보낸 캡챠 코드와 쿠키에서 추출한 캡챠코드를 비교한다.
        if (!clientInputCaptchaCode.equals(originalCaptchaCode)) {
            errorHttpResponseConfig(response, "캡챠 코드가 틀렸습니다." , requestURI);
            return;
        } else {
            // 정상적으로 캡챠코드가 확인됐으면, 해당 쿠키를 지운다.
            cookie.setMaxAge(0);
            response.addCookie(cookie);
        }

        filterChain.doFilter(request, response);
    }

    private void errorHttpResponseConfig(HttpServletResponse response, String errorMsg, String requestURI) throws IOException {
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-Type", "application/json");
        String errorResponseJson = createErrorResponseJson(errorMsg ,requestURI);
        response.getWriter().write(errorResponseJson);
    }

    private Cookie getCaptchaCookie(Cookie[] cookies, String findingCookie) {
        if(cookies == null) return null;

        for (Cookie cookie : cookies) {
            if(cookie.getName().equals(findingCookie)) return cookie;
        }

        return null;
    }

    private String createErrorResponseJson(String errorMsg, String requestURI) {
        return """
       {
          "code":"%s",
          "message":"%s",
          "result":"%s"
       }
       """.formatted(
                999,
                errorMsg,
                String.format("[%s] 요청이 블록되었습니다.", requestURI)
        ).trim();
    }
}
  • 먼저 요청이 반드시 캡챠 코드 확인이 필요한 요청인지 판단합니다.
  • 만약 필요한 요청이면 Header 에서 X-CHECK-CAPTCHA 를 조회하여 값이 있는지 확인합니다.
  • 값이 없다면 실패 http response 를 반환하고,
  • 값이 있다면 enc-captcha-code 이름의 쿠키에서 암호화된 캡챠 코드를 추출하여,
    복호화를 진행합니다.
  • 이후에 Header 값과 쿠키의 값을 비교해서 일치하는지 판단합니다.

PostMan 에서 아래처럼 캡챠 이미지 위의 코드값을 Http Header(X-CHECK-CAPTCHA)
에 넣고 전송해서 테스트해보시기 바랍니다. 성공한다면 "good job" 이라는 문자열을 반환 받습니다.
(참고로 POST /test/captchaCaptchaCodeCheckFilter 에 만든 테스트용 URL)



끝입니다.



참고 링크

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글