계정 보안 구현 - 연속 실패 시 계정 잠금 서비스 추가
- 비밀번호 본인확인 연속 실패 정책 및 처리 로직 추가 - 작성 요청 빈도 제한 서비스 및 메시지 구현 - 키보드 연속 문자 검증 규칙 및 테스트 추가
This commit is contained in:
@@ -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.TwoFactorProperties;
|
||||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -88,6 +89,7 @@ public class MyAppController {
|
|||||||
private final FileTypeDetector fileTypeDetector;
|
private final FileTypeDetector fileTypeDetector;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final TwoFactorProperties twoFactorProperties;
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
|
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,
|
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||||
@ModelAttribute("apiKeyRegistration") ApiKeyRegistrationDTO registration,
|
@ModelAttribute("apiKeyRegistration") ApiKeyRegistrationDTO registration,
|
||||||
SessionStatus sessionStatus,
|
SessionStatus sessionStatus,
|
||||||
|
RedirectAttributes redirectAttributes,
|
||||||
Model model) {
|
Model model) {
|
||||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
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) {
|
if (clear) {
|
||||||
@@ -717,6 +728,14 @@ public class MyAppController {
|
|||||||
return new ModelAndView("redirect:/clients/register/step1");
|
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();
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+11
-26
@@ -1,12 +1,11 @@
|
|||||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
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.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 com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
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.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -33,14 +32,9 @@ import javax.servlet.http.HttpSession;
|
|||||||
@RequestMapping("/auth/stepup")
|
@RequestMapping("/auth/stepup")
|
||||||
public class StepUpPasswordController {
|
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 UserFacade userFacade;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final UserSessionService userSessionService;
|
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||||
|
|
||||||
@GetMapping("/password")
|
@GetMapping("/password")
|
||||||
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
public String page(@RequestParam(required = false) String returnUrl, Model model) {
|
||||||
@@ -65,33 +59,24 @@ public class StepUpPasswordController {
|
|||||||
String loginId = SecurityUtil.getCurrentLoginId();
|
String loginId = SecurityUtil.getCurrentLoginId();
|
||||||
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
|
||||||
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
|
||||||
session.removeAttribute(ATTR_FAIL_COUNT);
|
passwordConfirmFailureTracker.reset(session);
|
||||||
twoFactorService.grantStepUpPass(session, path);
|
twoFactorService.grantStepUpPass(session, path);
|
||||||
return "redirect:" + path;
|
return "redirect:" + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어).
|
// 연속 실패 카운트 증가. 임계치 초과 시 정책(로그아웃/계정 차단)에 따라 조치한다(무차별 대입 방어).
|
||||||
int failCount = incrementFailCount(session);
|
Outcome outcome = passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
if (failCount >= MAX_FAIL_COUNT) {
|
if (outcome.isForcedLogout()) {
|
||||||
userSessionService.removeSession(session.getId());
|
return outcome.isAccountLocked()
|
||||||
new SecurityContextLogoutHandler().logout(request, response,
|
? "redirect:/login?pwFailExceeded=1&locked=1"
|
||||||
SecurityContextHolder.getContext().getAuthentication());
|
: "redirect:/login?pwFailExceeded=1";
|
||||||
return "redirect:/login?pwFailExceeded=1";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model.addAttribute("error",
|
model.addAttribute("error", outcome.getMessage());
|
||||||
"현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
|
|
||||||
model.addAttribute("returnUrl", path);
|
model.addAttribute("returnUrl", path);
|
||||||
return "apps/auth/stepupPassword";
|
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 방지) */
|
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
|
||||||
private static String pathOf(String url) {
|
private static String pathOf(String url) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|||||||
+13
-1
@@ -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.dto.PartnershipApplicationDTO;
|
||||||
import com.eactive.apim.portal.apps.community.partnership.service.PartnershipApplicationFacade;
|
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.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -20,10 +22,13 @@ import javax.validation.Valid;
|
|||||||
public class PartnershipApplicationController {
|
public class PartnershipApplicationController {
|
||||||
|
|
||||||
private final PartnershipApplicationFacade partnershipApplicationFacade;
|
private final PartnershipApplicationFacade partnershipApplicationFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade){
|
public PartnershipApplicationController(PartnershipApplicationFacade partnershipApplicationFacade,
|
||||||
|
WriteRateLimitService writeRateLimitService){
|
||||||
this.partnershipApplicationFacade = partnershipApplicationFacade;
|
this.partnershipApplicationFacade = partnershipApplicationFacade;
|
||||||
|
this.writeRateLimitService = writeRateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +57,13 @@ public class PartnershipApplicationController {
|
|||||||
return "apps/community/mainPartnershipForm";
|
return "apps/community/mainPartnershipForm";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 무제한 작성 차단 — 폼 전송이라 예외 대신 flash 메시지로 되돌린다.
|
||||||
|
if (writeRateLimitService.isExceeded(WriteTarget.PARTNERSHIP)) {
|
||||||
|
redirectAttributes.addFlashAttribute("error",
|
||||||
|
writeRateLimitService.exceededMessage(WriteTarget.PARTNERSHIP));
|
||||||
|
return "redirect:/partnership";
|
||||||
|
}
|
||||||
|
|
||||||
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
partnershipApplicationFacade.createPartnershipApplication(partnershipApplicationDTO);
|
||||||
|
|
||||||
// 성공 메시지 추가
|
// 성공 메시지 추가
|
||||||
|
|||||||
+7
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplicat
|
|||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -19,6 +20,12 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
|||||||
*/
|
*/
|
||||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* 등가 조회가 가능한 이유는 위와 동일하다.
|
||||||
|
*/
|
||||||
|
List<PartnershipApplication> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
|
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
|
||||||
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
|
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
|
||||||
|
|||||||
+13
-1
@@ -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.InquiryDTO;
|
||||||
import com.eactive.apim.portal.apps.community.qna.dto.InquirySearch;
|
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.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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||||
@@ -37,11 +39,14 @@ public class InquiryController {
|
|||||||
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
public static final String REDIRECT_INQUIRY = "redirect:/inquiry";
|
||||||
private final InquiryFacade inquiryFacade;
|
private final InquiryFacade inquiryFacade;
|
||||||
private final InquiryCommentFacade inquiryCommentFacade;
|
private final InquiryCommentFacade inquiryCommentFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
public InquiryController(InquiryFacade inquiryFacade,
|
public InquiryController(InquiryFacade inquiryFacade,
|
||||||
InquiryCommentFacade inquiryCommentFacade) {
|
InquiryCommentFacade inquiryCommentFacade,
|
||||||
|
WriteRateLimitService writeRateLimitService) {
|
||||||
this.inquiryFacade = inquiryFacade;
|
this.inquiryFacade = inquiryFacade;
|
||||||
this.inquiryCommentFacade = inquiryCommentFacade;
|
this.inquiryCommentFacade = inquiryCommentFacade;
|
||||||
|
this.writeRateLimitService = writeRateLimitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,6 +138,13 @@ public class InquiryController {
|
|||||||
return APPS_COMMUNITY_MAIN_INQUIRY_FORM;
|
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);
|
inquiryFacade.createInquiry(inquiryDTO, image);
|
||||||
|
|
||||||
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
redirectAttributes.addFlashAttribute("success", "Q&A 작성이 완료되었습니다.");
|
||||||
|
|||||||
+7
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.qna.entity.Inquiry;
|
|||||||
import com.eactive.eai.rms.data.EMSDataSource;
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -21,4 +22,10 @@ public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSp
|
|||||||
|
|
||||||
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
|
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
|
||||||
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
|
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
|
*/
|
||||||
|
List<Inquiry> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.dto.*;
|
||||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
@@ -55,6 +56,7 @@ public class AccountController {
|
|||||||
private final UserSessionService userSessionService;
|
private final UserSessionService userSessionService;
|
||||||
private final TwoFactorService twoFactorService;
|
private final TwoFactorService twoFactorService;
|
||||||
private final TwoFactorProperties twoFactorProperties;
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||||
|
|
||||||
|
|
||||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||||
@@ -65,12 +67,18 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/confirm")
|
@PostMapping("/password/confirm")
|
||||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword,
|
||||||
|
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
|
||||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
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 ? "비밀번호가 확인되었습니다." : "비밀번호가 일치하지 않습니다.";
|
// 무제한 시도 차단 — step-up 확인 페이지와 동일한 카운터/정책을 쓴다.
|
||||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
PasswordConfirmFailureTracker.Outcome outcome =
|
||||||
|
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
|
return ResponseEntity.ok(new ValidationResponse(false, outcome.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/password/verify")
|
@GetMapping("/password/verify")
|
||||||
@@ -96,15 +104,29 @@ public class AccountController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password/verify")
|
@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();
|
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||||
|
passwordConfirmFailureTracker.reset(session);
|
||||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||||
|
// GET /password/change 와 동일하게 2FA 필요 여부를 내려준다.
|
||||||
|
// 누락되면 폼 JS 가 제출 가로채기(비밀번호 규칙 게이트 + 2FA 팝업)를 등록하지 않아
|
||||||
|
// 서버가 통과권 없음으로 계속 되돌리고 비밀번호를 바꿀 수 없다.
|
||||||
|
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
|
||||||
return "apps/mypage/passwordChange";
|
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")
|
@PostMapping("/password/change")
|
||||||
|
|||||||
@@ -127,26 +127,4 @@ public class PasswordService {
|
|||||||
|
|
||||||
passwordHistoryRepository.save(newHistory);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,23 @@
|
|||||||
package com.eactive.apim.portal.apps.user.validator;
|
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 com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 문자열 정책 검사 진입점. 규칙 자체는 정본인 {@link PasswordRuleValidator} 가 갖고,
|
||||||
|
* 여기서는 DB 토글({@link PasswordPolicyProperties})을 얹어 위임만 한다.
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class PasswordValidator {
|
public class PasswordValidator {
|
||||||
|
|
||||||
public boolean isValidPassword(String password) {
|
private final PasswordPolicyProperties passwordPolicyProperties;
|
||||||
if (password == null || password.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
String tmpPw = password.toUpperCase();
|
|
||||||
return isValidLengthAndCharacters(tmpPw) && !containsInvalidPatterns(tmpPw);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||||
return validator.isValid(password, loginId, mobileNumber);
|
return validator.isValid(password, loginId, mobileNumber,
|
||||||
}
|
passwordPolicyProperties.isKeyboardSequenceBlocked());
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.auth.AuthNoticeProperties;
|
||||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||||
import com.eactive.apim.portal.common.security.ClientGuardService;
|
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.RelatedSite;
|
||||||
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
import com.eactive.apim.portal.djb.footer.RelatedSiteService;
|
||||||
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
import com.eactive.apim.portal.djb.guide.GuideProperty;
|
||||||
@@ -32,6 +33,9 @@ public class GlobalControllerAdvice {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ClientGuardService clientGuardService;
|
private ClientGuardService clientGuardService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordPolicyProperties passwordPolicyProperties;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PortalPropertyService portalPropertyService;
|
private PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
@@ -111,6 +115,16 @@ public class GlobalControllerAdvice {
|
|||||||
return clientGuardService.isDevtoolsGuardEnabled();
|
return clientGuardService.isDevtoolsGuardEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 정책 - 키보드 배열 연속 문자 차단 여부.
|
||||||
|
* PortalProperty(Portal/password.keyboard-sequence.block)에서 조회.
|
||||||
|
* head 의 window.__PASSWORD_POLICY__ 와 비밀번호 요구사항 체크리스트 노출에 함께 쓴다.
|
||||||
|
*/
|
||||||
|
@ModelAttribute("passwordKeyboardSequenceBlock")
|
||||||
|
public boolean passwordKeyboardSequenceBlock() {
|
||||||
|
return passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||||
*/
|
*/
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.eactive.apim.portal.common.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 요청이 허용 빈도를 초과했을 때 던진다(무제한 요청 차단).
|
||||||
|
*
|
||||||
|
* <p>JSON 응답 컨트롤러에서만 사용한다 — 폼 전송 컨트롤러는 예외 대신
|
||||||
|
* flash 메시지로 되돌려야 하므로 {@code isExceeded} 판정을 직접 쓴다.</p>
|
||||||
|
*/
|
||||||
|
public class TooManyWriteRequestsException extends RuntimeException {
|
||||||
|
|
||||||
|
public TooManyWriteRequestsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정 잠금 처리 단일 지점.
|
||||||
|
*
|
||||||
|
* <p>로그인 연속 실패(`PortalAuthenticationFailureHandler`)와 본인확인 연속 실패
|
||||||
|
* (`PasswordConfirmFailureTracker`)가 같은 방식으로 계정을 잠그도록 로직을 모았다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccountLockService {
|
||||||
|
|
||||||
|
private final PortalUserRepository portalUserRepository;
|
||||||
|
private final MessageHandlerService messageHandlerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계정을 잠그고 잠금 알림을 발송한다. 이미 잠긴 계정이면 알림을 중복 발송하지 않는다.
|
||||||
|
*
|
||||||
|
* <p>호출자의 트랜잭션에 참여한다 — 로그인 실패 핸들러는 이미 자신만의
|
||||||
|
* {@code REQUIRES_NEW} 트랜잭션에서 실행되므로 별도 전파를 두면 같은 row 를 두 트랜잭션이
|
||||||
|
* 잠그게 된다.</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -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)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code password.confirm.failure.limit} - 연속 실패 허용 횟수 (기본 {@value #DEFAULT_LIMIT})</li>
|
||||||
|
* <li>{@code password.confirm.failure.action} - 임계 초과 시 조치 ({@code LOGOUT} / {@code LOCK})</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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=계정 차단 후 로그아웃)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중요 페이지 진입 시 비밀번호 재인증(본인확인)의 연속 실패를 추적하고 임계 초과 시 조치한다.
|
||||||
|
*
|
||||||
|
* <p>무차별 대입 방어. 카운터는 세션 attribute 로 유지하고, 임계 초과 시
|
||||||
|
* {@link PasswordConfirmFailurePolicy.Action} 에 따라 강제 로그아웃하거나 계정을 잠근다.
|
||||||
|
* step-up 확인 페이지·webhook·비밀번호 변경 진입이 모두 이 클래스를 쓴다.</p>
|
||||||
|
*/
|
||||||
|
@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<PortalUser> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 하는 항목을 조회한다.
|
||||||
|
*
|
||||||
|
* <p>group 은 기존 {@code Portal} 을 재사용하여 {@link PortalPropertyService#getOrCreateProperty}
|
||||||
|
* 의 자동 생성이 동작하도록 한다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code password.keyboard-sequence.block} - 키보드 배열 연속 문자(qwe/asd 등) 사용 금지</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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 를 미리 생성한다.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY 에는 (GROUP, NAME) 유니크 제약이 없어
|
||||||
|
* {@code getOrCreateProperty} 최초 조회가 동시 요청으로 경합하면 같은 키가 중복 INSERT 된다
|
||||||
|
* (중복 시 Hibernate 가 {@code More than one row with the given identifier} 로 실패한다).
|
||||||
|
* 특히 {@link PasswordPolicyProperties} 는 GlobalControllerAdvice 를 통해 <b>매 요청</b> 조회되므로
|
||||||
|
* 기동 직후 동시 접속에서 경합할 가능성이 높다.</p>
|
||||||
|
*
|
||||||
|
* <p>부팅 완료 시점에 단일 스레드로 한 번 조회해 두면 이후 요청은 항상 기존 행을 읽는다.
|
||||||
|
* {@code PortalPropertyDuplicateChecker} 보다 먼저 실행되도록 우선순위를 높인다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class SecurityPolicyPropertySeeder implements ApplicationListener<ApplicationReadyEvent> {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY (group={@code Portal}, name={@code write.rate-limit.per-minute}) 값으로 제어한다.
|
||||||
|
* 허용 범위는 {@value #MIN_LIMIT}~{@value #MAX_LIMIT} 이며 벗어나거나 숫자가 아니면 기본값
|
||||||
|
* {@value #DEFAULT_LIMIT} 로 동작한다. {@code 0} 은 제한 없음을 뜻한다.</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 요청 빈도 제한(무제한 요청 차단).
|
||||||
|
*
|
||||||
|
* <p>카운터는 대상별로 독립이며 저장소는 DB 다 — 세션 재발급·다중 탭으로 우회할 수 없다.
|
||||||
|
* 허용 건수는 {@link WriteRateLimitPolicy} 가 PortalProperty 에서 읽는다.</p>
|
||||||
|
*
|
||||||
|
* <p>판정은 "최신 N건을 뽑아 N번째가 1분 이내인가" 로 한다. {@code createdDate} 는
|
||||||
|
* {@code LocalDateTimeToStringConverter} 로 문자열 저장되므로 쿼리에서 범위 비교하지 않고
|
||||||
|
* Java 에서 비교한다.</p>
|
||||||
|
*/
|
||||||
|
@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<AppRequest> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.eactive.apim.portal.common.validator;
|
package com.eactive.apim.portal.common.validator;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.security.PasswordPolicyProperties;
|
||||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import org.apache.commons.beanutils.PropertyUtils;
|
import org.apache.commons.beanutils.PropertyUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
import javax.validation.ConstraintValidator;
|
import javax.validation.ConstraintValidator;
|
||||||
import javax.validation.ConstraintValidatorContext;
|
import javax.validation.ConstraintValidatorContext;
|
||||||
@@ -26,6 +28,23 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
// 공백 문자 정규식
|
// 공백 문자 정규식
|
||||||
private static final String BLANKPT = "(\\s)";
|
private static final String BLANKPT = "(\\s)";
|
||||||
|
|
||||||
|
/** 키보드 배열 인접 판정 기준 길이 (기존 연속 문자/숫자 규칙과 동일하게 3자) */
|
||||||
|
private static final int KEYBOARD_SEQUENCE_LENGTH = 3;
|
||||||
|
/** 키보드 배열 행. 대문자 기준으로 비교한다. */
|
||||||
|
private static final String[] KEYBOARD_ROWS = {
|
||||||
|
"QWERTYUIOP",
|
||||||
|
"ASDFGHJKL",
|
||||||
|
"ZXCVBNM",
|
||||||
|
"1234567890"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키보드 연속 문자 차단 여부. Spring 이 관리하는 경로(LocalValidatorFactoryBean)에서만 주입되며,
|
||||||
|
* 순수 {@code new} 로 생성된 경우 null 이라 기본값(차단)으로 동작한다.
|
||||||
|
*/
|
||||||
|
@Autowired(required = false)
|
||||||
|
private PasswordPolicyProperties passwordPolicyProperties;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void initialize(PasswordRule constraintAnnotation) {
|
public void initialize(PasswordRule constraintAnnotation) {
|
||||||
this.password = constraintAnnotation.password();
|
this.password = constraintAnnotation.password();
|
||||||
@@ -48,10 +67,21 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
return isValid(passwordValue, loginIdValue, mobileNumberValue, keyboardSequenceBlocked());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주입이 안 된 경우(순수 new)에는 기본값인 차단으로 동작한다. */
|
||||||
|
private boolean keyboardSequenceBlocked() {
|
||||||
|
return passwordPolicyProperties == null
|
||||||
|
? PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK
|
||||||
|
: passwordPolicyProperties.isKeyboardSequenceBlocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||||
|
return isValid(password, loginId, mobileNumber, PasswordPolicyProperties.DEFAULT_KEYBOARD_SEQUENCE_BLOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid(String password, String loginId, String mobileNumber, boolean blockKeyboardSequence) {
|
||||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||||
|
|
||||||
@@ -81,6 +111,11 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 키보드 배열 연속 문자 체크 (PortalProperty 로 on/off)
|
||||||
|
if (blockKeyboardSequence && hasKeyboardSequence(password)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 공백 체크
|
// 공백 체크
|
||||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||||
if (matcher.find()) {
|
if (matcher.find()) {
|
||||||
@@ -118,6 +153,29 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키보드 배열상 인접한 문자가 {@value #KEYBOARD_SEQUENCE_LENGTH} 자 이상 이어지는지 (정·역방향 모두).
|
||||||
|
*
|
||||||
|
* <p>같은 행(row) 안에서 좌우로 이어지는 경우만 본다. 예) {@code qwe}, {@code asd}, {@code trewq}.
|
||||||
|
* 클라이언트 {@code static/js/password-policy.js} 의 {@code hasKeyboardSequence} 와 동일 규칙이어야 한다.</p>
|
||||||
|
*/
|
||||||
|
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 가 비밀번호에 포함되는지 (대소문자 무시) */
|
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
||||||
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
||||||
if (password == null || loginId == null || loginId.isEmpty()) {
|
if (password == null || loginId == null || loginId.isEmpty()) {
|
||||||
|
|||||||
+8
-17
@@ -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.login.constants.LoginFailureReason;
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
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.security.LoginLockPolicy;
|
||||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
@@ -47,18 +44,18 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
|||||||
|
|
||||||
private final PortalUserRepository portalUserRepository;
|
private final PortalUserRepository portalUserRepository;
|
||||||
private final PortalUserLogService userLogService;
|
private final PortalUserLogService userLogService;
|
||||||
private final MessageHandlerService messageHandlerService;
|
|
||||||
private final LoginLockPolicy loginLockPolicy;
|
private final LoginLockPolicy loginLockPolicy;
|
||||||
|
private final AccountLockService accountLockService;
|
||||||
|
|
||||||
|
|
||||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||||
PortalUserLogService userLogService,
|
PortalUserLogService userLogService,
|
||||||
MessageHandlerService messageHandlerService,
|
LoginLockPolicy loginLockPolicy,
|
||||||
LoginLockPolicy loginLockPolicy) {
|
AccountLockService accountLockService) {
|
||||||
this.portalUserRepository = portalUserRepository;
|
this.portalUserRepository = portalUserRepository;
|
||||||
this.userLogService = userLogService;
|
this.userLogService = userLogService;
|
||||||
this.messageHandlerService = messageHandlerService;
|
|
||||||
this.loginLockPolicy = loginLockPolicy;
|
this.loginLockPolicy = loginLockPolicy;
|
||||||
|
this.accountLockService = accountLockService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -79,16 +76,10 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
|||||||
|
|
||||||
int lockCount = loginLockPolicy.lockCount();
|
int lockCount = loginLockPolicy.lockCount();
|
||||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
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);
|
portalUserRepository.save(user);
|
||||||
|
if (user.getLoginFailureCount() >= lockCount) {
|
||||||
|
accountLockService.lock(user, lockCount + "회 이상 로그인 실패로 인한 계정 잠금");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
@@ -1,5 +1,8 @@
|
|||||||
package com.eactive.apim.portal.djb.community.qna.comment.controller;
|
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.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
|
import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentCreateRequest;
|
||||||
@@ -32,6 +35,7 @@ import java.util.Map;
|
|||||||
public class InquiryCommentController {
|
public class InquiryCommentController {
|
||||||
|
|
||||||
private final InquiryCommentFacade inquiryCommentFacade;
|
private final InquiryCommentFacade inquiryCommentFacade;
|
||||||
|
private final WriteRateLimitService writeRateLimitService;
|
||||||
|
|
||||||
@GetMapping("/{inquiryId}/comments")
|
@GetMapping("/{inquiryId}/comments")
|
||||||
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
|
public ResponseEntity<List<InquiryCommentDTO>> list(@PathVariable String inquiryId) {
|
||||||
@@ -42,6 +46,7 @@ public class InquiryCommentController {
|
|||||||
@PostMapping("/{inquiryId}/comments")
|
@PostMapping("/{inquiryId}/comments")
|
||||||
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
public ResponseEntity<InquiryCommentDTO> create(@PathVariable String inquiryId,
|
||||||
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
@Valid @RequestBody InquiryCommentCreateRequest request) {
|
||||||
|
writeRateLimitService.assertNotExceeded(WriteTarget.INQUIRY_COMMENT);
|
||||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
InquiryCommentDTO created = inquiryCommentFacade.createUserComment(
|
||||||
inquiryId, request.getContent(), request.getVisibility(), current);
|
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()));
|
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorBody("COMMENT_NOT_OWNED", ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(TooManyWriteRequestsException.class)
|
||||||
|
public ResponseEntity<Map<String, String>> handleTooManyRequests(TooManyWriteRequestsException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(errorBody("TOO_MANY_REQUESTS", ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
private Map<String, String> errorBody(String code, String message) {
|
private Map<String, String> errorBody(String code, String message) {
|
||||||
Map<String, String> body = new HashMap<>();
|
Map<String, String> body = new HashMap<>();
|
||||||
body.put("code", code);
|
body.put("code", code);
|
||||||
|
|||||||
+8
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
||||||
|
|
||||||
import com.eactive.apim.portal.qna.entity.InquiryComment;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -25,4 +26,11 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
|
|||||||
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
|
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
|
||||||
@Transactional
|
@Transactional
|
||||||
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
|
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작성 빈도 제한 판정용 — 특정 작성자의 최근 댓글을 최신순으로 조회한다(Pageable 로 건수 제한).
|
||||||
|
* createdBy 는 결정적 암호화라 평문 사용자 id 로 등가 조회가 가능하다.
|
||||||
|
* 삭제(delYn='Y')된 댓글도 포함한다 — 작성 후 지우는 방식의 우회를 막기 위함.
|
||||||
|
*/
|
||||||
|
List<InquiryComment> findByCreatedByOrderByCreatedDateDesc(String createdBy, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-24
@@ -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.apiservice.dto.ApiGroupSearch;
|
||||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
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.exception.UserErrorMessageResolver;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
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.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||||
@@ -23,8 +23,6 @@ import javax.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.access.annotation.Secured;
|
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.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.validation.BindingResult;
|
import org.springframework.validation.BindingResult;
|
||||||
@@ -57,16 +55,12 @@ public class WebhookController {
|
|||||||
|
|
||||||
private static final int TOTAL_STEPS = 3;
|
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 WebhookService webhookService;
|
||||||
private final WebhookEventTypeProvider eventTypeProvider;
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
private final ApiServiceService apiServiceService;
|
private final ApiServiceService apiServiceService;
|
||||||
private final AppServiceFacade appServiceFacade;
|
private final AppServiceFacade appServiceFacade;
|
||||||
private final UserSessionService userSessionService;
|
// 본인확인 연속 실패는 step-up 확인 페이지와 같은 카운터를 쓴다(화면을 바꿔가며 우회하지 못하게).
|
||||||
|
private final PasswordConfirmFailureTracker passwordConfirmFailureTracker;
|
||||||
|
|
||||||
@ModelAttribute("webhookRegistration")
|
@ModelAttribute("webhookRegistration")
|
||||||
public WebhookRegistrationDTO webhookRegistration() {
|
public WebhookRegistrationDTO webhookRegistration() {
|
||||||
@@ -393,32 +387,25 @@ public class WebhookController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
|
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
|
||||||
* 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다
|
* 연속 실패가 임계치에 도달하면 정책({@code password.confirm.failure.action})에 따라 세션을 강제 종료하거나
|
||||||
* (무차별 대입 방어 — {@code StepUpPasswordController} 답습).
|
* 계정을 차단하고 {@code forceLogout=true} 를 담는다 (무차별 대입 방어).
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> checkPassword(String password, HttpSession session,
|
private Map<String, Object> checkPassword(String password, HttpSession session,
|
||||||
HttpServletRequest request, HttpServletResponse response) {
|
HttpServletRequest request, HttpServletResponse response) {
|
||||||
if (verifyPassword(password)) {
|
if (verifyPassword(password)) {
|
||||||
session.removeAttribute(ATTR_PW_FAIL_COUNT);
|
passwordConfirmFailureTracker.reset(session);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT);
|
PasswordConfirmFailureTracker.Outcome outcome =
|
||||||
int failCount = (count == null ? 0 : count) + 1;
|
passwordConfirmFailureTracker.recordFailure(session, request, response);
|
||||||
session.setAttribute(ATTR_PW_FAIL_COUNT, failCount);
|
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("success", false);
|
result.put("success", false);
|
||||||
if (failCount >= MAX_PW_FAIL_COUNT) {
|
result.put("message", outcome.getMessage());
|
||||||
userSessionService.removeSession(session.getId());
|
if (outcome.isForcedLogout()) {
|
||||||
new SecurityContextLogoutHandler().logout(request, response,
|
|
||||||
SecurityContextHolder.getContext().getAuthentication());
|
|
||||||
result.put("forceLogout", true);
|
result.put("forceLogout", true);
|
||||||
result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다.");
|
result.put("accountLocked", outcome.isAccountLocked());
|
||||||
log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId());
|
|
||||||
} else {
|
|
||||||
result.put("message",
|
|
||||||
"비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,14 @@
|
|||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.status === 409) throw new Error('종료된 문의에는 댓글을 작성할 수 없습니다.');
|
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('댓글 등록에 실패했습니다.');
|
if (!res.ok) throw new Error('댓글 등록에 실패했습니다.');
|
||||||
return res.json();
|
return res.json();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,33 @@
|
|||||||
return false;
|
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 } — 값이 없으면 해당 규칙은 통과 처리
|
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
|
||||||
var RULES = {
|
var RULES = {
|
||||||
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
||||||
@@ -42,6 +69,8 @@
|
|||||||
nospace: function (pw) { return !/\s/.test(pw); },
|
nospace: function (pw) { return !/\s/.test(pw); },
|
||||||
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
||||||
noseq: function (pw) { return !hasSequential(pw); },
|
noseq: function (pw) { return !hasSequential(pw); },
|
||||||
|
// 키보드 배열 연속 문자 금지 — PortalProperty 로 끄면 항상 통과
|
||||||
|
nokeyseq: function (pw) { return !keyboardSequenceBlocked() || !hasKeyboardSequence(pw); },
|
||||||
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
|
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
|
||||||
noid: function (pw, ctx) {
|
noid: function (pw, ctx) {
|
||||||
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
|
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
|
||||||
|
|||||||
@@ -173,6 +173,10 @@
|
|||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
customPopups.showAlert(successMsg);
|
customPopups.showAlert(successMsg);
|
||||||
}
|
}
|
||||||
|
var errorMsg = [[${ error }]];
|
||||||
|
if (errorMsg) {
|
||||||
|
customPopups.showAlert(errorMsg);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -39,9 +39,13 @@
|
|||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
|
<div th:if="${param.pwFailExceeded != null and param.locked == null}" class="login-alert alert-info">
|
||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
|
<span>비밀번호 확인 연속 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
|
||||||
|
</div>
|
||||||
|
<div th:if="${param.pwFailExceeded != null and param.locked != null}" class="login-alert alert-info">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>비밀번호 확인 연속 실패로 계정이 차단되었습니다. 관리자에게 문의해 주세요.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
|
|||||||
@@ -146,6 +146,12 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
|
<!-- 신청 쿨다운 등으로 되돌아온 경우의 안내 -->
|
||||||
|
<script th:if="${error}" th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
customPopups.showAlert([[${ error }]]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
|
class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
|
||||||
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span
|
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span
|
||||||
class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
|
class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
|
||||||
|
<li data-rule="nokeyseq" class="is-idle" th:if="${passwordKeyboardSequenceBlock}"><span
|
||||||
|
class="policy-icon"></span><span
|
||||||
|
class="policy-text">키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 불가</span></li>
|
||||||
<li data-rule="noid-server" class="is-idle"><span class="policy-icon"></span><span
|
<li data-rule="noid-server" class="is-idle"><span class="policy-icon"></span><span
|
||||||
class="policy-text">아이디(이메일) 포함 불가</span></li>
|
class="policy-text">아이디(이메일) 포함 불가</span></li>
|
||||||
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
|
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
|
||||||
|
|||||||
@@ -96,6 +96,8 @@
|
|||||||
3자리 이상 반복 불가</span></li>
|
3자리 이상 반복 불가</span></li>
|
||||||
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자
|
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자
|
||||||
3자리 이상 불가</span></li>
|
3자리 이상 불가</span></li>
|
||||||
|
<li data-rule="nokeyseq" class="is-idle" th:if="${passwordKeyboardSequenceBlock}"><span
|
||||||
|
class="policy-icon"></span><span class="policy-text">키보드 배열 연속 문자(qwe·asd 등) 3자리 이상 불가</span></li>
|
||||||
<li data-rule="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
|
<li data-rule="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
|
||||||
포함 불가</span></li>
|
포함 불가</span></li>
|
||||||
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
|
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
|
||||||
|
|||||||
@@ -175,12 +175,12 @@
|
|||||||
|
|
||||||
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
// 비밀번호 5회 오답 → 서버가 세션을 강제 종료함. 경고 후 로그인 화면으로 이동.
|
// 비밀번호 연속 오답 → 서버가 세션을 강제 종료함(정책에 따라 계정 차단까지). 경고 후 로그인 화면으로 이동.
|
||||||
// true 반환 시 호출측은 이후 처리를 중단해야 한다.
|
// true 반환 시 호출측은 이후 처리를 중단해야 한다.
|
||||||
function handleForceLogout(res) {
|
function handleForceLogout(res) {
|
||||||
if (res && res.forceLogout) {
|
if (res && res.forceLogout) {
|
||||||
alert(res.message || '비밀번호 확인 5회 실패로 로그아웃되었습니다.');
|
alert(res.message || '비밀번호 확인 연속 실패로 로그아웃되었습니다.');
|
||||||
window.location.href = '/login?pwFailExceeded=1';
|
window.location.href = res.accountLocked ? '/login?pwFailExceeded=1&locked=1' : '/login?pwFailExceeded=1';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -25,6 +25,13 @@
|
|||||||
<script th:src="@{/js/djb/client-guard.js}"></script>
|
<script th:src="@{/js/djb/client-guard.js}"></script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 비밀번호 정책 토글. password-policy.js 보다 먼저 실행되어야 규칙 판정에 반영된다. -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
window.__PASSWORD_POLICY__ = {
|
||||||
|
keyboardSequence: /*[[${passwordKeyboardSequenceBlock}]]*/ true
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|||||||
@@ -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] 키보드 배열 연속 문자 차단 규칙 검증.
|
||||||
|
*
|
||||||
|
* <p>클라이언트 {@code static/js/password-policy.js} 의 hasKeyboardSequence 와 동일 규칙이어야 한다.</p>
|
||||||
|
*/
|
||||||
|
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자 미만은 거부");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user