중앙해커톤 리팩터링-2

EauLune01·2026년 2월 13일

ggolist-refactor

목록 보기
2/3

이번 시간에는 JWT 인증과 관련해 정리해볼 계획이다. 회원가입, 로그인, 토큰 재발급, 로그아웃 구현까지이다.

JWT 키 발급

먼저, 터미널을 열고 다음과 같이 입력 후,

openssl rand -base64 32

발급된 키를

JWT_SECRET=kDeYoQ1U9A6PHqCOVFdwpH7OfySDBUSz/OA2kJlAAXI=
JWT_ACCESS_VALIDITY=1800
JWT_REFRESH_VALIDITY=604800

access 시간과 refresh 시간과 함께 저장해주세요.
이 키는, JwtTokenProvier의 Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));에서 사용됩니다!

UserDetailsService

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));
    }
}
  • UserDetails: 스프링 시큐리티가 유저를 인증하기 위해 필요한 정보를 담는 인터페이스
  • UserDetailsService: DB에서 유저 정보를 가져와서 UserDetails를 생성

JwtTokenProvider

@Slf4j
@Component
public class JwtTokenProvider {

    private final UserDetailsService userDetailsService;

    private final SecretKey key;
    private final long accessTokenValidity;
    private final long refreshTokenValidity;

    private static final String AUTHORITIES_KEY = "auth";

    public JwtTokenProvider(
            @Value("${jwt.secret}") String secretKey,
            @Value("${jwt.access-token-validity-in-seconds}") long accessTokenValidity,
            @Value("${jwt.refresh-token-validity-in-seconds}") long refreshTokenValidity,
            UserDetailsService userDetailsService) {

        this.key = Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(secretKey));
        this.accessTokenValidity = accessTokenValidity * 1000;
        this.refreshTokenValidity = refreshTokenValidity * 1000;
        this.userDetailsService = userDetailsService;
    }

    /**
     * Access Token 생성
     */
    public String createAccessToken(Authentication authentication) {
        User user = (User) authentication.getPrincipal();
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        return buildToken(user.getId().toString(), user.getEmail(), authorities, accessTokenValidity);
    }

    /**
     * Refresh Token 생성 (보안상 최소 정보만 포함)
     */
    public String createRefreshToken(User user) {
        return buildToken(user.getId().toString(), null, null, refreshTokenValidity);
    }

    /**
     * 토큰에서 Authentication 객체 추출 (SecurityContext 보관용)
     */
    public Authentication getAuthentication(String token) {
        Claims claims = parseClaims(token);

        if (claims.get("email") == null) {
            throw new InvalidTokenException("유효하지 않은 토큰입니다.");
        }

        String email = claims.get("email").toString();

        UserDetails userDetails = userDetailsService.loadUserByUsername(email);

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * 토큰 유효성 검증
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            log.error("JWT 검증 실패: {}", e.getMessage());
            return false;
        }
    }

    /**
     * 토큰의 남은 만료 시간 조회
     */
    public long getRemainingTime(String token) {
        Date expiration = parseClaims(token).getExpiration();
        return expiration.getTime() - System.currentTimeMillis();
    }

    /**
     * Claims 파싱
     */
    public Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /**
     * 토큰에서 userId 추출
     */
    public Long getUserId(String token) {
        Claims claims = parseClaims(token);

        String subject = claims.getSubject();

        if (subject == null) {
            throw new InvalidTokenException("토큰에 사용자 정보가 없습니다.");
        }
        try {
            return Long.valueOf(subject);
        } catch (NumberFormatException e) {
            throw new InvalidTokenException("토큰의 사용자 ID 형식이 올바르지 않습니다.");
        }
    }

    /**
     * JWT 만드는 Helper Method
     */
    private String buildToken(String sub, String email, String auth, long validity) {
        Date now = new Date();
        JwtBuilder builder = Jwts.builder()
                .subject(sub)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + validity))
                .signWith(key);
        if (email != null) builder.claim("email", email);
        if (auth != null) builder.claim(AUTHORITIES_KEY, auth);
        return builder.compact();
    }
}
  • AccessToken: AuthenticationManager가 전달받은 유저의 ID, PW가 맞으면 Authentication 객체를 반환, 이를 매개변수로 받아 유저 식별자, 이메일, 권한을 담아 AccessToken 생성
  • RefreshToken: 보안상 최소 정보만 포함하여 RefreshToekn 생성 후 DB에 저장
  • getAuthentication: JWT를 읽어 Authentication의 구현체인 UsernamePasswordAuthenticationToken 발급(SecurityContext에 유일하게 담길 수 있는 규격), ROLE_USER, ROLE_ADMIN 등의 권한이 담김

JwtConstants

public interface JwtConstants {
    String TOKEN_PREFIX = "Bearer ";
    String HEADER_STRING = "Authorization";
}

JwtUtils

public final class JwtUtils {

    private JwtUtils() {
    }

    public static String stripBearerPrefix(String token) {
        if (token == null) {
            return null;
        }

        if (token.startsWith(JwtConstants.TOKEN_PREFIX)) {
            return token.substring(JwtConstants.TOKEN_PREFIX.length());
        }

        return token;
    }
}

이 두 클래스와 인터페이스는 util용으로 만들었다

JwtAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String bearerToken = request.getHeader(JwtConstants.HEADER_STRING);
        String token = JwtUtils.stripBearerPrefix(bearerToken);
        log.info("Prefix 제거 후 토큰: {}", token);

        try {
            if (StringUtils.hasText(token)) {
                if (jwtTokenProvider.validateToken(token)) {
                    log.info("토큰 검증 성공!");

                    if (isBlacklisted(token)) {
                        log.warn("블랙리스트에 등록된 토큰입니다.");
                        filterChain.doFilter(request, response);
                        return;
                    }

                    Authentication auth = jwtTokenProvider.getAuthentication(token);
                    SecurityContextHolder.getContext().setAuthentication(auth);
                    log.info("Context 저장 성공: {}", auth.getName());
                } else {
                    log.warn("토큰 검증 실패 (validateToken == false)");
                }
            } else {
                log.info("토큰이 존재하지 않음 (비로그인 상태)");
            }
        } catch (Exception e) {
            log.error("JWT 인증 과정에서 예외 발생: ", e);
            SecurityContextHolder.clearContext();
        }

        filterChain.doFilter(request, response);
    }

    private boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token));
    }
}

먼저 헤더에서 사용자가 보낸 토큰을 꺼내 전처리

  • 1차 검증: 토큰 유효성 검사
  • 2차 검증: 로그아웃되어 redis 블랙리스트에 등록된 토큰 아닌지 검사
  • 이후, getAuthentication으로 신분증을 만들어서 SecurityContextHolder에 보관 후 다음 필터/컨트롤러로!

Handler 2개 작성하기

@Component
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
            throws IOException {

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        ApiResponse<?> apiResponse = new ApiResponse<>(false, 403, "접근 권한이 없습니다.");

        response.getWriter().write(objectMapper.writeValueAsString(apiResponse));
    }
}
  • Authorization 관련 핸들러
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException {

        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        ApiResponse<?> apiResponse = new ApiResponse<>(false, 401, "인증이 필요합니다.");

        response.getWriter().write(objectMapper.writeValueAsString(apiResponse));
    }
}
  • Authentication 관련 예외 처리

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final RedisTemplate<String, Object> redisTemplate;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                        .accessDeniedHandler(customAccessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/static/**", "/images/**", "/favicon.ico", "/error",
                                "/v3/api-docs/**", "/swagger-ui/**"
                        ).permitAll()
                        .requestMatchers("/api/auth/signup",
                                "/api/auth/login",
                                "/api/auth/refresh","/api/mail/**").permitAll()
                        .requestMatchers("/api/users/**").hasRole("USER")
                        .requestMatchers("/api/merchants/**").hasRole("MERCHANT")
                        .requestMatchers("/api/auth/logout").authenticated()
                        .requestMatchers("/api/**").permitAll()
                        .anyRequest().permitAll()
                )
                .logout(logout -> logout.disable())
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, redisTemplate),
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}
  • BCryptPasswordEncoder: 회원가입 시 비밀번호를 암호화하고, 로그인 시 입력받은 비밀번호와 DB의 암호화된 비밀번호를 비교할 때 사용
  • AuthenticationManager: 로그인 서비스에서 유저 아이디와 비밀번호를 검증할 때 이 매니저를 호출
  • filterChain: 우리 서비스는 기본적으로 비로그인 유저도 볼 수 있게 했다. 그러나, 특정 api는 로그인된 유저만 사용할 수 있도록 설정했다.

인증 관련 API

이메일 인증번호 전송

회원 가입 전에 먼저, 이메일을 인증해야 한다. 모든 유저가 이메일 인증 시, 내 이메일을 통해 인증 번호가 유저의 이메일로 전달되도록 설정했다. 이메일을 전송할때는 SMTP 프로토콜을 이용해야 한다. 먼저, 구글에서 앱 비밀번호 설정을 통해 비밀번호 6자리를 발급받고, gmail에서 POP과 IMAP(이건 자동으로 사용 설정되어있음) 사용으로 설정해주었다.

EmailSender

@Component
@RequiredArgsConstructor
public class EmailSender {
    private final JavaMailSender mailSender;

    public void send(SimpleMailMessage message) {
        try {
            mailSender.send(message);
        } catch (Exception e) {
            throw new EmailSendException("인증 이메일 발송에 실패했습니다.");
        }
    }
}
  • 비동기 방식으로 이메일 보내려고 했다가, 동기 방식은 예외 처리를 잡아줄 수 있어서 동기 방식으로 수정했다.
  • JavaMailSender: SMTP와 연동해 SimpleMailMessage(텍스트 전송용)나 MimeMessage(파일 첨부, HTML 전송용)를 받아서 실제로 발송해줌

RedisMailConstants

public interface RedisMailConstants {
    String AUTH_PREFIX = "auth:email:";
    String VERIFIED_PREFIX = "verified:email:";
    long AUTH_TTL_MINUTE = 5;

    String REQUEST_COUNT_PREFIX = "email:count:";
    int MAX_REQUEST_COUNT = 5;
    long BLOCK_24_HOURS = 24;
}
  • 뒤에, 서비스에서 이용할 상수 목록을 여기에 등록해주었다.

Controller

@PostMapping
    public ResponseEntity<ApiResponse<Void>> sendEmail(@RequestBody @Valid EmailSendRequest request) {
        String authCode = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
        emailService.sendVerificationMail(request.toCommand(), authCode);
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "인증 메일이 발송되었습니다."));
    }

Service

 public void sendVerificationMail(EmailSendCommand command, String authCode) {
        String email = command.getEmail();
        validateEmailRequest(email);

        SimpleMailMessage message = createMessage(email, authCode);
        emailSender.send(message);

        saveAuthCode(email, authCode);
        increaseRequestCount(email);
    }
  • 이메일을 가져와 이미 가입되어 있거나 일일 요청 5번을 넘기지 않았는지 검토-> 메시지 만들어 이메일 전송->전송 후 redis에 <email,인증번호> 저장 (5분 뒤 만료)-> 일일request 횟수 1 증가

Helper Method

private void validateEmailRequest(String email) {
        checkDuplicatedEmail(email);
        validateRequestCount(email);
    }

    private void checkDuplicatedEmail(String email) {
        if (userRepository.existsByEmail(email)) {
            throw new EmailDuplicateException("이미 가입된 이메일입니다.");
        }
    }

    private void validateRequestCount(String email) {
        String key = RedisMailConstants.REQUEST_COUNT_PREFIX + email;
        String countVal = redisTemplate.opsForValue().get(key);

        if (countVal != null && Integer.parseInt(countVal) >= RedisMailConstants.MAX_REQUEST_COUNT) {
            throw new RuntimeException("일일 메일 요청 횟수(5회)를 초과했습니다. 24시간 후 다시 시도해주세요.");
        }
    }

    private SimpleMailMessage createMessage(String email, String authCode) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(email);
        message.setSubject("[ggolist] 회원가입 인증번호 안내");
        message.setText("인증번호는 [" + authCode + "] 입니다. (5분 뒤에 만료됩니다.)");
        return message;
    }

    private void saveAuthCode(String email, String authCode) {
        redisTemplate.opsForValue().set(
                RedisMailConstants.AUTH_PREFIX + email,
                authCode,
                RedisMailConstants.AUTH_TTL_MINUTE,
                TimeUnit.MINUTES
        );
    }

    private void increaseRequestCount(String email) {
        String key = RedisMailConstants.REQUEST_COUNT_PREFIX + email;
        Long count = redisTemplate.opsForValue().increment(key);

        if (count != null && count == 1) {
            redisTemplate.expire(key, RedisMailConstants.BLOCK_24_HOURS, TimeUnit.HOURS);
        }
    }

인증번호 인증

Controller

@PostMapping("/verify")
    public ResponseEntity<ApiResponse<Void>> verifyEmail(@RequestBody @Valid EmailVerifyRequest request) {
        EmailVerifyCommand command = request.toCommand();
        emailService.verifyCode(command.getEmail(), command.getAuthCode());
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "이메일 인증에 성공했습니다."));
    }

Service

 public void verifyCode(String email, String inputCode) {
        String savedCode = getSavedCodeOrThrow(email);
        checkCodeMismatch(inputCode, savedCode);
        completeVerification(email);
    }
  • 이메일을 통해 redis에서 인증번호 가져와, dto의 인증번호와 같은지 검사-> 통과되면 redis에서 <키,인증번호> 삭제 후 회원가입 유효 시간 10분 설정

회원가입

위의 두 post 메서드 200을 받으면, 회원가입을 진행한다.

Controller

@PostMapping("/signup")
    public ResponseEntity<ApiResponse<Void>> signup(@Valid @RequestBody SignupRequest request) {
        authService.signup(request.toCommand());
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "회원가입이 완료되었습니다."));
    }

Service

 public void signup(SignupCommand command) {
        validateSignupEmail(command.getEmail());
        String encodedPassword = passwordEncoder.encode(command.getPassword());
        saveUser(command, encodedPassword);
        redisTemplate.delete(RedisMailConstants.VERIFIED_PREFIX + command.getEmail());
    }
  • 이메일 검증(인증번호 확인 후 10분이 지났거나, 인증이 안되었거나, 중복된 이메일 에러 처리)-> 패스워드 암호화 후 유저 저장

로그인

Controller

@PostMapping("/login")
    public ResponseEntity<ApiResponse<LoginResponse>> login(@Valid @RequestBody LoginRequest request) {
        LoginResult result = authService.login(request.toCommand());
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "로그인 성공", LoginResponse.from(result)));
    }

Service

public LoginResult login(LoginCommand command) {
        User user = getUserByEmail(command.getEmail());
        checkPassword(command.getPassword(), user.getPassword());
        return issueTokens(user);
    }
  • email로 유저를 찾아와, 비밀번호 일치 여부 확인 후 access token, refresh token, role 반환

토큰 재발급

Controller

@PostMapping("/refresh")
    public ResponseEntity<ApiResponse<TokenRefreshResponse>> refresh(
            @Valid @RequestBody TokenRefreshRequest request) {
        TokenRefreshResult result = authService.refreshToken(TokenRefreshCommand.of(request.getRefreshToken()));
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "토큰 재발급 성공", TokenRefreshResponse.from(result)));
    }

Service

public TokenRefreshResult refreshToken(TokenRefreshCommand command) {
        User user = getValidatedUserFromRefreshToken(command.getRefreshToken());
        return reissueTokens(user);
    }
  • RefreshToken을 통해 user 검증 후 토큰 재발급

로그아웃

Controller

@SecurityRequirement(name = "bearerAuth")
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> logout(
            @AuthenticationPrincipal User user,
            @RequestHeader(value = JwtConstants.HEADER_STRING) String authHeader) {
        String accessToken = JwtUtils.stripBearerPrefix(authHeader);
        authService.logout(user.getId(), accessToken);
        return ResponseEntity.ok(new ApiResponse<>(true, 200, "로그아웃 성공"));
    }

Service

public void logout(Long userId, String accessToken) {
        registerBlacklist(accessToken);
        getUserById(userId).invalidateRefreshToken();
    }
  • Redis에 accessToken 블랙리스트로 등록 후 DB에서 refreshToken 무효화
profile
Séoul

0개의 댓글