diff --git a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java index 81aebe4..33ab770 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java @@ -16,6 +16,7 @@ import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties; import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; import com.eactive.apim.portal.common.exception.UserErrorMessageResolver; +import com.eactive.apim.portal.common.security.WriteRateLimitService; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.ApiServiceHelper; import com.eactive.apim.portal.common.util.SecurityUtil; @@ -88,6 +89,7 @@ public class MyAppController { private final FileTypeDetector fileTypeDetector; private final TwoFactorService twoFactorService; private final TwoFactorProperties twoFactorProperties; + private final WriteRateLimitService writeRateLimitService; private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB @@ -530,9 +532,18 @@ public class MyAppController { @RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear, @ModelAttribute("apiKeyRegistration") ApiKeyRegistrationDTO registration, SessionStatus sessionStatus, + RedirectAttributes redirectAttributes, Model model) { PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); + // 재신청 쿨다운이면 입력을 다 하고 막히지 않도록 진입 시점에 되돌린다(최종 차단은 step2 가 한다). + long cooldownMinutes = writeRateLimitService.newAppRequestCooldownRemainingMinutes(); + if (cooldownMinutes > 0) { + sessionStatus.setComplete(); + redirectAttributes.addFlashAttribute("error", writeRateLimitService.cooldownMessage(cooldownMinutes)); + return new ModelAndView("redirect:/clients"); + } + // 명시적으로 요청된 경우에만 세션 초기화 (새 등록 시작) // 페이지 새로고침이나 뒤로가기 시에는 세션 데이터를 보존 if (clear) { @@ -717,6 +728,14 @@ public class MyAppController { return new ModelAndView("redirect:/clients/register/step1"); } + // 무제한 신청 차단 — 신규 신청은 1시간에 1건으로 고정한다(PortalProperty 무관). + long cooldownMinutes = writeRateLimitService.newAppRequestCooldownRemainingMinutes(); + if (cooldownMinutes > 0) { + sessionStatus.setComplete(); + redirectAttributes.addFlashAttribute("error", writeRateLimitService.cooldownMessage(cooldownMinutes)); + return new ModelAndView("redirect:/clients"); + } + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); try { diff --git a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java index c685569..297ff6c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java +++ b/src/main/java/com/eactive/apim/portal/apps/auth/twofactor/StepUpPasswordController.java @@ -1,12 +1,11 @@ package com.eactive.apim.portal.apps.auth.twofactor; -import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.apps.user.facade.UserFacade; +import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker; +import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker.Outcome; import com.eactive.apim.portal.common.util.SecurityUtil; import lombok.RequiredArgsConstructor; import org.springframework.security.access.annotation.Secured; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -33,14 +32,9 @@ import javax.servlet.http.HttpSession; @RequestMapping("/auth/stepup") public class StepUpPasswordController { - /** 비밀번호 재확인 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) */ - private static final int MAX_FAIL_COUNT = 5; - /** 연속 실패 횟수 세션 attribute 키 */ - private static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT"; - private final UserFacade userFacade; private final TwoFactorService twoFactorService; - private final UserSessionService userSessionService; + private final PasswordConfirmFailureTracker passwordConfirmFailureTracker; @GetMapping("/password") public String page(@RequestParam(required = false) String returnUrl, Model model) { @@ -65,33 +59,24 @@ public class StepUpPasswordController { String loginId = SecurityUtil.getCurrentLoginId(); if (userFacade.verifyCurrentPassword(loginId, currentPassword)) { // 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀 - session.removeAttribute(ATTR_FAIL_COUNT); + passwordConfirmFailureTracker.reset(session); twoFactorService.grantStepUpPass(session, path); return "redirect:" + path; } - // 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어). - int failCount = incrementFailCount(session); - if (failCount >= MAX_FAIL_COUNT) { - userSessionService.removeSession(session.getId()); - new SecurityContextLogoutHandler().logout(request, response, - SecurityContextHolder.getContext().getAuthentication()); - return "redirect:/login?pwFailExceeded=1"; + // 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어). + Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response); + if (outcome.isForcedLogout()) { + return outcome.isAccountLocked() + ? "redirect:/login?pwFailExceeded=1&locked=1" + : "redirect:/login?pwFailExceeded=1"; } - model.addAttribute("error", - "현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)"); + model.addAttribute("error", outcome.getMessage()); model.addAttribute("returnUrl", path); return "apps/auth/stepupPassword"; } - private static int incrementFailCount(HttpSession session) { - Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT); - int next = (count == null ? 0 : count) + 1; - session.setAttribute(ATTR_FAIL_COUNT, next); - return next; - } - /** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */ private static String pathOf(String url) { if (url == null) { diff --git a/src/main/java/com/eactive/apim/portal/apps/community/partnership/controller/PartnershipApplicationController.java b/src/main/java/com/eactive/apim/portal/apps/community/partnership/controller/PartnershipApplicationController.java index fdb6d90..74b60b5 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/partnership/controller/PartnershipApplicationController.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/partnership/controller/PartnershipApplicationController.java @@ -2,6 +2,8 @@ package com.eactive.apim.portal.apps.community.partnership.controller; import com.eactive.apim.portal.apps.community.partnership.dto.PartnershipApplicationDTO; import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade; +import com.eactive.apim.portal.common.security.WriteRateLimitService; +import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.UserTypeUtil; import java.io.IOException; @@ -20,10 +22,13 @@ import javax.validation.Valid; public class PartnershipApplicationController { private final PartnershipApplicationFacade partnershipApplicationFacade; + private final WriteRateLimitService writeRateLimitService; @Autowired - public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){ + public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade, + WriteRateLimitService writeRateLimitService){ this.partnershipApplicationFacade = partnershipApplicationFacade; + this.writeRateLimitService = writeRateLimitService; } @@ -52,6 +57,13 @@ public class PartnershipApplicationController { return "apps/community/mainPartnershipForm"; } + // 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다. + if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) { + redirectAttributes.addFlashAttribute("error", + writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP)); + return "redirect:/partnership"; + } + partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO); // 성공 메시지 추가 diff --git a/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java b/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java index 45538e0..c790efc 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/partnership/repository/PartnershipApplicationRepository.java @@ -4,6 +4,7 @@ import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplicat import com.eactive.eai.rms.data.EMSDataSource; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @@ -19,6 +20,12 @@ public interface PartnershipApplicationRepository extends JpaRepository findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy); + /** + * 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한). + * 등가 조회가 가능한 이유는 위와 동일하다. + */ + List findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable); + /** * 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어 * 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제). diff --git a/src/main/java/com/eactive/apim/portal/apps/community/qna/controller/InquiryController.java b/src/main/java/com/eactive/apim/portal/apps/community/qna/controller/InquiryController.java index 4dbbb02..6d0d24a 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/qna/controller/InquiryController.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/qna/controller/InquiryController.java @@ -3,6 +3,8 @@ package com.eactive.apim.portal.apps.community.qna.controller; import com.eactive.apim.portal.apps.community.qna.dto.InquiryDTO; import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch; import com.eactive.apim.portal.apps.community.qna.service.InquiryFacade; +import com.eactive.apim.portal.common.security.WriteRateLimitService; +import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.UserTypeUtil; @@ -37,11 +39,14 @@ public class InquiryController { public static final String REDIRECT_INQUIRY = "redirect:/inquiry"; private final InquiryFacade inquiryFacade; private final InquiryCommentFacade inquiryCommentFacade; + private final WriteRateLimitService writeRateLimitService; public InquiryController(InquiryFacade inquiryFacade, - InquiryCommentFacade inquiryCommentFacade) { + InquiryCommentFacade inquiryCommentFacade, + WriteRateLimitService writeRateLimitService) { this.inquiryFacade = inquiryFacade; this.inquiryCommentFacade = inquiryCommentFacade; + this.writeRateLimitService = writeRateLimitService; } /** @@ -133,6 +138,13 @@ public class InquiryController { return APPS_COMMUNITY_MAIN_INQUIRY_FORM; } + // 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다. + if (writeRateLimitService.isExceeded(WriteTarget.INQUIRY)) { + redirectAttributes.addFlashAttribute("error", + writeRateLimitService.exceededMessage(WriteTarget.INQUIRY)); + return REDIRECT_INQUIRY; + } + inquiryFacade.createInquiry(inquiryDTO, image); redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다."); diff --git a/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java b/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java index 7d1dffc..271eeb0 100644 --- a/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java +++ b/src/main/java/com/eactive/apim/portal/apps/community/qna/repository/InquiryRepository.java @@ -5,6 +5,7 @@ import com.eactive.apim.portal.qna.entity.Inquiry; import com.eactive.eai.rms.data.EMSDataSource; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; @@ -21,4 +22,10 @@ public interface InquiryRepository extends JpaRepository, JpaSp /** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */ List findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix); + + /** + * 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한). + * createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다. + */ + List findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java index 2f90a16..05eaa6a 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java @@ -9,6 +9,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.apps.user.dto.*; import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade; import com.eactive.apim.portal.apps.user.facade.UserFacade; +import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.PhoneNumberUtil; import com.eactive.apim.portal.common.util.SecurityUtil; @@ -55,6 +56,7 @@ public class AccountController { private final UserSessionService userSessionService; private final TwoFactorService twoFactorService; private final TwoFactorProperties twoFactorProperties; + private final PasswordConfirmFailureTracker passwordConfirmFailureTracker; /** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */ @@ -65,12 +67,18 @@ public class AccountController { } @PostMapping("/password/confirm") - public ResponseEntity confirmPassword(@RequestParam String inputPassword) { + public ResponseEntity confirmPassword(@RequestParam String inputPassword, + HttpSession session, HttpServletRequest request, HttpServletResponse response) { String currentLoginId = SecurityUtil.getCurrentLoginId(); - boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword); + if (userFacade.verifyCurrentPassword(currentLoginId, inputPassword)) { + passwordConfirmFailureTracker.reset(session); + return ResponseEntity.ok(new ValidationResponse(true, "비밀번호가 확인되었습니다.")); + } - String message = isPasswordCorrect ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다."; - return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message)); + // 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다. + PasswordConfirmFailureTracker.Outcome outcome = + passwordConfirmFailureTracker.recordFailure(session, request, response); + return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage())); } @GetMapping("/password/verify") @@ -96,15 +104,29 @@ public class AccountController { } @PostMapping("/password/verify") - public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) { + public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, + HttpSession session, HttpServletRequest request, HttpServletResponse response, Model model) { String currentLoginId = SecurityUtil.getCurrentLoginId(); if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) { + passwordConfirmFailureTracker.reset(session); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); + // GET /password/change 와 동일하게 2FA 필요 여부를 내려준다. + // 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아 + // 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다. + model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session)); return "apps/mypage/passwordChange"; - } else { - redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다."); - return "redirect:/password/verify"; } + + // 무제한 시도 차단 — 임계 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다. + PasswordConfirmFailureTracker.Outcome outcome = + passwordConfirmFailureTracker.recordFailure(session, request, response); + if (outcome.isForcedLogout()) { + return outcome.isAccountLocked() + ? "redirect:/login?pwFailExceeded=1&locked=1" + : "redirect:/login?pwFailExceeded=1"; + } + redirectAttributes.addFlashAttribute("error", outcome.getMessage()); + return "redirect:/password/verify"; } @PostMapping("/password/change") diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java index 68cc50a..d59fe20 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PasswordService.java @@ -127,26 +127,4 @@ public class PasswordService { passwordHistoryRepository.save(newHistory); } - - private boolean isValidPassword(String password) { - if (password.length() < 8) { - return false; - } - - boolean hasLetter = false; - boolean hasDigit = false; - boolean hasSpecial = false; - - for (char c : password.toCharArray()) { - if (Character.isLetter(c)) { - hasLetter = true; - } else if (Character.isDigit(c)) { - hasDigit = true; - } else if (!Character.isWhitespace(c)) { - hasSpecial = true; - } - } - - return hasLetter && hasDigit && hasSpecial; - } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/validator/PasswordValidator.java b/src/main/java/com/eactive/apim/portal/apps/user/validator/PasswordValidator.java index 1d954f8..ed73d6c 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/validator/PasswordValidator.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/validator/PasswordValidator.java @@ -1,53 +1,23 @@ package com.eactive.apim.portal.apps.user.validator; +import com.eactive.apim.portal.common.security.PasswordPolicyProperties; import com.eactive.apim.portal.common.validator.PasswordRuleValidator; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +/** + * 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고, + * 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다. + */ @Component +@RequiredArgsConstructor public class PasswordValidator { - public boolean isValidPassword(String password) { - if (password == null || password.isEmpty()) { - return false; - } - - String tmpPw = password.toUpperCase(); - return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw); - } + private final PasswordPolicyProperties passwordPolicyProperties; public boolean isValidPassword(String password, String loginId, String mobileNumber) { PasswordRuleValidator validator = new PasswordRuleValidator(); - return validator.isValid(password, loginId, mobileNumber); - } - - private boolean isValidLengthAndCharacters(String password) { - final int MIN = 8; - final int MAX = 50; - final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$"; - return password.matches(REGEX); - } - - private boolean containsInvalidPatterns(String password) { - final String SAMEPT = "(\\w)\\1\\1"; - final String BLANKPT = "(\\s)"; - - if (password.matches(BLANKPT) || password.matches(SAMEPT)) { - return true; - } - - return containsContinuousCharacters(password); - } - - private boolean containsContinuousCharacters(String password) { - for (int i = 0; i < password.length() - 2; i++) { - if (isContinuous(password.charAt(i), password.charAt(i + 1), password.charAt(i + 2))) { - return true; - } - } - return false; - } - - private boolean isContinuous(char a, char b, char c) { - return (b - a == 1) && (c - b == 1); + return validator.isValid(password, loginId, mobileNumber, + passwordPolicyProperties.isKeyboardSequenceBlocked()); } } diff --git a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java index aa6dbd2..be69738 100644 --- a/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java +++ b/src/main/java/com/eactive/apim/portal/common/breadcrumb/GlobalControllerAdvice.java @@ -12,6 +12,7 @@ import com.eactive.apim.portal.config.PortalProperties; import com.eactive.apim.portal.apps.auth.AuthNoticeProperties; import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.common.security.ClientGuardService; +import com.eactive.apim.portal.common.security.PasswordPolicyProperties; import com.eactive.apim.portal.djb.footer.RelatedSite; import com.eactive.apim.portal.djb.footer.RelatedSiteService; import com.eactive.apim.portal.djb.guide.GuideProperty; @@ -32,6 +33,9 @@ public class GlobalControllerAdvice { @Autowired private ClientGuardService clientGuardService; + @Autowired + private PasswordPolicyProperties passwordPolicyProperties; + @Autowired private PortalPropertyService portalPropertyService; @@ -111,6 +115,16 @@ public class GlobalControllerAdvice { return clientGuardService.isDevtoolsGuardEnabled(); } + /** + * 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부. + * PortalProperty(Portal/password.keyboard-sequence.block)에서 조회. + * head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다. + */ + @ModelAttribute("passwordKeyboardSequenceBlock") + public boolean passwordKeyboardSequenceBlock() { + return passwordPolicyProperties.isKeyboardSequenceBlocked(); + } + /** * 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null). */ diff --git a/src/main/java/com/eactive/apim/portal/common/exception/TooManyWriteRequestsException.java b/src/main/java/com/eactive/apim/portal/common/exception/TooManyWriteRequestsException.java new file mode 100644 index 0000000..5deeed6 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/exception/TooManyWriteRequestsException.java @@ -0,0 +1,14 @@ +package com.eactive.apim.portal.common.exception; + +/** + * 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단). + * + *

JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신 + * flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.

+ */ +public class TooManyWriteRequestsException extends RuntimeException { + + public TooManyWriteRequestsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/AccountLockService.java b/src/main/java/com/eactive/apim/portal/common/security/AccountLockService.java new file mode 100644 index 0000000..1ba7bd0 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/AccountLockService.java @@ -0,0 +1,52 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.template.entity.MessageCode; +import com.eactive.apim.portal.template.service.MessageHandlerService; +import com.eactive.apim.portal.template.service.MessageRecipient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.groovy.util.Maps; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 계정 잠금 처리 단일 지점. + * + *

로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패 + * (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AccountLockService { + + private final PortalUserRepository portalUserRepository; + private final MessageHandlerService messageHandlerService; + + /** + * 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다. + * + *

호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의 + * {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이 + * 잠그게 된다.

+ * + * @param reason 알림 문구에 실을 잠금 사유 + */ + @Transactional + public void lock(PortalUser user, String reason) { + if (user == null) { + return; + } + if ("Y".equals(user.getAccountLockYn())) { + return; + } + + user.setAccountLockYn("Y"); + portalUserRepository.save(user); + messageHandlerService.publishEvent(MessageCode.USER_ACCOUNT_LOCKED, + MessageRecipient.of(user), Maps.of("reason", reason)); + log.warn("계정 잠금 처리 userId={} reason={}", user.getId(), reason); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailurePolicy.java b/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailurePolicy.java new file mode 100644 index 0000000..2952c39 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailurePolicy.java @@ -0,0 +1,75 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 중요 페이지 진입용 비밀번호 재인증(본인확인)의 연속 실패 정책을 DB(PortalProperty)에서 조회한다. + * + *
    + *
  • {@code password.confirm.failure.limit} - 연속 실패 허용 횟수 (기본 {@value #DEFAULT_LIMIT})
  • + *
  • {@code password.confirm.failure.action} - 임계 초과 시 조치 ({@code LOGOUT} / {@code LOCK})
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PasswordConfirmFailurePolicy { + + private static final String GROUP = "Portal"; + private static final String NAME_LIMIT = "password.confirm.failure.limit"; + private static final String NAME_ACTION = "password.confirm.failure.action"; + + /** 프로퍼티 미존재/파싱 실패 시 기본 허용 횟수 */ + public static final int DEFAULT_LIMIT = 5; + + /** 임계 초과 시 조치 */ + public enum Action { + /** 세션만 강제 종료 (기본) */ + LOGOUT, + /** 계정을 잠근 뒤 세션도 종료 — 재로그인 자체가 차단된다 */ + LOCK; + + public static final Action DEFAULT = LOGOUT; + + public static Action from(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return DEFAULT; + } + try { + return Action.valueOf(raw.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("{} 값이 유효하지 않음('{}') - 기본값 {} 사용", NAME_ACTION, raw, DEFAULT); + return DEFAULT; + } + } + } + + private final PortalPropertyService portalPropertyService; + + /** 연속 실패가 이 값 이상이면 {@link #action()} 을 수행한다. */ + public int limit() { + String raw = portalPropertyService.getOrCreateProperty( + GROUP, NAME_LIMIT, String.valueOf(DEFAULT_LIMIT), + "본인확인(비밀번호 재인증) 연속 실패 허용 횟수 (이 값 이상 실패 시 조치)"); + try { + int parsed = Integer.parseInt(raw.trim()); + if (parsed > 0) { + return parsed; + } + log.warn("{} 값이 0 이하({}) - 기본값 {} 사용", NAME_LIMIT, parsed, DEFAULT_LIMIT); + } catch (NumberFormatException e) { + log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME_LIMIT, raw, DEFAULT_LIMIT); + } + return DEFAULT_LIMIT; + } + + /** 임계 초과 시 조치 */ + public Action action() { + return Action.from(portalPropertyService.getOrCreateProperty( + GROUP, NAME_ACTION, Action.DEFAULT.name(), + "본인확인 연속 실패 임계 초과 시 조치 (LOGOUT=강제 로그아웃, LOCK=계정 차단 후 로그아웃)")); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailureTracker.java b/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailureTracker.java new file mode 100644 index 0000000..0392349 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/PasswordConfirmFailureTracker.java @@ -0,0 +1,130 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.apps.session.service.UserSessionService; +import com.eactive.apim.portal.common.security.PasswordConfirmFailurePolicy.Action; +import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.util.Optional; + +/** + * 중요 페이지 진입 시 비밀번호 재인증(본인확인)의 연속 실패를 추적하고 임계 초과 시 조치한다. + * + *

무차별 대입 방어. 카운터는 세션 attribute 로 유지하고, 임계 초과 시 + * {@link PasswordConfirmFailurePolicy.Action} 에 따라 강제 로그아웃하거나 계정을 잠근다. + * step-up 확인 페이지·webhook·비밀번호 변경 진입이 모두 이 클래스를 쓴다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PasswordConfirmFailureTracker { + + /** 연속 실패 횟수 세션 attribute 키 */ + public static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT"; + + private final PasswordConfirmFailurePolicy policy; + private final UserSessionService userSessionService; + private final AccountLockService accountLockService; + private final PortalUserRepository portalUserRepository; + + /** 실패 처리 결과 */ + @Getter + public static class Outcome { + + private final int failCount; + private final int limit; + private final boolean forcedLogout; + private final boolean accountLocked; + private final String message; + + Outcome(int failCount, int limit, boolean forcedLogout, boolean accountLocked, String message) { + this.failCount = failCount; + this.limit = limit; + this.forcedLogout = forcedLogout; + this.accountLocked = accountLocked; + this.message = message; + } + } + + /** 확인 성공 시 카운터 초기화 */ + public void reset(HttpSession session) { + if (session != null) { + session.removeAttribute(ATTR_FAIL_COUNT); + } + } + + /** + * 실패 1회를 기록하고, 임계에 도달했으면 정책에 따라 조치(로그아웃 또는 계정 잠금 후 로그아웃)한다. + * 조치가 수행되면 호출자는 로그인 화면으로 보내기만 하면 된다. + */ + public Outcome recordFailure(HttpSession session, HttpServletRequest request, HttpServletResponse response) { + int limit = policy.limit(); + int failCount = increment(session); + + if (failCount < limit) { + return new Outcome(failCount, limit, false, false, + "현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + limit + "회, 초과 시 자동 " + + (policy.action() == Action.LOCK ? "차단" : "로그아웃") + "됩니다)"); + } + + boolean locked = false; + if (policy.action() == Action.LOCK) { + locked = lockCurrentUser(limit); + } + + forceLogout(session, request, response); + log.warn("본인확인 비밀번호 {}회 실패로 {} 처리", failCount, locked ? "계정 차단" : "강제 로그아웃"); + + return new Outcome(failCount, limit, true, locked, + locked + ? "비밀번호 확인 " + limit + "회 실패로 계정이 차단되었습니다. 관리자에게 문의해 주세요." + : "비밀번호 확인 " + limit + "회 실패로 로그아웃되었습니다."); + } + + private int increment(HttpSession session) { + Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT); + int next = (count == null ? 0 : count) + 1; + session.setAttribute(ATTR_FAIL_COUNT, next); + return next; + } + + /** + * 현재 사용자를 잠근다. 로그인 실패 카운트도 임계치로 맞춰 두어야 + * 세션을 새로 받아도 잠금 상태가 이어진다. + */ + private boolean lockCurrentUser(int limit) { + PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser(); + if (current == null) { + return false; + } + Optional found = portalUserRepository.findById(current.getId()); + if (!found.isPresent()) { + return false; + } + PortalUser user = found.get(); + if (user.getLoginFailureCount() == null || user.getLoginFailureCount() < limit) { + user.setLoginFailureCount(limit); + portalUserRepository.save(user); + } + accountLockService.lock(user, "본인확인 비밀번호 " + limit + "회 실패로 인한 계정 차단"); + return true; + } + + private void forceLogout(HttpSession session, HttpServletRequest request, HttpServletResponse response) { + // SecurityContextLogoutHandler 는 HTTP 세션만 무효화하므로 DB 세션 레코드를 먼저 정리한다. + userSessionService.removeSession(session.getId()); + new SecurityContextLogoutHandler().logout(request, response, + SecurityContextHolder.getContext().getAuthentication()); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/PasswordPolicyProperties.java b/src/main/java/com/eactive/apim/portal/common/security/PasswordPolicyProperties.java new file mode 100644 index 0000000..828e36e --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/PasswordPolicyProperties.java @@ -0,0 +1,36 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 비밀번호 문자열 정책 중 DB(PortalProperty)로 on/off 하는 항목을 조회한다. + * + *

group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty} + * 의 자동 생성이 동작하도록 한다.

+ * + *
    + *
  • {@code password.keyboard-sequence.block} - 키보드 배열 연속 문자(qwe/asd 등) 사용 금지
  • + *
+ */ +@Service +@RequiredArgsConstructor +public class PasswordPolicyProperties { + + private static final String GROUP = "Portal"; + private static final String NAME_KEYBOARD_SEQUENCE = "password.keyboard-sequence.block"; + + /** 프로퍼티 미존재/파싱 실패 시 기본값 (차단) */ + public static final boolean DEFAULT_KEYBOARD_SEQUENCE_BLOCK = true; + + private final PortalPropertyService portalPropertyService; + + /** 키보드 배열 연속 문자 3자리 이상 사용 금지 여부 */ + public boolean isKeyboardSequenceBlocked() { + return Boolean.parseBoolean( + portalPropertyService.getOrCreateProperty(GROUP, NAME_KEYBOARD_SEQUENCE, + String.valueOf(DEFAULT_KEYBOARD_SEQUENCE_BLOCK), + "키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 사용 금지 여부 (true/false)").trim()); + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/SecurityPolicyPropertySeeder.java b/src/main/java/com/eactive/apim/portal/common/security/SecurityPolicyPropertySeeder.java new file mode 100644 index 0000000..341e355 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/SecurityPolicyPropertySeeder.java @@ -0,0 +1,49 @@ +package com.eactive.apim.portal.common.security; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 기동 시 보안 정책 PTL_PROPERTY 를 미리 생성한다. + * + *

PTL_PROPERTY 에는 (GROUP, NAME) 유니크 제약이 없어 + * {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합하면 같은 키가 중복 INSERT 된다 + * (중복 시 Hibernate 가 {@code More than one row with the given identifier} 로 실패한다). + * 특히 {@link PasswordPolicyProperties} 는 GlobalControllerAdvice 를 통해 매 요청 조회되므로 + * 기동 직후 동시 접속에서 경합할 가능성이 높다.

+ * + *

부팅 완료 시점에 단일 스레드로 한 번 조회해 두면 이후 요청은 항상 기존 행을 읽는다. + * {@code PortalPropertyDuplicateChecker} 보다 먼저 실행되도록 우선순위를 높인다.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@Order(Ordered.HIGHEST_PRECEDENCE) +public class SecurityPolicyPropertySeeder implements ApplicationListener { + + private final PasswordPolicyProperties passwordPolicyProperties; + private final WriteRateLimitPolicy writeRateLimitPolicy; + private final PasswordConfirmFailurePolicy passwordConfirmFailurePolicy; + + @Override + @Transactional + public void onApplicationEvent(ApplicationReadyEvent event) { + try { + boolean keyboardSequenceBlock = passwordPolicyProperties.isKeyboardSequenceBlocked(); + int writeLimit = writeRateLimitPolicy.perMinuteLimit(); + int confirmLimit = passwordConfirmFailurePolicy.limit(); + PasswordConfirmFailurePolicy.Action confirmAction = passwordConfirmFailurePolicy.action(); + log.info("[보안 정책] keyboard-sequence.block={}, write.rate-limit.per-minute={}, " + + "password.confirm.failure.limit={}, password.confirm.failure.action={}", + keyboardSequenceBlock, writeLimit, confirmLimit, confirmAction); + } catch (Exception e) { + log.warn("[보안 정책] PTL_PROPERTY 사전 생성 실패 — 최초 요청 시 생성된다", e); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitPolicy.java b/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitPolicy.java new file mode 100644 index 0000000..3373650 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitPolicy.java @@ -0,0 +1,46 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 게시물·댓글·피드백 작성의 1분당 허용 건수를 DB(PortalProperty)에서 조회한다. + * + *

PTL_PROPERTY (group={@code Portal}, name={@code write.rate-limit.per-minute}) 값으로 제어한다. + * 허용 범위는 {@value #MIN_LIMIT}~{@value #MAX_LIMIT} 이며 벗어나거나 숫자가 아니면 기본값 + * {@value #DEFAULT_LIMIT} 로 동작한다. {@code 0} 은 제한 없음을 뜻한다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WriteRateLimitPolicy { + + private static final String GROUP = "Portal"; + private static final String NAME = "write.rate-limit.per-minute"; + + /** 프로퍼티 미존재/파싱 실패 시 기본 허용 건수 */ + public static final int DEFAULT_LIMIT = 1; + private static final int MIN_LIMIT = 0; + private static final int MAX_LIMIT = 10; + + private final PortalPropertyService portalPropertyService; + + /** 1분당 작성 허용 건수. 0 이면 제한하지 않는다. */ + public int perMinuteLimit() { + String raw = portalPropertyService.getOrCreateProperty( + GROUP, NAME, String.valueOf(DEFAULT_LIMIT), + "1분당 게시물·댓글·피드백 작성 허용 건수 (" + MIN_LIMIT + "~" + MAX_LIMIT + ", 0=제한없음)"); + try { + int parsed = Integer.parseInt(raw.trim()); + if (parsed >= MIN_LIMIT && parsed <= MAX_LIMIT) { + return parsed; + } + log.warn("{} 값이 허용 범위({}~{}) 밖({}) - 기본값 {} 사용", NAME, MIN_LIMIT, MAX_LIMIT, parsed, DEFAULT_LIMIT); + } catch (NumberFormatException e) { + log.warn("{} 값이 숫자가 아님('{}') - 기본값 {} 사용", NAME, raw, DEFAULT_LIMIT); + } + return DEFAULT_LIMIT; + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitService.java b/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitService.java new file mode 100644 index 0000000..5f29d69 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/security/WriteRateLimitService.java @@ -0,0 +1,156 @@ +package com.eactive.apim.portal.common.security; + +import com.eactive.apim.portal.apprequest.entity.AppRequest; +import com.eactive.apim.portal.apprequest.entity.AppRequestType; +import com.eactive.apim.portal.apprequest.repository.AppRequestRepository; +import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository; +import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository; +import com.eactive.apim.portal.common.entity.Auditable; +import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException; +import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * 작성 요청 빈도 제한(무제한 요청 차단). + * + *

카운터는 대상별로 독립이며 저장소는 DB 다 — 세션 재발급·다중 탭으로 우회할 수 없다. + * 허용 건수는 {@link WriteRateLimitPolicy} 가 PortalProperty 에서 읽는다.

+ * + *

판정은 "최신 N건을 뽑아 N번째가 1분 이내인가" 로 한다. {@code createdDate} 는 + * {@code LocalDateTimeToStringConverter} 로 문자열 저장되므로 쿼리에서 범위 비교하지 않고 + * Java 에서 비교한다.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WriteRateLimitService { + + /** 빈도 판정 기준 구간 */ + private static final Duration WINDOW = Duration.ofMinutes(1); + + /** 클라이언트/API 신규 이용신청 재신청 금지 시간. PortalProperty 와 무관한 고정값. */ + public static final Duration NEW_APP_REQUEST_COOLDOWN = Duration.ofHours(1); + + /** 빈도 제한 대상. 대상별로 카운터가 독립이다. */ + public enum WriteTarget { + INQUIRY("Q&A 문의"), + INQUIRY_COMMENT("댓글"), + PARTNERSHIP("피드백/개선요청"); + + private final String label; + + WriteTarget(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + private final WriteRateLimitPolicy writeRateLimitPolicy; + private final InquiryRepository inquiryRepository; + private final InquiryCommentRepository inquiryCommentRepository; + private final PartnershipApplicationRepository partnershipApplicationRepository; + private final AppRequestRepository appRequestRepository; + + /** 현재 로그인 사용자 기준으로 허용 빈도를 넘었는지 */ + public boolean isExceeded(WriteTarget target) { + return isExceeded(target, currentUserId()); + } + + /** 허용 빈도를 넘었는지. userId 가 없으면(비인증) 판정하지 않는다. */ + @Transactional(readOnly = true) + public boolean isExceeded(WriteTarget target, String userId) { + int limit = writeRateLimitPolicy.perMinuteLimit(); + if (limit <= 0 || userId == null || userId.isEmpty()) { + return false; + } + + List recent = findRecent(target, userId, limit); + if (recent.size() < limit) { + return false; + } + + LocalDateTime oldestOfWindow = recent.get(limit - 1).getCreatedDate(); + boolean exceeded = oldestOfWindow != null && oldestOfWindow.isAfter(LocalDateTime.now().minus(WINDOW)); + if (exceeded) { + log.warn("작성 빈도 제한 초과 target={} limit={}/분", target, limit); + } + return exceeded; + } + + /** 허용 빈도를 넘었으면 예외. JSON 응답 컨트롤러 전용. */ + public void assertNotExceeded(WriteTarget target) { + if (isExceeded(target)) { + throw new TooManyWriteRequestsException(exceededMessage(target)); + } + } + + /** 사용자에게 보여줄 차단 안내 문구 */ + public String exceededMessage(WriteTarget target) { + return target.getLabel() + " 작성은 1분에 " + writeRateLimitPolicy.perMinuteLimit() + + "건까지 가능합니다. 잠시 후 다시 시도해 주세요."; + } + + /** + * 클라이언트/API 신규 이용신청 재신청까지 남은 시간(분). 쿨다운이 아니면 0. + * 1분 미만 남았어도 안내 문구를 위해 최소 1을 돌려준다. + */ + @Transactional(readOnly = true) + public long newAppRequestCooldownRemainingMinutes() { + String userId = currentUserId(); + if (userId == null || userId.isEmpty()) { + return 0; + } + Optional latest = appRequestRepository + .findFirstByApproval_Requester_IdAndTypeOrderByCreatedDateDesc(userId, AppRequestType.NEW); + if (!latest.isPresent() || latest.get().getCreatedDate() == null) { + return 0; + } + + LocalDateTime available = latest.get().getCreatedDate().plus(NEW_APP_REQUEST_COOLDOWN); + LocalDateTime now = LocalDateTime.now(); + if (!available.isAfter(now)) { + return 0; + } + return Math.max(1, Duration.between(now, available).toMinutes()); + } + + /** 쿨다운 안내 문구 */ + public String cooldownMessage(long remainingMinutes) { + return "클라이언트/API 이용신청은 1시간에 1건까지 가능합니다. 약 " + remainingMinutes + "분 후 다시 시도해 주세요."; + } + + /** 감사 필드 createdBy 와 동일한 식별자(PortalUser.id). 비인증이면 null. */ + private static String currentUserId() { + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); + return user == null ? null : user.getId(); + } + + private List findRecent(WriteTarget target, String userId, int limit) { + PageRequest page = PageRequest.of(0, limit); + switch (target) { + case INQUIRY: + return inquiryRepository.findByCreatedByOrderByCreatedDateDesc(userId, page); + case INQUIRY_COMMENT: + return inquiryCommentRepository.findByCreatedByOrderByCreatedDateDesc(userId, page); + case PARTNERSHIP: + return partnershipApplicationRepository.findByCreatedByOrderByCreatedDateDesc(userId, page); + default: + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/common/validator/PasswordRuleValidator.java b/src/main/java/com/eactive/apim/portal/common/validator/PasswordRuleValidator.java index 717df43..b4c27d1 100644 --- a/src/main/java/com/eactive/apim/portal/common/validator/PasswordRuleValidator.java +++ b/src/main/java/com/eactive/apim/portal/common/validator/PasswordRuleValidator.java @@ -1,7 +1,9 @@ package com.eactive.apim.portal.common.validator; +import com.eactive.apim.portal.common.security.PasswordPolicyProperties; import com.eactive.apim.portal.common.util.PhoneNumberUtil; import org.apache.commons.beanutils.PropertyUtils; +import org.springframework.beans.factory.annotation.Autowired; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; @@ -26,6 +28,23 @@ public class PasswordRuleValidator implements ConstraintValidator같은 행(row) 안에서 좌우로 이어지는 경우만 본다. 예) {@code qwe}, {@code asd}, {@code trewq}. + * 클라이언트 {@code static/js/password-policy.js} 의 {@code hasKeyboardSequence} 와 동일 규칙이어야 한다.

+ */ + public static boolean hasKeyboardSequence(String password) { + if (password == null || password.length() < KEYBOARD_SEQUENCE_LENGTH) { + return false; + } + String tmpPw = password.toUpperCase(); + for (int i = 0; i <= tmpPw.length() - KEYBOARD_SEQUENCE_LENGTH; i++) { + String chunk = tmpPw.substring(i, i + KEYBOARD_SEQUENCE_LENGTH); + String reversed = new StringBuilder(chunk).reverse().toString(); + for (String row : KEYBOARD_ROWS) { + if (row.contains(chunk) || row.contains(reversed)) { + return true; + } + } + } + return false; + } + /** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */ public static boolean containsLoginIdLocalPart(String password, String loginId) { if (password == null || loginId == null || loginId.isEmpty()) { diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java index f2aa349..21b4ab1 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationFailureHandler.java @@ -4,16 +4,13 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants; import com.eactive.apim.portal.apps.login.constants.LoginFailureReason; import com.eactive.apim.portal.apps.user.service.PortalUserLogService; import com.eactive.apim.portal.common.exception.UserNotFoundException; +import com.eactive.apim.portal.common.security.AccountLockService; import com.eactive.apim.portal.common.security.LoginLockPolicy; import com.eactive.apim.portal.common.util.HttpRequestUtil; import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.common.util.StringRepeatUtil; import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; -import com.eactive.apim.portal.template.entity.MessageCode; -import com.eactive.apim.portal.template.service.MessageHandlerService; -import com.eactive.apim.portal.template.service.MessageRecipient; -import org.apache.groovy.util.Maps; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; @@ -47,18 +44,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure private final PortalUserRepository portalUserRepository; private final PortalUserLogService userLogService; - private final MessageHandlerService messageHandlerService; private final LoginLockPolicy loginLockPolicy; + private final AccountLockService accountLockService; public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository, PortalUserLogService userLogService, - MessageHandlerService messageHandlerService, - LoginLockPolicy loginLockPolicy) { + LoginLockPolicy loginLockPolicy, + AccountLockService accountLockService) { this.portalUserRepository = portalUserRepository; this.userLogService = userLogService; - this.messageHandlerService = messageHandlerService; this.loginLockPolicy = loginLockPolicy; + this.accountLockService = accountLockService; } @Override @@ -79,16 +76,10 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure int lockCount = loginLockPolicy.lockCount(); user.setLoginFailureCount(user.getLoginFailureCount() + 1); - if (user.getLoginFailureCount() >= lockCount) { - user.setAccountLockYn("Y"); - - // 계정 잠금 알림 - messageHandlerService.publishEvent( - MessageCode.USER_ACCOUNT_LOCKED, - MessageRecipient.of(user), - Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금")); - } portalUserRepository.save(user); + if (user.getLoginFailureCount() >= lockCount) { + accountLockService.lock(user, lockCount + "회 이상 로그인 실패로 인한 계정 잠금"); + } diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java index ba82247..a7e172d 100644 --- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java +++ b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/controller/InquiryCommentController.java @@ -1,5 +1,8 @@ package com.eactive.apim.portal.djb.community.qna.comment.controller; +import com.eactive.apim.portal.common.exception.TooManyWriteRequestsException; +import com.eactive.apim.portal.common.security.WriteRateLimitService; +import com.eactive.apim.portal.common.security.WriteRateLimitService.WriteTarget; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest; @@ -32,6 +35,7 @@ import java.util.Map; public class InquiryCommentController { private final InquiryCommentFacade inquiryCommentFacade; + private final WriteRateLimitService writeRateLimitService; @GetMapping("/{inquiryId}/comments") public ResponseEntity> list(@PathVariable String inquiryId) { @@ -42,6 +46,7 @@ public class InquiryCommentController { @PostMapping("/{inquiryId}/comments") public ResponseEntity create(@PathVariable String inquiryId, @Valid @RequestBody InquiryCommentCreateRequest request) { + writeRateLimitService.assertNotExceeded(WriteTarget.INQUIRY_COMMENT); PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser(); InquiryCommentDTO created = inquiryCommentFacade.createUserComment( inquiryId, request.getContent(), request.getVisibility(), current); @@ -65,6 +70,12 @@ public class InquiryCommentController { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage())); } + @ExceptionHandler(TooManyWriteRequestsException.class) + public ResponseEntity> handleTooManyRequests(TooManyWriteRequestsException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(errorBody("TOO_MANY_REQUESTS", ex.getMessage())); + } + private Map errorBody(String code, String message) { Map body = new HashMap<>(); body.put("code", code); diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java index 80bf9a3..9a83b1b 100644 --- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java +++ b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/InquiryCommentRepository.java @@ -1,6 +1,7 @@ package com.eactive.apim.portal.djb.community.qna.comment.repository; import com.eactive.apim.portal.qna.entity.InquiryComment; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -25,4 +26,11 @@ public interface InquiryCommentRepository extends JpaRepository inquiryIds); + + /** + * 작성 빈도 제한 판정용 — 특정 작성자의 최근 댓글을 최신순으로 조회한다(Pageable 로 건수 제한). + * createdBy 는 결정적 암호화라 평문 사용자 id 로 등가 조회가 가능하다. + * 삭제(delYn='Y')된 댓글도 포함한다 — 작성 후 지우는 방식의 우회를 막기 위함. + */ + List findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable); } diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java index a67581c..3c7b50c 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java @@ -3,9 +3,9 @@ package com.eactive.apim.portal.djb.webhook.controller; import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch; import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; -import com.eactive.apim.portal.apps.session.service.UserSessionService; import com.eactive.apim.portal.common.exception.UserErrorMessageResolver; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.security.PasswordConfirmFailureTracker; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO; @@ -23,8 +23,6 @@ import javax.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.annotation.Secured; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -57,16 +55,12 @@ public class WebhookController { private static final int TOTAL_STEPS = 3; - /** 비밀번호 재인증 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) — StepUpPasswordController 답습. */ - private static final int MAX_PW_FAIL_COUNT = 5; - /** 연속 실패 횟수 세션 attribute 키 */ - private static final String ATTR_PW_FAIL_COUNT = "WEBHOOK_PW_CONFIRM_FAIL_COUNT"; - private final WebhookService webhookService; private final WebhookEventTypeProvider eventTypeProvider; private final ApiServiceService apiServiceService; private final AppServiceFacade appServiceFacade; - private final UserSessionService userSessionService; + // 본인확인 연속 실패는 step-up 확인 페이지와 같은 카운터를 쓴다(화면을 바꿔가며 우회하지 못하게). + private final PasswordConfirmFailureTracker passwordConfirmFailureTracker; @ModelAttribute("webhookRegistration") public WebhookRegistrationDTO webhookRegistration() { @@ -393,32 +387,25 @@ public class WebhookController { /** * 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다. - * 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다 - * (무차별 대입 방어 — {@code StepUpPasswordController} 답습). + * 연속 실패가 임계치에 도달하면 정책({@code password.confirm.failure.action})에 따라 세션을 강제 종료하거나 + * 계정을 차단하고 {@code forceLogout=true} 를 담는다 (무차별 대입 방어). */ private Map checkPassword(String password, HttpSession session, HttpServletRequest request, HttpServletResponse response) { if (verifyPassword(password)) { - session.removeAttribute(ATTR_PW_FAIL_COUNT); + passwordConfirmFailureTracker.reset(session); return null; } - Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT); - int failCount = (count == null ? 0 : count) + 1; - session.setAttribute(ATTR_PW_FAIL_COUNT, failCount); + PasswordConfirmFailureTracker.Outcome outcome = + passwordConfirmFailureTracker.recordFailure(session, request, response); Map result = new HashMap<>(); result.put("success", false); - if (failCount >= MAX_PW_FAIL_COUNT) { - userSessionService.removeSession(session.getId()); - new SecurityContextLogoutHandler().logout(request, response, - SecurityContextHolder.getContext().getAuthentication()); + result.put("message", outcome.getMessage()); + if (outcome.isForcedLogout()) { result.put("forceLogout", true); - result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다."); - log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId()); - } else { - result.put("message", - "비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)"); + result.put("accountLocked", outcome.isAccountLocked()); } return result; } diff --git a/src/main/resources/static/js/djb/inquiry-comments.js b/src/main/resources/static/js/djb/inquiry-comments.js index a78f3fb..2f2f7c2 100644 --- a/src/main/resources/static/js/djb/inquiry-comments.js +++ b/src/main/resources/static/js/djb/inquiry-comments.js @@ -134,6 +134,14 @@ }) .then(function (res) { if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.'); + // 작성 빈도 제한(무제한 요청 차단) — 서버가 내려준 안내 문구를 그대로 보여준다. + if (res.status === 429) { + return res.json() + .catch(function () { return {}; }) + .then(function (body) { + throw new Error(body.message || '작성 빈도 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.'); + }); + } if (!res.ok) throw new Error('댓글 등록에 실패했습니다.'); return res.json(); }) diff --git a/src/main/resources/static/js/password-policy.js b/src/main/resources/static/js/password-policy.js index d9dab8e..d3d2c16 100644 --- a/src/main/resources/static/js/password-policy.js +++ b/src/main/resources/static/js/password-policy.js @@ -33,6 +33,33 @@ return false; } + // 키보드 배열 인접 3자 검사 — 서버 PasswordRuleValidator.hasKeyboardSequence 와 동일 규칙 + var KEYBOARD_SEQUENCE_LENGTH = 3; + var KEYBOARD_ROWS = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM', '1234567890']; + + function hasKeyboardSequence(pw) { + if (!pw || pw.length < KEYBOARD_SEQUENCE_LENGTH) { + return false; + } + var s = pw.toUpperCase(); + for (var i = 0; i <= s.length - KEYBOARD_SEQUENCE_LENGTH; i++) { + var chunk = s.substr(i, KEYBOARD_SEQUENCE_LENGTH); + var reversed = chunk.split('').reverse().join(''); + for (var r = 0; r < KEYBOARD_ROWS.length; r++) { + if (KEYBOARD_ROWS[r].indexOf(chunk) !== -1 || KEYBOARD_ROWS[r].indexOf(reversed) !== -1) { + return true; + } + } + } + return false; + } + + // 키보드 연속 문자 차단 여부 — 서버가 head 에 내려주는 PortalProperty 값. 미지정이면 차단(서버 기본값과 동일) + function keyboardSequenceBlocked() { + var flags = global.__PASSWORD_POLICY__; + return !(flags && flags.keyboardSequence === false); + } + // 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리 var RULES = { length: function (pw) { return pw.length >= 8 && pw.length <= 50; }, @@ -42,6 +69,8 @@ nospace: function (pw) { return !/\s/.test(pw); }, norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); }, noseq: function (pw) { return !hasSequential(pw); }, + // 키보드 배열 연속 문자 금지 — PortalProperty 로 끄면 항상 통과 + nokeyseq: function (pw) { return !keyboardSequenceBlocked() || !hasKeyboardSequence(pw); }, // 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅 noid: function (pw, ctx) { var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : ''; diff --git a/src/main/resources/templates/views/apps/community/mainInquiryList.html b/src/main/resources/templates/views/apps/community/mainInquiryList.html index 687f793..75db2e8 100644 --- a/src/main/resources/templates/views/apps/community/mainInquiryList.html +++ b/src/main/resources/templates/views/apps/community/mainInquiryList.html @@ -173,6 +173,10 @@ if (successMsg) { customPopups.showAlert(successMsg); } + var errorMsg = [[${ error }]]; + if (errorMsg) { + customPopups.showAlert(errorMsg); + } }); + + + diff --git a/src/test/java/com/eactive/apim/portal/common/validator/PasswordRuleValidatorTest.java b/src/test/java/com/eactive/apim/portal/common/validator/PasswordRuleValidatorTest.java new file mode 100644 index 0000000..b1fbbba --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/common/validator/PasswordRuleValidatorTest.java @@ -0,0 +1,73 @@ +package com.eactive.apim.portal.common.validator; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * [WEB-SER-006] 키보드 배열 연속 문자 차단 규칙 검증. + * + *

클라이언트 {@code static/js/password-policy.js} 의 hasKeyboardSequence 와 동일 규칙이어야 한다.

+ */ +class PasswordRuleValidatorTest { + + private final PasswordRuleValidator validator = new PasswordRuleValidator(); + + @Test + @DisplayName("키보드 배열 인접 3자 이상이면 연속으로 판정한다") + void detectsKeyboardSequence() { + String[] values = { + "qwe", "asd", "zxc", "qwert", "asdfg", + "QWE", "AsDfG", // 대소문자 무시 + "ewq", "dsa", "trewq", // 역방향 + "abqwe12", "12asd!@" // 문자열 중간에 포함 + }; + for (String value : values) { + assertTrue(PasswordRuleValidator.hasKeyboardSequence(value), value + " 는 키보드 연속으로 판정돼야 한다"); + } + } + + @Test + @DisplayName("키보드 배열 인접이 아니면 연속으로 보지 않는다") + void ignoresNonSequence() { + String[] values = { "qw", "qa", "qaz", "qwa", "Djb#7k2Qm4", "Jeju@2026x", "aq", "" }; + for (String value : values) { + assertFalse(PasswordRuleValidator.hasKeyboardSequence(value), value + " 는 키보드 연속이 아니어야 한다"); + } + } + + @Test + @DisplayName("null 은 연속으로 보지 않는다") + void nullIsNotSequence() { + assertFalse(PasswordRuleValidator.hasKeyboardSequence(null)); + } + + @Test + @DisplayName("점검 지적 사례 qwert12! 는 정책 적용 시 거부된다") + void rejectsReportedCase() { + assertFalse(validator.isValid("qwert12!", null, null, true), "qwert12! 는 거부돼야 한다"); + } + + @Test + @DisplayName("정책을 끄면 qwert12! 가 다른 규칙만으로 판정된다") + void allowsWhenPolicyDisabled() { + assertTrue(validator.isValid("qwert12!", null, null, false), "정책 off 면 통과해야 한다"); + } + + @Test + @DisplayName("정상 비밀번호는 정책을 켜도 통과한다") + void acceptsStrongPassword() { + assertTrue(validator.isValid("Djb#7k2Qm4", null, null, true)); + } + + @Test + @DisplayName("기존 규칙(연속 문자·반복·공백)은 그대로 동작한다") + void keepsExistingRules() { + assertFalse(validator.isValid("abc12345!", null, null, true), "알파벳 3연속은 거부"); + assertFalse(validator.isValid("Djb#7kkk2", null, null, true), "동일 문자 3회 반복은 거부"); + assertFalse(validator.isValid("Djb# 7k2Q", null, null, true), "공백 포함은 거부"); + assertFalse(validator.isValid("Djb#7k2", null, null, true), "8자 미만은 거부"); + } +}