diff --git a/module-app/src/main/resources/application.yml b/module-app/src/main/resources/application.yml index 06a0ff3e..ef00f191 100644 --- a/module-app/src/main/resources/application.yml +++ b/module-app/src/main/resources/application.yml @@ -120,6 +120,14 @@ auth: PathOption: ${JWT_COOKIE_PATH} secureOption: ${JWT_SECURE_OPTION} sameSiteOption: ${JWT_SAME_SITE} + # Exchange code 설정 + exchange: + code: + ttl-seconds: 60 + # OAuth2 임시 사용자 정보 TTL + oauth2: + temp-user: + ttl-minutes: 5 # 카카오 좌표 설정 kakao: diff --git a/module-app/src/main/resources/logback-spring.xml b/module-app/src/main/resources/logback-spring.xml index 7bbce494..7e7e3f75 100644 --- a/module-app/src/main/resources/logback-spring.xml +++ b/module-app/src/main/resources/logback-spring.xml @@ -6,7 +6,8 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - + %msg%n diff --git a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java index 64aa3c27..b10c525a 100644 --- a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java +++ b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/SecurityErrorCode.java @@ -37,12 +37,21 @@ public enum SecurityErrorCode implements ErrorCode { REFRESH_TOKEN_NOT_FOUND(401_112, HttpStatus.UNAUTHORIZED, "리프레쉬 토큰이 없기에 재로그인이 필요합니다."), REFRESH_TOKEN_LOGOUT(401_113, HttpStatus.UNAUTHORIZED, "리프레쉬 토큰을 사용한 로그아웃에 유저가 다릅니다."), + EXCHANGE_CODE_INVALID(401_114, HttpStatus.UNAUTHORIZED, "유효하지 않은 Exchange Code입니다."), + EXCHANGE_CODE_EXPIRED(401_115, HttpStatus.UNAUTHORIZED, "만료된 Exchange Code입니다."), + + // ======================== + // 429 Too Many Requests + // ======================== + EXCHANGE_CODE_RATE_LIMITED(429_100, HttpStatus.TOO_MANY_REQUESTS, "Exchange Code 교환 요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + // ======================== // 403 Forbidden // ======================== FORBIDDEN(403_100, HttpStatus.FORBIDDEN, "접속 권한이 없습니다."), ACCESS_DENY(403_101, HttpStatus.FORBIDDEN, "접근이 거부되었습니다."), UNAUTHORIZED_POST_ACCESS(403_102, HttpStatus.FORBIDDEN, "해당 게시글에 접근할 권한이 없습니다."), + INTERNAL_API_FORBIDDEN(403_103, HttpStatus.FORBIDDEN, "내부 API 접근이 거부되었습니다."), // ======================== // 404 Not Found diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java index 6d5c4780..ba8cbdba 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisDetailResponse.java @@ -64,6 +64,7 @@ public static DiagnosisDetailResponse from(EvaluationContext context) { ? List.of("해당 없음") : context.getCurrentCandidates().stream() .map((c -> c.noticeType().getValue() + " : " + c.supplyType().getValue())) + .distinct() .toList(); // 지원 가능한 공급 유형 (중복 제거) diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java index b37099b7..d4c360c0 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/application/dto/response/DiagnosisResponse.java @@ -34,6 +34,7 @@ public static DiagnosisResponse from(EvaluationContext context) { ? List.of("해당 없음") : context.getCurrentCandidates().stream() .map((c -> c.noticeType().getValue() + " : " + c.supplyType().getValue())) + .distinct() .toList(); return DiagnosisResponse.builder() diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java index c138fb72..281380f2 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/application/service/PolicyProvider.java @@ -154,21 +154,21 @@ private double incomeByFamily(int familyCount, Map table, doubl /** * 최대 총자산 제한 (단위: 원) - * 통합공공임대/국민임대/행복주택: 총자산 33,700만원 이하 - * 영구임대: 총자산 23,700만원 이하 + * 통합공공임대/국민임대: 총자산 34,500만원 이하 + * 영구임대: 총자산 24,500만원 이하 * 장기전세/공공임대: 부동산 21,550만원 이하 */ @Override public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount) { return switch (rental) { - /// 통합공공임대 - 총자산 337,000,000원 (우선공급 중 일부 적용 제외) - case PUBLIC_INTEGRATED -> 337_000_000L; + /// 통합공공임대 - 총자산 345,000,000원 + case PUBLIC_INTEGRATED -> 345_000_000L; - /// 영구임대 - 총자산 237,000,000원 (수급자 등 일부 대상 제외) - case PERMANENT_RENTAL -> 237_000_000L; + /// 영구임대 - 총자산 245,000,000원 + case PERMANENT_RENTAL -> 245_000_000L; - /// 국민임대 - 총자산 337,000,000원 (우선공급 중 일부 적용 제외) - case NATIONAL_RENTAL -> 337_000_000L; + /// 국민임대 - 총자산 345,000,000원 + case NATIONAL_RENTAL -> 345_000_000L; /// 장기전세 - 부동산 215,500,000원 (우선공급 중 일부 적용 제외) case LONG_TERM_JEONSE -> 215_500_000L; @@ -178,11 +178,11 @@ public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount) /// 행복주택 - 계층별 총자산 기준 상이 case HAPPY_HOUSING -> switch (supply) { - /// 대학생 특별공급 - 총자산 104,000,000원 (1억 400만원) - case STUDENT_SPECIAL -> 104_000_000L; + /// 대학생 특별공급 - 총자산 108,000,000원 + case STUDENT_SPECIAL -> 108_000_000L; - /// 청년 특별공급 - 총자산 254,000,000원 (2억 5천 4백만원) - 맞벌이 등 고려 - case YOUTH_SPECIAL -> 254_000_000L; + /// 청년 특별공급 - 총자산 251,000,000원 + case YOUTH_SPECIAL -> 251_000_000L; /// 기타 - 총자산 337,000,000원 (3억 3천 7백만원) default -> 337_000_000L; @@ -194,12 +194,12 @@ public long maxTotalAsset(SupplyType supply, NoticeType rental, int familyCount) } /** - * 자동차 자산 제한 - 45,630,000원 (4,563만원) + * 자동차 자산 제한 - 45,420,000원 (4,542만원) * 모든 임대주택 유형 공통 적용 */ @Override public long checkMaxCarValue() { - return 45_630_000L; + return 45_420_000L; } @Override diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java new file mode 100644 index 00000000..e5b21691 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/CandidatePolicyKey.java @@ -0,0 +1,67 @@ +package co.kr.pinhouse.domain.diagnostic.rule.domain.entity; + +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType; + +/** + * 내부 정책 variant. + * 응답 DTO에는 노출하지 않고, 후보별 세부 판정과 exact-support 여부만 관리한다. + */ +public enum CandidatePolicyKey { + + DEFAULT(true, null), + INTEGRATED_YOUTH_18_TO_39(true, null), + YOUTH_19_TO_39(true, null), + SINGLE_PARENT_WITH_INFANT_OR_FETUS(true, null), + NEWLY_MARRIED_GENERIC(true, null), + UNSUPPORTED_PUBLIC_RENTAL_PRODUCT( + false, "공공임대는 5년·6년·10년 유형과 모집공고별 면적 기준 정보가 필요합니다."), + UNSUPPORTED_LONG_TERM_JEONSE_REGION( + false, "장기전세는 지역별 자산 기준과 모집공고 정보가 필요합니다."), + UNSUPPORTED_STUDENT_PARENT_INCOME( + false, "행복주택 대학생·취업준비생은 부모 소득 및 자산 정보가 필요합니다."), + UNSUPPORTED_MINOR_CHILD_AGE( + false, "신생아 특별공급은 2세 미만 자녀 여부를 확인할 입력이 필요합니다."), + UNSUPPORTED_FIRST_HOME_HISTORY( + false, "생애최초 특별공급은 과거 주택 소유 이력과 소득세 납부 이력이 필요합니다."), + UNSUPPORTED_ELDER_SUPPORT_DURATION( + false, "노부모부양 특별공급은 1년 또는 3년 이상 부양기간 정보가 필요합니다."); + + private final boolean exactSupported; + private final String unsupportedReason; + + CandidatePolicyKey(boolean exactSupported, String unsupportedReason) { + this.exactSupported = exactSupported; + this.unsupportedReason = unsupportedReason; + } + + public static CandidatePolicyKey resolve(NoticeType noticeType, SupplyType supplyType) { + if (noticeType == NoticeType.PUBLIC_RENTAL) { + return UNSUPPORTED_PUBLIC_RENTAL_PRODUCT; + } + + if (noticeType == NoticeType.LONG_TERM_JEONSE) { + return UNSUPPORTED_LONG_TERM_JEONSE_REGION; + } + + return switch (supplyType) { + case STUDENT_SPECIAL -> UNSUPPORTED_STUDENT_PARENT_INCOME; + case MINOR_SPECIAL -> UNSUPPORTED_MINOR_CHILD_AGE; + case FIRST_SPECIAL -> UNSUPPORTED_FIRST_HOME_HISTORY; + case ELDER_SUPPORT_SPECIAL -> UNSUPPORTED_ELDER_SUPPORT_DURATION; + case YOUTH_SPECIAL -> noticeType == NoticeType.PUBLIC_INTEGRATED + ? INTEGRATED_YOUTH_18_TO_39 + : YOUTH_19_TO_39; + case SINGLE_PARENT_SPECIAL -> SINGLE_PARENT_WITH_INFANT_OR_FETUS; + case NEWCOUPLE_SPECIAL -> NEWLY_MARRIED_GENERIC; + default -> DEFAULT; + }; + } + + public boolean exactSupported() { + return exactSupported; + } + + public String unsupportedReason() { + return unsupportedReason; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java index b5a08c23..76d8fe47 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/RentalSupplyMapping.java @@ -9,26 +9,35 @@ public class RentalSupplyMapping { public static final Map> RENTAL_SUPPLY_MAP = Map.of( NoticeType.PUBLIC_INTEGRATED, List.of( - SupplyType.SPECIAL, // 미성년자 SupplyType.YOUTH_SPECIAL, SupplyType.ELDER_SPECIAL, SupplyType.NEWCOUPLE_SPECIAL, SupplyType.SINGLE_PARENT_SPECIAL, SupplyType.GENERAL, SupplyType.MULTICHILD_SPECIAL, + SupplyType.ELDER_SUPPORT_SPECIAL, SupplyType.DEMOLITION, SupplyType.NATIONAL_MERIT, SupplyType.LONG_SERVICE_VETERAN, SupplyType.NORTH_DEFECTOR, + SupplyType.COMFORT_WOMAN_VICTIM, SupplyType.DISABLED, - SupplyType.NON_HOUSING_RESIDENT + SupplyType.NON_HOUSING_RESIDENT, + SupplyType.PERMANENT_LEASE_EVICTEE ), NoticeType.NATIONAL_RENTAL, List.of( - SupplyType.SPECIAL, // 미성년자 SupplyType.NEWCOUPLE_SPECIAL, SupplyType.SINGLE_PARENT_SPECIAL, + SupplyType.MULTICHILD_SPECIAL, + SupplyType.ELDER_SUPPORT_SPECIAL, SupplyType.GENERAL, SupplyType.DEMOLITION, + SupplyType.NATIONAL_MERIT, + SupplyType.LONG_SERVICE_VETERAN, + SupplyType.NORTH_DEFECTOR, + SupplyType.DISABLED, + SupplyType.PERMANENT_LEASE_EVICTEE, + SupplyType.TEMPORARY_STRUCTURE_RESIDENT, SupplyType.UNAUTHORIZED_BUILDING_TENANT ), NoticeType.HAPPY_HOUSING, List.of( @@ -36,11 +45,7 @@ public class RentalSupplyMapping { SupplyType.YOUTH_SPECIAL, SupplyType.NEWCOUPLE_SPECIAL, SupplyType.SINGLE_PARENT_SPECIAL, - SupplyType.ELDER_SPECIAL, - SupplyType.DISABLED, - SupplyType.NATIONAL_MERIT, - SupplyType.PERMANENT_LEASE_EVICTEE, - SupplyType.TEMPORARY_STRUCTURE_RESIDENT + SupplyType.ELDER_SPECIAL ), NoticeType.PUBLIC_RENTAL, List.of( SupplyType.GENERAL, @@ -50,10 +55,14 @@ public class RentalSupplyMapping { SupplyType.FIRST_SPECIAL, // 생애최초 SupplyType.MINOR_SPECIAL, // 신생아 SupplyType.YOUTH_SPECIAL, // 청년 - SupplyType.NATIONAL_MERIT + SupplyType.NATIONAL_MERIT, + SupplyType.DEMOLITION, + SupplyType.LONG_SERVICE_VETERAN, + SupplyType.NORTH_DEFECTOR, + SupplyType.DISABLED ), NoticeType.PERMANENT_RENTAL, List.of( - SupplyType.NEWCOUPLE_SPECIAL, + SupplyType.GENERAL, SupplyType.SINGLE_PARENT_SPECIAL, SupplyType.ELDER_SUPPORT_SPECIAL, SupplyType.ELDER_SPECIAL, @@ -63,9 +72,15 @@ public class RentalSupplyMapping { SupplyType.DISABLED ), NoticeType.LONG_TERM_JEONSE, List.of( + SupplyType.GENERAL, SupplyType.NEWCOUPLE_SPECIAL, - SupplyType.SINGLE_PARENT_SPECIAL, - SupplyType.MULTICHILD_SPECIAL + SupplyType.MULTICHILD_SPECIAL, + SupplyType.DEMOLITION, + SupplyType.NATIONAL_MERIT, + SupplyType.DISABLED, + SupplyType.PERMANENT_LEASE_EVICTEE, + SupplyType.TEMPORARY_STRUCTURE_RESIDENT, + SupplyType.UNAUTHORIZED_BUILDING_TENANT ) ); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java index bb632bf7..267e3c16 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/entity/SupplyRentalCandidate.java @@ -13,18 +13,13 @@ @Builder public record SupplyRentalCandidate( SupplyType supplyType, - NoticeType noticeType + NoticeType noticeType, + CandidatePolicyKey policyKey ) { /// 정적 팩토리 메서드 (기본 값) public static List basic() { - /// 모든 값 가져오기 - List supplyTypes = SupplyType.getAllTypes(); - - /// 모든 값 가져오기 - List noticeTypes = NoticeType.getAllTypes(); - List candidates = new ArrayList<>(); /// for문 돌리면서 값 합치기 @@ -52,6 +47,7 @@ public static SupplyRentalCandidate basic(SupplyType supplyType, NoticeType noti return SupplyRentalCandidate.builder() .supplyType(supplyType) .noticeType(noticeType) + .policyKey(CandidatePolicyKey.resolve(noticeType, supplyType)) .build(); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java index c5bf151f..ba622377 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/AccountRule.java @@ -2,7 +2,6 @@ import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.LONG_TERM_JEONSE; import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.NATIONAL_RENTAL; -import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.PUBLIC_INTEGRATED; import static co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeType.PUBLIC_RENTAL; import java.util.ArrayList; @@ -31,7 +30,6 @@ public class AccountRule implements Rule { /// 청약통장이 없으면 불가능한 목록들 private static final List NO_ACCOUNT_ALLOWED = List.of( NATIONAL_RENTAL, - PUBLIC_INTEGRATED, LONG_TERM_JEONSE, PUBLIC_RENTAL ); @@ -83,6 +81,7 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 청약통장 없는 경우, NO_ACCOUNT_ALLOWED에 해당하는 RentalType 제거 candidates.removeIf(c -> NO_ACCOUNT_ALLOWED.contains(c.noticeType())); + ctx.setCurrentCandidates(candidates); return RuleResult.pass(code(), "청약통장 미보유로 인한 유형 제거 완료", diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java index 8538ab51..f08cec4e 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/BaseEligibilityRule.java @@ -37,8 +37,7 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 나이별 후보군 필터링 if (age < 19) { candidates.removeIf(c -> - c.supplyType() == SupplyType.NEWCOUPLE_SPECIAL - || c.supplyType() == SupplyType.ELDER_SPECIAL + c.supplyType() == SupplyType.ELDER_SPECIAL || c.supplyType() == SupplyType.GENERAL ); } else if (age <= 39) { @@ -49,10 +48,7 @@ public RuleResult evaluate(EvaluationContext ctx) { || c.supplyType() == SupplyType.ELDER_SPECIAL ); } else if (age >= 65) { - candidates.removeIf(c -> - c.supplyType() == SupplyType.YOUTH_SPECIAL - || c.supplyType() == SupplyType.NEWCOUPLE_SPECIAL - ); + candidates.removeIf(c -> c.supplyType() == SupplyType.YOUTH_SPECIAL); } /// 2차, 주택 소유 여부 @@ -67,6 +63,7 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 본인이 주택을 소유한 경우 모든 임대주택 신청 불가 if (ownsHouse) { + ctx.setCurrentCandidates(List.of()); return RuleResult.fail( code(), "주택 소유자는 임대주택 지원이 불가능", diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java index c98b8c6d..473d4884 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/GeneralSupplyRule.java @@ -38,8 +38,7 @@ public class GeneralSupplyRule implements Rule { /// 청약통장이 필요한 임대주택 유형 private static final Set SUBSCRIPTION_REQUIRED_TYPES = Set.of( - NoticeType.PUBLIC_RENTAL, // 공공임대 (분양전환 가능) - NoticeType.PUBLIC_INTEGRATED // 통합공공임대 (분양전환 가능) + NoticeType.PUBLIC_RENTAL // 공공임대 (분양전환 가능) ); private final PolicyUseCase policyUseCase; diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java index d8042ed4..1926390a 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/NewlyMarriedCandidateRule.java @@ -32,7 +32,6 @@ public RuleResult evaluate(EvaluationContext ctx) { var candidates = new ArrayList<>(ctx.getCurrentCandidates()); /// 무주택 세대주 또는 예비 세대주 여부 - boolean isHouseholdHead = diagnosis.isHouseholdHead(); boolean isNoHouse = diagnosis.getHousingStatus().equals( co.kr.pinhouse.domain.diagnostic.diagnosis.domain.entity.HousingOwnershipStatus.NO_ONE_OWNS_HOUSE) || diagnosis.getHousingStatus().equals( @@ -49,11 +48,11 @@ public RuleResult evaluate(EvaluationContext ctx) { boolean withYouthAge = diagnosis.getUnbornChildrenCount() > 0 || diagnosis.getUnder6ChildrenCount() > 0; /// 신혼부부 특별공급 요건: - /// - 무주택 세대주 또는 예비 세대주 + /// - 무주택 세대구성원 또는 무주택자 /// - 결혼했고 7년 이하, 또는 /// - 결혼 7년 초과지만 자녀가 6세 이하인 경우 - if (!(isHouseholdHead && isNoHouse && isMarried && (withinYears || withYouthAge))) { + if (!(isNoHouse && isMarried && (withinYears || withYouthAge))) { /// 있다면 삭제 candidates.removeIf(c -> @@ -64,9 +63,7 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 실패 이유를 담아줌 String failReason; - if (!isHouseholdHead) { - failReason = "세대주가 아님"; - } else if (!isNoHouse) { + if (!isNoHouse) { failReason = "무주택 요건 미충족"; } else if (!isMarried) { failReason = "결혼하지 않음"; diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java index ee8dd4a7..d122af96 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SingleParentRule.java @@ -44,8 +44,9 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 한부모인지 boolean hasSingleParent = specialCategories.stream() .anyMatch(c -> c == SINGLE_PARENT || c == PROTECTED_SINGLE_PARENT); + boolean hasInfantOrFetus = diagnosis.getUnbornChildrenCount() > 0 || diagnosis.getUnder6ChildrenCount() > 0; - if (!hasSingleParent) { + if (!hasSingleParent || !hasInfantOrFetus) { /// 한부모 계층이 아니라면 삭제 candidates.removeIf(c -> @@ -56,7 +57,10 @@ public RuleResult evaluate(EvaluationContext ctx) { return RuleResult.fail(code(), "한부모 특별공급 해당 없음", - Map.of("candidate", candidates)); + Map.of( + "candidate", candidates, + "failReason", hasSingleParent ? "태아 또는 6세 이하 자녀 요건 미충족" : "한부모가족 요건 미충족" + )); } return RuleResult.pass(code(), diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java index 9b763b67..b3225b29 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/SpecialRule.java @@ -43,6 +43,7 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 사용자가 가진 특수계층을 SupplyType으로 매핑 List ownedTypes = specialCategory.stream() .map(SpecialCategory::toSupplyType) + .filter(java.util.Objects::nonNull) .toList(); /// 존재하는 계층은 냅두고, 없는 것은 삭제하기 @@ -76,6 +77,6 @@ public RuleResult evaluate(EvaluationContext ctx) { @Override public String code() { - return ""; + return "SPECIAL_CATEGORY"; } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java new file mode 100644 index 00000000..14634312 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/UnsupportedCandidateRule.java @@ -0,0 +1,60 @@ +package co.kr.pinhouse.domain.diagnostic.rule.domain.rule; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import co.kr.pinhouse.domain.diagnostic.rule.application.dto.RuleResult; +import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.EvaluationContext; +import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.SupplyRentalCandidate; + +/** + * 현재 DTO만으로 최신 공식 기준을 정확히 재현할 수 없는 후보를 보수적으로 제거한다. + */ +@Order(16) +@Component +public class UnsupportedCandidateRule implements Rule { + + @Override + public RuleResult evaluate(EvaluationContext ctx) { + var candidates = new ArrayList<>(ctx.getCurrentCandidates()); + List removed = new ArrayList<>(); + + candidates.removeIf(candidate -> { + boolean unsupported = !candidate.policyKey().exactSupported(); + + if (unsupported) { + removed.add(candidate); + } + return unsupported; + }); + + ctx.setCurrentCandidates(candidates); + + if (removed.isEmpty()) { + return RuleResult.pass(code(), + "DTO로 정확 판정 가능한 후보만 유지", + Map.of("candidate", candidates)); + } + + return RuleResult.pass(code(), + "현재 DTO로 정확 판정이 어려운 후보 제거", + Map.of( + "candidate", candidates, + "removed", removed.stream() + .map(candidate -> String.format("%s : %s (%s)", + candidate.noticeType().getValue(), + candidate.supplyType().getValue(), + candidate.policyKey().unsupportedReason())) + .toList() + )); + } + + @Override + public String code() { + return "UNSUPPORTED_CANDIDATE_FILTER"; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java index 7a01f551..cc61056c 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/rule/domain/rule/YouthCandidateRule.java @@ -10,6 +10,7 @@ import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.entity.HousingOwnershipStatus; import co.kr.pinhouse.domain.diagnostic.rule.application.dto.RuleResult; import co.kr.pinhouse.domain.diagnostic.rule.application.usecase.PolicyUseCase; +import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.CandidatePolicyKey; import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.EvaluationContext; import co.kr.pinhouse.domain.diagnostic.rule.domain.entity.SupplyType; import lombok.RequiredArgsConstructor; @@ -34,35 +35,38 @@ public RuleResult evaluate(EvaluationContext ctx) { /// 가능한 리스트 추출하기 var candidates = new ArrayList<>(ctx.getCurrentCandidates()); - /// 나이 체크 (19~39세) int age = diagnosis.getAge(); - int youthAgeMin = policyUseCase.youthAgeMin(); - boolean ageOk = age >= youthAgeMin && age <= 39; - - /// 무주택 세대주 또는 예비 세대주 여부 - boolean isHouseholdHead = diagnosis.isHouseholdHead(); + boolean unmarried = !diagnosis.isMaritalStatus(); boolean isNoHouse = diagnosis.getHousingStatus().equals(HousingOwnershipStatus.NO_ONE_OWNS_HOUSE) || diagnosis.getHousingStatus().equals(HousingOwnershipStatus.HOUSEHOLD_MEMBER_OWNS_HOUSE); - /// 1인 가구 또는 세대주인 경우 청년 특별공급 가능 - boolean qualifies = ageOk && (isHouseholdHead || diagnosis.isSingle()) && isNoHouse; + candidates.removeIf(candidate -> { + if (candidate.supplyType() != SupplyType.YOUTH_SPECIAL) { + return false; + } - if (!qualifies) { + int minAge = candidate.policyKey() == CandidatePolicyKey.INTEGRATED_YOUTH_18_TO_39 + ? 18 + : policyUseCase.youthAgeMin(); + boolean ageOk = age >= minAge && age <= 39; + return !(ageOk && unmarried && isNoHouse); + }); - /// 청년 특별공급 제거 - candidates.removeIf(c -> c.supplyType() == SupplyType.YOUTH_SPECIAL); + ctx.setCurrentCandidates(candidates); - /// 결과 저장하기 - ctx.setCurrentCandidates(candidates); + boolean hasYouthCandidate = candidates.stream() + .anyMatch(candidate -> candidate.supplyType() == SupplyType.YOUTH_SPECIAL); - // 실패 이유 분류 + if (!hasYouthCandidate) { String failReason; - if (!ageOk) { - failReason = "나이 기준 미충족 (19~39세)"; + if (diagnosis.isMaritalStatus()) { + failReason = "청년 계층 혼인 요건 미충족"; } else if (!isNoHouse) { failReason = "무주택 요건 미충족"; + } else if (age < 18) { + failReason = "나이 기준 미충족 (통합공공임대 청년 18세 이상)"; } else { - failReason = "세대주 또는 1인 가구 요건 미충족"; + failReason = "나이 기준 미충족"; } return RuleResult.fail(code(), @@ -73,7 +77,6 @@ public RuleResult evaluate(EvaluationContext ctx) { )); } - /// 청년 특별공급 후보 return RuleResult.pass(code(), "청년 특별공급 후보", Map.of("candidate", candidates)); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java new file mode 100644 index 00000000..4859c28f --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/dto/response/SignupResponse.java @@ -0,0 +1,18 @@ +package co.kr.pinhouse.domain.user.application.dto.response; + +import lombok.Builder; + +@Builder +public record SignupResponse( + String accessToken, + String refreshToken +) { + + /// 정적 팩토리 메서드 + public static SignupResponse of(String accessToken, String refreshToken) { + return SignupResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java new file mode 100644 index 00000000..b6d19c7f --- /dev/null +++ b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexServiceTest.java @@ -0,0 +1,162 @@ +package co.kr.pinhouse.domain.housing.complex.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import co.kr.pinhouse.domain.Location; +import co.kr.pinhouse.domain.housing.complex.application.dto.response.ChipType; +import co.kr.pinhouse.domain.housing.complex.application.dto.response.DistanceResponse; +import co.kr.pinhouse.domain.housing.complex.application.dto.response.TransitInfoResponse; +import co.kr.pinhouse.domain.housing.complex.application.util.DistanceUtil; +import co.kr.pinhouse.domain.housing.complex.application.util.TransitResponseMapper; +import co.kr.pinhouse.domain.housing.complex.domain.entity.ComplexDocument; +import co.kr.pinhouse.domain.housing.complex.domain.repository.ComplexDocumentRepository; +import co.kr.pinhouse.domain.housing.complex.domain.transit.PathResult; +import co.kr.pinhouse.domain.housing.complex.domain.transit.RootResult; +import co.kr.pinhouse.domain.housing.facility.application.usecase.FacilityUseCase; +import co.kr.pinhouse.domain.like.application.usecase.LikeQueryUseCase; +import co.kr.pinhouse.domain.pinpoint.application.usecase.PinPointUseCase; +import co.kr.pinhouse.domain.pinpoint.domain.entity.PinPoint; + +@ExtendWith(MockitoExtension.class) +class ComplexServiceTest { + + @Mock + private ComplexDocumentRepository repository; + + @Mock + private PinPointUseCase pinPointService; + + @Mock + private DistanceUtil distanceUtil; + + @Mock + private TransitResponseMapper mapper; + + @Mock + private LikeQueryUseCase likeService; + + @Mock + private FacilityUseCase facilityService; + + @Mock + private DistanceCacheService distanceCacheService; + + @InjectMocks + private ComplexService complexService; + + @Test + void getTransitInfo_usesTransitInfoCacheBeforeRecalculation() throws UnsupportedEncodingException { + TransitInfoResponse cachedTransitInfo = TransitInfoResponse.builder() + .totalTime("1시간 10분") + .totalTimeMinutes(70) + .totalDistance(21.4) + .segments(List.of()) + .build(); + + when(distanceCacheService.getTransitInfo("complex-1", "pin-1")).thenReturn(cachedTransitInfo); + + TransitInfoResponse result = complexService.getTransitInfo("complex-1", "pin-1"); + + assertThat(result).isSameAs(cachedTransitInfo); + verify(distanceUtil, never()).findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble()); + verify(repository, never()).findById(any()); + verify(distanceCacheService, never()).getRootResult(any(), any()); + } + + @Test + void getTransitInfo_recalculatesWhenTransitInfoCacheIsMissing() throws UnsupportedEncodingException { + ComplexDocument complex = mock(ComplexDocument.class); + when(complex.getLocation()).thenReturn(Location.of(127.0, 37.5)); + when(repository.findById("complex-1")).thenReturn(Optional.of(complex)); + + PinPoint pinPoint = PinPoint.of("user-1", "서울", "집", 37.6, 127.1, true); + when(pinPointService.loadPinPoint("pin-1")).thenReturn(pinPoint); + + PathResult pathResult = mock(PathResult.class); + RootResult rootResult = RootResult.builder() + .totalTime(25) + .totalPayment(1450) + .totalDistance(8200) + .steps(List.of()) + .build(); + TransitInfoResponse transitInfo = TransitInfoResponse.builder() + .totalTime("25분") + .totalTimeMinutes(25) + .totalDistance(8.2) + .segments(List.of()) + .build(); + + when(distanceCacheService.getTransitInfo("complex-1", "pin-1")).thenReturn(null); + when(distanceUtil.findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(pathResult); + when(pathResult.routes()).thenReturn(List.of(rootResult)); + when(mapper.selectBest(pathResult)).thenReturn(rootResult); + when(mapper.toTransitInfoResponse(rootResult)).thenReturn(transitInfo); + + TransitInfoResponse result = complexService.getTransitInfo("complex-1", "pin-1"); + + assertThat(result).isSameAs(transitInfo); + verify(distanceUtil).findPathResult(eq(37.6), eq(127.1), eq(37.5), eq(127.0)); + verify(distanceCacheService).cacheRootResult("complex-1", "pin-1", rootResult); + verify(distanceCacheService).cacheTransitInfo("complex-1", "pin-1", transitInfo); + verify(distanceCacheService, never()).getRootResult(any(), any()); + } + + @Test + void getEasyDistance_warmsTransitInfoCacheOnFreshCalculation() throws UnsupportedEncodingException { + ComplexDocument complex = mock(ComplexDocument.class); + when(complex.getLocation()).thenReturn(Location.of(127.0, 37.5)); + when(repository.findById("complex-1")).thenReturn(Optional.of(complex)); + + PinPoint pinPoint = PinPoint.of("user-1", "서울", "집", 37.6, 127.1, true); + when(pinPointService.loadPinPoint("pin-1")).thenReturn(pinPoint); + + PathResult pathResult = mock(PathResult.class); + RootResult rootResult = RootResult.builder() + .totalTime(25) + .totalPayment(1450) + .totalDistance(8200) + .steps(List.of()) + .build(); + TransitInfoResponse transitInfo = TransitInfoResponse.builder() + .totalTime("25분") + .totalTimeMinutes(25) + .totalDistance(8.2) + .segments(List.of()) + .build(); + List routes = List.of( + DistanceResponse.TransitResponse.builder() + .type(ChipType.WALK) + .build() + ); + + when(distanceCacheService.getRootResult("complex-1", "pin-1")).thenReturn(null); + when(distanceUtil.findPathResult(anyDouble(), anyDouble(), anyDouble(), anyDouble())).thenReturn(pathResult); + when(pathResult.routes()).thenReturn(List.of(rootResult)); + when(mapper.selectBest(pathResult)).thenReturn(rootResult); + when(mapper.toTransitInfoResponse(rootResult)).thenReturn(transitInfo); + when(mapper.from(rootResult)).thenReturn(routes); + + DistanceResponse result = complexService.getEasyDistance("complex-1", "pin-1"); + + assertThat(result.totalTimeMinutes()).isEqualTo(25); + verify(distanceCacheService).cacheRootResult("complex-1", "pin-1", rootResult); + verify(distanceCacheService).cacheTransitInfo("complex-1", "pin-1", transitInfo); + } +} diff --git a/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java new file mode 100644 index 00000000..786df37e --- /dev/null +++ b/module-domain/src/test/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeServiceTest.java @@ -0,0 +1,126 @@ +package co.kr.pinhouse.domain.housing.notice.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import co.kr.pinhouse.domain.housing.complex.application.service.DistanceCacheService; +import co.kr.pinhouse.domain.housing.complex.application.usecase.ComplexUseCase; +import co.kr.pinhouse.domain.housing.complex.domain.entity.Address; +import co.kr.pinhouse.domain.housing.complex.domain.entity.ComplexDocument; +import co.kr.pinhouse.domain.housing.complex.domain.entity.Deposit; +import co.kr.pinhouse.domain.housing.complex.domain.entity.UnitType; +import co.kr.pinhouse.domain.housing.facility.application.usecase.FacilityUseCase; +import co.kr.pinhouse.domain.housing.notice.application.dto.UnitTypeSortType; +import co.kr.pinhouse.domain.housing.notice.application.dto.response.UnitTypeCompareResponse; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import co.kr.pinhouse.domain.housing.notice.domain.repository.NoticeDocumentRepository; +import co.kr.pinhouse.domain.like.application.usecase.LikeQueryUseCase; +import co.kr.pinhouse.domain.pinpoint.application.usecase.PinPointUseCase; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceTest { + + @Mock + private NoticeDocumentRepository repository; + + @Mock + private ComplexUseCase complexService; + + @Mock + private ComplexFilterService complexFilterService; + + @Mock + private LikeQueryUseCase likeService; + + @Mock + private FacilityUseCase facilityService; + + @Mock + private PinPointUseCase pinPointService; + + @Mock + private DistanceCacheService distanceCacheService; + + @InjectMocks + private NoticeService noticeService; + + @Test + void compareUnitTypes_sortsDepositsGloballyAcrossComplexes() { + String noticeId = "notice-1"; + ComplexDocument complexA = complex("complex-a", "A단지", "서울시 강남구", List.of( + unitType("type-1", "30A", 30.0, 1_000_000L), + unitType("type-2", "40A", 40.0, 3_000_000L) + )); + ComplexDocument complexB = complex("complex-b", "B단지", "서울시 관악구", List.of( + unitType("type-3", "20A", 20.0, 2_000_000L), + unitType("type-4", "25A", 25.0, 2_500_000L) + )); + + when(repository.findById(noticeId)).thenReturn(Optional.of(mock(NoticeDocument.class))); + when(complexService.loadSortedComplexes(noticeId, UnitTypeSortType.DEPOSIT_ASC)) + .thenReturn(List.of(complexA, complexB)); + when(facilityService.getNearFacilities(anyString())).thenReturn(null); + + UnitTypeCompareResponse response = + noticeService.compareUnitTypes(noticeId, null, UnitTypeSortType.DEPOSIT_ASC, null, null); + + assertThat(response.unitTypes()) + .extracting(UnitTypeCompareResponse.UnitTypeComparisonItem::typeId) + .containsExactly("type-1", "type-3", "type-4", "type-2"); + } + + @Test + void compareUnitTypes_sortsAreasGloballyAcrossComplexes() { + String noticeId = "notice-2"; + ComplexDocument complexA = complex("complex-a", "A단지", "서울시 강남구", List.of( + unitType("type-1", "40A", 40.0, 4_000_000L), + unitType("type-2", "20A", 20.0, 1_000_000L) + )); + ComplexDocument complexB = complex("complex-b", "B단지", "서울시 관악구", List.of( + unitType("type-3", "35A", 35.0, 3_000_000L), + unitType("type-4", "30A", 30.0, 2_000_000L) + )); + + when(repository.findById(noticeId)).thenReturn(Optional.of(mock(NoticeDocument.class))); + when(complexService.loadSortedComplexes(noticeId, UnitTypeSortType.AREA_DESC)) + .thenReturn(List.of(complexA, complexB)); + when(facilityService.getNearFacilities(anyString())).thenReturn(null); + + UnitTypeCompareResponse response = + noticeService.compareUnitTypes(noticeId, null, UnitTypeSortType.AREA_DESC, null, null); + + assertThat(response.unitTypes()) + .extracting(UnitTypeCompareResponse.UnitTypeComparisonItem::typeId) + .containsExactly("type-1", "type-3", "type-4", "type-2"); + } + + private ComplexDocument complex(String id, String name, String address, List unitTypes) { + ComplexDocument complex = mock(ComplexDocument.class); + when(complex.getId()).thenReturn(id); + when(complex.getName()).thenReturn(name); + when(complex.getAddress()).thenReturn(Address.builder().full(address).build()); + when(complex.getUnitTypes()).thenReturn(unitTypes); + return complex; + } + + private UnitType unitType(String typeId, String typeCode, double areaM2, long depositTotal) { + return UnitType.builder() + .typeId(typeId) + .typeCode(typeCode) + .exclusiveAreaM2(areaM2) + .deposit(Deposit.builder().total(depositTotal).build()) + .group(List.of()) + .build(); + } +} diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java index d456a7b8..6f4f7a6a 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/HttpUtil.java @@ -19,6 +19,9 @@ @Component public class HttpUtil { + private static final String BEARER_PREFIX = "Bearer "; + private static final String REFRESH_TOKEN_HEADER = "X-Refresh-Token"; + @Value("${auth.jwt.access.expiration}") private long accessExpiration; @@ -39,12 +42,14 @@ public class HttpUtil { /// 액세스 토큰 쿠키 가져오기 public Optional getAccessToken(HttpServletRequest request) { - return extractToken(request, ACCESS_TOKEN); + return extractBearerAuthorizationToken(request) + .or(() -> extractCookieToken(request, ACCESS_TOKEN)); } /// 리프레쉬 토큰 쿠키 가져오기 public Optional getRefreshToken(HttpServletRequest request) { - return extractToken(request, REFRESH_TOKEN); + return extractHeaderToken(request, REFRESH_TOKEN_HEADER) + .or(() -> extractCookieToken(request, REFRESH_TOKEN)); } /// 액세스 토큰을 쿠키에 저장하기 @@ -98,6 +103,10 @@ public HeaderInfo getClientInfo(HttpServletRequest request) { return new HeaderInfo(ip, httpMethod, uri, username); } + public String getClientIp(HttpServletRequest request) { + return extractClientIp(request); + } + /// 쿠키 생성하기 private void createCookie(HttpServletResponse response, String cookieName, String cookieValue, long maxAge) { @@ -117,8 +126,26 @@ private void createCookie(HttpServletResponse response, String cookieName, Strin // 내부 공통 함수 // ================= + /// Authorization Bearer 헤더에서 액세스 토큰 가져오기 + private Optional extractBearerAuthorizationToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)) + .map(String::trim) + .filter(headerValue -> headerValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) + .map(headerValue -> headerValue.substring(BEARER_PREFIX.length()).trim()) + .filter(token -> !token.isEmpty()); + } + + /// 사용자 정의 헤더에서 토큰 가져오기 + private Optional extractHeaderToken(HttpServletRequest request, String headerName) { + return Optional.ofNullable(request.getHeader(headerName)) + .map(String::trim) + .filter(headerValue -> !headerValue.isEmpty()) + .map(this::stripBearerPrefixIfPresent) + .filter(token -> !token.isEmpty()); + } + /// 쿠키에서 토큰 가져오기 - private Optional extractToken(HttpServletRequest httpServletRequest, String type) { + private Optional extractCookieToken(HttpServletRequest httpServletRequest, String type) { /// 쿠키 가져오기 Cookie[] cookies = httpServletRequest.getCookies(); @@ -136,6 +163,13 @@ private Optional extractToken(HttpServletRequest httpServletRequest, Str return Optional.empty(); } + private String stripBearerPrefixIfPresent(String headerValue) { + if (!headerValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return headerValue; + } + return headerValue.substring(BEARER_PREFIX.length()).trim(); + } + /// 공통 쿠키 삭제 메서드 private void deleteCookie(HttpServletResponse response, String cookieName) { ResponseCookie cookie = ResponseCookie.from(cookieName, "") @@ -150,7 +184,7 @@ private void deleteCookie(HttpServletResponse response, String cookieName) { } /// 요청자의 실제 IP를 조회하기 위한 함수 - private String getClientIp(HttpServletRequest request) { + private String extractClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); /// X-Forwarded-For이 있다면 diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java index 65cf664a..a2b5cad2 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/KeyUtil.java @@ -22,6 +22,9 @@ public class KeyUtil { private static final String DELIMITER = "-"; /// OAUTH private static final String OAUTH2_TEMP_USER = "OAUTH2_TEMP_USER:"; + /// EXCHANGE CODE + private static final String EXCHANGE_CODE = "EXCHANGE_CODE:"; + private static final String EXCHANGE_CODE_RATE_LIMIT = "EXCHANGE_CODE_RATE_LIMIT:"; // ===================== // 합쳐서 사용하는 키 목록 @@ -31,9 +34,31 @@ public static String getRefreshTokenKey(UUID userId) { return REFRESH_TOKEN + SEPARATOR + userId; } + public static String getExchangeCodeKey(UUID userId) { + return EXCHANGE_CODE + userId; + } + + public static String getExchangeCodeKey(String tempUserKey) { + return EXCHANGE_CODE + tempUserKey; + } + + public static String getExchangeCodeRateLimitKey(String clientIdentifier) { + return EXCHANGE_CODE_RATE_LIMIT + clientIdentifier; + } + + // ===================== + // 키 생성 함수 + // ===================== + /// 키 생성 함수 public String generateOAuth2TempUserKey() { return OAUTH2_TEMP_USER + UUID.randomUUID(); } + /// Exchange code 생성 (암호학적으로 안전한 48자 랜덤 문자열) + public String generateExchangeCode() { + return UUID.randomUUID().toString().replace("-", "") + + UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + } diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java index 009b8900..be02301e 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java @@ -5,25 +5,33 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import co.kr.pinhouse.common.response.ApiResponse; import co.kr.pinhouse.common.util.HttpUtil; +import co.kr.pinhouse.security.auth.application.dto.request.ExchangeCodeRequest; +import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse; +import co.kr.pinhouse.security.auth.application.service.ExchangeCodeRateLimitService; import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase; import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse; import co.kr.pinhouse.security.principal.AuthenticatedUser; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestController @RequestMapping("/v1/auth") @RequiredArgsConstructor public class AuthApi implements AuthApiSpec { private final AuthUseCase service; + private final ExchangeCodeRateLimitService exchangeCodeRateLimitService; /// HTTP 서비스 private final HttpUtil httpUtil; @@ -32,13 +40,30 @@ public class AuthApi implements AuthApiSpec { // 퍼블릭 로직 // ================= + /** + * Exchange code를 JWT 토큰으로 교환 + */ + @PostMapping("/exchange") + public ApiResponse exchangeCode( + HttpServletRequest httpServletRequest, + @RequestBody @Valid ExchangeCodeRequest request + ) { + String clientIp = httpUtil.getClientIp(httpServletRequest); + exchangeCodeRateLimitService.validateRequestAllowed(clientIp); + + log.info("공개 토큰 교환 요청 - clientIp: {}, code: {}...", + clientIp, request.code().substring(0, Math.min(8, request.code().length()))); + + AuthExchangeResponse response = service.exchangeCode(request.code()); + return ApiResponse.ok(response); + } + /** * 로그아웃 */ @DeleteMapping public ApiResponse logout( HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse, @AuthenticationPrincipal AuthenticatedUser principalDetails) { /// 리프레쉬 토큰 까보기 @@ -47,9 +72,7 @@ public ApiResponse logout( /// 서비스 로직 실행 service.logout(principalDetails.getId(), refreshToken); - /// 쿠키 삭제하기 - httpUtil.removeAccessTokenCookie(httpServletResponse); - httpUtil.removeRefreshTokenCookie(httpServletResponse); + /// 쿠키 삭제는 Next가 처리 /// 리턴 return ApiResponse.deleted(); @@ -59,9 +82,8 @@ public ApiResponse logout( * 토큰 재발급 */ @PutMapping - public ApiResponse reissue( - HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse + public ApiResponse reissue( + HttpServletRequest httpServletRequest ) { /// 리프레쉬 토큰 까보기 @@ -70,12 +92,9 @@ public ApiResponse reissue( /// 서비스 로직 실행 JwtTokenResponse response = service.reissue(refreshToken); - /// 토큰 재발급하기 (액세스/리프레쉬) - httpUtil.addAccessTokenCookie(httpServletResponse, response.accessToken()); - httpUtil.addRefreshTokenCookie(httpServletResponse, response.refreshToken()); - - /// 리턴 - return ApiResponse.updated(); + /// 토큰을 응답 바디로 반환 + /// Next가 쿠키로 설정 + return ApiResponse.ok(response); } /** diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java index 8744b632..1a0c9efa 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApiSpec.java @@ -1,17 +1,31 @@ package co.kr.pinhouse.domain.auth; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.security.auth.application.dto.request.ExchangeCodeRequest; +import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse; +import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse; import co.kr.pinhouse.security.principal.AuthenticatedUser; 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; @Tag(name = "인증 API", description = "로그아웃/액세스 토큰 재발급을 수행하는 API입니다") public interface AuthApiSpec { + /// Exchange code 교환 + @Operation( + summary = "Exchange Code 교환 API", + description = "OAuth2 또는 회원가입 이후 받은 Exchange Code를 처리하여 토큰 발급 또는 회원가입 필요 상태를 반환합니다." + ) + ApiResponse exchangeCode( + HttpServletRequest httpServletRequest, + @RequestBody @Valid ExchangeCodeRequest request + ); + /// 로그아웃 @Operation( summary = "로그아웃 API", @@ -19,7 +33,6 @@ public interface AuthApiSpec { ) ApiResponse logout( HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse, @AuthenticationPrincipal AuthenticatedUser customUserDetails); /// 토큰 재발급 @@ -27,9 +40,8 @@ ApiResponse logout( summary = "액세스토큰 재발급 API", description = "액세스 토큰을 재발급받는 API" ) - ApiResponse reissue( - HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse + ApiResponse reissue( + HttpServletRequest httpServletRequest ); /// 액세 토큰 여부 체크 diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java index 128af58b..c560576f 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java @@ -20,6 +20,7 @@ import co.kr.pinhouse.domain.user.application.dto.request.UserRequest; import co.kr.pinhouse.domain.user.application.dto.request.WithdrawRequest; import co.kr.pinhouse.domain.user.application.dto.response.MyPageResponse; +import co.kr.pinhouse.domain.user.application.dto.response.SignupResponse; import co.kr.pinhouse.domain.user.application.dto.response.TempUserResponse; import co.kr.pinhouse.domain.user.application.dto.response.UserResponse; import co.kr.pinhouse.domain.user.application.usecase.UserUseCase; @@ -28,7 +29,9 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestController @RequestMapping("/v1/users") @RequiredArgsConstructor @@ -48,22 +51,25 @@ public ApiResponse getUser(@RequestParam String tempKey) { return ApiResponse.ok(service.getUserByKey(tempKey)); } - /// 회원가입 (JWT 토큰 발급은 security 모듈/app 모듈에서 처리 필요) + /// 회원가입 (JWT 토큰 직접 발급) @PostMapping() - public ApiResponse signUp(HttpServletResponse httpServletResponse, + public ApiResponse signUp( @RequestParam String tempKey, @RequestBody @Valid UserRequest request) { /// 서비스 - User 엔티티 반환 var user = service.saveUser(tempKey, request); - /// 회원가입 직후 즉시 로그인 상태가 되도록 토큰 발급 + /// JWT 토큰 직접 발급 var tokenResponse = authUseCase.issueTokens(user); - httpUtil.addAccessTokenCookie(httpServletResponse, tokenResponse.accessToken()); - httpUtil.addRefreshTokenCookie(httpServletResponse, tokenResponse.refreshToken()); + + log.info("회원가입 완료 - userId: {}, 토큰 발급 완료", user.getId()); /// 리턴 - return ApiResponse.created(); + return ApiResponse.ok(SignupResponse.of( + tokenResponse.accessToken(), + tokenResponse.refreshToken() + )); } /// 나의 정보 조회하기 diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java index e4b534ad..43e0e88e 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApiSpec.java @@ -12,6 +12,7 @@ import co.kr.pinhouse.domain.user.application.dto.request.UserRequest; import co.kr.pinhouse.domain.user.application.dto.request.WithdrawRequest; import co.kr.pinhouse.domain.user.application.dto.response.MyPageResponse; +import co.kr.pinhouse.domain.user.application.dto.response.SignupResponse; import co.kr.pinhouse.domain.user.application.dto.response.TempUserResponse; import co.kr.pinhouse.domain.user.application.dto.response.UserResponse; import io.swagger.v3.oas.annotations.Operation; @@ -26,9 +27,9 @@ public interface UserApiSpec { /// 회원가입 @Operation( summary = "회원가입 API", - description = "온보딩의 데이터를 바탕으로 회원가입합니다." + description = "온보딩의 데이터를 바탕으로 회원가입하고 JWT 토큰을 발급합니다. 응답으로 accessToken과 refreshToken을 반환합니다." ) - ApiResponse signUp(HttpServletResponse httpServletResponse, + ApiResponse signUp( @RequestParam String tempKey, @RequestBody @Valid UserRequest request); diff --git a/module-security/build.gradle b/module-security/build.gradle index 64897785..cc8ea948 100644 --- a/module-security/build.gradle +++ b/module-security/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework:spring-tx' compileOnly 'jakarta.persistence:jakarta.persistence-api' compileOnly 'org.springframework.data:spring-data-jpa' @@ -15,7 +16,12 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Swagger/OpenAPI for DTO annotations + implementation 'org.springdoc:springdoc-openapi-starter-common:2.8.6' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'jakarta.persistence:jakarta.persistence-api' + testImplementation 'org.springframework.data:spring-data-jpa' } bootJar { diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java new file mode 100644 index 00000000..3e739e9f --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/request/ExchangeCodeRequest.java @@ -0,0 +1,18 @@ +package co.kr.pinhouse.security.auth.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder +public record ExchangeCodeRequest( + @NotBlank(message = "Exchange Code는 비어 있을 수 없습니다.") + String code +) { + + /// 정적 팩토리 메서드 + public static ExchangeCodeRequest of(String code) { + return ExchangeCodeRequest.builder() + .code(code) + .build(); + } +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java new file mode 100644 index 00000000..7136d5d2 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResponse.java @@ -0,0 +1,39 @@ +package co.kr.pinhouse.security.auth.application.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(name = "[응답][인증] Exchange Code 교환 응답", description = "Exchange Code 해석 결과를 반환합니다.") +@Builder +public record AuthExchangeResponse( + @Schema(description = "Exchange Code 처리 결과", example = "TOKEN_ISSUED") + AuthExchangeResultType result, + + @Schema(description = "발급된 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") + String accessToken, + + @Schema(description = "발급된 리프레쉬 토큰", example = "eyJhbGciOiJIUzI1NiJ9...") + String refreshToken, + + @Schema(description = "회원가입이 필요한 경우 사용할 임시 키", example = "OAUTH2_TEMP_USER:1234...") + String tempKey +) { + + public static AuthExchangeResponse tokenIssued(String accessToken, String refreshToken) { + return AuthExchangeResponse.builder() + .result(AuthExchangeResultType.TOKEN_ISSUED) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public static AuthExchangeResponse signupRequired(String tempKey) { + return AuthExchangeResponse.builder() + .result(AuthExchangeResultType.SIGNUP_REQUIRED) + .tempKey(tempKey) + .build(); + } +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java new file mode 100644 index 00000000..e2b05876 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/dto/response/AuthExchangeResultType.java @@ -0,0 +1,6 @@ +package co.kr.pinhouse.security.auth.application.dto.response; + +public enum AuthExchangeResultType { + TOKEN_ISSUED, + SIGNUP_REQUIRED +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java index 293dfe68..68b73050 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java @@ -1,23 +1,34 @@ package co.kr.pinhouse.security.auth.application.service; +import java.time.Duration; import java.util.Optional; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import co.kr.pinhouse.common.exception.code.SecurityErrorCode; import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.util.KeyUtil; import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo; import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse; import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase; import co.kr.pinhouse.security.jwt.application.dto.request.JwtTokenRequest; import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse; import co.kr.pinhouse.security.jwt.application.util.JwtProvider; import co.kr.pinhouse.security.jwt.application.util.JwtValidator; +import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCode; +import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCodeType; import co.kr.pinhouse.security.jwt.domain.entity.JwtRefreshToken; +import co.kr.pinhouse.security.jwt.domain.repository.JwtExchangeCodeRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -30,6 +41,17 @@ public class AuthService implements AuthUseCase { private final JwtValidator jwtValidator; private final JwtProvider jwtProvider; + /// Exchange code 저장소 + private final JwtExchangeCodeRepository exchangeCodeRepository; + private final RedisTemplate redisTemplate; + private final KeyUtil keyUtil; + + @Value("${auth.exchange.code.ttl-seconds:60}") + private long exchangeCodeTtlSeconds; + + @Value("${auth.oauth2.temp-user.ttl-minutes:5}") + private long tempUserTtlMinutes; + // ================= // 퍼블릭 로직 // ================= @@ -84,6 +106,29 @@ public boolean checkToken(Optional optionalAccessToken) { return true; } + @Override + @Transactional + public String createExchangeCode(User user) { + String exchangeCode = keyUtil.generateExchangeCode(); + JwtExchangeCode codeEntity = JwtExchangeCode.tokenIssuable(user.getId(), exchangeCode, exchangeCodeTtlSeconds); + exchangeCodeRepository.save(codeEntity); + + return exchangeCode; + } + + @Override + @Transactional + public String createSignupExchangeCode(TempUserInfo tempUserInfo) { + String tempUserKey = keyUtil.generateOAuth2TempUserKey(); + redisTemplate.opsForValue().set(tempUserKey, tempUserInfo, Duration.ofMinutes(tempUserTtlMinutes)); + + String exchangeCode = keyUtil.generateExchangeCode(); + JwtExchangeCode codeEntity = JwtExchangeCode.signupRequired(tempUserKey, exchangeCode, exchangeCodeTtlSeconds); + exchangeCodeRepository.save(codeEntity); + + return exchangeCode; + } + /// 토큰 재발급 @Override @Transactional @@ -107,4 +152,36 @@ public JwtTokenResponse reissue(Optional refreshToken) { return issueTokens(user); } + /// Exchange code 처리 + @Override + @Transactional + public AuthExchangeResponse exchangeCode(String exchangeCode) { + + /// Exchange code 검증 및 조회 + JwtExchangeCode codeEntity = exchangeCodeRepository.findByExchangeCode(exchangeCode) + .orElseThrow(() -> new CustomException(SecurityErrorCode.EXCHANGE_CODE_INVALID)); + + /// Exchange code 즉시 삭제 (one-time 보장) + exchangeCodeRepository.delete(codeEntity); + + if (codeEntity.getExchangeCodeType() == JwtExchangeCodeType.SIGNUP_REQUIRED) { + log.info("Exchange code 사용 완료 - signup required, tempKey: {}", codeEntity.getTempUserKey()); + return AuthExchangeResponse.signupRequired(codeEntity.getTempUserKey()); + } + + if (codeEntity.getExchangeCodeType() != JwtExchangeCodeType.TOKEN_ISSUABLE) { + throw new CustomException(SecurityErrorCode.EXCHANGE_CODE_INVALID); + } + + /// 사용자 조회 + User user = repository.findById(codeEntity.getUserId()) + .orElseThrow(() -> new CustomException(SecurityErrorCode.NOT_FOUND_ID)); + + log.info("Exchange code 사용 완료 - userId: {}", user.getId()); + + /// 토큰 발급 + JwtTokenResponse tokenResponse = issueTokens(user); + return AuthExchangeResponse.tokenIssued(tokenResponse.accessToken(), tokenResponse.refreshToken()); + } + } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java new file mode 100644 index 00000000..b02ce087 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java @@ -0,0 +1,54 @@ +package co.kr.pinhouse.security.auth.application.service; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import co.kr.pinhouse.common.exception.code.SecurityErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.util.KeyUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExchangeCodeRateLimitService { + + private final RedisTemplate redisTemplate; + + @Value("${auth.exchange.rate-limit.max-attempts:10}") + private long maxAttempts; + + @Value("${auth.exchange.rate-limit.window-seconds:60}") + private long windowSeconds; + + public void validateRequestAllowed(String clientIdentifier) { + String normalizedClientIdentifier = normalizeClientIdentifier(clientIdentifier); + String key = KeyUtil.getExchangeCodeRateLimitKey(normalizedClientIdentifier); + Long requestCount = redisTemplate.opsForValue().increment(key); + + if (requestCount == null) { + throw new IllegalStateException("Exchange Code rate limit 카운트 증가에 실패했습니다."); + } + + if (requestCount == 1L) { + redisTemplate.expire(key, Duration.ofSeconds(windowSeconds)); + } + + if (requestCount > maxAttempts) { + log.warn("Exchange Code 교환 요청 제한 초과 - clientIdentifier: {}, count: {}", + normalizedClientIdentifier, requestCount); + throw new CustomException(SecurityErrorCode.EXCHANGE_CODE_RATE_LIMITED); + } + } + + private String normalizeClientIdentifier(String clientIdentifier) { + if (clientIdentifier == null || clientIdentifier.isBlank()) { + return "unknown"; + } + return clientIdentifier.trim(); + } +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java index e07928d7..f6c0004b 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/usecase/AuthUseCase.java @@ -4,6 +4,8 @@ import java.util.UUID; import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo; +import co.kr.pinhouse.security.auth.application.dto.response.AuthExchangeResponse; import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse; public interface AuthUseCase { @@ -20,4 +22,13 @@ public interface AuthUseCase { /// 토큰 여부 판단하기 boolean checkToken(Optional accessToken); + /// 기존 유저용 Exchange code 생성 + String createExchangeCode(User user); + + /// 회원가입 필요 유저용 Exchange code 생성 + String createSignupExchangeCode(TempUserInfo tempUserInfo); + + /// Exchange code 처리 + AuthExchangeResponse exchangeCode(String exchangeCode); + } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java new file mode 100644 index 00000000..5394defd --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCode.java @@ -0,0 +1,69 @@ +package co.kr.pinhouse.security.jwt.domain.entity; + +import static co.kr.pinhouse.common.util.KeyUtil.getExchangeCodeKey; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash +public class JwtExchangeCode { + + @Id + private String id; + + @Indexed + private UUID userId; + + @Indexed + private String tempUserKey; + + @Indexed + private String exchangeCode; + + private JwtExchangeCodeType exchangeCodeType; + + @TimeToLive(unit = TimeUnit.SECONDS) + private long expireTime; + + /// 빌더 생성자 + @Builder + public JwtExchangeCode(String id, UUID userId, String tempUserKey, String exchangeCode, + JwtExchangeCodeType exchangeCodeType, long expireTime) { + this.id = id; + this.userId = userId; + this.tempUserKey = tempUserKey; + this.exchangeCode = exchangeCode; + this.exchangeCodeType = exchangeCodeType; + this.expireTime = expireTime; + } + + public static JwtExchangeCode tokenIssuable(UUID userId, String exchangeCode, long expireTime) { + return JwtExchangeCode.builder() + .id(getExchangeCodeKey(userId)) + .userId(userId) + .exchangeCode(exchangeCode) + .expireTime(expireTime) + .exchangeCodeType(JwtExchangeCodeType.TOKEN_ISSUABLE) + .build(); + } + + public static JwtExchangeCode signupRequired(String tempUserKey, String exchangeCode, long expireTime) { + return JwtExchangeCode.builder() + .id(getExchangeCodeKey(tempUserKey)) + .tempUserKey(tempUserKey) + .exchangeCode(exchangeCode) + .expireTime(expireTime) + .exchangeCodeType(JwtExchangeCodeType.SIGNUP_REQUIRED) + .build(); + } + +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java new file mode 100644 index 00000000..049693e3 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/entity/JwtExchangeCodeType.java @@ -0,0 +1,6 @@ +package co.kr.pinhouse.security.jwt.domain.entity; + +public enum JwtExchangeCodeType { + TOKEN_ISSUABLE, + SIGNUP_REQUIRED +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java new file mode 100644 index 00000000..10dc3e51 --- /dev/null +++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/domain/repository/JwtExchangeCodeRepository.java @@ -0,0 +1,13 @@ +package co.kr.pinhouse.security.jwt.domain.repository; + +import java.util.Optional; + +import org.springframework.data.repository.CrudRepository; + +import co.kr.pinhouse.security.jwt.domain.entity.JwtExchangeCode; + +public interface JwtExchangeCodeRepository extends CrudRepository { + + /// Exchange code로 조회 + Optional findByExchangeCode(String exchangeCode); +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java index aa7fc7d6..e69f76d2 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java @@ -39,6 +39,7 @@ public class RequestMatcherHolder { new RequestInfo(POST, "/v1/auth/dev", null), /// 개발용 토큰 // auth + new RequestInfo(POST, "/v1/auth/exchange", null), /// exchange code 교환 new RequestInfo(DELETE, "/v1/auth", Role.USER), /// 로그아웃 new RequestInfo(PUT, "/v1/auth", null), /// 재발급 new RequestInfo(GET, "/v1/auth", null), /// 토큰 여부 체크 diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java index 256ec2ec..f13356ff 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java @@ -1,16 +1,14 @@ package co.kr.pinhouse.security.oauth2.handler; import java.io.IOException; -import java.time.Duration; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; -import co.kr.pinhouse.common.util.KeyUtil; import co.kr.pinhouse.common.util.RedirectUrlResolver; import co.kr.pinhouse.domain.user.domain.onboarding.TempUserInfo; +import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -22,9 +20,7 @@ @RequiredArgsConstructor public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { - /// 레디스 의존성 도입 - private final RedisTemplate redisTemplate; - private final KeyUtil keyUtil; + private final AuthUseCase authUseCase; private final RedirectUrlResolver redirectUrlResolver; /** @@ -37,22 +33,29 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo /// SignupRequiredException 예외처리 받았으면 실행 if (authException instanceof SignupRequiredException) { - /// 레디스로 회원가입 키 생성 - String tempUserKey = keyUtil.generateOAuth2TempUserKey(); - /// 임시 유저 처리 TempUserInfo userInfo = ((SignupRequiredException)authException).getUserInfo(); - /// 레디스에 값 추가( 유효시간 5분 ) - redisTemplate.opsForValue().set(tempUserKey, userInfo, Duration.ofMinutes(5)); + /// 회원가입 필요 상태를 나타내는 Exchange code 생성 + String exchangeCode = authUseCase.createSignupExchangeCode(userInfo); /// 동적으로 리다이렉트 URL 결정 - String extraInfoUrl = redirectUrlResolver.resolveRedirectUrlWithPath(request, "/signup?state=" + tempUserKey); + String callbackUrl = buildCallbackUrl(request, exchangeCode); /// 응답을 리다이렉트 - response.sendRedirect(extraInfoUrl); + response.sendRedirect(callbackUrl); } else { response.sendRedirect("/login?error"); } } + + private String buildCallbackUrl(HttpServletRequest request, String exchangeCode) { + String baseUrl = resolveCallbackUrl(request); + return baseUrl + "?code=" + exchangeCode; + } + + private String resolveCallbackUrl(HttpServletRequest request) { + String frontUrl = redirectUrlResolver.resolveRedirectUrl(request); + return frontUrl + "/api/auth/callback"; + } } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java index 4c59f1e1..f8910506 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java @@ -7,11 +7,9 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import co.kr.pinhouse.common.util.HttpUtil; import co.kr.pinhouse.common.util.RedirectUrlResolver; import co.kr.pinhouse.domain.user.domain.entity.User; import co.kr.pinhouse.security.auth.application.usecase.AuthUseCase; -import co.kr.pinhouse.security.jwt.application.dto.response.JwtTokenResponse; import co.kr.pinhouse.security.principal.PrincipalDetails; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -25,12 +23,10 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final AuthUseCase authUseCase; - private final HttpUtil httpUtil; private final RedirectUrlResolver redirectUrlResolver; /* - 기존에 존재하는 유저의 경우, 토큰 발급을 진행합니다. - 리다이렉트 시킵니다. + 기존에 존재하는 유저의 경우, Exchange code를 발급하고 BFF로 리다이렉트합니다. */ @Override @@ -42,23 +38,32 @@ public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpS PrincipalDetails principal = (PrincipalDetails)authentication.getPrincipal(); User user = principal.getUser(); - /// Access, Refresh 토큰 생성 - JwtTokenResponse tokenResponse = authUseCase.issueTokens(user); - - /// HTTP 쿠키 추가 - httpUtil.addAccessTokenCookie(httpServletResponse, tokenResponse.accessToken()); - httpUtil.addRefreshTokenCookie(httpServletResponse, tokenResponse.refreshToken()); + /// Exchange code 생성 + String exchangeCode = authUseCase.createExchangeCode(user); /// 시큐리티 홀더에 해당 멤버 저장 SecurityContextHolder.getContext().setAuthentication(authentication); - /// 동적으로 리다이렉트 URL 결정 - String redirectUrl = redirectUrlResolver.resolveRedirectUrl(httpServletRequest); + /// BFF 콜백 URL 생성 + String redirectUrl = buildBffCallbackUrl(httpServletRequest, exchangeCode); + + log.info("OAuth2 인증 성공 - userId: {}, BFF 리다이렉트: {}", user.getId(), redirectUrl); - /// 쿠키와 함께 리다이렉트 (프론트 홈 주소) + /// BFF로 리다이렉트 (exchange code 포함) getRedirectStrategy().sendRedirect(httpServletRequest, httpServletResponse, redirectUrl); } catch (Exception e) { - log.error("OAuth2 회원가입 진행중 에러 발생", e); + log.error("OAuth2 인증 처리 중 에러 발생", e); + throw e; } } + + private String buildBffCallbackUrl(HttpServletRequest request, String exchangeCode) { + String baseUrl = resolveBffCallbackUrl(request); + return baseUrl + "?code=" + exchangeCode; + } + + private String resolveBffCallbackUrl(HttpServletRequest request) { + String frontUrl = redirectUrlResolver.resolveRedirectUrl(request); + return frontUrl + "/api/auth/callback"; + } }