From 7de1a71461c123a3e1b3c84c0905eb054edba2eb Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 5 May 2025 23:22:14 +0900 Subject: [PATCH 1/7] Refactor: Clean up code formatting and enhance JWT handling - Consolidated tag annotations in AdminController and MemberController for consistency. - Improved formatting in SecurityConfig and JwtAuthorizationFilter for better readability. - Added constants for access and refresh token durations in AuthConstants. - Updated error codes in ErrorCode for better clarity on refresh token issues. - Refactored JwtTokenProvider to streamline token creation and validation processes. - Enhanced MemberService with clearer method documentation and improved member retrieval logic. --- .../juu/juulabel/admin/AdminController.java | 12 +- .../common/config/SecurityConfig.java | 35 +- .../common/constants/AuthConstants.java | 6 + .../dto/response/MemberProfileResponse.java | 1 - .../common/dto/response/RefreshResponse.java | 5 + .../common/exception/code/ErrorCode.java | 6 +- .../handler/GlobalExceptionHandler.java | 11 +- .../common/filter/JwtAuthorizationFilter.java | 13 +- .../common/provider/JwtTokenProvider.java | 179 ++++++-- .../juulabel/common/util/HttpRequestUtil.java | 33 ++ .../member/controller/AuthController.java | 98 +++++ .../member/controller/MemberController.java | 143 +++---- .../juulabel/member/domain/RefreshToken.java | 58 +++ .../repository/RefreshTokenRepository.java | 55 +++ .../jpa/RefreshTokenJpaRepository.java | 21 + .../member/request/OAuthUserInfo.java | 1 + .../juulabel/member/service/AuthService.java | 291 +++++++++++++ .../member/service/MemberService.java | 386 +++++++----------- .../juu/juulabel/member/util/MemberUtils.java | 123 ++++++ .../tastingnote/domain/TastingNote.java | 34 +- 20 files changed, 1079 insertions(+), 432 deletions(-) create mode 100644 src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java create mode 100644 src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java create mode 100644 src/main/java/com/juu/juulabel/member/controller/AuthController.java create mode 100644 src/main/java/com/juu/juulabel/member/domain/RefreshToken.java create mode 100644 src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java create mode 100644 src/main/java/com/juu/juulabel/member/service/AuthService.java create mode 100644 src/main/java/com/juu/juulabel/member/util/MemberUtils.java diff --git a/src/main/java/com/juu/juulabel/admin/AdminController.java b/src/main/java/com/juu/juulabel/admin/AdminController.java index a148bcee..d6178b22 100644 --- a/src/main/java/com/juu/juulabel/admin/AdminController.java +++ b/src/main/java/com/juu/juulabel/admin/AdminController.java @@ -10,12 +10,9 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag( - name = "관리자 API", - description = "뱃지 부여 및 알림 발송 등 관리자 관련 API" -) +@Tag(name = "관리자 API", description = "뱃지 부여 및 알림 발송 등 관리자 관련 API") @RestController -@RequestMapping(value = {"/v1/api/admins"}) +@RequestMapping(value = { "/v1/api/admins" }) @RequiredArgsConstructor public class AdminController { @@ -24,9 +21,8 @@ public class AdminController { @Operation(summary = "뱃지 부여") @PostMapping("/badges") public ResponseEntity> assignBadge( - @AuthenticationPrincipal Member member, - @RequestParam(value = "email") String email - ) { + @AuthenticationPrincipal Member member, + @RequestParam(value = "email") String email) { adminService.assignBadge(email, member); return CommonResponse.success(SuccessCode.SUCCESS); } diff --git a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java index 378405b9..273ca8e4 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -29,25 +29,25 @@ public class SecurityConfig { private final JwtExceptionFilter jwtExceptionFilter; private static final String[] PERMIT_PATHS = { - "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", - "/v1/api/members/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", - "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow" , "/**", "/v1/api/reports" + "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", + "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", + "/v1/api/auth/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", + "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow", "/**", "/v1/api/reports" }; private static final String[] ALLOW_ORIGINS = { - "http://localhost:8084", - "http://localhost:8080", - "http://localhost:5173", - "http://localhost:3000", - "https://api.juulabel.com", - "https://dev.juulabel.com", - "https://qa.juulabel.com", - "https://juulabel.com", - "https://juulabel.shop", - "https://juulabel-front.vercel.app/", - "https://juulabel-front-seven.vercel.app/", - "https://d3jwyw9rpnxu8p.cloudfront.net" + "http://localhost:8084", + "http://localhost:8080", + "http://localhost:5173", + "http://localhost:3000", + "https://api.juulabel.com", + "https://dev.juulabel.com", + "https://qa.juulabel.com", + "https://juulabel.com", + "https://juulabel.shop", + "https://juulabel-front.vercel.app/", + "https://juulabel-front-seven.vercel.app/", + "https://d3jwyw9rpnxu8p.cloudfront.net" }; @Bean @@ -65,8 +65,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(OPTIONS, "**").permitAll() .requestMatchers(PERMIT_PATHS).permitAll() .requestMatchers("/v1/api/admins/permission/test").hasAnyAuthority(MemberRole.ROLE_ADMIN.name()) - .anyRequest().authenticated() - ) + .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) diff --git a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java index 1480d7aa..d45e72de 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -1,5 +1,7 @@ package com.juu.juulabel.common.constants; +import java.time.Duration; + import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -11,4 +13,8 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; + public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1); + public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(30); + public static final String REFRESH_TOKEN_HEADER_NAME = "X-Refresh-Token"; + } diff --git a/src/main/java/com/juu/juulabel/common/dto/response/MemberProfileResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/MemberProfileResponse.java index 01ec4717..419cc989 100644 --- a/src/main/java/com/juu/juulabel/common/dto/response/MemberProfileResponse.java +++ b/src/main/java/com/juu/juulabel/common/dto/response/MemberProfileResponse.java @@ -1,6 +1,5 @@ package com.juu.juulabel.common.dto.response; -import com.querydsl.core.types.dsl.BooleanExpression; import io.swagger.v3.oas.annotations.media.Schema; public record MemberProfileResponse( diff --git a/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java b/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java new file mode 100644 index 00000000..2a1418f4 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/dto/response/RefreshResponse.java @@ -0,0 +1,5 @@ +package com.juu.juulabel.common.dto.response; + +public record RefreshResponse(String accessToken) { + +} diff --git a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index 11793ace..be3e7733 100644 --- a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java +++ b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java @@ -28,12 +28,16 @@ public enum ErrorCode { JWT_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), JWT_UNSUPPORTED_EXCEPTION(HttpStatus.BAD_REQUEST, "지원되지 않는 토큰입니다."), JWT_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), - JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), /** * Authentication */ OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "Provider를 찾을 수 없습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), + REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용 감지"), + REFRESH_TOKEN_ALREADY_ROTATED(HttpStatus.UNAUTHORIZED, "이미 회전된 토큰입니다."), /** * Admin, Member diff --git a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java index 68cf2cf6..0c607a0c 100644 --- a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java @@ -5,7 +5,6 @@ import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.response.CommonResponse; -import io.jsonwebtoken.ExpiredJwtException; import io.sentry.Sentry; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -36,13 +35,6 @@ public ResponseEntity> handle(BaseException e) { return CommonResponse.fail(e.getErrorCode()); } - @ExceptionHandler(RuntimeException.class) - public ResponseEntity> handle(RuntimeException e) { - log.error("RuntimeException :", e); - Sentry.captureException(e); - return CommonResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage()); - } - @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handle(MethodArgumentNotValidException e) { log.error("MethodArgumentNotValidException :", e); @@ -58,8 +50,7 @@ public ResponseEntity> handle(CustomJwtException e) { @ExceptionHandler(NoResourceFoundException.class) public void handle(NoResourceFoundException e) { - // 이거 키면 출력이 너무 많이 됨 - // log.warn("NoResourceFoundException : {}", e.getMessage()); + log.warn("NoResourceFoundException : {}", e.getMessage()); } @ExceptionHandler(HttpMessageNotReadableException.class) diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java index f2cb0a18..aef9bf44 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -10,7 +10,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -18,6 +17,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import com.juu.juulabel.common.util.HttpRequestUtil; + import java.io.IOException; @Component @@ -25,14 +26,15 @@ public class JwtAuthorizationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + private final HttpRequestUtil httpRequestUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { + throws ServletException, IOException { - String header = request.getHeader(HttpHeaders.AUTHORIZATION); + String header = httpRequestUtil.extractAuthorization(request); if (header != null) { - String token = jwtTokenProvider.resolveToken(request.getHeader(HttpHeaders.AUTHORIZATION)); + String token = jwtTokenProvider.resolveToken(header); try { if (jwtTokenProvider.isValidateToken(token)) { authenticate(token); @@ -55,7 +57,8 @@ private void handleJwtException(HttpServletResponse response) throws IOException response.setCharacterEncoding("UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.getWriter().write(CommonResponse.fail(ErrorCode.INVALID_AUTHENTICATION, "만료되었거나 잘못된 토큰입니다.").toString()); + response.getWriter() + .write(CommonResponse.fail(ErrorCode.INVALID_AUTHENTICATION, "만료되었거나 잘못된 토큰입니다.").toString()); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java index fc9da767..f59061ff 100644 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java @@ -1,11 +1,11 @@ package com.juu.juulabel.common.provider; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.MemberRole; + import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; @@ -13,82 +13,181 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; +import static com.juu.juulabel.common.constants.AuthConstants.ACCESS_TOKEN_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.TOKEN_PREFIX; + import javax.crypto.SecretKey; + import java.time.Duration; import java.util.*; +import java.util.function.Function; @Component public class JwtTokenProvider { - private static final long ACCESS_TOKEN_EXPIRE_TIME = Duration.ofDays(1).toMillis(); private static final String ISSUER = "juulabel"; private static final String ROLE_CLAIM = "role"; private final SecretKey key; + private final JwtParser jwtParser; public JwtTokenProvider(@Value("${spring.jwt.secret}") String key) { - byte[] keyBytes = Base64.getDecoder().decode(key); - this.key = Keys.hmacShaKeyFor(keyBytes); + this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(key)); + this.jwtParser = Jwts.parser().verifyWith(this.key).build(); } + /** + * Creates an access token for a member + * + * @param member The member for whom to create the token + * @return The JWT access token string + */ public String createAccessToken(Member member) { - return Jwts.builder() - .subject(String.valueOf(member.getId())) - .claim(ROLE_CLAIM, member.getRole().name()) + return buildToken(member.getId(), member.getRole().name(), ACCESS_TOKEN_DURATION); + } + + /** + * Creates a refresh token entity for a member + * + * @param member The member for whom to create the token + * @return A RefreshToken entity + */ + public String createRefreshToken(Long memberId) { + return buildToken(memberId, null, REFRESH_TOKEN_DURATION); + } + + /** + * Builds a JWT token + * + * @param memberId The member ID + * @param role The role (can be null) + * @param duration The duration + * @return The JWT token string + */ + private String buildToken(Long memberId, String role, Duration duration) { + Date expirationDate = getExpirationDate(duration); + JwtBuilder builder = Jwts.builder() + .subject(String.valueOf(memberId)) .issuedAt(new Date()) .issuer(ISSUER) - .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME)) - .signWith(key) - .compact(); - } + .expiration(expirationDate) + .signWith(key); - public Authentication getAuthentication(String accessToken) { - Claims claims = parseClaims(accessToken); + if (role != null) { + builder.claim(ROLE_CLAIM, role); + } - Collection roles = Collections - .singletonList(new SimpleGrantedAuthority(claims.get(ROLE_CLAIM, String.class))); + return builder.compact(); + } - Member member = Member.builder() - .id(Long.parseLong(claims.getSubject())) - .role(MemberRole.valueOf(claims.get(ROLE_CLAIM, String.class))) - .build(); + /** + * Gets authentication from an access token + * + * @param accessToken The access token + * @return The Authentication object + */ + public Authentication getAuthentication(String accessToken) { + return extractFromClaims(accessToken, claims -> { + String role = claims.get(ROLE_CLAIM, String.class); + Long memberId = Long.parseLong(claims.getSubject()); + + Member member = Member.builder() + .id(memberId) + .role(MemberRole.valueOf(role)) + .build(); + + return new UsernamePasswordAuthenticationToken( + member, + null, + Collections.singletonList(new SimpleGrantedAuthority(role))); + }); + } - return new UsernamePasswordAuthenticationToken( - member, - null, - roles); + /** + * Extracts member information from a token + * + * @param token The JWT token + * @return The Member object + */ + public Member getMemberFromToken(String token) { + return extractFromClaims(token, claims -> { + Long memberId = Long.parseLong(claims.getSubject()); + String role = claims.get(ROLE_CLAIM, String.class); + + return Member.builder() + .id(memberId) + .role(role != null ? MemberRole.valueOf(role) : MemberRole.ROLE_USER) + .build(); + }); } + /** + * Resolves a token from the Authorization header + * + * @param header The Authorization header + * @return The token without the prefix + */ public String resolveToken(String header) { - return Optional.ofNullable(header) - .orElseThrow(() -> new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION)) - .replace(AuthConstants.TOKEN_PREFIX, ""); + if (header == null) { + throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); + } + return header.replace(TOKEN_PREFIX, ""); } + /** + * Validates a token + * + * @param token The JWT token + * @return true if the token is valid, false otherwise + */ public boolean isValidateToken(String token) { if (!StringUtils.hasText(token)) { return false; } - - return !getExpirationByToken(token).before(new Date()); + try { + return !parseClaims(token).getExpiration().before(new Date()); + } catch (CustomJwtException e) { + return false; + } } + /** + * Gets the expiration date from a token + * + * @param token The JWT token + * @return The expiration date + */ public Date getExpirationByToken(String token) { - return parseClaims(token).getExpiration(); + return extractFromClaims(token, Claims::getExpiration); + } + + /** + * Extracts data from claims using a function + * + * @param token The JWT token + * @param claimsResolver The function to extract data from claims + * @return The extracted data + */ + private T extractFromClaims(String token, Function claimsResolver) { + Claims claims = parseClaims(token); + return claimsResolver.apply(claims); } + /** + * Parses claims from a token + * + * @param token The JWT token + * @return The claims + * @throws CustomJwtException if the token is invalid + */ private Claims parseClaims(String token) { try { - return Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token) - .getPayload(); + return jwtParser.parseSignedClaims(token).getPayload(); } catch (SignatureException | MalformedJwtException ex) { throw new CustomJwtException(ErrorCode.JWT_MALFORMED_EXCEPTION); } catch (ExpiredJwtException ex) { @@ -100,4 +199,14 @@ private Claims parseClaims(String token) { } } + /** + * Gets an expiration date based on current time plus duration + * + * @param duration The duration + * @return The expiration date + */ + private Date getExpirationDate(Duration duration) { + return new Date(System.currentTimeMillis() + duration.toMillis()); + } + } diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java new file mode 100644 index 00000000..8bfcb49f --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java @@ -0,0 +1,33 @@ +package com.juu.juulabel.common.util; + +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility class for HTTP request operations + */ +@Component +public class HttpRequestUtil { + + /** + * Extract client IP address from request + * Handles X-Forwarded-For header for clients behind a proxy + */ + public String extractIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + return xForwardedFor != null && !xForwardedFor.isEmpty() + ? xForwardedFor.split(",")[0].trim() + : request.getRemoteAddr(); + } + + public String extractUserAgent(HttpServletRequest request) { + return request.getHeader(HttpHeaders.USER_AGENT); + } + + public String extractAuthorization(HttpServletRequest request) { + return request.getHeader(HttpHeaders.AUTHORIZATION); + + } +} diff --git a/src/main/java/com/juu/juulabel/member/controller/AuthController.java b/src/main/java/com/juu/juulabel/member/controller/AuthController.java new file mode 100644 index 00000000..72f410eb --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/controller/AuthController.java @@ -0,0 +1,98 @@ +package com.juu.juulabel.member.controller; + +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.dto.response.RefreshResponse; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.exception.code.SuccessCode; +import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "인증 API", description = "로그인, 회원가입, 토큰 관리 등 인증 관련 API") +@RestController +@RequestMapping(value = { "/v1/api/auth" }) +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "OAuth 로그인 (소셜 로그인)") + @PostMapping("/login/{provider}") + public ResponseEntity> oauthLogin( + @PathVariable String provider, + @Valid @RequestBody OAuthLoginRequest requestBody, + HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse) { + + // 경로에서 제공자 정보를 파싱하여 새 요청 객체를 생성 + OAuthLoginRequest request = new OAuthLoginRequest( + requestBody.code(), + requestBody.redirectUri(), + Provider.valueOf(provider.toUpperCase())); + + LoginResponse loginResponse = authService.login(request); + if (!loginResponse.isNewMember()) { + authService.registerRefreshToken(loginResponse.oAuthUserInfo().memberId(), httpServletRequest, + httpServletResponse); + } + return CommonResponse.success(SuccessCode.SUCCESS, loginResponse); + } + + @Operation(summary = "회원가입") + @PostMapping("/sign-up") + public ResponseEntity> signUp( + @Valid @RequestBody SignUpMemberRequest request, + HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse) { + SignUpMemberResponse signUpMemberResponse = authService.signUp(request); + authService.registerRefreshToken(signUpMemberResponse.memberId(), httpServletRequest, + httpServletResponse); + return CommonResponse.success(SuccessCode.SUCCESS, signUpMemberResponse); + } + + @Operation(summary = "액세스 토큰 갱신") + @PostMapping("/refresh") + public ResponseEntity> refresh( + @CookieValue(value = "refreshToken", required = true) String refreshTokenCookie, + HttpServletRequest request, + HttpServletResponse response) { + return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshTokenCookie, request, + response)); + } + + @Operation(summary = "로그아웃") + @PostMapping("/logout") + public ResponseEntity> logout( + @CookieValue(value = "refreshToken", required = true) String refreshTokenCookie) { + authService.logout(refreshTokenCookie); + return CommonResponse.success(SuccessCode.SUCCESS); + } + + @Operation(summary = "회원 탈퇴") + @DeleteMapping("/me") + public ResponseEntity> deleteAccount( + @AuthenticationPrincipal Member member, + @RequestBody WithdrawalRequest request) { + authService.deleteAccount(member, request); + return CommonResponse.success(SuccessCode.SUCCESS_DELETE); + } + + @Operation(summary = "닉네임 중복 검사") + @GetMapping("/nicknames/{nickname}/exists") + public ResponseEntity> checkNickname(@PathVariable String nickname) { + return CommonResponse.success(SuccessCode.SUCCESS, authService.checkNickname(nickname)); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/controller/MemberController.java b/src/main/java/com/juu/juulabel/member/controller/MemberController.java index bf6c6796..db305c45 100644 --- a/src/main/java/com/juu/juulabel/member/controller/MemberController.java +++ b/src/main/java/com/juu/juulabel/member/controller/MemberController.java @@ -8,153 +8,108 @@ import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; -@Tag( - name = "회원 API", - description = "로그인,회원가입,프로필 수정,내가 작성한 게시글 조회 등 회원 관련 API" -) +@Tag(name = "회원 API", description = "프로필 수정, 내 정보 조회 등 회원 관련 API") @RestController -@RequestMapping(value = {"/v1/api/members"}) +@RequestMapping(value = { "/v1/api/members" }) @RequiredArgsConstructor public class MemberController { private final MemberService memberService; - @Operation(summary = "카카오 로그인") - @PostMapping("/login/kakao") - public ResponseEntity> kakaoLogin(@Valid @RequestBody OAuthLoginRequest request) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.login(request)); - } - - @Operation(summary = "구글 로그인") - @PostMapping("/login/google") - public ResponseEntity> googleLogin(@Valid @RequestBody OAuthLoginRequest request) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.login(request)); + @Operation(summary = "프로필 수정") + @PutMapping("/me/profile") + public ResponseEntity> updateProfile( + @AuthenticationPrincipal Member member, + @Valid @RequestPart(value = "request") UpdateProfileRequest request, + @RequestPart(value = "image", required = false) MultipartFile image) { + return CommonResponse.success(SuccessCode.SUCCESS, memberService.updateProfile(member, request, image)); } - @Operation(summary = "회원가입") - @PostMapping("/sign-up") - public ResponseEntity> signUp(@Valid @RequestBody SignUpMemberRequest request) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.signUp(request)); + @Operation(summary = "내 정보 조회") + @GetMapping("/my-info") + public ResponseEntity> getMyInfo(@AuthenticationPrincipal Member member) { + return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMyInfo(member)); } - @Operation(summary = "닉네임 중복 검사") - @GetMapping("/nicknames/{nickname}/exists") - public ResponseEntity> checkNickname(@NotNull @PathVariable String nickname) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.checkNickname(nickname)); + @Operation(summary = "내 공간 조회") + @GetMapping("/my-space") + public ResponseEntity> getMySpace(@AuthenticationPrincipal Member member) { + return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMySpace(member)); } - @Operation(summary = "프로필 수정") - @PutMapping("/me/profile") - public ResponseEntity> updateProfile( - @AuthenticationPrincipal Member member, - @Valid @RequestPart(value = "request") UpdateProfileRequest request, - @RequestPart(value = "image", required = false) MultipartFile image - ) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.updateProfile(member, request, image)); + @Operation(summary = "타 유저 프로필 조회") + @GetMapping("/{memberId}/profile") + public ResponseEntity> getMemberProfile( + @AuthenticationPrincipal Member member, + @PathVariable Long memberId) { + return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMemberProfile(member, memberId)); } @Operation(summary = "내가 작성한 일상생활 목록 조회") @Parameters(@Parameter(name = "request", description = "내가 작성한 일상생활 목록 조회 요청", required = true)) @GetMapping("/daily-lives/my") public ResponseEntity> loadMyDailyLifeList( - @AuthenticationPrincipal Member member, - @Valid DailyLifeListRequest request - ) { + @AuthenticationPrincipal Member member, + @Valid DailyLifeListRequest request) { return CommonResponse.success(SuccessCode.SUCCESS, memberService.loadMyDailyLifeList(member, request)); } @Operation(summary = "내가 작성한 시음노트 목록 조회") @Parameters(@Parameter(name = "request", description = "내가 작성한 시음노트 목록 조회 요청", required = true)) - @GetMapping("/tasting_notes/my") + @GetMapping("/tasting-notes/my") public ResponseEntity> loadMyTastingNoteList( - @AuthenticationPrincipal Member member, - @Valid TastingNoteListRequest request - ) { + @AuthenticationPrincipal Member member, + @Valid TastingNoteListRequest request) { return CommonResponse.success(SuccessCode.SUCCESS, memberService.loadMyTastingNoteList(member, request)); } - @Operation(hidden = true, summary = "전통주 저장") - @PostMapping("/{alcoholicDrinksId}/save") + @Operation(summary = "전통주 저장") + @PostMapping("/alcoholic-drinks/{alcoholicDrinksId}/save") public ResponseEntity> saveAlcoholicDrinks( - @AuthenticationPrincipal Member member, - @PathVariable Long alcoholicDrinksId - ) { + @AuthenticationPrincipal Member member, + @PathVariable Long alcoholicDrinksId) { boolean isSaved = memberService.saveAlcoholicDrinks(member, alcoholicDrinksId); return CommonResponse.success(isSaved ? SuccessCode.SUCCESS_INSERT : SuccessCode.SUCCESS_DELETE); } - @Operation(hidden = true, summary = "내가 저장한 전통주 목록 조회") + @Operation(summary = "내가 저장한 전통주 목록 조회") @Parameters(@Parameter(name = "request", description = "내가 저장한 전통주 목록 조회 요청", required = true)) @GetMapping("/alcoholic-drinks/my") public ResponseEntity> loadMyAlcoholicDrinks( - @AuthenticationPrincipal Member member, - @Valid MyAlcoholicDrinksListRequest request - ) { + @AuthenticationPrincipal Member member, + @Valid MyAlcoholicDrinksListRequest request) { return CommonResponse.success(SuccessCode.SUCCESS, memberService.loadMyAlcoholicDrinks(member, request)); } - @Operation(summary = "내 공간 조회") - @GetMapping("/my-space") - public ResponseEntity> getMySpace(@AuthenticationPrincipal Member member) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMySpace(member)); - } - - @Operation(summary = "내 정보 조회") - @GetMapping("/my-info") - public ResponseEntity> getMyInfo(@AuthenticationPrincipal Member member) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMyInfo(member)); - } - - @Operation(summary = "타 유저 프로필 조회") - @GetMapping("/{memberId}/profile") - public ResponseEntity> getMemberProfile( - @AuthenticationPrincipal Member member, - @PathVariable Long memberId - ) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.getMemberProfile(member, memberId)); - } - @Operation(summary = "특정 회원이 작성한 시음노트 목록 조회") @Parameters(@Parameter(name = "request", description = "특정 회원이 작성한 시음노트 목록 조회 요청", required = true)) - @GetMapping("/{memberId}/tasting_notes") + @GetMapping("/members/{memberId}/tasting-notes") public ResponseEntity> loadMemberTastingNoteList( - @AuthenticationPrincipal Member member, - @Valid TastingNoteListRequest request, - @PathVariable Long memberId - ) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.loadMemberTastingNoteList(member, request, memberId)); + @AuthenticationPrincipal Member member, + @Valid TastingNoteListRequest request, + @PathVariable Long memberId) { + return CommonResponse.success(SuccessCode.SUCCESS, + memberService.loadMemberTastingNoteList(member, request, memberId)); } @Operation(summary = "특정 회원이 작성한 일상생활 목록 조회") @Parameters(@Parameter(name = "request", description = "특정 회원이 작성한 일상생활 목록 조회 요청", required = true)) - @GetMapping("/{memberId}/daily-lives") + @GetMapping("/members/{memberId}/daily-lives") public ResponseEntity> loadMemberDailyLifeList( - @AuthenticationPrincipal Member member, - @Valid DailyLifeListRequest request, - @PathVariable Long memberId - ) { - return CommonResponse.success(SuccessCode.SUCCESS, memberService.loadMemberDailyLifeList(member, request, memberId)); + @AuthenticationPrincipal Member member, + @Valid DailyLifeListRequest request, + @PathVariable Long memberId) { + return CommonResponse.success(SuccessCode.SUCCESS, + memberService.loadMemberDailyLifeList(member, request, memberId)); } - - @Operation(summary = "회원 탈퇴") - @DeleteMapping("/me") - public ResponseEntity> deleteAccount( - @AuthenticationPrincipal Member member, - @RequestBody WithdrawalRequest request - ) { - memberService.deleteAccount(member, request); - return CommonResponse.success(SuccessCode.SUCCESS_DELETE); - } - } diff --git a/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java new file mode 100644 index 00000000..1e540d9e --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java @@ -0,0 +1,58 @@ +package com.juu.juulabel.member.domain; + +import java.time.LocalDateTime; + +import com.juu.juulabel.common.base.BaseTimeEntity; + +import jakarta.persistence.*; +import lombok.*; + +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +@Table(name = "refresh_tokens", indexes = { + @Index(name = "idx_refresh_token_token_unq", columnList = "token", unique = true), + @Index(name = "idx_refresh_token_member_id", columnList = "member_id"), + @Index(name = "idx_refresh_token_expiry_date", columnList = "expires_at") +}) +public class RefreshToken extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", columnDefinition = "BIGINT UNSIGNED comment '리프레시 토큰 고유 번호'") + private Long id; + + @Column(name = "member_id", nullable = false, columnDefinition = "BIGINT UNSIGNED comment '회원 고유 번호'") + private Long memberId; + + @Column(name = "token", nullable = false, unique = true, columnDefinition = "varchar(255) comment '리프레시 토큰'") + private String token; + + @Column(name = "parent_token_id", columnDefinition = "BIGINT UNSIGNED comment '부모 토큰 아이디'") + private Long parentTokenId; + + @Column(name = "ip_address", nullable = false, columnDefinition = "varchar(255) comment 'IP 주소'") + private String ipAddress; + + @Column(name = "user_agent", nullable = false, columnDefinition = "varchar(255) comment '유저 에이전트'") + private String userAgent; + + @Column(name = "device_id", columnDefinition = "varchar(255) comment '디바이스 아이디'") + private String deviceId; + + @Column(name = "expires_at", nullable = false, columnDefinition = "datetime comment '토큰 만료 일시'") + private LocalDateTime expiresAt; + + @Column(name = "revoked", nullable = false, columnDefinition = "TINYINT(1) comment '토큰 무효화 여부'") + @Builder.Default + private boolean revoked = false; + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } + + public void setParentTokenId(Long parentTokenId) { + this.parentTokenId = parentTokenId; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java b/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..ac61f14b --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java @@ -0,0 +1,55 @@ +package com.juu.juulabel.member.repository; + +import java.util.Optional; + +import com.juu.juulabel.member.domain.RefreshToken; + +/** + * Interface for refresh token persistence operations. + * This abstraction allows swapping implementations (e.g., JPA, Redis). + */ +public interface RefreshTokenRepository { + + /** + * Finds a refresh token by its token string. + * + * @param token The token string. + * @return An Optional containing the RefreshToken if found, otherwise empty. + */ + Optional findByToken(String token); + + /** + * Checks if a refresh token exists with the given parent token ID. + * Used for detecting token reuse after rotation. + * + * @param parentTokenId The ID of the parent token. + * @return true if a token with the specified parent ID exists, false otherwise. + */ + boolean existsByParentTokenId(Long parentTokenId); + + /** + * Deletes all refresh tokens associated with a specific member ID. + * Used when revoking all tokens for a user due to security concerns (e.g., + * reuse detection). + * + * @param memberId The ID of the member whose tokens should be deleted. + */ + void deleteByMemberId(Long memberId); + + /** + * Saves a refresh token entity. + * Used for storing new tokens during issuance or rotation. + * + * @param refreshToken The RefreshToken entity to save. + * @return The saved RefreshToken entity. + */ + RefreshToken save(RefreshToken refreshToken); + + /** + * Deletes a specific refresh token. + * (Optional: Might be useful for explicit deletion scenarios if needed later) + * + * @param refreshToken The RefreshToken entity to delete. + */ + +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java b/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java new file mode 100644 index 00000000..cbd6fce5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java @@ -0,0 +1,21 @@ +package com.juu.juulabel.member.repository.jpa; + +import java.util.Optional; + +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.juu.juulabel.member.domain.RefreshToken; +import com.juu.juulabel.member.repository.RefreshTokenRepository; + +@Primary +public interface RefreshTokenJpaRepository extends JpaRepository, RefreshTokenRepository { + @Override + Optional findByToken(String token); + + @Override + boolean existsByParentTokenId(Long parentTokenId); + + @Override + void deleteByMemberId(Long memberId); +} diff --git a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java b/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java index a2b9330b..647ae475 100644 --- a/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java +++ b/src/main/java/com/juu/juulabel/member/request/OAuthUserInfo.java @@ -3,6 +3,7 @@ import com.juu.juulabel.member.domain.Provider; public record OAuthUserInfo( + Long memberId, String email, String providerId, Provider provider diff --git a/src/main/java/com/juu/juulabel/member/service/AuthService.java b/src/main/java/com/juu/juulabel/member/service/AuthService.java new file mode 100644 index 00000000..b72acf29 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/service/AuthService.java @@ -0,0 +1,291 @@ +package com.juu.juulabel.member.service; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.dto.response.RefreshResponse; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.common.util.HttpRequestUtil; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.domain.RefreshToken; +import com.juu.juulabel.member.domain.WithdrawalRecord; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.repository.MemberWriter; +import com.juu.juulabel.member.repository.RefreshTokenRepository; +import com.juu.juulabel.member.repository.WithdrawalRecordReader; +import com.juu.juulabel.member.repository.WithdrawalRecordWriter; +import com.juu.juulabel.member.request.OAuthLoginInfo; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.request.OAuthUserInfo; +import com.juu.juulabel.member.token.Token; +import com.juu.juulabel.member.util.MemberUtils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HEADER_NAME; + +/** + * 인증 및 토큰 관리 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final OAuthProviderFactory providerFactory; + private final JwtTokenProvider jwtTokenProvider; + private final MemberReader memberReader; + private final MemberWriter memberWriter; + private final WithdrawalRecordReader withdrawalRecordReader; + private final WithdrawalRecordWriter withdrawalRecordWriter; + private final RefreshTokenRepository refreshTokenRepository; + private final HttpRequestUtil httpRequestUtil; + private final MemberUtils memberUtils; + + // ===== 인증 관련 메서드 ===== + + /** + * OAuth 로그인 처리 + */ + @Transactional + public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { + OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); + Provider provider = authLoginInfo.provider(); + + // 인가 코드를 이용해 토큰 발급 요청 + String accessToken = providerFactory.getAccessToken( + provider, + authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), + authLoginInfo.propertyMap().get(AuthConstants.CODE)); + + // 토큰을 이용해 사용자 정보 가져오기 + OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); + + // 회원가입 or 로그인 + String email = oAuthUser.email(); + validateNotWithdrawnMember(email); + + boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); + Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); + + Token token = memberOpt.map(member -> { + String generatedToken = jwtTokenProvider.createAccessToken(member); + return new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); + }).orElse(new Token(null, null)); + + return new LoginResponse( + token, + isNewMember, + new OAuthUserInfo( + memberOpt.map(Member::getId).orElse(null), + email, + oAuthUser.id(), + provider)); + } + + /** + * 회원 가입 + */ + @Transactional + public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { + validateNickname(signUpRequest.nickname()); + validateEmail(signUpRequest.email()); + + Member member = Member.create(signUpRequest); + memberWriter.store(member); + + // 선호전통주 주종 등록 + memberUtils.processAlcoholTypes(member, signUpRequest); + + // 약관 등록 + memberUtils.processTermsAgreements(member, signUpRequest); + + String token = jwtTokenProvider.createAccessToken(member); + + return new SignUpMemberResponse( + member.getId(), + new Token(token, jwtTokenProvider.getExpirationByToken(token))); + } + + /** + * 회원 탈퇴 + */ + @Transactional + public void deleteAccount(Member loginMember, WithdrawalRequest request) { + loginMember.deleteAccount(); + withdrawalRecordWriter.store( + WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); + } + + /** + * 닉네임 중복 확인 + */ + @Transactional(readOnly = true) + public boolean checkNickname(String nickname) { + return memberReader.existActiveNickname(nickname); + } + + // ===== 토큰 관련 메서드 ===== + + /** + * 액세스 토큰 및 리프레시 토큰 갱신 + */ + @Transactional + public RefreshResponse refresh(String refreshTokenCookie, HttpServletRequest request, + HttpServletResponse response) { + // 한 번의 호출로 Member와 RefreshToken을 함께 가져오도록 최적화 + Member member = jwtTokenProvider.getMemberFromToken(refreshTokenCookie); + RefreshToken oldToken = validateAndGetOldToken(refreshTokenCookie); + + String ipAddress = httpRequestUtil.extractIpAddress(request); + String userAgent = httpRequestUtil.extractUserAgent(request); + + // 토큰 환경 검증 및 토큰 회전 + validateTokenEnvironment(oldToken, ipAddress, userAgent, member); + createAndSaveRefreshToken(member.getId(), oldToken.getId(), ipAddress, userAgent, response); + + // 새 액세스 토큰 생성 및 반환 + return new RefreshResponse(jwtTokenProvider.createAccessToken(member)); + } + + /** + * 리프레시 토큰 등록 + */ + @Transactional + public void registerRefreshToken(Long memberId, HttpServletRequest request, HttpServletResponse response) { + String ipAddress = httpRequestUtil.extractIpAddress(request); + String userAgent = httpRequestUtil.extractUserAgent(request); + + createAndSaveRefreshToken(memberId, null, ipAddress, userAgent, response); + } + + /** + * 로그아웃 처리 - 토큰 비활성화 + */ + @Transactional + public void logout(String refreshTokenCookie) { + refreshTokenRepository.findByToken(refreshTokenCookie) + .ifPresent(token -> { + token.setRevoked(true); + refreshTokenRepository.save(token); + }); + } + + /** + * 새 리프레시 토큰 생성 및 저장 + */ + public void createAndSaveRefreshToken(Long memberId, Long parentTokenId, String ipAddress, String userAgent, + HttpServletResponse response) { + + String token = jwtTokenProvider.createRefreshToken(memberId); + + RefreshToken newToken = RefreshToken.builder() + .token(token) + .memberId(memberId) + .parentTokenId(parentTokenId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .expiresAt(LocalDateTime.now().plusSeconds(REFRESH_TOKEN_DURATION.getSeconds())) + .build(); + + refreshTokenRepository.save(newToken); + setCookie(response, newToken.getToken()); + } + + /** + * 리프레시 토큰 검증 및 조회 + */ + private RefreshToken validateAndGetOldToken(String tokenStr) { + return refreshTokenRepository.findByToken(tokenStr) + .orElseThrow(() -> new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + } + + /** + * 토큰 환경 검증 (IP, UserAgent 등) + */ + private void validateTokenEnvironment(RefreshToken oldToken, String ipAddress, String userAgent, Member member) { + // 환경 일치 여부 확인 + if (!oldToken.getIpAddress().equals(ipAddress) && !oldToken.getUserAgent().equals(userAgent)) { + revokeAndThrow(oldToken, + String.format("의심스러운 활동 감지: IP=%s UA=%s Expected IP=%s UA=%s", + ipAddress, userAgent, oldToken.getIpAddress(), oldToken.getUserAgent()), + ErrorCode.REFRESH_TOKEN_INVALID); + } + + // 토큰이 이미 비활성화되었는지 확인 + if (oldToken.isRevoked()) { + if (refreshTokenRepository.existsByParentTokenId(oldToken.getId())) { + refreshTokenRepository.deleteByMemberId(member.getId()); + throw new BaseException( + String.format("리프레시 토큰 재사용 감지: IP=%s User-Agent=%s", + ipAddress, userAgent), + ErrorCode.REFRESH_TOKEN_INVALID); + } + throw new BaseException("이미 회전된 토큰", ErrorCode.REFRESH_TOKEN_ALREADY_ROTATED); + } + + // 토큰 비활성화 + oldToken.setRevoked(true); + refreshTokenRepository.save(oldToken); + } + + /** + * 토큰 비활성화 및 예외 발생 + */ + private void revokeAndThrow(RefreshToken oldToken, String message, ErrorCode errorCode) { + oldToken.setRevoked(true); + refreshTokenRepository.save(oldToken); + throw new BaseException(message, errorCode); + } + + /** + * 리프레시 토큰 쿠키 설정 + */ + private void setCookie(HttpServletResponse response, String token) { + Cookie cookie = new Cookie(REFRESH_TOKEN_HEADER_NAME, token); + cookie.setMaxAge((int) REFRESH_TOKEN_DURATION.getSeconds()); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setSecure(true); + response.addCookie(cookie); + } + + // ===== 유효성 검증 메서드 ===== + + private void validateNickname(String nickname) { + if (memberReader.existActiveNickname(nickname)) { + throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); + } + } + + private void validateNotWithdrawnMember(String email) { + if (withdrawalRecordReader.existEmail(email)) { + throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); + } + } + + private void validateEmail(String email) { + if (memberReader.existActiveEmail(email)) { + throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/service/MemberService.java b/src/main/java/com/juu/juulabel/member/service/MemberService.java index bddf38a5..2dbc00bb 100644 --- a/src/main/java/com/juu/juulabel/member/service/MemberService.java +++ b/src/main/java/com/juu/juulabel/member/service/MemberService.java @@ -1,19 +1,14 @@ package com.juu.juulabel.member.service; -import com.juu.juulabel.alcohol.domain.AlcoholType; import com.juu.juulabel.alcohol.domain.AlcoholicDrinks; import com.juu.juulabel.alcohol.repository.AlcoholTypeReader; import com.juu.juulabel.alcohol.repository.AlcoholicDrinksReader; import com.juu.juulabel.alcohol.repository.TastingNoteReader; import com.juu.juulabel.alcohol.response.AlcoholicDrinksSummary; -import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.dto.request.*; import com.juu.juulabel.common.dto.response.*; import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.factory.OAuthProviderFactory; -import com.juu.juulabel.common.provider.JwtTokenProvider; import com.juu.juulabel.dailylife.repository.DailyLifeReader; import com.juu.juulabel.dailylife.response.DailyLifeListRequest; import com.juu.juulabel.dailylife.response.DailyLifeSummary; @@ -22,239 +17,92 @@ import com.juu.juulabel.member.domain.*; import com.juu.juulabel.member.repository.*; import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; -import com.juu.juulabel.member.request.OAuthLoginInfo; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.member.request.OAuthUserInfo; -import com.juu.juulabel.member.token.Token; +import com.juu.juulabel.member.util.MemberUtils; import com.juu.juulabel.s3.S3Service; import com.juu.juulabel.s3.UploadImageInfo; import com.juu.juulabel.tastingnote.request.MyTastingNoteSummary; import com.juu.juulabel.tastingnote.request.TastingNoteSummary; -import com.juu.juulabel.terms.domain.Terms; -import com.juu.juulabel.terms.request.TermsAgreement; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; +/** + * 회원 프로필 및 계정 관리 서비스 + */ @Service @RequiredArgsConstructor public class MemberService { - private final OAuthProviderFactory providerFactory; - private final JwtTokenProvider jwtTokenProvider; private final MemberReader memberReader; - private final MemberWriter memberWriter; - private final TermsReader termsReader; - private final MemberTermsWriter memberTermsWriter; private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; private final MemberAlcoholTypeReader memberAlcoholTypeReader; private final AlcoholTypeReader alcoholTypeReader; private final S3Service s3Service; private final DailyLifeReader dailyLifeReader; - private final AlcoholicDrinksReader alcoholicDrinksReader; - private final MemberAlcoholicDrinksReader memberAlcoholicDrinksReader; - private final MemberAlcoholicDrinksWriter memberAlcoholicDrinksWriter; private final TastingNoteReader tastingNoteReader; - private final WithdrawalRecordWriter withdrawalRecordWriter; - private final WithdrawalRecordReader withdrawalRecordReader; - private final FollowReader followReader; private final MemberJpaRepository memberJpaRepository; + private final FollowReader followReader; + private final MemberUtils memberUtils; + private final AlcoholicDrinksReader alcoholicDrinksReader; + private final MemberAlcoholicDrinksReader memberAlcoholicDrinksReader; + private final MemberAlcoholicDrinksWriter memberAlcoholicDrinksWriter; - public Member getMemberByEmail(String email) { - return memberReader.getByEmail(email); - } - - @Transactional - public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { - OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); - Provider provider = authLoginInfo.provider(); - - // 인가 코드를 이용해 토큰 발급 요청 - String accessToken = providerFactory.getAccessToken( - provider, - authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), - authLoginInfo.propertyMap().get(AuthConstants.CODE) - ); - - // 토큰을 이용해 사용자 정보 가져오기 - OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); - - // 회원가입 or 로그인 - String email = oAuthUser.email(); - boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); - - validateNotWithdrawnMember(email); - - String generatedToken = jwtTokenProvider.createAccessToken(getMemberByEmail(email)); - Token token; - if (isNewMember) { - token = new Token(null, null); - } else { - token = new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); - } - return new LoginResponse( - token, - isNewMember, - new OAuthUserInfo(email, oAuthUser.id(), provider) - ); - } - - @Transactional - public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { - validateNickname(signUpRequest.nickname()); - validateEmail(signUpRequest.email()); - - Member member = Member.create(signUpRequest); - memberWriter.store(member); - - // 선호전통주 주종 등록 - List memberAlcoholTypeList = - getMemberAlcoholTypeList(member, signUpRequest.alcoholTypeIds()); - if (!memberAlcoholTypeList.isEmpty()) { - memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); - } - - // 약관 등록 - List memberTerms = - getAndValidateTermsWithMapping(member, signUpRequest.termsAgreements()); - if (!memberTerms.isEmpty()) { - memberTermsWriter.storeAll(memberTerms); - } - - String token = jwtTokenProvider.createAccessToken(member); - - return new SignUpMemberResponse( - member.getId(), - new Token(token, jwtTokenProvider.getExpirationByToken(token)) - ); - } - - - private List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList) { - return alcoholTypeIdList.stream() - .map(alcoholTypeId -> { - AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); - return MemberAlcoholType.create(member, alcoholType); - }) - .toList(); - } - - private List getAndValidateTermsWithMapping(Member member, List termsAgreements) { - List usedTermsList = termsReader.getAllByIsUsed(); - // 사용중인 약관이 존재하지 않을 경우 생성하지 않는다. - if (!usedTermsList.isEmpty()) { - validateTermsList(usedTermsList, termsAgreements); - } - - return getMemberTermsList(member, usedTermsList, termsAgreements); - } - - private void validateTermsList(List usedTermsList, List termsAgreements) { - if (usedTermsList.size() != termsAgreements.size()) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); - } - } - - private List getMemberTermsList(Member member, List usedTermsList, List termsAgreements) { - List mappings = new ArrayList<>(); - final LocalDateTime now = LocalDateTime.now(); - - usedTermsList.forEach(terms -> { - TermsAgreement termsAgreement = termsAgreements.stream() - .filter(agreement -> agreement.termsId().equals(terms.getId())) - .findFirst() - .orElseThrow(() -> new InvalidParamException(ErrorCode.TERMS_NOT_FOUND)); - - final boolean isAgreed = termsAgreement.isAgreed(); - - if (terms.isRequired() && !isAgreed) { - throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); - } - - mappings.add(MemberTerms.create(member, terms, isAgreed, now)); - }); - - return mappings; - } - - @Transactional(readOnly = true) - public boolean checkNickname(String nickname) { - return memberReader.existActiveNickname(nickname); - } - + /** + * 프로필 수정 + */ @Transactional public UpdateProfileResponse updateProfile(Member loginMember, UpdateProfileRequest request, MultipartFile image) { Member member = memberReader.getByEmail(loginMember.getEmail()); + String profileImageUrl = processProfileImage(image); - String profileImageUrl = null; - if (image != null) { - UploadImageInfo uploadImageInfo = s3Service.uploadMemberProfileImage(image); - profileImageUrl = uploadImageInfo.ImageUrl(); - } + // 프로필 업데이트 member.updateProfile(request, profileImageUrl); memberAlcoholTypeWriter.deleteAllByMember(member); - List memberAlcoholTypeList = getMemberAlcoholTypeList(member, request.alcoholTypeIds()); - if (!memberAlcoholTypeList.isEmpty()) { - memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); - } + // 알콜 타입 업데이트 + updateMemberAlcoholTypes(member, request.alcoholTypeIds()); return new UpdateProfileResponse(member.getId()); } - @Transactional(readOnly = true) - public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListRequest request) { - Slice myDailyLifeList = - dailyLifeReader.getAllMyDailyLives(member, request.lastDailyLifeId(), request.pageSize()); - - return new MyDailyLifeListResponse(myDailyLifeList); - } - - @Transactional(readOnly = true) - public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNoteListRequest request) { - Slice myTastingNoteList = - tastingNoteReader.getAllMyTastingNotes(member, request.lastTastingNoteId(), request.pageSize()); - - return new MyTastingNoteListResponse(myTastingNoteList); - } - - @Transactional - public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { - AlcoholicDrinks alcoholicDrinks = alcoholicDrinksReader.getById(alcoholicDrinksId); - Optional memberAlcoholicDrinks = - memberAlcoholicDrinksReader.findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); - - // 전통주가 이미 저장되어 있다면 삭제, 저장되어 있지 않다면 등록 - return memberAlcoholicDrinks - .map(save -> { - memberAlcoholicDrinksWriter.delete(save); - return false; - }) - .orElseGet(() -> { - memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); - return true; - }); + /** + * 프로필 이미지 처리 + */ + private String processProfileImage(MultipartFile image) { + if (image != null && !image.isEmpty()) { + UploadImageInfo uploadImageInfo = s3Service.uploadMemberProfileImage(image); + return uploadImageInfo.ImageUrl(); + } + return null; } - @Transactional(readOnly = true) - public MyAlcoholicDrinksListResponse loadMyAlcoholicDrinks(Member member, MyAlcoholicDrinksListRequest request) { - Slice alcoholicDrinksSummaries = - alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, request.lastAlcoholicDrinksId(), request.pageSize()); - - return new MyAlcoholicDrinksListResponse(alcoholicDrinksSummaries); + /** + * 회원의 알콜 타입 업데이트 + */ + private void updateMemberAlcoholTypes(Member member, List alcoholTypeIds) { + if (!CollectionUtils.isEmpty(alcoholTypeIds)) { + List memberAlcoholTypeList = memberUtils.getMemberAlcoholTypeList( + member, alcoholTypeIds, alcoholTypeReader); + if (!memberAlcoholTypeList.isEmpty()) { + memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); + } + } } - @Transactional(readOnly = true) - public MySpaceResponse getMySpace(Member member) { + /** + * 내 공간 정보 조회 + */ + @Transactional(readOnly = true) + public MySpaceResponse getMySpace(Member loginMember) { + Member member = memberReader.getById(loginMember.getId()); long tastingNoteCount = tastingNoteReader.getMyTastingNoteCount(member); long dailyLifeCount = dailyLifeReader.getMyDailyLifeCount(member); long followingCount = followReader.countFollowing(member); @@ -270,12 +118,15 @@ public MySpaceResponse getMySpace(Member member) { dailyLifeCount, followingCount, followerCount, - 0 - ); + 0); } + /** + * 내 정보 조회 + */ @Transactional(readOnly = true) - public MyInfoResponse getMyInfo(Member member) { + public MyInfoResponse getMyInfo(Member loginMember) { + Member member = memberReader.getById(loginMember.getId()); List alcoholTypeIdList = memberAlcoholTypeReader.getIdListByMember(member); return new MyInfoResponse( member.getId(), @@ -286,27 +137,14 @@ public MyInfoResponse getMyInfo(Member member) { member.getIntroduction(), member.getProfileImage(), member.getGender(), - alcoholTypeIdList - ); - } - - @Transactional(readOnly = true) - public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLifeListRequest request, Long memberId) { - Slice dailyLifeList = - dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, request.lastDailyLifeId(), request.pageSize()); - - return new DailyLifeListResponse(dailyLifeList); - } - - @Transactional(readOnly = true) - public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, TastingNoteListRequest request, Long memberId) { - Slice tastingNoteList = - tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, request.lastTastingNoteId(), request.pageSize()); - - return new TastingNoteListResponse(tastingNoteList); + alcoholTypeIdList); } + /** + * 타 유저 프로필 조회 + */ @Transactional(readOnly = true) + @Cacheable(value = "memberProfile", key = "#memberId", unless = "#result == null") public MemberProfileResponse getMemberProfile(Member loginMember, Long memberId) { Member member = memberReader.getById(memberId); long tastingNoteCount = tastingNoteReader.getTastingNoteCountByMemberId(memberId, loginMember); @@ -325,40 +163,106 @@ public MemberProfileResponse getMemberProfile(Member loginMember, Long memberId) dailyLifeCount, followingCount, followerCount, - isFollowing - ); + isFollowing); } - private void validateNickname(String nickname) { - if (memberReader.existActiveNickname(nickname)) { - throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); - } + /** + * ID로 회원 조회 + */ + @Transactional(readOnly = true) + @Cacheable(value = "memberById", key = "#memberId", unless = "#result == null") + public Member findById(Long memberId) { + return memberJpaRepository.findById(memberId) + .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); } - private void validateNotWithdrawnMember(String email) { - if (withdrawalRecordReader.existEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); - } + /** + * 이메일로 회원 조회 + */ + @Transactional(readOnly = true) + @Cacheable(value = "memberByEmail", key = "#email", unless = "#result == null") + public Member getMemberByEmail(String email) { + return memberReader.getByEmail(email); } - private void validateEmail(String email) { - if (memberReader.existActiveEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } + /** + * 내가 작성한 일상생활 목록 조회 + */ + @Transactional(readOnly = true) + public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListRequest request) { + Slice myDailyLifeList = dailyLifeReader.getAllMyDailyLives(member, + request.lastDailyLifeId(), request.pageSize()); + + return new MyDailyLifeListResponse(myDailyLifeList); } + /** + * 특정 회원이 작성한 일상생활 목록 조회 + */ + @Transactional(readOnly = true) + public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLifeListRequest request, + Long memberId) { + Slice dailyLifeList = dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, + request.lastDailyLifeId(), request.pageSize()); + + return new DailyLifeListResponse(dailyLifeList); + } + + /** + * 내가 작성한 시음노트 목록 조회 + */ + @Transactional(readOnly = true) + public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNoteListRequest request) { + Slice myTastingNoteList = tastingNoteReader.getAllMyTastingNotes(member, + request.lastTastingNoteId(), request.pageSize()); + + return new MyTastingNoteListResponse(myTastingNoteList); + } + + /** + * 특정 회원이 작성한 시음노트 목록 조회 + */ + @Transactional(readOnly = true) + public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, TastingNoteListRequest request, + Long memberId) { + Slice tastingNoteList = tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, + request.lastTastingNoteId(), request.pageSize()); + + return new TastingNoteListResponse(tastingNoteList); + } + + /** + * 전통주 저장하기 또는 저장 취소 + * + * @return true if saved, false if unsaved + */ @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request) { - loginMember.deleteAccount(); - withdrawalRecordWriter.store( - WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname()) - ); + public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { + AlcoholicDrinks alcoholicDrinks = alcoholicDrinksReader.getById(alcoholicDrinksId); + Optional memberAlcoholicDrinks = memberAlcoholicDrinksReader + .findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); + + // 전통주가 이미 저장되어 있다면 삭제, 저장되어 있지 않다면 등록 + return memberAlcoholicDrinks + .map(save -> { + memberAlcoholicDrinksWriter.delete(save); + return false; + }) + .orElseGet(() -> { + memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); + return true; + }); } + /** + * 내가 저장한 전통주 목록 조회 + */ @Transactional(readOnly = true) - public Member findById(Long memberId) { - return memberJpaRepository.findById(memberId) - .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); + public MyAlcoholicDrinksListResponse loadMyAlcoholicDrinks(Member member, MyAlcoholicDrinksListRequest request) { + Slice alcoholicDrinksSummaries = alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, + request.lastAlcoholicDrinksId(), request.pageSize()); + + return new MyAlcoholicDrinksListResponse(alcoholicDrinksSummaries); } -} +} diff --git a/src/main/java/com/juu/juulabel/member/util/MemberUtils.java b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java new file mode 100644 index 00000000..b099a702 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/util/MemberUtils.java @@ -0,0 +1,123 @@ +package com.juu.juulabel.member.util; + +import com.juu.juulabel.alcohol.domain.AlcoholType; +import com.juu.juulabel.alcohol.repository.AlcoholTypeReader; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberAlcoholType; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.MemberTerms; +import com.juu.juulabel.member.repository.MemberAlcoholTypeWriter; +import com.juu.juulabel.member.repository.MemberTermsWriter; +import com.juu.juulabel.member.repository.TermsReader; +import com.juu.juulabel.terms.domain.Terms; +import com.juu.juulabel.terms.request.TermsAgreement; + +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.Collections; + +/** + * Member 관련 유틸리티 클래스 + */ +@Component +@RequiredArgsConstructor +public class MemberUtils { + + /** + * 회원-주종 관계 목록 생성 + * + * @param member 회원 + * @param alcoholTypeIdList 주종 ID 목록 + * @param alcoholTypeReader 주종 조회 리포지토리 + * @return 회원-주종 관계 목록 + */ + private final TermsReader termsReader; + private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; + private final AlcoholTypeReader alcoholTypeReader; + private final MemberTermsWriter memberTermsWriter; + + public List getMemberAlcoholTypeList(Member member, List alcoholTypeIdList, + AlcoholTypeReader alcoholTypeReader) { + return alcoholTypeIdList.stream() + .map(alcoholTypeId -> { + AlcoholType alcoholType = alcoholTypeReader.getById(alcoholTypeId); + return MemberAlcoholType.create(member, alcoholType); + }) + .toList(); + } + + public void processAlcoholTypes(Member member, SignUpMemberRequest signUpRequest) { + List memberAlcoholTypeList = getMemberAlcoholTypeList( + member, signUpRequest.alcoholTypeIds(), alcoholTypeReader); + if (!memberAlcoholTypeList.isEmpty()) { + memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); + } + } + + public void processTermsAgreements(Member member, SignUpMemberRequest signUpRequest) { + List memberTerms = getAndValidateTermsWithMapping(member, + signUpRequest.termsAgreements()); + if (!memberTerms.isEmpty()) { + memberTermsWriter.storeAll(memberTerms); + } + } + + /** + * 약관 동의 정보 검증 및 매핑 생성 + */ + public List getAndValidateTermsWithMapping(Member member, List termsAgreements) { + List usedTermsList = termsReader.getAllByIsUsed(); + + if (usedTermsList.isEmpty()) { + return Collections.emptyList(); + } + + validateTermsList(usedTermsList, termsAgreements); + return createMemberTermsList(member, usedTermsList, termsAgreements); + } + + public List createMemberTermsList(Member member, List usedTermsList, + List termsAgreements) { + + // 약관 ID를 키로 하는 맵으로 변환하여 조회 성능 개선 + Map agreementMap = termsAgreements.stream() + .collect(Collectors.toMap(TermsAgreement::termsId, Function.identity())); + + final LocalDateTime now = LocalDateTime.now(); + List mappings = new ArrayList<>(usedTermsList.size()); + + for (Terms terms : usedTermsList) { + TermsAgreement termsAgreement = Optional.ofNullable(agreementMap.get(terms.getId())) + .orElseThrow(() -> new InvalidParamException(ErrorCode.TERMS_NOT_FOUND)); + + final boolean isAgreed = termsAgreement.isAgreed(); + + if (terms.isRequired() && !isAgreed) { + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISSING_REQUIRED); + } + + mappings.add(MemberTerms.create(member, terms, isAgreed, now)); + } + + return mappings; + } + + public void validateTermsList(List usedTermsList, List termsAgreements) { + if (usedTermsList.size() != termsAgreements.size()) { + throw new InvalidParamException(ErrorCode.TERMS_AGREEMENT_MISMATCH); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/tastingnote/domain/TastingNote.java b/src/main/java/com/juu/juulabel/tastingnote/domain/TastingNote.java index 25178ffd..fa1aceb7 100644 --- a/src/main/java/com/juu/juulabel/tastingnote/domain/TastingNote.java +++ b/src/main/java/com/juu/juulabel/tastingnote/domain/TastingNote.java @@ -21,9 +21,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Entity -@Table( - name = "tasting_note" -) +@Table(name = "tasting_note") public class TastingNote extends BaseTimeEntity { @Id @@ -67,15 +65,14 @@ public class TastingNote extends BaseTimeEntity { @OneToMany(mappedBy = "tastingNote", cascade = CascadeType.ALL, orphanRemoval = true) private List tastingNoteScents = new ArrayList<>(); - public static TastingNote of(Member member, - AlcoholType alcoholType, - AlcoholicDrinks alcoholicDrinks, - Color color, - AlcoholicDrinksSnapshot alcoholDrinksInfo, - Double rating, - String content, - boolean isPrivate) { + AlcoholType alcoholType, + AlcoholicDrinks alcoholicDrinks, + Color color, + AlcoholicDrinksSnapshot alcoholDrinksInfo, + Double rating, + String content, + boolean isPrivate) { return TastingNote.builder() .member(member) .alcoholType(alcoholType) @@ -89,14 +86,13 @@ public static TastingNote of(Member member, } public void update( - AlcoholType alcoholType, - AlcoholicDrinks alcoholicDrinks, - Color color, - AlcoholicDrinksSnapshot alcoholDrinksInfo, - Double rating, - String content, - boolean isPrivate - ) { + AlcoholType alcoholType, + AlcoholicDrinks alcoholicDrinks, + Color color, + AlcoholicDrinksSnapshot alcoholDrinksInfo, + Double rating, + String content, + boolean isPrivate) { this.alcoholType = alcoholType; this.alcoholicDrinks = alcoholicDrinks; this.color = color; From f489ff6ed0b49a832f392a5668d12d654efc2b40 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Sun, 18 May 2025 10:18:58 +0900 Subject: [PATCH 2/7] Enhance authentication and token management features - Added Redis support for token storage in JwtTokenProvider. - Introduced device ID handling in refresh token logic to improve security. - Updated AuthConstants to include new constants for refresh token handling. - Refactored HttpRequestUtil for better IP and device ID extraction. - Removed deprecated AuthController and consolidated member-related services for improved maintainability. - Added error handling for missing device ID in requests. - Enhanced MemberService to delegate responsibilities to specialized services for better separation of concerns. --- build.gradle | 7 + .../aws-elasticache-redis-local-setup.md | 92 ++++++ docs/pr/PR-139-refactor---auth-api.md | 95 ++++++ .../juu/juulabel/admin/AdminController.java | 2 - .../juulabel/auth/aop/RefreshTokenAspect.java | 120 ++++++++ .../auth/aop/SetRefreshTokenCookie.java | 13 + .../juu/juulabel/auth/aop/tokenService.java | 5 + .../controller/AuthController.java | 55 ++-- .../juu/juulabel/auth/domain/ClientId.java | 27 ++ .../juulabel/auth/domain/RefreshToken.java | 80 +++++ .../redis/CustomRefreshTokenRepository.java | 14 + .../CustomRefreshTokenRepositoryImpl.java | 63 ++++ .../redis/RefreshTokenRedisRepository.java | 11 + .../auth/service/FraudDetectionService.java | 7 + .../auth/service/GeoIp2Exception.java | 5 + .../auth/service/MemberAuthService.java | 68 ++++ .../juulabel/auth/service/OAuthService.java | 69 +++++ .../RefreshTokenFraudDetectionService.java | 234 ++++++++++++++ .../juulabel/auth/service/RiskAssessment.java | 28 ++ .../juulabel/auth/service/TokenService.java | 60 ++++ .../juulabel/common/config/RedisConfig.java | 21 ++ .../common/constants/AuthConstants.java | 8 +- .../common/exception/code/ErrorCode.java | 2 + .../common/filter/JwtAuthorizationFilter.java | 5 +- .../common/provider/JwtTokenProvider.java | 106 ++++++- .../common/util/AbstractHttpUtil.java | 61 ++++ .../juulabel/common/util/HttpRequestUtil.java | 62 +++- .../common/util/HttpResponseUtil.java | 29 ++ .../member/controller/MemberController.java | 10 +- .../juulabel/member/domain/RefreshToken.java | 58 ---- .../repository/RefreshTokenRepository.java | 55 ---- .../jpa/RefreshTokenJpaRepository.java | 21 -- .../juulabel/member/service/AuthService.java | 291 ------------------ .../member/service/MemberContentService.java | 133 ++++++++ .../member/service/MemberLookupService.java | 41 +++ .../member/service/MemberProfileService.java | 165 ++++++++++ .../member/service/MemberService.java | 187 ++--------- 37 files changed, 1659 insertions(+), 651 deletions(-) create mode 100644 docs/infra/aws-elasticache-redis-local-setup.md create mode 100644 docs/pr/PR-139-refactor---auth-api.md create mode 100644 src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java create mode 100644 src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java create mode 100644 src/main/java/com/juu/juulabel/auth/aop/tokenService.java rename src/main/java/com/juu/juulabel/{member => auth}/controller/AuthController.java (61%) create mode 100644 src/main/java/com/juu/juulabel/auth/domain/ClientId.java create mode 100644 src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/OAuthService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/TokenService.java create mode 100644 src/main/java/com/juu/juulabel/common/config/RedisConfig.java create mode 100644 src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java create mode 100644 src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java delete mode 100644 src/main/java/com/juu/juulabel/member/domain/RefreshToken.java delete mode 100644 src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java delete mode 100644 src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java delete mode 100644 src/main/java/com/juu/juulabel/member/service/AuthService.java create mode 100644 src/main/java/com/juu/juulabel/member/service/MemberContentService.java create mode 100644 src/main/java/com/juu/juulabel/member/service/MemberLookupService.java create mode 100644 src/main/java/com/juu/juulabel/member/service/MemberProfileService.java diff --git a/build.gradle b/build.gradle index 14b3b484..57fc0628 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,9 @@ dependencies { // jpa implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -48,6 +51,10 @@ dependencies { // jjwt implementation 'io.jsonwebtoken:jjwt:0.12.5' + // GeoIP + implementation 'com.maxmind.geoip2:geoip2:4.1.0' + + // lombok implementation 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok' diff --git a/docs/infra/aws-elasticache-redis-local-setup.md b/docs/infra/aws-elasticache-redis-local-setup.md new file mode 100644 index 00000000..f640bb51 --- /dev/null +++ b/docs/infra/aws-elasticache-redis-local-setup.md @@ -0,0 +1,92 @@ +# Redis 로컬 개발 환경 연결 설정 가이드 (with AWS ElastiCache) + +> 운영 환경의 Redis(VPC 내부)와 로컬 개발 환경을 동일하게 연결하기 위한 구성 절차를 정리한 문서입니다. +> 비용 효율성과 실용성을 위해 별도의 Bastion Host 없이 EC2 백엔드 인스턴스를 포트 포워딩용 중계 노드로 활용합니다. + +--- + +## 1. 기본 개념 및 요구 사항 + +- **Redis 위치**: AWS ElastiCache for Redis (VPC 내부, 퍼블릭 액세스 불가) +- **로컬 개발 환경 연결 방식**: AWS Systems Manager(SSM)의 `PortForwardingSession` 사용 +- **전제 조건**: + - AWS CLI 설치 및 설정 완료 + - EC2 인스턴스에 SSM Agent 설치 및 IAM Role 연결 + - EC2와 Redis가 동일 VPC/Subnet 내에 존재 + - ElastiCache Redis의 보안 그룹에 EC2 인스턴스 허용 설정 + +--- + +## 2. 설정 절차 + +### 2.1 AWS CLI 구성 + +1. 인증 키 생성 후 `.csv` 파일 다운로드 (예: `EcPortForwarding_accessKeys.csv`) +2. AWS CLI에 프로파일 등록: + +```bash +aws configure --profile [your-profile-name] +``` + +- `Access Key ID`, `Secret Access Key`, `Region` 입력 + +> 예시: +> ``` +> aws configure --profile dev-redis +> ``` + +--- + +### 2.2 EC2 포트 포워딩 세션 시작 + +1. EC2 인스턴스 ID 확인 (SSM 접속이 가능한 상태여야 함) +2. 아래 명령어로 Redis 포트(6379) 포워딩: + +```bash +aws ssm start-session --target i-0xxxxxxxxxxxxxxx --document-name AWS-StartPortForwardingSession --parameters '{"portNumber":["6379"],"localPortNumber":["6379"]}' --profile dev-redis +``` + +> 🔁 위 명령어를 실행하면 **로컬의 `localhost:6379`** 로 접근 시, 해당 EC2 인스턴스 내부에서 Redis에 접속하는 것과 동일한 효과를 가집니다. + +--- + +## 3. Spring Boot 설정 (`application.yml`) + +로컬과 운영 환경 모두에서 동일하게 설정합니다: + +```yaml +spring: + data: + redis: + host: localhost + port: 6379 +``` + +> 운영에서는 EC2 내부에서 Redis에 직접 접근 가능 +> 로컬에서는 포트 포워딩 세션을 통해 동일하게 동작 + +--- + +## 4. 자주 발생하는 문제 및 해결법 + +| 증상 | 원인 및 해결 방법 | +|------------------------------------------|------------------------------------------------------------------------------------| +| `Timeout` 또는 연결 불가 | SSM 세션이 끊어졌거나 Redis 보안 그룹이 EC2를 허용하지 않음 | +| 포트 포워딩 명령어 실행 시 에러 발생 | EC2 인스턴스에 SSM Agent 미설치, IAM Role 누락, 혹은 CLI 인증 프로파일 오류 | +| Redis 연결은 되나 데이터가 이상하게 보임 | Redis 클러스터 모드가 활성화된 경우, Lettuce 설정을 클러스터 모드로 변경 필요 | + +--- + +## 5. 유의 사항 및 권장 전략 + +- Bastion Host 불필요 → 비용 및 인프라 단순화 +- `localhost:6379`을 고정하여 개발/운영 동일한 코드 사용 가능 +- 보안 강화를 위해 EC2에 최소 권한 IAM Role 부여 및 Redis 보안 그룹 제한 +- 필요시 HAProxy 도입으로 로컬 클러스터 라우팅 테스트도 가능 + +--- + +## 6. 참고 자료 + +- [AWS 공식 블로그: Session Manager Port Forwarding to Redis](https://aws.amazon.com/blogs/mt/aws-systems-manager-session-manager-port-forwarding-to-amazon-elasticache-redis-inside-private-subnet/) +- [PR #139](링크): 인증 구조 리팩토링 및 Redis 도입 관련 변경 내역 \ No newline at end of file diff --git a/docs/pr/PR-139-refactor---auth-api.md b/docs/pr/PR-139-refactor---auth-api.md new file mode 100644 index 00000000..831c3e4f --- /dev/null +++ b/docs/pr/PR-139-refactor---auth-api.md @@ -0,0 +1,95 @@ +# Auth API 리팩터링 및 인증 전략 고도화 안내 (PR [#139]()) + +## 1. 개요 + +이번 변경은 인증 도메인의 역할 분리를 명확히 하고, 보안 수준을 강화하기 위해 다음과 같은 개선 사항을 포함합니다. + +- 인증 관련 API를 `/v1/api/auth`로 별도 분리하여 도메인 책임을 명확히 분리 +- Refresh Token 기반 인증 구조 도입 및 Rotation 전략 적용 +- Redis 기반의 서버 측 Refresh Token 관리 및 블랙리스트 처리 +- 비정상 로그인 탐지 및 보안 로깅을 위한 클라이언트 정보 수집 로직 추가 + +이 변경은 향후 보안 알림 시스템 및 세션 이상 행위 탐지를 위한 기반 설계를 포함하고 있습니다. + +--- + +## 2. 주요 변경 사항 + +### 📁 2.1 API 경로 구조 리팩터링 + +기존 인증 관련 API가 `/members` 하위에 존재해 도메인 책임 구분이 불명확했습니다. 이를 `/auth`로 이동시켜 인증과 사용자 리소스를 명확히 구분했습니다. + +| 변경 전 경로 | 변경 후 경로 | 변경 내용 | +|---------------------------|---------------------------|----------------------------| +| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | +| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | 인증 도메인 분리 | +| `/v1/api/members/me` | `/v1/api/auth/me` | 인증 도메인 분리 | +| - | `/v1/api/auth/refresh` | Refresh Token 발급 추가 | +| - | `/v1/api/auth/logout` | 서버 측 로그아웃 로직 추가 | +| `/.../tasting_notes` | `/.../tasting-notes` | REST 관례에 맞춘 typo 수정 | + +신규 경로: + +- `/v1/api/auth/refresh`: Access Token 및 Refresh Token 재발급 +- `/v1/api/auth/logout`: 서버 측 세션 종료 및 토큰 무효화 처리 + +--- + +### 🔄 2.2 Refresh Token 인증 전략 도입 + +- **Access Token 만료 시**, 클라이언트는 `/auth/refresh`를 통해 새로운 Access/Refresh Token을 발급받습니다. +- **Rotation 전략 적용**: 사용된 Refresh Token은 즉시 폐기(블랙리스트 처리)되며, 새로운 Refresh Token이 반환됩니다. +- 보안상의 이유로 클라이언트는 이전 Refresh Token으로 재요청 시 인증에 실패하게 됩니다. + +--- + +### 🔐 2.3 비정상 로그인 탐지를 위한 추가 메타데이터 수집 + +- 인증 관련 API `v1/api/auth/...` 요청 시, 다음 헤더의 전송이 **필수**입니다: + + - `Device-Id`: 클라이언트 단말기 고유 식별자 + - (백엔드에서는 UA, IP 등도 별도로 수집 및 보관) + +- 해당 정보는 **비정상 행위 탐지 로직 및 알림 시스템 설계**에 활용됩니다. + +--- + +### 🚪 2.4 로그아웃 처리 로직 개선 + +기존에는 클라이언트 측에서 Access Token만 제거하여 로그아웃 처리가 완료되었습니다. +이제는 Refresh Token 보안성을 고려하여 서버 측 `/auth/logout` 호출을 통해 다음 처리를 수행합니다: + +- Redis에 저장된 해당 Refresh Token을 블랙리스트 처리 +- 재사용 방지 및 세션 강제 만료 지원 + +--- + +## 3. Redis 기반 세션 토큰 관리 구조 + +| 항목 | 내용 | +|------------------|----------------------------------------------| +| 인프라 구성 | AWS ElastiCache (Valkey) | +| 접근 방식 | VPC 내부 접근: HAProxy + SSM Port Forwarding | +| 저장소 라이브러리 | `spring-data-redis` | +| 운영 전략 | 토큰 TTL 기반 자동 만료 + 블랙리스트 수동 등록 | + +--- + +## 4. 보안 고려 사항 요약 + +- Refresh Token Rotation 전략 적용 → 사용된 토큰 즉시 폐기 +- Redis를 통한 토큰 상태 관리 (블랙리스트 기반 무효화 처리) +- 클라이언트 디바이스 식별자 수집 → 비정상 세션 탐지 기반 구축 +- 향후 보안 알림 시스템 및 계정 탈취 방지 기능 확장 가능성 확보 + +--- + +## 5. 적용 후 유의사항 + +| 항목 | 설명 | +|-----------------------------------|------------------------------------------------------------| +| 인증 API 호출 시 헤더에 `Device-Id` 필수 포함 | 누락 시 400 에러 발생 | +| 로그아웃 시 `/auth/logout` 호출 필요 | 단순 쿠키 제거만으로는 세션이 만료되지 않음 | +| 기존 경로 `/members/*` 사용 중단 | 호출 시 404 또는 리다이렉션 응답 발생 가능, 조속한 반영 요망 | + +--- diff --git a/src/main/java/com/juu/juulabel/admin/AdminController.java b/src/main/java/com/juu/juulabel/admin/AdminController.java index 048ef799..23ed58eb 100644 --- a/src/main/java/com/juu/juulabel/admin/AdminController.java +++ b/src/main/java/com/juu/juulabel/admin/AdminController.java @@ -1,7 +1,5 @@ package com.juu.juulabel.admin; -import com.juu.juulabel.admin.response.MemberListSummary; -import com.juu.juulabel.alcohol.response.CategorySearchAlcoholRequest; import com.juu.juulabel.common.dto.request.MemberListRequest; import com.juu.juulabel.common.dto.response.MemberListResponse; import com.juu.juulabel.common.exception.code.SuccessCode; diff --git a/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java b/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java new file mode 100644 index 00000000..80651c3d --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java @@ -0,0 +1,120 @@ +package com.juu.juulabel.auth.aop; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.service.TokenService; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.member.domain.Member; + +import java.util.Arrays; +import java.util.Optional; +import java.util.Map; +import java.util.function.Function; +import java.util.HashMap; + +@Aspect +@Component +public class RefreshTokenAspect { + private final TokenService tokenService; + private final SpelExpressionParser parser; + + private static final Map, Function> memberIdExtractors = new HashMap<>(); + + static { + memberIdExtractors.put(LoginResponse.class, result -> ((LoginResponse) result).oAuthUserInfo().memberId()); + memberIdExtractors.put(SignUpMemberResponse.class, result -> ((SignUpMemberResponse) result).memberId()); + } + + public RefreshTokenAspect(TokenService tokenService) { + this.tokenService = tokenService; + this.parser = new SpelExpressionParser(); + } + + @AfterReturning(pointcut = "@annotation(setRefreshTokenCookie)", returning = "responseEntity") + public void setRefreshTokenCookie(JoinPoint joinPoint, + SetRefreshTokenCookie setRefreshTokenCookie, + ResponseEntity> responseEntity) { + + boolean isNewSession = setRefreshTokenCookie.isNewSession(); + String parentTokenId = setRefreshTokenCookie.parentTokenId(); + + Optional memberId = extractMemberId(isNewSession, joinPoint, responseEntity); + + if (memberId.isEmpty()) { + if (!isNewSession) { + // 토큰 리프레시 멤버 추출 실패 + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + // 비회원 유저가 로그인 했을 때 쿠키 설정 안함 + return; + } + + String refreshToken = extractRefreshTokenCookie(joinPoint, parentTokenId); + tokenService.saveAndSetCookie(memberId.get(), refreshToken); + } + + private String extractRefreshTokenCookie(JoinPoint joinPoint, String parentTokenId) { + if (parentTokenId.isEmpty()) { + return null; + } + + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + // Bind method arguments + String[] paramNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < args.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + + Object evaluated = parser.parseExpression(parentTokenId).getValue(context); + if (!(evaluated instanceof String)) { + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + return (String) evaluated; + } + + private Optional extractMemberId(boolean isNewSession, JoinPoint joinPoint, + ResponseEntity> responseEntity) { + // For existing sessions, extract from Member object + if (!isNewSession) { + return findFirstArgOfType(joinPoint, Member.class) + .map(Member::getId); + } + + // For new sessions, extract from response body + CommonResponse body = responseEntity.getBody(); + if (body == null || body.result() == null) { + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + Object result = body.result(); + Class resultClass = result.getClass(); + + Function extractor = memberIdExtractors.get(resultClass); + if (extractor != null) { + return Optional.of(extractor.apply(result)); + } + + throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + private Optional findFirstArgOfType(JoinPoint joinPoint, Class clazz) { + return Arrays.stream(joinPoint.getArgs()) + .filter(clazz::isInstance) + .map(clazz::cast) + .findFirst(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java b/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java new file mode 100644 index 00000000..1343bf03 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java @@ -0,0 +1,13 @@ +package com.juu.juulabel.auth.aop; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface SetRefreshTokenCookie { + String parentTokenId() default ""; + boolean isNewSession() default true; +} diff --git a/src/main/java/com/juu/juulabel/auth/aop/tokenService.java b/src/main/java/com/juu/juulabel/auth/aop/tokenService.java new file mode 100644 index 00000000..4c035ade --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/aop/tokenService.java @@ -0,0 +1,5 @@ +package com.juu.juulabel.auth.aop; + +public class tokenService { + +} diff --git a/src/main/java/com/juu/juulabel/member/controller/AuthController.java b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java similarity index 61% rename from src/main/java/com/juu/juulabel/member/controller/AuthController.java rename to src/main/java/com/juu/juulabel/auth/controller/AuthController.java index 72f410eb..5978df66 100644 --- a/src/main/java/com/juu/juulabel/member/controller/AuthController.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java @@ -1,5 +1,9 @@ -package com.juu.juulabel.member.controller; +package com.juu.juulabel.auth.controller; +import com.juu.juulabel.auth.aop.SetRefreshTokenCookie; +import com.juu.juulabel.auth.service.MemberAuthService; +import com.juu.juulabel.auth.service.OAuthService; +import com.juu.juulabel.auth.service.TokenService; import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; @@ -10,11 +14,9 @@ import com.juu.juulabel.common.response.CommonResponse; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.member.service.AuthService; + import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -27,15 +29,16 @@ @RequiredArgsConstructor public class AuthController { - private final AuthService authService; + private final TokenService tokenService; + private final OAuthService oAuthService; + private final MemberAuthService memberAuthService; @Operation(summary = "OAuth 로그인 (소셜 로그인)") @PostMapping("/login/{provider}") + @SetRefreshTokenCookie public ResponseEntity> oauthLogin( @PathVariable String provider, - @Valid @RequestBody OAuthLoginRequest requestBody, - HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse) { + @Valid @RequestBody OAuthLoginRequest requestBody) { // 경로에서 제공자 정보를 파싱하여 새 요청 객체를 생성 OAuthLoginRequest request = new OAuthLoginRequest( @@ -43,41 +46,34 @@ public ResponseEntity> oauthLogin( requestBody.redirectUri(), Provider.valueOf(provider.toUpperCase())); - LoginResponse loginResponse = authService.login(request); - if (!loginResponse.isNewMember()) { - authService.registerRefreshToken(loginResponse.oAuthUserInfo().memberId(), httpServletRequest, - httpServletResponse); - } + LoginResponse loginResponse = oAuthService.login(request); return CommonResponse.success(SuccessCode.SUCCESS, loginResponse); } @Operation(summary = "회원가입") @PostMapping("/sign-up") + @SetRefreshTokenCookie public ResponseEntity> signUp( - @Valid @RequestBody SignUpMemberRequest request, - HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse) { - SignUpMemberResponse signUpMemberResponse = authService.signUp(request); - authService.registerRefreshToken(signUpMemberResponse.memberId(), httpServletRequest, - httpServletResponse); + @Valid @RequestBody SignUpMemberRequest request) { + SignUpMemberResponse signUpMemberResponse = memberAuthService.signUp(request); return CommonResponse.success(SuccessCode.SUCCESS, signUpMemberResponse); } @Operation(summary = "액세스 토큰 갱신") @PostMapping("/refresh") + @SetRefreshTokenCookie(parentTokenId = "#refreshToken", isNewSession = false) public ResponseEntity> refresh( - @CookieValue(value = "refreshToken", required = true) String refreshTokenCookie, - HttpServletRequest request, - HttpServletResponse response) { - return CommonResponse.success(SuccessCode.SUCCESS, authService.refresh(refreshTokenCookie, request, - response)); + @AuthenticationPrincipal Member member, + @CookieValue(value = "refreshToken", required = true) String refreshToken) { + return CommonResponse.success(SuccessCode.SUCCESS, tokenService.refresh(refreshToken)); } @Operation(summary = "로그아웃") @PostMapping("/logout") public ResponseEntity> logout( - @CookieValue(value = "refreshToken", required = true) String refreshTokenCookie) { - authService.logout(refreshTokenCookie); + @CookieValue(value = "refreshToken", required = true) String refreshToken, + @AuthenticationPrincipal Member member) { + tokenService.logout(refreshToken, member.getId()); return CommonResponse.success(SuccessCode.SUCCESS); } @@ -86,13 +82,8 @@ public ResponseEntity> logout( public ResponseEntity> deleteAccount( @AuthenticationPrincipal Member member, @RequestBody WithdrawalRequest request) { - authService.deleteAccount(member, request); + memberAuthService.deleteAccount(member, request); return CommonResponse.success(SuccessCode.SUCCESS_DELETE); } - @Operation(summary = "닉네임 중복 검사") - @GetMapping("/nicknames/{nickname}/exists") - public ResponseEntity> checkNickname(@PathVariable String nickname) { - return CommonResponse.success(SuccessCode.SUCCESS, authService.checkNickname(nickname)); - } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/domain/ClientId.java b/src/main/java/com/juu/juulabel/auth/domain/ClientId.java new file mode 100644 index 00000000..0a7b638d --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/domain/ClientId.java @@ -0,0 +1,27 @@ +package com.juu.juulabel.auth.domain; + +import java.util.Arrays; +import java.util.Optional; + +public enum ClientId { + WEB("web-client"), + IOS("ios-app"), + ANDROID("android-app"), + ADMIN("admin-panel"); + + private final String value; + + ClientId(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static Optional from(String value) { + return Arrays.stream(values()) + .filter(c -> c.value.equals(value)) + .findFirst(); + } +} diff --git a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java new file mode 100644 index 00000000..7cf1d35f --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java @@ -0,0 +1,80 @@ +package com.juu.juulabel.auth.domain; + +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_TTL_IN_SECONDS; + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RedisHash("RefreshToken") // Acts like a key prefix +public class RefreshToken implements Serializable { + + @Id + private String hashedToken; + + @Indexed + private Long memberId; + + @Indexed + private String deviceId; + + private ClientId clientId; + + private Instant revokedAt; + + private Instant issuedAt; + + // Metadata + // • ipAddress: Current request IP address + // • userAgent: Current request user agent + // • ttl: Time to live + + private String ipAddress; + + private String userAgent; + + @TimeToLive(unit = TimeUnit.SECONDS) + private Long ttl; + + @Builder + public RefreshToken(String token, Long memberId, ClientId clientId, String deviceId, String ipAddress, + String userAgent) { + this.hashedToken = hashToken(token); + this.memberId = memberId; + this.clientId = clientId; + this.deviceId = deviceId; + this.ipAddress = ipAddress; + this.userAgent = userAgent; + this.revokedAt = null; + this.issuedAt = Instant.now(); + this.ttl = REFRESH_TOKEN_TTL_IN_SECONDS; + } + + public void setRevoked(Instant revokedAt) { + this.revokedAt = revokedAt; + } + + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashedBytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java new file mode 100644 index 00000000..a6291156 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java @@ -0,0 +1,14 @@ +package com.juu.juulabel.auth.repository.redis; + +import java.util.Optional; + +import com.juu.juulabel.auth.domain.RefreshToken; + +public interface CustomRefreshTokenRepository { + + void revokeByMemberId(Long memberId); + + void revokeByDeviceId(Long memberId, String deviceId); + + Optional findByTokenHash(String tokenHash); +} diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java new file mode 100644 index 00000000..81564305 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.juu.juulabel.auth.repository.redis; + +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HASH_PREFIX; + +import lombok.RequiredArgsConstructor; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class CustomRefreshTokenRepositoryImpl implements CustomRefreshTokenRepository { + + private final RedisTemplate redisTemplate; + + @Override + public void revokeByMemberId(Long memberId) { + revokeByPattern(REFRESH_TOKEN_HASH_PREFIX + ":" + memberId + ":*"); + } + + @Override + public void revokeByDeviceId(Long memberId, String deviceId) { + revokeByPattern(REFRESH_TOKEN_HASH_PREFIX + ":" + memberId + ":" + deviceId + ":*"); + } + + @Override + public Optional findByTokenHash(String tokenHash) { + return Optional.ofNullable(redisTemplate.opsForValue().get(tokenHash)) + .map(RefreshToken.class::cast); + } + + private void revokeByPattern(String pattern) { + ScanOptions options = ScanOptions.scanOptions() + .match(pattern) + .count(1000) + .build(); + + redisTemplate.execute((RedisCallback) connection -> { + try (Cursor cursor = connection.keyCommands().scan(options)) { + List keys = new ArrayList<>(); + while (cursor.hasNext()) { + keys.add(new String(cursor.next())); + } + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + throw new BaseException(ErrorCode.REFRESH_TOKEN_INVALID); + } + return null; + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java b/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java new file mode 100644 index 00000000..a7799e9c --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java @@ -0,0 +1,11 @@ + +package com.juu.juulabel.auth.repository.redis; + +import org.springframework.data.repository.CrudRepository; + +import com.juu.juulabel.auth.domain.RefreshToken; + +public interface RefreshTokenRedisRepository + extends CrudRepository, CustomRefreshTokenRepository { + +} diff --git a/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java b/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java new file mode 100644 index 00000000..83ea0535 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/FraudDetectionService.java @@ -0,0 +1,7 @@ +package com.juu.juulabel.auth.service; + +public interface FraudDetectionService { + RiskAssessment assessRisk(T data, + String currentIpAddress, String currentUserAgent, String currentDeviceId); + +} diff --git a/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java b/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java new file mode 100644 index 00000000..6d5dbc3e --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java @@ -0,0 +1,5 @@ +package com.juu.juulabel.auth.service; + +public class GeoIp2Exception { + +} diff --git a/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java b/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java new file mode 100644 index 00000000..d8cf2fde --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java @@ -0,0 +1,68 @@ +package com.juu.juulabel.auth.service; + +import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.WithdrawalRecord; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.repository.MemberWriter; +import com.juu.juulabel.member.repository.WithdrawalRecordWriter; +import com.juu.juulabel.member.token.Token; +import com.juu.juulabel.member.util.MemberUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberAuthService { + private final MemberReader memberReader; + private final MemberWriter memberWriter; + private final WithdrawalRecordWriter withdrawalRecordWriter; + private final JwtTokenProvider jwtTokenProvider; + private final MemberUtils memberUtils; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + + @Transactional + public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { + validateNickname(signUpRequest.nickname()); + validateEmail(signUpRequest.email()); + + Member member = Member.create(signUpRequest); + memberWriter.store(member); + + memberUtils.processAlcoholTypes(member, signUpRequest); + memberUtils.processTermsAgreements(member, signUpRequest); + + String token = jwtTokenProvider.createAccessToken(member); + + return new SignUpMemberResponse( + member.getId(), + new Token(token, jwtTokenProvider.getExpirationByToken(token))); + } + + @Transactional + public void deleteAccount(Member loginMember, WithdrawalRequest request) { + loginMember.deleteAccount(); + withdrawalRecordWriter.store( + WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); + refreshTokenRedisRepository.revokeByMemberId(loginMember.getId()); + } + + private void validateNickname(String nickname) { + if (memberReader.existActiveNickname(nickname)) { + throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); + } + } + + private void validateEmail(String email) { + if (memberReader.existActiveEmail(email)) { + throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/OAuthService.java b/src/main/java/com/juu/juulabel/auth/service/OAuthService.java new file mode 100644 index 00000000..814a5cca --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/OAuthService.java @@ -0,0 +1,69 @@ +package com.juu.juulabel.auth.service; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.repository.WithdrawalRecordReader; +import com.juu.juulabel.member.request.OAuthLoginInfo; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.request.OAuthUserInfo; +import com.juu.juulabel.member.token.Token; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class OAuthService { + private final OAuthProviderFactory providerFactory; + private final JwtTokenProvider jwtTokenProvider; + private final MemberReader memberReader; + private final WithdrawalRecordReader withdrawalRecordReader; + + @Transactional + public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { + OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); + Provider provider = authLoginInfo.provider(); + + String accessToken = providerFactory.getAccessToken( + provider, + authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), + authLoginInfo.propertyMap().get(AuthConstants.CODE)); + + OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); + String email = oAuthUser.email(); + validateNotWithdrawnMember(email); + + boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); + Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); + + Token token = memberOpt.map(member -> { + String generatedToken = jwtTokenProvider.createAccessToken(member); + return new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); + }).orElse(new Token(null, null)); + + return new LoginResponse( + token, + isNewMember, + new OAuthUserInfo( + memberOpt.map(Member::getId).orElse(null), + email, + oAuthUser.id(), + provider)); + } + + private void validateNotWithdrawnMember(String email) { + if (withdrawalRecordReader.existEmail(email)) { + throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java b/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java new file mode 100644 index 00000000..19552ec9 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java @@ -0,0 +1,234 @@ +package com.juu.juulabel.auth.service; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.juu.juulabel.auth.domain.RefreshToken; +import com.maxmind.geoip2.DatabaseReader; +import com.maxmind.geoip2.exception.AddressNotFoundException; +import com.maxmind.geoip2.exception.GeoIp2Exception; +import com.maxmind.geoip2.model.CityResponse; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class RefreshTokenFraudDetectionService implements FraudDetectionService { + + private static final double IP_CHANGE_WEIGHT = 0.4; + private static final double UA_CHANGE_WEIGHT = 0.3; + private static final double DEVICE_ID_MISMATCH_WEIGHT = 0.7; + private static final double UNUSUAL_ACCESS_TIME_WEIGHT = 0.2; + private static final double VELOCITY_CHANGE_WEIGHT = 0.35; + private static final double SUSPICIOUS_UA_WEIGHT = 0.45; + private static final double TOR_EXIT_NODE_WEIGHT = 0.6; + + private static final int SUSPICIOUS_LOGIN_DISTANCE_KM = 500; + private static final int IMPOSSIBLE_TRAVEL_DISTANCE_KM = 1000; + private static final Duration IMPOSSIBLE_TRAVEL_TIME = Duration.ofHours(3); + + private static final Set KNOWN_TOR_EXIT_NODES = new HashSet<>(); + private static final Set KNOWN_MALICIOUS_IPS = new HashSet<>(); + private static final Set SUSPICIOUS_USER_AGENT_PATTERNS = new HashSet<>(); + + @Value("${geoip.database.path:classpath:GeoLite2-City.mmdb}") + private String geoipDatabasePath; + + private DatabaseReader databaseReader; + + @PostConstruct + public void initialize() { + try { + // Initialize the GeoIP database reader + File database = new File(geoipDatabasePath.replace("classpath:", "")); + databaseReader = new DatabaseReader.Builder(database).build(); + + // Initialize known Tor exit nodes (would be updated regularly in production) + KNOWN_TOR_EXIT_NODES.add("176.10.99.200"); + KNOWN_TOR_EXIT_NODES.add("185.220.101.21"); + // More Tor exit nodes would be added here or fetched from an API + + // Initialize known malicious IPs (would be updated regularly in production) + KNOWN_MALICIOUS_IPS.add("103.91.181.5"); + KNOWN_MALICIOUS_IPS.add("45.95.168.112"); + // More malicious IPs would be added here or fetched from a threat intelligence + // service + + // Initialize suspicious user agent patterns + SUSPICIOUS_USER_AGENT_PATTERNS.add("nikto"); + SUSPICIOUS_USER_AGENT_PATTERNS.add("sqlmap"); + SUSPICIOUS_USER_AGENT_PATTERNS.add("vulnerability"); + SUSPICIOUS_USER_AGENT_PATTERNS.add("masscan"); + SUSPICIOUS_USER_AGENT_PATTERNS.add("nmap"); + // More patterns would be added here + + } catch (IOException e) { + log.error("Failed to initialize GeoIP database: {}", e.getMessage()); + } + } + + @Override + public RiskAssessment assessRisk(RefreshToken token, String currentIpAddress, String currentUserAgent, + String currentDeviceId) { + + double currentScore = 0.0; + StringBuilder reasons = new StringBuilder(); + boolean immediateFamilyCompromise = false; + + // Rule 1: IP Address Change + if (token.getIpAddress() != null && + !token.getIpAddress().equals(currentIpAddress)) { + // More sophisticated: check IP geolocation, ASN, known proxy, Tor exit node + if (!areIpAddressesGeographicallyClose(token.getIpAddress(), currentIpAddress)) { + currentScore += IP_CHANGE_WEIGHT; + reasons.append("Significant IP geolocation change. "); + } + } + + // Rule 2: User-Agent Change + if (token.getUserAgent() != null && + !token.getUserAgent().equals(currentUserAgent)) { + currentScore += UA_CHANGE_WEIGHT; + reasons.append("User-Agent changed. "); + } + + // Rule 3: Device ID Mismatch + if (token.getDeviceId() != null && currentDeviceId != null && + !token.getDeviceId().equals(currentDeviceId)) { + currentScore += DEVICE_ID_MISMATCH_WEIGHT; + reasons.append("Device ID mismatch. "); + immediateFamilyCompromise = true; // Device ID mismatch is often a strong indicator + } else if (token.getDeviceId() != null && currentDeviceId == null) { + currentScore += DEVICE_ID_MISMATCH_WEIGHT * 0.5; // Device ID disappeared + reasons.append("Device ID removed. "); + } else if (token.getDeviceId() == null && currentDeviceId != null) { + // New device ID added, could be a new legitimate device, lower weight or needs + // context + reasons.append("New Device ID added. "); + } + + // Rule 4: Check for Tor Exit Nodes + if (KNOWN_TOR_EXIT_NODES.contains(currentIpAddress)) { + currentScore += TOR_EXIT_NODE_WEIGHT; + reasons.append("Connection from known Tor exit node. "); + immediateFamilyCompromise = true; + } + + // Rule 5: Check for Known Malicious IPs + if (KNOWN_MALICIOUS_IPS.contains(currentIpAddress)) { + currentScore += 0.8; // Very high risk + reasons.append("Connection from known malicious IP. "); + immediateFamilyCompromise = true; + } + + // Rule 6: Check for Suspicious User Agents + if (currentUserAgent != null) { + for (String pattern : SUSPICIOUS_USER_AGENT_PATTERNS) { + if (currentUserAgent.toLowerCase().contains(pattern)) { + currentScore += SUSPICIOUS_UA_WEIGHT; + reasons.append("Suspicious User-Agent pattern detected. "); + break; + } + } + } + + // Rule 7: Velocity Check (impossible travel) + if (token.getIssuedAt() != null && token.getIpAddress() != null && + !token.getIpAddress().equals(currentIpAddress)) { + + LocalDateTime issuedAt = token.getIssuedAt(); + LocalDateTime now = LocalDateTime.now(); + Duration timeBetweenLogins = Duration.between(issuedAt, now); + + double distance = calculateDistanceBetweenIps(data.getIpAddress(), currentIpAddress); + + // If distance is very large and time between logins is short, flag as + // impossible travel + if (distance > IMPOSSIBLE_TRAVEL_DISTANCE_KM && timeBetweenLogins.compareTo(IMPOSSIBLE_TRAVEL_TIME) < 0) { + currentScore += VELOCITY_CHANGE_WEIGHT; + reasons.append("Impossible travel detected. "); + immediateFamilyCompromise = true; + } + } + + return new RiskAssessment(Math.min(currentScore, 1.0), reasons.toString(), immediateFamilyCompromise); + } + + private boolean areIpAddressesGeographicallyClose(String ip1, String ip2) { + if (ip1.equals(ip2)) { + return true; + } + + // Check for internal/private IP addresses + if (isPrivateIpAddress(ip1) || isPrivateIpAddress(ip2)) { + return true; + } + + double distance = calculateDistanceBetweenIps(ip1, ip2); + + // Consider IPs close if they are within a reasonable distance (e.g., 500km) + return distance < SUSPICIOUS_LOGIN_DISTANCE_KM; + } + + private boolean isPrivateIpAddress(String ip) { + return ip.startsWith("192.168.") || ip.startsWith("10.") || + ip.startsWith("172.16.") || ip.startsWith("172.17.") || + ip.startsWith("172.18.") || ip.startsWith("172.19.") || + ip.startsWith("172.20.") || ip.startsWith("172.21.") || + ip.startsWith("172.22.") || ip.startsWith("172.23.") || + ip.startsWith("172.24.") || ip.startsWith("172.25.") || + ip.startsWith("172.26.") || ip.startsWith("172.27.") || + ip.startsWith("172.28.") || ip.startsWith("172.29.") || + ip.startsWith("172.30.") || ip.startsWith("172.31.") || + ip.equals("127.0.0.1") || ip.equals("::1") || + ip.equals("localhost"); + } + + private double calculateDistanceBetweenIps(String ip1, String ip2) { + try { + // Get locations from MaxMind GeoIP database + CityResponse location1 = databaseReader.city(InetAddress.getByName(ip1)); + CityResponse location2 = databaseReader.city(InetAddress.getByName(ip2)); + + // Get latitude and longitude from responses + double lat1 = location1.getLocation().getLatitude(); + double lon1 = location1.getLocation().getLongitude(); + double lat2 = location2.getLocation().getLatitude(); + double lon2 = location2.getLocation().getLongitude(); + + // Calculate distance using Haversine formula + return calculateHaversineDistance(lat1, lon1, lat2, lon2); + + } catch (IOException | GeoIp2Exception e) { + log.warn("Error calculating distance between IPs: {}", e.getMessage()); + // If we can't determine distance, assume they're not close for safety + return Double.MAX_VALUE; + } + } + + // Haversine formula to calculate distance between two points on Earth + private double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) { + // Radius of Earth in kilometers + final double R = 6371.0; + + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; // Distance in kilometers + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java b/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java new file mode 100644 index 00000000..41d7188a --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/RiskAssessment.java @@ -0,0 +1,28 @@ +package com.juu.juulabel.auth.service; + +import lombok.Getter; + +/** + * Risk assessment result + */ +@Getter +public class RiskAssessment { + private final double score; // 0.0 (low) to 1.0 (high) + private final String reason; + private final boolean familyShouldBeCompromised; + + public RiskAssessment(double score, String reason, boolean familyShouldBeCompromised) { + this.score = score; + this.reason = reason; + this.familyShouldBeCompromised = familyShouldBeCompromised; + } + + public boolean isHighRisk() { + /* e.g., score > 0.8 */ + return score > 0.8; + } + + public boolean isFamilyCompromised() { + return familyShouldBeCompromised; + } +} diff --git a/src/main/java/com/juu/juulabel/auth/service/TokenService.java b/src/main/java/com/juu/juulabel/auth/service/TokenService.java new file mode 100644 index 00000000..5f4cf1bd --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/TokenService.java @@ -0,0 +1,60 @@ +package com.juu.juulabel.auth.service; + +import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; +import com.juu.juulabel.common.dto.response.RefreshResponse; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.member.domain.Member; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenService { + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + + @Transactional + public RefreshResponse refresh(String refreshTokenCookie) { + Member member = jwtTokenProvider.getMemberFromToken(refreshTokenCookie); + RefreshToken oldToken = validateAndGetOldToken(refreshTokenCookie); + + jwtTokenProvider.rotateRefreshToken(oldToken); + + return new RefreshResponse(jwtTokenProvider.createAccessToken(member)); + } + + @Transactional + public void logout(String refreshTokenCookie, Long memberId) { + + refreshTokenRedisRepository.findByTokenHash(refreshTokenCookie) + .ifPresent(token -> { + token.setRevoked(Instant.now()); + refreshTokenRedisRepository.save(token); + }); + } + + @Transactional + public void saveAndSetCookie(Long memberId, String parentTokenId) { + RefreshToken newToken = jwtTokenProvider.createRefreshToken(memberId, parentTokenId); + + RefreshToken oldToken = validateAndGetOldToken(parentTokenId); + oldToken.setRevoked(Instant.now()); + + refreshTokenRedisRepository.save(newToken); + refreshTokenRedisRepository.save(oldToken); + } + + private RefreshToken validateAndGetOldToken(String tokenStr) { + return refreshTokenRedisRepository.findByTokenHash(tokenStr) + .orElseThrow(() -> new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/config/RedisConfig.java b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java new file mode 100644 index 00000000..365ab81d --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java @@ -0,0 +1,21 @@ +package com.juu.juulabel.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java index d45e72de..f59c9ef6 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -13,8 +13,14 @@ public class AuthConstants { public static final String TOKEN_PREFIX = "Bearer "; + public static final String REFRESH_TOKEN_HASH_PREFIX = "RefreshToken"; public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1); public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(30); - public static final String REFRESH_TOKEN_HEADER_NAME = "X-Refresh-Token"; + + // The RFC 6648 (published in 2012) deprecated the X- prefix for custom headers: + public static final String REFRESH_TOKEN_HEADER_NAME = "Refresh-Token"; + public static final String DEVICE_ID_HEADER_NAME = "Device-Id"; + + public static final Long REFRESH_TOKEN_TTL_IN_SECONDS = REFRESH_TOKEN_DURATION.getSeconds() + Duration.ofDays(15).getSeconds(); } diff --git a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index be3e7733..0a39e270 100644 --- a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java +++ b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java @@ -33,6 +33,8 @@ public enum ErrorCode { /** * Authentication */ + DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "Device-Id 헤더가 필요합니다."), + OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "Provider를 찾을 수 없습니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java index aef9bf44..df92b0b1 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -25,14 +25,13 @@ @RequiredArgsConstructor public class JwtAuthorizationFilter extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; - private final HttpRequestUtil httpRequestUtil; + private final JwtTokenProvider jwtTokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String header = httpRequestUtil.extractAuthorization(request); + String header = HttpRequestUtil.getAuthorization(request); if (header != null) { String token = jwtTokenProvider.resolveToken(header); try { diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java index f59061ff..082c4d1c 100644 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java @@ -1,8 +1,13 @@ package com.juu.juulabel.common.provider; +import com.juu.juulabel.auth.domain.ClientId; +import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.HttpRequestUtil; +import com.juu.juulabel.common.util.HttpResponseUtil; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.MemberRole; @@ -15,10 +20,13 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import static com.juu.juulabel.common.constants.AuthConstants.ACCESS_TOKEN_DURATION; import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HEADER_NAME; import static com.juu.juulabel.common.constants.AuthConstants.TOKEN_PREFIX; import javax.crypto.SecretKey; @@ -35,10 +43,13 @@ public class JwtTokenProvider { private final SecretKey key; private final JwtParser jwtParser; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; - public JwtTokenProvider(@Value("${spring.jwt.secret}") String key) { + public JwtTokenProvider(@Value("${spring.jwt.secret}") String key, + RefreshTokenRedisRepository refreshTokenRedisRepository) { this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(key)); this.jwtParser = Jwts.parser().verifyWith(this.key).build(); + this.refreshTokenRedisRepository = refreshTokenRedisRepository; } /** @@ -57,8 +68,24 @@ public String createAccessToken(Member member) { * @param member The member for whom to create the token * @return A RefreshToken entity */ - public String createRefreshToken(Long memberId) { - return buildToken(memberId, null, REFRESH_TOKEN_DURATION); + public RefreshToken createRefreshToken(Long memberId, String parentTokenId) { + String ipAddress = HttpRequestUtil.getClientIpAddress(); + String userAgent = HttpRequestUtil.getUserAgent(); + String deviceId = HttpRequestUtil.getDeviceId(); + + String token = buildToken(memberId, null, REFRESH_TOKEN_DURATION); + + HttpResponseUtil.addCookie(REFRESH_TOKEN_HEADER_NAME, token, + (int) REFRESH_TOKEN_DURATION.getSeconds()); + + return RefreshToken.builder() + .token(token) + .memberId(memberId) + .clientId(ClientId.WEB) + .deviceId(deviceId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); } /** @@ -70,7 +97,7 @@ public String createRefreshToken(Long memberId) { * @return The JWT token string */ private String buildToken(Long memberId, String role, Duration duration) { - Date expirationDate = getExpirationDate(duration); + Date expirationDate = new Date(System.currentTimeMillis() + duration.toMillis()); JwtBuilder builder = Jwts.builder() .subject(String.valueOf(memberId)) .issuedAt(new Date()) @@ -91,7 +118,7 @@ private String buildToken(Long memberId, String role, Duration duration) { * @param accessToken The access token * @return The Authentication object */ - public Authentication getAuthentication(String accessToken) { + public Authentication getAuthentication(String accessToken) { return extractFromClaims(accessToken, claims -> { String role = claims.get(ROLE_CLAIM, String.class); Long memberId = Long.parseLong(claims.getSubject()); @@ -200,13 +227,72 @@ private Claims parseClaims(String token) { } /** - * Gets an expiration date based on current time plus duration + * Validates a refresh token * - * @param duration The duration - * @return The expiration date + * @param token The refresh token + * @param ipAddress Current request IP address + * @param userAgent Current request user agent + * @return true if valid, throws exception otherwise */ - private Date getExpirationDate(Duration duration) { - return new Date(System.currentTimeMillis() + duration.toMillis()); + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void rotateRefreshToken(RefreshToken token) { + String ipAddress = HttpRequestUtil.getClientIpAddress(); + String userAgent = HttpRequestUtil.getUserAgent(); + String deviceId = HttpRequestUtil.getDeviceId(); + + Long memberId = token.getMemberId(); + + // Case 1: Device ID doesn’t match the previous token + // • Revoke the entire chain of the previous token (current + descendants). + // • This blocks access from the stolen session while allowing the user to + // continue on the new device. + // • Keep the new device’s session active if validated correctly (e.g., fresh + // login or MFA). + + if (!token.getDeviceId().equals(deviceId)) { + refreshTokenRedisRepository.revokeByDeviceId(memberId, deviceId); + throw new CustomJwtException( + String.format( + "Device ID mismatch: Device ID=%s, Current Token Device ID=%s", + deviceId, token.getDeviceId()), + ErrorCode.REFRESH_TOKEN_INVALID); + } + + // Case 2: Token is reused/revoked + // • Revoke the entire token from the member. + // • This blocks access from the stolen session while allowing the user to + // continue on the new device. + // • Keep the new device’s session active if validated correctly (e.g., fresh + // login or MFA). + + if (token.getRevokedAt() != null) { + refreshTokenRedisRepository.revokeByMemberId(memberId); + throw new CustomJwtException( + String.format( + "Parent token is revoked: Device ID=%s IP=%s User-Agent=%s, Parent Token Device ID=%s IP=%s User-Agent=%s", + deviceId, ipAddress, userAgent, token.getDeviceId(), token.getIpAddress(), + token.getUserAgent()), + ErrorCode.REFRESH_TOKEN_INVALID); + } + + } + + /** + * Checks for token reuse + * + * @param token The refresh token + * @param hasChildTokens Whether the token has child tokens + * @param ipAddress Current request IP + * @param userAgent Current request user agent + * @throws CustomJwtException when token reuse is detected + */ + public void checkTokenReuse(RefreshToken token, boolean hasChildTokens, String ipAddress, String userAgent) { + if (token.getRevokedAt() != null && hasChildTokens) { + throw new CustomJwtException( + String.format("Refresh token reuse detected: IP=%s User-Agent=%s", + ipAddress, userAgent), + ErrorCode.REFRESH_TOKEN_INVALID); + } } } diff --git a/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java new file mode 100644 index 00000000..df8eba8f --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/AbstractHttpUtil.java @@ -0,0 +1,61 @@ +package com.juu.juulabel.common.util; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.util.Optional; +import java.util.function.Function; + +/** + * Abstract base class for HTTP utility operations + */ +public abstract class AbstractHttpUtil { + + /** + * Protected constructor to prevent direct instantiation + */ + protected AbstractHttpUtil() { + throw new AssertionError("Utility class should not be instantiated"); + } + + /** + * Gets the current HTTP request from the servlet context + * + * @return the current HttpServletRequest + * @throws BaseException if request attributes are not available + */ + protected static HttpServletRequest getCurrentRequest() { + return getFromRequestAttributes(ServletRequestAttributes::getRequest); + } + + /** + * Gets the current HTTP response from the servlet context + * + * @return the current HttpServletResponse + * @throws BaseException if request attributes are not available + */ + protected static HttpServletResponse getCurrentResponse() { + return getFromRequestAttributes(ServletRequestAttributes::getResponse); + } + + /** + * Extracts data from ServletRequestAttributes using the provided function + * + * @param extractor function to extract data from ServletRequestAttributes + * @return extracted data + * @throws BaseException if request attributes are not available + */ + protected static T getFromRequestAttributes(Function extractor) { + return Optional.ofNullable(RequestContextHolder.getRequestAttributes()) + .filter(ServletRequestAttributes.class::isInstance) + .map(ServletRequestAttributes.class::cast) + .map(extractor) + .orElseThrow(() -> new BaseException(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java index 8bfcb49f..3d6c24a2 100644 --- a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java +++ b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java @@ -1,33 +1,67 @@ package com.juu.juulabel.common.util; import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Component; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; + +import static com.juu.juulabel.common.constants.AuthConstants.DEVICE_ID_HEADER_NAME; + /** * Utility class for HTTP request operations */ -@Component -public class HttpRequestUtil { +public final class HttpRequestUtil extends AbstractHttpUtil { + + private static final String UNKNOWN = "unknown"; + private static final List IP_HEADER_CANDIDATES = List.of( + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR"); /** - * Extract client IP address from request - * Handles X-Forwarded-For header for clients behind a proxy + * Private constructor to prevent instantiation of utility class */ - public String extractIpAddress(HttpServletRequest request) { - String xForwardedFor = request.getHeader("X-Forwarded-For"); - return xForwardedFor != null && !xForwardedFor.isEmpty() - ? xForwardedFor.split(",")[0].trim() - : request.getRemoteAddr(); + private HttpRequestUtil() { + super(); + } + + public static String getAuthorization(HttpServletRequest request) { + return request.getHeader(HttpHeaders.AUTHORIZATION); } - public String extractUserAgent(HttpServletRequest request) { - return request.getHeader(HttpHeaders.USER_AGENT); + public static String getClientIpAddress() { + HttpServletRequest request = getCurrentRequest(); + + return IP_HEADER_CANDIDATES.stream() + .map(request::getHeader) + .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) + .map(ip -> ip.split(",")[0].trim()) + .findFirst() + .orElseGet(request::getRemoteAddr); } - public String extractAuthorization(HttpServletRequest request) { - return request.getHeader(HttpHeaders.AUTHORIZATION); + public static String getUserAgent() { + return getCurrentRequest().getHeader(HttpHeaders.USER_AGENT); + } + public static String getDeviceId() { + HttpServletRequest request = getCurrentRequest(); + String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); + if (deviceId == null) { + throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); + } + return deviceId; } } diff --git a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java new file mode 100644 index 00000000..98143859 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java @@ -0,0 +1,29 @@ +package com.juu.juulabel.common.util; + +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +public final class HttpResponseUtil extends AbstractHttpUtil { + + private HttpResponseUtil() { + super(); + } + + public static void addCookie(String name, String value, int maxAge) { + HttpServletResponse response = getCurrentResponse(); + Cookie cookie = new Cookie(name, value); + cookie.setMaxAge(maxAge); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setSecure(true); + + response.addCookie(cookie); + } + + public static HttpServletResponse getCurrentResponse() { + return getFromRequestAttributes(ServletRequestAttributes::getResponse); + } + +} diff --git a/src/main/java/com/juu/juulabel/member/controller/MemberController.java b/src/main/java/com/juu/juulabel/member/controller/MemberController.java index db305c45..049199ac 100644 --- a/src/main/java/com/juu/juulabel/member/controller/MemberController.java +++ b/src/main/java/com/juu/juulabel/member/controller/MemberController.java @@ -26,6 +26,12 @@ public class MemberController { private final MemberService memberService; + @Operation(summary = "닉네임 중복 검사") + @GetMapping("/nicknames/{nickname}/exists") + public ResponseEntity> checkNickname(@PathVariable String nickname) { + return CommonResponse.success(SuccessCode.SUCCESS, memberService.checkNickname(nickname)); + } + @Operation(summary = "프로필 수정") @PutMapping("/me/profile") public ResponseEntity> updateProfile( @@ -93,7 +99,7 @@ public ResponseEntity> loadMyAlcoh @Operation(summary = "특정 회원이 작성한 시음노트 목록 조회") @Parameters(@Parameter(name = "request", description = "특정 회원이 작성한 시음노트 목록 조회 요청", required = true)) - @GetMapping("/members/{memberId}/tasting-notes") + @GetMapping("/{memberId}/tasting-notes") public ResponseEntity> loadMemberTastingNoteList( @AuthenticationPrincipal Member member, @Valid TastingNoteListRequest request, @@ -104,7 +110,7 @@ public ResponseEntity> loadMemberTasting @Operation(summary = "특정 회원이 작성한 일상생활 목록 조회") @Parameters(@Parameter(name = "request", description = "특정 회원이 작성한 일상생활 목록 조회 요청", required = true)) - @GetMapping("/members/{memberId}/daily-lives") + @GetMapping("/{memberId}/daily-lives") public ResponseEntity> loadMemberDailyLifeList( @AuthenticationPrincipal Member member, @Valid DailyLifeListRequest request, diff --git a/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java deleted file mode 100644 index 1e540d9e..00000000 --- a/src/main/java/com/juu/juulabel/member/domain/RefreshToken.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.juu.juulabel.member.domain; - -import java.time.LocalDateTime; - -import com.juu.juulabel.common.base.BaseTimeEntity; - -import jakarta.persistence.*; -import lombok.*; - -@Getter -@Builder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Entity -@Table(name = "refresh_tokens", indexes = { - @Index(name = "idx_refresh_token_token_unq", columnList = "token", unique = true), - @Index(name = "idx_refresh_token_member_id", columnList = "member_id"), - @Index(name = "idx_refresh_token_expiry_date", columnList = "expires_at") -}) -public class RefreshToken extends BaseTimeEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", columnDefinition = "BIGINT UNSIGNED comment '리프레시 토큰 고유 번호'") - private Long id; - - @Column(name = "member_id", nullable = false, columnDefinition = "BIGINT UNSIGNED comment '회원 고유 번호'") - private Long memberId; - - @Column(name = "token", nullable = false, unique = true, columnDefinition = "varchar(255) comment '리프레시 토큰'") - private String token; - - @Column(name = "parent_token_id", columnDefinition = "BIGINT UNSIGNED comment '부모 토큰 아이디'") - private Long parentTokenId; - - @Column(name = "ip_address", nullable = false, columnDefinition = "varchar(255) comment 'IP 주소'") - private String ipAddress; - - @Column(name = "user_agent", nullable = false, columnDefinition = "varchar(255) comment '유저 에이전트'") - private String userAgent; - - @Column(name = "device_id", columnDefinition = "varchar(255) comment '디바이스 아이디'") - private String deviceId; - - @Column(name = "expires_at", nullable = false, columnDefinition = "datetime comment '토큰 만료 일시'") - private LocalDateTime expiresAt; - - @Column(name = "revoked", nullable = false, columnDefinition = "TINYINT(1) comment '토큰 무효화 여부'") - @Builder.Default - private boolean revoked = false; - - public void setRevoked(boolean revoked) { - this.revoked = revoked; - } - - public void setParentTokenId(Long parentTokenId) { - this.parentTokenId = parentTokenId; - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java b/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java deleted file mode 100644 index ac61f14b..00000000 --- a/src/main/java/com/juu/juulabel/member/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.juu.juulabel.member.repository; - -import java.util.Optional; - -import com.juu.juulabel.member.domain.RefreshToken; - -/** - * Interface for refresh token persistence operations. - * This abstraction allows swapping implementations (e.g., JPA, Redis). - */ -public interface RefreshTokenRepository { - - /** - * Finds a refresh token by its token string. - * - * @param token The token string. - * @return An Optional containing the RefreshToken if found, otherwise empty. - */ - Optional findByToken(String token); - - /** - * Checks if a refresh token exists with the given parent token ID. - * Used for detecting token reuse after rotation. - * - * @param parentTokenId The ID of the parent token. - * @return true if a token with the specified parent ID exists, false otherwise. - */ - boolean existsByParentTokenId(Long parentTokenId); - - /** - * Deletes all refresh tokens associated with a specific member ID. - * Used when revoking all tokens for a user due to security concerns (e.g., - * reuse detection). - * - * @param memberId The ID of the member whose tokens should be deleted. - */ - void deleteByMemberId(Long memberId); - - /** - * Saves a refresh token entity. - * Used for storing new tokens during issuance or rotation. - * - * @param refreshToken The RefreshToken entity to save. - * @return The saved RefreshToken entity. - */ - RefreshToken save(RefreshToken refreshToken); - - /** - * Deletes a specific refresh token. - * (Optional: Might be useful for explicit deletion scenarios if needed later) - * - * @param refreshToken The RefreshToken entity to delete. - */ - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java b/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java deleted file mode 100644 index cbd6fce5..00000000 --- a/src/main/java/com/juu/juulabel/member/repository/jpa/RefreshTokenJpaRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.juu.juulabel.member.repository.jpa; - -import java.util.Optional; - -import org.springframework.context.annotation.Primary; -import org.springframework.data.jpa.repository.JpaRepository; - -import com.juu.juulabel.member.domain.RefreshToken; -import com.juu.juulabel.member.repository.RefreshTokenRepository; - -@Primary -public interface RefreshTokenJpaRepository extends JpaRepository, RefreshTokenRepository { - @Override - Optional findByToken(String token); - - @Override - boolean existsByParentTokenId(Long parentTokenId); - - @Override - void deleteByMemberId(Long memberId); -} diff --git a/src/main/java/com/juu/juulabel/member/service/AuthService.java b/src/main/java/com/juu/juulabel/member/service/AuthService.java deleted file mode 100644 index b72acf29..00000000 --- a/src/main/java/com/juu/juulabel/member/service/AuthService.java +++ /dev/null @@ -1,291 +0,0 @@ -package com.juu.juulabel.member.service; - -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; -import com.juu.juulabel.common.dto.request.SignUpMemberRequest; -import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.factory.OAuthProviderFactory; -import com.juu.juulabel.common.provider.JwtTokenProvider; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.member.domain.RefreshToken; -import com.juu.juulabel.member.domain.WithdrawalRecord; -import com.juu.juulabel.member.repository.MemberReader; -import com.juu.juulabel.member.repository.MemberWriter; -import com.juu.juulabel.member.repository.RefreshTokenRepository; -import com.juu.juulabel.member.repository.WithdrawalRecordReader; -import com.juu.juulabel.member.repository.WithdrawalRecordWriter; -import com.juu.juulabel.member.request.OAuthLoginInfo; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.member.request.OAuthUserInfo; -import com.juu.juulabel.member.token.Token; -import com.juu.juulabel.member.util.MemberUtils; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.Optional; - -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HEADER_NAME; - -/** - * 인증 및 토큰 관리 서비스 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class AuthService { - - private final OAuthProviderFactory providerFactory; - private final JwtTokenProvider jwtTokenProvider; - private final MemberReader memberReader; - private final MemberWriter memberWriter; - private final WithdrawalRecordReader withdrawalRecordReader; - private final WithdrawalRecordWriter withdrawalRecordWriter; - private final RefreshTokenRepository refreshTokenRepository; - private final HttpRequestUtil httpRequestUtil; - private final MemberUtils memberUtils; - - // ===== 인증 관련 메서드 ===== - - /** - * OAuth 로그인 처리 - */ - @Transactional - public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { - OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); - Provider provider = authLoginInfo.provider(); - - // 인가 코드를 이용해 토큰 발급 요청 - String accessToken = providerFactory.getAccessToken( - provider, - authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), - authLoginInfo.propertyMap().get(AuthConstants.CODE)); - - // 토큰을 이용해 사용자 정보 가져오기 - OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); - - // 회원가입 or 로그인 - String email = oAuthUser.email(); - validateNotWithdrawnMember(email); - - boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); - Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); - - Token token = memberOpt.map(member -> { - String generatedToken = jwtTokenProvider.createAccessToken(member); - return new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); - }).orElse(new Token(null, null)); - - return new LoginResponse( - token, - isNewMember, - new OAuthUserInfo( - memberOpt.map(Member::getId).orElse(null), - email, - oAuthUser.id(), - provider)); - } - - /** - * 회원 가입 - */ - @Transactional - public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { - validateNickname(signUpRequest.nickname()); - validateEmail(signUpRequest.email()); - - Member member = Member.create(signUpRequest); - memberWriter.store(member); - - // 선호전통주 주종 등록 - memberUtils.processAlcoholTypes(member, signUpRequest); - - // 약관 등록 - memberUtils.processTermsAgreements(member, signUpRequest); - - String token = jwtTokenProvider.createAccessToken(member); - - return new SignUpMemberResponse( - member.getId(), - new Token(token, jwtTokenProvider.getExpirationByToken(token))); - } - - /** - * 회원 탈퇴 - */ - @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request) { - loginMember.deleteAccount(); - withdrawalRecordWriter.store( - WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); - } - - /** - * 닉네임 중복 확인 - */ - @Transactional(readOnly = true) - public boolean checkNickname(String nickname) { - return memberReader.existActiveNickname(nickname); - } - - // ===== 토큰 관련 메서드 ===== - - /** - * 액세스 토큰 및 리프레시 토큰 갱신 - */ - @Transactional - public RefreshResponse refresh(String refreshTokenCookie, HttpServletRequest request, - HttpServletResponse response) { - // 한 번의 호출로 Member와 RefreshToken을 함께 가져오도록 최적화 - Member member = jwtTokenProvider.getMemberFromToken(refreshTokenCookie); - RefreshToken oldToken = validateAndGetOldToken(refreshTokenCookie); - - String ipAddress = httpRequestUtil.extractIpAddress(request); - String userAgent = httpRequestUtil.extractUserAgent(request); - - // 토큰 환경 검증 및 토큰 회전 - validateTokenEnvironment(oldToken, ipAddress, userAgent, member); - createAndSaveRefreshToken(member.getId(), oldToken.getId(), ipAddress, userAgent, response); - - // 새 액세스 토큰 생성 및 반환 - return new RefreshResponse(jwtTokenProvider.createAccessToken(member)); - } - - /** - * 리프레시 토큰 등록 - */ - @Transactional - public void registerRefreshToken(Long memberId, HttpServletRequest request, HttpServletResponse response) { - String ipAddress = httpRequestUtil.extractIpAddress(request); - String userAgent = httpRequestUtil.extractUserAgent(request); - - createAndSaveRefreshToken(memberId, null, ipAddress, userAgent, response); - } - - /** - * 로그아웃 처리 - 토큰 비활성화 - */ - @Transactional - public void logout(String refreshTokenCookie) { - refreshTokenRepository.findByToken(refreshTokenCookie) - .ifPresent(token -> { - token.setRevoked(true); - refreshTokenRepository.save(token); - }); - } - - /** - * 새 리프레시 토큰 생성 및 저장 - */ - public void createAndSaveRefreshToken(Long memberId, Long parentTokenId, String ipAddress, String userAgent, - HttpServletResponse response) { - - String token = jwtTokenProvider.createRefreshToken(memberId); - - RefreshToken newToken = RefreshToken.builder() - .token(token) - .memberId(memberId) - .parentTokenId(parentTokenId) - .ipAddress(ipAddress) - .userAgent(userAgent) - .expiresAt(LocalDateTime.now().plusSeconds(REFRESH_TOKEN_DURATION.getSeconds())) - .build(); - - refreshTokenRepository.save(newToken); - setCookie(response, newToken.getToken()); - } - - /** - * 리프레시 토큰 검증 및 조회 - */ - private RefreshToken validateAndGetOldToken(String tokenStr) { - return refreshTokenRepository.findByToken(tokenStr) - .orElseThrow(() -> new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); - } - - /** - * 토큰 환경 검증 (IP, UserAgent 등) - */ - private void validateTokenEnvironment(RefreshToken oldToken, String ipAddress, String userAgent, Member member) { - // 환경 일치 여부 확인 - if (!oldToken.getIpAddress().equals(ipAddress) && !oldToken.getUserAgent().equals(userAgent)) { - revokeAndThrow(oldToken, - String.format("의심스러운 활동 감지: IP=%s UA=%s Expected IP=%s UA=%s", - ipAddress, userAgent, oldToken.getIpAddress(), oldToken.getUserAgent()), - ErrorCode.REFRESH_TOKEN_INVALID); - } - - // 토큰이 이미 비활성화되었는지 확인 - if (oldToken.isRevoked()) { - if (refreshTokenRepository.existsByParentTokenId(oldToken.getId())) { - refreshTokenRepository.deleteByMemberId(member.getId()); - throw new BaseException( - String.format("리프레시 토큰 재사용 감지: IP=%s User-Agent=%s", - ipAddress, userAgent), - ErrorCode.REFRESH_TOKEN_INVALID); - } - throw new BaseException("이미 회전된 토큰", ErrorCode.REFRESH_TOKEN_ALREADY_ROTATED); - } - - // 토큰 비활성화 - oldToken.setRevoked(true); - refreshTokenRepository.save(oldToken); - } - - /** - * 토큰 비활성화 및 예외 발생 - */ - private void revokeAndThrow(RefreshToken oldToken, String message, ErrorCode errorCode) { - oldToken.setRevoked(true); - refreshTokenRepository.save(oldToken); - throw new BaseException(message, errorCode); - } - - /** - * 리프레시 토큰 쿠키 설정 - */ - private void setCookie(HttpServletResponse response, String token) { - Cookie cookie = new Cookie(REFRESH_TOKEN_HEADER_NAME, token); - cookie.setMaxAge((int) REFRESH_TOKEN_DURATION.getSeconds()); - cookie.setPath("/"); - cookie.setHttpOnly(true); - cookie.setSecure(true); - response.addCookie(cookie); - } - - // ===== 유효성 검증 메서드 ===== - - private void validateNickname(String nickname) { - if (memberReader.existActiveNickname(nickname)) { - throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); - } - } - - private void validateNotWithdrawnMember(String email) { - if (withdrawalRecordReader.existEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); - } - } - - private void validateEmail(String email) { - if (memberReader.existActiveEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/service/MemberContentService.java b/src/main/java/com/juu/juulabel/member/service/MemberContentService.java new file mode 100644 index 00000000..44b1eb86 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/service/MemberContentService.java @@ -0,0 +1,133 @@ +package com.juu.juulabel.member.service; + +import com.juu.juulabel.alcohol.domain.AlcoholicDrinks; +import com.juu.juulabel.alcohol.repository.AlcoholicDrinksReader; +import com.juu.juulabel.alcohol.repository.TastingNoteReader; +import com.juu.juulabel.alcohol.response.AlcoholicDrinksSummary; +import com.juu.juulabel.common.dto.request.MyAlcoholicDrinksListRequest; +import com.juu.juulabel.common.dto.request.TastingNoteListRequest; +import com.juu.juulabel.common.dto.response.DailyLifeListResponse; +import com.juu.juulabel.common.dto.response.MyAlcoholicDrinksListResponse; +import com.juu.juulabel.common.dto.response.MyDailyLifeListResponse; +import com.juu.juulabel.common.dto.response.MyTastingNoteListResponse; +import com.juu.juulabel.common.dto.response.TastingNoteListResponse; +import com.juu.juulabel.dailylife.repository.DailyLifeReader; +import com.juu.juulabel.dailylife.response.DailyLifeListRequest; +import com.juu.juulabel.dailylife.response.DailyLifeSummary; +import com.juu.juulabel.dailylife.response.MyDailyLifeSummary; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberAlcoholicDrinks; +import com.juu.juulabel.member.repository.MemberAlcoholicDrinksReader; +import com.juu.juulabel.member.repository.MemberAlcoholicDrinksWriter; +import com.juu.juulabel.tastingnote.request.MyTastingNoteSummary; +import com.juu.juulabel.tastingnote.request.TastingNoteSummary; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 회원 콘텐츠 관리 서비스 + * 일상생활, 시음노트, 전통주 관련 작업을 처리합니다. + */ +@Service +@RequiredArgsConstructor +public class MemberContentService { + + // 일상생활, 시음노트 관련 + private final DailyLifeReader dailyLifeReader; + private final TastingNoteReader tastingNoteReader; + + // 전통주 관련 + private final AlcoholicDrinksReader alcoholicDrinksReader; + private final MemberAlcoholicDrinksReader memberAlcoholicDrinksReader; + private final MemberAlcoholicDrinksWriter memberAlcoholicDrinksWriter; + + // ===== 일상생활 관련 메소드 ===== + + /** + * 내가 작성한 일상생활 목록 조회 + */ + @Transactional(readOnly = true) + public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListRequest request) { + Slice myDailyLifeList = dailyLifeReader.getAllMyDailyLives(member, + request.lastDailyLifeId(), request.pageSize()); + + return new MyDailyLifeListResponse(myDailyLifeList); + } + + /** + * 특정 회원이 작성한 일상생활 목록 조회 + */ + @Transactional(readOnly = true) + public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLifeListRequest request, + Long memberId) { + Slice dailyLifeList = dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, + request.lastDailyLifeId(), request.pageSize()); + + return new DailyLifeListResponse(dailyLifeList); + } + + // ===== 시음노트 관련 메소드 ===== + + /** + * 내가 작성한 시음노트 목록 조회 + */ + @Transactional(readOnly = true) + public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNoteListRequest request) { + Slice myTastingNoteList = tastingNoteReader.getAllMyTastingNotes(member, + request.lastTastingNoteId(), request.pageSize()); + + return new MyTastingNoteListResponse(myTastingNoteList); + } + + /** + * 특정 회원이 작성한 시음노트 목록 조회 + */ + @Transactional(readOnly = true) + public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, TastingNoteListRequest request, + Long memberId) { + Slice tastingNoteList = tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, + request.lastTastingNoteId(), request.pageSize()); + + return new TastingNoteListResponse(tastingNoteList); + } + + // ===== 전통주 관련 메소드 ===== + + /** + * 전통주 저장하기 또는 저장 취소 + * + * @return true if saved, false if unsaved + */ + @Transactional + public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { + AlcoholicDrinks alcoholicDrinks = alcoholicDrinksReader.getById(alcoholicDrinksId); + Optional memberAlcoholicDrinks = memberAlcoholicDrinksReader + .findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); + + // 전통주가 이미 저장되어 있다면 삭제, 저장되어 있지 않다면 등록 + return memberAlcoholicDrinks + .map(save -> { + memberAlcoholicDrinksWriter.delete(save); + return false; + }) + .orElseGet(() -> { + memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); + return true; + }); + } + + /** + * 내가 저장한 전통주 목록 조회 + */ + @Transactional(readOnly = true) + public MyAlcoholicDrinksListResponse loadMyAlcoholicDrinks(Member member, MyAlcoholicDrinksListRequest request) { + Slice alcoholicDrinksSummaries = alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, + request.lastAlcoholicDrinksId(), request.pageSize()); + + return new MyAlcoholicDrinksListResponse(alcoholicDrinksSummaries); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/service/MemberLookupService.java b/src/main/java/com/juu/juulabel/member/service/MemberLookupService.java new file mode 100644 index 00000000..adac1790 --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/service/MemberLookupService.java @@ -0,0 +1,41 @@ +package com.juu.juulabel.member.service; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 회원 조회 서비스 + */ +@Service +@RequiredArgsConstructor +public class MemberLookupService { + + private final MemberReader memberReader; + private final MemberJpaRepository memberJpaRepository; + + /** + * ID로 회원 조회 + */ + @Transactional(readOnly = true) + @Cacheable(value = "memberById", key = "#memberId", unless = "#result == null") + public Member findById(Long memberId) { + return memberJpaRepository.findById(memberId) + .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); + } + + /** + * 이메일로 회원 조회 + */ + @Transactional(readOnly = true) + @Cacheable(value = "memberByEmail", key = "#email", unless = "#result == null") + public Member getMemberByEmail(String email) { + return memberReader.getByEmail(email); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/service/MemberProfileService.java b/src/main/java/com/juu/juulabel/member/service/MemberProfileService.java new file mode 100644 index 00000000..a99cb6eb --- /dev/null +++ b/src/main/java/com/juu/juulabel/member/service/MemberProfileService.java @@ -0,0 +1,165 @@ +package com.juu.juulabel.member.service; + +import com.juu.juulabel.alcohol.repository.AlcoholTypeReader; +import com.juu.juulabel.alcohol.repository.TastingNoteReader; +import com.juu.juulabel.common.dto.request.UpdateProfileRequest; +import com.juu.juulabel.common.dto.response.MemberProfileResponse; +import com.juu.juulabel.common.dto.response.MyInfoResponse; +import com.juu.juulabel.common.dto.response.MySpaceResponse; +import com.juu.juulabel.common.dto.response.UpdateProfileResponse; +import com.juu.juulabel.dailylife.repository.DailyLifeReader; +import com.juu.juulabel.follow.repository.FollowReader; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.MemberAlcoholType; +import com.juu.juulabel.member.repository.MemberAlcoholTypeReader; +import com.juu.juulabel.member.repository.MemberAlcoholTypeWriter; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.util.MemberUtils; +import com.juu.juulabel.s3.S3Service; +import com.juu.juulabel.s3.UploadImageInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 회원 프로필 관리 서비스 + */ +@Service +@RequiredArgsConstructor +public class MemberProfileService { + + private final MemberReader memberReader; + private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; + private final MemberAlcoholTypeReader memberAlcoholTypeReader; + private final AlcoholTypeReader alcoholTypeReader; + private final S3Service s3Service; + private final DailyLifeReader dailyLifeReader; + private final TastingNoteReader tastingNoteReader; + private final FollowReader followReader; + private final MemberUtils memberUtils; + + /** + * 닉네임 중복 확인 + */ + @Transactional(readOnly = true) + public boolean checkNickname(String nickname) { + return memberReader.existActiveNickname(nickname); + } + + /** + * 프로필 수정 + */ + @Transactional + public UpdateProfileResponse updateProfile(Member loginMember, UpdateProfileRequest request, MultipartFile image) { + Member member = memberReader.getByEmail(loginMember.getEmail()); + String profileImageUrl = processProfileImage(image); + + // 프로필 업데이트 + member.updateProfile(request, profileImageUrl); + + memberAlcoholTypeWriter.deleteAllByMember(member); + + // 알콜 타입 업데이트 + updateMemberAlcoholTypes(member, request.alcoholTypeIds()); + + return new UpdateProfileResponse(member.getId()); + } + + /** + * 프로필 이미지 처리 + */ + private String processProfileImage(MultipartFile image) { + if (image != null && !image.isEmpty()) { + UploadImageInfo uploadImageInfo = s3Service.uploadMemberProfileImage(image); + return uploadImageInfo.ImageUrl(); + } + return null; + } + + /** + * 회원의 알콜 타입 업데이트 + */ + private void updateMemberAlcoholTypes(Member member, List alcoholTypeIds) { + if (!CollectionUtils.isEmpty(alcoholTypeIds)) { + List memberAlcoholTypeList = memberUtils.getMemberAlcoholTypeList( + member, alcoholTypeIds, alcoholTypeReader); + if (!memberAlcoholTypeList.isEmpty()) { + memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); + } + } + } + + /** + * 내 공간 정보 조회 + */ + @Transactional(readOnly = true) + public MySpaceResponse getMySpace(Member loginMember) { + Member member = memberReader.getById(loginMember.getId()); + long tastingNoteCount = tastingNoteReader.getMyTastingNoteCount(member); + long dailyLifeCount = dailyLifeReader.getMyDailyLifeCount(member); + long followingCount = followReader.countFollowing(member); + long followerCount = followReader.countFollower(member); + + return new MySpaceResponse( + member.getId(), + member.getProfileImage(), + member.getNickname(), + member.getIntroduction(), + member.isHasBadge(), + tastingNoteCount, + dailyLifeCount, + followingCount, + followerCount, + 0); + } + + /** + * 내 정보 조회 + */ + @Transactional(readOnly = true) + public MyInfoResponse getMyInfo(Member loginMember) { + Member member = memberReader.getById(loginMember.getId()); + List alcoholTypeIdList = memberAlcoholTypeReader.getIdListByMember(member); + return new MyInfoResponse( + member.getId(), + member.getNickname(), + member.getEmail(), + member.isHasBadge(), + member.isNotificationsAllowed(), + member.getIntroduction(), + member.getProfileImage(), + member.getGender(), + alcoholTypeIdList); + } + + /** + * 타 유저 프로필 조회 + */ + @Transactional(readOnly = true) + @Cacheable(value = "memberProfile", key = "#memberId", unless = "#result == null") + public MemberProfileResponse getMemberProfile(Member loginMember, Long memberId) { + Member member = memberReader.getById(memberId); + long tastingNoteCount = tastingNoteReader.getTastingNoteCountByMemberId(memberId, loginMember); + long dailyLifeCount = dailyLifeReader.getDailyLifeCountByMemberId(memberId, loginMember); + long followingCount = followReader.countFollowing(member); + long followerCount = followReader.countFollower(member); + boolean isFollowing = followReader.isFollowing(loginMember, member); + + return new MemberProfileResponse( + member.getId(), + member.getNickname(), + member.getProfileImage(), + member.getIntroduction(), + member.isHasBadge(), + tastingNoteCount, + dailyLifeCount, + followingCount, + followerCount, + isFollowing); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/member/service/MemberService.java b/src/main/java/com/juu/juulabel/member/service/MemberService.java index 2dbc00bb..c504938d 100644 --- a/src/main/java/com/juu/juulabel/member/service/MemberService.java +++ b/src/main/java/com/juu/juulabel/member/service/MemberService.java @@ -1,124 +1,48 @@ package com.juu.juulabel.member.service; -import com.juu.juulabel.alcohol.domain.AlcoholicDrinks; -import com.juu.juulabel.alcohol.repository.AlcoholTypeReader; -import com.juu.juulabel.alcohol.repository.AlcoholicDrinksReader; -import com.juu.juulabel.alcohol.repository.TastingNoteReader; -import com.juu.juulabel.alcohol.response.AlcoholicDrinksSummary; import com.juu.juulabel.common.dto.request.*; import com.juu.juulabel.common.dto.response.*; -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.dailylife.repository.DailyLifeReader; import com.juu.juulabel.dailylife.response.DailyLifeListRequest; -import com.juu.juulabel.dailylife.response.DailyLifeSummary; -import com.juu.juulabel.dailylife.response.MyDailyLifeSummary; -import com.juu.juulabel.follow.repository.FollowReader; import com.juu.juulabel.member.domain.*; -import com.juu.juulabel.member.repository.*; -import com.juu.juulabel.member.repository.jpa.MemberJpaRepository; -import com.juu.juulabel.member.util.MemberUtils; -import com.juu.juulabel.s3.S3Service; -import com.juu.juulabel.s3.UploadImageInfo; -import com.juu.juulabel.tastingnote.request.MyTastingNoteSummary; -import com.juu.juulabel.tastingnote.request.TastingNoteSummary; import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.CollectionUtils; import org.springframework.web.multipart.MultipartFile; -import java.util.*; - /** - * 회원 프로필 및 계정 관리 서비스 + * 회원 서비스 파사드 (Facade) 클래스 + * 다른 회원 관련 서비스 클래스들에 위임하는 역할을 담당 */ @Service @RequiredArgsConstructor public class MemberService { - private final MemberReader memberReader; - private final MemberAlcoholTypeWriter memberAlcoholTypeWriter; - private final MemberAlcoholTypeReader memberAlcoholTypeReader; - private final AlcoholTypeReader alcoholTypeReader; - private final S3Service s3Service; - private final DailyLifeReader dailyLifeReader; - private final TastingNoteReader tastingNoteReader; - private final MemberJpaRepository memberJpaRepository; - private final FollowReader followReader; - - private final MemberUtils memberUtils; - private final AlcoholicDrinksReader alcoholicDrinksReader; - private final MemberAlcoholicDrinksReader memberAlcoholicDrinksReader; - private final MemberAlcoholicDrinksWriter memberAlcoholicDrinksWriter; - - /** - * 프로필 수정 - */ - @Transactional - public UpdateProfileResponse updateProfile(Member loginMember, UpdateProfileRequest request, MultipartFile image) { - Member member = memberReader.getByEmail(loginMember.getEmail()); - String profileImageUrl = processProfileImage(image); - - // 프로필 업데이트 - member.updateProfile(request, profileImageUrl); - - memberAlcoholTypeWriter.deleteAllByMember(member); - - // 알콜 타입 업데이트 - updateMemberAlcoholTypes(member, request.alcoholTypeIds()); - - return new UpdateProfileResponse(member.getId()); - } + private final MemberProfileService memberProfileService; + private final MemberLookupService memberLookupService; + private final MemberContentService memberContentService; /** - * 프로필 이미지 처리 + * 닉네임 중복 확인 */ - private String processProfileImage(MultipartFile image) { - if (image != null && !image.isEmpty()) { - UploadImageInfo uploadImageInfo = s3Service.uploadMemberProfileImage(image); - return uploadImageInfo.ImageUrl(); - } - return null; + @Transactional(readOnly = true) + public boolean checkNickname(String nickname) { + return memberProfileService.checkNickname(nickname); } /** - * 회원의 알콜 타입 업데이트 + * 프로필 수정 */ - private void updateMemberAlcoholTypes(Member member, List alcoholTypeIds) { - if (!CollectionUtils.isEmpty(alcoholTypeIds)) { - List memberAlcoholTypeList = memberUtils.getMemberAlcoholTypeList( - member, alcoholTypeIds, alcoholTypeReader); - if (!memberAlcoholTypeList.isEmpty()) { - memberAlcoholTypeWriter.storeAll(memberAlcoholTypeList); - } - } + @Transactional + public UpdateProfileResponse updateProfile(Member loginMember, UpdateProfileRequest request, MultipartFile image) { + return memberProfileService.updateProfile(loginMember, request, image); } /** * 내 공간 정보 조회 */ - @Transactional(readOnly = true) + @Transactional(readOnly = true) public MySpaceResponse getMySpace(Member loginMember) { - Member member = memberReader.getById(loginMember.getId()); - long tastingNoteCount = tastingNoteReader.getMyTastingNoteCount(member); - long dailyLifeCount = dailyLifeReader.getMyDailyLifeCount(member); - long followingCount = followReader.countFollowing(member); - long followerCount = followReader.countFollower(member); - - return new MySpaceResponse( - member.getId(), - member.getProfileImage(), - member.getNickname(), - member.getIntroduction(), - member.isHasBadge(), - tastingNoteCount, - dailyLifeCount, - followingCount, - followerCount, - 0); + return memberProfileService.getMySpace(loginMember); } /** @@ -126,63 +50,31 @@ public MySpaceResponse getMySpace(Member loginMember) { */ @Transactional(readOnly = true) public MyInfoResponse getMyInfo(Member loginMember) { - Member member = memberReader.getById(loginMember.getId()); - List alcoholTypeIdList = memberAlcoholTypeReader.getIdListByMember(member); - return new MyInfoResponse( - member.getId(), - member.getNickname(), - member.getEmail(), - member.isHasBadge(), - member.isNotificationsAllowed(), - member.getIntroduction(), - member.getProfileImage(), - member.getGender(), - alcoholTypeIdList); + return memberProfileService.getMyInfo(loginMember); } /** * 타 유저 프로필 조회 */ @Transactional(readOnly = true) - @Cacheable(value = "memberProfile", key = "#memberId", unless = "#result == null") public MemberProfileResponse getMemberProfile(Member loginMember, Long memberId) { - Member member = memberReader.getById(memberId); - long tastingNoteCount = tastingNoteReader.getTastingNoteCountByMemberId(memberId, loginMember); - long dailyLifeCount = dailyLifeReader.getDailyLifeCountByMemberId(memberId, loginMember); - long followingCount = followReader.countFollowing(member); - long followerCount = followReader.countFollower(member); - boolean isFollowing = followReader.isFollowing(loginMember, member); - - return new MemberProfileResponse( - member.getId(), - member.getNickname(), - member.getProfileImage(), - member.getIntroduction(), - member.isHasBadge(), - tastingNoteCount, - dailyLifeCount, - followingCount, - followerCount, - isFollowing); + return memberProfileService.getMemberProfile(loginMember, memberId); } /** * ID로 회원 조회 */ @Transactional(readOnly = true) - @Cacheable(value = "memberById", key = "#memberId", unless = "#result == null") public Member findById(Long memberId) { - return memberJpaRepository.findById(memberId) - .orElseThrow(() -> new BaseException(ErrorCode.MEMBER_NOT_FOUND)); + return memberLookupService.findById(memberId); } /** * 이메일로 회원 조회 */ @Transactional(readOnly = true) - @Cacheable(value = "memberByEmail", key = "#email", unless = "#result == null") public Member getMemberByEmail(String email) { - return memberReader.getByEmail(email); + return memberLookupService.getMemberByEmail(email); } /** @@ -190,10 +82,7 @@ public Member getMemberByEmail(String email) { */ @Transactional(readOnly = true) public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListRequest request) { - Slice myDailyLifeList = dailyLifeReader.getAllMyDailyLives(member, - request.lastDailyLifeId(), request.pageSize()); - - return new MyDailyLifeListResponse(myDailyLifeList); + return memberContentService.loadMyDailyLifeList(member, request); } /** @@ -202,10 +91,7 @@ public MyDailyLifeListResponse loadMyDailyLifeList(Member member, DailyLifeListR @Transactional(readOnly = true) public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLifeListRequest request, Long memberId) { - Slice dailyLifeList = dailyLifeReader.getAllDailyLivesByMember(loginMember, memberId, - request.lastDailyLifeId(), request.pageSize()); - - return new DailyLifeListResponse(dailyLifeList); + return memberContentService.loadMemberDailyLifeList(loginMember, request, memberId); } /** @@ -213,10 +99,7 @@ public DailyLifeListResponse loadMemberDailyLifeList(Member loginMember, DailyLi */ @Transactional(readOnly = true) public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNoteListRequest request) { - Slice myTastingNoteList = tastingNoteReader.getAllMyTastingNotes(member, - request.lastTastingNoteId(), request.pageSize()); - - return new MyTastingNoteListResponse(myTastingNoteList); + return memberContentService.loadMyTastingNoteList(member, request); } /** @@ -225,10 +108,7 @@ public MyTastingNoteListResponse loadMyTastingNoteList(Member member, TastingNot @Transactional(readOnly = true) public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, TastingNoteListRequest request, Long memberId) { - Slice tastingNoteList = tastingNoteReader.getAllTastingNotesByMember(loginMember, memberId, - request.lastTastingNoteId(), request.pageSize()); - - return new TastingNoteListResponse(tastingNoteList); + return memberContentService.loadMemberTastingNoteList(loginMember, request, memberId); } /** @@ -238,20 +118,7 @@ public TastingNoteListResponse loadMemberTastingNoteList(Member loginMember, Tas */ @Transactional public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { - AlcoholicDrinks alcoholicDrinks = alcoholicDrinksReader.getById(alcoholicDrinksId); - Optional memberAlcoholicDrinks = memberAlcoholicDrinksReader - .findByMemberAndAlcoholicDrinks(member, alcoholicDrinks); - - // 전통주가 이미 저장되어 있다면 삭제, 저장되어 있지 않다면 등록 - return memberAlcoholicDrinks - .map(save -> { - memberAlcoholicDrinksWriter.delete(save); - return false; - }) - .orElseGet(() -> { - memberAlcoholicDrinksWriter.store(member, alcoholicDrinks); - return true; - }); + return memberContentService.saveAlcoholicDrinks(member, alcoholicDrinksId); } /** @@ -259,10 +126,6 @@ public boolean saveAlcoholicDrinks(Member member, Long alcoholicDrinksId) { */ @Transactional(readOnly = true) public MyAlcoholicDrinksListResponse loadMyAlcoholicDrinks(Member member, MyAlcoholicDrinksListRequest request) { - Slice alcoholicDrinksSummaries = alcoholicDrinksReader.getAllMyAlcoholicDrinks(member, - request.lastAlcoholicDrinksId(), request.pageSize()); - - return new MyAlcoholicDrinksListResponse(alcoholicDrinksSummaries); + return memberContentService.loadMyAlcoholicDrinks(member, request); } - } From 7fb8ed20c29142c41bb6fe8ba1312fb41433d7db Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 26 May 2025 18:56:21 +0900 Subject: [PATCH 3/7] Refactor authentication and token management system - Updated Redis configuration for enhanced token storage and management. - Removed deprecated classes and methods related to refresh token handling. - Consolidated authentication logic into a single AuthService for better maintainability. - Improved error handling for device ID mismatches and token reuse detection. - Enhanced security by implementing refresh token rotation and validation mechanisms. - Updated GlobalExceptionHandler to manage new error codes and exceptions effectively. - Refactored JwtTokenProvider to streamline token creation and hashing processes. - Improved CORS configuration and security settings in SecurityConfig for better API protection. --- .../aws-elasticache-redis-local-setup.md | 120 +++++---- docs/pr/PR-139-refactor---auth-api.md | 117 ++++----- .../com/juu/juulabel/JuulabelApplication.java | 2 + .../juulabel/auth/aop/RefreshTokenAspect.java | 120 --------- .../auth/aop/SetRefreshTokenCookie.java | 13 - .../juu/juulabel/auth/aop/tokenService.java | 5 - .../juulabel/auth/controller/AuthApiDocs.java | 93 +++++++ .../auth/controller/AuthController.java | 72 +++--- .../juulabel/auth/domain/RefreshToken.java | 64 ++--- .../LoginRefreshTokenScriptExecutor.java | 45 ++++ .../auth/executor/RedisScriptExecutor.java | 34 +++ .../auth/executor/RedisScriptName.java | 18 ++ .../RevokeRefreshTokenByIndexKeyExecutor.java | 38 +++ .../RotateRefreshTokenScriptExecutor.java | 64 +++++ .../SaveRefreshTokenScriptExecutor.java | 45 ++++ .../auth/executor/ScriptRegistry.java | 24 ++ .../RedisRefreshTokenRepository.java | 50 ++++ .../repository/RefreshTokenRepository.java | 33 +++ .../redis/CustomRefreshTokenRepository.java | 14 -- .../CustomRefreshTokenRepositoryImpl.java | 63 ----- .../redis/RefreshTokenRedisRepository.java | 11 - .../juulabel/auth/service/AuthService.java | 125 ++++++++++ .../auth/service/GeoIp2Exception.java | 5 - .../auth/service/MemberAuthService.java | 68 ----- .../juulabel/auth/service/OAuthService.java | 69 ------ .../RefreshTokenFraudDetectionService.java | 234 ------------------ .../juulabel/auth/service/TokenService.java | 125 ++++++++-- .../juulabel/common/config/RedisConfig.java | 15 ++ .../common/config/SecurityConfig.java | 156 +++++++----- .../juu/juulabel/common/config/WebConfig.java | 19 ++ .../common/constants/AuthConstants.java | 9 +- .../common/converter/ProviderConverter.java | 31 +++ .../exception/InvalidParamException.java | 1 - .../common/exception/code/ErrorCode.java | 12 +- .../handler/GlobalExceptionHandler.java | 20 +- .../common/filter/JwtAuthorizationFilter.java | 8 +- .../common/provider/JwtTokenProvider.java | 193 ++------------- .../common/util/AuthorizationExtractor.java | 27 ++ .../common/util/DeviceIdExtractor.java | 35 +++ .../juulabel/common/util/HttpRequestUtil.java | 67 ----- .../common/util/HttpResponseUtil.java | 21 +- .../common/util/IpAddressExtractor.java | 212 ++++++++++++++++ .../common/util/UserAgentExtractor.java | 27 ++ .../resources/scripts/login_refresh_token.lua | 44 ++++ .../revoke_refresh_token_by_index_key.lua | 15 ++ .../scripts/rotate_refresh_token.lua | 95 +++++++ .../resources/scripts/save_refresh_token.lua | 31 +++ 47 files changed, 1574 insertions(+), 1135 deletions(-) delete mode 100644 src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java delete mode 100644 src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java delete mode 100644 src/main/java/com/juu/juulabel/auth/aop/tokenService.java create mode 100644 src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java create mode 100644 src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java create mode 100644 src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java delete mode 100644 src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java create mode 100644 src/main/java/com/juu/juulabel/auth/service/AuthService.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/OAuthService.java delete mode 100644 src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java create mode 100644 src/main/java/com/juu/juulabel/common/config/WebConfig.java create mode 100644 src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java create mode 100644 src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java create mode 100644 src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java delete mode 100644 src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java create mode 100644 src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java create mode 100644 src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java create mode 100644 src/main/resources/scripts/login_refresh_token.lua create mode 100644 src/main/resources/scripts/revoke_refresh_token_by_index_key.lua create mode 100644 src/main/resources/scripts/rotate_refresh_token.lua create mode 100644 src/main/resources/scripts/save_refresh_token.lua diff --git a/docs/infra/aws-elasticache-redis-local-setup.md b/docs/infra/aws-elasticache-redis-local-setup.md index f640bb51..d96cac9a 100644 --- a/docs/infra/aws-elasticache-redis-local-setup.md +++ b/docs/infra/aws-elasticache-redis-local-setup.md @@ -1,58 +1,72 @@ -# Redis 로컬 개발 환경 연결 설정 가이드 (with AWS ElastiCache) +# Redis 로컬 개발 환경 접속 가이드 (with AWS ElastiCache) -> 운영 환경의 Redis(VPC 내부)와 로컬 개발 환경을 동일하게 연결하기 위한 구성 절차를 정리한 문서입니다. -> 비용 효율성과 실용성을 위해 별도의 Bastion Host 없이 EC2 백엔드 인스턴스를 포트 포워딩용 중계 노드로 활용합니다. +> 본 문서는 **VPC 내 ElastiCache Redis**에 대해, 로컬 개발 환경에서도 운영 환경과 동일한 방식으로 접근할 수 있도록 포트 포워딩 기반 개발 흐름을 정리한 가이드입니다. +> Bastion Host를 별도로 구성하지 않고, **기존 EC2 인스턴스를 SSM 포워딩 노드로 활용**합니다. --- -## 1. 기본 개념 및 요구 사항 +## ✅ 개요 -- **Redis 위치**: AWS ElastiCache for Redis (VPC 내부, 퍼블릭 액세스 불가) -- **로컬 개발 환경 연결 방식**: AWS Systems Manager(SSM)의 `PortForwardingSession` 사용 -- **전제 조건**: - - AWS CLI 설치 및 설정 완료 - - EC2 인스턴스에 SSM Agent 설치 및 IAM Role 연결 - - EC2와 Redis가 동일 VPC/Subnet 내에 존재 - - ElastiCache Redis의 보안 그룹에 EC2 인스턴스 허용 설정 +| 항목 | 내용 | +|-------------|-------------------------------------------------------| +| 대상 Redis | AWS ElastiCache for Redis (Private Subnet) | +| 접근 방식 | AWS Systems Manager - `PortForwardingSession` 사용 | +| 중계 노드 | 동일 VPC 내 EC2 인스턴스 (SSM Agent 연결 상태 필요) | --- -## 2. 설정 절차 +## 1. 요구 사항 -### 2.1 AWS CLI 구성 +### 1.1 사전 조건 -1. 인증 키 생성 후 `.csv` 파일 다운로드 (예: `EcPortForwarding_accessKeys.csv`) -2. AWS CLI에 프로파일 등록: +- AWS CLI 설치 및 `configure` 완료 +- EC2 인스턴스에 **SSM Agent 설치 + IAM Role 연결**되어 있어야 함 +- Redis와 EC2는 동일 VPC/Subnet 내 존재 +- Redis 보안 그룹에 EC2 인스턴스 허용 설정 + +--- + +## 2. 설정 단계 + +### 2.1 AWS CLI 인증 구성 ```bash -aws configure --profile [your-profile-name] +aws configure --profile dev-redis ``` -- `Access Key ID`, `Secret Access Key`, `Region` 입력 - -> 예시: -> ``` -> aws configure --profile dev-redis -> ``` +- Access Key, Secret, Region 입력 +- 사용 목적에 맞게 별도 프로파일 구성 권장 --- -### 2.2 EC2 포트 포워딩 세션 시작 +### 2.2 EC2 인스턴스를 통한 포트 포워딩 -1. EC2 인스턴스 ID 확인 (SSM 접속이 가능한 상태여야 함) -2. 아래 명령어로 Redis 포트(6379) 포워딩: +1. EC2 인스턴스 ID 확인 (`i-xxxxxxxxxxxxxxxxx`) +2. SSM 포트 포워딩 세션 실행: ```bash -aws ssm start-session --target i-0xxxxxxxxxxxxxxx --document-name AWS-StartPortForwardingSession --parameters '{"portNumber":["6379"],"localPortNumber":["6379"]}' --profile dev-redis +aws ssm start-session \ + --target i-xxxxxxxxxxxxxxxxx \ + --document-name AWS-StartPortForwardingSession \ + --parameters '{"portNumber":["6379"],"localPortNumber":["6379"]}' \ + --profile dev-redis ``` -> 🔁 위 명령어를 실행하면 **로컬의 `localhost:6379`** 로 접근 시, 해당 EC2 인스턴스 내부에서 Redis에 접속하는 것과 동일한 효과를 가집니다. +> 이 세션이 유지되는 동안 `localhost:6379`는 EC2 내부 Redis 포트에 직접 연결된 것과 동일하게 동작합니다. --- -## 3. Spring Boot 설정 (`application.yml`) +### 2.3 연결 확인 -로컬과 운영 환경 모두에서 동일하게 설정합니다: +```bash +valkey-cli --tls -h localhost -p 6379 ping +``` + +정상적으로 `PONG` 응답이 오면 연결 성공입니다. + +--- + +### 2.4 Spring Boot 환경 구성 예시 ```yaml spring: @@ -60,33 +74,49 @@ spring: redis: host: localhost port: 6379 + ssl: + enabled: true ``` -> 운영에서는 EC2 내부에서 Redis에 직접 접근 가능 -> 로컬에서는 포트 포워딩 세션을 통해 동일하게 동작 +- 운영/로컬 환경 모두 동일 구성 사용 +- 운영에서는 EC2 → Redis 직접 연결 +- 로컬에서는 포트포워딩 세션을 통해 동일 흐름 유지 --- -## 4. 자주 발생하는 문제 및 해결법 +## 3. Redis 연결 트러블슈팅 + +### 3.1 systemd 기반 socat 포워딩 관리 (옵션) + +```bash +sudo systemctl daemon-reexec +sudo systemctl daemon-reload +sudo systemctl enable socat-redis +sudo systemctl start socat-redis +sudo systemctl status socat-redis +``` -| 증상 | 원인 및 해결 방법 | -|------------------------------------------|------------------------------------------------------------------------------------| -| `Timeout` 또는 연결 불가 | SSM 세션이 끊어졌거나 Redis 보안 그룹이 EC2를 허용하지 않음 | -| 포트 포워딩 명령어 실행 시 에러 발생 | EC2 인스턴스에 SSM Agent 미설치, IAM Role 누락, 혹은 CLI 인증 프로파일 오류 | -| Redis 연결은 되나 데이터가 이상하게 보임 | Redis 클러스터 모드가 활성화된 경우, Lettuce 설정을 클러스터 모드로 변경 필요 | +- 서비스 로그 확인: + +```bash +journalctl -u socat-redis +``` --- -## 5. 유의 사항 및 권장 전략 +## 4. 자주 발생하는 이슈 -- Bastion Host 불필요 → 비용 및 인프라 단순화 -- `localhost:6379`을 고정하여 개발/운영 동일한 코드 사용 가능 -- 보안 강화를 위해 EC2에 최소 권한 IAM Role 부여 및 Redis 보안 그룹 제한 -- 필요시 HAProxy 도입으로 로컬 클러스터 라우팅 테스트도 가능 +| 증상 | 원인 및 해결 방안 | +|----------------------------------|------------------------------------------------------------------------------------| +| `Timeout` 또는 연결 안됨 | - SSM 세션이 종료되었거나
- Redis 보안 그룹에서 EC2 인바운드 허용 누락 | +| 포워딩 명령어 실행 시 오류 발생 | - EC2에 SSM Agent 미설치
- IAM Role에 `ssm:StartSession` 권한 미설정
- AWS CLI 인증 오류 | +| 데이터가 깨져 보임 | - Redis 클러스터 모드 사용 중
- Lettuce 클라이언트 설정을 클러스터 대응으로 변경 필요 | --- -## 6. 참고 자료 +## 📎 참고 자료 + +- [AWS Blog - Port Forwarding with SSM to ElastiCache Redis](https://aws.amazon.com/blogs/mt/aws-systems-manager-session-manager-port-forwarding-to-amazon-elasticache-redis-inside-private-subnet/) +- [PR #139](): 인증 전략 개선 및 Redis 기반 세션 관리 적용 상세 내역 -- [AWS 공식 블로그: Session Manager Port Forwarding to Redis](https://aws.amazon.com/blogs/mt/aws-systems-manager-session-manager-port-forwarding-to-amazon-elasticache-redis-inside-private-subnet/) -- [PR #139](링크): 인증 구조 리팩토링 및 Redis 도입 관련 변경 내역 \ No newline at end of file +--- \ No newline at end of file diff --git a/docs/pr/PR-139-refactor---auth-api.md b/docs/pr/PR-139-refactor---auth-api.md index 831c3e4f..e51870ba 100644 --- a/docs/pr/PR-139-refactor---auth-api.md +++ b/docs/pr/PR-139-refactor---auth-api.md @@ -1,95 +1,96 @@ -# Auth API 리팩터링 및 인증 전략 고도화 안내 (PR [#139]()) +# Auth API 리팩터링 및 인증 전략 고도화 (PR [#139]()) -## 1. 개요 +## 📌 Summary -이번 변경은 인증 도메인의 역할 분리를 명확히 하고, 보안 수준을 강화하기 위해 다음과 같은 개선 사항을 포함합니다. +이 PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다: -- 인증 관련 API를 `/v1/api/auth`로 별도 분리하여 도메인 책임을 명확히 분리 -- Refresh Token 기반 인증 구조 도입 및 Rotation 전략 적용 -- Redis 기반의 서버 측 Refresh Token 관리 및 블랙리스트 처리 -- 비정상 로그인 탐지 및 보안 로깅을 위한 클라이언트 정보 수집 로직 추가 - -이 변경은 향후 보안 알림 시스템 및 세션 이상 행위 탐지를 위한 기반 설계를 포함하고 있습니다. +- 인증 API 도메인의 **명확한 경계 설정** +- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보 +- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화 +- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계 --- -## 2. 주요 변경 사항 +## 1. 구조 리팩터링: 인증 도메인 책임 분리 -### 📁 2.1 API 경로 구조 리팩터링 +기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다: -기존 인증 관련 API가 `/members` 하위에 존재해 도메인 책임 구분이 불명확했습니다. 이를 `/auth`로 이동시켜 인증과 사용자 리소스를 명확히 구분했습니다. +| 기존 경로 | 신규 경로 | 목적 | +| ------------------------- | ---------------------- | ---------------------------- | +| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | +| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | | +| `/v1/api/members/me` | `/v1/api/auth/me` | | +| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 | +| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) | -| 변경 전 경로 | 변경 후 경로 | 변경 내용 | -|---------------------------|---------------------------|----------------------------| -| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 | -| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | 인증 도메인 분리 | -| `/v1/api/members/me` | `/v1/api/auth/me` | 인증 도메인 분리 | -| - | `/v1/api/auth/refresh` | Refresh Token 발급 추가 | -| - | `/v1/api/auth/logout` | 서버 측 로그아웃 로직 추가 | -| `/.../tasting_notes` | `/.../tasting-notes` | REST 관례에 맞춘 typo 수정 | +💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다. -신규 경로: +--- -- `/v1/api/auth/refresh`: Access Token 및 Refresh Token 재발급 -- `/v1/api/auth/logout`: 서버 측 세션 종료 및 토큰 무효화 처리 +## 2. Refresh Token 기반 인증 및 Rotation 전략 ---- +### Why Rotation? -### 🔄 2.2 Refresh Token 인증 전략 도입 +토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다. -- **Access Token 만료 시**, 클라이언트는 `/auth/refresh`를 통해 새로운 Access/Refresh Token을 발급받습니다. -- **Rotation 전략 적용**: 사용된 Refresh Token은 즉시 폐기(블랙리스트 처리)되며, 새로운 Refresh Token이 반환됩니다. -- 보안상의 이유로 클라이언트는 이전 Refresh Token으로 재요청 시 인증에 실패하게 됩니다. +### 동작 방식 ---- +- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답 +- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록 +- 동일 토큰 재사용 시 → 인증 실패 (401) -### 🔐 2.3 비정상 로그인 탐지를 위한 추가 메타데이터 수집 +💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화 -- 인증 관련 API `v1/api/auth/...` 요청 시, 다음 헤더의 전송이 **필수**입니다: +--- - - `Device-Id`: 클라이언트 단말기 고유 식별자 - - (백엔드에서는 UA, IP 등도 별도로 수집 및 보관) +## 3. 비정상 로그인 탐지 기반 확장 고려 -- 해당 정보는 **비정상 행위 탐지 로직 및 알림 시스템 설계**에 활용됩니다. +### 수집 항목 ---- +- `Device-Id` (필수 헤더) +- User-Agent, IP (서버 로그 자동 수집) -### 🚪 2.4 로그아웃 처리 로직 개선 +이 정보는 향후 다음 기능에 활용됩니다: -기존에는 클라이언트 측에서 Access Token만 제거하여 로그아웃 처리가 완료되었습니다. -이제는 Refresh Token 보안성을 고려하여 서버 측 `/auth/logout` 호출을 통해 다음 처리를 수행합니다: +- 동일 계정 다중 위치/디바이스 로그인 탐지 +- 의심 활동에 대한 보안 알림 트리거 +- 로그인 히스토리 시각화 -- Redis에 저장된 해당 Refresh Token을 블랙리스트 처리 -- 재사용 방지 및 세션 강제 만료 지원 +💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다. --- -## 3. Redis 기반 세션 토큰 관리 구조 +## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환 + +기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다. +새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행: -| 항목 | 내용 | -|------------------|----------------------------------------------| -| 인프라 구성 | AWS ElastiCache (Valkey) | -| 접근 방식 | VPC 내부 접근: HAProxy + SSM Port Forwarding | -| 저장소 라이브러리 | `spring-data-redis` | -| 운영 전략 | 토큰 TTL 기반 자동 만료 + 블랙리스트 수동 등록 | +- Redis에 등록된 Refresh Token을 블랙리스트화 +- 이후 해당 토큰 사용 시 인증 실패 + +💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화 --- -## 4. 보안 고려 사항 요약 +## 5. Redis 기반 토큰 관리 및 인프라 구성 + +| 항목 | 내용 | +| ----------- | ---------------------------------------------------- | +| 저장소 구성 | AWS ElastiCache (Valkey) | +| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 | +| 라이브러리 | `spring-data-redis (lettuce)` | +| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 | -- Refresh Token Rotation 전략 적용 → 사용된 토큰 즉시 폐기 -- Redis를 통한 토큰 상태 관리 (블랙리스트 기반 무효화 처리) -- 클라이언트 디바이스 식별자 수집 → 비정상 세션 탐지 기반 구축 -- 향후 보안 알림 시스템 및 계정 탈취 방지 기능 확장 가능성 확보 +💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능 --- -## 5. 적용 후 유의사항 +## 6. 적용 시 유의사항 -| 항목 | 설명 | -|-----------------------------------|------------------------------------------------------------| -| 인증 API 호출 시 헤더에 `Device-Id` 필수 포함 | 누락 시 400 에러 발생 | -| 로그아웃 시 `/auth/logout` 호출 필요 | 단순 쿠키 제거만으로는 세션이 만료되지 않음 | -| 기존 경로 `/members/*` 사용 중단 | 호출 시 404 또는 리다이렉션 응답 발생 가능, 조속한 반영 요망 | +| 항목 | 설명 | +| -------------------------------------------- | --------------------------------------------------------- | +| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 | +| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 | +| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 | --- diff --git a/src/main/java/com/juu/juulabel/JuulabelApplication.java b/src/main/java/com/juu/juulabel/JuulabelApplication.java index 5ae58127..49c3e8d7 100644 --- a/src/main/java/com/juu/juulabel/JuulabelApplication.java +++ b/src/main/java/com/juu/juulabel/JuulabelApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; @SpringBootApplication +@EnableRedisRepositories public class JuulabelApplication { public static void main(String[] args) { diff --git a/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java b/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java deleted file mode 100644 index 80651c3d..00000000 --- a/src/main/java/com/juu/juulabel/auth/aop/RefreshTokenAspect.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.juu.juulabel.auth.aop; - -import org.aspectj.lang.JoinPoint; -import org.aspectj.lang.annotation.AfterReturning; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.service.TokenService; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.response.CommonResponse; -import com.juu.juulabel.member.domain.Member; - -import java.util.Arrays; -import java.util.Optional; -import java.util.Map; -import java.util.function.Function; -import java.util.HashMap; - -@Aspect -@Component -public class RefreshTokenAspect { - private final TokenService tokenService; - private final SpelExpressionParser parser; - - private static final Map, Function> memberIdExtractors = new HashMap<>(); - - static { - memberIdExtractors.put(LoginResponse.class, result -> ((LoginResponse) result).oAuthUserInfo().memberId()); - memberIdExtractors.put(SignUpMemberResponse.class, result -> ((SignUpMemberResponse) result).memberId()); - } - - public RefreshTokenAspect(TokenService tokenService) { - this.tokenService = tokenService; - this.parser = new SpelExpressionParser(); - } - - @AfterReturning(pointcut = "@annotation(setRefreshTokenCookie)", returning = "responseEntity") - public void setRefreshTokenCookie(JoinPoint joinPoint, - SetRefreshTokenCookie setRefreshTokenCookie, - ResponseEntity> responseEntity) { - - boolean isNewSession = setRefreshTokenCookie.isNewSession(); - String parentTokenId = setRefreshTokenCookie.parentTokenId(); - - Optional memberId = extractMemberId(isNewSession, joinPoint, responseEntity); - - if (memberId.isEmpty()) { - if (!isNewSession) { - // 토큰 리프레시 멤버 추출 실패 - throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); - } - // 비회원 유저가 로그인 했을 때 쿠키 설정 안함 - return; - } - - String refreshToken = extractRefreshTokenCookie(joinPoint, parentTokenId); - tokenService.saveAndSetCookie(memberId.get(), refreshToken); - } - - private String extractRefreshTokenCookie(JoinPoint joinPoint, String parentTokenId) { - if (parentTokenId.isEmpty()) { - return null; - } - - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - StandardEvaluationContext context = new StandardEvaluationContext(); - - // Bind method arguments - String[] paramNames = signature.getParameterNames(); - Object[] args = joinPoint.getArgs(); - for (int i = 0; i < args.length; i++) { - context.setVariable(paramNames[i], args[i]); - } - - Object evaluated = parser.parseExpression(parentTokenId).getValue(context); - if (!(evaluated instanceof String)) { - throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); - } - return (String) evaluated; - } - - private Optional extractMemberId(boolean isNewSession, JoinPoint joinPoint, - ResponseEntity> responseEntity) { - // For existing sessions, extract from Member object - if (!isNewSession) { - return findFirstArgOfType(joinPoint, Member.class) - .map(Member::getId); - } - - // For new sessions, extract from response body - CommonResponse body = responseEntity.getBody(); - if (body == null || body.result() == null) { - throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - Object result = body.result(); - Class resultClass = result.getClass(); - - Function extractor = memberIdExtractors.get(resultClass); - if (extractor != null) { - return Optional.of(extractor.apply(result)); - } - - throw new BaseException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - private Optional findFirstArgOfType(JoinPoint joinPoint, Class clazz) { - return Arrays.stream(joinPoint.getArgs()) - .filter(clazz::isInstance) - .map(clazz::cast) - .findFirst(); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java b/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java deleted file mode 100644 index 1343bf03..00000000 --- a/src/main/java/com/juu/juulabel/auth/aop/SetRefreshTokenCookie.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.juu.juulabel.auth.aop; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface SetRefreshTokenCookie { - String parentTokenId() default ""; - boolean isNewSession() default true; -} diff --git a/src/main/java/com/juu/juulabel/auth/aop/tokenService.java b/src/main/java/com/juu/juulabel/auth/aop/tokenService.java deleted file mode 100644 index 4c035ade..00000000 --- a/src/main/java/com/juu/juulabel/auth/aop/tokenService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.juu.juulabel.auth.aop; - -public class tokenService { - -} diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java new file mode 100644 index 00000000..c661b540 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthApiDocs.java @@ -0,0 +1,93 @@ +package com.juu.juulabel.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.dto.response.RefreshResponse; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.Provider; + +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Schema; + +@Tag(name = "인증 API", description = "로그인, 회원가입, 회원탈퇴, 토큰 관리 등 인증 관련 API") +@RequestMapping("/v1/api/auth") +public interface AuthApiDocs { + + @Operation(summary = "OAuth 소셜 로그인", description = "지원되는 OAuth 제공자(Google, Kakao)를 통한 로그인") + @ApiResponse(responseCode = "200", description = "로그인 성공", headers = { + @Header(name = "Set-Cookie", description = "계정이 존재할시만 리프레시 토큰 발급", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + @ApiResponse(responseCode = "401", description = "인증 실패") + @PostMapping("/login/{provider}") + public ResponseEntity> oauthLogin( + @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, + @Valid @RequestBody OAuthLoginRequest requestBody); + + @Operation(summary = "회원가입", description = "새로운 회원 등록 및 초기 토큰 발급") + @ApiResponse(responseCode = "200", description = "회원가입 성공", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 발급", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "400", description = "유효성 검사 실패, 중복된 이메일 또는 닉네임") + @PostMapping("/sign-up") + public ResponseEntity> signUp( + @Valid @RequestBody SignUpMemberRequest request); + + @Operation(summary = "액세스 토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰 발급") + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 갱신", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "403", description = "토큰 재사용 감지", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) + }) + @PostMapping("/refresh") + public ResponseEntity> refresh( + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, + @AuthenticationPrincipal Member member); + + @Operation(summary = "로그아웃", description = "현재 디바이스의 리프레시 토큰 무효화") + @ApiResponse(responseCode = "200", description = "로그아웃 성공", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") + @PostMapping("/logout") + public ResponseEntity> logout( + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, + @AuthenticationPrincipal Member member); + + @Operation(summary = "회원 탈퇴", description = "회원 계정 삭제 및 모든 토큰 무효화") + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공", headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰 즉시 삭제", schema = @Schema(type = "string")) + }) + @ApiResponse(responseCode = "400", description = "잘못된 탈퇴 요청") + @ApiResponse(responseCode = "401", description = "인증되지 않은 요청") + @DeleteMapping("/me") + public ResponseEntity> deleteAccount( + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, + @AuthenticationPrincipal Member member, + @Valid @RequestBody WithdrawalRequest request); + +} diff --git a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java index 5978df66..2bb03848 100644 --- a/src/main/java/com/juu/juulabel/auth/controller/AuthController.java +++ b/src/main/java/com/juu/juulabel/auth/controller/AuthController.java @@ -1,9 +1,7 @@ package com.juu.juulabel.auth.controller; -import com.juu.juulabel.auth.aop.SetRefreshTokenCookie; -import com.juu.juulabel.auth.service.MemberAuthService; -import com.juu.juulabel.auth.service.OAuthService; -import com.juu.juulabel.auth.service.TokenService; +import com.juu.juulabel.auth.service.AuthService; +import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.dto.request.OAuthLoginRequest; import com.juu.juulabel.common.dto.request.SignUpMemberRequest; import com.juu.juulabel.common.dto.request.WithdrawalRequest; @@ -15,75 +13,67 @@ import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.Provider; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter; + import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "인증 API", description = "로그인, 회원가입, 토큰 관리 등 인증 관련 API") @RestController -@RequestMapping(value = { "/v1/api/auth" }) @RequiredArgsConstructor -public class AuthController { +public class AuthController implements AuthApiDocs { - private final TokenService tokenService; - private final OAuthService oAuthService; - private final MemberAuthService memberAuthService; + private final AuthService authService; - @Operation(summary = "OAuth 로그인 (소셜 로그인)") - @PostMapping("/login/{provider}") - @SetRefreshTokenCookie + @Override public ResponseEntity> oauthLogin( - @PathVariable String provider, + @Parameter(description = "OAuth 제공자 (GOOGLE, KAKAO)", required = true) @PathVariable Provider provider, @Valid @RequestBody OAuthLoginRequest requestBody) { - // 경로에서 제공자 정보를 파싱하여 새 요청 객체를 생성 - OAuthLoginRequest request = new OAuthLoginRequest( - requestBody.code(), - requestBody.redirectUri(), - Provider.valueOf(provider.toUpperCase())); + LoginResponse loginResponse = authService.login(requestBody); - LoginResponse loginResponse = oAuthService.login(request); return CommonResponse.success(SuccessCode.SUCCESS, loginResponse); } - @Operation(summary = "회원가입") - @PostMapping("/sign-up") - @SetRefreshTokenCookie + @Override public ResponseEntity> signUp( @Valid @RequestBody SignUpMemberRequest request) { - SignUpMemberResponse signUpMemberResponse = memberAuthService.signUp(request); + + SignUpMemberResponse signUpMemberResponse = authService.signUp(request); + return CommonResponse.success(SuccessCode.SUCCESS, signUpMemberResponse); } - @Operation(summary = "액세스 토큰 갱신") - @PostMapping("/refresh") - @SetRefreshTokenCookie(parentTokenId = "#refreshToken", isNewSession = false) + @Override public ResponseEntity> refresh( - @AuthenticationPrincipal Member member, - @CookieValue(value = "refreshToken", required = true) String refreshToken) { - return CommonResponse.success(SuccessCode.SUCCESS, tokenService.refresh(refreshToken)); + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, + @AuthenticationPrincipal Member member) { + + RefreshResponse refreshResponse = authService.refresh(refreshToken); + + return CommonResponse.success(SuccessCode.SUCCESS, refreshResponse); } - @Operation(summary = "로그아웃") - @PostMapping("/logout") + @Override public ResponseEntity> logout( - @CookieValue(value = "refreshToken", required = true) String refreshToken, + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member) { - tokenService.logout(refreshToken, member.getId()); + + authService.logout(refreshToken); + return CommonResponse.success(SuccessCode.SUCCESS); } - @Operation(summary = "회원 탈퇴") - @DeleteMapping("/me") + @Override public ResponseEntity> deleteAccount( + @Parameter(description = "리프레시 토큰 (쿠키)", required = true) @CookieValue(value = AuthConstants.REFRESH_TOKEN_HEADER_NAME, required = true) String refreshToken, @AuthenticationPrincipal Member member, - @RequestBody WithdrawalRequest request) { - memberAuthService.deleteAccount(member, request); + @Valid @RequestBody WithdrawalRequest request) { + + authService.deleteAccount(member, request, refreshToken); + return CommonResponse.success(SuccessCode.SUCCESS_DELETE); } - } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java index 7cf1d35f..55f423cd 100644 --- a/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java +++ b/src/main/java/com/juu/juulabel/auth/domain/RefreshToken.java @@ -1,80 +1,62 @@ package com.juu.juulabel.auth.domain; import lombok.*; -import org.springframework.data.annotation.Id; -import org.springframework.data.redis.core.RedisHash; -import org.springframework.data.redis.core.TimeToLive; -import org.springframework.data.redis.core.index.Indexed; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_TTL_IN_SECONDS; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HASH_PREFIX; +import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_INDEX_PREFIX; import java.io.Serializable; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Instant; -import java.util.Base64; -import java.util.concurrent.TimeUnit; + +import java.util.List; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) -@RedisHash("RefreshToken") // Acts like a key prefix public class RefreshToken implements Serializable { - @Id + private String token; + private String hashedToken; - @Indexed private Long memberId; - @Indexed private String deviceId; private ClientId clientId; - private Instant revokedAt; - - private Instant issuedAt; - - // Metadata - // • ipAddress: Current request IP address - // • userAgent: Current request user agent - // • ttl: Time to live - private String ipAddress; private String userAgent; - @TimeToLive(unit = TimeUnit.SECONDS) private Long ttl; + private boolean revoked; + @Builder - public RefreshToken(String token, Long memberId, ClientId clientId, String deviceId, String ipAddress, - String userAgent) { - this.hashedToken = hashToken(token); + public RefreshToken(String token, String hashedToken, Long memberId, ClientId clientId, String deviceId, + String ipAddress, String userAgent) { + this.token = token; + this.hashedToken = hashedToken; this.memberId = memberId; this.clientId = clientId; this.deviceId = deviceId; this.ipAddress = ipAddress; this.userAgent = userAgent; - this.revokedAt = null; - this.issuedAt = Instant.now(); - this.ttl = REFRESH_TOKEN_TTL_IN_SECONDS; + this.ttl = REFRESH_TOKEN_DURATION.getSeconds(); + this.revoked = false; + } + + public String getTokenKey() { + return REFRESH_TOKEN_HASH_PREFIX + ":" + hashedToken; } - public void setRevoked(Instant revokedAt) { - this.revokedAt = revokedAt; + public String getIndexKey() { + return REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId; } - private String hashToken(String token) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashedBytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } + public List getArgs() { + return List.of(memberId.toString(), clientId.toString(), deviceId, ipAddress, userAgent, ttl.toString()); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java new file mode 100644 index 00000000..25c43b79 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/LoginRefreshTokenScriptExecutor.java @@ -0,0 +1,45 @@ +package com.juu.juulabel.auth.executor; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.domain.RefreshToken; + +@Component +public class LoginRefreshTokenScriptExecutor implements RedisScriptExecutor { + + private final RedisTemplate redisTemplate; + private final RedisScript redisScript; + + public LoginRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { + this.redisTemplate = redisTemplate; + String scriptText = Files.readString( + new ClassPathResource("scripts/login_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); + this.redisScript = RedisScript.of(scriptText, Object.class); + } + + @Override + public Object execute(RefreshToken refreshToken, Object... args) { + String newTokenKey = refreshToken.getTokenKey(); + String indexKey = refreshToken.getIndexKey(); + + List keys = List.of(newTokenKey, indexKey); + List arguments = refreshToken.getArgs(); + + try { + return redisTemplate.execute(redisScript, keys, arguments.toArray()); + } catch (RedisSystemException e) { + handleRedisException(e); + throw e; // This line will never be reached due to the exception thrown above + } + } + +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java new file mode 100644 index 00000000..8bb5673a --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/RedisScriptExecutor.java @@ -0,0 +1,34 @@ +package com.juu.juulabel.auth.executor; + +import org.springframework.data.redis.RedisSystemException; + +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.HttpResponseUtil; + +import io.lettuce.core.RedisCommandExecutionException; + +public interface RedisScriptExecutor { + T execute(R arg, Object... args); + + default void handleRedisException(RedisSystemException e) { + // Check if the cause is a RedisCommandExecutionException + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof RedisCommandExecutionException) { + handleRedisScriptError(cause.getMessage()); + return; + } + cause = cause.getCause(); + } + + // If no RedisCommandExecutionException found, check the main exception message + handleRedisScriptError(e.getMessage()); + } + + default void handleRedisScriptError(String errorMessage) { + HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); + throw new BaseException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java b/src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java new file mode 100644 index 00000000..be473390 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/RedisScriptName.java @@ -0,0 +1,18 @@ +package com.juu.juulabel.auth.executor; + +public enum RedisScriptName { + ROTATE_REFRESH_TOKEN("RotateRefreshTokenScriptExecutor"), + LOGIN_REFRESH_TOKEN("LoginRefreshTokenScriptExecutor"), + SAVE_REFRESH_TOKEN("SaveRefreshTokenScriptExecutor"), + REVOKE_REFRESH_TOKEN_BY_INDEX_KEY("RevokeRefreshTokenByIndexKeyExecutor"); + + private final String executorName; + + RedisScriptName(String name) { + this.executorName = name; + } + + public String getExecutorName() { + return executorName; + } +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java new file mode 100644 index 00000000..f7ef25ed --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/RevokeRefreshTokenByIndexKeyExecutor.java @@ -0,0 +1,38 @@ +package com.juu.juulabel.auth.executor; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +@Component +public class RevokeRefreshTokenByIndexKeyExecutor implements RedisScriptExecutor { + + private final RedisTemplate redisTemplate; + private final RedisScript redisScript; + + public RevokeRefreshTokenByIndexKeyExecutor(RedisTemplate redisTemplate) throws IOException { + this.redisTemplate = redisTemplate; + String scriptText = Files.readString( + new ClassPathResource("scripts/revoke_refresh_token_by_index_key.lua").getFile().toPath(), + StandardCharsets.UTF_8); + this.redisScript = RedisScript.of(scriptText, Object.class); + } + + @Override + public Object execute(String indexKey, Object... args) { + try { + return redisTemplate.execute(redisScript, List.of(indexKey)); + } catch (RedisSystemException e) { + handleRedisException(e); + throw e; // This line will never be reached due to the exception thrown above + } + } + +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java new file mode 100644 index 00000000..0c83b421 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/RotateRefreshTokenScriptExecutor.java @@ -0,0 +1,64 @@ +package com.juu.juulabel.auth.executor; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.util.HttpResponseUtil; + +@Component +public class RotateRefreshTokenScriptExecutor implements RedisScriptExecutor { + + private final RedisTemplate redisTemplate; + private final RedisScript redisScript; + + public RotateRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { + this.redisTemplate = redisTemplate; + String scriptText = Files.readString( + new ClassPathResource("scripts/rotate_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); + this.redisScript = RedisScript.of(scriptText, Object.class); + } + + @Override + public Object execute(RefreshToken refreshToken, Object... args) { + String newTokenKey = refreshToken.getTokenKey(); + String indexKey = refreshToken.getIndexKey(); + String oldTokenKey = args[0].toString(); + + List keys = Arrays.asList(newTokenKey, indexKey, oldTokenKey); + List arguments = refreshToken.getArgs(); + + try { + return redisTemplate.execute(redisScript, keys, arguments.toArray()); + } catch (RedisSystemException e) { + handleRedisException(e); + throw e; // This line will never be reached due to the exception thrown above + } + } + + @Override + public void handleRedisScriptError(String errorMessage) { + HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); + if (errorMessage.contains("OLD_TOKEN_NOT_FOUND")) { + throw new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND); + } else if (errorMessage.contains("OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED")) { + throw new BaseException(ErrorCode.REFRESH_TOKEN_REUSE_DETECTED); + } else if (errorMessage.contains("DEVICE_ID_MISMATCH")) { + throw new BaseException(ErrorCode.DEVICE_ID_MISMATCH); + } else { + throw new BaseException(errorMessage, ErrorCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java new file mode 100644 index 00000000..0ed0e2e2 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/SaveRefreshTokenScriptExecutor.java @@ -0,0 +1,45 @@ +package com.juu.juulabel.auth.executor; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import com.juu.juulabel.auth.domain.RefreshToken; + +@Component +public class SaveRefreshTokenScriptExecutor implements RedisScriptExecutor { + + private final RedisTemplate redisTemplate; + private final RedisScript redisScript; + + public SaveRefreshTokenScriptExecutor(RedisTemplate redisTemplate) throws IOException { + this.redisTemplate = redisTemplate; + String scriptText = Files.readString( + new ClassPathResource("scripts/save_refresh_token.lua").getFile().toPath(), StandardCharsets.UTF_8); + this.redisScript = RedisScript.of(scriptText, Object.class); + } + + @Override + public Object execute(RefreshToken refreshToken, Object... args) { + String newTokenKey = refreshToken.getTokenKey(); + String indexKey = refreshToken.getIndexKey(); + + List keys = Arrays.asList(newTokenKey, indexKey); + List arguments = refreshToken.getArgs(); + + try { + return redisTemplate.execute(redisScript, keys, arguments.toArray()); + } catch (RedisSystemException e) { + handleRedisException(e); + throw e; // This line will never be reached due to the exception thrown above + } + } +} diff --git a/src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java b/src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java new file mode 100644 index 00000000..b300067d --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/executor/ScriptRegistry.java @@ -0,0 +1,24 @@ +package com.juu.juulabel.auth.executor; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +@Component +public class ScriptRegistry { + + private final Map> scripts; + + public ScriptRegistry(List> executors) { + this.scripts = executors.stream() + .collect(Collectors.toMap(e -> e.getClass().getSimpleName(), Function.identity())); + } + + @SuppressWarnings("unchecked") + public RedisScriptExecutor get(RedisScriptName name) { + return (RedisScriptExecutor) scripts.get(name.getExecutorName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java new file mode 100644 index 00000000..6e55cbc5 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/RedisRefreshTokenRepository.java @@ -0,0 +1,50 @@ +package com.juu.juulabel.auth.repository; + +import com.juu.juulabel.auth.domain.ClientId; +import com.juu.juulabel.auth.domain.RefreshToken; +import com.juu.juulabel.auth.executor.RedisScriptName; +import com.juu.juulabel.auth.executor.ScriptRegistry; +import com.juu.juulabel.common.constants.AuthConstants; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RedisRefreshTokenRepository implements RefreshTokenRepository { + + private final ScriptRegistry scriptRegistry; + + @Override + public void save(RefreshToken refreshToken) { + scriptRegistry.get(RedisScriptName.SAVE_REFRESH_TOKEN).execute(refreshToken); + } + + @Override + public void rotate(RefreshToken refreshToken, String hashedOldToken) { + + String oldTokenKey = AuthConstants.REFRESH_TOKEN_HASH_PREFIX + ":" + hashedOldToken; + + scriptRegistry.get(RedisScriptName.ROTATE_REFRESH_TOKEN).execute(refreshToken, oldTokenKey); + } + + @Override + public void login(RefreshToken refreshToken) { + scriptRegistry.get(RedisScriptName.LOGIN_REFRESH_TOKEN).execute(refreshToken); + } + + @Override + public void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId) { + String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":" + clientId + ":" + deviceId + + ":*"; + + scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); + } + + @Override + public void revokeAllByMember(Long memberId) { + String indexKey = AuthConstants.REFRESH_TOKEN_INDEX_PREFIX + ":" + memberId + ":*"; + + scriptRegistry.get(RedisScriptName.REVOKE_REFRESH_TOKEN_BY_INDEX_KEY).execute(indexKey); + } + +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..4dbe50e4 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,33 @@ +package com.juu.juulabel.auth.repository; + +import com.juu.juulabel.auth.domain.ClientId; +import com.juu.juulabel.auth.domain.RefreshToken; + +public interface RefreshTokenRepository { + + /** + * Saves a refresh token + */ + void save(RefreshToken refreshToken); + + /** + * Rotate + */ + void rotate(RefreshToken refreshToken, String hashedOldToken); + + /** + * Login + */ + void login(RefreshToken refreshToken); + + /** + * Revokes all refresh tokens for a member and device + */ + void revokeByMemberAndDevice(Long memberId, ClientId clientId, String deviceId); + + /** + * Revokes all refresh tokens for a member + */ + void revokeAllByMember(Long memberId); + +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java deleted file mode 100644 index a6291156..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.juu.juulabel.auth.repository.redis; - -import java.util.Optional; - -import com.juu.juulabel.auth.domain.RefreshToken; - -public interface CustomRefreshTokenRepository { - - void revokeByMemberId(Long memberId); - - void revokeByDeviceId(Long memberId, String deviceId); - - Optional findByTokenHash(String tokenHash); -} diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java b/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java deleted file mode 100644 index 81564305..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/redis/CustomRefreshTokenRepositoryImpl.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.juu.juulabel.auth.repository.redis; - -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.RedisCallback; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ScanOptions; -import org.springframework.stereotype.Component; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HASH_PREFIX; - -import lombok.RequiredArgsConstructor; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -@Component -@RequiredArgsConstructor -public class CustomRefreshTokenRepositoryImpl implements CustomRefreshTokenRepository { - - private final RedisTemplate redisTemplate; - - @Override - public void revokeByMemberId(Long memberId) { - revokeByPattern(REFRESH_TOKEN_HASH_PREFIX + ":" + memberId + ":*"); - } - - @Override - public void revokeByDeviceId(Long memberId, String deviceId) { - revokeByPattern(REFRESH_TOKEN_HASH_PREFIX + ":" + memberId + ":" + deviceId + ":*"); - } - - @Override - public Optional findByTokenHash(String tokenHash) { - return Optional.ofNullable(redisTemplate.opsForValue().get(tokenHash)) - .map(RefreshToken.class::cast); - } - - private void revokeByPattern(String pattern) { - ScanOptions options = ScanOptions.scanOptions() - .match(pattern) - .count(1000) - .build(); - - redisTemplate.execute((RedisCallback) connection -> { - try (Cursor cursor = connection.keyCommands().scan(options)) { - List keys = new ArrayList<>(); - while (cursor.hasNext()) { - keys.add(new String(cursor.next())); - } - if (!keys.isEmpty()) { - redisTemplate.delete(keys); - } - } catch (Exception e) { - throw new BaseException(ErrorCode.REFRESH_TOKEN_INVALID); - } - return null; - }); - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java b/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java deleted file mode 100644 index a7799e9c..00000000 --- a/src/main/java/com/juu/juulabel/auth/repository/redis/RefreshTokenRedisRepository.java +++ /dev/null @@ -1,11 +0,0 @@ - -package com.juu.juulabel.auth.repository.redis; - -import org.springframework.data.repository.CrudRepository; - -import com.juu.juulabel.auth.domain.RefreshToken; - -public interface RefreshTokenRedisRepository - extends CrudRepository, CustomRefreshTokenRepository { - -} diff --git a/src/main/java/com/juu/juulabel/auth/service/AuthService.java b/src/main/java/com/juu/juulabel/auth/service/AuthService.java new file mode 100644 index 00000000..e230fb21 --- /dev/null +++ b/src/main/java/com/juu/juulabel/auth/service/AuthService.java @@ -0,0 +1,125 @@ +package com.juu.juulabel.auth.service; + +import com.juu.juulabel.common.dto.request.SignUpMemberRequest; +import com.juu.juulabel.common.dto.request.WithdrawalRequest; +import com.juu.juulabel.common.dto.response.LoginResponse; +import com.juu.juulabel.common.dto.response.RefreshResponse; +import com.juu.juulabel.common.dto.response.SignUpMemberResponse; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.common.factory.OAuthProviderFactory; +import com.juu.juulabel.member.domain.Member; +import com.juu.juulabel.member.domain.WithdrawalRecord; +import com.juu.juulabel.member.repository.MemberReader; +import com.juu.juulabel.member.repository.MemberWriter; +import com.juu.juulabel.member.repository.WithdrawalRecordReader; +import com.juu.juulabel.member.repository.WithdrawalRecordWriter; +import com.juu.juulabel.member.request.OAuthLoginInfo; +import com.juu.juulabel.member.token.Token; +import com.juu.juulabel.member.util.MemberUtils; +import lombok.RequiredArgsConstructor; +import com.juu.juulabel.member.domain.Provider; +import com.juu.juulabel.common.constants.AuthConstants; +import com.juu.juulabel.member.request.OAuthUser; +import com.juu.juulabel.member.request.OAuthUserInfo; +import com.juu.juulabel.common.dto.request.OAuthLoginRequest; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + private final MemberReader memberReader; + private final MemberWriter memberWriter; + private final WithdrawalRecordWriter withdrawalRecordWriter; + private final MemberUtils memberUtils; + private final OAuthProviderFactory providerFactory; + private final WithdrawalRecordReader withdrawalRecordReader; + private final TokenService tokenService; + + @Transactional + public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { + OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); + Provider provider = authLoginInfo.provider(); + + String accessToken = providerFactory.getAccessToken( + provider, + authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), + authLoginInfo.propertyMap().get(AuthConstants.CODE)); + + OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); + String email = oAuthUser.email(); + + validateNotWithdrawnMember(email); + + boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); + Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); + + Optional token = tokenService.createAccessToken(memberOpt); + + // Create refresh token for existing members + memberOpt.ifPresent(member -> tokenService.createLoginRefreshToken(member)); + + return new LoginResponse( + token.orElse(new Token(null, null)), + isNewMember, + new OAuthUserInfo( + memberOpt.map(Member::getId).orElse(null), + email, + oAuthUser.id(), + provider)); + } + + @Transactional + public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { + validateSignUpRequest(signUpRequest); + + Member member = Member.create(signUpRequest); + memberWriter.store(member); + + memberUtils.processAlcoholTypes(member, signUpRequest); + memberUtils.processTermsAgreements(member, signUpRequest); + + Token token = tokenService.createTokenPair(member); + + return new SignUpMemberResponse(member.getId(), token); + } + + @Transactional + public RefreshResponse refresh(String oldToken) { + Token newToken = tokenService.rotateRefreshToken(oldToken); + return new RefreshResponse(newToken.accessToken()); + } + + public void logout(String oldToken) { + tokenService.revokeRefreshToken(oldToken); + } + + @Transactional + public void deleteAccount(Member loginMember, WithdrawalRequest request, String oldToken) { + loginMember.deleteAccount(); + withdrawalRecordWriter.store( + WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); + + tokenService.revokeAllRefreshTokens(oldToken); + } + + private void validateNotWithdrawnMember(String email) { + if (withdrawalRecordReader.existEmail(email)) { + throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); + } + } + + private void validateSignUpRequest(SignUpMemberRequest signUpRequest) { + if (memberReader.existActiveNickname(signUpRequest.nickname())) { + throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); + } + + if (memberReader.existActiveEmail(signUpRequest.email())) { + throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java b/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java deleted file mode 100644 index 6d5dbc3e..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/GeoIp2Exception.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.juu.juulabel.auth.service; - -public class GeoIp2Exception { - -} diff --git a/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java b/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java deleted file mode 100644 index d8cf2fde..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/MemberAuthService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.juu.juulabel.auth.service; - -import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; -import com.juu.juulabel.common.dto.request.SignUpMemberRequest; -import com.juu.juulabel.common.dto.request.WithdrawalRequest; -import com.juu.juulabel.common.dto.response.SignUpMemberResponse; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.provider.JwtTokenProvider; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.WithdrawalRecord; -import com.juu.juulabel.member.repository.MemberReader; -import com.juu.juulabel.member.repository.MemberWriter; -import com.juu.juulabel.member.repository.WithdrawalRecordWriter; -import com.juu.juulabel.member.token.Token; -import com.juu.juulabel.member.util.MemberUtils; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class MemberAuthService { - private final MemberReader memberReader; - private final MemberWriter memberWriter; - private final WithdrawalRecordWriter withdrawalRecordWriter; - private final JwtTokenProvider jwtTokenProvider; - private final MemberUtils memberUtils; - private final RefreshTokenRedisRepository refreshTokenRedisRepository; - - @Transactional - public SignUpMemberResponse signUp(SignUpMemberRequest signUpRequest) { - validateNickname(signUpRequest.nickname()); - validateEmail(signUpRequest.email()); - - Member member = Member.create(signUpRequest); - memberWriter.store(member); - - memberUtils.processAlcoholTypes(member, signUpRequest); - memberUtils.processTermsAgreements(member, signUpRequest); - - String token = jwtTokenProvider.createAccessToken(member); - - return new SignUpMemberResponse( - member.getId(), - new Token(token, jwtTokenProvider.getExpirationByToken(token))); - } - - @Transactional - public void deleteAccount(Member loginMember, WithdrawalRequest request) { - loginMember.deleteAccount(); - withdrawalRecordWriter.store( - WithdrawalRecord.create(request.withdrawalReason(), loginMember.getEmail(), loginMember.getNickname())); - refreshTokenRedisRepository.revokeByMemberId(loginMember.getId()); - } - - private void validateNickname(String nickname) { - if (memberReader.existActiveNickname(nickname)) { - throw new InvalidParamException(ErrorCode.MEMBER_NICKNAME_DUPLICATE); - } - } - - private void validateEmail(String email) { - if (memberReader.existActiveEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_EMAIL_DUPLICATE); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/OAuthService.java b/src/main/java/com/juu/juulabel/auth/service/OAuthService.java deleted file mode 100644 index 814a5cca..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/OAuthService.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.juu.juulabel.auth.service; - -import com.juu.juulabel.common.constants.AuthConstants; -import com.juu.juulabel.common.dto.request.OAuthLoginRequest; -import com.juu.juulabel.common.dto.response.LoginResponse; -import com.juu.juulabel.common.exception.InvalidParamException; -import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.factory.OAuthProviderFactory; -import com.juu.juulabel.common.provider.JwtTokenProvider; -import com.juu.juulabel.member.domain.Member; -import com.juu.juulabel.member.domain.Provider; -import com.juu.juulabel.member.repository.MemberReader; -import com.juu.juulabel.member.repository.WithdrawalRecordReader; -import com.juu.juulabel.member.request.OAuthLoginInfo; -import com.juu.juulabel.member.request.OAuthUser; -import com.juu.juulabel.member.request.OAuthUserInfo; -import com.juu.juulabel.member.token.Token; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class OAuthService { - private final OAuthProviderFactory providerFactory; - private final JwtTokenProvider jwtTokenProvider; - private final MemberReader memberReader; - private final WithdrawalRecordReader withdrawalRecordReader; - - @Transactional - public LoginResponse login(OAuthLoginRequest oAuthLoginRequest) { - OAuthLoginInfo authLoginInfo = oAuthLoginRequest.toDto(); - Provider provider = authLoginInfo.provider(); - - String accessToken = providerFactory.getAccessToken( - provider, - authLoginInfo.propertyMap().get(AuthConstants.REDIRECT_URI), - authLoginInfo.propertyMap().get(AuthConstants.CODE)); - - OAuthUser oAuthUser = providerFactory.getOAuthUser(provider, accessToken); - String email = oAuthUser.email(); - validateNotWithdrawnMember(email); - - boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider); - Optional memberOpt = isNewMember ? Optional.empty() : Optional.of(memberReader.getByEmail(email)); - - Token token = memberOpt.map(member -> { - String generatedToken = jwtTokenProvider.createAccessToken(member); - return new Token(generatedToken, jwtTokenProvider.getExpirationByToken(generatedToken)); - }).orElse(new Token(null, null)); - - return new LoginResponse( - token, - isNewMember, - new OAuthUserInfo( - memberOpt.map(Member::getId).orElse(null), - email, - oAuthUser.id(), - provider)); - } - - private void validateNotWithdrawnMember(String email) { - if (withdrawalRecordReader.existEmail(email)) { - throw new InvalidParamException(ErrorCode.MEMBER_WITHDRAWN); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java b/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java deleted file mode 100644 index 19552ec9..00000000 --- a/src/main/java/com/juu/juulabel/auth/service/RefreshTokenFraudDetectionService.java +++ /dev/null @@ -1,234 +0,0 @@ -package com.juu.juulabel.auth.service; - -import java.io.File; -import java.io.IOException; -import java.net.InetAddress; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import com.juu.juulabel.auth.domain.RefreshToken; -import com.maxmind.geoip2.DatabaseReader; -import com.maxmind.geoip2.exception.AddressNotFoundException; -import com.maxmind.geoip2.exception.GeoIp2Exception; -import com.maxmind.geoip2.model.CityResponse; - -import jakarta.annotation.PostConstruct; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -public class RefreshTokenFraudDetectionService implements FraudDetectionService { - - private static final double IP_CHANGE_WEIGHT = 0.4; - private static final double UA_CHANGE_WEIGHT = 0.3; - private static final double DEVICE_ID_MISMATCH_WEIGHT = 0.7; - private static final double UNUSUAL_ACCESS_TIME_WEIGHT = 0.2; - private static final double VELOCITY_CHANGE_WEIGHT = 0.35; - private static final double SUSPICIOUS_UA_WEIGHT = 0.45; - private static final double TOR_EXIT_NODE_WEIGHT = 0.6; - - private static final int SUSPICIOUS_LOGIN_DISTANCE_KM = 500; - private static final int IMPOSSIBLE_TRAVEL_DISTANCE_KM = 1000; - private static final Duration IMPOSSIBLE_TRAVEL_TIME = Duration.ofHours(3); - - private static final Set KNOWN_TOR_EXIT_NODES = new HashSet<>(); - private static final Set KNOWN_MALICIOUS_IPS = new HashSet<>(); - private static final Set SUSPICIOUS_USER_AGENT_PATTERNS = new HashSet<>(); - - @Value("${geoip.database.path:classpath:GeoLite2-City.mmdb}") - private String geoipDatabasePath; - - private DatabaseReader databaseReader; - - @PostConstruct - public void initialize() { - try { - // Initialize the GeoIP database reader - File database = new File(geoipDatabasePath.replace("classpath:", "")); - databaseReader = new DatabaseReader.Builder(database).build(); - - // Initialize known Tor exit nodes (would be updated regularly in production) - KNOWN_TOR_EXIT_NODES.add("176.10.99.200"); - KNOWN_TOR_EXIT_NODES.add("185.220.101.21"); - // More Tor exit nodes would be added here or fetched from an API - - // Initialize known malicious IPs (would be updated regularly in production) - KNOWN_MALICIOUS_IPS.add("103.91.181.5"); - KNOWN_MALICIOUS_IPS.add("45.95.168.112"); - // More malicious IPs would be added here or fetched from a threat intelligence - // service - - // Initialize suspicious user agent patterns - SUSPICIOUS_USER_AGENT_PATTERNS.add("nikto"); - SUSPICIOUS_USER_AGENT_PATTERNS.add("sqlmap"); - SUSPICIOUS_USER_AGENT_PATTERNS.add("vulnerability"); - SUSPICIOUS_USER_AGENT_PATTERNS.add("masscan"); - SUSPICIOUS_USER_AGENT_PATTERNS.add("nmap"); - // More patterns would be added here - - } catch (IOException e) { - log.error("Failed to initialize GeoIP database: {}", e.getMessage()); - } - } - - @Override - public RiskAssessment assessRisk(RefreshToken token, String currentIpAddress, String currentUserAgent, - String currentDeviceId) { - - double currentScore = 0.0; - StringBuilder reasons = new StringBuilder(); - boolean immediateFamilyCompromise = false; - - // Rule 1: IP Address Change - if (token.getIpAddress() != null && - !token.getIpAddress().equals(currentIpAddress)) { - // More sophisticated: check IP geolocation, ASN, known proxy, Tor exit node - if (!areIpAddressesGeographicallyClose(token.getIpAddress(), currentIpAddress)) { - currentScore += IP_CHANGE_WEIGHT; - reasons.append("Significant IP geolocation change. "); - } - } - - // Rule 2: User-Agent Change - if (token.getUserAgent() != null && - !token.getUserAgent().equals(currentUserAgent)) { - currentScore += UA_CHANGE_WEIGHT; - reasons.append("User-Agent changed. "); - } - - // Rule 3: Device ID Mismatch - if (token.getDeviceId() != null && currentDeviceId != null && - !token.getDeviceId().equals(currentDeviceId)) { - currentScore += DEVICE_ID_MISMATCH_WEIGHT; - reasons.append("Device ID mismatch. "); - immediateFamilyCompromise = true; // Device ID mismatch is often a strong indicator - } else if (token.getDeviceId() != null && currentDeviceId == null) { - currentScore += DEVICE_ID_MISMATCH_WEIGHT * 0.5; // Device ID disappeared - reasons.append("Device ID removed. "); - } else if (token.getDeviceId() == null && currentDeviceId != null) { - // New device ID added, could be a new legitimate device, lower weight or needs - // context - reasons.append("New Device ID added. "); - } - - // Rule 4: Check for Tor Exit Nodes - if (KNOWN_TOR_EXIT_NODES.contains(currentIpAddress)) { - currentScore += TOR_EXIT_NODE_WEIGHT; - reasons.append("Connection from known Tor exit node. "); - immediateFamilyCompromise = true; - } - - // Rule 5: Check for Known Malicious IPs - if (KNOWN_MALICIOUS_IPS.contains(currentIpAddress)) { - currentScore += 0.8; // Very high risk - reasons.append("Connection from known malicious IP. "); - immediateFamilyCompromise = true; - } - - // Rule 6: Check for Suspicious User Agents - if (currentUserAgent != null) { - for (String pattern : SUSPICIOUS_USER_AGENT_PATTERNS) { - if (currentUserAgent.toLowerCase().contains(pattern)) { - currentScore += SUSPICIOUS_UA_WEIGHT; - reasons.append("Suspicious User-Agent pattern detected. "); - break; - } - } - } - - // Rule 7: Velocity Check (impossible travel) - if (token.getIssuedAt() != null && token.getIpAddress() != null && - !token.getIpAddress().equals(currentIpAddress)) { - - LocalDateTime issuedAt = token.getIssuedAt(); - LocalDateTime now = LocalDateTime.now(); - Duration timeBetweenLogins = Duration.between(issuedAt, now); - - double distance = calculateDistanceBetweenIps(data.getIpAddress(), currentIpAddress); - - // If distance is very large and time between logins is short, flag as - // impossible travel - if (distance > IMPOSSIBLE_TRAVEL_DISTANCE_KM && timeBetweenLogins.compareTo(IMPOSSIBLE_TRAVEL_TIME) < 0) { - currentScore += VELOCITY_CHANGE_WEIGHT; - reasons.append("Impossible travel detected. "); - immediateFamilyCompromise = true; - } - } - - return new RiskAssessment(Math.min(currentScore, 1.0), reasons.toString(), immediateFamilyCompromise); - } - - private boolean areIpAddressesGeographicallyClose(String ip1, String ip2) { - if (ip1.equals(ip2)) { - return true; - } - - // Check for internal/private IP addresses - if (isPrivateIpAddress(ip1) || isPrivateIpAddress(ip2)) { - return true; - } - - double distance = calculateDistanceBetweenIps(ip1, ip2); - - // Consider IPs close if they are within a reasonable distance (e.g., 500km) - return distance < SUSPICIOUS_LOGIN_DISTANCE_KM; - } - - private boolean isPrivateIpAddress(String ip) { - return ip.startsWith("192.168.") || ip.startsWith("10.") || - ip.startsWith("172.16.") || ip.startsWith("172.17.") || - ip.startsWith("172.18.") || ip.startsWith("172.19.") || - ip.startsWith("172.20.") || ip.startsWith("172.21.") || - ip.startsWith("172.22.") || ip.startsWith("172.23.") || - ip.startsWith("172.24.") || ip.startsWith("172.25.") || - ip.startsWith("172.26.") || ip.startsWith("172.27.") || - ip.startsWith("172.28.") || ip.startsWith("172.29.") || - ip.startsWith("172.30.") || ip.startsWith("172.31.") || - ip.equals("127.0.0.1") || ip.equals("::1") || - ip.equals("localhost"); - } - - private double calculateDistanceBetweenIps(String ip1, String ip2) { - try { - // Get locations from MaxMind GeoIP database - CityResponse location1 = databaseReader.city(InetAddress.getByName(ip1)); - CityResponse location2 = databaseReader.city(InetAddress.getByName(ip2)); - - // Get latitude and longitude from responses - double lat1 = location1.getLocation().getLatitude(); - double lon1 = location1.getLocation().getLongitude(); - double lat2 = location2.getLocation().getLatitude(); - double lon2 = location2.getLocation().getLongitude(); - - // Calculate distance using Haversine formula - return calculateHaversineDistance(lat1, lon1, lat2, lon2); - - } catch (IOException | GeoIp2Exception e) { - log.warn("Error calculating distance between IPs: {}", e.getMessage()); - // If we can't determine distance, assume they're not close for safety - return Double.MAX_VALUE; - } - } - - // Haversine formula to calculate distance between two points on Earth - private double calculateHaversineDistance(double lat1, double lon1, double lat2, double lon2) { - // Radius of Earth in kilometers - final double R = 6371.0; - - double dLat = Math.toRadians(lat2 - lat1); - double dLon = Math.toRadians(lon2 - lon1); - - double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - - double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - - return R * c; // Distance in kilometers - } -} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/auth/service/TokenService.java b/src/main/java/com/juu/juulabel/auth/service/TokenService.java index 5f4cf1bd..88c01451 100644 --- a/src/main/java/com/juu/juulabel/auth/service/TokenService.java +++ b/src/main/java/com/juu/juulabel/auth/service/TokenService.java @@ -1,60 +1,131 @@ package com.juu.juulabel.auth.service; +import com.juu.juulabel.auth.domain.ClientId; import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; -import com.juu.juulabel.common.dto.response.RefreshResponse; -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.auth.repository.RefreshTokenRepository; +import com.juu.juulabel.common.constants.AuthConstants; import com.juu.juulabel.common.provider.JwtTokenProvider; +import com.juu.juulabel.common.util.DeviceIdExtractor; +import com.juu.juulabel.common.util.IpAddressExtractor; +import com.juu.juulabel.common.util.UserAgentExtractor; +import com.juu.juulabel.common.util.HttpResponseUtil; import com.juu.juulabel.member.domain.Member; - +import com.juu.juulabel.member.token.Token; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.Instant; +import java.util.Optional; @Slf4j @Service @RequiredArgsConstructor public class TokenService { + private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenRedisRepository refreshTokenRedisRepository; + private final RefreshTokenRepository refreshTokenRepository; + + /** + * Creates access and refresh tokens for a member + */ + @Transactional + public Token createTokenPair(Member member) { + String accessToken = jwtTokenProvider.createAccessToken(member); + RefreshToken refreshToken = createRefreshToken(member); + + refreshTokenRepository.save(refreshToken); + setRefreshTokenCookie(refreshToken.getToken()); + + return new Token(accessToken, jwtTokenProvider.getExpirationByToken(accessToken)); + } + + /** + * Creates access token only (for existing members during login) + */ + public Optional createAccessToken(Optional memberOpt) { + return memberOpt.map(member -> { + String accessToken = jwtTokenProvider.createAccessToken(member); + return new Token(accessToken, jwtTokenProvider.getExpirationByToken(accessToken)); + }); + } + + /** + * Creates refresh token for login (revokes existing tokens for same device) + */ + @Transactional + public void createLoginRefreshToken(Member member) { + + RefreshToken refreshToken = createRefreshToken(member); + refreshTokenRepository.login(refreshToken); + setRefreshTokenCookie(refreshToken.getToken()); + } + + /** + * Rotates refresh token + */ @Transactional - public RefreshResponse refresh(String refreshTokenCookie) { - Member member = jwtTokenProvider.getMemberFromToken(refreshTokenCookie); - RefreshToken oldToken = validateAndGetOldToken(refreshTokenCookie); + public Token rotateRefreshToken(String oldToken) { + Member member = jwtTokenProvider.getMemberFromToken(oldToken); + String hashedOldToken = jwtTokenProvider.hashToken(oldToken); + + RefreshToken newRefreshToken = createRefreshToken(member); - jwtTokenProvider.rotateRefreshToken(oldToken); + refreshTokenRepository.rotate(newRefreshToken, hashedOldToken); - return new RefreshResponse(jwtTokenProvider.createAccessToken(member)); + setRefreshTokenCookie(newRefreshToken.getToken()); + + String newAccessToken = jwtTokenProvider.createAccessToken(member); + return new Token(newAccessToken, jwtTokenProvider.getExpirationByToken(newAccessToken)); } + /** + * Revokes refresh token (logout) + */ @Transactional - public void logout(String refreshTokenCookie, Long memberId) { + public void revokeRefreshToken(String token) { + Member member = jwtTokenProvider.getMemberFromToken(token); + String deviceId = DeviceIdExtractor.getDeviceId(); - refreshTokenRedisRepository.findByTokenHash(refreshTokenCookie) - .ifPresent(token -> { - token.setRevoked(Instant.now()); - refreshTokenRedisRepository.save(token); - }); + refreshTokenRepository.revokeByMemberAndDevice(member.getId(), ClientId.WEB, deviceId); + clearRefreshTokenCookie(); } + /** + * Revokes all refresh tokens for a member (account deletion) + */ @Transactional - public void saveAndSetCookie(Long memberId, String parentTokenId) { - RefreshToken newToken = jwtTokenProvider.createRefreshToken(memberId, parentTokenId); + public void revokeAllRefreshTokens(String token) { + Member member = jwtTokenProvider.getMemberFromToken(token); + + refreshTokenRepository.revokeAllByMember(member.getId()); + clearRefreshTokenCookie(); + } - RefreshToken oldToken = validateAndGetOldToken(parentTokenId); - oldToken.setRevoked(Instant.now()); + private RefreshToken createRefreshToken(Member member) { + String token = jwtTokenProvider.createRefreshToken(member); + String hashedToken = jwtTokenProvider.hashToken(token); + + return RefreshToken.builder() + .token(token) + .hashedToken(hashedToken) + .memberId(member.getId()) + .clientId(ClientId.WEB) + .deviceId(DeviceIdExtractor.getDeviceId()) + .ipAddress(IpAddressExtractor.getClientIpAddress()) + .userAgent(UserAgentExtractor.getUserAgent()) + .build(); + } - refreshTokenRedisRepository.save(newToken); - refreshTokenRedisRepository.save(oldToken); + private void setRefreshTokenCookie(String token) { + HttpResponseUtil.addCookie( + AuthConstants.REFRESH_TOKEN_HEADER_NAME, + token, + (int) AuthConstants.REFRESH_TOKEN_DURATION.getSeconds()); } - private RefreshToken validateAndGetOldToken(String tokenStr) { - return refreshTokenRedisRepository.findByTokenHash(tokenStr) - .orElseThrow(() -> new BaseException(ErrorCode.REFRESH_TOKEN_NOT_FOUND)); + private void clearRefreshTokenCookie() { + HttpResponseUtil.addCookie(AuthConstants.REFRESH_TOKEN_HEADER_NAME, "", 0); } } \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/config/RedisConfig.java b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java index 365ab81d..e4231250 100644 --- a/src/main/java/com/juu/juulabel/common/config/RedisConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java @@ -3,6 +3,9 @@ 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.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -10,6 +13,18 @@ @Configuration public class RedisConfig { + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .useSsl() + .disablePeerVerification() // trust any certificate (disable hostname check) + .build(); + + return new LettuceConnectionFactory(config, clientConfig); + } + @Bean RedisTemplate redisTemplate(RedisConnectionFactory factory) { RedisTemplate template = new RedisTemplate<>(); diff --git a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java index 273ca8e4..389e8691 100644 --- a/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/SecurityConfig.java @@ -25,67 +25,99 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthorizationFilter jwtAuthenticationFilter; - private final JwtExceptionFilter jwtExceptionFilter; - - private static final String[] PERMIT_PATHS = { - "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", - "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", - "/v1/api/auth/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", - "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow", "/**", "/v1/api/reports" - }; - - private static final String[] ALLOW_ORIGINS = { - "http://localhost:8084", - "http://localhost:8080", - "http://localhost:5173", - "http://localhost:3000", - "https://api.juulabel.com", - "https://dev.juulabel.com", - "https://qa.juulabel.com", - "https://juulabel.com", - "https://juulabel.shop", - "https://juulabel-front.vercel.app/", - "https://juulabel-front-seven.vercel.app/", - "https://d3jwyw9rpnxu8p.cloudfront.net" - }; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - return http - .csrf(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .formLogin(AbstractHttpConfigurer::disable) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/v1/api/members/logout").authenticated() - .requestMatchers(OPTIONS, "**").permitAll() - .requestMatchers(PERMIT_PATHS).permitAll() - .requestMatchers("/v1/api/admins/permission/test").hasAnyAuthority(MemberRole.ROLE_ADMIN.name()) - .anyRequest().authenticated()) - - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) - - .build(); - } - - @Bean - public UrlBasedCorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - config.addAllowedHeader("*"); - config.addAllowedMethod("*"); - config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE")); - config.setAllowedOrigins(List.of(ALLOW_ORIGINS)); - config.addExposedHeader(HttpHeaders.AUTHORIZATION); - config.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } + private final JwtAuthorizationFilter jwtAuthenticationFilter; + private final JwtExceptionFilter jwtExceptionFilter; + // Public endpoints that don't require authentication + private static final String[] PUBLIC_ENDPOINTS = { + "/swagger-ui/**", "/v3/api-docs/**", "/error", "/favicon.ico", "/", "/actuator/**", + "/v1/api/alcohols/**", "/v1/api/terms/**", "/v1/api/images", + "/v1/api/auth/**", "/v1/api/shared-space/tasting-notes/**", "/v1/api/notifications/**", + "/v1/api/daily-lives/**", "/v1/api/alcoholicDrinks/**", "v1/api/follow", "/**", + "/v1/api/reports" + }; + + // Admin-only endpoints + private static final String[] ADMIN_ENDPOINTS = { + "/v1/api/admins/permission/test" + }; + + // Allowed origins for CORS + private static final String[] ALLOWED_ORIGINS = { + "http://localhost:8084", + "http://localhost:8080", + "http://localhost:5173", + "http://localhost:3000", + "https://api.juulabel.com", + "https://dev.juulabel.com", + "https://qa.juulabel.com", + "https://juulabel.com", + "https://juulabel.shop", + "https://juulabel-front.vercel.app/", + "https://juulabel-front-seven.vercel.app/", + "https://d3jwyw9rpnxu8p.cloudfront.net" + }; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + // Disable unnecessary features for stateless API + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // Configure headers + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + + // Configure CORS + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // Configure authorization rules + .authorizeHttpRequests(this::configureAuthorization) + + // Add custom filters + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class) + + .build(); + } + + private void configureAuthorization( + org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorize) { + authorize + // Allow OPTIONS requests for CORS preflight + .requestMatchers(OPTIONS, "**").permitAll() + + // Public endpoints + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + + // Admin endpoints + .requestMatchers(ADMIN_ENDPOINTS).hasAuthority(MemberRole.ROLE_ADMIN.name()) + + // Specific authenticated endpoints + .requestMatchers("/v1/api/members/logout").authenticated() + + // All other requests require authentication + .anyRequest().authenticated(); + } + + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // Configure CORS settings + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedOrigins(List.of(ALLOWED_ORIGINS)); + config.addExposedHeader(HttpHeaders.AUTHORIZATION); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/src/main/java/com/juu/juulabel/common/config/WebConfig.java b/src/main/java/com/juu/juulabel/common/config/WebConfig.java new file mode 100644 index 00000000..49a23b71 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.juu.juulabel.common.config; + +import com.juu.juulabel.common.converter.ProviderConverter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final ProviderConverter providerConverter; + + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter(providerConverter); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java index f59c9ef6..bda251ab 100644 --- a/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java +++ b/src/main/java/com/juu/juulabel/common/constants/AuthConstants.java @@ -12,15 +12,12 @@ public class AuthConstants { public static final String REDIRECT_URI = "redirectUri"; public static final String TOKEN_PREFIX = "Bearer "; + // The RFC 6648 (published in 2012) deprecated the X- prefix for custom headers: + public static final String REFRESH_TOKEN_HEADER_NAME = "Refresh-Token"; public static final String REFRESH_TOKEN_HASH_PREFIX = "RefreshToken"; + public static final String REFRESH_TOKEN_INDEX_PREFIX = "RefreshIndex"; public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1); public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(30); - // The RFC 6648 (published in 2012) deprecated the X- prefix for custom headers: - public static final String REFRESH_TOKEN_HEADER_NAME = "Refresh-Token"; - public static final String DEVICE_ID_HEADER_NAME = "Device-Id"; - - public static final Long REFRESH_TOKEN_TTL_IN_SECONDS = REFRESH_TOKEN_DURATION.getSeconds() + Duration.ofDays(15).getSeconds(); - } diff --git a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java new file mode 100644 index 00000000..515f5219 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java @@ -0,0 +1,31 @@ +package com.juu.juulabel.common.converter; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.InvalidParamException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import com.juu.juulabel.member.domain.Provider; + +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class ProviderConverter implements Converter { + + private static final Set ALLOWED_PROVIDERS = Set.of(Provider.GOOGLE, Provider.KAKAO); + + @Override + public Provider convert(String source) { + try { + final Provider provider = Provider.valueOf(source.toUpperCase()); + if (!ALLOWED_PROVIDERS.contains(provider)) { + throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + } + return provider; + } catch (ConversionFailedException | InvalidParamException | IllegalArgumentException e) { + throw new BaseException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/exception/InvalidParamException.java b/src/main/java/com/juu/juulabel/common/exception/InvalidParamException.java index 7305e7fe..277de5d7 100644 --- a/src/main/java/com/juu/juulabel/common/exception/InvalidParamException.java +++ b/src/main/java/com/juu/juulabel/common/exception/InvalidParamException.java @@ -1,6 +1,5 @@ package com.juu.juulabel.common.exception; - import com.juu.juulabel.common.exception.code.ErrorCode; public class InvalidParamException extends BaseException { diff --git a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java index 0a39e270..982e9344 100644 --- a/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java +++ b/src/main/java/com/juu/juulabel/common/exception/code/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { IS_NULL(HttpStatus.BAD_REQUEST, "NULL 값이 들어왔습니다."), COMMON_INVALID_PARAM(HttpStatus.BAD_REQUEST, "요청한 값이 올바르지 않습니다."), INVALID_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "인증이 올바르지 않습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."), /** * Json Web Token @@ -28,18 +29,18 @@ public enum ErrorCode { JWT_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), JWT_UNSUPPORTED_EXCEPTION(HttpStatus.BAD_REQUEST, "지원되지 않는 토큰입니다."), JWT_MALFORMED_EXCEPTION(HttpStatus.UNAUTHORIZED, "잘못된 형식의 토큰입니다."), - JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), + JWT_ILLEGAL_ARGUMENT_EXCEPTION(HttpStatus.BAD_REQUEST, "잘못된 인자가 전달되었습니다."), /** * Authentication */ DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "Device-Id 헤더가 필요합니다."), - + OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "Provider를 찾을 수 없습니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "토큰을 찾을 수 없습니다."), + DEVICE_ID_MISMATCH(HttpStatus.BAD_REQUEST, "Device-Id 불일치"), REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), - REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "토큰 재사용 감지"), - REFRESH_TOKEN_ALREADY_ROTATED(HttpStatus.UNAUTHORIZED, "이미 회전된 토큰입니다."), + REFRESH_TOKEN_REUSE_DETECTED(HttpStatus.FORBIDDEN, "토큰 재사용 감지"), /** * Admin, Member @@ -64,13 +65,11 @@ public enum ErrorCode { ALCOHOLIC_DRINKS_TYPE_NOT_FOUND(HttpStatus.BAD_REQUEST, "전통주를 찾을 수 없습니다."), ALCOHOLIC_DRINKS_INVALID_RATING(HttpStatus.BAD_REQUEST, "잘못된 평점입니다. 평점은 0.00에서 5.00 사이여야 합니다."), - /** * Notification */ NOTIFICATION_NOT_FOUND(HttpStatus.BAD_REQUEST, "해당 알림이 존재하지 않거나, 권한이 없습니다."), - /** * Comment */ @@ -91,7 +90,6 @@ public enum ErrorCode { EXCEEDED_FILE_COUNT(HttpStatus.BAD_REQUEST, "파일 첨부 허용 개수를 초과했습니다."), FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "파일 크기가 10MB를 초과했습니다."), - /** * Tasting Note */ diff --git a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java index 0c607a0c..a8befaea 100644 --- a/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/juu/juulabel/common/exception/handler/GlobalExceptionHandler.java @@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Arrays; @@ -49,8 +50,9 @@ public ResponseEntity> handle(CustomJwtException e) { } @ExceptionHandler(NoResourceFoundException.class) - public void handle(NoResourceFoundException e) { + public ResponseEntity> handle(NoResourceFoundException e) { log.warn("NoResourceFoundException : {}", e.getMessage()); + return CommonResponse.fail(ErrorCode.NOT_FOUND); } @ExceptionHandler(HttpMessageNotReadableException.class) @@ -69,4 +71,20 @@ public ResponseEntity> handleValidationException(HttpMess } return CommonResponse.fail(ErrorCode.VALIDATION_ERROR, errorDetails); } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handle(MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException :", e); + + // Check if the underlying cause is a BaseException + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof BaseException baseException) { + return CommonResponse.fail(baseException.getErrorCode()); + } + cause = cause.getCause(); + } + + return CommonResponse.fail(ErrorCode.VALIDATION_ERROR, e.getMessage()); + } } diff --git a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java index df92b0b1..ab5ba481 100644 --- a/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java +++ b/src/main/java/com/juu/juulabel/common/filter/JwtAuthorizationFilter.java @@ -3,6 +3,8 @@ import com.juu.juulabel.common.provider.JwtTokenProvider; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.common.response.CommonResponse; +import com.juu.juulabel.common.util.AuthorizationExtractor; + import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.FilterChain; @@ -17,21 +19,19 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import com.juu.juulabel.common.util.HttpRequestUtil; - import java.io.IOException; @Component @RequiredArgsConstructor public class JwtAuthorizationFilter extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenProvider jwtTokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String header = HttpRequestUtil.getAuthorization(request); + String header = AuthorizationExtractor.getAuthorization(); if (header != null) { String token = jwtTokenProvider.resolveToken(header); try { diff --git a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java index 082c4d1c..efdccfb5 100644 --- a/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java +++ b/src/main/java/com/juu/juulabel/common/provider/JwtTokenProvider.java @@ -1,13 +1,8 @@ package com.juu.juulabel.common.provider; -import com.juu.juulabel.auth.domain.ClientId; -import com.juu.juulabel.auth.domain.RefreshToken; -import com.juu.juulabel.auth.repository.redis.RefreshTokenRedisRepository; import com.juu.juulabel.common.exception.CustomJwtException; import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; -import com.juu.juulabel.common.util.HttpRequestUtil; -import com.juu.juulabel.common.util.HttpResponseUtil; import com.juu.juulabel.member.domain.Member; import com.juu.juulabel.member.domain.MemberRole; @@ -20,87 +15,48 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import static com.juu.juulabel.common.constants.AuthConstants.ACCESS_TOKEN_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_DURATION; -import static com.juu.juulabel.common.constants.AuthConstants.REFRESH_TOKEN_HEADER_NAME; -import static com.juu.juulabel.common.constants.AuthConstants.TOKEN_PREFIX; +import static com.juu.juulabel.common.constants.AuthConstants.*; import javax.crypto.SecretKey; - import java.time.Duration; import java.util.*; import java.util.function.Function; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + @Component public class JwtTokenProvider { - private static final String ISSUER = "juulabel"; private static final String ROLE_CLAIM = "role"; private final SecretKey key; private final JwtParser jwtParser; - private final RefreshTokenRedisRepository refreshTokenRedisRepository; - public JwtTokenProvider(@Value("${spring.jwt.secret}") String key, - RefreshTokenRedisRepository refreshTokenRedisRepository) { + public JwtTokenProvider(@Value("${spring.jwt.secret}") String key) { this.key = Keys.hmacShaKeyFor(Base64.getDecoder().decode(key)); this.jwtParser = Jwts.parser().verifyWith(this.key).build(); - this.refreshTokenRedisRepository = refreshTokenRedisRepository; } - /** - * Creates an access token for a member - * - * @param member The member for whom to create the token - * @return The JWT access token string - */ public String createAccessToken(Member member) { return buildToken(member.getId(), member.getRole().name(), ACCESS_TOKEN_DURATION); } - /** - * Creates a refresh token entity for a member - * - * @param member The member for whom to create the token - * @return A RefreshToken entity - */ - public RefreshToken createRefreshToken(Long memberId, String parentTokenId) { - String ipAddress = HttpRequestUtil.getClientIpAddress(); - String userAgent = HttpRequestUtil.getUserAgent(); - String deviceId = HttpRequestUtil.getDeviceId(); - - String token = buildToken(memberId, null, REFRESH_TOKEN_DURATION); - - HttpResponseUtil.addCookie(REFRESH_TOKEN_HEADER_NAME, token, - (int) REFRESH_TOKEN_DURATION.getSeconds()); - - return RefreshToken.builder() - .token(token) - .memberId(memberId) - .clientId(ClientId.WEB) - .deviceId(deviceId) - .ipAddress(ipAddress) - .userAgent(userAgent) - .build(); + public String createRefreshToken(Member member) { + return buildToken(member.getId(), member.getRole().name(), REFRESH_TOKEN_DURATION); } - /** - * Builds a JWT token - * - * @param memberId The member ID - * @param role The role (can be null) - * @param duration The duration - * @return The JWT token string - */ private String buildToken(Long memberId, String role, Duration duration) { - Date expirationDate = new Date(System.currentTimeMillis() + duration.toMillis()); + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + duration.toMillis()); + JwtBuilder builder = Jwts.builder() .subject(String.valueOf(memberId)) - .issuedAt(new Date()) + .issuedAt(now) .issuer(ISSUER) .expiration(expirationDate) .signWith(key); @@ -112,12 +68,6 @@ private String buildToken(Long memberId, String role, Duration duration) { return builder.compact(); } - /** - * Gets authentication from an access token - * - * @param accessToken The access token - * @return The Authentication object - */ public Authentication getAuthentication(String accessToken) { return extractFromClaims(accessToken, claims -> { String role = claims.get(ROLE_CLAIM, String.class); @@ -135,12 +85,6 @@ public Authentication getAuthentication(String accessToken) { }); } - /** - * Extracts member information from a token - * - * @param token The JWT token - * @return The Member object - */ public Member getMemberFromToken(String token) { return extractFromClaims(token, claims -> { Long memberId = Long.parseLong(claims.getSubject()); @@ -153,25 +97,13 @@ public Member getMemberFromToken(String token) { }); } - /** - * Resolves a token from the Authorization header - * - * @param header The Authorization header - * @return The token without the prefix - */ public String resolveToken(String header) { - if (header == null) { + if (!StringUtils.hasText(header)) { throw new InvalidParamException(ErrorCode.INVALID_AUTHENTICATION); } return header.replace(TOKEN_PREFIX, ""); } - /** - * Validates a token - * - * @param token The JWT token - * @return true if the token is valid, false otherwise - */ public boolean isValidateToken(String token) { if (!StringUtils.hasText(token)) { return false; @@ -183,35 +115,14 @@ public boolean isValidateToken(String token) { } } - /** - * Gets the expiration date from a token - * - * @param token The JWT token - * @return The expiration date - */ public Date getExpirationByToken(String token) { return extractFromClaims(token, Claims::getExpiration); } - /** - * Extracts data from claims using a function - * - * @param token The JWT token - * @param claimsResolver The function to extract data from claims - * @return The extracted data - */ private T extractFromClaims(String token, Function claimsResolver) { - Claims claims = parseClaims(token); - return claimsResolver.apply(claims); + return claimsResolver.apply(parseClaims(token)); } - /** - * Parses claims from a token - * - * @param token The JWT token - * @return The claims - * @throws CustomJwtException if the token is invalid - */ private Claims parseClaims(String token) { try { return jwtParser.parseSignedClaims(token).getPayload(); @@ -226,73 +137,13 @@ private Claims parseClaims(String token) { } } - /** - * Validates a refresh token - * - * @param token The refresh token - * @param ipAddress Current request IP address - * @param userAgent Current request user agent - * @return true if valid, throws exception otherwise - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void rotateRefreshToken(RefreshToken token) { - String ipAddress = HttpRequestUtil.getClientIpAddress(); - String userAgent = HttpRequestUtil.getUserAgent(); - String deviceId = HttpRequestUtil.getDeviceId(); - - Long memberId = token.getMemberId(); - - // Case 1: Device ID doesn’t match the previous token - // • Revoke the entire chain of the previous token (current + descendants). - // • This blocks access from the stolen session while allowing the user to - // continue on the new device. - // • Keep the new device’s session active if validated correctly (e.g., fresh - // login or MFA). - - if (!token.getDeviceId().equals(deviceId)) { - refreshTokenRedisRepository.revokeByDeviceId(memberId, deviceId); - throw new CustomJwtException( - String.format( - "Device ID mismatch: Device ID=%s, Current Token Device ID=%s", - deviceId, token.getDeviceId()), - ErrorCode.REFRESH_TOKEN_INVALID); - } - - // Case 2: Token is reused/revoked - // • Revoke the entire token from the member. - // • This blocks access from the stolen session while allowing the user to - // continue on the new device. - // • Keep the new device’s session active if validated correctly (e.g., fresh - // login or MFA). - - if (token.getRevokedAt() != null) { - refreshTokenRedisRepository.revokeByMemberId(memberId); - throw new CustomJwtException( - String.format( - "Parent token is revoked: Device ID=%s IP=%s User-Agent=%s, Parent Token Device ID=%s IP=%s User-Agent=%s", - deviceId, ipAddress, userAgent, token.getDeviceId(), token.getIpAddress(), - token.getUserAgent()), - ErrorCode.REFRESH_TOKEN_INVALID); - } - - } - - /** - * Checks for token reuse - * - * @param token The refresh token - * @param hasChildTokens Whether the token has child tokens - * @param ipAddress Current request IP - * @param userAgent Current request user agent - * @throws CustomJwtException when token reuse is detected - */ - public void checkTokenReuse(RefreshToken token, boolean hasChildTokens, String ipAddress, String userAgent) { - if (token.getRevokedAt() != null && hasChildTokens) { - throw new CustomJwtException( - String.format("Refresh token reuse detected: IP=%s User-Agent=%s", - ipAddress, userAgent), - ErrorCode.REFRESH_TOKEN_INVALID); + public String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashedBytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashedBytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); } } - } diff --git a/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java b/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java new file mode 100644 index 00000000..bcb345c4 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/AuthorizationExtractor.java @@ -0,0 +1,27 @@ +package com.juu.juulabel.common.util; + +import org.springframework.http.HttpHeaders; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility class for authorization header extraction + */ +public final class AuthorizationExtractor extends AbstractHttpUtil { + + /** + * Private constructor to prevent instantiation + */ + private AuthorizationExtractor() { + super(); + } + + /** + * Extract authorization header from request + * + * @return authorization header value + */ + public static String getAuthorization() { + HttpServletRequest request = getCurrentRequest(); + return request.getHeader(HttpHeaders.AUTHORIZATION); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java b/src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java new file mode 100644 index 00000000..3f6cb9d3 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/DeviceIdExtractor.java @@ -0,0 +1,35 @@ +package com.juu.juulabel.common.util; + +import com.juu.juulabel.common.exception.BaseException; +import com.juu.juulabel.common.exception.code.ErrorCode; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility class for device ID extraction + */ +public final class DeviceIdExtractor extends AbstractHttpUtil { + + private static final String DEVICE_ID_HEADER_NAME = "Device-Id"; + + /** + * Private constructor to prevent instantiation + */ + private DeviceIdExtractor() { + super(); + } + + /** + * Extract device ID from request headers + * + * @return device ID from Device-Id header + * @throws BaseException if Device-Id header is missing or empty + */ + public static String getDeviceId() { + HttpServletRequest request = getCurrentRequest(); + String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); + if (deviceId == null || deviceId.trim().isEmpty()) { + throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); + } + return deviceId.trim(); + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java deleted file mode 100644 index 3d6c24a2..00000000 --- a/src/main/java/com/juu/juulabel/common/util/HttpRequestUtil.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.juu.juulabel.common.util; - -import org.springframework.http.HttpHeaders; - -import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.code.ErrorCode; - -import jakarta.servlet.http.HttpServletRequest; - -import java.util.List; - -import static com.juu.juulabel.common.constants.AuthConstants.DEVICE_ID_HEADER_NAME; - -/** - * Utility class for HTTP request operations - */ -public final class HttpRequestUtil extends AbstractHttpUtil { - - private static final String UNKNOWN = "unknown"; - private static final List IP_HEADER_CANDIDATES = List.of( - "X-Forwarded-For", - "Proxy-Client-IP", - "WL-Proxy-Client-IP", - "HTTP_X_FORWARDED_FOR", - "HTTP_X_FORWARDED", - "HTTP_X_CLUSTER_CLIENT_IP", - "HTTP_CLIENT_IP", - "HTTP_FORWARDED_FOR", - "HTTP_FORWARDED", - "HTTP_VIA", - "REMOTE_ADDR"); - - /** - * Private constructor to prevent instantiation of utility class - */ - private HttpRequestUtil() { - super(); - } - - public static String getAuthorization(HttpServletRequest request) { - return request.getHeader(HttpHeaders.AUTHORIZATION); - } - - public static String getClientIpAddress() { - HttpServletRequest request = getCurrentRequest(); - - return IP_HEADER_CANDIDATES.stream() - .map(request::getHeader) - .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) - .map(ip -> ip.split(",")[0].trim()) - .findFirst() - .orElseGet(request::getRemoteAddr); - } - - public static String getUserAgent() { - return getCurrentRequest().getHeader(HttpHeaders.USER_AGENT); - } - - public static String getDeviceId() { - HttpServletRequest request = getCurrentRequest(); - String deviceId = request.getHeader(DEVICE_ID_HEADER_NAME); - if (deviceId == null) { - throw new BaseException(ErrorCode.DEVICE_ID_REQUIRED); - } - return deviceId; - } -} diff --git a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java index 98143859..a66ec0e8 100644 --- a/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java +++ b/src/main/java/com/juu/juulabel/common/util/HttpResponseUtil.java @@ -11,15 +11,32 @@ private HttpResponseUtil() { super(); } + /** + * Adds a secure HTTP-only cookie to the response + */ public static void addCookie(String name, String value, int maxAge) { HttpServletResponse response = getCurrentResponse(); + Cookie cookie = createSecureCookie(name, value, maxAge); + response.addCookie(cookie); + } + + /** + * Removes a cookie by setting its max age to 0 + */ + public static void removeCookie(String name) { + addCookie(name, "", 0); + } + + /** + * Creates a secure cookie with default settings + */ + private static Cookie createSecureCookie(String name, String value, int maxAge) { Cookie cookie = new Cookie(name, value); cookie.setMaxAge(maxAge); cookie.setPath("/"); cookie.setHttpOnly(true); cookie.setSecure(true); - - response.addCookie(cookie); + return cookie; } public static HttpServletResponse getCurrentResponse() { diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java new file mode 100644 index 00000000..5f6da897 --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java @@ -0,0 +1,212 @@ +package com.juu.juulabel.common.util; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Utility class for IP address extraction and validation + */ +public final class IpAddressExtractor extends AbstractHttpUtil { + + private static final String UNKNOWN = "unknown"; + + // Ordered by reliability - most trusted first + private static final List IP_HEADER_CANDIDATES = List.of( + "CF-Connecting-IP", // Cloudflare (most reliable if using CF) + "True-Client-IP", // Akamai + "X-Real-IP", // Nginx proxy + "X-Forwarded-For", // Standard but easily spoofed + "X-Cluster-Client-IP", // Google Cloud + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR", + "HTTP_X_FORWARDED", + "HTTP_X_CLUSTER_CLIENT_IP", + "HTTP_CLIENT_IP", + "HTTP_FORWARDED_FOR", + "HTTP_FORWARDED", + "HTTP_VIA", + "REMOTE_ADDR"); + + // IPv4 pattern + private static final Pattern IPV4_PATTERN = Pattern.compile( + "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"); + + // IPv6 pattern (simplified) + private static final Pattern IPV6_PATTERN = Pattern.compile( + "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$"); + + /** + * Private constructor to prevent instantiation + */ + private IpAddressExtractor() { + super(); + } + + /** + * Extract client IP address with validation and reliability checks + * + * @return most reliable client IP address found + */ + public static String getClientIpAddress() { + HttpServletRequest request = getCurrentRequest(); + + return IP_HEADER_CANDIDATES.stream() + .map(request::getHeader) + .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) + .map(ip -> ip.split(",")[0].trim()) // Take first IP from comma-separated list + .filter(IpAddressExtractor::isValidIpAddress) + .filter(IpAddressExtractor::isPublicIpAddress) // Prefer public IPs + .findFirst() + .orElseGet(() -> { + // Fallback: try to get any valid IP (including private) + String fallbackIp = IP_HEADER_CANDIDATES.stream() + .map(request::getHeader) + .filter(ip -> ip != null && !ip.isEmpty() && !UNKNOWN.equalsIgnoreCase(ip)) + .map(ip -> ip.split(",")[0].trim()) + .filter(IpAddressExtractor::isValidIpAddress) + .findFirst() + .orElse(request.getRemoteAddr()); + + return fallbackIp != null ? fallbackIp : "unknown"; + }); + } + + /** + * Get client IP with reliability score for monitoring/logging + */ + public static IpAddressInfo getClientIpAddressWithInfo() { + HttpServletRequest request = getCurrentRequest(); + + for (int i = 0; i < IP_HEADER_CANDIDATES.size(); i++) { + String headerName = IP_HEADER_CANDIDATES.get(i); + String headerValue = request.getHeader(headerName); + + if (headerValue != null && !headerValue.isEmpty() && !UNKNOWN.equalsIgnoreCase(headerValue)) { + String ip = headerValue.split(",")[0].trim(); + if (isValidIpAddress(ip)) { + ReliabilityLevel reliability = getReliabilityLevel(headerName, ip); + return new IpAddressInfo(ip, headerName, reliability); + } + } + } + + String remoteAddr = request.getRemoteAddr(); + return new IpAddressInfo( + remoteAddr != null ? remoteAddr : "unknown", + "REMOTE_ADDR", + ReliabilityLevel.LOW); + } + + /** + * Validate if string is a valid IP address (IPv4 or IPv6) + */ + private static boolean isValidIpAddress(String ip) { + if (ip == null || ip.trim().isEmpty()) { + return false; + } + + try { + InetAddress.getByName(ip); + return IPV4_PATTERN.matcher(ip).matches() || IPV6_PATTERN.matcher(ip).matches(); + } catch (UnknownHostException e) { + return false; + } + } + + /** + * Check if IP address is public (not private/local) + */ + private static boolean isPublicIpAddress(String ip) { + if (!isValidIpAddress(ip)) { + return false; + } + + return !isPrivateIpAddress(ip) && !isSpecialAddress(ip); + } + + /** + * Check if IP is in private ranges + */ + private static boolean isPrivateIpAddress(String ip) { + if (ip.startsWith("10.") || ip.startsWith("192.168.")) { + return true; + } + + if (ip.startsWith("172.")) { + return isPrivate172Range(ip); + } + + return false; + } + + /** + * Check if 172.x.x.x IP is in private range (172.16.0.0 to 172.31.255.255) + */ + private static boolean isPrivate172Range(String ip) { + String[] octets = ip.split("\\."); + if (octets.length < 2) { + return false; + } + + try { + int secondOctet = Integer.parseInt(octets[1]); + return secondOctet >= 16 && secondOctet <= 31; + } catch (NumberFormatException e) { + return false; + } + } + + /** + * Check if IP is localhost or other special addresses + */ + private static boolean isSpecialAddress(String ip) { + return ip.equals("127.0.0.1") || ip.equals("::1") || ip.equals("0.0.0.0"); + } + + private static ReliabilityLevel getReliabilityLevel(String headerName, String ip) { + // Rate headers by trustworthiness + return switch (headerName) { + case "CF-Connecting-IP", "True-Client-IP" -> ReliabilityLevel.HIGH; + case "X-Real-IP", "X-Cluster-Client-IP" -> ReliabilityLevel.MEDIUM; + case "X-Forwarded-For" -> isPublicIpAddress(ip) ? ReliabilityLevel.MEDIUM : ReliabilityLevel.LOW; + default -> ReliabilityLevel.LOW; + }; + } + + /** + * Data class for IP address information + */ + public static class IpAddressInfo { + private final String ipAddress; + private final String sourceHeader; + private final ReliabilityLevel reliability; + + public IpAddressInfo(String ipAddress, String sourceHeader, ReliabilityLevel reliability) { + this.ipAddress = ipAddress; + this.sourceHeader = sourceHeader; + this.reliability = reliability; + } + + public String getIpAddress() { + return ipAddress; + } + + public String getSourceHeader() { + return sourceHeader; + } + + public ReliabilityLevel getReliability() { + return reliability; + } + } + + public enum ReliabilityLevel { + HIGH, // Cloudflare, Akamai - very reliable + MEDIUM, // Nginx, proper proxies - generally reliable + LOW // Easy to spoof headers - use with caution + } +} \ No newline at end of file diff --git a/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java b/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java new file mode 100644 index 00000000..e9acf5fc --- /dev/null +++ b/src/main/java/com/juu/juulabel/common/util/UserAgentExtractor.java @@ -0,0 +1,27 @@ +package com.juu.juulabel.common.util; + +import org.springframework.http.HttpHeaders; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Utility class for user agent extraction + */ +public final class UserAgentExtractor extends AbstractHttpUtil { + + /** + * Private constructor to prevent instantiation + */ + private UserAgentExtractor() { + super(); + } + + /** + * Extract user agent from request headers + * + * @return user agent string from User-Agent header + */ + public static String getUserAgent() { + HttpServletRequest request = getCurrentRequest(); + return request.getHeader(HttpHeaders.USER_AGENT); + } +} \ No newline at end of file diff --git a/src/main/resources/scripts/login_refresh_token.lua b/src/main/resources/scripts/login_refresh_token.lua new file mode 100644 index 00000000..0568f5f1 --- /dev/null +++ b/src/main/resources/scripts/login_refresh_token.lua @@ -0,0 +1,44 @@ +-- KEYS[1] = newTokenKey (e.g., "RefreshToken:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") +-- ARGV[1] = memberId +-- ARGV[2] = clientId +-- ARGV[3] = deviceId +-- ARGV[4] = ipAddress +-- ARGV[5] = userAgent +-- ARGV[6] = ttl in seconds +local newTokenKey = KEYS[1] +local indexKey = KEYS[2] + +local memberId = ARGV[1] +local clientId = ARGV[2] +local deviceId = ARGV[3] +local ipAddress = ARGV[4] +local userAgent = ARGV[5] +local ttl = tonumber(ARGV[6]) + +-- Find all keys for the user+client+device +local oldTokenKeys = redis.call("SMEMBERS", indexKey) + +-- Revoke all old tokens +for _, key in ipairs(oldTokenKeys) do + if redis.call("EXISTS", key) == 1 then + redis.call("HSET", key, "revoked", 1) + else + redis.call("SREM", indexKey, key) -- clean up dead keys + end +end + +-- Save the new token hash +redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", + ipAddress, "userAgent", userAgent, "revoked", 0) + +-- Set TTL +redis.call("EXPIRE", newTokenKey, ttl) + +-- Update the index with new token +redis.call("SADD", indexKey, newTokenKey) +redis.call("EXPIRE", indexKey, ttl) + +return { + ok = "LOGIN_SUCCESS" +} diff --git a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua new file mode 100644 index 00000000..5f208d93 --- /dev/null +++ b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua @@ -0,0 +1,15 @@ +local pattern = KEYS[1] + +local indexKeys = redis.call("KEYS", pattern) + +for _, idxKey in ipairs(indexKeys) do + local tokenKeys = redis.call("SMEMBERS", idxKey) + for _, tokenKey in ipairs(tokenKeys) do + redis.call("HSET", tokenKey, "revoked", 1) + end + redis.call("SREM", idxKey, tokenKey) +end + +return { + ok = "REVOKED_ALL_TOKENS_BY_INDEX_KEY" +} diff --git a/src/main/resources/scripts/rotate_refresh_token.lua b/src/main/resources/scripts/rotate_refresh_token.lua new file mode 100644 index 00000000..4dd699e6 --- /dev/null +++ b/src/main/resources/scripts/rotate_refresh_token.lua @@ -0,0 +1,95 @@ +-- KEYS[1] = new token key (e.g., "RefreshToken:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") +-- KEYS[3] = old token key (e.g., "RefreshToken:{hashedToken}") +-- ARGV[1] = memberId +-- ARGV[2] = clientId +-- ARGV[3] = deviceId +-- ARGV[4] = ipAddress +-- ARGV[5] = userAgent +-- ARGV[6] = ttl in seconds +local newTokenKey = KEYS[1] +local indexKey = KEYS[2] +local oldTokenKey = KEYS[3] +local memberId = ARGV[1] +local clientId = ARGV[2] +local deviceId = ARGV[3] +local ipAddress = ARGV[4] +local userAgent = ARGV[5] +local ttl = tonumber(ARGV[6]) + +-- Helper function to revoke all member tokens +local function revokeAllMemberTokens(memberId) + local cursor = "0" + local pattern = "RefreshIndex:" .. memberId .. ":*" + + repeat + local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100) + cursor = result[1] + local indexKeys = result[2] + + for _, idxKey in ipairs(indexKeys) do + local tokenKeys = redis.call("SMEMBERS", idxKey) + + -- Batch revoke tokens (max 100 at a time to avoid large commands) + for i = 1, #tokenKeys, 100 do + local batch = {} + local endIdx = math.min(i + 99, #tokenKeys) + + for j = i, endIdx do + table.insert(batch, "HSET") + table.insert(batch, tokenKeys[j]) + table.insert(batch, "revoked") + table.insert(batch, 1) + end + + if #batch > 0 then + redis.call(unpack(batch)) + end + end + + -- Clean up index + redis.call("DEL", idxKey) + end + until cursor == "0" +end + +-- Check if old token exists and get all fields at once +local oldToken = redis.call("HMGET", oldTokenKey, "revoked", "deviceId") +if not oldToken[1] and not oldToken[2] then + return { + err = "OLD_TOKEN_NOT_FOUND" + } +end + +-- Check if token is already revoked (using direct array access) +if oldToken[1] == "1" then + revokeAllMemberTokens(memberId) + return { + err = "OLD_TOKEN_ALREADY_REVOKED_ALL_TOKENS_INVALIDATED" + } +end + +-- Check device ID mismatch (using direct array access) +if oldToken[2] ~= deviceId then + revokeAllMemberTokens(memberId) + return { + err = "DEVICE_ID_MISMATCH" + } +end + +-- Revoke old token +redis.call("HSET", oldTokenKey, "revoked", 1) +redis.call("SREM", indexKey, oldTokenKey) + +-- Store new token with all fields at once +redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", + ipAddress, "userAgent", userAgent, "revoked", 0) + +-- Set expiration and update index +redis.call("EXPIRE", newTokenKey, ttl) +redis.call("SADD", indexKey, newTokenKey) +redis.call("EXPIRE", indexKey, ttl) + +return { + ok = "ROTATION_SUCCESS" +} diff --git a/src/main/resources/scripts/save_refresh_token.lua b/src/main/resources/scripts/save_refresh_token.lua new file mode 100644 index 00000000..5742579c --- /dev/null +++ b/src/main/resources/scripts/save_refresh_token.lua @@ -0,0 +1,31 @@ +-- KEYS[1] = newTokenKey (e.g., "RefreshToken:{hashedToken}") +-- KEYS[2] = indexKey (e.g., "RefreshIndex:{memberId}:{clientId}:{deviceId}") +-- ARGV[1] = memberId +-- ARGV[2] = clientId +-- ARGV[3] = deviceId +-- ARGV[4] = ipAddress +-- ARGV[5] = userAgent +-- ARGV[6] = ttl in seconds +local newTokenKey = KEYS[1] +local indexKey = KEYS[2] + +local memberId = ARGV[1] +local clientId = ARGV[2] +local deviceId = ARGV[3] +local ipAddress = ARGV[4] +local userAgent = ARGV[5] +local ttl = tonumber(ARGV[6]) + +-- Store token +-- Save the new token hash +redis.call("HSET", newTokenKey, "memberId", memberId, "clientId", clientId, "deviceId", deviceId, "ipAddress", + ipAddress, "userAgent", userAgent, "revoked", 0) +redis.call("EXPIRE", newTokenKey, ttl) + +-- Update index with limited size +redis.call("SADD", indexKey, newTokenKey) +redis.call("EXPIRE", indexKey, ttl) + +return { + ok = "SAVE_REFRESH_TOKEN_SUCCESS" +} From 6ad5d99108af2e1954c107520d50f4582f932805 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 26 May 2025 19:11:22 +0900 Subject: [PATCH 4/7] Update documentation and .gitignore - Added links to PR #139 in relevant documentation files for better traceability. - Cleaned up .gitignore by removing duplicate entries and ensuring proper formatting. --- .gitignore | 5 +- .../aws-elasticache-redis-local-setup.md | 2 +- docs/pr/PR-139-refactor---auth-api.md | 2 +- .../com/juu/juulabel/report/QReport.java | 72 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 src/main/generated/com/juu/juulabel/report/QReport.java diff --git a/.gitignore b/.gitignore index 271c67a4..4275ec45 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,4 @@ out/ /.nb-gradle/ ### VS Code ### -.vscode/ - -### QueryDSL ### -src/main/generated/ +.vscode/ \ No newline at end of file diff --git a/docs/infra/aws-elasticache-redis-local-setup.md b/docs/infra/aws-elasticache-redis-local-setup.md index d96cac9a..2c877759 100644 --- a/docs/infra/aws-elasticache-redis-local-setup.md +++ b/docs/infra/aws-elasticache-redis-local-setup.md @@ -117,6 +117,6 @@ journalctl -u socat-redis ## 📎 참고 자료 - [AWS Blog - Port Forwarding with SSM to ElastiCache Redis](https://aws.amazon.com/blogs/mt/aws-systems-manager-session-manager-port-forwarding-to-amazon-elasticache-redis-inside-private-subnet/) -- [PR #139](): 인증 전략 개선 및 Redis 기반 세션 관리 적용 상세 내역 +- [PR #139](https://github.com/juulabel/juulabel-back/pull/141): 인증 전략 개선 및 Redis 기반 세션 관리 적용 상세 내역 --- \ No newline at end of file diff --git a/docs/pr/PR-139-refactor---auth-api.md b/docs/pr/PR-139-refactor---auth-api.md index e51870ba..1a45c560 100644 --- a/docs/pr/PR-139-refactor---auth-api.md +++ b/docs/pr/PR-139-refactor---auth-api.md @@ -1,4 +1,4 @@ -# Auth API 리팩터링 및 인증 전략 고도화 (PR [#139]()) +# Auth API 리팩터링 및 인증 전략 고도화 (PR [#139](https://github.com/juulabel/juulabel-back/pull/141)) ## 📌 Summary diff --git a/src/main/generated/com/juu/juulabel/report/QReport.java b/src/main/generated/com/juu/juulabel/report/QReport.java new file mode 100644 index 00000000..a6c3665f --- /dev/null +++ b/src/main/generated/com/juu/juulabel/report/QReport.java @@ -0,0 +1,72 @@ +package com.juu.juulabel.report; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReport is a Querydsl query type for Report + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReport extends EntityPathBase { + + private static final long serialVersionUID = 469175163L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReport report = new QReport("report"); + + public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath reason = createString("reason"); + + public final NumberPath reportedContentId = createNumber("reportedContentId", Long.class); + + public final com.juu.juulabel.member.domain.QMember reporter; + + public final DateTimePath reviewedAt = createDateTime("reviewedAt", java.time.LocalDateTime.class); + + public final com.juu.juulabel.member.domain.QMember reviewer; + + public final EnumPath status = createEnum("status", ReportStatus.class); + + public final EnumPath type = createEnum("type", ReportType.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QReport(String variable) { + this(Report.class, forVariable(variable), INITS); + } + + public QReport(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReport(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReport(PathMetadata metadata, PathInits inits) { + this(Report.class, metadata, inits); + } + + public QReport(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.reporter = inits.isInitialized("reporter") ? new com.juu.juulabel.member.domain.QMember(forProperty("reporter")) : null; + this.reviewer = inits.isInitialized("reviewer") ? new com.juu.juulabel.member.domain.QMember(forProperty("reviewer")) : null; + } + +} + From b550cc275d2eb02e48b636a3cb567f8a09b9e575 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 26 May 2025 19:58:30 +0900 Subject: [PATCH 5/7] Refactor: Clean up generated QueryDSL files and improve code formatting - Removed unused generated QueryDSL classes related to alcohol, daily life, and member domains to streamline the codebase. - Updated build.gradle to remove unnecessary dependencies and improve formatting. - Enhanced code readability by adjusting indentation and spacing in various configuration and utility classes. - Improved Redis token revocation script for better performance with large datasets by switching from KEYS to SCAN. --- build.gradle | 26 +----- .../juulabel/alcohol/domain/QAlcoholType.java | 54 ------------ .../alcohol/domain/QAlcoholTypeColor.java | 64 -------------- .../alcohol/domain/QAlcoholTypeFlavor.java | 64 -------------- .../alcohol/domain/QAlcoholTypeScent.java | 67 --------------- .../alcohol/domain/QAlcoholTypeSensory.java | 64 -------------- .../alcohol/domain/QAlcoholicDrinks.java | 84 ------------------- .../domain/QAlcoholicDrinksIngredient.java | 54 ------------ .../domain/QAlcoholicDrinksPairing.java | 54 ------------ .../juu/juulabel/alcohol/domain/QBrewery.java | 53 ------------ .../juu/juulabel/alcohol/domain/QColor.java | 41 --------- .../juu/juulabel/alcohol/domain/QFlavor.java | 39 --------- .../juulabel/alcohol/domain/QFlavorLevel.java | 55 ------------ .../juulabel/alcohol/domain/QIngredient.java | 39 --------- .../juu/juulabel/alcohol/domain/QPairing.java | 39 --------- .../juu/juulabel/alcohol/domain/QScent.java | 39 --------- .../juu/juulabel/alcohol/domain/QSensory.java | 41 --------- .../alcohol/domain/QSensoryLevel.java | 55 ------------ .../juulabel/category/domain/QCategory.java | 69 --------------- .../common/base/QBaseCreatedTimeEntity.java | 37 -------- .../juulabel/common/base/QBaseTimeEntity.java | 39 --------- .../juulabel/dailylife/domain/QDailyLife.java | 67 --------------- .../dailylife/domain/QDailyLifeComment.java | 69 --------------- .../domain/QDailyLifeCommentLike.java | 59 ------------- .../dailylife/domain/QDailyLifeImage.java | 65 -------------- .../dailylife/domain/QDailyLifeLike.java | 59 ------------- .../juu/juulabel/follow/domain/QFollow.java | 64 -------------- .../juu/juulabel/member/domain/QMember.java | 75 ----------------- .../member/domain/QMemberAlcoholType.java | 62 -------------- .../member/domain/QMemberAlcoholicDrinks.java | 59 ------------- .../juulabel/member/domain/QMemberTerms.java | 64 -------------- .../member/domain/QWithdrawalRecord.java | 48 ----------- .../notification/domain/QNotification.java | 68 --------------- .../com/juu/juulabel/report/QReport.java | 72 ---------------- .../tastingnote/domain/QTastingNote.java | 81 ------------------ .../domain/QTastingNoteComment.java | 69 --------------- .../domain/QTastingNoteCommentLike.java | 59 ------------- .../domain/QTastingNoteFlavorLevel.java | 62 -------------- .../tastingnote/domain/QTastingNoteImage.java | 65 -------------- .../tastingnote/domain/QTastingNoteLike.java | 59 ------------- .../tastingnote/domain/QTastingNoteScent.java | 62 -------------- .../domain/QTastingNoteSensoryLevel.java | 62 -------------- .../embedded/QAlcoholicDrinksSnapshot.java | 45 ---------- .../com/juu/juulabel/terms/domain/QTerms.java | 57 ------------- .../common/config/QuerydslConfig.java | 12 +-- .../common/converter/ProviderConverter.java | 3 +- .../common/util/IpAddressExtractor.java | 2 +- .../com/juu/juulabel/s3/S3Controller.java | 10 +-- .../revoke_refresh_token_by_index_key.lua | 40 +++++++-- 49 files changed, 45 insertions(+), 2551 deletions(-) delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholType.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeColor.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeFlavor.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeScent.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeSensory.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinks.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksIngredient.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksPairing.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QBrewery.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QColor.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QFlavor.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QFlavorLevel.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QIngredient.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QPairing.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QScent.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QSensory.java delete mode 100644 src/main/generated/com/juu/juulabel/alcohol/domain/QSensoryLevel.java delete mode 100644 src/main/generated/com/juu/juulabel/category/domain/QCategory.java delete mode 100644 src/main/generated/com/juu/juulabel/common/base/QBaseCreatedTimeEntity.java delete mode 100644 src/main/generated/com/juu/juulabel/common/base/QBaseTimeEntity.java delete mode 100644 src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLife.java delete mode 100644 src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeComment.java delete mode 100644 src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeCommentLike.java delete mode 100644 src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeImage.java delete mode 100644 src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeLike.java delete mode 100644 src/main/generated/com/juu/juulabel/follow/domain/QFollow.java delete mode 100644 src/main/generated/com/juu/juulabel/member/domain/QMember.java delete mode 100644 src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholType.java delete mode 100644 src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholicDrinks.java delete mode 100644 src/main/generated/com/juu/juulabel/member/domain/QMemberTerms.java delete mode 100644 src/main/generated/com/juu/juulabel/member/domain/QWithdrawalRecord.java delete mode 100644 src/main/generated/com/juu/juulabel/notification/domain/QNotification.java delete mode 100644 src/main/generated/com/juu/juulabel/report/QReport.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNote.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteComment.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteCommentLike.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteFlavorLevel.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteImage.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteLike.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteScent.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteSensoryLevel.java delete mode 100644 src/main/generated/com/juu/juulabel/tastingnote/domain/embedded/QAlcoholicDrinksSnapshot.java delete mode 100644 src/main/generated/com/juu/juulabel/terms/domain/QTerms.java diff --git a/build.gradle b/build.gradle index 57fc0628..08df474a 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ dependencies { // jpa implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - + // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -51,10 +51,6 @@ dependencies { // jjwt implementation 'io.jsonwebtoken:jjwt:0.12.5' - // GeoIP - implementation 'com.maxmind.geoip2:geoip2:4.1.0' - - // lombok implementation 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok' @@ -96,25 +92,6 @@ dependencies { implementation 'io.sentry:sentry-spring-boot-starter-jakarta:8.5.0' } -def generatedQueryDsl = 'src/main/generated' - -sourceSets { - main.java.srcDirs += [generatedQueryDsl] -} - -tasks.withType(JavaCompile).configureEach { - options.getGeneratedSourceOutputDirectory().set(file(generatedQueryDsl)) -} - -clean.doLast { - file(generatedQueryDsl).deleteDir() -} - -tasks.named('test') { - useJUnitPlatform() -} - - jib { from { image = 'openjdk:21' @@ -127,4 +104,3 @@ jib { creationTime = 'USE_CURRENT_TIMESTAMP' } } - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholType.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholType.java deleted file mode 100644 index 282d8036..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholType.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholType is a Querydsl query type for AlcoholType - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholType extends EntityPathBase { - - private static final long serialVersionUID = -1838556943L; - - public static final QAlcoholType alcoholType = new QAlcoholType("alcoholType"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath image = createString("image"); - - public final StringPath name = createString("name"); - - public final ListPath tastingNotes = this.createList("tastingNotes", com.juu.juulabel.tastingnote.domain.TastingNote.class, com.juu.juulabel.tastingnote.domain.QTastingNote.class, PathInits.DIRECT2); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QAlcoholType(String variable) { - super(AlcoholType.class, forVariable(variable)); - } - - public QAlcoholType(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QAlcoholType(PathMetadata metadata) { - super(AlcoholType.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeColor.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeColor.java deleted file mode 100644 index 3a63c2de..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeColor.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholTypeColor is a Querydsl query type for AlcoholTypeColor - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholTypeColor extends EntityPathBase { - - private static final long serialVersionUID = -1121889038L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholTypeColor alcoholTypeColor = new QAlcoholTypeColor("alcoholTypeColor"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final QAlcoholType alcoholType; - - public final QColor color; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QAlcoholTypeColor(String variable) { - this(AlcoholTypeColor.class, forVariable(variable), INITS); - } - - public QAlcoholTypeColor(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholTypeColor(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholTypeColor(PathMetadata metadata, PathInits inits) { - this(AlcoholTypeColor.class, metadata, inits); - } - - public QAlcoholTypeColor(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new QAlcoholType(forProperty("alcoholType")) : null; - this.color = inits.isInitialized("color") ? new QColor(forProperty("color")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeFlavor.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeFlavor.java deleted file mode 100644 index 8f937cc3..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeFlavor.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholTypeFlavor is a Querydsl query type for AlcoholTypeFlavor - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholTypeFlavor extends EntityPathBase { - - private static final long serialVersionUID = -336025873L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholTypeFlavor alcoholTypeFlavor = new QAlcoholTypeFlavor("alcoholTypeFlavor"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final QAlcoholType alcoholType; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final QFlavor flavor; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QAlcoholTypeFlavor(String variable) { - this(AlcoholTypeFlavor.class, forVariable(variable), INITS); - } - - public QAlcoholTypeFlavor(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholTypeFlavor(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholTypeFlavor(PathMetadata metadata, PathInits inits) { - this(AlcoholTypeFlavor.class, metadata, inits); - } - - public QAlcoholTypeFlavor(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new QAlcoholType(forProperty("alcoholType")) : null; - this.flavor = inits.isInitialized("flavor") ? new QFlavor(forProperty("flavor")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeScent.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeScent.java deleted file mode 100644 index bf18c0a3..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeScent.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholTypeScent is a Querydsl query type for AlcoholTypeScent - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholTypeScent extends EntityPathBase { - - private static final long serialVersionUID = -1107476950L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholTypeScent alcoholTypeScent = new QAlcoholTypeScent("alcoholTypeScent"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final QAlcoholType alcoholType; - - public final com.juu.juulabel.category.domain.QCategory category; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - public final QScent scent; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QAlcoholTypeScent(String variable) { - this(AlcoholTypeScent.class, forVariable(variable), INITS); - } - - public QAlcoholTypeScent(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholTypeScent(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholTypeScent(PathMetadata metadata, PathInits inits) { - this(AlcoholTypeScent.class, metadata, inits); - } - - public QAlcoholTypeScent(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new QAlcoholType(forProperty("alcoholType")) : null; - this.category = inits.isInitialized("category") ? new com.juu.juulabel.category.domain.QCategory(forProperty("category"), inits.get("category")) : null; - this.scent = inits.isInitialized("scent") ? new QScent(forProperty("scent")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeSensory.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeSensory.java deleted file mode 100644 index 1fb621c5..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholTypeSensory.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholTypeSensory is a Querydsl query type for AlcoholTypeSensory - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholTypeSensory extends EntityPathBase { - - private static final long serialVersionUID = 932258254L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholTypeSensory alcoholTypeSensory = new QAlcoholTypeSensory("alcoholTypeSensory"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final QAlcoholType alcoholType; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - public final QSensory sensory; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QAlcoholTypeSensory(String variable) { - this(AlcoholTypeSensory.class, forVariable(variable), INITS); - } - - public QAlcoholTypeSensory(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholTypeSensory(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholTypeSensory(PathMetadata metadata, PathInits inits) { - this(AlcoholTypeSensory.class, metadata, inits); - } - - public QAlcoholTypeSensory(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new QAlcoholType(forProperty("alcoholType")) : null; - this.sensory = inits.isInitialized("sensory") ? new QSensory(forProperty("sensory")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinks.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinks.java deleted file mode 100644 index 9fb03969..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinks.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholicDrinks is a Querydsl query type for AlcoholicDrinks - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholicDrinks extends EntityPathBase { - - private static final long serialVersionUID = -695932244L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholicDrinks alcoholicDrinks = new QAlcoholicDrinks("alcoholicDrinks"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final NumberPath alcoholContent = createNumber("alcoholContent", Double.class); - - public final QAlcoholType alcoholType; - - public final QBrewery brewery; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final StringPath description = createString("description"); - - public final NumberPath discountPrice = createNumber("discountPrice", Integer.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath image = createString("image"); - - public final StringPath name = createString("name"); - - public final NumberPath rating = createNumber("rating", Double.class); - - public final NumberPath regularPrice = createNumber("regularPrice", Integer.class); - - public final NumberPath tastingNoteCount = createNumber("tastingNoteCount", Integer.class); - - public final ListPath tastingNotes = this.createList("tastingNotes", com.juu.juulabel.tastingnote.domain.TastingNote.class, com.juu.juulabel.tastingnote.domain.QTastingNote.class, PathInits.DIRECT2); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public final NumberPath volume = createNumber("volume", Integer.class); - - public QAlcoholicDrinks(String variable) { - this(AlcoholicDrinks.class, forVariable(variable), INITS); - } - - public QAlcoholicDrinks(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholicDrinks(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholicDrinks(PathMetadata metadata, PathInits inits) { - this(AlcoholicDrinks.class, metadata, inits); - } - - public QAlcoholicDrinks(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new QAlcoholType(forProperty("alcoholType")) : null; - this.brewery = inits.isInitialized("brewery") ? new QBrewery(forProperty("brewery")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksIngredient.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksIngredient.java deleted file mode 100644 index aa82b6f3..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksIngredient.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholicDrinksIngredient is a Querydsl query type for AlcoholicDrinksIngredient - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholicDrinksIngredient extends EntityPathBase { - - private static final long serialVersionUID = 18141597L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholicDrinksIngredient alcoholicDrinksIngredient = new QAlcoholicDrinksIngredient("alcoholicDrinksIngredient"); - - public final QAlcoholicDrinks alcoholicDrinks; - - public final NumberPath id = createNumber("id", Long.class); - - public final QIngredient ingredient; - - public QAlcoholicDrinksIngredient(String variable) { - this(AlcoholicDrinksIngredient.class, forVariable(variable), INITS); - } - - public QAlcoholicDrinksIngredient(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholicDrinksIngredient(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholicDrinksIngredient(PathMetadata metadata, PathInits inits) { - this(AlcoholicDrinksIngredient.class, metadata, inits); - } - - public QAlcoholicDrinksIngredient(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholicDrinks = inits.isInitialized("alcoholicDrinks") ? new QAlcoholicDrinks(forProperty("alcoholicDrinks"), inits.get("alcoholicDrinks")) : null; - this.ingredient = inits.isInitialized("ingredient") ? new QIngredient(forProperty("ingredient")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksPairing.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksPairing.java deleted file mode 100644 index ebd4ef89..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QAlcoholicDrinksPairing.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QAlcoholicDrinksPairing is a Querydsl query type for AlcoholicDrinksPairing - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QAlcoholicDrinksPairing extends EntityPathBase { - - private static final long serialVersionUID = 1589617276L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QAlcoholicDrinksPairing alcoholicDrinksPairing = new QAlcoholicDrinksPairing("alcoholicDrinksPairing"); - - public final QAlcoholicDrinks alcoholicDrinks; - - public final NumberPath id = createNumber("id", Long.class); - - public final QPairing pairing; - - public QAlcoholicDrinksPairing(String variable) { - this(AlcoholicDrinksPairing.class, forVariable(variable), INITS); - } - - public QAlcoholicDrinksPairing(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QAlcoholicDrinksPairing(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QAlcoholicDrinksPairing(PathMetadata metadata, PathInits inits) { - this(AlcoholicDrinksPairing.class, metadata, inits); - } - - public QAlcoholicDrinksPairing(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholicDrinks = inits.isInitialized("alcoholicDrinks") ? new QAlcoholicDrinks(forProperty("alcoholicDrinks"), inits.get("alcoholicDrinks")) : null; - this.pairing = inits.isInitialized("pairing") ? new QPairing(forProperty("pairing")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QBrewery.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QBrewery.java deleted file mode 100644 index c019ef47..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QBrewery.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QBrewery is a Querydsl query type for Brewery - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QBrewery extends EntityPathBase { - - private static final long serialVersionUID = -570077389L; - - public static final QBrewery brewery = new QBrewery("brewery"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath message = createString("message"); - - public final StringPath name = createString("name"); - - public final StringPath region = createString("region"); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QBrewery(String variable) { - super(Brewery.class, forVariable(variable)); - } - - public QBrewery(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QBrewery(PathMetadata metadata) { - super(Brewery.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QColor.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QColor.java deleted file mode 100644 index 4af4be86..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QColor.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QColor is a Querydsl query type for Color - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QColor extends EntityPathBase { - - private static final long serialVersionUID = 2047172524L; - - public static final QColor color = new QColor("color"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public final StringPath rgb = createString("rgb"); - - public QColor(String variable) { - super(Color.class, forVariable(variable)); - } - - public QColor(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QColor(PathMetadata metadata) { - super(Color.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavor.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavor.java deleted file mode 100644 index 32170085..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavor.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QFlavor is a Querydsl query type for Flavor - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QFlavor extends EntityPathBase { - - private static final long serialVersionUID = -879365259L; - - public static final QFlavor flavor = new QFlavor("flavor"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public QFlavor(String variable) { - super(Flavor.class, forVariable(variable)); - } - - public QFlavor(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QFlavor(PathMetadata metadata) { - super(Flavor.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavorLevel.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavorLevel.java deleted file mode 100644 index 323b9c1b..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QFlavorLevel.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QFlavorLevel is a Querydsl query type for FlavorLevel - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QFlavorLevel extends EntityPathBase { - - private static final long serialVersionUID = -1624270577L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QFlavorLevel flavorLevel = new QFlavorLevel("flavorLevel"); - - public final StringPath description = createString("description"); - - public final QFlavor flavor; - - public final NumberPath id = createNumber("id", Long.class); - - public final NumberPath score = createNumber("score", Integer.class); - - public QFlavorLevel(String variable) { - this(FlavorLevel.class, forVariable(variable), INITS); - } - - public QFlavorLevel(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QFlavorLevel(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QFlavorLevel(PathMetadata metadata, PathInits inits) { - this(FlavorLevel.class, metadata, inits); - } - - public QFlavorLevel(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.flavor = inits.isInitialized("flavor") ? new QFlavor(forProperty("flavor")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QIngredient.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QIngredient.java deleted file mode 100644 index fac512c0..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QIngredient.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QIngredient is a Querydsl query type for Ingredient - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QIngredient extends EntityPathBase { - - private static final long serialVersionUID = -280754392L; - - public static final QIngredient ingredient = new QIngredient("ingredient"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public QIngredient(String variable) { - super(Ingredient.class, forVariable(variable)); - } - - public QIngredient(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QIngredient(PathMetadata metadata) { - super(Ingredient.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QPairing.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QPairing.java deleted file mode 100644 index 2e8851db..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QPairing.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QPairing is a Querydsl query type for Pairing - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QPairing extends EntityPathBase { - - private static final long serialVersionUID = -1513074479L; - - public static final QPairing pairing = new QPairing("pairing"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public QPairing(String variable) { - super(Pairing.class, forVariable(variable)); - } - - public QPairing(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QPairing(PathMetadata metadata) { - super(Pairing.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QScent.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QScent.java deleted file mode 100644 index bdfeb3be..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QScent.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QScent is a Querydsl query type for Scent - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QScent extends EntityPathBase { - - private static final long serialVersionUID = 2061584612L; - - public static final QScent scent = new QScent("scent"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public QScent(String variable) { - super(Scent.class, forVariable(variable)); - } - - public QScent(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QScent(PathMetadata metadata) { - super(Scent.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QSensory.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QSensory.java deleted file mode 100644 index 6f31b88e..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QSensory.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QSensory is a Querydsl query type for Sensory - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QSensory extends EntityPathBase { - - private static final long serialVersionUID = 1268606472L; - - public static final QSensory sensory = new QSensory("sensory"); - - public final StringPath description = createString("description"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath name = createString("name"); - - public QSensory(String variable) { - super(Sensory.class, forVariable(variable)); - } - - public QSensory(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QSensory(PathMetadata metadata) { - super(Sensory.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/alcohol/domain/QSensoryLevel.java b/src/main/generated/com/juu/juulabel/alcohol/domain/QSensoryLevel.java deleted file mode 100644 index d5869b82..00000000 --- a/src/main/generated/com/juu/juulabel/alcohol/domain/QSensoryLevel.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.juu.juulabel.alcohol.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QSensoryLevel is a Querydsl query type for SensoryLevel - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QSensoryLevel extends EntityPathBase { - - private static final long serialVersionUID = -1898460580L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QSensoryLevel sensoryLevel = new QSensoryLevel("sensoryLevel"); - - public final StringPath description = createString("description"); - - public final NumberPath id = createNumber("id", Long.class); - - public final NumberPath score = createNumber("score", Integer.class); - - public final QSensory sensory; - - public QSensoryLevel(String variable) { - this(SensoryLevel.class, forVariable(variable), INITS); - } - - public QSensoryLevel(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QSensoryLevel(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QSensoryLevel(PathMetadata metadata, PathInits inits) { - this(SensoryLevel.class, metadata, inits); - } - - public QSensoryLevel(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.sensory = inits.isInitialized("sensory") ? new QSensory(forProperty("sensory")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/category/domain/QCategory.java b/src/main/generated/com/juu/juulabel/category/domain/QCategory.java deleted file mode 100644 index 348abe78..00000000 --- a/src/main/generated/com/juu/juulabel/category/domain/QCategory.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.juu.juulabel.category.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QCategory is a Querydsl query type for Category - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QCategory extends EntityPathBase { - - private static final long serialVersionUID = -321007669L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QCategory category = new QCategory("category"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final ListPath children = this.createList("children", Category.class, QCategory.class, PathInits.DIRECT2); - - public final StringPath code = createString("code"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - public final StringPath name = createString("name"); - - public final QCategory parent; - - public final EnumPath type = createEnum("type", CategoryType.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QCategory(String variable) { - this(Category.class, forVariable(variable), INITS); - } - - public QCategory(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QCategory(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QCategory(PathMetadata metadata, PathInits inits) { - this(Category.class, metadata, inits); - } - - public QCategory(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.parent = inits.isInitialized("parent") ? new QCategory(forProperty("parent"), inits.get("parent")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/common/base/QBaseCreatedTimeEntity.java b/src/main/generated/com/juu/juulabel/common/base/QBaseCreatedTimeEntity.java deleted file mode 100644 index 24e59f44..00000000 --- a/src/main/generated/com/juu/juulabel/common/base/QBaseCreatedTimeEntity.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.juu.juulabel.common.base; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QBaseCreatedTimeEntity is a Querydsl query type for BaseCreatedTimeEntity - */ -@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") -public class QBaseCreatedTimeEntity extends EntityPathBase { - - private static final long serialVersionUID = -1233772198L; - - public static final QBaseCreatedTimeEntity baseCreatedTimeEntity = new QBaseCreatedTimeEntity("baseCreatedTimeEntity"); - - public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); - - public QBaseCreatedTimeEntity(String variable) { - super(BaseCreatedTimeEntity.class, forVariable(variable)); - } - - public QBaseCreatedTimeEntity(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QBaseCreatedTimeEntity(PathMetadata metadata) { - super(BaseCreatedTimeEntity.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/common/base/QBaseTimeEntity.java b/src/main/generated/com/juu/juulabel/common/base/QBaseTimeEntity.java deleted file mode 100644 index acf26e86..00000000 --- a/src/main/generated/com/juu/juulabel/common/base/QBaseTimeEntity.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.juu.juulabel.common.base; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QBaseTimeEntity is a Querydsl query type for BaseTimeEntity - */ -@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") -public class QBaseTimeEntity extends EntityPathBase { - - private static final long serialVersionUID = 282798094L; - - public static final QBaseTimeEntity baseTimeEntity = new QBaseTimeEntity("baseTimeEntity"); - - public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); - - public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); - - public QBaseTimeEntity(String variable) { - super(BaseTimeEntity.class, forVariable(variable)); - } - - public QBaseTimeEntity(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QBaseTimeEntity(PathMetadata metadata) { - super(BaseTimeEntity.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLife.java b/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLife.java deleted file mode 100644 index d1d41961..00000000 --- a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLife.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.juu.juulabel.dailylife.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QDailyLife is a Querydsl query type for DailyLife - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDailyLife extends EntityPathBase { - - private static final long serialVersionUID = 153685271L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QDailyLife dailyLife = new QDailyLife("dailyLife"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isPrivate = createBoolean("isPrivate"); - - public final com.juu.juulabel.member.domain.QMember member; - - public final StringPath title = createString("title"); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QDailyLife(String variable) { - this(DailyLife.class, forVariable(variable), INITS); - } - - public QDailyLife(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QDailyLife(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QDailyLife(PathMetadata metadata, PathInits inits) { - this(DailyLife.class, metadata, inits); - } - - public QDailyLife(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeComment.java b/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeComment.java deleted file mode 100644 index 37b0cb45..00000000 --- a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeComment.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.juu.juulabel.dailylife.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QDailyLifeComment is a Querydsl query type for DailyLifeComment - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDailyLifeComment extends EntityPathBase { - - private static final long serialVersionUID = 1729618248L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QDailyLifeComment dailyLifeComment = new QDailyLifeComment("dailyLifeComment"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final QDailyLife dailyLife; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public final QDailyLifeComment parent; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QDailyLifeComment(String variable) { - this(DailyLifeComment.class, forVariable(variable), INITS); - } - - public QDailyLifeComment(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QDailyLifeComment(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QDailyLifeComment(PathMetadata metadata, PathInits inits) { - this(DailyLifeComment.class, metadata, inits); - } - - public QDailyLifeComment(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.dailyLife = inits.isInitialized("dailyLife") ? new QDailyLife(forProperty("dailyLife"), inits.get("dailyLife")) : null; - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - this.parent = inits.isInitialized("parent") ? new QDailyLifeComment(forProperty("parent"), inits.get("parent")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeCommentLike.java b/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeCommentLike.java deleted file mode 100644 index 07fa1453..00000000 --- a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeCommentLike.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.juu.juulabel.dailylife.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QDailyLifeCommentLike is a Querydsl query type for DailyLifeCommentLike - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDailyLifeCommentLike extends EntityPathBase { - - private static final long serialVersionUID = 1784291583L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QDailyLifeCommentLike dailyLifeCommentLike = new QDailyLifeCommentLike("dailyLifeCommentLike"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final QDailyLifeComment dailyLifeComment; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public QDailyLifeCommentLike(String variable) { - this(DailyLifeCommentLike.class, forVariable(variable), INITS); - } - - public QDailyLifeCommentLike(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QDailyLifeCommentLike(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QDailyLifeCommentLike(PathMetadata metadata, PathInits inits) { - this(DailyLifeCommentLike.class, metadata, inits); - } - - public QDailyLifeCommentLike(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.dailyLifeComment = inits.isInitialized("dailyLifeComment") ? new QDailyLifeComment(forProperty("dailyLifeComment"), inits.get("dailyLifeComment")) : null; - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeImage.java b/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeImage.java deleted file mode 100644 index 384dfacf..00000000 --- a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeImage.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.juu.juulabel.dailylife.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QDailyLifeImage is a Querydsl query type for DailyLifeImage - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDailyLifeImage extends EntityPathBase { - - private static final long serialVersionUID = -1561443708L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QDailyLifeImage dailyLifeImage = new QDailyLifeImage("dailyLifeImage"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final QDailyLife dailyLife; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath imagePath = createString("imagePath"); - - public final NumberPath seq = createNumber("seq", Integer.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QDailyLifeImage(String variable) { - this(DailyLifeImage.class, forVariable(variable), INITS); - } - - public QDailyLifeImage(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QDailyLifeImage(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QDailyLifeImage(PathMetadata metadata, PathInits inits) { - this(DailyLifeImage.class, metadata, inits); - } - - public QDailyLifeImage(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.dailyLife = inits.isInitialized("dailyLife") ? new QDailyLife(forProperty("dailyLife"), inits.get("dailyLife")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeLike.java b/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeLike.java deleted file mode 100644 index bd03a5b0..00000000 --- a/src/main/generated/com/juu/juulabel/dailylife/domain/QDailyLifeLike.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.juu.juulabel.dailylife.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QDailyLifeLike is a Querydsl query type for DailyLifeLike - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QDailyLifeLike extends EntityPathBase { - - private static final long serialVersionUID = 88264014L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QDailyLifeLike dailyLifeLike = new QDailyLifeLike("dailyLifeLike"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final QDailyLife dailyLife; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public QDailyLifeLike(String variable) { - this(DailyLifeLike.class, forVariable(variable), INITS); - } - - public QDailyLifeLike(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QDailyLifeLike(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QDailyLifeLike(PathMetadata metadata, PathInits inits) { - this(DailyLifeLike.class, metadata, inits); - } - - public QDailyLifeLike(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.dailyLife = inits.isInitialized("dailyLife") ? new QDailyLife(forProperty("dailyLife"), inits.get("dailyLife")) : null; - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/follow/domain/QFollow.java b/src/main/generated/com/juu/juulabel/follow/domain/QFollow.java deleted file mode 100644 index 74b41dc6..00000000 --- a/src/main/generated/com/juu/juulabel/follow/domain/QFollow.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.follow.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QFollow is a Querydsl query type for Follow - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QFollow extends EntityPathBase { - - private static final long serialVersionUID = 1222043633L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QFollow follow = new QFollow("follow"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath followedAt = createDateTime("followedAt", java.time.LocalDateTime.class); - - public final com.juu.juulabel.member.domain.QMember followee; - - public final com.juu.juulabel.member.domain.QMember follower; - - public final NumberPath id = createNumber("id", Long.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QFollow(String variable) { - this(Follow.class, forVariable(variable), INITS); - } - - public QFollow(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QFollow(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QFollow(PathMetadata metadata, PathInits inits) { - this(Follow.class, metadata, inits); - } - - public QFollow(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.followee = inits.isInitialized("followee") ? new com.juu.juulabel.member.domain.QMember(forProperty("followee")) : null; - this.follower = inits.isInitialized("follower") ? new com.juu.juulabel.member.domain.QMember(forProperty("follower")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/member/domain/QMember.java b/src/main/generated/com/juu/juulabel/member/domain/QMember.java deleted file mode 100644 index b81b2a59..00000000 --- a/src/main/generated/com/juu/juulabel/member/domain/QMember.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.juu.juulabel.member.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QMember is a Querydsl query type for Member - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QMember extends EntityPathBase { - - private static final long serialVersionUID = -1376470525L; - - public static final QMember member = new QMember("member1"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final StringPath email = createString("email"); - - public final EnumPath gender = createEnum("gender", Gender.class); - - public final BooleanPath hasBadge = createBoolean("hasBadge"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath introduction = createString("introduction"); - - public final BooleanPath isNotificationsAllowed = createBoolean("isNotificationsAllowed"); - - public final StringPath name = createString("name"); - - public final StringPath nickname = createString("nickname"); - - public final StringPath password = createString("password"); - - public final StringPath phone = createString("phone"); - - public final StringPath profileImage = createString("profileImage"); - - public final EnumPath provider = createEnum("provider", Provider.class); - - public final StringPath providerId = createString("providerId"); - - public final EnumPath role = createEnum("role", MemberRole.class); - - public final EnumPath status = createEnum("status", MemberStatus.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QMember(String variable) { - super(Member.class, forVariable(variable)); - } - - public QMember(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QMember(PathMetadata metadata) { - super(Member.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholType.java b/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholType.java deleted file mode 100644 index 1a67224d..00000000 --- a/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholType.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.member.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QMemberAlcoholType is a Querydsl query type for MemberAlcoholType - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QMemberAlcoholType extends EntityPathBase { - - private static final long serialVersionUID = 2046452069L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QMemberAlcoholType memberAlcoholType = new QMemberAlcoholType("memberAlcoholType"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final com.juu.juulabel.alcohol.domain.QAlcoholType alcoholType; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final QMember member; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QMemberAlcoholType(String variable) { - this(MemberAlcoholType.class, forVariable(variable), INITS); - } - - public QMemberAlcoholType(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QMemberAlcoholType(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QMemberAlcoholType(PathMetadata metadata, PathInits inits) { - this(MemberAlcoholType.class, metadata, inits); - } - - public QMemberAlcoholType(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholType = inits.isInitialized("alcoholType") ? new com.juu.juulabel.alcohol.domain.QAlcoholType(forProperty("alcoholType")) : null; - this.member = inits.isInitialized("member") ? new QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholicDrinks.java b/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholicDrinks.java deleted file mode 100644 index 9998c679..00000000 --- a/src/main/generated/com/juu/juulabel/member/domain/QMemberAlcoholicDrinks.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.juu.juulabel.member.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QMemberAlcoholicDrinks is a Querydsl query type for MemberAlcoholicDrinks - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QMemberAlcoholicDrinks extends EntityPathBase { - - private static final long serialVersionUID = -118220512L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QMemberAlcoholicDrinks memberAlcoholicDrinks = new QMemberAlcoholicDrinks("memberAlcoholicDrinks"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - public final com.juu.juulabel.alcohol.domain.QAlcoholicDrinks alcoholicDrinks; - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final QMember member; - - public QMemberAlcoholicDrinks(String variable) { - this(MemberAlcoholicDrinks.class, forVariable(variable), INITS); - } - - public QMemberAlcoholicDrinks(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QMemberAlcoholicDrinks(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QMemberAlcoholicDrinks(PathMetadata metadata, PathInits inits) { - this(MemberAlcoholicDrinks.class, metadata, inits); - } - - public QMemberAlcoholicDrinks(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholicDrinks = inits.isInitialized("alcoholicDrinks") ? new com.juu.juulabel.alcohol.domain.QAlcoholicDrinks(forProperty("alcoholicDrinks"), inits.get("alcoholicDrinks")) : null; - this.member = inits.isInitialized("member") ? new QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/member/domain/QMemberTerms.java b/src/main/generated/com/juu/juulabel/member/domain/QMemberTerms.java deleted file mode 100644 index 417f946c..00000000 --- a/src/main/generated/com/juu/juulabel/member/domain/QMemberTerms.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.juu.juulabel.member.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QMemberTerms is a Querydsl query type for MemberTerms - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QMemberTerms extends EntityPathBase { - - private static final long serialVersionUID = 1507682628L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QMemberTerms memberTerms = new QMemberTerms("memberTerms"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final DateTimePath agreedAt = createDateTime("agreedAt", java.time.LocalDateTime.class); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final QMember member; - - public final com.juu.juulabel.terms.domain.QTerms terms; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QMemberTerms(String variable) { - this(MemberTerms.class, forVariable(variable), INITS); - } - - public QMemberTerms(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QMemberTerms(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QMemberTerms(PathMetadata metadata, PathInits inits) { - this(MemberTerms.class, metadata, inits); - } - - public QMemberTerms(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.member = inits.isInitialized("member") ? new QMember(forProperty("member")) : null; - this.terms = inits.isInitialized("terms") ? new com.juu.juulabel.terms.domain.QTerms(forProperty("terms")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/member/domain/QWithdrawalRecord.java b/src/main/generated/com/juu/juulabel/member/domain/QWithdrawalRecord.java deleted file mode 100644 index 0df2362a..00000000 --- a/src/main/generated/com/juu/juulabel/member/domain/QWithdrawalRecord.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.juu.juulabel.member.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QWithdrawalRecord is a Querydsl query type for WithdrawalRecord - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QWithdrawalRecord extends EntityPathBase { - - private static final long serialVersionUID = 1674934671L; - - public static final QWithdrawalRecord withdrawalRecord = new QWithdrawalRecord("withdrawalRecord"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final StringPath email = createString("email"); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath nickname = createString("nickname"); - - public final StringPath withdrawalReason = createString("withdrawalReason"); - - public QWithdrawalRecord(String variable) { - super(WithdrawalRecord.class, forVariable(variable)); - } - - public QWithdrawalRecord(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QWithdrawalRecord(PathMetadata metadata) { - super(WithdrawalRecord.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/notification/domain/QNotification.java b/src/main/generated/com/juu/juulabel/notification/domain/QNotification.java deleted file mode 100644 index 2eaa832b..00000000 --- a/src/main/generated/com/juu/juulabel/notification/domain/QNotification.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.juu.juulabel.notification.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QNotification is a Querydsl query type for Notification - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QNotification extends EntityPathBase { - - private static final long serialVersionUID = -1125011675L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QNotification notification = new QNotification("notification"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - public final NumberPath commentId = createNumber("commentId", Long.class); - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isRead = createBoolean("isRead"); - - public final EnumPath notificationType = createEnum("notificationType", NotificationType.class); - - public final StringPath profileImageUrl = createString("profileImageUrl"); - - public final com.juu.juulabel.member.domain.QMember receiver; - - public final StringPath relatedUrl = createString("relatedUrl"); - - public QNotification(String variable) { - this(Notification.class, forVariable(variable), INITS); - } - - public QNotification(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QNotification(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QNotification(PathMetadata metadata, PathInits inits) { - this(Notification.class, metadata, inits); - } - - public QNotification(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.receiver = inits.isInitialized("receiver") ? new com.juu.juulabel.member.domain.QMember(forProperty("receiver")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/report/QReport.java b/src/main/generated/com/juu/juulabel/report/QReport.java deleted file mode 100644 index a6c3665f..00000000 --- a/src/main/generated/com/juu/juulabel/report/QReport.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.juu.juulabel.report; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QReport is a Querydsl query type for Report - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QReport extends EntityPathBase { - - private static final long serialVersionUID = 469175163L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QReport report = new QReport("report"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath reason = createString("reason"); - - public final NumberPath reportedContentId = createNumber("reportedContentId", Long.class); - - public final com.juu.juulabel.member.domain.QMember reporter; - - public final DateTimePath reviewedAt = createDateTime("reviewedAt", java.time.LocalDateTime.class); - - public final com.juu.juulabel.member.domain.QMember reviewer; - - public final EnumPath status = createEnum("status", ReportStatus.class); - - public final EnumPath type = createEnum("type", ReportType.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QReport(String variable) { - this(Report.class, forVariable(variable), INITS); - } - - public QReport(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QReport(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QReport(PathMetadata metadata, PathInits inits) { - this(Report.class, metadata, inits); - } - - public QReport(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.reporter = inits.isInitialized("reporter") ? new com.juu.juulabel.member.domain.QMember(forProperty("reporter")) : null; - this.reviewer = inits.isInitialized("reviewer") ? new com.juu.juulabel.member.domain.QMember(forProperty("reviewer")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNote.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNote.java deleted file mode 100644 index 0d6269c2..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNote.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNote is a Querydsl query type for TastingNote - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNote extends EntityPathBase { - - private static final long serialVersionUID = 544443447L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNote tastingNote = new QTastingNote("tastingNote"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final com.juu.juulabel.tastingnote.domain.embedded.QAlcoholicDrinksSnapshot alcoholDrinksInfo; - - public final com.juu.juulabel.alcohol.domain.QAlcoholicDrinks alcoholicDrinks; - - public final com.juu.juulabel.alcohol.domain.QAlcoholType alcoholType; - - public final com.juu.juulabel.alcohol.domain.QColor color; - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isPrivate = createBoolean("isPrivate"); - - public final com.juu.juulabel.member.domain.QMember member; - - public final NumberPath rating = createNumber("rating", Double.class); - - public final ListPath tastingNoteScents = this.createList("tastingNoteScents", TastingNoteScent.class, QTastingNoteScent.class, PathInits.DIRECT2); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNote(String variable) { - this(TastingNote.class, forVariable(variable), INITS); - } - - public QTastingNote(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNote(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNote(PathMetadata metadata, PathInits inits) { - this(TastingNote.class, metadata, inits); - } - - public QTastingNote(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.alcoholDrinksInfo = inits.isInitialized("alcoholDrinksInfo") ? new com.juu.juulabel.tastingnote.domain.embedded.QAlcoholicDrinksSnapshot(forProperty("alcoholDrinksInfo")) : null; - this.alcoholicDrinks = inits.isInitialized("alcoholicDrinks") ? new com.juu.juulabel.alcohol.domain.QAlcoholicDrinks(forProperty("alcoholicDrinks"), inits.get("alcoholicDrinks")) : null; - this.alcoholType = inits.isInitialized("alcoholType") ? new com.juu.juulabel.alcohol.domain.QAlcoholType(forProperty("alcoholType")) : null; - this.color = inits.isInitialized("color") ? new com.juu.juulabel.alcohol.domain.QColor(forProperty("color")) : null; - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteComment.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteComment.java deleted file mode 100644 index 02db1d84..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteComment.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteComment is a Querydsl query type for TastingNoteComment - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteComment extends EntityPathBase { - - private static final long serialVersionUID = 435559976L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteComment tastingNoteComment = new QTastingNoteComment("tastingNoteComment"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public final QTastingNoteComment parent; - - public final QTastingNote tastingNote; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNoteComment(String variable) { - this(TastingNoteComment.class, forVariable(variable), INITS); - } - - public QTastingNoteComment(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteComment(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteComment(PathMetadata metadata, PathInits inits) { - this(TastingNoteComment.class, metadata, inits); - } - - public QTastingNoteComment(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - this.parent = inits.isInitialized("parent") ? new QTastingNoteComment(forProperty("parent"), inits.get("parent")) : null; - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteCommentLike.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteCommentLike.java deleted file mode 100644 index 278182fe..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteCommentLike.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteCommentLike is a Querydsl query type for TastingNoteCommentLike - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteCommentLike extends EntityPathBase { - - private static final long serialVersionUID = -670110241L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteCommentLike tastingNoteCommentLike = new QTastingNoteCommentLike("tastingNoteCommentLike"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public final QTastingNoteComment tastingNoteComment; - - public QTastingNoteCommentLike(String variable) { - this(TastingNoteCommentLike.class, forVariable(variable), INITS); - } - - public QTastingNoteCommentLike(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteCommentLike(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteCommentLike(PathMetadata metadata, PathInits inits) { - this(TastingNoteCommentLike.class, metadata, inits); - } - - public QTastingNoteCommentLike(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - this.tastingNoteComment = inits.isInitialized("tastingNoteComment") ? new QTastingNoteComment(forProperty("tastingNoteComment"), inits.get("tastingNoteComment")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteFlavorLevel.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteFlavorLevel.java deleted file mode 100644 index 22fdc745..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteFlavorLevel.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteFlavorLevel is a Querydsl query type for TastingNoteFlavorLevel - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteFlavorLevel extends EntityPathBase { - - private static final long serialVersionUID = -2092980529L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteFlavorLevel tastingNoteFlavorLevel = new QTastingNoteFlavorLevel("tastingNoteFlavorLevel"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final com.juu.juulabel.alcohol.domain.QFlavorLevel flavorLevel; - - public final NumberPath id = createNumber("id", Long.class); - - public final QTastingNote tastingNote; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNoteFlavorLevel(String variable) { - this(TastingNoteFlavorLevel.class, forVariable(variable), INITS); - } - - public QTastingNoteFlavorLevel(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteFlavorLevel(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteFlavorLevel(PathMetadata metadata, PathInits inits) { - this(TastingNoteFlavorLevel.class, metadata, inits); - } - - public QTastingNoteFlavorLevel(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.flavorLevel = inits.isInitialized("flavorLevel") ? new com.juu.juulabel.alcohol.domain.QFlavorLevel(forProperty("flavorLevel"), inits.get("flavorLevel")) : null; - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteImage.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteImage.java deleted file mode 100644 index a88c1103..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteImage.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteImage is a Querydsl query type for TastingNoteImage - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteImage extends EntityPathBase { - - private static final long serialVersionUID = 2012624740L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteImage tastingNoteImage = new QTastingNoteImage("tastingNoteImage"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final StringPath imagePath = createString("imagePath"); - - public final NumberPath seq = createNumber("seq", Integer.class); - - public final QTastingNote tastingNote; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNoteImage(String variable) { - this(TastingNoteImage.class, forVariable(variable), INITS); - } - - public QTastingNoteImage(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteImage(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteImage(PathMetadata metadata, PathInits inits) { - this(TastingNoteImage.class, metadata, inits); - } - - public QTastingNoteImage(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteLike.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteLike.java deleted file mode 100644 index 17ca2c4b..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteLike.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteLike is a Querydsl query type for TastingNoteLike - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteLike extends EntityPathBase { - - private static final long serialVersionUID = 1727577198L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteLike tastingNoteLike = new QTastingNoteLike("tastingNoteLike"); - - public final com.juu.juulabel.common.base.QBaseCreatedTimeEntity _super = new com.juu.juulabel.common.base.QBaseCreatedTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.member.domain.QMember member; - - public final QTastingNote tastingNote; - - public QTastingNoteLike(String variable) { - this(TastingNoteLike.class, forVariable(variable), INITS); - } - - public QTastingNoteLike(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteLike(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteLike(PathMetadata metadata, PathInits inits) { - this(TastingNoteLike.class, metadata, inits); - } - - public QTastingNoteLike(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.member = inits.isInitialized("member") ? new com.juu.juulabel.member.domain.QMember(forProperty("member")) : null; - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteScent.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteScent.java deleted file mode 100644 index f55c1dac..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteScent.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteScent is a Querydsl query type for TastingNoteScent - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteScent extends EntityPathBase { - - private static final long serialVersionUID = 2021566116L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteScent tastingNoteScent = new QTastingNoteScent("tastingNoteScent"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.alcohol.domain.QScent scent; - - public final QTastingNote tastingNote; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNoteScent(String variable) { - this(TastingNoteScent.class, forVariable(variable), INITS); - } - - public QTastingNoteScent(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteScent(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteScent(PathMetadata metadata, PathInits inits) { - this(TastingNoteScent.class, metadata, inits); - } - - public QTastingNoteScent(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.scent = inits.isInitialized("scent") ? new com.juu.juulabel.alcohol.domain.QScent(forProperty("scent")) : null; - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteSensoryLevel.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteSensoryLevel.java deleted file mode 100644 index cd08dbf5..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/QTastingNoteSensoryLevel.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.juu.juulabel.tastingnote.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; -import com.querydsl.core.types.dsl.PathInits; - - -/** - * QTastingNoteSensoryLevel is a Querydsl query type for TastingNoteSensoryLevel - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTastingNoteSensoryLevel extends EntityPathBase { - - private static final long serialVersionUID = 751400092L; - - private static final PathInits INITS = PathInits.DIRECT2; - - public static final QTastingNoteSensoryLevel tastingNoteSensoryLevel = new QTastingNoteSensoryLevel("tastingNoteSensoryLevel"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final NumberPath id = createNumber("id", Long.class); - - public final com.juu.juulabel.alcohol.domain.QSensoryLevel sensoryLevel; - - public final QTastingNote tastingNote; - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTastingNoteSensoryLevel(String variable) { - this(TastingNoteSensoryLevel.class, forVariable(variable), INITS); - } - - public QTastingNoteSensoryLevel(Path path) { - this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); - } - - public QTastingNoteSensoryLevel(PathMetadata metadata) { - this(metadata, PathInits.getFor(metadata, INITS)); - } - - public QTastingNoteSensoryLevel(PathMetadata metadata, PathInits inits) { - this(TastingNoteSensoryLevel.class, metadata, inits); - } - - public QTastingNoteSensoryLevel(Class type, PathMetadata metadata, PathInits inits) { - super(type, metadata, inits); - this.sensoryLevel = inits.isInitialized("sensoryLevel") ? new com.juu.juulabel.alcohol.domain.QSensoryLevel(forProperty("sensoryLevel"), inits.get("sensoryLevel")) : null; - this.tastingNote = inits.isInitialized("tastingNote") ? new QTastingNote(forProperty("tastingNote"), inits.get("tastingNote")) : null; - } - -} - diff --git a/src/main/generated/com/juu/juulabel/tastingnote/domain/embedded/QAlcoholicDrinksSnapshot.java b/src/main/generated/com/juu/juulabel/tastingnote/domain/embedded/QAlcoholicDrinksSnapshot.java deleted file mode 100644 index ee8a9f79..00000000 --- a/src/main/generated/com/juu/juulabel/tastingnote/domain/embedded/QAlcoholicDrinksSnapshot.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.juu.juulabel.tastingnote.domain.embedded; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QAlcoholicDrinksSnapshot is a Querydsl query type for AlcoholicDrinksSnapshot - */ -@Generated("com.querydsl.codegen.DefaultEmbeddableSerializer") -public class QAlcoholicDrinksSnapshot extends BeanPath { - - private static final long serialVersionUID = 579942130L; - - public static final QAlcoholicDrinksSnapshot alcoholicDrinksSnapshot = new QAlcoholicDrinksSnapshot("alcoholicDrinksSnapshot"); - - public final NumberPath alcoholContent = createNumber("alcoholContent", Double.class); - - public final StringPath alcoholicDrinksName = createString("alcoholicDrinksName"); - - public final StringPath alcoholTypeName = createString("alcoholTypeName"); - - public final StringPath breweryName = createString("breweryName"); - - public final StringPath breweryRegion = createString("breweryRegion"); - - public QAlcoholicDrinksSnapshot(String variable) { - super(AlcoholicDrinksSnapshot.class, forVariable(variable)); - } - - public QAlcoholicDrinksSnapshot(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QAlcoholicDrinksSnapshot(PathMetadata metadata) { - super(AlcoholicDrinksSnapshot.class, metadata); - } - -} - diff --git a/src/main/generated/com/juu/juulabel/terms/domain/QTerms.java b/src/main/generated/com/juu/juulabel/terms/domain/QTerms.java deleted file mode 100644 index 64210851..00000000 --- a/src/main/generated/com/juu/juulabel/terms/domain/QTerms.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.juu.juulabel.terms.domain; - -import static com.querydsl.core.types.PathMetadataFactory.*; - -import com.querydsl.core.types.dsl.*; - -import com.querydsl.core.types.PathMetadata; -import javax.annotation.processing.Generated; -import com.querydsl.core.types.Path; - - -/** - * QTerms is a Querydsl query type for Terms - */ -@Generated("com.querydsl.codegen.DefaultEntitySerializer") -public class QTerms extends EntityPathBase { - - private static final long serialVersionUID = 1516037303L; - - public static final QTerms terms = new QTerms("terms"); - - public final com.juu.juulabel.common.base.QBaseTimeEntity _super = new com.juu.juulabel.common.base.QBaseTimeEntity(this); - - public final StringPath content = createString("content"); - - //inherited - public final DateTimePath createdAt = _super.createdAt; - - public final DateTimePath deletedAt = createDateTime("deletedAt", java.time.LocalDateTime.class); - - public final NumberPath id = createNumber("id", Long.class); - - public final BooleanPath isRequired = createBoolean("isRequired"); - - public final BooleanPath isUsed = createBoolean("isUsed"); - - public final StringPath title = createString("title"); - - public final EnumPath type = createEnum("type", TermsType.class); - - //inherited - public final DateTimePath updatedAt = _super.updatedAt; - - public QTerms(String variable) { - super(Terms.class, forVariable(variable)); - } - - public QTerms(Path path) { - super(path.getType(), path.getMetadata()); - } - - public QTerms(PathMetadata metadata) { - super(Terms.class, metadata); - } - -} - diff --git a/src/main/java/com/juu/juulabel/common/config/QuerydslConfig.java b/src/main/java/com/juu/juulabel/common/config/QuerydslConfig.java index 7c9fe38c..c5bb9a93 100644 --- a/src/main/java/com/juu/juulabel/common/config/QuerydslConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/QuerydslConfig.java @@ -9,11 +9,11 @@ @Configuration public class QuerydslConfig { - @PersistenceContext - private EntityManager entityManager; + @PersistenceContext + private EntityManager entityManager; - @Bean - public JPAQueryFactory queryFactory() { - return new JPAQueryFactory(entityManager); - } + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } } diff --git a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java index 515f5219..f0bc665e 100644 --- a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java +++ b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java @@ -5,7 +5,6 @@ import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.domain.Provider; -import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.converter.Converter; import org.springframework.stereotype.Component; @@ -24,7 +23,7 @@ public Provider convert(String source) { throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); } return provider; - } catch (ConversionFailedException | InvalidParamException | IllegalArgumentException e) { + } catch (IllegalArgumentException e) { throw new BaseException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); } } diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java index 5f6da897..a9641f50 100644 --- a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java +++ b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java @@ -37,7 +37,7 @@ public final class IpAddressExtractor extends AbstractHttpUtil { // IPv6 pattern (simplified) private static final Pattern IPV6_PATTERN = Pattern.compile( - "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$"); + "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|:((:[0-9a-fA-F]{1,4}){1,7}|:)|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4})$"); /** * Private constructor to prevent instantiation diff --git a/src/main/java/com/juu/juulabel/s3/S3Controller.java b/src/main/java/com/juu/juulabel/s3/S3Controller.java index 5edc4bce..ecf494fa 100644 --- a/src/main/java/com/juu/juulabel/s3/S3Controller.java +++ b/src/main/java/com/juu/juulabel/s3/S3Controller.java @@ -14,12 +14,9 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -@Tag( - name = "이미지 파일 업로드 API", - description = "프론트 개발 편의를 위한 이미지 개별 업로드 API" -) +@Tag(name = "이미지 파일 업로드 API", description = "프론트 개발 편의를 위한 이미지 개별 업로드 API") @RestController -@RequestMapping(value = {"/v1/api/images"}) +@RequestMapping(value = { "/v1/api/images" }) @RequiredArgsConstructor public class S3Controller { @@ -28,8 +25,7 @@ public class S3Controller { @Operation(summary = "이미지 파일 업로드") @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> uploadImage( - @RequestPart(value = "image") MultipartFile image - ) { + @RequestPart(value = "image") MultipartFile image) { return CommonResponse.success(SuccessCode.SUCCESS, s3Service.uploadMemberImage(image)); } diff --git a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua index 5f208d93..beedff35 100644 --- a/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua +++ b/src/main/resources/scripts/revoke_refresh_token_by_index_key.lua @@ -1,14 +1,40 @@ local pattern = KEYS[1] -local indexKeys = redis.call("KEYS", pattern) +-- Use SCAN instead of KEYS for better performance with large datasets +local cursor = "0" +local batchSize = 100 -for _, idxKey in ipairs(indexKeys) do - local tokenKeys = redis.call("SMEMBERS", idxKey) - for _, tokenKey in ipairs(tokenKeys) do - redis.call("HSET", tokenKey, "revoked", 1) +repeat + local result = redis.call("SCAN", cursor, "MATCH", pattern, "COUNT", batchSize) + cursor = result[1] + local indexKeys = result[2] + + for _, idxKey in ipairs(indexKeys) do + local tokenKeys = redis.call("SMEMBERS", idxKey) + + -- Batch revoke tokens to reduce Redis calls + if #tokenKeys > 0 then + for i = 1, #tokenKeys, batchSize do + local batch = {} + local endIdx = math.min(i + batchSize - 1, #tokenKeys) + + for j = i, endIdx do + table.insert(batch, "HSET") + table.insert(batch, tokenKeys[j]) + table.insert(batch, "revoked") + table.insert(batch, 1) + end + + if #batch > 0 then + redis.call(unpack(batch)) + end + end + + -- Clean up the index key + redis.call("DEL", idxKey) + end end - redis.call("SREM", idxKey, tokenKey) -end +until cursor == "0" return { ok = "REVOKED_ALL_TOKENS_BY_INDEX_KEY" From fe986c1e75856e628d33998f22011169b36fd505 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 26 May 2025 20:24:00 +0900 Subject: [PATCH 6/7] Enhance Redis configuration for dynamic connection settings - Updated RedisConfig to use properties for host, port, and SSL settings, allowing for more flexible configuration. - Improved connection factory setup to conditionally enable SSL based on configuration values. --- .../juulabel/common/config/RedisConfig.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/juu/juulabel/common/config/RedisConfig.java b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java index e4231250..f7c37545 100644 --- a/src/main/java/com/juu/juulabel/common/config/RedisConfig.java +++ b/src/main/java/com/juu/juulabel/common/config/RedisConfig.java @@ -1,5 +1,6 @@ package com.juu.juulabel.common.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -13,14 +14,29 @@ @Configuration public class RedisConfig { + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.ssl.enabled}") + private boolean sslEnabled; + @Bean - public LettuceConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379); + LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + + LettuceClientConfiguration clientConfig; - LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() - .useSsl() - .disablePeerVerification() // trust any certificate (disable hostname check) - .build(); + if (sslEnabled) { + clientConfig = LettuceClientConfiguration.builder() + .useSsl() + .disablePeerVerification() // trust any certificate (disable hostname check) + .build(); + } else { + clientConfig = LettuceClientConfiguration.builder().build(); + } return new LettuceConnectionFactory(config, clientConfig); } From 5725a469e3347fc2697987b68acfdeac8f135b10 Mon Sep 17 00:00:00 2001 From: Youngjun Kim <158126791+Youngjun97@users.noreply.github.com> Date: Mon, 26 May 2025 20:34:44 +0900 Subject: [PATCH 7/7] Refactor ProviderConverter and enhance IP address validation - Replaced InvalidParamException with BaseException in ProviderConverter for improved error handling. - Added support for IPv6 private address validation in IpAddressExtractor, enhancing the method to check for private IP ranges. --- .../common/converter/ProviderConverter.java | 3 +-- .../common/util/IpAddressExtractor.java | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java index f0bc665e..a6b53546 100644 --- a/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java +++ b/src/main/java/com/juu/juulabel/common/converter/ProviderConverter.java @@ -1,7 +1,6 @@ package com.juu.juulabel.common.converter; import com.juu.juulabel.common.exception.BaseException; -import com.juu.juulabel.common.exception.InvalidParamException; import com.juu.juulabel.common.exception.code.ErrorCode; import com.juu.juulabel.member.domain.Provider; @@ -20,7 +19,7 @@ public Provider convert(String source) { try { final Provider provider = Provider.valueOf(source.toUpperCase()); if (!ALLOWED_PROVIDERS.contains(provider)) { - throw new InvalidParamException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); + throw new BaseException(ErrorCode.OAUTH_PROVIDER_NOT_FOUND); } return provider; } catch (IllegalArgumentException e) { diff --git a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java index a9641f50..f2ebf42e 100644 --- a/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java +++ b/src/main/java/com/juu/juulabel/common/util/IpAddressExtractor.java @@ -132,17 +132,32 @@ private static boolean isPublicIpAddress(String ip) { * Check if IP is in private ranges */ private static boolean isPrivateIpAddress(String ip) { + // Check IPv6 private ranges first + if (ip.contains(":")) { + return isPrivateIpv6(ip); + } + if (ip.startsWith("10.") || ip.startsWith("192.168.")) { return true; } - if (ip.startsWith("172.")) { return isPrivate172Range(ip); } - return false; } + private static boolean isPrivateIpv6(String ip) { + try { + InetAddress addr = InetAddress.getByName(ip); + return addr.isSiteLocalAddress() + || addr.isLinkLocalAddress() + || ip.toLowerCase().startsWith("fc") + || ip.toLowerCase().startsWith("fd"); + } catch (UnknownHostException e) { + return false; + } + } + /** * Check if 172.x.x.x IP is in private range (172.16.0.0 to 172.31.255.255) */