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 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..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 @@ -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()); }); } @@ -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..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 @@ -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); @@ -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/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/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/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/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/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/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/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/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/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/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/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/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/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; } 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/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/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/apple/AppleAuthService.kt b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt new file mode 100644 index 00000000..43e247d6 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/apple/AppleAuthService.kt @@ -0,0 +1,263 @@ +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 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 cached: CachedKeys? = null + 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: AppleAuthenticationException) { + throw e + } 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" -> { + log.warn("유효하지 않은 발급자: {}", iss) + throw AppleAuthenticationException() + } + + audiences.isEmpty() || !audiences.contains(appleProperties.clientId) -> { + log.warn("유효하지 않은 audience: {}. 기대값: {}", audiences, appleProperties.clientId) + throw AppleAuthenticationException() + } + + expiration.before(now) -> { + log.warn("만료된 ID Token") + throw AppleAuthenticationException() + } + + claims.subject.isNullOrBlank() -> { + log.warn("유효하지 않은 subject") + throw AppleAuthenticationException() + } + } + } + + private fun getApplePublicKeys(): ApplePublicKeys { + val now = Instant.now(clock) + cached?.let { + if (now.isBefore(it.expiresAt)) { + return it.keys + } + } + + val fetched = + requireNotNull( + restClient + .get() + .uri(appleProperties.keysUri) + .retrieve() + .body(), + ) + + cached = CachedKeys(fetched, now.plus(publicKeysTtl)) + return fetched + } + + private fun parseJson(json: String): ObjectNode = + try { + objectMapper.readTree(json) as? ObjectNode ?: throw AppleAuthenticationException() + } catch (e: Exception) { + throw AppleAuthenticationException() + } + + 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) 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/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/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/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/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..02fcc525 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractor.kt @@ -0,0 +1,68 @@ +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 +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: Role, + ) + + 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? = extractClaim(accessToken, JwtTokenProvider.EMAIL_CLAIM, String::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.javaObjectType), + email = claims.get(JwtTokenProvider.EMAIL_CLAIM, String::class.java), + role = Role.valueOf(claims.get(JwtTokenProvider.ROLE_CLAIM, String::class.java)), + ) + }.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 new file mode 100644 index 00000000..3fadd973 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -0,0 +1,50 @@ +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 +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: Role, + ): 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 = jwtTokenExtractor.extractId(requestToken) ?: throw InvalidTokenException() + 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: 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 new file mode 100644 index 00000000..12aeacea --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/RefreshTokenStorePort.kt @@ -0,0 +1,28 @@ +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: Role, + email: String, + ) + + fun delete(userId: Long) + + fun validateRefreshToken( + userId: Long, + requestToken: String, + ) + + fun getEmail(userId: Long): String + + fun getRole(userId: Long): Role + + fun updateRole( + userId: Long, + 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 new file mode 100644 index 00000000..139900e4 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProvider.kt @@ -0,0 +1,90 @@ +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 +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: Role, + ): String { + val now = Date() + return Jwts + .builder() + .subject(ACCESS_TOKEN_SUBJECT) + .claim(ID_CLAIM, id) + .claim(EMAIL_CLAIM, email) + .claim(ROLE_CLAIM, role.name) + .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..0613cf15 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -0,0 +1,53 @@ +package com.weeth.global.auth.jwt.filter + +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) + } + + private fun saveAuthentication(accessToken: String) { + val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + val principal = AuthenticatedUser(claims.id, claims.email, claims.role) + + val authentication = + UsernamePasswordAuthenticationToken( + principal, + null, + 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 new file mode 100644 index 00000000..1819978f --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisRefreshTokenStoreAdapter.kt @@ -0,0 +1,87 @@ +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 +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: Role, + email: String, + ) { + val key = getKey(userId) + redisTemplate.opsForHash().putAll( + key, + mapOf( + TOKEN to refreshToken, + ROLE to role.name, + 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): Role { + val key = getKey(userId) + val role = + redisTemplate.opsForHash().get(key, ROLE) + ?: throw RedisTokenNotFoundException() + return runCatching { Role.valueOf(role) }.getOrElse { throw InvalidTokenException() } + } + + override fun updateRole( + userId: Long, + role: Role, + ) { + val key = getKey(userId) + if (redisTemplate.hasKey(key) == true) { + redisTemplate.opsForHash().put(key, ROLE, role.name) + } + } + + 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/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..c97334e6 --- /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..da0ca572 --- /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, +) 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/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..1d67f2c0 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/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/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..b26717de --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/exception/CommonExceptionHandler.kt @@ -0,0 +1,92 @@ +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("예외 처리(BaseException)", 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>> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + val exceptionResponses = mutableListOf() + + if (ex is ErrorResponse) { + ex.bindingResult.fieldErrors.forEach { fieldError -> + exceptionResponses.add( + BindExceptionResponse( + message = fieldError.defaultMessage, + value = fieldError.rejectedValue, + ), + ) + } + } + + log.warn("예외 처리(BindException)", 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> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 400 + + log.warn("예외 처리(MethodArgumentTypeMismatchException)", 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> { + val statusCode = if (ex is ErrorResponse) ex.statusCode.value() else 500 + + log.warn("예외 처리(Exception)", 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/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, ) 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..a6763ffe --- /dev/null +++ b/src/main/kotlin/com/weeth/global/config/SwaggerConfig.kt @@ -0,0 +1,182 @@ +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 + +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 = SWAGGER_DESCRIPTION, + ), +) +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()) + } +} 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..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 @@ -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, Role.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) } } } 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..3a54cad5 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/JwtTokenExtractorTest.kt @@ -0,0 +1,85 @@ +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 +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.javaObjectType) } 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.javaObjectType) } 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 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 new file mode 100644 index 00000000..e7ec96a6 --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCaseTest.kt @@ -0,0 +1,51 @@ +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 +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", Role.USER) } returns "access" + every { jwtProvider.createRefreshToken(1L) } returns "refresh" + + val result = useCase.create(1L, "a@weeth.com", Role.USER) + + result shouldBe JwtDto("access", "refresh") + verify(exactly = 1) { refreshTokenStore.save(1L, "refresh", Role.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 Role.ADMIN + every { refreshTokenStore.getEmail(10L) } returns "admin@weeth.com" + 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", 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 new file mode 100644 index 00000000..e77028fa --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/domain/service/JwtTokenProviderTest.kt @@ -0,0 +1,36 @@ +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 +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", Role.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..ecb9507d --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -0,0 +1,84 @@ +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 +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", Role.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("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 null + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } + } + }) 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..1ba5944b --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisRefreshTokenStoreAdapterTest.kt @@ -0,0 +1,90 @@ +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 +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", Role.ADMIN, "a@weeth.com") + + redisRefreshTokenStoreAdapter.getRole(1L) shouldBe Role.ADMIN + redisRefreshTokenStoreAdapter.getEmail(1L) shouldBe "a@weeth.com" + redisTemplate.opsForHash().get("refreshToken:1", "token") shouldBe "rt" + } + } + + describe("validateRefreshToken") { + it("저장된 토큰과 일치하면 예외가 발생하지 않는다") { + redisRefreshTokenStoreAdapter.save(2L, "stored", Role.USER, "u@weeth.com") + + redisRefreshTokenStoreAdapter.validateRefreshToken(2L, "stored") + } + + it("요청 토큰이 다르면 InvalidTokenException이 발생한다") { + redisRefreshTokenStoreAdapter.save(3L, "stored", Role.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", Role.USER, "x@weeth.com") + redisRefreshTokenStoreAdapter.delete(4L) + + shouldThrow { + redisRefreshTokenStoreAdapter.getRole(4L) + } + } + + it("updateRole은 기존 저장 값의 role만 변경한다") { + redisRefreshTokenStoreAdapter.save(5L, "rt", Role.USER, "x@weeth.com") + + redisRefreshTokenStoreAdapter.updateRole(5L, Role.ADMIN) + + redisRefreshTokenStoreAdapter.getRole(5L) shouldBe Role.ADMIN + redisRefreshTokenStoreAdapter.getEmail(5L) shouldBe "x@weeth.com" + } + } + }) { + companion object { + private const val PREFIX = "refreshToken:" + } +} 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..c94c74bf --- /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.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.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() + } + } +} 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" + } + } + })