Spring Boot JWT 인증부터 OAuth, Google OTP 2FA까지

Sonyk9919·2026년 4월 25일

보안

목록 보기
2/2

💡 Spring Boot 환경에서 JWT 기반 인증/인가부터 OAuth 소셜 로그인, Google OTP를 이용한 2단계 인증까지 구현한 내용을 정리한다.


1. JWT를 통한 Stateless 인증 구성

JWT란?

JWT(Json Web Token)는 사용자가 서버를 재방문할 때 DB나 세션 저장소를 조회하지 않고 Stateless하게 인증/인가를 처리할 수 있도록 돕는 토큰이다.

이때, 주의할 점이 두 가지 있다.

  1. JWT에 담기는 정보는 암호화가 아닌 Base64 인코딩이므로 누구든 디코딩할 수 있다. 따라서 비밀번호나 개인정보를 담아서는 안 된다.
  2. 위변조를 방지하기 위해 페이로드를 서버의 비밀키로 해싱한 서명값을 토큰에 첨부한다. 토큰 사용 전 동일한 비밀키로 서명값을 재계산하여 비교함으로써 위변조 여부를 탐지한다.

Spring Security와 JWT

Spring Security를 JWT와 함께 사용하면 기본 제공 필터의 상당수를 사용할 수 없다. 기본 필터들이 세션 기반 인증을 가정하고 설계되어 있기 때문이다.

구분필터이유
❌ 사용 불가UsernamePasswordAuthenticationFilterForm 기반 인증 필터로 비활성화
❌ 사용 불가SecurityContextHolderFilter세션에서 Context를 복원하므로 무의미
❌ 사용 불가LogoutFilter세션 말소 방식이라 JWT 로그아웃과 맞지 않음
✅ 사용 가능AuthorizationFilterSecurityContext의 인증 정보로 권한을 체크하므로 재사용 가능
✅ 사용 가능ExceptionTranslationFilter인증/인가 예외 처리는 그대로 재사용 가능

결국 JWT 방식에서는 AuthorizationFilter 부분만 재사용하고, 인증 부분은 JwtAuthenticationFilter를 직접 구현해야 한다. 로그아웃도 세션 말소 대신 쿠키를 직접 만료시키는 방식으로 처리한다.

의존성

implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'

ClaimsConvertible

JWT에 담길 내용을 각 DTO가 직접 정의하도록 ClaimsConvertible 인터페이스를 사용했다. JwtProvider는 이 인터페이스에만 의존하므로, 새로운 토큰 종류가 추가되어도 JwtProvider를 수정하지 않고 인터페이스만 구현하면 된다.

public interface ClaimsConvertible {
    String getSubject();
    Map<String, Object> getBody();
}

AuthMemberDto가 이를 구현하여 JWT의 subjectbody를 정의한다.

@Getter
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class AuthMemberDto implements ClaimsConvertible {

    private final Long id;
    private final AccountRole role;

    public static AuthMemberDto from(MemberAccount account) {
        return new AuthMemberDto(account.getId(), account.getRole());
    }

    // JWT Claims -> DTO 변환
    public static AuthMemberDto from(Claims claims) {
        return new AuthMemberDto(
                Long.valueOf(claims.getSubject()),
                AccountRole.findByKey(claims.get(Body.ROLE, String.class))
        );
    }

    @Override
    public String getSubject() {
        return String.valueOf(id);
    }

    @Override
    public Map<String, Object> getBody() {
        return Map.of(Body.ROLE, role.getKey());
    }

    private static class Body {
        public static final String ROLE = "role";
    }
}

JwtProvider

JWT 생성/파싱을 담당하는 핵심 모듈이다.

비밀키는 예측이 어렵도록 랜덤 문자열로 설정한다.

openssl rand -base64 32

parseJwt에서 파싱과 예외 처리를 함께 담당하며, 만료된 토큰과 잘못된 토큰을 구분하여 각각 다른 예외를 던진다. 이를 통해 필터에서 만료 여부에 따라 분기 처리가 가능하다.

@Service
public class JwtProvider {

    private final SecretKey secretKey;

    public JwtProvider(@Value("${jwt.secret}") String key) {
        secretKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(key));
    }

    public String createJwt(Claims claims, long expirationMs) {
        Date now = new Date();
        return Jwts.builder()
                .claims(claims)
                .signWith(secretKey)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expirationMs))
                .compact();
    }

    public Claims createClaims(ClaimsConvertible convertible) {
        return Jwts.claims()
                .subject(convertible.getSubject())
                .add(convertible.getBody())
                .build();
    }

    public Claims parseJwt(String jwt) {
        try {
            return Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(jwt)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            throw new CustomException(JwtStatus.JWT_EXPIRY_REQUEST); // 만료된 토큰
        } catch (JwtException | IllegalArgumentException e) {
            throw new CustomException(JwtStatus.JWT_BAD_REQUEST);    // 잘못된 토큰
        }
    }
}

CookieProvider

쿠키 추가/만료/조회를 담당한다. sameSite, domain 등 보안 설정은 CookieProperty로 환경별로 관리한다.

@Component
@RequiredArgsConstructor
public class CookieProvider {

    private final CookieProperty cookieProperty;

    public void addCookie(HttpServletResponse response, String name, String value, Long expiry) {
        ResponseCookie cookie = ResponseCookie.from(name, value)
                .secure(true)
                .httpOnly(true)
                .path("/")
                .maxAge(expiry)
                .sameSite(cookieProperty.getSameSite())
                .domain(cookieProperty.getDomain())
                .build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }

    public void expireCookie(HttpServletResponse response, String name) {
        ResponseCookie cookie = ResponseCookie.from(name, "")
                .maxAge(0)
                .domain(cookieProperty.getDomain())
                .path("/")
                .build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
    }

    public String resolveCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) return null;
        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(name))
                .findFirst()
                .map(Cookie::getValue)
                .orElse(null);
    }
}

JwtAuthenticationFilter

AuthorizationFilter 도달 이전에 요청의 토큰을 검증하고 SecurityContext에 인증 정보를 주입하는 필터다.

동작 흐름은 다음과 같다.

  1. accessToken이 유효하면 SecurityContext에 주입하고 다음 필터로 넘긴다.
  2. accessToken이 없고 refreshToken이 있으면 재발급을 시도한다.
  3. 예외 발생 시 두 쿠키를 모두 만료시키고 HandlerExceptionResolver를 통해 @RestControllerAdvice로 예외를 위임한다.

JwtAuthenticationFilterExceptionTranslationFilter보다 앞단에 위치하기 때문에 예외가 @RestControllerAdvice까지 전파되지 않는다. HandlerExceptionResolver를 통해 직접 위임하는 방식으로 이를 해결한다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final CookieProvider cookieProvider;
    private final JwtProvider jwtProvider;
    private final HandlerExceptionResolver handlerExceptionResolver;
    private final AuthTokenIssuer authTokenIssuer;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String accessToken = cookieProvider.resolveCookie(request, TokenCookieName.ACCESS_TOKEN);
        String refreshToken = cookieProvider.resolveCookie(request, TokenCookieName.REFRESH_TOKEN);

        try {
            if (accessToken != null) {
                injectSecurityContext(AuthMemberDto.from(jwtProvider.parseJwt(accessToken)));
            } else if (refreshToken != null) {
                AuthMemberDto member = AuthMemberDto.from(jwtProvider.parseJwt(refreshToken));
                reissueAuthenticationToken(member, refreshToken, response);
            }
            filterChain.doFilter(request, response);
        } catch (CustomException e) {
            cookieProvider.expireCookie(response, TokenCookieName.ACCESS_TOKEN);
            cookieProvider.expireCookie(response, TokenCookieName.REFRESH_TOKEN);
            handlerExceptionResolver.resolveException(request, response, null, e);
        }
    }

    private void injectSecurityContext(AuthMemberDto member) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                member, null,
                List.of(new SimpleGrantedAuthority(member.getRole().getKey()))
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    private void reissueAuthenticationToken(AuthMemberDto member, String refreshToken, HttpServletResponse response) {
        authTokenIssuer.reissue(member, refreshToken)
                .forEach(token -> cookieProvider.addCookie(response, token.getName(), token.getToken(), token.getExpiry()));
        injectSecurityContext(member);
    }
}

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CorsProperty corsProperty;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, HandlerExceptionResolver handlerExceptionResolver) {
        httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/v1/oauth/**", "/v3/api-docs/**", "/swagger-ui/**", "/h2-console/**").permitAll()
                        .anyRequest().hasAnyAuthority(AccountRole.USER.getKey(), AccountRole.ADMIN.getKey())
                )
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint((request, response, authException) ->  // 401
                                handlerExceptionResolver.resolveException(request, response, null, authException)
                        )
                        .accessDeniedHandler((request, response, accessDeniedException) ->  // 403
                                handlerExceptionResolver.resolveException(request, response, null, accessDeniedException)
                        )
                )
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return httpSecurity.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowedOrigins(corsProperty.getOrigins());
        corsConfiguration.setAllowedMethods(corsProperty.getMethods());
        corsConfiguration.setAllowedHeaders(corsProperty.getHeaders());
        corsConfiguration.setMaxAge(corsProperty.getMaxAge());
        corsConfiguration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration(corsProperty.getPath(), corsConfiguration);
        return source;
    }
}

주요 포인트

  • STATELESS: 세션을 사용하지 않도록 SessionCreationPolicy.STATELESS로 설정한다.
  • CORS: Spring Security의 필터가 WebMvcConfigurer보다 앞단에 위치하기 때문에 여기서 설정해야 한다. 설정값은 CorsProperty로 외부화하여 환경별로 관리한다.
  • exceptionHandling: authenticationEntryPoint(401)와 accessDeniedHandler(403)를 람다로 인라인 등록하여 @RestControllerAdvice에서 일관되게 처리한다.

AuthMemberArgumentResolver

@AuthMember 어노테이션이 붙은 컨트롤러 파라미터에 SecurityContext의 인증 정보를 자동으로 주입한다. 컨트롤러에서 SecurityContextHolder를 직접 호출하지 않아도 된다.

@Component
public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthMember.class)
                && parameter.getParameterType().equals(AuthMemberDto.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
            return null;
        }
        return authentication.getPrincipal();
    }
}

컨트롤러에서는 아래와 같이 사용한다.

@GetMapping("/me")
public ResponseEntity<?> getMyInfo(@AuthMember AuthMemberDto member) {
    ...
}

2. Refresh Token (Redis)

accessToken의 만료 시간을 짧게 유지하면서도 사용자가 매번 로그인하지 않아도 되도록 refreshToken 전략을 도입했다. (단, Redis를 조회하기 때문에 완전한 Stateless 구조는 아니게 된다.)

Redis 설정

// container 실행
docker run --name redis -p 6379:6379 -d redis:latest
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

RefreshToken

refreshTokenmemberId, token, expiry, status 필드를 가지며 Redis에 저장된다. statusACTIVATESTALE 두 가지 상태를 가진다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class RefreshToken {

    private Long memberId;
    private String token;
    private Long expiry;
    private RefreshTokenStatus status; // ACTIVATE, STALE

    public static RefreshToken from(Long memberId, String token, Long expiry, RefreshTokenStatus status) {
        return new RefreshToken(memberId, token, expiry, status);
    }
}

RefreshTokenService

refreshToken의 생성/조회/갱신을 담당한다.

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    public void createRefreshToken(Long memberId, String token, Long expiry) {
        RefreshToken refreshToken = RefreshToken.from(memberId, token, expiry, RefreshTokenStatus.ACTIVATE);
        refreshTokenRepository.save(refreshToken);
    }

    public RefreshToken getRefreshToken(Long memberId, String token) {
        return refreshTokenRepository.findBy(memberId, token)
                .orElseThrow(() -> new CustomException(MemberStatus.REFRESH_TOKEN_UNAUTHORIZED));
    }

    public void upsertRefreshToken(Long memberId, String token, Long expiry) {
        RefreshToken refreshToken = RefreshToken.from(memberId, token, expiry, RefreshTokenStatus.ACTIVATE);
        refreshTokenRepository.upsert(refreshToken);
    }
}

Refresh Token Rotation과 STALE 상태

accessToken 재발급 시 refreshToken도 함께 갱신하는 Refresh Token Rotation 전략을 사용했다. 공격자가 탈취한 refreshToken을 사용하더라도 이미 갱신된 상태라면 무효화할 수 있어 보안이 강화된다.

여기서 한 가지 문제가 있다. 브라우저는 HTTP/1.1 기준 최대 5~6개의 커넥션을 동시에 맺을 수 있어, refreshToken 갱신 직후 기존 토큰으로 동시 요청이 들어올 수 있다. 기존 토큰을 즉시 삭제하면 이 요청들이 전부 실패한다.

이를 해결하기 위해 기존 refreshToken을 즉시 삭제하지 않고 STALE 상태로 짧은 유예 시간을 부여한다. STALE 상태의 토큰으로 요청이 들어오면 accessToken만 재발급하고 refreshToken은 갱신하지 않는다.


3. OAuth (카카오)

구조 설계

카카오 OAuth를 구현하면서 spring-boot-starter-oauth2-client 모듈 대신 직접 구현을 선택했다.

가장 큰 이유는 학습 목적이다. oauth2-starter가 내부적으로 어떤 흐름으로 동작하는지를 직접 설계해보고 싶었다. 토큰 발급, 사용자 정보 조회, 사용자 등록/로그인 처리까지 직접 구현해보면서 OAuth 흐름 전반을 이해하는 것이 목표였다.

부수적인 이유로는 아키텍처 불일치도 있다. 해당 모듈은 백엔드가 Authorization Code를 직접 받는 구조를 가정하고 있는데, 현재 구조는 클라이언트가 코드를 받아 서버로 전달하는 방식이라 모듈의 기본 흐름과 맞지 않는다.

전체 흐름은 다음과 같다.

클라이언트 → 카카오 로그인 → 클라이언트로 Authorization Code 전달
→ 클라이언트가 코드를 서버로 전달
→ 서버가 코드로 카카오 accessToken 발급
→ accessToken으로 사용자 정보 조회
→ 사용자 조회/등록 후 JWT 발급

OAuthProvider 인터페이스

추후 네이버, 구글 등 다른 벤더사 추가를 고려하여 OAuthProvider 인터페이스를 정의했다. 새로운 벤더사를 추가할 때 이 인터페이스를 구현하고 Bean으로 등록하기만 하면 된다.

public interface OAuthProvider {
    boolean isSupported(OAuthProviderType type);
    OAuthUserInfoDto getUserInfo(String code);
}

OAuthProviderFactory는 주입받은 구현체 목록에서 요청된 벤더사 타입에 맞는 것을 찾아 반환한다.

@Service
@RequiredArgsConstructor
public class OAuthProviderFactory {

    private final List<OAuthProvider> oAuthProviders;

    public OAuthProvider getProvider(OAuthProviderType type) {
        return oAuthProviders.stream()
                .filter(provider -> provider.isSupported(type))
                .findFirst()
                .orElseThrow(() -> new CustomException(AuthStatus.NOT_SUPPORTED_OAUTH_PROVIDER));
    }
}

KakaoProvider

카카오 OAuth 구현체다. KakaoAuthClientaccessToken을 발급받고, KakaoApiClient로 사용자 정보를 조회한다. 사용자 식별은 카카오 고유 ID를 기반으로 한다.

@Slf4j
@Component
@RequiredArgsConstructor
public class KakaoProvider implements OAuthProvider {

    private final KakaoAuthClient kakaoAuthClient;
    private final KakaoApiClient kakaoApiClient;

    @Value("${custom.kakao.clientId}") private String clientId;
    @Value("${custom.kakao.clientSecret}") private String clientSecret;
    @Value("${custom.kakao.redirectUri}") private String redirectUri;

    @Override
    public boolean isSupported(OAuthProviderType type) {
        return type.equals(OAuthProviderType.KAKAO);
    }

    @Override
    public OAuthUserInfoDto getUserInfo(String code) {
        RequestBodyDto requestBody = RequestBodyDto.from(clientId, code, clientSecret, redirectUri);
        try {
            TokenResponseDto token = kakaoAuthClient.getToken(requestBody.toFormData());
            KakaoUserInfoDto userInfo = kakaoApiClient.getUserInfo(RequestHeaderUtils.getBearerToken(token.getAccessToken()));
            return OAuthUserInfoDto.from(userInfo);
        } catch (HttpClientErrorException e) {
            log.info("[Kakao] error message={}", e.getMessage());
            if (e.getStatusCode().equals(HttpStatus.BAD_REQUEST)) {
                throw new CustomException(AuthStatus.BAD_REQUEST);
            }
            throw new CustomException(AuthStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

카카오 API 호출을 위한 RestClient 인터페이스는 Spring 6의 HttpServiceProxyFactory를 활용한다. /oauth/tokenMultiValueMap을 사용하는 이유는 카카오가 요구하는 Content-Typeapplication/x-www-form-urlencoded이기 때문이다.

public interface KakaoAuthClient {
    @PostExchange(url = "/oauth/token", contentType = "application/x-www-form-urlencoded;charset=utf-8")
    KakaoTokenDto.TokenResponseDto getToken(@RequestBody MultiValueMap<String, String> requestBodyDto);
}

public interface KakaoApiClient {
    @PostExchange(url = "/v2/user/me", contentType = "application/x-www-form-urlencoded;charset=utf-8")
    KakaoUserInfoDto getUserInfo(@RequestHeader("Authorization") String accessToken);
}

AuthLoginFacade & OAuthController

AuthLoginFacade는 벤더사와 무관하게 동작하는 로그인 파사드다. 사용자가 이미 존재하면 토큰을 발급하고, 존재하지 않으면 등록 후 토큰을 발급한다.

@Service
@RequiredArgsConstructor
public class AuthLoginFacade {

    private final MemberAccountService memberAccountService;
    private final OAuthProviderFactory oAuthProviderFactory;
    private final AuthTokenIssuer authTokenIssuer;

    public List<TokenResponseDto> login(OAuthProviderType type, String code) {
        OAuthProvider provider = oAuthProviderFactory.getProvider(type);
        OAuthUserInfoDto userInfo = provider.getUserInfo(code);
        try {
            MemberAccount memberAccount = memberAccountService.getMemberAccount(userInfo, type);
            return authTokenIssuer.issue(AuthMemberDto.from(memberAccount));
        } catch (CustomException e) {
            MemberAccount memberAccount = memberAccountService.createMemberAccount(userInfo, type);
            return authTokenIssuer.issue(AuthMemberDto.from(memberAccount));
        }
    }
}

AuthTokenIssuer

토큰 발급을 담당한다. issue는 최초 로그인 시 accessTokenrefreshToken을 함께 발급하고, refreshToken은 Redis에 저장한다.

@Service
@RequiredArgsConstructor
public class AuthTokenIssuer {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    @Value("${jwt.expiry.access}")
    private long accessTokenExpiry;

    @Value("${jwt.expiry.refresh}")
    private long refreshTokenExpiry;

    public List<TokenResponseDto> issue(AuthMemberDto member) {
        TokenResponseDto accessToken = createToken(member, TokenCookieName.ACCESS_TOKEN, accessTokenExpiry);
        TokenResponseDto refreshToken = createToken(member, TokenCookieName.REFRESH_TOKEN, refreshTokenExpiry);
        refreshTokenService.createRefreshToken(member.getId(), refreshToken.getToken(), refreshTokenExpiry);
        return List.of(accessToken, refreshToken);
    }

    private TokenResponseDto createToken(AuthMemberDto member, String name, Long expiry) {
        Claims claims = jwtProvider.createClaims(member);
        String jwt = jwtProvider.createJwt(claims, expiry * 1000);
        return TokenResponseDto.from(name, jwt, expiry);
    }
}

컨트롤러에서는 발급된 토큰을 CookieProvider를 통해 쿠키로 내려준다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/oauth")
public class OAuthController {

    private final AuthLoginFacade authLoginFacade;
    private final CookieProvider cookieProvider;

    @PostMapping("/kakao")
    @ResponseStatus(HttpStatus.OK)
    public void kakaoLogin(@RequestBody OAuthCodeDto oAuthCodeDto, HttpServletResponse response) {
        authLoginFacade.login(OAuthProviderType.KAKAO, oAuthCodeDto.getCode())
                .forEach(token -> cookieProvider.addCookie(response, token.getName(), token.getToken(), token.getExpiry()));
    }
}

4. 2FA (Google OTP)

구조 설계

2FA가 활성화된 사용자는 일반 로그인 후 accessToken 대신 만료 시간이 짧은 twoFAToken을 받는다. 이 토큰으로는 OTP 검증 엔드포인트에만 접근할 수 있으며, OTP 인증에 성공하면 비로소 accessTokenrefreshToken이 발급된다.

전체 흐름은 다음과 같다.

로그인 → 2FA 활성화된 사용자 → twoFAToken 발급
→ OTP 검증 요청 (twoFAToken 필요)
→ 인증 성공 → accessToken + refreshToken 발급

의존성

implementation('com.warrenstrange:googleauth:1.5.0') {
    exclude group: 'org.apache.httpcomponents', module: 'httpclient'
}
implementation 'org.apache.httpcomponents:httpclient:4.5.14'

해당 모듈 내부의 HttpClient에서 취약점이 발견되었으므로 위와 같이 교체한다.

AuthTokenIssuer (2FA 적용 후 개선)

2FA를 도입하면서 AuthTokenIssuer에 두 가지 변화가 생긴다.

첫째, issue에 2FA 분기가 추가된다. 2FA가 활성화된 사용자면 twoFAToken만, 그렇지 않으면 일반 토큰을 발급한다.

둘째, issueAfterTwoFA가 추가된다. OTP 검증 완료 후 실제 accessToken + refreshToken을 발급하는 메서드다. issue와 내부 로직이 동일하므로 issueTokens로 공통 로직을 추출하여 중복을 제거한다.

@Service
@RequiredArgsConstructor
public class AuthTokenIssuer {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    @Value("${jwt.expiry.access}")
    private long accessTokenExpiry;

    @Value("${jwt.expiry.refresh}")
    private long refreshTokenExpiry;

    @Value("${jwt.expiry.twofa}")
    private long twoFATokenExpiry;

    // 최초 로그인 시 토큰 발급
    // 2FA 활성화된 사용자라면 twoFAToken만 발급
    public List<TokenResponseDto> issue(AuthMemberDto member) {
        if (member.hasTwoFA()) {
            return List.of(createToken(member, TokenCookieName.TWO_FA_TOKEN, twoFATokenExpiry));
        }
        return issueTokens(member);
    }

    // refreshToken 기반 재발급
    public List<TokenResponseDto> reissue(AuthMemberDto member, String refreshToken) {
        RefreshToken oldRefreshToken = refreshTokenService.getRefreshToken(member.getId(), refreshToken);
        return switch (oldRefreshToken.getStatus()) {
            case STALE ->
                List.of(createToken(member, TokenCookieName.ACCESS_TOKEN, accessTokenExpiry));
            case ACTIVATE -> {
                TokenResponseDto newRefreshToken = createToken(member, TokenCookieName.REFRESH_TOKEN, refreshTokenExpiry);
                refreshTokenService.upsertRefreshToken(member.getId(), newRefreshToken.getToken(), refreshTokenExpiry);
                yield List.of(
                        createToken(member, TokenCookieName.ACCESS_TOKEN, accessTokenExpiry),
                        newRefreshToken
                );
            }
        };
    }

    // 2FA 검증 완료 후 실제 토큰 발급
    public List<TokenResponseDto> issueAfterTwoFA(AuthMemberDto member) {
        return issueTokens(member);
    }

    // accessToken + refreshToken 발급 공통 로직
    private List<TokenResponseDto> issueTokens(AuthMemberDto member) {
        TokenResponseDto accessToken = createToken(member, TokenCookieName.ACCESS_TOKEN, accessTokenExpiry);
        TokenResponseDto refreshToken = createToken(member, TokenCookieName.REFRESH_TOKEN, refreshTokenExpiry);
        refreshTokenService.createRefreshToken(member.getId(), refreshToken.getToken(), refreshTokenExpiry);
        return List.of(accessToken, refreshToken);
    }

    private TokenResponseDto createToken(AuthMemberDto member, String name, Long expiry) {
        Claims claims = jwtProvider.createClaims(member);
        String jwt = jwtProvider.createJwt(claims, expiry * 1000);
        return TokenResponseDto.from(name, jwt, expiry);
    }
}

GoogleOTPService

Google Authenticator는 TOTP(Time-based One-Time Password) 방식을 사용한다. 서버와 사용자 기기가 동일한 비밀키를 공유하고, 현재 시간을 기반으로 30초마다 새로운 6자리 코드를 생성한다. 서버는 사용자가 입력한 코드를 동일한 방식으로 계산하여 일치 여부를 검증한다.

비밀키는 사용자마다 고유하게 생성되며, DB에 저장하여 관리한다. 검증 시에는 사용자의 DB에서 비밀키를 꺼내 입력된 OTP와 비교한다.

2FA 활성화 흐름은 다음과 같다.

서버가 사용자별 비밀키 생성 → 사용자에게 전달 (QR코드 등) → DB 저장
→ 사용자가 Google Authenticator에 등록
→ 이후 로그인 시 앱에서 생성된 OTP 코드로 검증 (DB의 비밀키 사용)
@Service
@RequiredArgsConstructor
public class GoogleOTPService {

    private final GoogleAuthenticator googleAuthenticator;

    // 2FA 활성화 시 사용자별 비밀키 생성
    public String generateSecret() {
        return googleAuthenticator.createCredentials().getKey();
    }

    // 사용자의 비밀키로 OTP 코드 검증
    public boolean validate(String secret, int code) {
        return googleAuthenticator.authorize(secret, code);
    }
}
@Configuration
public class GoogleAuthenticatorConfig {

    @Bean
    public GoogleAuthenticator googleAuthenticator() {
        return new GoogleAuthenticator();
    }
}

TwoFactorAuthenticationFilter

TwoFactorAuthenticationFilter는 반드시 JwtAuthenticationFilter 이후에 위치해야 한다. 이유는 JwtAuthenticationFilter에서 accessToken이 유효하면 이미 SecurityContext에 인증 정보가 주입되기 때문이다.

이 경우 shouldNotFilterauthentication != null 조건에 의해 TwoFactorAuthenticationFilter는 스킵된다. 즉, 이미 정상 인증된 사용자는 이 필터를 타지 않는다.

반대로 accessToken이 없고 twoFAToken만 있는 경우, JwtAuthenticationFilter에서 인증이 되지 않아 SecurityContext가 비어있으므로 TwoFactorAuthenticationFilter가 동작하여 임시 권한을 부여한다.

@Component
@RequiredArgsConstructor
public class TwoFactorAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final CookieProvider cookieProvider;
    private final PathMatcher pathMatcher;
    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return !pathMatcher.match(AuthPublicPath.OTP, request.getRequestURI()) || authentication != null;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String twoFaToken = cookieProvider.resolveCookie(request, TokenCookieName.TWO_FA_TOKEN);
        try {
            if (twoFaToken != null) {
                AuthMemberDto member = AuthMemberDto.from(jwtProvider.parseJwt(twoFaToken));
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        member, "",
                        List.of(new SimpleGrantedAuthority(member.getRole().getKey()))
                );
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);
        } catch (CustomException e) {
            cookieProvider.expireCookie(response, TokenCookieName.TWO_FA_TOKEN);
            handlerExceptionResolver.resolveException(request, response, null, e);
        }
    }
}

OTP 검증 및 토큰 발급

OTP 검증에 성공하면 twoFAToken에서 사용자 정보를 꺼내 accessTokenrefreshToken을 발급한다. twoFAToken은 쿠키에서 만료시킨다.

@Service
@RequiredArgsConstructor
public class TwoFAService {

    private final GoogleOTPService googleOTPService;
    private final MemberAccountService memberAccountService;
    private final AuthTokenIssuer authTokenIssuer;

    // 2FA 활성화 - 사용자별 비밀키 생성 후 DB 저장
    public String enable(Long memberId) {
        String secret = googleOTPService.generateSecret();
        memberAccountService.saveTwoFASecret(memberId, secret);
        return secret;
    }

    // OTP 검증 성공 → 실제 accessToken + refreshToken 발급
    public List<TokenResponseDto> verify(AuthMemberDto twoFAMember, int otpCode) {
        MemberAccount member = memberAccountService.getById(twoFAMember.getId());

        if (!googleOTPService.validate(member.getGoogleSecret(), otpCode)) {
            throw new CustomException(AuthStatus.OTP_UNAUTHORIZED);
        }

        return authTokenIssuer.issueAfterTwoFA(AuthMemberDto.from(member));
    }
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/auth")
public class AuthController {

    private final TwoFAService twoFAService;
    private final CookieProvider cookieProvider;

    @PostMapping("/otp/verify")
    @ResponseStatus(HttpStatus.OK)
    public void verifyOtp(
            @AuthMember AuthMemberDto member,
            @RequestBody OtpVerifyRequest request,
            HttpServletResponse response
    ) {
        twoFAService.verify(member, request.getCode())
                .forEach(token -> cookieProvider.addCookie(response, token.getName(), token.getToken(), token.getExpiry()));
        cookieProvider.expireCookie(response, TokenCookieName.TWO_FA_TOKEN);
    }

    // 2FA 활성화 - 사용자별 비밀키 생성 후 반환 (클라이언트에서 QR코드로 변환하여 Google Authenticator 등록)
    @PostMapping("/otp/enable")
    @ResponseStatus(HttpStatus.OK)
    public OtpSecretResponse enableTwoFA(@AuthMember AuthMemberDto member) {
        String secret = twoFAService.enable(member.getId());
        return new OtpSecretResponse(secret);
    }
}

SecurityConfig (2FA 버전)

OTP 검증 경로는 twoFAToken(TWO_FA_ROLE) 또는 accessToken(USER_ROLE) 중 하나를 가진 사용자만 접근할 수 있도록 hasAnyAuthority로 설정한다. TwoFactorAuthenticationFilterJwtAuthenticationFilter 이후에 등록한다.

.authorizeHttpRequests(auth -> auth
        .requestMatchers(AuthPublicPath.AUTH, AuthPublicPath.H2).permitAll()
        .requestMatchers(AuthPublicPath.OTP).hasAnyAuthority(
                UserRole.TWO_FA_ROLE.getName(), UserRole.USER_ROLE.getName()
        )
        .anyRequest().hasAuthority(UserRole.USER_ROLE.getName())
)
.addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(twoFactorAuthenticationFilter, JwtAuthenticationFilter.class)

참고 자료

profile
개발 고수가 되고 싶다

0개의 댓글