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 extends Auditable> 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 extends Auditable> 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