이번 시간에는 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));에서 사용됩니다!
@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));
}
}
@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();
}
}
public interface JwtConstants {
String TOKEN_PREFIX = "Bearer ";
String HEADER_STRING = "Authorization";
}
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용으로 만들었다
@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));
}
}
먼저 헤더에서 사용자가 보낸 토큰을 꺼내 전처리
@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));
}
}
@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));
}
}
@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();
}
}
회원 가입 전에 먼저, 이메일을 인증해야 한다. 모든 유저가 이메일 인증 시, 내 이메일을 통해 인증 번호가 유저의 이메일로 전달되도록 설정했다. 이메일을 전송할때는 SMTP 프로토콜을 이용해야 한다. 먼저, 구글에서 앱 비밀번호 설정을 통해 비밀번호 6자리를 발급받고, gmail에서 POP과 IMAP(이건 자동으로 사용 설정되어있음) 사용으로 설정해주었다.
@Component
@RequiredArgsConstructor
public class EmailSender {
private final JavaMailSender mailSender;
public void send(SimpleMailMessage message) {
try {
mailSender.send(message);
} catch (Exception e) {
throw new EmailSendException("인증 이메일 발송에 실패했습니다.");
}
}
}
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;
}
@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, "인증 메일이 발송되었습니다."));
}
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);
}
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);
}
}
@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, "이메일 인증에 성공했습니다."));
}
public void verifyCode(String email, String inputCode) {
String savedCode = getSavedCodeOrThrow(email);
checkCodeMismatch(inputCode, savedCode);
completeVerification(email);
}
위의 두 post 메서드 200을 받으면, 회원가입을 진행한다.
@PostMapping("/signup")
public ResponseEntity<ApiResponse<Void>> signup(@Valid @RequestBody SignupRequest request) {
authService.signup(request.toCommand());
return ResponseEntity.ok(new ApiResponse<>(true, 200, "회원가입이 완료되었습니다."));
}
public void signup(SignupCommand command) {
validateSignupEmail(command.getEmail());
String encodedPassword = passwordEncoder.encode(command.getPassword());
saveUser(command, encodedPassword);
redisTemplate.delete(RedisMailConstants.VERIFIED_PREFIX + command.getEmail());
}
@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)));
}
public LoginResult login(LoginCommand command) {
User user = getUserByEmail(command.getEmail());
checkPassword(command.getPassword(), user.getPassword());
return issueTokens(user);
}
@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)));
}
public TokenRefreshResult refreshToken(TokenRefreshCommand command) {
User user = getValidatedUserFromRefreshToken(command.getRefreshToken());
return reissueTokens(user);
}
@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, "로그아웃 성공"));
}
public void logout(Long userId, String accessToken) {
registerBlacklist(accessToken);
getUserById(userId).invalidateRefreshToken();
}