From aefee51e5f3290157dc0ea9fbefd12078fe7ca4e Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:36:38 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20JWT=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/jwt/application/dto/JwtDto.java | 7 -- .../application/usecase/JwtManageUseCase.java | 59 ------------ .../AnonymousAuthenticationException.java | 9 -- .../jwt/exception/InvalidTokenException.java | 9 -- .../JwtAuthenticationProcessingFilter.java | 70 --------------- .../global/auth/jwt/service/JwtProvider.java | 87 ------------------ .../auth/jwt/service/JwtRedisService.java | 88 ------------------ .../global/auth/jwt/service/JwtService.java | 85 ------------------ .../global/auth/jwt/application/dto/JwtDto.kt | 6 ++ .../AnonymousAuthenticationException.kt | 5 ++ .../exception/InvalidTokenException.kt | 5 ++ .../application/exception/JwtErrorCode.kt} | 35 ++++---- .../application/service/JwtTokenExtractor.kt | 71 +++++++++++++++ .../application/usecase/JwtManageUseCase.kt | 48 ++++++++++ .../jwt/domain/service/JwtTokenProvider.kt | 89 +++++++++++++++++++ .../JwtAuthenticationProcessingFilter.kt | 58 ++++++++++++ .../service/JwtTokenExtractorTest.kt | 84 +++++++++++++++++ .../usecase/JwtManageUseCaseTest.kt | 50 +++++++++++ .../domain/service/JwtTokenProviderTest.kt | 35 ++++++++ .../JwtAuthenticationProcessingFilterTest.kt | 85 ++++++++++++++++++ 20 files changed, 556 insertions(+), 429 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/service/JwtService.java create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt rename src/main/{java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java => kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt} (51%) create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt diff --git a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java b/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java deleted file mode 100644 index e307c480..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/dto/JwtDto.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.jwt.application.dto; - -public record JwtDto( - String accessToken, - String refreshToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java b/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java deleted file mode 100644 index 304d7631..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.weeth.global.auth.jwt.application.usecase; - -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtRedisService; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.io.IOException; - -@Service -@RequiredArgsConstructor -public class JwtManageUseCase { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtRedisService jwtRedisService; - - // 토큰 발급 - public JwtDto create(Long userId, String email, Role role){ - String accessToken = jwtProvider.createAccessToken(userId, email, role); - String refreshToken = jwtProvider.createRefreshToken(userId); - - updateToken(userId, refreshToken, role, email); - - return new JwtDto(accessToken, refreshToken); - } - - // 토큰 헤더로 전송 - public void sendToken(JwtDto dto, HttpServletResponse response) throws IOException { - jwtService.sendAccessAndRefreshToken(response, dto.accessToken(), dto.refreshToken()); - } - - // 토큰 재발급 - public JwtDto reIssueToken(String requestToken){ - jwtProvider.validate(requestToken); - - Long userId = jwtService.extractId(requestToken).get(); - - jwtRedisService.validateRefreshToken(userId, requestToken); - - Role role = jwtRedisService.getRole(userId); - String email = jwtRedisService.getEmail(userId); - - JwtDto token = create(userId, email, role); - jwtRedisService.set(userId, token.refreshToken(), role, email); - - return token; - } - - // 리프레시 토큰 업데이트 - private void updateToken(long userId, String refreshToken, Role role, String email){ - jwtRedisService.set(userId, refreshToken, role, email); - } - -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java b/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java deleted file mode 100644 index 37a858a4..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/AnonymousAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AnonymousAuthenticationException extends BaseException { - public AnonymousAuthenticationException() { - super(JwtErrorCode.ANONYMOUS_AUTHENTICATION); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java b/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java deleted file mode 100644 index 2eb97951..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/InvalidTokenException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class InvalidTokenException extends BaseException { - public InvalidTokenException() { - super(JwtErrorCode.INVALID_TOKEN); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java deleted file mode 100644 index 7490ca02..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.weeth.global.auth.jwt.filter; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.auth.model.AuthenticatedUser; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.util.List; - -@RequiredArgsConstructor -@Slf4j -public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { - - private static final String NO_CHECK_URL = "/api/v1/login"; - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (request.getRequestURI().equals(NO_CHECK_URL)) { - filterChain.doFilter(request, response); - return; - } - // 유저 캐싱 도입 - try { - String accessToken = jwtService.extractAccessToken(request) - .orElseThrow(TokenNotFoundException::new); - if (jwtProvider.validate(accessToken)) { - saveAuthentication(accessToken); - } - } catch (TokenNotFoundException e) { - log.debug("Token not found: {}", e.getMessage()); - } catch (RuntimeException e) { - log.info("error token: {}", e.getMessage()); - } - - filterChain.doFilter(request, response); - - } - - public void saveAuthentication(String accessToken) { - - Long userId = jwtService.extractId(accessToken).orElseThrow(TokenNotFoundException::new); - String email = jwtService.extractEmail(accessToken).orElseThrow(TokenNotFoundException::new); - Role role = Role.valueOf(jwtService.extractRole(accessToken).orElseThrow(TokenNotFoundException::new)); - AuthenticatedUser principal = new AuthenticatedUser(userId, email, role); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - principal, - null, - List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) - ); - - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java deleted file mode 100644 index ca5e413d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtProvider.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Service -@Slf4j -public class JwtProvider { - - private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; - private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - - private final SecretKey secretKey; - private final Long accessTokenExpirationPeriod; - private final Long refreshTokenExpirationPeriod; - - public JwtProvider(JwtProperties jwtProperties) { - this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getKey().getBytes(StandardCharsets.UTF_8)); - this.accessTokenExpirationPeriod = jwtProperties.getAccess().getExpiration(); - this.refreshTokenExpirationPeriod = jwtProperties.getRefresh().getExpiration(); - } - - - public String createAccessToken(Long id, String email, Role role) { - Date now = new Date(); - return Jwts.builder() - .subject(ACCESS_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .claim(EMAIL_CLAIM, email) - .claim(ROLE_CLAIM, role.toString()) - .issuedAt(now) - .expiration(new Date(now.getTime() + accessTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public String createRefreshToken(Long id) { - Date now = new Date(); - return Jwts.builder() - .subject(REFRESH_TOKEN_SUBJECT) - .claim(ID_CLAIM, id) - .issuedAt(now) - .expiration(new Date(now.getTime() + refreshTokenExpirationPeriod)) - .signWith(secretKey) - .compact(); - } - - public boolean validate(String token) { - try { - Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - log.error("유효하지 않은 토큰입니다. {}", e.getMessage()); - throw new InvalidTokenException(); - } - } - - public Claims parseClaims(String token) { - try { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (JwtException | IllegalArgumentException e) { - log.error("토큰 파싱 실패: {}", e.getMessage()); - throw new InvalidTokenException(); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java deleted file mode 100644 index 90c021cf..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtRedisService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.weeth.domain.user.application.exception.EmailNotFoundException; -import com.weeth.domain.user.application.exception.RoleNotFoundException; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.jwt.exception.InvalidTokenException; -import com.weeth.global.auth.jwt.exception.RedisTokenNotFoundException; -import com.weeth.global.config.properties.JwtProperties; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtRedisService { - - private static final String PREFIX = "refreshToken:"; - private static final String TOKEN = "token"; - private static final String ROLE = "role"; - private static final String EMAIL = "email"; - - private final JwtProperties jwtProperties; - private final RedisTemplate redisTemplate; - - public void set(long userId, String refreshToken, Role role, String email) { - String key = getKey(userId); - put(key, TOKEN, refreshToken); - put(key, ROLE, role.toString()); - put(key, EMAIL, email); - redisTemplate.expire(key, jwtProperties.getRefresh().getExpiration(), TimeUnit.MINUTES); - log.info("Refresh Token 저장/업데이트: {}", key); - } - - public void delete(Long userId) { - String key = getKey(userId); - redisTemplate.delete(key); - } - - public void validateRefreshToken(long userId, String requestToken) { - if (!find(userId).equals(requestToken)) { - throw new InvalidTokenException(); - } - } - - public String getEmail(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "email"); - - return Optional.ofNullable(roleValue) - .orElseThrow(EmailNotFoundException::new); - } - - public Role getRole(long userId) { - String key = getKey(userId); - String roleValue = (String) redisTemplate.opsForHash().get(key, "role"); - - return Optional.ofNullable(roleValue) - .map(Role::valueOf) - .orElseThrow(RoleNotFoundException::new); - } - - public void updateRole(long userId, String role) { - String key = getKey(userId); - - if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { - redisTemplate.opsForHash().put(key, "role", role); - } - } - - private String find(long userId) { - String key = getKey(userId); - return Optional.ofNullable((String) redisTemplate.opsForHash().get(key, "token")) - .orElseThrow(RedisTokenNotFoundException::new); - } - - private String getKey(long userId) { - return PREFIX + userId; - } - - private void put(String key, String hashKey, Object value) { - redisTemplate.opsForHash().put(key, hashKey, value); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java b/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java deleted file mode 100644 index 40b9737d..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/service/JwtService.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.weeth.global.auth.jwt.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.TokenNotFoundException; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.jsonwebtoken.Claims; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.io.IOException; -import java.util.Optional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class JwtService { - - private static final String EMAIL_CLAIM = "email"; - private static final String ID_CLAIM = "id"; - private static final String ROLE_CLAIM = "role"; - private static final String BEARER = "Bearer "; - private static final String LOGIN_SUCCESS_MESSAGE = "자체 로그인 성공."; - - private final JwtProperties jwtProperties; - private final JwtProvider jwtProvider; - - public String extractRefreshToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getRefresh().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")) - .orElseThrow(TokenNotFoundException::new); - } - - public Optional extractAccessToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(jwtProperties.getAccess().getHeader())) - .filter(refreshToken -> refreshToken.startsWith(BEARER)) - .map(refreshToken -> refreshToken.replace(BEARER, "")); - } - - public Optional extractEmail(String accessToken) { - try { - Claims claims = jwtProvider.parseClaims(accessToken); - return Optional.ofNullable(claims.get(EMAIL_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractId(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ID_CLAIM, Long.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - public Optional extractRole(String token) { - try { - Claims claims = jwtProvider.parseClaims(token); - return Optional.ofNullable(claims.get(ROLE_CLAIM, String.class)); - } catch (Exception e) { - log.error("액세스 토큰이 유효하지 않습니다."); - return Optional.empty(); - } - } - - // header -> body로 수정 - public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) throws IOException { - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createSuccess(LOGIN_SUCCESS_MESSAGE, new JwtDto(accessToken, refreshToken))); - response.getWriter().write(message); - } - -} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt new file mode 100644 index 00000000..d72ba9ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/dto/JwtDto.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.jwt.application.dto + +data class JwtDto( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt new file mode 100644 index 00000000..6e6e4960 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/AnonymousAuthenticationException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class AnonymousAuthenticationException : BaseException(JwtErrorCode.ANONYMOUS_AUTHENTICATION) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt new file mode 100644 index 00000000..9571c89c --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/InvalidTokenException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class InvalidTokenException : BaseException(JwtErrorCode.INVALID_TOKEN) diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt similarity index 51% rename from src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java rename to src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt index 165a5149..5ccded53 100644 --- a/src/main/java/com/weeth/global/auth/jwt/exception/JwtErrorCode.java +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/JwtErrorCode.kt @@ -1,28 +1,33 @@ -package com.weeth.global.auth.jwt.exception; +package com.weeth.global.auth.jwt.application.exception -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum JwtErrorCode implements ErrorCodeInterface { +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus +enum class JwtErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { @ExplainError("토큰의 구조가 올바르지 않거나(Malformed), 서명이 유효하지 않은 경우 발생합니다. 토큰을 재발급 받아주세요.") INVALID_TOKEN(2900, HttpStatus.BAD_REQUEST, "올바르지 않은 Token 입니다."), @ExplainError("Redis에 해당 리프레시 토큰이 존재하지 않습니다. 토큰이 만료되었거나, 이미 로그아웃(삭제)된 상태일 수 있습니다. 다시 로그인해주세요.") - REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND,"저장된 리프레시 토큰이 존재하지 않습니다."), + REDIS_TOKEN_NOT_FOUND(2901, HttpStatus.NOT_FOUND, "저장된 리프레시 토큰이 존재하지 않습니다."), @ExplainError("API 요청 헤더(Authorization)에 토큰 값이 포함되지 않았거나 비어있을 때 발생합니다.") TOKEN_NOT_FOUND(2902, HttpStatus.NOT_FOUND, "헤더에서 토큰을 찾을 수 없습니다."), @ExplainError("인증이 필요한 리소스에 인증 정보 없이(Anonymous) 접근을 시도했을 때 발생합니다. (Spring Security 필터 단계 차단)") - ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."); + ANONYMOUS_AUTHENTICATION(2903, HttpStatus.UNAUTHORIZED, "인증정보가 존재하지 않습니다."), + + @ExplainError("Apple 인증 과정에서 토큰 교환 또는 검증에 실패했을 때 발생합니다.") + APPLE_AUTHENTICATION_FAILED(2904, HttpStatus.UNAUTHORIZED, "애플 로그인에 실패했습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status - private final int code; - private final HttpStatus status; - private final String message; + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt new file mode 100644 index 00000000..437570bb --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -0,0 +1,71 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class JwtTokenExtractor( + private val jwtProperties: JwtProperties, + private val jwtTokenProvider: JwtTokenProvider, +) { + private val log = LoggerFactory.getLogger(javaClass) + + data class TokenClaims( + val id: Long, + val email: String, + val role: String, + ) + + fun extractRefreshToken(request: HttpServletRequest): String = + request + .getHeader(jwtProperties.refresh.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + ?: throw TokenNotFoundException() + + fun extractAccessToken(request: HttpServletRequest): String? = + request + .getHeader(jwtProperties.access.header) + ?.takeIf { it.startsWith(BEARER) } + ?.removePrefix(BEARER) + + fun extractEmail(accessToken: String): String? = + runCatching { + val claims: Claims = jwtTokenProvider.parseClaims(accessToken) + claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java) + }.getOrElse { + log.error("액세스 토큰이 유효하지 않습니다.") + null + } + + fun extractId(token: String): Long? = + runCatching { + val claims: Claims = jwtTokenProvider.parseClaims(token) + claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java) + }.getOrElse { + log.error("액세스 토큰이 유효하지 않습니다.") + null + } + + fun extractClaims(token: String): TokenClaims? = + runCatching { + val claims: Claims = jwtTokenProvider.parseClaims(token) + TokenClaims( + id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java), + email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), + role = claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java), + ) + }.getOrElse { + log.error("액세스 토큰이 유효하지 않습니다.") + null + } + + companion object { + private const val BEARER = "Bearer " + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt new file mode 100644 index 00000000..67617d26 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -0,0 +1,48 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import org.springframework.stereotype.Service + +@Service +class JwtManageUseCase( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val refreshTokenStore: RefreshTokenStorePort, +) { + fun create( + userId: Long, + email: String, + role: String, + ): JwtDto { + val accessToken = jwtTokenProvider.createAccessToken(userId, email, role) + val refreshToken = jwtTokenProvider.createRefreshToken(userId) + + updateToken(userId, refreshToken, role, email) + + return JwtDto(accessToken, refreshToken) + } + + fun reIssueToken(requestToken: String): JwtDto { + jwtTokenProvider.validate(requestToken) + + val userId = requireNotNull(jwtTokenExtractor.extractId(requestToken)) + refreshTokenStore.validateRefreshToken(userId, requestToken) + + val role = refreshTokenStore.getRole(userId) + val email = refreshTokenStore.getEmail(userId) + + return create(userId, email, role) + } + + private fun updateToken( + userId: Long, + refreshToken: String, + role: String, + email: String, + ) { + refreshTokenStore.save(userId, refreshToken, role, email) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt new file mode 100644 index 00000000..bf722962 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -0,0 +1,89 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.config.properties.JwtProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.util.Date +import javax.crypto.SecretKey + +@Service +class JwtTokenProvider( + jwtProperties: JwtProperties, +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.key.toByteArray(StandardCharsets.UTF_8)) + private val accessTokenExpirationPeriod: Long = jwtProperties.access.expiration + private val refreshTokenExpirationPeriod: Long = jwtProperties.refresh.expiration + private val jwtParser: JwtParser = + Jwts + .parser() + .verifyWith(secretKey) + .build() + + fun createAccessToken( + id: Long, + email: String, + role: String, + ): String { + val now = Date() + return Jwts + .builder() + .subject(ACCESS_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(ROLE_CLAIM, role) + .issuedAt(now) + .expiration(Date(now.time + accessTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun createRefreshToken(id: Long): String { + val now = Date() + return Jwts + .builder() + .subject(REFRESH_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .issuedAt(now) + .expiration(Date(now.time + refreshTokenExpirationPeriod)) + .signWith(secretKey) + .compact() + } + + fun validate(token: String) { + parseSignedClaims(token, "유효하지 않은 토큰입니다.") + } + + fun parseClaims(token: String): Claims = + parseSignedClaims(token, "토큰 파싱 실패") + .payload + + private fun parseSignedClaims( + token: String, + errorMessage: String, + ) = try { + jwtParser.parseSignedClaims(token) + } catch (e: JwtException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } catch (e: IllegalArgumentException) { + log.error("{}: {}", errorMessage, e.message) + throw InvalidTokenException() + } + + companion object { + private const val ACCESS_TOKEN_SUBJECT = "AccessToken" + private const val REFRESH_TOKEN_SUBJECT = "RefreshToken" + internal const val EMAIL_CLAIM = "email" + internal const val ID_CLAIM = "id" + internal const val ROLE_CLAIM = "role" + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt new file mode 100644 index 00000000..9aeeabb3 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -0,0 +1,58 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationProcessingFilter( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, +) : OncePerRequestFilter() { + private val log = LoggerFactory.getLogger(javaClass) + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + try { + val accessToken = jwtTokenExtractor.extractAccessToken(request) ?: throw TokenNotFoundException() + jwtTokenProvider.validate(accessToken) + saveAuthentication(accessToken) + } catch (e: TokenNotFoundException) { + log.debug("Token not found: {}", e.message) + } catch (e: RuntimeException) { + log.info("error token: {}", e.message) + } + + filterChain.doFilter(request, response) + } + + fun saveAuthentication(accessToken: String) { + val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + val role = + runCatching { Role.valueOf(claims.role) } + .getOrElse { throw InvalidTokenException() } + val principal = AuthenticatedUser(claims.id, claims.email, role) + + val authentication = + UsernamePasswordAuthenticationToken( + principal, + null, + listOf(SimpleGrantedAuthority("ROLE_${role.name}")), + ) + + SecurityContextHolder.getContext().authentication = authentication + } +} diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt new file mode 100644 index 00000000..4010ad31 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -0,0 +1,84 @@ +package com.weeth.global.auth.jwt.application.service + +import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import jakarta.servlet.http.HttpServletRequest + +class JwtTokenExtractorTest : + DescribeSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Auth"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Refresh"), + ) + + val jwtProvider = mockk() + val jwtTokenExtractor = JwtTokenExtractor(jwtProperties, jwtProvider) + + beforeTest { + clearMocks(jwtProvider) + } + + describe("extractAccessToken") { + it("Bearer 헤더에서 access token을 추출한다") { + val request = mockk() + every { request.getHeader("Auth") } returns "Bearer access-token" + + val token = jwtTokenExtractor.extractAccessToken(request) + + token shouldBe "access-token" + } + } + + describe("extractRefreshToken") { + it("헤더가 없으면 TokenNotFoundException이 발생한다") { + val request = mockk() + every { request.getHeader("Refresh") } returns null + + shouldThrow { + jwtTokenExtractor.extractRefreshToken(request) + } + } + } + + describe("extractId") { + it("parseClaims를 통해 id를 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.java) } returns 77L + + val id = jwtTokenExtractor.extractId(token) + + id shouldBe 77L + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + } + + describe("extractClaims") { + it("id, email, role을 함께 반환한다") { + val token = "sample" + val claims = mockk() + every { jwtProvider.parseClaims(token) } returns claims + every { claims.get("id", Long::class.java) } returns 77L + every { claims.get("email", String::class.java) } returns "sample@com" + every { claims.get("role", String::class.java) } returns "USER" + + val tokenClaims = jwtTokenExtractor.extractClaims(token) + + tokenClaims?.id shouldBe 77L + tokenClaims?.email shouldBe "sample@com" + tokenClaims?.role shouldBe "USER" + verify(exactly = 1) { jwtProvider.parseClaims(token) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt new file mode 100644 index 00000000..de2a53ff --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -0,0 +1,50 @@ +package com.weeth.global.auth.jwt.application.usecase + +import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class JwtManageUseCaseTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val refreshTokenStore = mockk(relaxUnitFun = true) + val useCase = JwtManageUseCase(jwtProvider, jwtService, refreshTokenStore) + + describe("create") { + it("access/refresh token을 생성하고 저장한다") { + every { jwtProvider.createAccessToken(1L, "a@weeth.com", "USER") } returns "access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", "USER") + + result shouldBe JwtDto("access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "USER", "a@weeth.com") } + } + } + + describe("reIssueToken") { + it("저장 토큰 검증 후 새 토큰을 재발급한다") { + every { jwtProvider.validate("old-refresh") } just runs + every { jwtService.extractId("old-refresh") } returns 10L + every { refreshTokenStore.getRole(10L) } returns "ADMIN" + every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" + every { jwtProvider.createAccessToken(10L, "admin@weeth.com", "ADMIN") } returns "new-access" + every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" + + val result = useCase.reIssueToken("old-refresh") + + result shouldBe JwtDto("new-access", "new-refresh") + verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "ADMIN", "admin@weeth.com") } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt new file mode 100644 index 00000000..36418211 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -0,0 +1,35 @@ +package com.weeth.global.auth.jwt.domain.service + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.config.properties.JwtProperties +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class JwtTokenProviderTest : + StringSpec({ + val jwtProperties = + JwtProperties( + key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + access = JwtProperties.TokenProperties(expiration = 60_000L, header = "Authorization"), + refresh = JwtProperties.TokenProperties(expiration = 120_000L, header = "Authorization_refresh"), + ) + + val jwtProvider = JwtTokenProvider(jwtProperties) + + "access token 생성 후 claims를 파싱할 수 있다" { + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", "ADMIN") + + val claims = jwtProvider.parseClaims(token) + + claims.get("id", Number::class.java).toLong() shouldBe 1L + claims.get("email", String::class.java) shouldBe "test@weeth.com" + claims.get("role", String::class.java) shouldBe "ADMIN" + } + + "유효하지 않은 토큰 검증 시 InvalidTokenException이 발생한다" { + shouldThrow { + jwtProvider.validate("not-a-token") + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt new file mode 100644 index 00000000..c9c35f34 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -0,0 +1,85 @@ +package com.weeth.global.auth.jwt.filter + +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.context.SecurityContextHolder + +class JwtAuthenticationProcessingFilterTest : + DescribeSpec({ + val jwtProvider = mockk() + val jwtService = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService) + + beforeTest { + SecurityContextHolder.clearContext() + clearMocks(jwtProvider, jwtService) + } + + afterTest { + SecurityContextHolder.clearContext() + } + + describe("doFilterInternal") { + it("유효한 토큰이면 SecurityContext에 인증을 저장한다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", "ADMIN") + + filter.doFilter(request, response, chain) + + val authentication = SecurityContextHolder.getContext().authentication + (authentication == null) shouldBe false + (authentication.principal is AuthenticatedUser) shouldBe true + val principal = authentication.principal as AuthenticatedUser + principal.id shouldBe 1L + principal.email shouldBe "admin@weeth.com" + principal.role.name shouldBe "ADMIN" + authentication.authorities.any { it.authority == "ROLE_ADMIN" } shouldBe true + } + + it("토큰이 없으면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + verify(exactly = 0) { jwtProvider.validate(any()) } + } + + it("role claim이 유효하지 않으면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { + jwtService.extractClaims("access-token") + } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", "NOT_A_ROLE") + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } + } + }) From 714f699ffec191e57e771c7e8a460dfcff1ff219 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:36:54 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20Apple=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/apple/AppleAuthService.java | 238 ----------------- .../global/auth/apple/dto/ApplePublicKey.java | 13 - .../auth/apple/dto/ApplePublicKeys.java | 8 - .../auth/apple/dto/AppleTokenResponse.java | 10 - .../global/auth/apple/dto/AppleUserInfo.java | 11 - .../AppleAuthenticationException.java | 9 - .../global/auth/apple/AppleAuthService.kt | 245 ++++++++++++++++++ .../global/auth/apple/dto/ApplePublicKey.kt | 10 + .../global/auth/apple/dto/ApplePublicKeys.kt | 5 + .../auth/apple/dto/AppleTokenResponse.kt | 16 ++ .../global/auth/apple/dto/AppleUserInfo.kt | 7 + .../exception/AppleAuthenticationException.kt | 6 + 12 files changed, 289 insertions(+), 289 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/apple/AppleAuthService.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java delete mode 100644 src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt diff --git a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java b/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java deleted file mode 100644 index 4e46af42..00000000 --- a/src/main/java/com/weeth/global/auth/apple/AppleAuthService.java +++ /dev/null @@ -1,238 +0,0 @@ -package com.weeth.global.auth.apple; - -import com.weeth.global.auth.apple.dto.ApplePublicKey; -import com.weeth.global.auth.apple.dto.ApplePublicKeys; -import com.weeth.global.auth.apple.dto.AppleTokenResponse; -import com.weeth.global.auth.apple.dto.AppleUserInfo; -import com.weeth.global.auth.apple.exception.AppleAuthenticationException; -import com.weeth.global.config.properties.OAuthProperties; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.spec.RSAPublicKeySpec; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Base64; -import java.util.Date; -import java.util.Map; - -@Service -@Slf4j -public class AppleAuthService { - - private final OAuthProperties.AppleProperties appleProperties; - private final RestClient restClient = RestClient.create(); - - public AppleAuthService(OAuthProperties oAuthProperties) { - this.appleProperties = oAuthProperties.getApple(); - } - - // todo: 성능 개선 (캐싱 등) - - /** - * Authorization code로 애플 토큰 요청 - * client_secret은 JWT로 생성 (ES256 알고리즘) - */ - public AppleTokenResponse getAppleToken(String authCode) { - String clientSecret = generateClientSecret(); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", "authorization_code"); - body.add("client_id", appleProperties.getClientId()); - body.add("client_secret", clientSecret); - body.add("code", authCode); - body.add("redirect_uri", appleProperties.getRedirectUri()); - - return restClient.post() - .uri(appleProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(AppleTokenResponse.class); - } - - /** - * ID Token 검증 및 사용자 정보 추출 - * 애플은 별도 userInfo 엔드포인트가 없고 ID Token에 정보가 포함됨 - */ - public AppleUserInfo verifyAndDecodeIdToken(String idToken) { - try { - // 1. ID Token의 헤더에서 kid 추출 - String[] tokenParts = idToken.split("\\."); - String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); - Map headerMap = parseJson(header); - String kid = (String) headerMap.get("kid"); - - // 2. 애플 공개키 가져오기 - ApplePublicKeys publicKeys = restClient.get() - .uri(appleProperties.getKeysUri()) - .retrieve() - .body(ApplePublicKeys.class); - - // 3. kid와 일치하는 공개키 찾기 - ApplePublicKey matchedKey = publicKeys.keys().stream() - .filter(key -> key.kid().equals(kid)) - .findFirst() - .orElseThrow(AppleAuthenticationException::new); - - // 4. 공개키로 ID Token 검증 - PublicKey publicKey = generatePublicKey(matchedKey); - // JJWT 0.13.0+ uses parser() instead of parserBuilder() - Claims claims = Jwts.parser() - .verifyWith(publicKey) - .build() - .parseSignedClaims(idToken) - .getPayload(); - - // 5. Claims 검증 - validateClaims(claims); - - // 6. 사용자 정보 추출 - String appleId = claims.getSubject(); - String email = claims.get("email", String.class); - Boolean emailVerified = claims.get("email_verified", Boolean.class); - - return AppleUserInfo.builder() - .appleId(appleId) - .email(email) - .emailVerified(emailVerified != null ? emailVerified : false) - .build(); - - } catch (Exception e) { - log.error("애플 ID Token 검증 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 애플 로그인용 client_secret 생성 - * ES256 알고리즘으로 JWT 생성 (p8 키 파일 사용) - */ - private String generateClientSecret() { - try (InputStream inputStream = getInputStream(appleProperties.getPrivateKeyPath())) { - // p8 파일에서 Private Key 읽기 - String privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - - // PEM 형식의 헤더/푸터 제거 - privateKeyContent = privateKeyContent - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - - // Private Key 객체 생성 - byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); - KeyFactory keyFactory = KeyFactory.getInstance("EC"); - PrivateKey privateKey = keyFactory.generatePrivate( - new java.security.spec.PKCS8EncodedKeySpec(keyBytes) - ); - - // JWT 생성 - LocalDateTime now = LocalDateTime.now(); - Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); - Date expiration = Date.from(now.plusMonths(5).atZone(ZoneId.systemDefault()).toInstant()); - - return Jwts.builder() - .setHeaderParam("kid", appleProperties.getKeyId()) - .setHeaderParam("alg", "ES256") - .setIssuer(appleProperties.getTeamId()) - .setIssuedAt(issuedAt) - .setExpiration(expiration) - .setAudience("https://appleid.apple.com") - .setSubject(appleProperties.getClientId()) - .signWith(privateKey, SignatureAlgorithm.ES256) - .compact(); - - } catch (Exception e) { - log.error("애플 Client Secret 생성 실패", e); - throw new AppleAuthenticationException(); - } - } - - /** - * 파일 경로에서 InputStream 가져오기 - * 절대 경로면 파일 시스템에서, 상대 경로면 classpath에서 읽음 - */ - private InputStream getInputStream(String path) throws IOException { - // 절대 경로인 경우 파일 시스템에서 읽기 - if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { - return new FileInputStream(path); - } - // 상대 경로는 classpath에서 읽기 - return new ClassPathResource(path).getInputStream(); - } - - /** - * 애플 공개키로부터 PublicKey 객체 생성 - */ - private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { - try { - byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); - byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); - - BigInteger n = new BigInteger(1, nBytes); - BigInteger e = new BigInteger(1, eBytes); - - RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - - return keyFactory.generatePublic(publicKeySpec); - } catch (Exception ex) { - log.error("애플 공개키 생성 실패", ex); - throw new AppleAuthenticationException(); - } - } - - /** - * ID Token의 Claims 검증 - */ - private void validateClaims(Claims claims) { - String iss = claims.getIssuer(); - // JJWT 0.13.0+ returns Set for getAudience() - var audSet = claims.getAudience(); - String aud = audSet.iterator().hasNext() ? audSet.iterator().next() : null; - - if (!iss.equals("https://appleid.apple.com")) { - throw new RuntimeException("유효하지 않은 발급자(issuer)입니다."); - } - - // audience가 clientId와 일치하는지 확인 - if (aud == null || !aud.equals(appleProperties.getClientId())) { - log.error("유효하지 않은 audience: {}. 기대값: {}", aud, appleProperties.getClientId()); - throw new RuntimeException("유효하지 않은 수신자(audience)입니다."); - } - - Date expiration = claims.getExpiration(); - if (expiration.before(new Date())) { - throw new RuntimeException("만료된 ID Token입니다."); - } - } - - /** - * JSON 문자열을 Map으로 파싱 - */ - @SuppressWarnings("unchecked") - private Map parseJson(String json) { - try { - com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return objectMapper.readValue(json, Map.class); - } catch (Exception e) { - throw new RuntimeException("JSON 파싱 실패"); - } - } -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java deleted file mode 100644 index b84cfb3b..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKey.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record ApplePublicKey( - String kty, - String kid, - String use, - String alg, - String n, - String e -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java b/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java deleted file mode 100644 index 6c247f5a..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/ApplePublicKeys.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import java.util.List; - -public record ApplePublicKeys( - List keys -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java deleted file mode 100644 index 31944ec5..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -public record AppleTokenResponse( - String access_token, - String token_type, - Long expires_in, - String refresh_token, - String id_token -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java b/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java deleted file mode 100644 index 6f895fe9..00000000 --- a/src/main/java/com/weeth/global/auth/apple/dto/AppleUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.apple.dto; - -import lombok.Builder; - -@Builder -public record AppleUserInfo( - String appleId, - String email, - Boolean emailVerified -) { -} diff --git a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java b/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java deleted file mode 100644 index 0ad880ed..00000000 --- a/src/main/java/com/weeth/global/auth/apple/exception/AppleAuthenticationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.apple.exception; - -import com.weeth.global.common.exception.BaseException; - -public class AppleAuthenticationException extends BaseException { - public AppleAuthenticationException() { - super(401, "애플 로그인에 실패했습니다."); - } -} diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt new file mode 100644 index 00000000..f33fda1d --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -0,0 +1,245 @@ +package com.weeth.global.auth.apple + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.weeth.global.auth.apple.dto.ApplePublicKey +import com.weeth.global.auth.apple.dto.ApplePublicKeys +import com.weeth.global.auth.apple.dto.AppleTokenResponse +import com.weeth.global.auth.apple.dto.AppleUserInfo +import com.weeth.global.auth.apple.exception.AppleAuthenticationException +import com.weeth.global.config.properties.OAuthProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import org.slf4j.LoggerFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.Base64 +import java.util.Date + +@Service +class AppleAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, + private val objectMapper: ObjectMapper, + private val clock: Clock = Clock.systemUTC(), +) { + private val log = LoggerFactory.getLogger(javaClass) + + private val appleProperties = oAuthProperties.apple + private val restClient = restClientBuilder.build() + private val publicKeysTtl: Duration = Duration.ofHours(1) + @Volatile private var cachedPublicKeys: ApplePublicKeys? = null + @Volatile private var cachedPublicKeysExpiresAt: Instant = Instant.EPOCH + private val privateKey: PrivateKey by lazy { loadPrivateKey() } + + fun getAppleToken(authCode: String): AppleTokenResponse { + val clientSecret = generateClientSecret() + + val body = + LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", appleProperties.clientId) + add("client_secret", clientSecret) + add("code", authCode) + add("redirect_uri", appleProperties.redirectUri) + } + + return requireNotNull( + restClient + .post() + .uri(appleProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun verifyAndDecodeIdToken(idToken: String): AppleUserInfo { + try { + val tokenParts = idToken.split(".") + if (tokenParts.size < 2) { + throw AppleAuthenticationException() + } + val header = decodeBase64Url(tokenParts[0]) + val headerJson = parseJson(header) + val kid = headerJson["kid"]?.asText()?.takeIf { it.isNotBlank() } ?: throw AppleAuthenticationException() + val alg = headerJson["alg"]?.asText() + if (alg != "RS256") { + throw AppleAuthenticationException() + } + + val publicKeys = getApplePublicKeys() + + val matchedKey = + publicKeys.keys + .firstOrNull { key -> key.kid == kid } + ?: throw AppleAuthenticationException() + + val publicKey = generatePublicKey(matchedKey) + val claims = + Jwts + .parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .payload + + validateClaims(claims) + + val appleId = claims.subject + val email = claims.get("email", String::class.java) + val emailVerified = parseEmailVerified(claims["email_verified"]) + + return AppleUserInfo( + appleId = appleId, + email = email, + emailVerified = emailVerified, + ) + } catch (e: Exception) { + log.error("애플 ID Token 검증 실패", e) + throw AppleAuthenticationException() + } + } + + private fun generateClientSecret(): String { + try { + val now = Instant.now(clock) + val expiration = now.plus(Duration.ofDays(150)) // Apple limit is <= 6 months. + + return Jwts + .builder() + .header() + .keyId(appleProperties.keyId) + .and() + .issuer(appleProperties.teamId) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .audience() + .add("https://appleid.apple.com") + .and() + .subject(appleProperties.clientId) + .signWith(privateKey, Jwts.SIG.ES256) + .compact() + } catch (e: Exception) { + log.error("애플 Client Secret 생성 실패", e) + throw AppleAuthenticationException() + } + } + + private fun loadPrivateKey(): PrivateKey = + try { + getInputStream(appleProperties.privateKeyPath).use { inputStream -> + var privateKeyContent = String(inputStream.readAllBytes(), StandardCharsets.UTF_8) + privateKeyContent = + privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(privateKeyContent) + val keyFactory = KeyFactory.getInstance("EC") + keyFactory.generatePrivate(PKCS8EncodedKeySpec(keyBytes)) + } + } catch (e: Exception) { + log.error("애플 개인키 로드 실패", e) + throw AppleAuthenticationException() + } + + @Throws(IOException::class) + private fun getInputStream(path: String): InputStream = + if (path.startsWith("/") || path.matches(Regex("^[A-Za-z]:.*"))) { + FileInputStream(path) + } else { + ClassPathResource(path).inputStream + } + + private fun generatePublicKey(applePublicKey: ApplePublicKey): PublicKey = + try { + val nBytes = Base64.getUrlDecoder().decode(applePublicKey.n) + val eBytes = Base64.getUrlDecoder().decode(applePublicKey.e) + + val n = BigInteger(1, nBytes) + val e = BigInteger(1, eBytes) + + val publicKeySpec = RSAPublicKeySpec(n, e) + val keyFactory = KeyFactory.getInstance("RSA") + + keyFactory.generatePublic(publicKeySpec) + } catch (ex: Exception) { + log.error("애플 공개키 생성 실패", ex) + throw AppleAuthenticationException() + } + + private fun validateClaims(claims: Claims) { + val iss = claims.issuer + val audiences = claims.audience + val expiration = claims.expiration + val now = Date.from(Instant.now(clock)) + + when { + iss != "https://appleid.apple.com" -> throw RuntimeException("유효하지 않은 발급자(issuer)입니다.") + audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { + log.error("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) + throw RuntimeException("유효하지 않은 수신자(audience)입니다.") + } + expiration.before(now) -> throw RuntimeException("만료된 ID Token입니다.") + claims.subject.isNullOrBlank() -> throw RuntimeException("유효하지 않은 subject입니다.") + } + } + + private fun getApplePublicKeys(): ApplePublicKeys { + val now = Instant.now(clock) + val cached = cachedPublicKeys + if (cached != null && now.isBefore(cachedPublicKeysExpiresAt)) { + return cached + } + + val fetched = + requireNotNull( + restClient + .get() + .uri(appleProperties.keysUri) + .retrieve() + .body(), + ) + + cachedPublicKeys = fetched + cachedPublicKeysExpiresAt = now.plus(publicKeysTtl) + return fetched + } + + private fun parseJson(json: String): ObjectNode = + try { + objectMapper.readTree(json) as? ObjectNode ?: throw RuntimeException("JSON 객체가 아닙니다.") + } catch (e: Exception) { + throw RuntimeException("JSON 파싱 실패") + } + + private fun decodeBase64Url(value: String): String = + String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) + + private fun parseEmailVerified(raw: Any?): Boolean = + when (raw) { + is Boolean -> raw + is String -> raw.toBooleanStrictOrNull() ?: false + else -> false + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt new file mode 100644 index 00000000..8d778923 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKey.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKey( + val kty: String, + val kid: String, + val use: String, + val alg: String, + val n: String, + val e: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt new file mode 100644 index 00000000..82950ccf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/ApplePublicKeys.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.apple.dto + +data class ApplePublicKeys( + val keys: List, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt new file mode 100644 index 00000000..5cb7f8ee --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.apple.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class AppleTokenResponse( + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("expires_in") + val expiresIn: Long, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("id_token") + val idToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt new file mode 100644 index 00000000..6678fb98 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/dto/AppleUserInfo.kt @@ -0,0 +1,7 @@ +package com.weeth.global.auth.apple.dto + +data class AppleUserInfo( + val appleId: String, + val email: String?, + val emailVerified: Boolean, +) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt new file mode 100644 index 00000000..02ebf951 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/exception/AppleAuthenticationException.kt @@ -0,0 +1,6 @@ +package com.weeth.global.auth.apple.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.BaseException + +class AppleAuthenticationException : BaseException(JwtErrorCode.APPLE_AUTHENTICATION_FAILED) From b7e1fcb8d7d109d36862c602244f250504963ef3 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:37:12 +0900 Subject: [PATCH 03/17] =?UTF-8?q?refactor:=20Kakao=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/kakao/KakaoAuthService.java | 47 ------------------ .../auth/kakao/dto/KakaoAccessToken.java | 6 --- .../global/auth/kakao/dto/KakaoAccount.java | 8 --- .../auth/kakao/dto/KakaoTokenResponse.java | 10 ---- .../auth/kakao/dto/KakaoUserInfoResponse.java | 7 --- .../global/auth/kakao/KakaoAuthService.kt | 49 +++++++++++++++++++ .../global/auth/kakao/dto/KakaoAccessToken.kt | 8 +++ .../global/auth/kakao/dto/KakaoAccount.kt | 12 +++++ .../auth/kakao/dto/KakaoTokenResponse.kt | 16 ++++++ .../auth/kakao/dto/KakaoUserInfoResponse.kt | 10 ++++ 10 files changed, 95 insertions(+), 78 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java delete mode 100644 src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt diff --git a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java b/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java deleted file mode 100644 index de231ea3..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/KakaoAuthService.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.auth.kakao; - -import com.weeth.global.auth.kakao.dto.KakaoTokenResponse; -import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; -import com.weeth.global.config.properties.OAuthProperties; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestClient; - -@Service -@Slf4j -public class KakaoAuthService { - - private final OAuthProperties.KakaoProperties kakaoProperties; - private final RestClient restClient = RestClient.create(); - - public KakaoAuthService(OAuthProperties oAuthProperties) { - this.kakaoProperties = oAuthProperties.getKakao(); - } - - public KakaoTokenResponse getKakaoToken(String authCode) { - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("grant_type", kakaoProperties.getGrantType()); - body.add("client_id", kakaoProperties.getClientId()); - body.add("redirect_uri", kakaoProperties.getRedirectUri()); - body.add("code", authCode); - - return restClient.post() - .uri(kakaoProperties.getTokenUri()) - .body(body) - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .retrieve() - .body(KakaoTokenResponse.class); - } - - public KakaoUserInfoResponse getUserInfo(String accessToken) { - return restClient.get() - .uri(kakaoProperties.getUserInfoUri()) - .header("Authorization", "Bearer " + accessToken) - .retrieve() - .body(KakaoUserInfoResponse.class); - - } -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java deleted file mode 100644 index 21a18865..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccessToken.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccessToken ( - String accessToken -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java deleted file mode 100644 index 6aaaf0f4..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoAccount.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoAccount( - Boolean is_email_valid, - Boolean is_email_verified, - String email -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java deleted file mode 100644 index 9bc612de..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoTokenResponse( - String token_type, - String access_token, - Integer expires_in, - String refresh_token, - Integer refresh_token_expires_in -) { -} diff --git a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java b/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java deleted file mode 100644 index e9e58760..00000000 --- a/src/main/java/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.global.auth.kakao.dto; - -public record KakaoUserInfoResponse( - Long id, - KakaoAccount kakao_account -) { -} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt new file mode 100644 index 00000000..e93662ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt @@ -0,0 +1,49 @@ +package com.weeth.global.auth.kakao + +import com.weeth.global.auth.kakao.dto.KakaoTokenResponse +import com.weeth.global.auth.kakao.dto.KakaoUserInfoResponse +import com.weeth.global.config.properties.OAuthProperties +import org.springframework.http.MediaType +import org.springframework.stereotype.Service +import org.springframework.util.LinkedMultiValueMap +import org.springframework.web.client.RestClient +import org.springframework.web.client.body + +@Service +class KakaoAuthService( + oAuthProperties: OAuthProperties, + restClientBuilder: RestClient.Builder, + ) { + private val kakaoProperties = oAuthProperties.kakao + private val restClient = restClientBuilder.build() + + fun getKakaoToken(authCode: String): KakaoTokenResponse { + val body = + LinkedMultiValueMap().apply { + add("grant_type", kakaoProperties.grantType) + add("client_id", kakaoProperties.clientId) + add("redirect_uri", kakaoProperties.redirectUri) + add("code", authCode) + } + + return requireNotNull( + restClient + .post() + .uri(kakaoProperties.tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(), + ) + } + + fun getUserInfo(accessToken: String): KakaoUserInfoResponse = + requireNotNull( + restClient + .get() + .uri(kakaoProperties.userInfoUri) + .header("Authorization", "Bearer $accessToken") + .retrieve() + .body(), + ) +} diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt new file mode 100644 index 00000000..95fa14f5 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccessToken.kt @@ -0,0 +1,8 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccessToken( + @field:JsonProperty("access_token") + val accessToken: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt new file mode 100644 index 00000000..11a93f6e --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt @@ -0,0 +1,12 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoAccount( + @field:JsonProperty("is_email_valid") + val isEmailValid: Boolean, + @field:JsonProperty("is_email_verified") + val isEmailVerified: Boolean, + @field:JsonProperty("email") + val email: String, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt new file mode 100644 index 00000000..f188c14b --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoTokenResponse.kt @@ -0,0 +1,16 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoTokenResponse( + @field:JsonProperty("token_type") + val tokenType: String, + @field:JsonProperty("access_token") + val accessToken: String, + @field:JsonProperty("expires_in") + val expiresIn: Int, + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:JsonProperty("refresh_token_expires_in") + val refreshTokenExpiresIn: Int, +) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt new file mode 100644 index 00000000..7633c77a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,10 @@ +package com.weeth.global.auth.kakao.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class KakaoUserInfoResponse( + @field:JsonProperty("id") + val id: Long, + @field:JsonProperty("kakao_account") + val kakaoAccount: KakaoAccount, +) From ac6418aa0193cc107042480299e5c2057c951372 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:38:01 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20Config=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/global/config/AwsS3Config.java | 29 --- .../com/weeth/global/config/RedisConfig.java | 47 ----- .../weeth/global/config/SecurityConfig.java | 115 ----------- .../com/weeth/global/config/WebMvcConfig.java | 19 -- .../global/config/swagger/SwaggerConfig.java | 195 ------------------ .../com/weeth/global/config/AwsS3Config.kt | 28 +++ .../com/weeth/global/config/RedisConfig.kt | 40 ++++ .../com/weeth/global/config/SecurityConfig.kt | 114 ++++++++++ .../com/weeth/global/config/SwaggerConfig.kt | 183 ++++++++++++++++ .../com/weeth/global/config/WebMvcConfig.kt | 15 ++ 10 files changed, 380 insertions(+), 405 deletions(-) delete mode 100644 src/main/java/com/weeth/global/config/AwsS3Config.java delete mode 100644 src/main/java/com/weeth/global/config/RedisConfig.java delete mode 100644 src/main/java/com/weeth/global/config/SecurityConfig.java delete mode 100644 src/main/java/com/weeth/global/config/WebMvcConfig.java delete mode 100644 src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java create mode 100644 src/main/kotlin/com/weeth/global/config/AwsS3Config.kt create mode 100644 src/main/kotlin/com/weeth/global/config/RedisConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/SecurityConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt create mode 100644 src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt diff --git a/src/main/java/com/weeth/global/config/AwsS3Config.java b/src/main/java/com/weeth/global/config/AwsS3Config.java deleted file mode 100644 index b53a82f4..00000000 --- a/src/main/java/com/weeth/global/config/AwsS3Config.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.AwsS3Properties; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; - -@Configuration -@RequiredArgsConstructor -public class AwsS3Config { - - private final AwsS3Properties awsS3Properties; - - @Bean - public S3Presigner s3Presigner() { - AwsBasicCredentials credentials = AwsBasicCredentials.create( - awsS3Properties.getCredentials().getAccessKey(), - awsS3Properties.getCredentials().getSecretKey() - ); - return S3Presigner.builder() - .region(Region.of(awsS3Properties.getRegion().getStatic())) - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .build(); - } -} diff --git a/src/main/java/com/weeth/global/config/RedisConfig.java b/src/main/java/com/weeth/global/config/RedisConfig.java deleted file mode 100644 index b7fe36ab..00000000 --- a/src/main/java/com/weeth/global/config/RedisConfig.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.config.properties.RedisProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisKeyValueAdapter; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; -import org.springframework.data.redis.serializer.StringRedisSerializer; - -@Configuration -@RequiredArgsConstructor -@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) // redis index ttl 설정 -public class RedisConfig { - - private final RedisProperties redisProperties; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); - - redisConfiguration.setHostName(redisProperties.getHost()); - redisConfiguration.setPort(redisProperties.getPort()); - if (redisProperties.getPassword() != null && !redisProperties.getPassword().isEmpty()) { - redisConfiguration.setPassword(redisProperties.getPassword()); - } - - return new LettuceConnectionFactory(redisConfiguration); - } - - - @Bean - public RedisTemplate redisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - - redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new StringRedisSerializer()); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - - return redisTemplate; - } - -} diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java deleted file mode 100644 index 223c0e71..00000000 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.weeth.global.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.global.auth.authentication.CustomAccessDeniedHandler; -import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint; -import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; -import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter; -import com.weeth.global.auth.jwt.service.JwtProvider; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authorization.AuthorizationDecision; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.Arrays; - -import static org.springframework.security.config.Customizer.withDefaults; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final JwtProvider jwtProvider; - private final JwtService jwtService; - private final JwtManageUseCase jwtManageUseCase; - private final ObjectMapper objectMapper; - - private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - private final CustomAccessDeniedHandler customAccessDeniedHandler; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .cors(withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .headers( - headersConfigurer -> - headersConfigurer - .frameOptions( - HeadersConfigurer.FrameOptionsConfig::sameOrigin - ) - ) - // 세션 사용하지 않으므로 STATELESS로 설정 - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - //== URL별 권한 관리 옵션 ==// - .authorizeHttpRequests( - authorize -> - authorize - .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apple/login", "/api/v1/users/apple/register", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() - .requestMatchers("/health-check").permitAll() - .requestMatchers("/admin", "/admin/login", "/admin/account", "/admin/meeting", "/admin/member", "/admin/penalty").permitAll() - // 스웨거 경로 - .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**").permitAll() - .requestMatchers("/actuator/prometheus") - .access((authentication, context) -> { - String ip = context.getRequest().getRemoteAddr(); - boolean allowed = ip.startsWith("172.") || ip.equals("127.0.0.1"); - return new AuthorizationDecision(allowed); - }) - .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/v1/admin/**", "/api/v4/admin/**").hasRole("ADMIN") - .anyRequest().authenticated() - ) - .exceptionHandling(exceptionHandling -> - exceptionHandling - .authenticationEntryPoint(customAuthenticationEntryPoint) - .accessDeniedHandler(customAccessDeniedHandler)) - .addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class) - .build(); - } - - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); - configuration.setExposedHeaders(Arrays.asList("Authorization", "Authorization_refresh")); - configuration.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } - - @Bean - public PasswordEncoder passwordEncoder() { - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); - } - - @Bean - public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { - return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService); - } -} diff --git a/src/main/java/com/weeth/global/config/WebMvcConfig.java b/src/main/java/com/weeth/global/config/WebMvcConfig.java deleted file mode 100644 index d0127ba9..00000000 --- a/src/main/java/com/weeth/global/config/WebMvcConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.global.config; - -import com.weeth.global.auth.resolver.CurrentUserArgumentResolver; -import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.List; - -@Configuration -public class WebMvcConfig implements WebMvcConfigurer { - - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(new CurrentUserArgumentResolver()); - resolvers.add(new CurrentUserRoleArgumentResolver()); - } -} diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java deleted file mode 100644 index ad5958f1..00000000 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.weeth.global.config.swagger; - -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExampleHolder; -import com.weeth.global.common.response.CommonResponse; -import com.weeth.global.config.properties.JwtProperties; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.examples.Example; -import io.swagger.v3.oas.models.media.Content; -import io.swagger.v3.oas.models.media.MediaType; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.oas.models.responses.ApiResponses; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; -import lombok.RequiredArgsConstructor; -import org.springdoc.core.customizers.OperationCustomizer; -import org.springdoc.core.models.GroupedOpenApi; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.groupingBy; - -@Configuration -@RequiredArgsConstructor -@OpenAPIDefinition( - info = @Info( - title = "Weeth API", - version = "v4.0.0", - description = """ - ## Response Code 규칙 - - Success: **1xxx** - - Domain Error: **2xxx** - - Server Error: **3xxx** - - Client Error: **4xxx** - - ## 도메인별 코드 범위 - | Domain | Success | Error | - |--------|---------|------| - | Account | 11xx | 21xx | - | Attendance | 12xx | 22xx | - | Board | 13xx | 23xx | - | Comment | 14xx | 24xx | - | File | 15xx | 25xx | - | Penalty | 16xx | 26xx | - | Schedule | 17xx | 27xx | - | User | 18xx | 28xx | - | Auth/JWT (Global) | - | 29xx | - - > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. - """ - ) -) -public class SwaggerConfig { - - private final JwtProperties jwtProperties; - - @Bean - public OpenAPI openAPI() { - SecurityScheme accessSecurityScheme = getAccessSecurityScheme(); - SecurityScheme refreshSecurityScheme = getRefreshSecurityScheme(); - - return new OpenAPI() - .addServersItem(new Server().url("/")) - .components(new Components() - .addSecuritySchemes("bearerAuth", accessSecurityScheme) - .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme)) - .security(List.of( - new SecurityRequirement().addList("bearerAuth"), - new SecurityRequirement().addList("refreshBearerAuth") - )); - } - - @Bean - public GroupedOpenApi adminApi() { - return GroupedOpenApi.builder() - .group("admin") - .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") - .addOperationCustomizer(operationCustomizer()) - .build(); - } - - @Bean - public GroupedOpenApi publicApi() { - return GroupedOpenApi.builder() - .group("public") - .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") - .addOperationCustomizer(operationCustomizer()) - .build(); - } - - @Bean - public OperationCustomizer operationCustomizer() { - return (operation, handlerMethod) -> { - ApiErrorCodeExample apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample.class); - if (apiErrorCodeExample != null) { - for (Class type : apiErrorCodeExample.value()) { - generateErrorCodeResponseExample(operation.getResponses(), type); - } - } - - return operation; - }; - } - - private void generateErrorCodeResponseExample(ApiResponses responses, Class type) { - ErrorCodeInterface[] errorCodes = type.getEnumConstants(); - - Map> statusWithExampleHolders = - Arrays.stream(errorCodes) - .map(errorCode -> { - try { - String enumName = ((Enum) errorCode).name(); - - return ExampleHolder.builder() - .holder(getSwaggerExample(errorCode.getExplainError(), errorCode)) - .code(errorCode.getStatus().value()) - .name("[" + enumName + "] " + errorCode.getMessage()) - .build(); - } catch (NoSuchFieldException e) { - throw new RuntimeException(e); - } - }) - .collect(groupingBy(ExampleHolder::getCode)); - - addExamplesToResponses(responses, statusWithExampleHolders); - } - - private Example getSwaggerExample(String description, ErrorCodeInterface errorCode) { - CommonResponse errorResponse = CommonResponse.createFailure(errorCode.getCode(), errorCode.getMessage()); - Example example = new Example(); - example.description(description); - example.setValue(errorResponse); - - return example; - } - - private void addExamplesToResponses(ApiResponses responses, Map> statusWithExampleHolders) { - statusWithExampleHolders.forEach((status, exampleHolders) -> { - ApiResponse apiResponse = responses.computeIfAbsent(String.valueOf(status), k -> new ApiResponse()); - MediaType mediaType = getOrCreateMediaType(apiResponse); - exampleHolders.forEach(holder -> mediaType.addExamples(holder.getName(), holder.getHolder())); - }); - } - - private A findAnnotation(org.springframework.web.method.HandlerMethod handlerMethod, Class annotationType) { - A annotation = handlerMethod.getMethodAnnotation(annotationType); - if (annotation != null) { - return annotation; - } - return handlerMethod.getBeanType().getAnnotation(annotationType); - } - - private MediaType getOrCreateMediaType(ApiResponse apiResponse) { - Content content = apiResponse.getContent(); - if (content == null) { - content = new Content(); - apiResponse.setContent(content); - } - - MediaType mediaType = content.get("application/json"); - if (mediaType == null) { - mediaType = new MediaType(); - content.addMediaType("application/json", mediaType); - } - - return mediaType; - } - - private SecurityScheme getAccessSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getAccess().getHeader()); - } - - private SecurityScheme getRefreshSecurityScheme() { - return new SecurityScheme() - .type(SecurityScheme.Type.APIKEY) - .scheme("bearer") - .bearerFormat("JWT") - .in(SecurityScheme.In.HEADER) - .name(jwtProperties.getRefresh().getHeader()); - } -} diff --git a/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt new file mode 100644 index 00000000..bc8feeb6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/AwsS3Config.kt @@ -0,0 +1,28 @@ +package com.weeth.global.config + +import com.weeth.global.config.properties.AwsS3Properties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +@Configuration +class AwsS3Config( + private val awsS3Properties: AwsS3Properties, +) { + @Bean + fun s3Presigner(): S3Presigner { + val credentials = + AwsBasicCredentials.create( + awsS3Properties.credentials.accessKey, + awsS3Properties.credentials.secretKey, + ) + return S3Presigner + .builder() + .region(Region.of(awsS3Properties.region.static)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/RedisConfig.kt b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt new file mode 100644 index 00000000..cffd6992 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/RedisConfig.kt @@ -0,0 +1,40 @@ +package com.weeth.global.config + +import com.weeth.global.config.properties.RedisProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.core.RedisKeyValueAdapter +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories +import org.springframework.data.redis.serializer.StringRedisSerializer + +@Configuration +@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) +class RedisConfig( + private val redisProperties: RedisProperties, +) { + @Bean + fun redisConnectionFactory(): RedisConnectionFactory { + val redisConfiguration = + RedisStandaloneConfiguration().apply { + hostName = redisProperties.host + port = redisProperties.port + if (!redisProperties.password.isNullOrEmpty()) { + setPassword(redisProperties.password) + } + } + + return LettuceConnectionFactory(redisConfiguration) + } + + @Bean + fun redisTemplate(): RedisTemplate = + RedisTemplate().apply { + keySerializer = StringRedisSerializer() + valueSerializer = StringRedisSerializer() + connectionFactory = redisConnectionFactory() + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt new file mode 100644 index 00000000..10dc755f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -0,0 +1,114 @@ +package com.weeth.global.config + +import com.weeth.global.auth.authentication.CustomAccessDeniedHandler +import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint +import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider +import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.config.Customizer.withDefaults +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +class SecurityConfig( + private val jwtTokenProvider: JwtTokenProvider, + private val jwtTokenExtractor: JwtTokenExtractor, + private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint, + private val customAccessDeniedHandler: CustomAccessDeniedHandler, +) { + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain = + http + .formLogin { it.disable() } + .httpBasic { it.disable() } + .cors(withDefaults()) + .csrf { it.disable() } + .headers { headers -> + headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } + }.sessionManagement { session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers( + "/api/v1/users/kakao/login", + "/api/v1/users/kakao/register", + "/api/v1/users/kakao/link", + "/api/v1/users/apple/login", + "/api/v1/users/apple/register", + "/api/v1/users/apply", + "/api/v1/users/email", + "/api/v1/users/refresh", + ).permitAll() + .requestMatchers("/health-check") + .permitAll() + .requestMatchers( + "/admin", + "/admin/login", + "/admin/account", + "/admin/meeting", + "/admin/member", + "/admin/penalty", + ).permitAll() + .requestMatchers( + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/swagger/**", + ).permitAll() + .requestMatchers("/actuator/prometheus") + .access { _, context -> + val ip = context.request.remoteAddr + val allowed = ip.startsWith("172.") || ip == "127.0.0.1" + AuthorizationDecision(allowed) + }.requestMatchers("/actuator/health") + .permitAll() + .requestMatchers( + "/api/v1/admin/**", + "/api/v4/admin/**", + ).hasRole("ADMIN") + .anyRequest() + .authenticated() + }.exceptionHandling { exceptionHandling -> + exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) + }.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter::class.java) + .build() + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = + CorsConfiguration().apply { + allowedOriginPatterns = listOf("http://localhost:*", "http://127.0.0.1:*") + allowedMethods = listOf("GET", "POST", "PATCH", "DELETE", "OPTIONS") + allowedHeaders = listOf("*") + exposedHeaders = listOf("Authorization", "Authorization_refresh") + allowCredentials = true + } + + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", configuration) + } + } + + @Bean + fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + + @Bean + fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) +} diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt new file mode 100644 index 00000000..83b6002f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -0,0 +1,183 @@ +package com.weeth.global.config + +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExampleHolder +import com.weeth.global.common.response.CommonResponse +import com.weeth.global.config.properties.JwtProperties +import io.swagger.v3.oas.annotations.OpenAPIDefinition +import io.swagger.v3.oas.annotations.info.Info +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import org.springdoc.core.customizers.OperationCustomizer +import org.springdoc.core.models.GroupedOpenApi +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.HandlerMethod + +@Configuration +@OpenAPIDefinition( + info = + Info( + title = "Weeth API", + version = "v4.0.0", + description = """ + ## Response Code 규칙 + - Success: **1xxx** + - Domain Error: **2xxx** + - Server Error: **3xxx** + - Client Error: **4xxx** + + ## 도메인별 코드 범위 + | Domain | Success | Error | + |--------|---------|------| + | Account | 11xx | 21xx | + | Attendance | 12xx | 22xx | + | Board | 13xx | 23xx | + | Comment | 14xx | 24xx | + | File | 15xx | 25xx | + | Penalty | 16xx | 26xx | + | Schedule | 17xx | 27xx | + | User | 18xx | 28xx | + | Auth/JWT (Global) | - | 29xx | + + > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. + """, + ), +) +class SwaggerConfig( + private val jwtProperties: JwtProperties, +) { + @Bean + fun openAPI(): OpenAPI { + val accessSecurityScheme = getAccessSecurityScheme() + val refreshSecurityScheme = getRefreshSecurityScheme() + + return OpenAPI() + .addServersItem(Server().url("/")) + .components( + Components() + .addSecuritySchemes("bearerAuth", accessSecurityScheme) + .addSecuritySchemes("refreshBearerAuth", refreshSecurityScheme), + ).security( + listOf( + SecurityRequirement().addList("bearerAuth"), + SecurityRequirement().addList("refreshBearerAuth"), + ), + ) + } + + @Bean + fun adminApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("admin") + .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun publicApi(): GroupedOpenApi = + GroupedOpenApi + .builder() + .group("public") + .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") + .addOperationCustomizer(operationCustomizer()) + .build() + + @Bean + fun operationCustomizer(): OperationCustomizer = + OperationCustomizer { operation, handlerMethod -> + val apiErrorCodeExample = findAnnotation(handlerMethod, ApiErrorCodeExample::class.java) + if (apiErrorCodeExample != null) { + apiErrorCodeExample.value.forEach { type -> + generateErrorCodeResponseExample(operation.responses, type.java) + } + } + + operation + } + + private fun generateErrorCodeResponseExample( + responses: ApiResponses, + type: Class, + ) { + val errorCodes = type.enumConstants ?: return + + val statusWithExampleHolders = + errorCodes + .map { errorCode -> + val enumName = (errorCode as Enum<*>).name + val description = runCatching { errorCode.getExplainError() }.getOrDefault(errorCode.getMessage()) + + ExampleHolder( + holder = getSwaggerExample(description, errorCode), + code = errorCode.getStatus().value(), + name = "[$enumName] ${errorCode.getMessage()}", + ) + }.groupBy { it.code } + + addExamplesToResponses(responses, statusWithExampleHolders) + } + + private fun getSwaggerExample( + description: String, + errorCode: ErrorCodeInterface, + ): Example { + val errorResponse = CommonResponse.Companion.createFailure(errorCode.getCode(), errorCode.getMessage()) + return Example() + .description(description) + .value(errorResponse) + } + + private fun addExamplesToResponses( + responses: ApiResponses, + statusWithExampleHolders: Map>, + ) { + statusWithExampleHolders.forEach { (status, exampleHolders) -> + val apiResponse = responses.computeIfAbsent(status.toString()) { ApiResponse() } + val mediaType = getOrCreateMediaType(apiResponse) + exampleHolders.forEach { holder -> mediaType.addExamples(holder.name, holder.holder) } + } + } + + private fun findAnnotation( + handlerMethod: HandlerMethod, + annotationType: Class, + ): A? { + val annotation = handlerMethod.getMethodAnnotation(annotationType) + if (annotation != null) { + return annotation + } + return handlerMethod.beanType.getAnnotation(annotationType) + } + + private fun getOrCreateMediaType(apiResponse: ApiResponse): MediaType { + val content = apiResponse.content ?: Content().also { apiResponse.content = it } + return content["application/json"] ?: MediaType().also { content.addMediaType("application/json", it) } + } + + private fun getAccessSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.access.header) + + private fun getRefreshSecurityScheme(): SecurityScheme = + SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .scheme("bearer") + .bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER) + .name(jwtProperties.refresh.header) +} diff --git a/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt new file mode 100644 index 00000000..43b02d4a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/WebMvcConfig.kt @@ -0,0 +1,15 @@ +package com.weeth.global.config + +import com.weeth.global.auth.resolver.CurrentUserArgumentResolver +import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(CurrentUserArgumentResolver()) + resolvers.add(CurrentUserRoleArgumentResolver()) + } +} From 1003617edda2d19bfd4493e5612f34eb58c29e9b Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:39:17 +0900 Subject: [PATCH 05/17] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/authentication/ErrorMessage.java | 16 --- .../RedisTokenNotFoundException.java | 9 -- .../jwt/exception/TokenNotFoundException.java | 9 -- .../common/exception/ApiErrorCodeExample.java | 12 --- .../common/exception/BaseException.java | 32 ------ .../exception/BindExceptionResponse.java | 10 -- .../exception/CommonExceptionHandler.java | 97 ----------------- .../common/exception/ErrorCodeInterface.java | 19 ---- .../common/exception/ExampleHolder.java | 13 --- .../global/common/exception/ExplainError.java | 12 --- .../auth/authentication/ErrorMessage.kt | 9 ++ .../exception/RedisTokenNotFoundException.kt | 5 + .../exception/TokenNotFoundException.kt | 5 + .../controller/ExceptionDocController.kt} | 66 ++++++------ .../common/exception/ApiErrorCodeExample.kt | 9 ++ .../global/common/exception/BaseException.kt | 26 +++++ .../common/exception/BindExceptionResponse.kt | 6 ++ .../exception/CommonExceptionHandler.kt | 100 ++++++++++++++++++ .../common/exception/ErrorCodeInterface.kt | 18 ++++ .../global/common/exception/ExampleHolder.kt | 9 ++ .../global/common/exception/ExplainError.kt | 7 ++ .../exception/CommonExceptionHandlerTest.kt | 39 +++++++ 22 files changed, 266 insertions(+), 262 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java delete mode 100644 src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java delete mode 100644 src/main/java/com/weeth/global/common/exception/BaseException.java delete mode 100644 src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java delete mode 100644 src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ExampleHolder.java delete mode 100644 src/main/java/com/weeth/global/common/exception/ExplainError.java create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt rename src/main/{java/com/weeth/global/common/controller/ExceptionDocController.java => kotlin/com/weeth/global/common/controller/ExceptionDocController.kt} (51%) create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/BaseException.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt create mode 100644 src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt create mode 100644 src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt diff --git a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java b/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java deleted file mode 100644 index 970d768c..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/ErrorMessage.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.weeth.global.auth.authentication; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum ErrorMessage { - - UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), - FORBIDDEN(403, "권한이 없습니다."), - SC_BAD_REQUEST_PROVIDER(400, "잘못된 provider 요청입니다."); - - private final int code; - private final String message; -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java deleted file mode 100644 index 8fdd5e86..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/RedisTokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class RedisTokenNotFoundException extends BaseException { - public RedisTokenNotFoundException() { - super(JwtErrorCode.REDIS_TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java b/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java deleted file mode 100644 index 8f798861..00000000 --- a/src/main/java/com/weeth/global/auth/jwt/exception/TokenNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.global.auth.jwt.exception; - -import com.weeth.global.common.exception.BaseException; - -public class TokenNotFoundException extends BaseException { - public TokenNotFoundException() { - super(JwtErrorCode.TOKEN_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java b/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java deleted file mode 100644 index dda006c6..00000000 --- a/src/main/java/com/weeth/global/common/exception/ApiErrorCodeExample.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiErrorCodeExample { - Class[] value(); -} diff --git a/src/main/java/com/weeth/global/common/exception/BaseException.java b/src/main/java/com/weeth/global/common/exception/BaseException.java deleted file mode 100644 index c93f459a..00000000 --- a/src/main/java/com/weeth/global/common/exception/BaseException.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Getter; - -@Getter -public abstract class BaseException extends RuntimeException { - - private final int statusCode; - private final ErrorCodeInterface errorCode; - - public BaseException(int code, String message) { - super(message); - this.statusCode = code; - this.errorCode = null; - } - - public BaseException(int code, String message, Throwable cause) { - super(message, cause); - this.statusCode = code; - this.errorCode = null; - } - - public BaseException(ErrorCodeInterface errorCode, Throwable cause) { - super(errorCode.getMessage(), cause); - this.statusCode = errorCode.getStatus().value(); - this.errorCode = errorCode; - } - - public BaseException(ErrorCodeInterface errorCode) { - this(errorCode, null); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java b/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java deleted file mode 100644 index 572ed828..00000000 --- a/src/main/java/com/weeth/global/common/exception/BindExceptionResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.common.exception; - -import lombok.Builder; - -@Builder -public record BindExceptionResponse( - String message, - Object value -) { -} diff --git a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java b/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java deleted file mode 100644 index 394779a3..00000000 --- a/src/main/java/com/weeth/global/common/exception/CommonExceptionHandler.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.weeth.global.common.exception; - -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.ErrorResponse; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.util.ArrayList; -import java.util.List; - -@Slf4j -@RestControllerAdvice -public class CommonExceptionHandler { - - private static final String INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다."; - private static final String LOG_FORMAT = "Class : {}, Code : {}, Message : {}"; - - @ExceptionHandler(BaseException.class) // 커스텀 예외 처리 - public ResponseEntity> handle(BaseException ex) { - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), ex.getStatusCode(), ex.getMessage()); - - CommonResponse response = ex.getErrorCode() != null - ? CommonResponse.error(ex.getErrorCode()) - : CommonResponse.createFailure(ex.getStatusCode(), ex.getMessage()); - - return ResponseEntity - .status(ex.getStatusCode()) - .body(response); - } - - @ExceptionHandler(BindException.class) // BindException == @ModelAttribute 어노테이션으로 받은 파라미터의 @Valid 통해 발생한 Exception - public ResponseEntity>> handle(BindException ex) { - int statusCode = 400; - List exceptionResponses = new ArrayList<>(); - - if (ex instanceof ErrorResponse) { - statusCode = ((ErrorResponse) ex).getStatusCode().value(); - ex.getBindingResult().getFieldErrors().forEach(fieldError -> { - exceptionResponses.add(BindExceptionResponse.builder() - .message(fieldError.getDefaultMessage()) - .value(fieldError.getRejectedValue()) - .build()); - }); - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, exceptionResponses); - - CommonResponse> response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - // MethodArgumentTypeMismatchException == 클라이언트가 날짜 포맷을 다르게 입력한 경우 - public ResponseEntity> handle(MethodArgumentTypeMismatchException ex) { - int statusCode = 400; // 파라미터 값 실수이므로 4XX - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE); - - return ResponseEntity - .status(statusCode) - .body(response); - } - - @ExceptionHandler(Exception.class) // 모든 Exception 처리 - public ResponseEntity> handle(Exception ex) { - int statusCode = 500; - - if (ex instanceof ErrorResponse) { // Exception이 ErrorResponse의 인스턴스라면 (http status를 가지는 예외) - statusCode = ((ErrorResponse) ex).getStatusCode().value(); // ErrorResponse에서 상태 값 가져오기 - } - - log.warn("구체로그: ", ex); - log.warn(LOG_FORMAT, ex.getClass().getSimpleName(), statusCode, ex.getMessage()); - - CommonResponse response = CommonResponse.createFailure(statusCode, ex.getMessage()); - - return ResponseEntity - .status(statusCode) - .body(response); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java b/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java deleted file mode 100644 index de96249c..00000000 --- a/src/main/java/com/weeth/global/common/exception/ErrorCodeInterface.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.global.common.exception; - -import org.springframework.http.HttpStatus; - -import java.lang.reflect.Field; -import java.util.Objects; - -public interface ErrorCodeInterface { - int getCode(); - HttpStatus getStatus(); - String getMessage(); - - // ExplainError 어노테이션에 작성된 설명을 조회하는 메서드 - default String getExplainError() throws NoSuchFieldException { - Field field = this.getClass().getField(((Enum) this).name()); - ExplainError annotation = field.getAnnotation(ExplainError.class); - return Objects.nonNull(annotation) ? annotation.value() : getMessage(); - } -} diff --git a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java b/src/main/java/com/weeth/global/common/exception/ExampleHolder.java deleted file mode 100644 index 897bf1cb..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExampleHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.weeth.global.common.exception; - -import io.swagger.v3.oas.models.examples.Example; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ExampleHolder { - private Example holder; - private String name; - private int code; -} diff --git a/src/main/java/com/weeth/global/common/exception/ExplainError.java b/src/main/java/com/weeth/global/common/exception/ExplainError.java deleted file mode 100644 index f609ee3b..00000000 --- a/src/main/java/com/weeth/global/common/exception/ExplainError.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.weeth.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExplainError { - String value() default ""; -} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt new file mode 100644 index 00000000..2d458307 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/ErrorMessage.kt @@ -0,0 +1,9 @@ +package com.weeth.global.auth.authentication + +enum class ErrorMessage( + val code: Int, + val message: String, +) { + UNAUTHORIZED(401, "인증 정보가 존재하지 않습니다."), + FORBIDDEN(403, "권한이 없습니다."), +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt new file mode 100644 index 00000000..54b8fde8 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/RedisTokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class RedisTokenNotFoundException : BaseException(JwtErrorCode.REDIS_TOKEN_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt new file mode 100644 index 00000000..3a652367 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/exception/TokenNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.jwt.application.exception + +import com.weeth.global.common.exception.BaseException + +class TokenNotFoundException : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) diff --git a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt similarity index 51% rename from src/main/java/com/weeth/global/common/controller/ExceptionDocController.java rename to src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 95b7c199..08b2724c 100644 --- a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -1,65 +1,65 @@ -package com.weeth.global.common.controller; +package com.weeth.global.common.controller -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.account.application.exception.AccountErrorCode; -import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.comment.application.exception.CommentErrorCode; -import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; -import com.weeth.domain.schedule.application.exception.EventErrorCode; -import com.weeth.domain.schedule.application.exception.MeetingErrorCode; -import com.weeth.domain.user.application.exception.UserErrorCode; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.weeth.domain.account.application.exception.AccountErrorCode +import com.weeth.domain.attendance.application.exception.AttendanceErrorCode +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.comment.application.exception.CommentErrorCode +import com.weeth.domain.penalty.application.exception.PenaltyErrorCode +import com.weeth.domain.schedule.application.exception.EventErrorCode +import com.weeth.domain.schedule.application.exception.MeetingErrorCode +import com.weeth.domain.user.application.exception.UserErrorCode +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController @RestController -@RequestMapping("/api/v1/docs/exceptions") +@RequestMapping("/api/v4/docs/exceptions") @Tag(name = "Exception Document", description = "API 에러 코드 문서") -public class ExceptionDocController { - +class ExceptionDocController { @GetMapping("/account") @Operation(summary = "Account 도메인 에러 코드 목록") - @ApiErrorCodeExample(AccountErrorCode.class) - public void accountErrorCodes() { + @ApiErrorCodeExample(AccountErrorCode::class) + fun accountErrorCodes() { } @GetMapping("/attendance") @Operation(summary = "Attendance 도메인 에러 코드 목록") - @ApiErrorCodeExample(AttendanceErrorCode.class) - public void attendanceErrorCodes() { + @ApiErrorCodeExample(AttendanceErrorCode::class) + fun attendanceErrorCodes() { } @GetMapping("/board") @Operation(summary = "Board 도메인 에러 코드 목록") - @ApiErrorCodeExample({BoardErrorCode.class, CommentErrorCode.class}) - public void boardErrorCodes() { + @ApiErrorCodeExample(BoardErrorCode::class, CommentErrorCode::class) + fun boardErrorCodes() { } @GetMapping("/penalty") @Operation(summary = "Penalty 도메인 에러 코드 목록") - @ApiErrorCodeExample(PenaltyErrorCode.class) - public void penaltyErrorCodes() { + @ApiErrorCodeExample(PenaltyErrorCode::class) + fun penaltyErrorCodes() { } @GetMapping("/schedule") @Operation(summary = "Schedule 도메인 에러 코드 목록") - @ApiErrorCodeExample({EventErrorCode.class, MeetingErrorCode.class}) - public void scheduleErrorCodes() { + @ApiErrorCodeExample(EventErrorCode::class, MeetingErrorCode::class) + fun scheduleErrorCodes() { } @GetMapping("/user") @Operation(summary = "User 도메인 에러 코드 목록") - @ApiErrorCodeExample(UserErrorCode.class) - public void userErrorCodes() { + @ApiErrorCodeExample(UserErrorCode::class) + fun userErrorCodes() { } + //todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") - @ApiErrorCodeExample({JwtErrorCode.class}) - public void authErrorCodes() { + @ApiErrorCodeExample(JwtErrorCode::class) + fun authErrorCodes() { } } diff --git a/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt new file mode 100644 index 00000000..3a7c3caf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ApiErrorCodeExample.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiErrorCodeExample( + vararg val value: KClass, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt new file mode 100644 index 00000000..6bc0a895 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BaseException.kt @@ -0,0 +1,26 @@ +package com.weeth.global.common.exception + +abstract class BaseException : RuntimeException { + val statusCode: Int + val errorCode: ErrorCodeInterface? + + constructor(code: Int, message: String) : super(message) { + statusCode = code + errorCode = null + } + + constructor(code: Int, message: String, cause: Throwable) : super(message, cause) { + statusCode = code + errorCode = null + } + + constructor(errorCode: ErrorCodeInterface) : super(errorCode.getMessage()) { + statusCode = errorCode.getStatus().value() + this.errorCode = errorCode + } + + constructor(errorCode: ErrorCodeInterface, cause: Throwable?) : super(errorCode.getMessage(), cause) { + statusCode = errorCode.getStatus().value() + this.errorCode = errorCode + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt new file mode 100644 index 00000000..ae6e6fcf --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/BindExceptionResponse.kt @@ -0,0 +1,6 @@ +package com.weeth.global.common.exception + +data class BindExceptionResponse( + val message: String?, + val value: Any?, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt new file mode 100644 index 00000000..3cf6b9f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -0,0 +1,100 @@ +package com.weeth.global.common.exception + +import com.weeth.global.common.response.CommonResponse +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.validation.BindException +import org.springframework.web.ErrorResponse +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +@RestControllerAdvice +class CommonExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(BaseException::class) + fun handle(ex: BaseException): ResponseEntity> { + log.warn("구체로그: ", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) + + val errorCode = ex.errorCode + val response: CommonResponse = + if (errorCode != null) { + CommonResponse.error(errorCode) + } else { + CommonResponse.createFailure(ex.statusCode, ex.message ?: "") + } + + return ResponseEntity + .status(ex.statusCode) + .body(response) + } + + @ExceptionHandler(BindException::class) + fun handle(ex: BindException): ResponseEntity>> { + var statusCode = 400 + val exceptionResponses = mutableListOf() + + if (ex is ErrorResponse) { + statusCode = ex.statusCode.value() + ex.bindingResult.fieldErrors.forEach { fieldError -> + exceptionResponses.add( + BindExceptionResponse( + message = fieldError.defaultMessage, + value = fieldError.rejectedValue, + ), + ) + } + } + + log.warn("구체로그: ", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, exceptionResponses) + + val response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses.toList()) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(MethodArgumentTypeMismatchException::class) + fun handle(ex: MethodArgumentTypeMismatchException): ResponseEntity> { + var statusCode = 400 + if (ex is ErrorResponse) { + statusCode = ex.statusCode.value() + } + + log.warn("구체로그: ", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + + val response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE) + + return ResponseEntity + .status(statusCode) + .body(response) + } + + @ExceptionHandler(Exception::class) + fun handle(ex: Exception): ResponseEntity> { + var statusCode = 500 + + if (ex is ErrorResponse) { + statusCode = ex.statusCode.value() + } + + log.warn("구체로그: ", ex) + log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) + + val response = CommonResponse.createFailure(statusCode, ex.message ?: "") + + return ResponseEntity + .status(statusCode) + .body(response) + } + + companion object { + private const val INPUT_FORMAT_ERROR_MESSAGE = "입력 포맷이 올바르지 않습니다." + private const val LOG_FORMAT = "Class : {}, Code : {}, Message : {}" + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt new file mode 100644 index 00000000..a137f5ef --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ErrorCodeInterface.kt @@ -0,0 +1,18 @@ +package com.weeth.global.common.exception + +import org.springframework.http.HttpStatus + +interface ErrorCodeInterface { + fun getCode(): Int + + fun getStatus(): HttpStatus + + fun getMessage(): String + + @Throws(NoSuchFieldException::class) + fun getExplainError(): String { + val field = this::class.java.getField((this as Enum<*>).name) + val annotation = field.getAnnotation(ExplainError::class.java) + return annotation?.value ?: getMessage() + } +} diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt new file mode 100644 index 00000000..488f8acb --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExampleHolder.kt @@ -0,0 +1,9 @@ +package com.weeth.global.common.exception + +import io.swagger.v3.oas.models.examples.Example + +data class ExampleHolder( + val holder: Example, + val name: String, + val code: Int, +) diff --git a/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt new file mode 100644 index 00000000..ae445e96 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/ExplainError.kt @@ -0,0 +1,7 @@ +package com.weeth.global.common.exception + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class ExplainError( + val value: String = "", +) diff --git a/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt new file mode 100644 index 00000000..31576538 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/common/exception/CommonExceptionHandlerTest.kt @@ -0,0 +1,39 @@ +package com.weeth.global.common.exception + +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.validation.BeanPropertyBindingResult +import org.springframework.validation.BindException +import org.springframework.validation.FieldError + +class CommonExceptionHandlerTest : + DescribeSpec({ + val handler = CommonExceptionHandler() + + describe("handle(BaseException)") { + it("ErrorCode 기반 응답으로 변환한다") { + val ex = object : BaseException(JwtErrorCode.TOKEN_NOT_FOUND) {} + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 404 + response.body?.code shouldBe 2902 + } + } + + describe("handle(BindException)") { + it("필드 에러 목록을 CommonResponse로 반환한다") { + val bindingResult = BeanPropertyBindingResult(Any(), "request") + bindingResult.addError( + FieldError("request", "name", "", false, emptyArray(), emptyArray(), "must not be blank"), + ) + val ex = BindException(bindingResult) + + val response = handler.handle(ex) + + response.statusCode.value() shouldBe 400 + response.body?.message shouldBe "bindException" + } + } + }) From 67570ef32d61950e128a357ab082f5e3ef09dec4 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:42:49 +0900 Subject: [PATCH 06/17] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=94=84=EB=A7=81?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/annotation/CurrentUser.java | 11 --- .../auth/annotation/CurrentUserRole.java | 11 --- .../CustomAccessDeniedHandler.java | 33 --------- .../CustomAuthenticationEntryPoint.java | 33 --------- .../global/auth/model/AuthenticatedUser.java | 10 --- .../resolver/CurrentUserArgumentResolver.java | 39 ----------- .../CurrentUserRoleArgumentResolver.java | 48 ------------- .../global/auth/annotation/CurrentUser.kt | 5 ++ .../global/auth/annotation/CurrentUserRole.kt | 5 ++ .../CustomAccessDeniedHandler.kt | 45 +++++++++++++ .../CustomAuthenticationEntryPoint.kt | 45 +++++++++++++ .../global/auth/model/AuthenticatedUser.kt | 12 ++++ .../resolver/CurrentUserArgumentResolver.kt | 42 ++++++++++++ .../CurrentUserRoleArgumentResolver.kt | 50 ++++++++++++++ .../CurrentUserArgumentResolverTest.kt | 67 +++++++++++++++++++ 15 files changed, 271 insertions(+), 185 deletions(-) delete mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUser.java delete mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java delete mode 100644 src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java delete mode 100644 src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java delete mode 100644 src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java delete mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java delete mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java create mode 100644 src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java deleted file mode 100644 index 8b37b036..00000000 --- a/src/main/java/com/weeth/global/auth/annotation/CurrentUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUser { -} diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java deleted file mode 100644 index 56643824..00000000 --- a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.weeth.global.auth.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.PARAMETER}) -@Retention(RetentionPolicy.RUNTIME) -public @interface CurrentUserRole { -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java b/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java deleted file mode 100644 index cf605df0..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.web.access.AccessDeniedHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAccessDeniedHandler implements AccessDeniedHandler { - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", accessDeniedException.getClass().getSimpleName(), accessDeniedException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.FORBIDDEN.getCode(), ErrorMessage.FORBIDDEN.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java b/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java deleted file mode 100644 index b4f8c552..00000000 --- a/src/main/java/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.weeth.global.auth.authentication; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import com.weeth.global.common.response.CommonResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.stereotype.Component; - -import java.io.IOException; - -@Slf4j -@Component -public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - setResponse(response); - log.error("ExceptionClass: {}, Message: {}", authException.getClass().getSimpleName(), authException.getMessage()); - } - - private void setResponse(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - String message = new ObjectMapper().writeValueAsString(CommonResponse.createFailure(ErrorMessage.UNAUTHORIZED.getCode(), ErrorMessage.UNAUTHORIZED.getMessage())); - response.getWriter().write(message); - } -} diff --git a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java deleted file mode 100644 index b79c8800..00000000 --- a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.weeth.global.auth.model; - -import com.weeth.domain.user.domain.entity.enums.Role; - -public record AuthenticatedUser( - Long id, - String email, - Role role -) { -} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java deleted file mode 100644 index 49c801eb..00000000 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.global.auth.resolver; - -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.model.AuthenticatedUser; -import org.springframework.core.MethodParameter; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { // parameter가 해당 resolver를 지원하는 여부 확인 - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); // @CurrentUser이 존재하는가? - boolean parameterType = Long.class.isAssignableFrom(parameter.getParameterType()); // 파라미터 타입이 Long을 상속하거나 구현하였는가? - return hasAnnotation && parameterType; // 둘 다 충족할 시 true - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 인증 객체 가져오기 - - if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { - throw new AnonymousAuthenticationException(); - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof AuthenticatedUser authenticatedUser) { - return authenticatedUser.id(); - } - - throw new AnonymousAuthenticationException(); - } -} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java deleted file mode 100644 index 063be6a1..00000000 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.weeth.global.auth.resolver; - -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.global.auth.annotation.CurrentUserRole; -import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.model.AuthenticatedUser; -import org.springframework.core.MethodParameter; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -public class CurrentUserRoleArgumentResolver implements HandlerMethodArgumentResolver { - - @Override - public boolean supportsParameter(MethodParameter parameter) { - boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole.class); - boolean parameterType = Role.class.isAssignableFrom(parameter.getParameterType()); - return hasAnnotation && parameterType; - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { - throw new AnonymousAuthenticationException(); - } - - Object principal = authentication.getPrincipal(); - if (principal instanceof AuthenticatedUser authenticatedUser) { - return authenticatedUser.role(); - } - - for (GrantedAuthority authority : authentication.getAuthorities()) { - String role = authority.getAuthority(); - if (role != null && role.startsWith("ROLE_")) { - return Role.valueOf(role.substring("ROLE_".length())); - } - } - - throw new AnonymousAuthenticationException(); - } -} diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt new file mode 100644 index 00000000..71d3cce6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUser.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUser diff --git a/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt new file mode 100644 index 00000000..90690a12 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/annotation/CurrentUserRole.kt @@ -0,0 +1,5 @@ +package com.weeth.global.auth.annotation + +@Target(AnnotationTarget.VALUE_PARAMETER) +@Retention(AnnotationRetention.RUNTIME) +annotation class CurrentUserRole diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt new file mode 100644 index 00000000..5e0318e0 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAccessDeniedHandler.kt @@ -0,0 +1,45 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + +@Component +class CustomAccessDeniedHandler( + private val objectMapper: ObjectMapper, +) : AccessDeniedHandler { + private val log = LoggerFactory.getLogger(javaClass) + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException, + ) { + setResponse(response) + log.error( + "ExceptionClass: {}, Message: {}", + accessDeniedException::class.simpleName, + accessDeniedException.message, + ) + } + + private fun setResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_FORBIDDEN + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val message = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.FORBIDDEN.code, + ErrorMessage.FORBIDDEN.message, + ), + ) + response.writer.write(message) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt new file mode 100644 index 00000000..dcdbffa4 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/authentication/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,45 @@ +package com.weeth.global.auth.authentication + +import com.fasterxml.jackson.databind.ObjectMapper +import com.weeth.global.common.response.CommonResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class CustomAuthenticationEntryPoint( + private val objectMapper: ObjectMapper, +) : AuthenticationEntryPoint { + private val log = LoggerFactory.getLogger(javaClass) + + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException, + ) { + setResponse(response) + log.error( + "ExceptionClass: {}, Message: {}", + authException::class.simpleName, + authException.message, + ) + } + + private fun setResponse(response: HttpServletResponse) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.contentType = "application/json" + response.characterEncoding = "UTF-8" + + val message = + objectMapper.writeValueAsString( + CommonResponse.createFailure( + ErrorMessage.UNAUTHORIZED.code, + ErrorMessage.UNAUTHORIZED.message, + ), + ) + response.writer.write(message) + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt new file mode 100644 index 00000000..11d59dc7 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/model/AuthenticatedUser.kt @@ -0,0 +1,12 @@ +package com.weeth.global.auth.model + +import com.weeth.domain.user.domain.entity.enums.Role + +/** + * Authentication 설정을 위한 model + */ +data class AuthenticatedUser( + val id: Long, + val email: String, + val role: Role, +) diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt new file mode 100644 index 00000000..336a5fff --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.kt @@ -0,0 +1,42 @@ +package com.weeth.global.auth.resolver + +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class CurrentUserArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(CurrentUser::class.java) + val parameterType = parameter.parameterType + val isLongType = parameterType == Long::class.java || parameterType == Long::class.javaPrimitiveType + return hasAnnotation && isLongType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val authentication = SecurityContextHolder.getContext().authentication + + if (authentication == null || authentication is AnonymousAuthenticationToken) { + throw AnonymousAuthenticationException() + } + + val principal = authentication.principal + + if (principal is AuthenticatedUser) { + return principal.id + } + + throw AnonymousAuthenticationException() + } +} diff --git a/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt new file mode 100644 index 00000000..7e5bbaff --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.kt @@ -0,0 +1,50 @@ +package com.weeth.global.auth.resolver + +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.global.auth.model.AuthenticatedUser +import org.springframework.core.MethodParameter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class CurrentUserRoleArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean { + val hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole::class.java) + val parameterType = Role::class.java.isAssignableFrom(parameter.parameterType) + return hasAnnotation && parameterType + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + val authentication = SecurityContextHolder.getContext().authentication + + if (authentication == null || authentication is AnonymousAuthenticationToken) { + throw AnonymousAuthenticationException() + } + + val principal = authentication.principal + if (principal is AuthenticatedUser) { + return principal.role + } + + val role = + authentication.authorities + .asSequence() + .mapNotNull { authority -> authority.authority } + .filter { it.startsWith("ROLE_") } + .mapNotNull { raw -> + runCatching { Role.valueOf(raw.removePrefix("ROLE_")) }.getOrNull() + }.firstOrNull() + + return role ?: throw AnonymousAuthenticationException() + } +} diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt new file mode 100644 index 00000000..c5ff81d7 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -0,0 +1,67 @@ +package com.weeth.global.auth.resolver + +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.model.AuthenticatedUser +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.core.MethodParameter +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.context.request.ServletWebRequest + +class CurrentUserArgumentResolverTest : + StringSpec({ + val resolver = CurrentUserArgumentResolver() + + afterTest { + SecurityContextHolder.clearContext() + } + + "@CurrentUser Long 파라미터를 지원한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + + resolver.supportsParameter(parameter) shouldBe true + } + + "인증 컨텍스트가 익명이면 예외가 발생한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + + SecurityContextHolder.getContext().authentication = + AnonymousAuthenticationToken("key", "anonymousUser", listOf(SimpleGrantedAuthority("ROLE_ANONYMOUS"))) + + shouldThrow { + resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + } + } + + "principal이 AuthenticatedUser면 userId를 반환한다" { + val method = DummyController::class.java.getDeclaredMethod("target", java.lang.Long.TYPE) + val parameter = MethodParameter(method, 0) + val request = MockHttpServletRequest() + val principal = AuthenticatedUser(id = 99L, email = "test@weeth.com", role = Role.USER) + SecurityContextHolder.getContext().authentication = + UsernamePasswordAuthenticationToken(principal, null, emptyList()) + + val result = resolver.resolveArgument(parameter, null, ServletWebRequest(request), null) + + result shouldBe 99L + } + }) { + private class DummyController { + @Suppress("unused") + fun target( + @CurrentUser userId: Long, + ) { + userId.toString() + } + } +} From 6b21c82e717e82082bbcdcca0f75bf7be3e56f84 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:44:48 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20Redis=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/domain/port/RefreshTokenStorePort.kt | 26 ++++++ .../RedisRefreshTokenStoreAdapter.kt | 85 ++++++++++++++++++ .../RedisRefreshTokenStoreAdapterTest.kt | 89 +++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt create mode 100644 src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt create mode 100644 src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt new file mode 100644 index 00000000..26f5f307 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -0,0 +1,26 @@ +package com.weeth.global.auth.jwt.domain.port + +interface RefreshTokenStorePort { + fun save( + userId: Long, + refreshToken: String, + role: String, + email: String, + ) + + fun delete(userId: Long) + + fun validateRefreshToken( + userId: Long, + requestToken: String, + ) + + fun getEmail(userId: Long): String + + fun getRole(userId: Long): String + + fun updateRole( + userId: Long, + role: String, + ) +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt new file mode 100644 index 00000000..a96bf844 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -0,0 +1,85 @@ +package com.weeth.global.auth.jwt.infrastructure + +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort +import com.weeth.global.config.properties.JwtProperties +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisRefreshTokenStoreAdapter( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate, +) : RefreshTokenStorePort { + + override fun save( + userId: Long, + refreshToken: String, + role: String, + email: String, + ) { + val key = getKey(userId) + redisTemplate.opsForHash().putAll( + key, + mapOf( + TOKEN to refreshToken, + ROLE to role, + EMAIL to email, + ), + ) + redisTemplate.expire(key, jwtProperties.refresh.expiration, TimeUnit.MINUTES) + } + + override fun delete(userId: Long) { + val key = getKey(userId) + redisTemplate.delete(key) + } + + override fun validateRefreshToken( + userId: Long, + requestToken: String, + ) { + if (find(userId) != requestToken) { + throw InvalidTokenException() + } + } + + override fun getEmail(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, EMAIL) + ?: throw RedisTokenNotFoundException() + } + + override fun getRole(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, ROLE) + ?: throw RedisTokenNotFoundException() + } + + override fun updateRole( + userId: Long, + role: String, + ) { + val key = getKey(userId) + if (redisTemplate.hasKey(key) == true) { + redisTemplate.opsForHash().put(key, ROLE, role) + } + } + + private fun find(userId: Long): String { + val key = getKey(userId) + return redisTemplate.opsForHash().get(key, TOKEN) + ?: throw RedisTokenNotFoundException() + } + + private fun getKey(userId: Long): String = "$PREFIX$userId" + + companion object { + private const val PREFIX = "refreshToken:" + private const val TOKEN = "token" + private const val ROLE = "role" + private const val EMAIL = "email" + } +} diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt new file mode 100644 index 00000000..a1864e93 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -0,0 +1,89 @@ +package com.weeth.global.auth.jwt.infrastructure.store + +import com.weeth.config.TestContainersConfig +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException +import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException +import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class RedisRefreshTokenStoreAdapterTest( + private val redisRefreshTokenStoreAdapter: RedisRefreshTokenStoreAdapter, + private val redisTemplate: RedisTemplate, +) : DescribeSpec({ + beforeTest { + val keys = redisTemplate.keys("$PREFIX*") + if (!keys.isNullOrEmpty()) { + redisTemplate.delete(keys) + } + } + + describe("save/get") { + it("실제 Redis에 role/email/token을 저장하고 조회한다") { + redisRefreshTokenStoreAdapter.save(1L, "rt", "ADMIN", "a@weeth.com") + + redisRefreshTokenStoreAdapter.getRole(1L) shouldBe "ADMIN" + redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" + redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" + } + } + + describe("validateRefreshToken") { + it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { + redisRefreshTokenStoreAdapter.save(2L, "stored", "USER", "u@weeth.com") + + redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") + } + + it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { + redisRefreshTokenStoreAdapter.save(3L, "stored", "USER", "u@weeth.com") + + shouldThrow { + redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") + } + } + } + + describe("getRole/getEmail") { + it("값이 없으면 RedisTokenNotFoundException이 발생한다") { + shouldThrow { + redisRefreshTokenStoreAdapter.getRole(999L) + } + shouldThrow { + redisRefreshTokenStoreAdapter.getEmail(999L) + } + } + } + + describe("delete/updateRole") { + it("delete 후 조회 시 예외가 발생한다") { + redisRefreshTokenStoreAdapter.save(4L, "rt", "USER", "x@weeth.com") + redisRefreshTokenStoreAdapter.delete(4L) + + shouldThrow { + redisRefreshTokenStoreAdapter.getRole(4L) + } + } + + it("updateRole은 기존 저장 값의 role만 변경한다") { + redisRefreshTokenStoreAdapter.save(5L, "rt", "USER", "x@weeth.com") + + redisRefreshTokenStoreAdapter.updateRole(5L, "ADMIN") + + redisRefreshTokenStoreAdapter.getRole(5L) shouldBe "ADMIN" + redisRefreshTokenStoreAdapter.getEmail(5L) shouldBe "x@weeth.com" + } + } + }) { + companion object { + private const val PREFIX = "refreshToken:" + } +} From 2c7d4494d878701792ec7f69f30182820c67c445 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:45:49 +0900 Subject: [PATCH 08/17] =?UTF-8?q?refactor:=20=EA=B8=B0=ED=83=80=20global?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/presentation/CardinalController.java | 2 +- .../controller/StatusCheckController.java | 18 ------------------ .../controller/StatusCheckController.kt | 13 +++++++++++++ .../global/common/converter/JsonConverter.kt | 2 ++ .../global/common/response/CommonResponse.kt | 19 +++++-------------- 5 files changed, 21 insertions(+), 33 deletions(-) delete mode 100644 src/main/java/com/weeth/global/common/controller/StatusCheckController.java create mode 100644 src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt diff --git a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java index cd2c3ad9..17b76427 100644 --- a/src/main/java/com/weeth/domain/user/presentation/CardinalController.java +++ b/src/main/java/com/weeth/domain/user/presentation/CardinalController.java @@ -8,7 +8,7 @@ import com.weeth.domain.user.application.dto.response.CardinalResponse; import com.weeth.domain.user.application.exception.UserErrorCode; import com.weeth.domain.user.application.usecase.CardinalUseCase; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java b/src/main/java/com/weeth/global/common/controller/StatusCheckController.java deleted file mode 100644 index 6c88bbcb..00000000 --- a/src/main/java/com/weeth/global/common/controller/StatusCheckController.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.global.common.controller; - -import io.swagger.v3.oas.annotations.Hidden; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@Hidden -@RestController -public class StatusCheckController { - - @GetMapping("/health-check") - public ResponseEntity checkHealthStatus() { - - return new ResponseEntity<>(HttpStatus.OK); - } -} diff --git a/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt new file mode 100644 index 00000000..dccaec23 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/controller/StatusCheckController.kt @@ -0,0 +1,13 @@ +package com.weeth.global.common.controller + +import io.swagger.v3.oas.annotations.Hidden +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +class StatusCheckController { + @GetMapping("/health-check") + fun checkHealthStatus(): ResponseEntity = ResponseEntity.ok().build() +} diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt index 4cec9574..962e50a3 100644 --- a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -4,7 +4,9 @@ import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter +@Converter abstract class JsonConverter( private val typeRef: TypeReference, ) : AttributeConverter { diff --git a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt index 31e9b13a..092d4d2f 100644 --- a/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt +++ b/src/main/kotlin/com/weeth/global/common/response/CommonResponse.kt @@ -48,20 +48,11 @@ data class CommonResponse( data = data, ) - @JvmStatic - fun createSuccess(message: String): CommonResponse = success(message) - - @JvmStatic - fun createSuccess( - message: String, - data: T, - ): CommonResponse = success(message, data) - @JvmStatic fun error(errorCode: ErrorCodeInterface): CommonResponse = CommonResponse( - code = errorCode.code, - message = errorCode.message, + code = errorCode.getCode(), + message = errorCode.getMessage(), data = null, ) @@ -71,7 +62,7 @@ data class CommonResponse( message: String, ): CommonResponse = CommonResponse( - code = errorCode.code, + code = errorCode.getCode(), message = message, data = null, ) @@ -82,8 +73,8 @@ data class CommonResponse( data: T, ): CommonResponse = CommonResponse( - code = errorCode.code, - message = errorCode.message, + code = errorCode.getCode(), + message = errorCode.getMessage(), data = data, ) From 41c3b56f07cd52f10b61549e79afcb2ecbb5f912 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:46:58 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B3=80=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=A0=84=ED=8C=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/UserManageUseCaseImpl.java | 10 ++++----- .../application/usecase/UserUseCaseImpl.java | 22 +++++++++---------- .../presentation/UserAdminController.java | 2 +- .../user/presentation/UserController.java | 2 +- .../usecase/UserManageUseCaseTest.kt | 12 +++++----- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java index b9e1b630..9ad4221a 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java @@ -12,7 +12,7 @@ import com.weeth.domain.user.domain.entity.enums.StatusPriority; import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; import com.weeth.domain.user.domain.service.*; -import com.weeth.global.auth.jwt.service.JwtRedisService; +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -35,7 +35,7 @@ public class UserManageUseCaseImpl implements UserManageUseCase { private final AttendanceSaveService attendanceSaveService; private final MeetingGetService meetingGetService; - private final JwtRedisService jwtRedisService; + private final RefreshTokenStorePort refreshTokenStorePort; private final CardinalGetService cardinalGetService; private final UserCardinalSaveService userCardinalSaveService; private final UserCardinalGetService userCardinalGetService; @@ -108,7 +108,7 @@ public void update(List requests) { User user = userGetService.find(request.userId()); userUpdateService.update(user, request.role().name()); - jwtRedisService.updateRole(user.getId(), request.role().name()); + refreshTokenStorePort.updateRole(user.getId(), request.role().name()); }); } @@ -116,7 +116,7 @@ public void update(List requests) { public void leave(Long userId) { User user = userGetService.find(userId); // 탈퇴하는 경우 리프레시 토큰 삭제 - jwtRedisService.delete(user.getId()); + refreshTokenStorePort.delete(user.getId()); userDeleteService.leave(user); } @@ -125,7 +125,7 @@ public void ban(UserId userIds) { List users = userGetService.findAll(userIds.userId()); users.forEach(user -> { - jwtRedisService.delete(user.getId()); + refreshTokenStorePort.delete(user.getId()); userDeleteService.ban(user); }); } diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java index 9f9e14e5..41d8d145 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java @@ -69,7 +69,7 @@ public SocialLoginResponse login(Login dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); return mapper.toLoginResponse(user, token); } @@ -94,7 +94,7 @@ public SocialLoginResponse integrate(NormalLogin dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); return mapper.toLoginResponse(user, token); } @@ -182,7 +182,7 @@ public JwtDto refresh(String refreshToken) { JwtDto token = jwtManageUseCase.reIssueToken(requestToken); log.info("RefreshToken 발급 완료: {}", token); - return new JwtDto(token.accessToken(), token.refreshToken()); + return new JwtDto(token.getAccessToken(), token.getRefreshToken()); } @Override @@ -206,9 +206,9 @@ public List searchUser(String keyword) { private long getKakaoId(Login dto) { KakaoTokenResponse tokenResponse = kakaoAuthService.getKakaoToken(dto.authCode()); - KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.access_token()); + KakaoUserInfoResponse userInfo = kakaoAuthService.getUserInfo(tokenResponse.getAccessToken()); - return userInfo.id(); + return userInfo.getId(); } private void validate(Update dto, Long userId) { @@ -246,10 +246,10 @@ private UserCardinalDto getUserCardinalDto(Long userId) { public SocialLoginResponse appleLogin(Login dto) { // Apple Token 요청 및 유저 정보 요청 AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); - AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); - String appleIdToken = tokenResponse.id_token(); - String appleId = userInfo.appleId(); + String appleIdToken = tokenResponse.getIdToken(); + String appleId = userInfo.getAppleId(); Optional optionalUser = userGetService.findByAppleId(appleId); @@ -264,7 +264,7 @@ public SocialLoginResponse appleLogin(Login dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); return mapper.toAppleLoginResponse(user, token); } @@ -275,13 +275,13 @@ public void appleRegister(Register dto) { // Apple authCode로 토큰 교환 후 ID Token 검증 및 사용자 정보 추출 AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); - AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.getIdToken()); Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); User user = mapper.from(dto); // Apple ID 설정 - user.addAppleId(appleUserInfo.appleId()); + user.addAppleId(appleUserInfo.getAppleId()); UserCardinal userCardinal = new UserCardinal(user, cardinal); diff --git a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java index 04d593cf..e91dcc01 100644 --- a/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java +++ b/src/main/java/com/weeth/domain/user/presentation/UserAdminController.java @@ -5,7 +5,7 @@ import com.weeth.domain.user.application.exception.UserErrorCode; import com.weeth.domain.user.application.usecase.UserManageUseCase; import com.weeth.domain.user.domain.entity.enums.UsersOrderBy; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/weeth/domain/user/presentation/UserController.java b/src/main/java/com/weeth/domain/user/presentation/UserController.java index 05df410e..49ed0767 100644 --- a/src/main/java/com/weeth/domain/user/presentation/UserController.java +++ b/src/main/java/com/weeth/domain/user/presentation/UserController.java @@ -13,7 +13,7 @@ import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.annotation.CurrentUser; import com.weeth.global.auth.jwt.application.dto.JwtDto; -import com.weeth.global.auth.jwt.exception.JwtErrorCode; +import com.weeth.global.auth.jwt.application.exception.JwtErrorCode; import com.weeth.global.common.exception.ApiErrorCodeExample; import com.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt index d2164df9..7331070f 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt @@ -20,7 +20,7 @@ import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.user.domain.service.UserUpdateService import com.weeth.domain.user.fixture.CardinalTestFixture import com.weeth.domain.user.fixture.UserTestFixture -import com.weeth.global.auth.jwt.service.JwtRedisService +import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.collections.shouldHaveSize @@ -40,7 +40,7 @@ class UserManageUseCaseTest : val userDeleteService = mockk(relaxUnitFun = true) val attendanceSaveService = mockk(relaxUnitFun = true) val meetingGetService = mockk() - val jwtRedisService = mockk(relaxUnitFun = true) + val refreshTokenStorePort = mockk(relaxUnitFun = true) val cardinalGetService = mockk() val userCardinalSaveService = mockk(relaxUnitFun = true) val userCardinalGetService = mockk() @@ -54,7 +54,7 @@ class UserManageUseCaseTest : userDeleteService, attendanceSaveService, meetingGetService, - jwtRedisService, + refreshTokenStorePort, cardinalGetService, userCardinalSaveService, userCardinalGetService, @@ -164,7 +164,7 @@ class UserManageUseCaseTest : useCase.update(listOf(request)) verify { userUpdateService.update(user1, "ADMIN") } - verify { jwtRedisService.updateRole(1L, "ADMIN") } + verify { refreshTokenStorePort.updateRole(1L, "ADMIN") } } } @@ -175,7 +175,7 @@ class UserManageUseCaseTest : useCase.leave(1L) - verify { jwtRedisService.delete(1L) } + verify { refreshTokenStorePort.delete(1L) } verify { userDeleteService.leave(user1) } } } @@ -188,7 +188,7 @@ class UserManageUseCaseTest : useCase.ban(ids) - verify { jwtRedisService.delete(1L) } + verify { refreshTokenStorePort.delete(1L) } verify { userDeleteService.ban(user1) } } } From f05569f391c13998671ded0cefe7189d2bb8d182 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:47:10 +0900 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/weeth/global/common/entity/BaseEntity.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/weeth/global/common/entity/BaseEntity.java b/src/main/java/com/weeth/global/common/entity/BaseEntity.java index 3e8a520a..fa970c29 100644 --- a/src/main/java/com/weeth/global/common/entity/BaseEntity.java +++ b/src/main/java/com/weeth/global/common/entity/BaseEntity.java @@ -18,11 +18,13 @@ @EntityListeners(AuditingEntityListener.class) @SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) +// NOTE: Java 엔티티들의 Lombok @SuperBuilder 체인(BaseEntityBuilder) 호환을 위해 현재는 Java로 유지한다. public class BaseEntity { @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; + @LastModifiedDate private LocalDateTime modifiedAt; } From 85c60e906bcec40e2987f01f2c2d075e817b172e Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:47:19 +0900 Subject: [PATCH 11/17] =?UTF-8?q?docs:=20=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/code-style.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index d531ea3a..caf065dc 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -67,6 +67,17 @@ companion object { } ``` +## Comments + +- Do NOT comment on self-explanatory code +- Add comments in these cases: + - **Core business logic**: Domain rules, policy decisions — explain "why", not "what" + - **Collaboration aid**: Intent or background that other developers need to understand the code + - **Non-obvious implementation**: Performance optimizations, workarounds, external system constraints + - **Architecture decisions**: Reason for choosing a specific pattern or structure (e.g., `// NOTE: Kept in Java for Lombok @SuperBuilder compatibility`) +- Use KDoc (`/** */`) for public APIs, Port interfaces, and external contracts +- Use inline comments (`//`) for implementation intent within methods + ## Null Handling ```kotlin From 3c979100c86539dd6c72dea666f4efa08a6cb31a Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:48:00 +0900 Subject: [PATCH 12/17] =?UTF-8?q?docs:=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EA=B9=A8=EC=A7=90=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/global/config/SwaggerConfig.kt | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt index 83b6002f..a6763ffe 100644 --- a/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -23,34 +23,33 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.web.method.HandlerMethod +private const val SWAGGER_DESCRIPTION = + "## Response Code 규칙\n" + + "- Success: **1xxx**\n" + + "- Domain Error: **2xxx**\n" + + "- Server Error: **3xxx**\n" + + "- Client Error: **4xxx**\n\n" + + "## 도메인별 코드 범위\n" + + "| Domain | Success | Error |\n" + + "|--------|---------|------|\n" + + "| Account | 11xx | 21xx |\n" + + "| Attendance | 12xx | 22xx |\n" + + "| Board | 13xx | 23xx |\n" + + "| Comment | 14xx | 24xx |\n" + + "| File | 15xx | 25xx |\n" + + "| Penalty | 16xx | 26xx |\n" + + "| Schedule | 17xx | 27xx |\n" + + "| User | 18xx | 28xx |\n" + + "| Auth/JWT (Global) | - | 29xx |\n\n" + + "> 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요." + @Configuration @OpenAPIDefinition( info = Info( title = "Weeth API", version = "v4.0.0", - description = """ - ## Response Code 규칙 - - Success: **1xxx** - - Domain Error: **2xxx** - - Server Error: **3xxx** - - Client Error: **4xxx** - - ## 도메인별 코드 범위 - | Domain | Success | Error | - |--------|---------|------| - | Account | 11xx | 21xx | - | Attendance | 12xx | 22xx | - | Board | 13xx | 23xx | - | Comment | 14xx | 24xx | - | File | 15xx | 25xx | - | Penalty | 16xx | 26xx | - | Schedule | 17xx | 27xx | - | User | 18xx | 28xx | - | Auth/JWT (Global) | - | 29xx | - - > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. - """, + description = SWAGGER_DESCRIPTION, ), ) class SwaggerConfig( From 8a2eedc3b3741ca5dac2b0ca4d6221d604a806a1 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:51:13 +0900 Subject: [PATCH 13/17] =?UTF-8?q?chore:=20lint=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/apple/AppleAuthService.kt | 20 ++++++++++++++----- .../RedisRefreshTokenStoreAdapter.kt | 1 - .../global/auth/kakao/KakaoAuthService.kt | 2 +- .../controller/ExceptionDocController.kt | 2 +- .../CurrentUserArgumentResolverTest.kt | 2 +- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt index f33fda1d..6c70d1c6 100644 --- a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -45,7 +45,9 @@ class AppleAuthService( private val appleProperties = oAuthProperties.apple private val restClient = restClientBuilder.build() private val publicKeysTtl: Duration = Duration.ofHours(1) + @Volatile private var cachedPublicKeys: ApplePublicKeys? = null + @Volatile private var cachedPublicKeysExpiresAt: Instant = Instant.EPOCH private val privateKey: PrivateKey by lazy { loadPrivateKey() } @@ -195,13 +197,22 @@ class AppleAuthService( val now = Date.from(Instant.now(clock)) when { - iss != "https://appleid.apple.com" -> throw RuntimeException("유효하지 않은 발급자(issuer)입니다.") + iss != "https://appleid.apple.com" -> { + throw RuntimeException("유효하지 않은 발급자(issuer)입니다.") + } + audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { log.error("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) throw RuntimeException("유효하지 않은 수신자(audience)입니다.") } - expiration.before(now) -> throw RuntimeException("만료된 ID Token입니다.") - claims.subject.isNullOrBlank() -> throw RuntimeException("유효하지 않은 subject입니다.") + + expiration.before(now) -> { + throw RuntimeException("만료된 ID Token입니다.") + } + + claims.subject.isNullOrBlank() -> { + throw RuntimeException("유효하지 않은 subject입니다.") + } } } @@ -233,8 +244,7 @@ class AppleAuthService( throw RuntimeException("JSON 파싱 실패") } - private fun decodeBase64Url(value: String): String = - String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) + private fun decodeBase64Url(value: String): String = String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) private fun parseEmailVerified(raw: Any?): Boolean = when (raw) { diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt index a96bf844..d8eb01f5 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -13,7 +13,6 @@ class RedisRefreshTokenStoreAdapter( private val jwtProperties: JwtProperties, private val redisTemplate: RedisTemplate, ) : RefreshTokenStorePort { - override fun save( userId: Long, refreshToken: String, diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt index e93662ef..c97334e6 100644 --- a/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt +++ b/src/main/kotlin/com/weeth/global/auth/kakao/KakaoAuthService.kt @@ -13,7 +13,7 @@ import org.springframework.web.client.body class KakaoAuthService( oAuthProperties: OAuthProperties, restClientBuilder: RestClient.Builder, - ) { +) { private val kakaoProperties = oAuthProperties.kakao private val restClient = restClientBuilder.build() diff --git a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt index 08b2724c..1d67f2c0 100644 --- a/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt +++ b/src/main/kotlin/com/weeth/global/common/controller/ExceptionDocController.kt @@ -56,7 +56,7 @@ class ExceptionDocController { fun userErrorCodes() { } - //todo: SAS 관련 예외도 추가 + // todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") @ApiErrorCodeExample(JwtErrorCode::class) diff --git a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt index c5ff81d7..c94c74bf 100644 --- a/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/resolver/CurrentUserArgumentResolverTest.kt @@ -1,8 +1,8 @@ package com.weeth.global.auth.resolver +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.annotation.CurrentUser import com.weeth.global.auth.jwt.application.exception.AnonymousAuthenticationException -import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.model.AuthenticatedUser import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec From 5dba5280a48867776327339c57bd3f586b9db38d Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 23:57:49 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=20Role=20->=20String=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=9B=90=EB=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/UserManageUseCaseImpl.java | 2 +- .../application/usecase/UserUseCaseImpl.java | 6 +++--- .../application/service/JwtTokenExtractor.kt | 8 ++++++-- .../jwt/application/usecase/JwtManageUseCase.kt | 5 +++-- .../jwt/domain/port/RefreshTokenStorePort.kt | 8 +++++--- .../auth/jwt/domain/service/JwtTokenProvider.kt | 5 +++-- .../filter/JwtAuthenticationProcessingFilter.kt | 9 ++------- .../RedisRefreshTokenStoreAdapter.kt | 17 ++++++++++------- .../usecase/UserManageUseCaseTest.kt | 2 +- .../service/JwtTokenExtractorTest.kt | 3 ++- .../application/usecase/JwtManageUseCaseTest.kt | 13 +++++++------ .../jwt/domain/service/JwtTokenProviderTest.kt | 3 ++- .../JwtAuthenticationProcessingFilterTest.kt | 9 ++++----- .../store/RedisRefreshTokenStoreAdapterTest.kt | 17 +++++++++-------- 14 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java index 9ad4221a..aa194a5d 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserManageUseCaseImpl.java @@ -108,7 +108,7 @@ public void update(List requests) { User user = userGetService.find(request.userId()); userUpdateService.update(user, request.role().name()); - refreshTokenStorePort.updateRole(user.getId(), request.role().name()); + refreshTokenStorePort.updateRole(user.getId(), request.role()); }); } diff --git a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java index 41d8d145..1702081b 100644 --- a/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ b/src/main/java/com/weeth/domain/user/application/usecase/UserUseCaseImpl.java @@ -69,7 +69,7 @@ public SocialLoginResponse login(Login dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); return mapper.toLoginResponse(user, token); } @@ -94,7 +94,7 @@ public SocialLoginResponse integrate(NormalLogin dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); return mapper.toLoginResponse(user, token); } @@ -264,7 +264,7 @@ public SocialLoginResponse appleLogin(Login dto) { throw new UserInActiveException(); } - JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole().name()); + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); return mapper.toAppleLoginResponse(user, token); } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index 437570bb..6ab8ae7d 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -1,5 +1,7 @@ package com.weeth.global.auth.jwt.application.service +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -18,7 +20,7 @@ class JwtTokenExtractor( data class TokenClaims( val id: Long, val email: String, - val role: String, + val role: Role, ) fun extractRefreshToken(request: HttpServletRequest): String = @@ -58,7 +60,9 @@ class JwtTokenExtractor( TokenClaims( id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java), email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), - role = claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java), + role = + runCatching { Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)) } + .getOrElse { throw InvalidTokenException() }, ) }.getOrElse { log.error("액세스 토큰이 유효하지 않습니다.") diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index 67617d26..85c50307 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.application.usecase +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort @@ -15,7 +16,7 @@ class JwtManageUseCase( fun create( userId: Long, email: String, - role: String, + role: Role, ): JwtDto { val accessToken = jwtTokenProvider.createAccessToken(userId, email, role) val refreshToken = jwtTokenProvider.createRefreshToken(userId) @@ -40,7 +41,7 @@ class JwtManageUseCase( private fun updateToken( userId: Long, refreshToken: String, - role: String, + role: Role, email: String, ) { refreshTokenStore.save(userId, refreshToken, role, email) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt index 26f5f307..12aeacea 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -1,10 +1,12 @@ package com.weeth.global.auth.jwt.domain.port +import com.weeth.domain.user.domain.entity.enums.Role + interface RefreshTokenStorePort { fun save( userId: Long, refreshToken: String, - role: String, + role: Role, email: String, ) @@ -17,10 +19,10 @@ interface RefreshTokenStorePort { fun getEmail(userId: Long): String - fun getRole(userId: Long): String + fun getRole(userId: Long): Role fun updateRole( userId: Long, - role: String, + role: Role, ) } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt index bf722962..139900e4 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.domain.service +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.jsonwebtoken.Claims @@ -31,7 +32,7 @@ class JwtTokenProvider( fun createAccessToken( id: Long, email: String, - role: String, + role: Role, ): String { val now = Date() return Jwts @@ -39,7 +40,7 @@ class JwtTokenProvider( .subject(ACCESS_TOKEN_SUBJECT) .claim(ID_CLAIM, id) .claim(EMAIL_CLAIM, email) - .claim(ROLE_CLAIM, role) + .claim(ROLE_CLAIM, role.name) .issuedAt(now) .expiration(Date(now.time + accessTokenExpirationPeriod)) .signWith(secretKey) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 9aeeabb3..8cc06e8b 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -1,7 +1,5 @@ package com.weeth.global.auth.jwt.filter -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider @@ -41,16 +39,13 @@ class JwtAuthenticationProcessingFilter( fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() - val role = - runCatching { Role.valueOf(claims.role) } - .getOrElse { throw InvalidTokenException() } - val principal = AuthenticatedUser(claims.id, claims.email, role) + val principal = AuthenticatedUser(claims.id, claims.email, claims.role) val authentication = UsernamePasswordAuthenticationToken( principal, null, - listOf(SimpleGrantedAuthority("ROLE_${role.name}")), + listOf(SimpleGrantedAuthority("ROLE_${claims.role.name}")), ) SecurityContextHolder.getContext().authentication = authentication diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt index d8eb01f5..1819978f 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.infrastructure +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort @@ -16,7 +17,7 @@ class RedisRefreshTokenStoreAdapter( override fun save( userId: Long, refreshToken: String, - role: String, + role: Role, email: String, ) { val key = getKey(userId) @@ -24,7 +25,7 @@ class RedisRefreshTokenStoreAdapter( key, mapOf( TOKEN to refreshToken, - ROLE to role, + ROLE to role.name, EMAIL to email, ), ) @@ -51,19 +52,21 @@ class RedisRefreshTokenStoreAdapter( ?: throw RedisTokenNotFoundException() } - override fun getRole(userId: Long): String { + override fun getRole(userId: Long): Role { val key = getKey(userId) - return redisTemplate.opsForHash().get(key, ROLE) - ?: throw RedisTokenNotFoundException() + val role = + redisTemplate.opsForHash().get(key, ROLE) + ?: throw RedisTokenNotFoundException() + return runCatching { Role.valueOf(role) }.getOrElse { throw InvalidTokenException() } } override fun updateRole( userId: Long, - role: String, + role: Role, ) { val key = getKey(userId) if (redisTemplate.hasKey(key) == true) { - redisTemplate.opsForHash().put(key, ROLE, role) + redisTemplate.opsForHash().put(key, ROLE, role.name) } } diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt index 7331070f..680b0e94 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/UserManageUseCaseTest.kt @@ -164,7 +164,7 @@ class UserManageUseCaseTest : useCase.update(listOf(request)) verify { userUpdateService.update(user1, "ADMIN") } - verify { refreshTokenStorePort.updateRole(1L, "ADMIN") } + verify { refreshTokenStorePort.updateRole(1L, Role.ADMIN) } } } diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt index 4010ad31..5ac60311 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.application.service +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -77,7 +78,7 @@ class JwtTokenExtractorTest : tokenClaims?.id shouldBe 77L tokenClaims?.email shouldBe "sample@com" - tokenClaims?.role shouldBe "USER" + tokenClaims?.role shouldBe Role.USER verify(exactly = 1) { jwtProvider.parseClaims(token) } } } diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt index de2a53ff..e7ec96a6 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.application.usecase +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort @@ -21,13 +22,13 @@ class JwtManageUseCaseTest : describe("create") { it("access/refresh token을 생성하고 저장한다") { - every { jwtProvider.createAccessToken(1L, "a@weeth.com", "USER") } returns "access" + every { jwtProvider.createAccessToken(1L, "a@weeth.com", Role.USER) } returns "access" every { jwtProvider.createRefreshToken(1L) } returns "refresh" - val result = useCase.create(1L, "a@weeth.com", "USER") + val result = useCase.create(1L, "a@weeth.com", Role.USER) result shouldBe JwtDto("access", "refresh") - verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", "USER", "a@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", Role.USER, "a@weeth.com") } } } @@ -35,16 +36,16 @@ class JwtManageUseCaseTest : it("저장 토큰 검증 후 새 토큰을 재발급한다") { every { jwtProvider.validate("old-refresh") } just runs every { jwtService.extractId("old-refresh") } returns 10L - every { refreshTokenStore.getRole(10L) } returns "ADMIN" + every { refreshTokenStore.getRole(10L) } returns Role.ADMIN every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" - every { jwtProvider.createAccessToken(10L, "admin@weeth.com", "ADMIN") } returns "new-access" + every { jwtProvider.createAccessToken(10L, "admin@weeth.com", Role.ADMIN) } returns "new-access" every { jwtProvider.createRefreshToken(10L) } returns "new-refresh" val result = useCase.reIssueToken("old-refresh") result shouldBe JwtDto("new-access", "new-refresh") verify(exactly = 1) { refreshTokenStore.validateRefreshToken(10L, "old-refresh") } - verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", "ADMIN", "admin@weeth.com") } + verify(exactly = 1) { refreshTokenStore.save(10L, "new-refresh", Role.ADMIN, "admin@weeth.com") } } } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt index 36418211..e77028fa 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.domain.service +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.config.properties.JwtProperties import io.kotest.assertions.throwables.shouldThrow @@ -18,7 +19,7 @@ class JwtTokenProviderTest : val jwtProvider = JwtTokenProvider(jwtProperties) "access token 생성 후 claims를 파싱할 수 있다" { - val token = jwtProvider.createAccessToken(1L, "test@weeth.com", "ADMIN") + val token = jwtProvider.createAccessToken(1L, "test@weeth.com", Role.ADMIN) val claims = jwtProvider.parseClaims(token) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index c9c35f34..ecb9507d 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -1,5 +1,6 @@ package com.weeth.global.auth.jwt.filter +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser @@ -39,7 +40,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs - every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", "ADMIN") + every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", Role.ADMIN) filter.doFilter(request, response, chain) @@ -66,16 +67,14 @@ class JwtAuthenticationProcessingFilterTest : verify(exactly = 0) { jwtProvider.validate(any()) } } - it("role claim이 유효하지 않으면 인증을 저장하지 않는다") { + it("claims 추출에 실패하면 인증을 저장하지 않는다") { val request = MockHttpServletRequest().apply { requestURI = "/api/v1/users" } val response = MockHttpServletResponse() val chain = MockFilterChain() every { jwtService.extractAccessToken(request) } returns "access-token" every { jwtProvider.validate("access-token") } just runs - every { - jwtService.extractClaims("access-token") - } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", "NOT_A_ROLE") + every { jwtService.extractClaims("access-token") } returns null filter.doFilter(request, response, chain) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt index a1864e93..1ba5944b 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -1,6 +1,7 @@ package com.weeth.global.auth.jwt.infrastructure.store import com.weeth.config.TestContainersConfig +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.RedisTokenNotFoundException import com.weeth.global.auth.jwt.infrastructure.RedisRefreshTokenStoreAdapter @@ -28,9 +29,9 @@ class RedisRefreshTokenStoreAdapterTest( describe("save/get") { it("실제 Redis에 role/email/token을 저장하고 조회한다") { - redisRefreshTokenStoreAdapter.save(1L, "rt", "ADMIN", "a@weeth.com") + redisRefreshTokenStoreAdapter.save(1L, "rt", Role.ADMIN, "a@weeth.com") - redisRefreshTokenStoreAdapter.getRole(1L) shouldBe "ADMIN" + redisRefreshTokenStoreAdapter.getRole(1L) shouldBe Role.ADMIN redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" } @@ -38,13 +39,13 @@ class RedisRefreshTokenStoreAdapterTest( describe("validateRefreshToken") { it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { - redisRefreshTokenStoreAdapter.save(2L, "stored", "USER", "u@weeth.com") + redisRefreshTokenStoreAdapter.save(2L, "stored", Role.USER, "u@weeth.com") redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") } it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { - redisRefreshTokenStoreAdapter.save(3L, "stored", "USER", "u@weeth.com") + redisRefreshTokenStoreAdapter.save(3L, "stored", Role.USER, "u@weeth.com") shouldThrow { redisRefreshTokenStoreAdapter.validateRefreshToken(3L, "different") @@ -65,7 +66,7 @@ class RedisRefreshTokenStoreAdapterTest( describe("delete/updateRole") { it("delete 후 조회 시 예외가 발생한다") { - redisRefreshTokenStoreAdapter.save(4L, "rt", "USER", "x@weeth.com") + redisRefreshTokenStoreAdapter.save(4L, "rt", Role.USER, "x@weeth.com") redisRefreshTokenStoreAdapter.delete(4L) shouldThrow { @@ -74,11 +75,11 @@ class RedisRefreshTokenStoreAdapterTest( } it("updateRole은 기존 저장 값의 role만 변경한다") { - redisRefreshTokenStoreAdapter.save(5L, "rt", "USER", "x@weeth.com") + redisRefreshTokenStoreAdapter.save(5L, "rt", Role.USER, "x@weeth.com") - redisRefreshTokenStoreAdapter.updateRole(5L, "ADMIN") + redisRefreshTokenStoreAdapter.updateRole(5L, Role.ADMIN) - redisRefreshTokenStoreAdapter.getRole(5L) shouldBe "ADMIN" + redisRefreshTokenStoreAdapter.getRole(5L) shouldBe Role.ADMIN redisRefreshTokenStoreAdapter.getEmail(5L) shouldBe "x@weeth.com" } } From a32b398c823430f40ea2713a132586f81bf34d4e Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 20 Feb 2026 00:20:28 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/apple/AppleAuthService.kt | 38 ++++++++++------- .../application/service/JwtTokenExtractor.kt | 41 ++++++++----------- .../application/usecase/JwtManageUseCase.kt | 3 +- .../JwtAuthenticationProcessingFilter.kt | 2 +- .../exception/CommonExceptionHandler.kt | 22 ++++------ 5 files changed, 50 insertions(+), 56 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt index 6c70d1c6..43e247d6 100644 --- a/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -40,15 +40,18 @@ class AppleAuthService( private val objectMapper: ObjectMapper, private val clock: Clock = Clock.systemUTC(), ) { + private data class CachedKeys( + val keys: ApplePublicKeys, + val expiresAt: Instant, + ) + private val log = LoggerFactory.getLogger(javaClass) private val appleProperties = oAuthProperties.apple private val restClient = restClientBuilder.build() private val publicKeysTtl: Duration = Duration.ofHours(1) - @Volatile private var cachedPublicKeys: ApplePublicKeys? = null - - @Volatile private var cachedPublicKeysExpiresAt: Instant = Instant.EPOCH + @Volatile private var cached: CachedKeys? = null private val privateKey: PrivateKey by lazy { loadPrivateKey() } fun getAppleToken(authCode: String): AppleTokenResponse { @@ -115,6 +118,8 @@ class AppleAuthService( email = email, emailVerified = emailVerified, ) + } catch (e: AppleAuthenticationException) { + throw e } catch (e: Exception) { log.error("애플 ID Token 검증 실패", e) throw AppleAuthenticationException() @@ -198,29 +203,33 @@ class AppleAuthService( when { iss != "https://appleid.apple.com" -> { - throw RuntimeException("유효하지 않은 발급자(issuer)입니다.") + log.warn("유효하지 않은 발급자: {}", iss) + throw AppleAuthenticationException() } audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { - log.error("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) - throw RuntimeException("유효하지 않은 수신자(audience)입니다.") + log.warn("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) + throw AppleAuthenticationException() } expiration.before(now) -> { - throw RuntimeException("만료된 ID Token입니다.") + log.warn("만료된 ID Token") + throw AppleAuthenticationException() } claims.subject.isNullOrBlank() -> { - throw RuntimeException("유효하지 않은 subject입니다.") + log.warn("유효하지 않은 subject") + throw AppleAuthenticationException() } } } private fun getApplePublicKeys(): ApplePublicKeys { val now = Instant.now(clock) - val cached = cachedPublicKeys - if (cached != null && now.isBefore(cachedPublicKeysExpiresAt)) { - return cached + cached?.let { + if (now.isBefore(it.expiresAt)) { + return it.keys + } } val fetched = @@ -232,16 +241,15 @@ class AppleAuthService( .body(), ) - cachedPublicKeys = fetched - cachedPublicKeysExpiresAt = now.plus(publicKeysTtl) + cached = CachedKeys(fetched, now.plus(publicKeysTtl)) return fetched } private fun parseJson(json: String): ObjectNode = try { - objectMapper.readTree(json) as? ObjectNode ?: throw RuntimeException("JSON 객체가 아닙니다.") + objectMapper.readTree(json) as? ObjectNode ?: throw AppleAuthenticationException() } catch (e: Exception) { - throw RuntimeException("JSON 파싱 실패") + throw AppleAuthenticationException() } private fun decodeBase64Url(value: String): String = String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index 6ab8ae7d..8e128528 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -1,7 +1,6 @@ package com.weeth.global.auth.jwt.application.service import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.config.properties.JwtProperties @@ -36,23 +35,9 @@ class JwtTokenExtractor( ?.takeIf { it.startsWith(BEARER) } ?.removePrefix(BEARER) - fun extractEmail(accessToken: String): String? = - runCatching { - val claims: Claims = jwtTokenProvider.parseClaims(accessToken) - claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java) - }.getOrElse { - log.error("액세스 토큰이 유효하지 않습니다.") - null - } + fun extractEmail(accessToken: String): String? = extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) - fun extractId(token: String): Long? = - runCatching { - val claims: Claims = jwtTokenProvider.parseClaims(token) - claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java) - }.getOrElse { - log.error("액세스 토큰이 유효하지 않습니다.") - null - } + fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.java) fun extractClaims(token: String): TokenClaims? = runCatching { @@ -60,14 +45,22 @@ class JwtTokenExtractor( TokenClaims( id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java), email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), - role = - runCatching { Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)) } - .getOrElse { throw InvalidTokenException() }, + role = Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)), ) - }.getOrElse { - log.error("액세스 토큰이 유효하지 않습니다.") - null - } + }.onFailure { + log.error("액세스 토큰이 유효하지 않습니다: {}", it.message) + }.getOrNull() + + private fun extractClaim( + token: String, + claimName: String, + type: Class, + ): T? = + runCatching { + jwtTokenProvider.parseClaims(token).get(claimName, type) + }.onFailure { + log.error("액세스 토큰 claim 추출 실패({}): {}", claimName, it.message) + }.getOrNull() companion object { private const val BEARER = "Bearer " diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index 85c50307..3fadd973 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -2,6 +2,7 @@ package com.weeth.global.auth.jwt.application.usecase import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.jwt.application.dto.JwtDto +import com.weeth.global.auth.jwt.application.exception.InvalidTokenException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.port.RefreshTokenStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider @@ -29,7 +30,7 @@ class JwtManageUseCase( fun reIssueToken(requestToken: String): JwtDto { jwtTokenProvider.validate(requestToken) - val userId = requireNotNull(jwtTokenExtractor.extractId(requestToken)) + val userId = jwtTokenExtractor.extractId(requestToken) ?: throw InvalidTokenException() refreshTokenStore.validateRefreshToken(userId, requestToken) val role = refreshTokenStore.getRole(userId) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 8cc06e8b..0613cf15 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -37,7 +37,7 @@ class JwtAuthenticationProcessingFilter( filterChain.doFilter(request, response) } - fun saveAuthentication(accessToken: String) { + private fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() val principal = AuthenticatedUser(claims.id, claims.email, claims.role) diff --git a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt index 3cf6b9f0..b26717de 100644 --- a/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -15,7 +15,7 @@ class CommonExceptionHandler { @ExceptionHandler(BaseException::class) fun handle(ex: BaseException): ResponseEntity> { - log.warn("구체로그: ", ex) + log.warn("예외 처리(BaseException)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, ex.statusCode, ex.message) val errorCode = ex.errorCode @@ -33,11 +33,10 @@ class CommonExceptionHandler { @ExceptionHandler(BindException::class) fun handle(ex: BindException): ResponseEntity>> { - var statusCode = 400 + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 val exceptionResponses = mutableListOf() if (ex is ErrorResponse) { - statusCode = ex.statusCode.value() ex.bindingResult.fieldErrors.forEach { fieldError -> exceptionResponses.add( BindExceptionResponse( @@ -48,7 +47,7 @@ class CommonExceptionHandler { } } - log.warn("구체로그: ", ex) + log.warn("예외 처리(BindException)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, exceptionResponses) val response = CommonResponse.createFailure(statusCode, "bindException", exceptionResponses.toList()) @@ -60,12 +59,9 @@ class CommonExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException::class) fun handle(ex: MethodArgumentTypeMismatchException): ResponseEntity> { - var statusCode = 400 - if (ex is ErrorResponse) { - statusCode = ex.statusCode.value() - } + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 - log.warn("구체로그: ", ex) + log.warn("예외 처리(MethodArgumentTypeMismatchException)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) val response = CommonResponse.createFailure(statusCode, INPUT_FORMAT_ERROR_MESSAGE) @@ -77,13 +73,9 @@ class CommonExceptionHandler { @ExceptionHandler(Exception::class) fun handle(ex: Exception): ResponseEntity> { - var statusCode = 500 - - if (ex is ErrorResponse) { - statusCode = ex.statusCode.value() - } + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 500 - log.warn("구체로그: ", ex) + log.warn("예외 처리(Exception)", ex) log.warn(LOG_FORMAT, ex::class.simpleName, statusCode, ex.message) val response = CommonResponse.createFailure(statusCode, ex.message ?: "") From ac93cd55f514ce29c0de8e75dfd7b3a121e2873f Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 20 Feb 2026 00:31:30 +0900 Subject: [PATCH 16/17] =?UTF-8?q?refactor:=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/jwt/application/service/JwtTokenExtractor.kt | 4 ++-- .../auth/jwt/application/service/JwtTokenExtractorTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt index 8e128528..02fcc525 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -37,13 +37,13 @@ class JwtTokenExtractor( fun extractEmail(accessToken: String): String? = extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::class.java) - fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.java) + fun extractId(token: String): Long? = extractClaim(token, JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType) fun extractClaims(token: String): TokenClaims? = runCatching { val claims: Claims = jwtTokenProvider.parseClaims(token) TokenClaims( - id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.java), + id = claims.get(JwtTokenProvider.ID_CLAIM, Long::class.javaObjectType), email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), role = Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)), ) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt index 5ac60311..3a54cad5 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -56,7 +56,7 @@ class JwtTokenExtractorTest : val token = "sample" val claims = mockk() every { jwtProvider.parseClaims(token) } returns claims - every { claims.get("id", Long::class.java) } returns 77L + every { claims.get("id", Long::class.javaObjectType) } returns 77L val id = jwtTokenExtractor.extractId(token) @@ -70,7 +70,7 @@ class JwtTokenExtractorTest : val token = "sample" val claims = mockk() every { jwtProvider.parseClaims(token) } returns claims - every { claims.get("id", Long::class.java) } returns 77L + every { claims.get("id", Long::class.javaObjectType) } returns 77L every { claims.get("email", String::class.java) } returns "sample@com" every { claims.get("role", String::class.java) } returns "USER" From 5579e5fee6d770acd4a3a8dd3821906dad7ae9e3 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Fri, 20 Feb 2026 13:30:48 +0900 Subject: [PATCH 17/17] =?UTF-8?q?refactor:=20email=20nullable=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt index 11a93f6e..da0ca572 100644 --- a/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt +++ b/src/main/kotlin/com/weeth/global/auth/kakao/dto/KakaoAccount.kt @@ -8,5 +8,5 @@ data class KakaoAccount( @field:JsonProperty("is_email_verified") val isEmailVerified: Boolean, @field:JsonProperty("email") - val email: String, + val email: String?, )