Spring Security 7 MFA 토이 프로젝트

2026. 2. 24. 12:27·Spring

[Spring Security 7] OTT MFA를 FACTOR 권한으로 붙이면서 겪은 고민, 헷갈림, 해결 과정

 

이번 포스팅은 toy-mfa-system 백엔드에서 Spring Security 7 OTT MFA를 붙이면서 실제로 겪은 문제와 해결 과정을 정리한 글이다.

 

이번 글의 핵심은 아래다.

 

  • FACTOR(PASSWORD, OTT) 기반 권한 설계
  • OneTimeTokenService 커스텀 구현
  • Resolver / Converter / SuccessHandler / FailureHandler를 실제 코드로 연결한 방식
  • 구현하면서 헷갈렸던 개념과 정리된 결론

왜 이 구조로 갔는가

MFA를 붙일 때 내가 가장 먼저 고민했던 건 이거였다.

“2차 인증을 붙였다”가 아니라,
“2차 인증 상태를 권한처럼 다룰 수 있나?”

 

그래서 인증 상태를 boolean으로 처리하지 않고,

 

JWT claim의 factors를 GrantedAuthority로 매핑해서 URL 정책에 반영했다.

 

즉,

  • PASSWORD만 가진 사용자
  • PASSWORD + OTT까지 가진 사용자

를 스프링 시큐리티 단계에서 분리한다.

 


최종 보안 정책 (현재 코드 기준)

 

SecurityConfig에서 최종 정책은 아래다.

 

  • POST /api/v1/users, POST /api/v1/auth/sign-in -> permitAll
  • GET /api/v1/users/me -> PASSWORD
  • GET /api/v1/posts/** -> PASSWORD
  • POST /api/v1/posts/** -> PASSWORD + OTT
  • /api/v1/auth/mfa/** -> PASSWORD

 

핵심은 이 부분이다.

 

.requestMatchers(HttpMethod.POST, "/api/v1/posts/**")
.hasAllAuthorities(SecurityFactor.PASSWORD, SecurityFactor.OTT)

 

“로그인했는가?”를 넘어서 “2차 인증까지 완료했는가?”를 강제한다.

 


OTT 커스텀 구현 포인트 (실제 클래스 기준)

 

이번 구현에서 OTT 관련 핵심 클래스는 아래다.

  • RedisOneTimeTokenService
  • MfaGenerateOneTimeTokenRequestResolver
  • MfaOttAuthenticationConverter
  • MfaOttGenerationSuccessHandler
  • MfaOttAuthenticationSuccessHandler
  • MfaOttAuthenticationFailureHandler

 

그리고 이들을 SecurityConfig#oneTimeTokenLogin에 연결했다.

 

SecurityConfig

.oneTimeTokenLogin(ott -> ott
        .showDefaultSubmitPage(false)
        .tokenService(oneTimeTokenService)
        .tokenGeneratingUrl("/api/v1/auth/mfa/ott/generate")
        .generateRequestResolver(mfaGenerateOneTimeTokenRequestResolver)
        .tokenGenerationSuccessHandler(mfaOttGenerationSuccessHandler)
        .loginProcessingUrl("/api/v1/auth/mfa/ott/verify")
        .authenticationConverter(mfaOttAuthenticationConverter)
        .successHandler(mfaOttAuthenticationSuccessHandler)
        .failureHandler(mfaOttAuthenticationFailureHandler)
)

 

여기서 중요한 건 oneTimeTokenLogin이 내부적으로 generate용 필터와 verify용 인증 필터를 체인에 등록한다는 점이다.

 

즉, 내가 resolver/converter/handler를 주입한 건 단순 헬퍼가 아니라, 실제 스프링 시큐리티 인증 체인에 끼워 넣은 것이다.

  • generate 경로: GenerateOneTimeTokenFilter -> GenerateOneTimeTokenRequestResolver -> OneTimeTokenService#generate -> OneTimeTokenGenerationSuccessHandler
  • verify 경로: (OTT 인증 필터) -> AuthenticationConverter -> AuthenticationManager -> provider 단계(consume) -> success/failure handler

 

RedisOneTimeTokenService

 

generate에서는 토큰 원문을 저장하지 않고 SHA-256 해시 키로 저장한다.
consume에서는 1회 사용을 보장하고 실패 횟수를 제한한다.

 

@Override
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
    Instant now = clock.instant();
    Duration expiresIn = request.getExpiresIn();
    Instant expiresAt = now.plus(expiresIn);

    String tokenValue = UUID.randomUUID().toString().replace("-", "");
    String tokenHash = sha256(tokenValue);
    String value = request.getUsername() + "|" + expiresAt.toEpochMilli();

    redisTemplate.opsForValue().set(tokenKey(tokenHash), value, expiresIn);
    log.info("OTT generated. username={}, expiresAt={}", request.getUsername(), expiresAt);
    return new DefaultOneTimeToken(tokenValue, request.getUsername(), expiresAt);
}

@Override
@Nullable
public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) {
    String tokenValue = authenticationToken.getTokenValue();
    if (tokenValue == null || tokenValue.isBlank()) {
        log.warn("OTT consume failed: token is empty");
        return null;
    }

    String tokenHash = sha256(tokenValue);
    if (readAttempts(tokenHash) >= MAX_ATTEMPTS) {
        log.warn("OTT consume failed: too many attempts. tokenHash={}", tokenHash);
        return null;
    }

    String key = tokenKey(tokenHash);
    String value = redisTemplate.opsForValue().get(key);
    if (value == null || value.isBlank()) {
        log.warn("OTT consume failed: token not found or already consumed. tokenHash={}", tokenHash);
        return fail(tokenHash);
    }

    String[] parts = value.split("\\|", 2);
    if (parts.length != 2) {
        log.warn("OTT consume failed: malformed token payload in redis. tokenHash={}", tokenHash);
        return fail(tokenHash);
    }

    String storedUserId = parts[0];
    Instant expiresAt;
    try {
        expiresAt = Instant.ofEpochMilli(Long.parseLong(parts[1]));
    } catch (NumberFormatException e) {
        log.warn("OTT consume failed: invalid expiresAt format. tokenHash={}", tokenHash);
        return fail(tokenHash);
    }

    if (expiresAt.isBefore(clock.instant())) {
        log.warn("OTT consume failed: token expired. tokenHash={}, expiresAt={}", tokenHash, expiresAt);
        return failAndDelete(tokenHash, key);
    }

    redisTemplate.delete(key);
    redisTemplate.delete(attemptKey(tokenHash));
    log.info("OTT consume success. username={}", storedUserId);
    return new DefaultOneTimeToken(tokenValue, storedUserId, expiresAt);
}

 

이 클래스의 포인트는 “토큰 저장소” 역할 그 이상이다.

 

스프링 시큐리티 provider는 consume의 반환값(null/OneTimeToken)으로 인증 성공/실패를 결정한다.

 

즉, consume은 사실상 verify 단계의 핵심 인증 로직이다.

 

MfaGenerateOneTimeTokenRequestResolver

 

이 클래스는 generate 시점의 사용자 식별을 담당한다. subject를 username이 아니라 userId로 통일했다.

 

@Override
public GenerateOneTimeTokenRequest resolve(HttpServletRequest request) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null || !authentication.isAuthenticated()) {
        throw new RuntimeException("MFA_PASSWORD_FACTOR_REQUIRED");
    }

    Object principal = authentication.getPrincipal();
    if (!(principal instanceof JwtUserClaims claims)) {
        throw new RuntimeException("MFA_PASSWORD_FACTOR_REQUIRED");
    }

    User user = userRepository.findById(UUID.fromString(claims.id()))
            .orElseThrow(() -> new RuntimeException("해당 유저는 존재하지 않습니다."));

    request.setAttribute("mfaUserId", user.getId().toString());
    request.setAttribute("mfaEmail", user.getEmail());
    return new GenerateOneTimeTokenRequest(user.getId().toString(), Duration.ofMinutes(5));
}

 

generate 필터는 이 resolver가 만든 GenerateOneTimeTokenRequest를 그대로 사용한다.

 

그래서 여기서 어떤 값을 username(subject)으로 넣느냐가, 이후 verify/provider 단계의 사용자 조회 기준을 사실상 결정한다.

 

MfaOttAuthenticationConverter

 

verify 요청에서 token 파라미터를 꺼내 OneTimeTokenAuthenticationToken으로 변환한다.

 

@Override
public Authentication convert(HttpServletRequest request) {
    String token = request.getParameter("token");
    if (token == null || token.isBlank()) {
        return null;
    }
    return new OneTimeTokenAuthenticationToken(token);
}

 

여기서 null을 반환하면 인증 시도 토큰 자체가 만들어지지 않는다.

 

반대로 OneTimeTokenAuthenticationToken을 반환하면 그 시점부터는 시큐리티 인증 파이프라인(AuthenticationManager -> provider)으로 넘어간다.

 

MfaOttGenerationSuccessHandler

 

OTT 생성 성공 후 메일을 발송하고 API 응답을 반환한다.

 

@Override
public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException {
    Object emailAttr = request.getAttribute("mfaEmail");
    if (!(emailAttr instanceof String email) || email.isBlank()) {
        throw new RuntimeException("MFA_EMAIL_REQUIRED");
    }

    String encodedToken = URLEncoder.encode(oneTimeToken.getTokenValue(), StandardCharsets.UTF_8);
    String magicLink = magicLinkBaseUrl + "?token=" + encodedToken;
    mfaEmailService.sendMagicLink(email, magicLink);

    response.setStatus(HttpServletResponse.SC_OK);
    response.setCharacterEncoding("UTF-8");
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.success(HttpStatus.OK)));
}

 

즉, OTT 생성 API의 실제 비즈니스 분기(메일 발송 여부/형태)는 여기서 끝난다.

 

resolver가 request attribute로 전달한 mfaEmail을 이 핸들러에서 소비하는 구조다.

 

MfaOttAuthenticationSuccessHandler

 

verify 성공 시 PASSWORD + OTT가 들어간 새 JWT를 발급한다.

 

@Override
public void onAuthenticationSuccess(
        HttpServletRequest request,
        HttpServletResponse response,
        Authentication authentication
) throws IOException {
    User user = userRepository.findById(UUID.fromString(authentication.getName()))
            .orElseThrow(() -> new RuntimeException("해당 유저는 존재하지 않습니다."));

    Pair<String, String> tokens = jwtProvider.createTokens(
            user.getId().toString(),
            List.of(user.getRole()),
            List.of(SecurityFactor.PASSWORD, SecurityFactor.OTT)
    );

    String accessToken = tokens.getLeft();
    String refreshToken = tokens.getRight();

    authRedisService.saveRefreshToken(
            user.getId().toString(),
            refreshToken,
            jwtProvider.getRefreshTokenExpirationMillis()
    );

    ResponseCookie refreshCookie = jwtProvider.generateRefreshCookie(refreshToken);

    response.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
    response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());
    response.setStatus(HttpServletResponse.SC_OK);
    response.setCharacterEncoding("UTF-8");
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.success(HttpStatus.OK)));
}

 

여기서 받은 Authentication은 이미 provider 단계를 통과한 최종 인증 객체다.

 

그래서 authentication.getName()을 믿고 user 조회를 할 수 있고, 여기서 factor 승격 토큰을 재발급하게 된다.

 

MfaOttAuthenticationFailureHandler

 

@Override
public void onAuthenticationFailure(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException exception
) throws IOException {
    log.warn("OTT authentication failed. requestURI={}, reason={}", request.getRequestURI(), exception.getMessage());

    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setCharacterEncoding("UTF-8");
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.getWriter().write(objectMapper.writeValueAsString(
            ApiResponse.fail("MFA_OTT_AUTH_FAILED", HttpStatus.UNAUTHORIZED, exception.getMessage())
    ));
}

 

verify 과정 어디서 실패하든(토큰 파싱/consume/provider/userDetails 로딩) 최종 실패 응답은 이 핸들러로 모인다.

 

운영에서 원인 분석이 쉬운 이유가 이 중앙 실패 처리 덕분이다.

 


헷갈렸던 포인트와 실제 결론

authentication.getName()은 왜 UUID가 되는가?

MfaOttAuthenticationSuccessHandler에서 아래 코드를 사용하고 있었다.

 

User user = userRepository.findById(UUID.fromString(authentication.getName()))

 

여기서 처음 헷갈렸던 건,

  • consume에서 DefaultOneTimeToken(..., storedUserId, ...)를 리턴하면
  • 그게 자동으로 authentication.getName()에 그대로 들어가나?

결론은 "중간 단계가 있다"였다.

  • consume 성공 후 Provider가 UserDetailsService#loadUserByUsername를 호출한다.
  • 최종 Authentication#getName()은 보통 UserDetails#getUsername() 값이다.

 

즉 내부 플로우를 줄이면 아래처럼 된다.

  1. verify 필터가 OneTimeTokenAuthenticationToken 생성
  2. provider가 oneTimeTokenService.consume(...) 호출
  3. consume이 DefaultOneTimeToken(username=storedUserId) 반환
  4. provider가 UserDetailsService.loadUserByUsername(storedUserId) 호출
  5. 최종 Authentication 생성
  6. successHandler에서 authentication.getName() 사용 가능

그래서 UserDetailsService를 명시적으로 맞춰야 했다.

 

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userRepository.findById(UUID.fromString(username))
            .orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다."));

    return org.springframework.security.core.userdetails.User
            .withUsername(user.getId().toString())
            .password(user.getPassword())
            .authorities(user.getRole().toSpringRole())
            .build();
}

 

이걸 맞추고 나서야 authentication.getName() -> UUID 흐름이 안정화됐다.


MfaGenerateOneTimeTokenRequestResolver에서 Authentication이 null이던 문제

 

이 문제는 필터 순서 이슈였다.

 

처음에는 JWT 필터를 아래처럼 등록했다.

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

 

그런데 OTT generate 흐름에서 resolver가 실행될 때,
SecurityContext가 비어 있는 케이스가 생겼다.

 

즉,

  • MfaGenerateOneTimeTokenRequestResolver에서 authentication == null
  • MFA_PASSWORD_FACTOR_REQUIRED로 떨어짐

해결은 기준 필터를 바꾸는 것이었다.

 

.addFilterBefore(jwtAuthenticationFilter, GenerateOneTimeTokenFilter.class)

 

내가 이걸 바꾸면서 깨달은 건 “어떤 필터 앞에 둘지”를 추상적으로 정하면 안 된다는 점이다.

 

내가 인증정보를 읽어야 하는 실제 필터보다 앞에 JWT 필터를 둬야 한다.

 

핵심은 “로그인 필터보다 앞”이라는 일반론이 아니라,
내가 인증 정보를 필요로 하는 필터보다 앞에 JWT 필터가 있어야 한다는 점이었다.

 


request.getUserPrincipal() vs SecurityContextHolder

 

resolver에서 request.getUserPrincipal()은 null인 경우가 있었다.

 

최종적으로는 아래 방식으로 정리했다.

  • SecurityContextHolder.getContext().getAuthentication() 사용
  • principal 타입 확인(JwtUserClaims)

이 흐름에서 훨씬 안정적이었다.


 

verify를 permitAll로 두면 생기는 문제

 

verify를 열어두면 편하긴 한데, 매직 링크에서는 선소비(scanner/pre-fetch) 이슈가 바로 생긴다.

 

실제 로그도 아래 패턴으로 확인됐다.

  • OTT consume failed: token not found or already consumed

그래서 현재는 /api/v1/auth/mfa/** 자체를 PASSWORD 권한이 필요하도록 두었다.


전체 인증 플로우 (현재 코드 기준)

 

로그인부터 보호 API 접근까지 흐름은 아래다.

  1. sign-in 성공
  2. access token에 PASSWORD factor 포함
  3. ott/generate 호출 (PASSWORD 필요)
  4. resolver가 userId/email 세팅
  5. token service가 OTT 생성/저장
  6. success handler가 매직 링크 메일 발송
  7. ott/verify 호출
  8. converter가 token 파싱
  9. token service가 consume
  10. provider + userDetailsService로 사용자 로딩
  11. success handler에서 PASSWORD + OTT 토큰 재발급
  12. POST /api/v1/posts/** 접근 허용

 

위 과정을 Spring Security 내부 컴포넌트 이름까지 포함해 보면 아래처럼 된다.

  1. JwtAuthenticationFilter가 access token을 검증하고 SecurityContext 세팅
  2. GenerateOneTimeTokenFilter가 generate 요청 진입
  3. MfaGenerateOneTimeTokenRequestResolver가 subject/userId 생성
  4. RedisOneTimeTokenService#generate 저장
  5. MfaOttGenerationSuccessHandler 실행
  6. verify 요청 시 OTT 인증 필터가 MfaOttAuthenticationConverter 호출
  7. provider 단계에서 RedisOneTimeTokenService#consume 호출
  8. consume 성공 후 CustomUserDetailsService#loadUserByUsername 호출
  9. 최종 Authentication 생성
  10. MfaOttAuthenticationSuccessHandler 실행 후 factor 승격 JWT 발급

내가 이번에 얻은 결론

  • MFA는 기능 추가가 아니라 권한 모델 설계다.
  • OTT consume success가 끝이 아니다. provider/userDetails 단계가 더 중요할 수 있다.
  • 필터 순서는 “일반론”이 아니라 내가 인증을 요구하는 실제 지점 기준으로 잡아야 한다.
  • authentication.getName()은 중간 체인을 이해하고 나면 자연스럽게 통제할 수 있다.

 

저작자표시 비영리 변경금지 (새창열림)

'Spring' 카테고리의 다른 글

[LangChain(LangGraph), Spring AI] - LangChain과 LangGraph란?  (1) 2026.01.14
'Spring' 카테고리의 다른 글
  • [LangChain(LangGraph), Spring AI] - LangChain과 LangGraph란?
진꿈청
진꿈청
기록하는 개발자가 되기를 희망하는 진꿈청입니다. 사소한 개발 일지도 기록하기 위해 노력하겠습니다
  • 진꿈청
    기록형 개발자 희망
    진꿈청
  • 전체
    오늘
    어제
    • 분류 전체보기 (377)
      • 개발 기록 (140)
      • Language (12)
        • Java (4)
        • Python (0)
        • Kotlin (8)
      • Spring (2)
        • JPA (9)
        • AOP (1)
        • MVC (0)
        • Cloud (4)
        • WebSocket (11)
        • 유용한 정보 (8)
        • 동시성 & Lock (7)
        • Test (7)
      • DevOps (3)
        • AWS (3)
      • Docker (7)
      • Git (1)
      • PS (53)
        • 프로그래머스 (16)
        • 백준 (36)
        • goorm (1)
      • 프로젝트 (39)
        • 토이 프로젝트 (9)
        • FitTrip (16)
        • StudyWithMe (14)
      • Ceph (3)
      • CS (10)
        • Network (6)
        • OS (4)
      • Design Pattern (8)
      • Infra (40)
        • Jenkins (13)
        • DevOps (7)
        • Kubernetes (10)
        • NGINX (4)
        • Flyway (1)
        • Kafka (1)
      • DB (7)
        • Redis (5)
        • MongoDB (1)
        • PostgreSQL (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    docker
    nginx
    백준
    JPA
    Spring cloud
    java
    k8s
    Jenkins
    디자인 패턴
    flyway
    ApplicationEventPublisher
    Design Pattern
    김영한
    spring rest docs
    AWS EC2
    Kafka
    kotlin
    Spring
    fittrip
    Github Actions
    openfeign
    Spring JPA
    websocket
    springboot
    Kubernetes
    ansible
    @Transactional
    프로그래머스
    Spring Cloud Gateway
    Redis
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
진꿈청
Spring Security 7 MFA 토이 프로젝트
상단으로

티스토리툴바