충돌 해결

This commit is contained in:
hong
2026-07-27 17:44:23 +09:00
44 changed files with 29183 additions and 583 deletions
@@ -1,6 +1,7 @@
package com.eactive.apim.portal.apps.app.controller; package com.eactive.apim.portal.apps.app.controller;
import com.eactive.apim.portal.apprequest.entity.AppRequest; import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto; import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService; import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch; import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -178,7 +179,6 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey); model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable); model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
return new ModelAndView(CREDENTIAL_DETAIL); return new ModelAndView(CREDENTIAL_DETAIL);
} }
@@ -217,9 +217,17 @@ public class MyAppController {
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg()); appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
result.put("success", true); result.put("success", true);
result.put("message", "신청이 취소되었습니다."); result.put("message", "신청이 취소되었습니다.");
} catch (Exception e) { } catch (InvalidApprovalTransitionException e) {
result.put("success", false); result.put("success", false);
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage()); result.put("message", "내부 결재가 진행 중이라 신청 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
} catch (IllegalStateException e) {
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
result.put("success", false);
result.put("message", e.getMessage());
} catch (Exception e) {
log.error("API Key 신청 취소 실패. id={}", id, e);
result.put("success", false);
result.put("message", "신청 취소 중 오류가 발생했습니다.");
} }
return result; return result;
@@ -59,6 +59,7 @@ public class AppServiceFacade {
private final ApiServiceHelper apiServiceHelper; private final ApiServiceHelper apiServiceHelper;
private final FileService fileService; private final FileService fileService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AdminGatewayClient adminGatewayClient;
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) { public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
@@ -68,9 +69,13 @@ public class AppServiceFacade {
} }
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) { public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE), List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
Arrays.asList(new ProcessingState(), new RequestedState())); Arrays.asList(new ProcessingState(), new RequestedState()));
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
return appRequests; return appRequests;
} }
@@ -134,7 +139,23 @@ public class AppServiceFacade {
} }
public void cancelApiRequest(String id, PortalOrg portalOrg) { public void cancelApiRequest(String id, PortalOrg portalOrg) {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval); appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
if (request.getApproval() == null) {
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
if (StringUtils.isNotBlank(request.getClientId())) {
try {
adminGatewayClient.blockClient(request.getClientId());
} catch (Exception e) {
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
}
}
appRequestRepository.delete(request);
} else {
approvalService.cancelAppApproval(request);
}
});
} }
@@ -164,9 +185,15 @@ public class AppServiceFacade {
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices); Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
for (String apiId : apiList) { for (String apiId : apiList) {
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId); // 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
ApiSpecInfoDto spec = apiService.selectDetail(apiId); ApiSpecInfoDto spec = apiService.selectDetail(apiId);
spec.setService(serviceDTO.getGroupName()); if (spec == null) {
continue;
}
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
if (serviceDTO != null) {
spec.setService(serviceDTO.getGroupName());
}
appRequest.getApiSpecList().add(spec); appRequest.getApiSpecList().add(spec);
} }
} }
@@ -51,7 +51,7 @@ public class AccountController {
private final UserSessionService userSessionService; private final UserSessionService userSessionService;
@PostMapping("/confirm_password") @PostMapping("/password/confirm")
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) { public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword); boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
@@ -60,19 +60,23 @@ public class AccountController {
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message)); return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
} }
@GetMapping("/change_password") @GetMapping("/password/verify")
public String showChangePasswordPage(Model model) { public String showChangePasswordPage(Model model, HttpSession session) {
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// ENFORCE 강제 상태면 변경 페이지에 "변경/로그아웃" 강제 팝업을 띄운다.
if (Boolean.TRUE.equals(session.getAttribute("pwEnforce"))) {
model.addAttribute("forcedPasswordReset", true);
}
return "apps/mypage/passwordChangeEntry"; return "apps/mypage/passwordChangeEntry";
} }
@GetMapping("/new_password") @GetMapping("/password/change")
public String showNewPasswordPage(Model model) { public String showNewPasswordPage(Model model) {
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
return "apps/mypage/passwordChange"; return "apps/mypage/passwordChange";
} }
@PostMapping("/verify_current_password") @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, Model model) {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) { if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
@@ -80,11 +84,11 @@ public class AccountController {
return "apps/mypage/passwordChange"; return "apps/mypage/passwordChange";
} else { } else {
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다."); redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
return "redirect:/change_password"; return "redirect:/password/verify";
} }
} }
@PostMapping("/mypage/change_new_password") @PostMapping("/password/change")
public String updatePassword(@RequestParam String newPassword, public String updatePassword(@RequestParam String newPassword,
@RequestParam String confirmPassword, @RequestParam String confirmPassword,
HttpSession session, HttpSession session,
@@ -96,10 +100,11 @@ public class AccountController {
String currentLoginId = SecurityUtil.getCurrentLoginId(); String currentLoginId = SecurityUtil.getCurrentLoginId();
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword); userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
// 비밀번호 만료 관련 세션 속성 제거 // 비밀번호 만료/강제 관련 세션 속성 제거
session.removeAttribute("passwordExpired"); session.removeAttribute("passwordExpired");
session.removeAttribute("success"); session.removeAttribute("success");
session.removeAttribute("redirectUrl"); session.removeAttribute("redirectUrl");
session.removeAttribute("pwEnforce");
// 세션 무효화 전에 DB 세션 레코드를 정리한다. // 세션 무효화 전에 DB 세션 레코드를 정리한다.
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는 // SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
@@ -113,11 +118,16 @@ public class AccountController {
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다."); redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
return "redirect:/login"; return "redirect:/login";
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
model.addAttribute("error", e.getMessage()); model.addAttribute("error", e.getMessage());
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
return "apps/mypage/passwordChange"; return "apps/mypage/passwordChange";
} catch (Exception e) { } catch (Exception e) {
model.addAttribute("error", e.getMessage()); // 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
logger.error("비밀번호 변경 처리 중 오류", e);
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO()); model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
return "apps/mypage/passwordChange"; return "apps/mypage/passwordChange";
} }
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.AuthNumberMatch; import com.eactive.apim.portal.common.validator.AuthNumberMatch;
import com.eactive.apim.portal.common.validator.CellPhone; import com.eactive.apim.portal.common.validator.CellPhone;
import com.eactive.apim.portal.common.validator.PasswordMatch; import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank; import com.eactive.apim.portal.common.validator.PasswordRule;
import lombok.Data; import lombok.Data;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotEmpty; import org.hibernate.validator.constraints.NotEmpty;
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber") @AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2") @PasswordMatch(input = "password", confirm = "password2")
@Data @Data
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber") @PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO { public class PortalUserRegistrationDTO {
/** /**
@@ -1,7 +1,7 @@
package com.eactive.apim.portal.apps.user.dto; package com.eactive.apim.portal.apps.user.dto;
import com.eactive.apim.portal.common.validator.PasswordMatch; import com.eactive.apim.portal.common.validator.PasswordMatch;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank; import com.eactive.apim.portal.common.validator.PasswordRule;
import com.eactive.apim.portal.common.validator.UniqueId; import com.eactive.apim.portal.common.validator.UniqueId;
import com.eactive.apim.portal.portaluser.entity.UserStatus; import com.eactive.apim.portal.portaluser.entity.UserStatus;
import lombok.Data; import lombok.Data;
@@ -13,7 +13,7 @@ import java.io.Serializable;
@PasswordMatch(input = "password", confirm = "password2") @PasswordMatch(input = "password", confirm = "password2")
@Data @Data
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone") @PasswordRule(loginId = "userId", password = "password", mobile = "mobilePhone")
public class UserRegisterDTO implements Serializable { public class UserRegisterDTO implements Serializable {
@@ -89,7 +89,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
@Override @Override
public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) { public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) {
boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber); boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber);
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~20자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다."; String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~50자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
return new ValidationResponse(isValid, message); return new ValidationResponse(isValid, message);
} }
@@ -43,6 +43,8 @@ public class PasswordService {
// 새 비밀번호 설정 // 새 비밀번호 설정
String newPasswordHash = passwordEncoder.encode(newPassword); String newPasswordHash = passwordEncoder.encode(newPassword);
user.setPasswordHash(newPasswordHash); user.setPasswordHash(newPasswordHash);
// 변경일 기록 → 재설정 강제(null 트리거) 해제
user.setPasswordChangeDate(LocalDateTime.now());
portalUserRepository.save(user); portalUserRepository.save(user);
savePasswordHistory(user.getId(), newPasswordHash); savePasswordHistory(user.getId(), newPasswordHash);
@@ -184,6 +184,8 @@ public class PortalUserService {
user.setPasswordHash(passwordEncoder.encode(dto.getPassword())); user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
user.setMobileNumber(dto.getMobileNumber()); user.setMobileNumber(dto.getMobileNumber());
user.setEmailAddr(normalizedEmail); user.setEmailAddr(normalizedEmail);
// 가입 시점을 비밀번호 변경일로 기록 → 신규 가입자는 재설정 강제 대상에서 제외된다.
user.setPasswordChangeDate(java.time.LocalDateTime.now());
} }
/** /**
@@ -1,7 +1,6 @@
package com.eactive.apim.portal.apps.user.validator; package com.eactive.apim.portal.apps.user.validator;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator; import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@@ -17,13 +16,13 @@ public class PasswordValidator {
} }
public boolean isValidPassword(String password, String loginId, String mobileNumber) { public boolean isValidPassword(String password, String loginId, String mobileNumber) {
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator(); PasswordRuleValidator validator = new PasswordRuleValidator();
return validator.isValid(password, loginId, mobileNumber); return validator.isValid(password, loginId, mobileNumber);
} }
private boolean isValidLengthAndCharacters(String password) { private boolean isValidLengthAndCharacters(String password) {
final int MIN = 8; final int MIN = 8;
final int MAX = 20; final int MAX = 50;
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$"; final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
return password.matches(REGEX); return password.matches(REGEX);
} }
@@ -1,12 +1,12 @@
package com.eactive.apim.portal.common.dto; package com.eactive.apim.portal.common.dto;
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank; import com.eactive.apim.portal.common.validator.PasswordRule;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@Getter @Getter
@Setter @Setter
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobile") @PasswordRule(password = "password", loginId = "loginId", mobile = "mobile")
public class PasswordValidationDTO { public class PasswordValidationDTO {
private String password; private String password;
private String loginId; private String loginId;
@@ -5,14 +5,20 @@ import javax.validation.Payload;
import java.lang.annotation.*; import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleValidator.class) @Constraint(validatedBy = PasswordRuleValidator.class)
@Target({ElementType.FIELD}) @Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Documented @Documented
public @interface PasswordRule { public @interface PasswordRule {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 3자리 이상 연속,반복 문자 불가)"; String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~50자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {}; Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {}; Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
} }
@@ -1,24 +0,0 @@
package com.eactive.apim.portal.common.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PasswordRuleForDjbank {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
}
@@ -1,144 +0,0 @@
package com.eactive.apim.portal.common.validator;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created by Sungpil Hyun
*/
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
// 최소 8자, 최대 20자 상수 선언
private static final int MIN = 8;
private static final int MAX = 20;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1";
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
@Override
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
// 정규식 검사객체
Matcher matcher;
// 공백 체크
if (password == null || "".equals(password)) {
return false;
}
// ASCII 문자 비교를 위한 UpperCase
String tmpPw = password.toUpperCase();
// 문자열 길이
int strLen = tmpPw.length();
// 글자 길이 체크
if (strLen > 20 || strLen < 8) {
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 비밀번호 정규식 체크
matcher = Pattern.compile(REGEX).matcher(tmpPw);
if (!matcher.find()) {
return false;
}
// 동일한 문자 3개 이상 체크
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 연속된 문자 / 숫자 3개 이상 체크
// ASCII Char를 담을 배열 선언
int[] tmpArray = new int[strLen];
// Make Array
for (int i = 0; i < strLen; i++) {
tmpArray[i] = tmpPw.charAt(i);
}
// Validation Array
for (int i = 0; i < strLen - 2; i++) {
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
return false;
}
}
// Validation Complete
return true;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
}
static boolean isContinuous(int first, int second, int third) {
// 배열의 연속된 수 검사
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
}
}
@@ -1,28 +0,0 @@
package com.eactive.apim.portal.common.validator;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
@Constraint(validatedBy = PasswordRuleForKbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PasswordRuleForKbank {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
}
@@ -1,144 +0,0 @@
package com.eactive.apim.portal.common.validator;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import org.apache.commons.beanutils.PropertyUtils;
/**
* Created by Sungpil Hyun
*/
public class PasswordRuleForKbankValidator implements ConstraintValidator<PasswordRuleForKbank, Object> {
// 최소 8자, 최대 20자 상수 선언
private static final int MIN = 8;
private static final int MAX = 20;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1";
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
@Override
public void initialize(PasswordRuleForKbank constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
// 정규식 검사객체
Matcher matcher;
// 공백 체크
if (password == null || "".equals(password)) {
return false;
}
// ASCII 문자 비교를 위한 UpperCase
String tmpPw = password.toUpperCase();
// 문자열 길이
int strLen = tmpPw.length();
// 글자 길이 체크
if (strLen > 20 || strLen < 8) {
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 비밀번호 정규식 체크
matcher = Pattern.compile(REGEX).matcher(tmpPw);
if (!matcher.find()) {
return false;
}
// 동일한 문자 3개 이상 체크
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 연속된 문자 / 숫자 3개 이상 체크
// ASCII Char를 담을 배열 선언
int[] tmpArray = new int[strLen];
// Make Array
for (int i = 0; i < strLen; i++) {
tmpArray[i] = tmpPw.charAt(i);
}
// Validation Array
for (int i = 0; i < strLen - 2; i++) {
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
return false;
}
}
// Validation Complete
return true;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
}
static boolean isContinuous(int first, int second, int third) {
// 배열의 연속된 수 검사
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
}
}
@@ -1,24 +0,0 @@
package com.eactive.apim.portal.common.validator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PasswordRuleForKjbank {
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String loginId();
String mobile();
}
@@ -1,144 +0,0 @@
package com.eactive.apim.portal.common.validator;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created by Sungpil Hyun
*/
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
// 최소 8자, 최대 20자 상수 선언
private static final int MIN = 8;
private static final int MAX = 20;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1";
// 공백 문자 정규식
private static final String BLANKPT = "(\\s)";
@Override
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
// 정규식 검사객체
Matcher matcher;
// 공백 체크
if (password == null || "".equals(password)) {
return false;
}
// ASCII 문자 비교를 위한 UpperCase
String tmpPw = password.toUpperCase();
// 문자열 길이
int strLen = tmpPw.length();
// 글자 길이 체크
if (strLen > 20 || strLen < 8) {
return false;
}
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 비밀번호 정규식 체크
matcher = Pattern.compile(REGEX).matcher(tmpPw);
if (!matcher.find()) {
return false;
}
// 동일한 문자 3개 이상 체크
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
if (matcher.find()) {
return false;
}
// 연속된 문자 / 숫자 3개 이상 체크
// ASCII Char를 담을 배열 선언
int[] tmpArray = new int[strLen];
// Make Array
for (int i = 0; i < strLen; i++) {
tmpArray[i] = tmpPw.charAt(i);
}
// Validation Array
for (int i = 0; i < strLen - 2; i++) {
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
return false;
}
}
// Validation Complete
return true;
}
static boolean isContinuous(int first, int third) {
// 첫 글자 A-Z / 0-9
return (first > 47 && third < 58) || (first > 64 && third < 91);
}
static boolean isContinuous(int first, int second, int third) {
// 배열의 연속된 수 검사
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
}
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.common.validator; package com.eactive.apim.portal.common.validator;
import org.apache.commons.beanutils.PropertyUtils;
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext; import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher; import java.util.regex.Matcher;
@@ -8,11 +10,15 @@ import java.util.regex.Pattern;
/** /**
* Created by Sungpil Hyun * Created by Sungpil Hyun
*/ */
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, String> { public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, Object> {
// 최소 8자, 최대 20자 상수 선언 // 최소 8자, 최대 50자 상수 선언
private static final int MIN = 8; private static final int MIN = 8;
private static final int MAX = 20; private static final int MAX = 50;
private String password;
private String loginId;
private String mobileNumber;
// 3자리 연속 문자 정규식 // 3자리 연속 문자 정규식
private static final String SAMEPT = "(\\w)\\1\\1"; private static final String SAMEPT = "(\\w)\\1\\1";
@@ -21,11 +27,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
@Override @Override
public void initialize(PasswordRule constraintAnnotation) { public void initialize(PasswordRule constraintAnnotation) {
this.password = constraintAnnotation.password();
this.loginId = constraintAnnotation.loginId();
this.mobileNumber = constraintAnnotation.mobile();
} }
@Override @Override
public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) { public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String passwordValue = null;
String loginIdValue = null;
String mobileNumberValue = null;
try {
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
} catch (Exception e) {
return false;
}
return isValid(passwordValue, loginIdValue, mobileNumberValue);
}
public boolean isValid(String password, String loginId, String mobileNumber) {
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식 // 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$"; String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
@@ -43,10 +68,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
int strLen = tmpPw.length(); int strLen = tmpPw.length();
// 글자 길이 체크 // 글자 길이 체크
if (strLen > 20 || strLen < 8) { if (strLen > MAX || strLen < MIN) {
return false; return false;
} }
if (loginId != null && !loginId.isEmpty()) {
String[] loginParts = loginId.split("@");
if (loginParts.length > 0) {
String username = loginParts[0].toUpperCase();
if (tmpPw.contains(username)) {
return false;
}
}
}
// Mobile number validation
if (mobileNumber != null && !mobileNumber.isEmpty()) {
String[] mobileParts = mobileNumber.split("-");
for (String part : mobileParts) {
if (!part.isEmpty() && tmpPw.contains(part)) {
return false;
}
}
}
// 공백 체크 // 공백 체크
matcher = Pattern.compile(BLANKPT).matcher(tmpPw); matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
if (matcher.find()) { if (matcher.find()) {
@@ -94,5 +139,4 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2 // 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2; return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
} }
} }
@@ -0,0 +1,85 @@
package com.eactive.apim.portal.config;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* 비밀번호 변경 강제(ENFORCE) 가드.
*
* <p>로그인 시 {@code PortalAuthenticationSuccessHandler} 가 대상자(비밀번호 미변경/만료)에게
* 세션 플래그 {@link #ENFORCE_SESSION_ATTR} 를 설정한다. 이 플래그가 있는 동안에는 비밀번호
* 변경/검증/로그아웃 경로를 제외한 모든 요청을 변경 페이지로 리다이렉트하여 접근을 차단한다.
* 비밀번호 변경 완료 시 플래그가 제거되어 정상 접근이 회복된다.</p>
*
* <p>정적 자원 경로는 {@code PortalConfigWebDispatcherServlet.addInterceptors} 의
* excludePathPatterns 로 제외한다.</p>
*/
public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor {
/** ENFORCE 대상 세션 플래그. 로그인 핸들러가 설정, 변경 완료 시 제거. */
public static final String ENFORCE_SESSION_ATTR = "pwEnforce";
/** 강제 상태에서도 접근 허용하는 경로(화이트리스트) */
private static final Set<String> ALLOWED_PATHS = new HashSet<>(Arrays.asList(
"/password/verify", // 현재 비밀번호 입력(진입) + 검증(POST)
"/password/change", // 새 비밀번호 폼(GET) + 실제 변경(POST)
"/password/confirm", // 비밀번호 확인 AJAX
"/actionLogout.do", // 로그아웃
"/login",
"/error", "/403", "/404"
));
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false);
if (session == null || !Boolean.TRUE.equals(session.getAttribute(ENFORCE_SESSION_ATTR))) {
return true;
}
// AJAX/API 등 비(非)페이지 요청은 강제 리다이렉트 대상에서 제외한다.
// (세션 heartbeat 같은 인프라 호출을 302로 튕기면 keepalive JS가 세션만료로 오판하여
// 로그인↔홈↔변경페이지 무한 리다이렉트가 발생한다.)
if (!isTopLevelHtmlNavigation(request)) {
return true;
}
String path = request.getServletPath();
if (path != null && ALLOWED_PATHS.contains(path)) {
return true;
}
String target = request.getContextPath() + "/password/verify";
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
if (request.getRequestURI().equals(target)) {
return true;
}
response.sendRedirect(target);
return false;
}
/**
* 브라우저 주소창 이동(최상위 HTML 문서 요청)인지 판별한다.
* GET + Accept: text/html + 비-AJAX 만 강제 리다이렉트 대상으로 본다.
*/
private boolean isTopLevelHtmlNavigation(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod())) {
return false;
}
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
return false;
}
String fetchMode = request.getHeader("Sec-Fetch-Mode");
if (fetchMode != null && !"navigate".equalsIgnoreCase(fetchMode)) {
return false;
}
String accept = request.getHeader("Accept");
return accept != null && accept.contains("text/html");
}
}
@@ -0,0 +1,38 @@
package com.eactive.apim.portal.config;
/**
* 비밀번호 변경 강제 정책 레벨.
*
* <p>PTL_PROPERTY (group={@code Portal}, name={@code passwordChangeEnforcement}) 값으로 제어한다.</p>
* <ul>
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
* <li>{@link #ENFORCE} — 대상자는 비밀번호 변경 완료 전까지 변경/검증/로그아웃 외 접근 차단.</li>
* </ul>
*/
public enum PasswordEnforcementPolicy {
NONE,
PERMISSIVE,
ENFORCE;
/** PTL_PROPERTY 그룹명 */
public static final String PROPERTY_GROUP = "Portal";
/** PTL_PROPERTY 이름 */
public static final String PROPERTY_NAME = "passwordChangeEnforcement";
/** 기본값 (배포 직후 동작) */
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
/**
* 문자열을 정책으로 파싱한다. 대소문자 무시, 미해당/공백이면 {@link #DEFAULT} 반환.
*/
public static PasswordEnforcementPolicy from(String value) {
if (value == null) {
return DEFAULT;
}
try {
return PasswordEnforcementPolicy.valueOf(value.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return DEFAULT;
}
}
}
@@ -13,6 +13,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory; import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository; import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.template.entity.MessageCode; import com.eactive.apim.portal.template.entity.MessageCode;
@@ -52,6 +53,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
private final UserInvitationRepository userInvitationRepository; private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository; private final PortalOrgRepository portalOrgRepository;
private final UserSessionService userSessionService; private final UserSessionService userSessionService;
private final PortalPropertyService portalPropertyService;
@Override @Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
@@ -85,13 +87,18 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
session.setAttribute("dormantLoginId", username); session.setAttribute("dormantLoginId", username);
session.setAttribute("redirectUrl", contextPath + "/dormant_account"); session.setAttribute("redirectUrl", contextPath + "/dormant_account");
} else if (isTemporaryPasswordLogin(user)) { } else if (isTemporaryPasswordLogin(user)) {
session.setAttribute("success", "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요."); applyPasswordChangeState(session,
session.setAttribute("passwordExpired", true); "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
session.setAttribute("redirectUrl", contextPath + "/new_password"); contextPath + "/password/change");
} else if (isPasswordChangeRequired(user)) { } else if (isPasswordChangeRequired(user)) {
session.setAttribute("success", "비밀번호를 변경한 지 90일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요."); applyPasswordChangeState(session,
session.setAttribute("passwordExpired", true); "비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
session.setAttribute("redirectUrl", contextPath + "/new_password"); contextPath + "/password/change");
} else if (user.getPasswordChangeDate() == null) {
// 비밀번호 변경일 미기록(예: 기존 가입자) → 재설정 대상. 현재 비밀번호 검증 진입 경로로 안내.
applyPasswordChangeState(session,
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
contextPath + "/password/verify");
} }
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시) // 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
@@ -179,6 +186,35 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
sessionLogger.info(logMessage.toString()); sessionLogger.info(logMessage.toString());
} }
/**
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
* <ul>
* <li>NONE — 아무것도 하지 않음.</li>
* <li>PERMISSIVE — 안내 알림만(로그인 후 dismissible 팝업). 강제 없음.</li>
* <li>ENFORCE — 안내 + 세션 강제 플래그 설정 → 인터셉터가 변경 완료까지 접근 차단.</li>
* </ul>
*/
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
portalPropertyService.getOrCreateProperty(
PasswordEnforcementPolicy.PROPERTY_GROUP,
PasswordEnforcementPolicy.PROPERTY_NAME,
PasswordEnforcementPolicy.DEFAULT.name(),
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
if (policy == PasswordEnforcementPolicy.NONE) {
return;
}
session.setAttribute("success", message);
session.setAttribute("passwordExpired", true);
session.setAttribute("redirectUrl", redirectUrl);
if (policy == PasswordEnforcementPolicy.ENFORCE) {
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
}
}
private boolean isPasswordChangeRequired(PortalUser user) { private boolean isPasswordChangeRequired(PortalUser user) {
// 가장 최근 비밀번호 변경 이력 조회 // 가장 최근 비밀번호 변경 이력 조회
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
@@ -22,6 +22,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.multipart.support.MultipartFilter; import org.springframework.web.multipart.support.MultipartFilter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration; import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -76,6 +77,17 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
registry.addConverter(enabledStatusConverter()); registry.addConverter(enabledStatusConverter());
} }
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
"/api/**");
}
@Bean @Bean
public EnabledStatusConverter enabledStatusConverter() { public EnabledStatusConverter enabledStatusConverter() {
return new EnabledStatusConverter(); return new EnabledStatusConverter();
+4 -4
View File
@@ -369,12 +369,12 @@ page:
credential_detail: credential_detail:
name: "인증키 정보" name: "인증키 정보"
path: "/myapikey/credential_detail" path: "/myapikey/credential_detail"
change_password: password_verify:
name: "비밀번호 변경" name: "비밀번호 변경"
path: "/change_password" path: "/password/verify"
verify_current_password: password_change:
name: "비밀번호 변경" name: "비밀번호 변경"
path: "/verify_current_password" path: "/password/change"
myapikey_register_step1: myapikey_register_step1:
name: "앱 생성 (기본 정보)" name: "앱 생성 (기본 정보)"
path: "/myapikey/register/step1" path: "/myapikey/register/step1"
+20
View File
@@ -69,6 +69,24 @@
</encoder> </encoder>
</appender> </appender>
<!-- ERROR 레벨만 별도 수집(스택 트레이스 포함). 장애 원인 추적용. -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level> <level>${CONSOLE_EFFECTIVE_LEVEL}</level>
@@ -89,12 +107,14 @@
<root level="INFO"> <root level="INFO">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root> </root>
<springProfile name="dev"> <springProfile name="dev">
<root level="DEBUG"> <root level="DEBUG">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
<appender-ref ref="CONSOLE"/> <appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_FILE"/>
</root> </root>
</springProfile> </springProfile>
</configuration> </configuration>
@@ -39,7 +39,7 @@ deptUserManageRegister.id=Department User ID
deptUserManageRegister.name=Department User Name deptUserManageRegister.name=Department User Name
portalUser.confirm.password=Please enter your existing password portalUser.confirm.password=Please enter your existing password
portalUser.Register.userName=Name portalUser.Register.userName=Name
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-20 characters) portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-50 characters)
portalUser.Register.passConfirm=Confirm Password portalUser.Register.passConfirm=Confirm Password
portalUser.Register.email=Email ID portalUser.Register.email=Email ID
portalUser.Register.domain=Domain portalUser.Register.domain=Domain
@@ -39,7 +39,7 @@ deptUserManageRegister.name=\uBD80\uC11C \uC0AC\uC6A9\uC790 \uC774\uB984
entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984 entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984
portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694 portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694
portalUser.Register.userName=\uC774\uB984 portalUser.Register.userName=\uC774\uB984
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-20\uC790 portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-50\uC790
portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778 portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778
portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514 portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514
portalUser.Register.domain=\uB3C4\uBA54\uC778 portalUser.Register.domain=\uB3C4\uBA54\uC778
+69
View File
@@ -7571,6 +7571,69 @@ button.djb-comment-submit:disabled {
font-size: 13px; font-size: 13px;
} }
.password-policy-checklist {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
}
.password-policy-checklist li {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
line-height: 20px;
margin-bottom: 6px;
transition: color 0.15s ease;
}
.password-policy-checklist li:last-child {
margin-bottom: 0;
}
.password-policy-checklist li .policy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.password-policy-checklist li .policy-icon::before {
content: "•";
}
.password-policy-checklist li.is-idle {
color: #888;
}
.password-policy-checklist li.is-idle .policy-icon {
color: #b5b5b5;
}
.password-policy-checklist li.is-pass {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon {
color: #1a8f4c;
}
.password-policy-checklist li.is-pass .policy-icon::before {
content: "✔";
}
.password-policy-checklist li.is-fail {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon {
color: #d63a3a;
}
.password-policy-checklist li.is-fail .policy-icon::before {
content: "✖";
}
.password-policy-note {
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
.hero-carousel-section { .hero-carousel-section {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -17995,6 +18058,12 @@ input[type=checkbox]:checked + .custom-checkbox {
font-size: 14px; font-size: 14px;
margin: 0; margin: 0;
} }
.detail-wrap .dt-cancel-notice {
margin: 10px 0 0;
text-align: center;
font-size: 14px;
color: #64748b;
}
.detail-wrap .dt-actions { .detail-wrap .dt-actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
/**
* 비밀번호 문자열 정책 라이브 검증 (공용)
*
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
*
* 사용법(마크업 구동):
* <ul class="password-policy-checklist" data-password-input="newPassword">
* <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">...</span></li>
* ... (rule: length | letter | digit | special | nospace | norepeat | noseq)
* </ul>
* <input type="password" id="newPassword" ...>
*
* DOM ready 시 자동으로 스캔하여 대상 input 에 바인딩한다.
*/
(function (global) {
'use strict';
// 3자리 연속(오름/내림) 문자·숫자 검사 — 서버 로직과 동일하게 대문자로 비교
function hasSequential(pw) {
var s = pw.toUpperCase();
for (var i = 0; i < s.length - 2; i++) {
var a = s.charCodeAt(i), b = s.charCodeAt(i + 1), c = s.charCodeAt(i + 2);
var sameCategory = (a > 47 && c < 58) || (a > 64 && c < 91); // 숫자 0-9 또는 영문 A-Z
var consecutive = Math.abs(c - b) === 1 && Math.abs(c - a) === 2;
if (sameCategory && consecutive) {
return true;
}
}
return false;
}
// 규칙별 판정 함수 (통과=true)
var RULES = {
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
digit: function (pw) { return /[0-9]/.test(pw); },
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
nospace: function (pw) { return !/\s/.test(pw); },
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
noseq: function (pw) { return !hasSequential(pw); }
};
// 전체 문자열 규칙 통과 여부
function isValid(pw) {
if (!pw) {
return false;
}
for (var key in RULES) {
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
return false;
}
}
return true;
}
// 체크리스트(ul) 하나를 대상 input 에 바인딩
function bind(input, list) {
var $input = (input && input.jquery) ? input : $(input);
var $list = (list && list.jquery) ? list : $(list);
var $items = $list.find('li[data-rule]');
if (!$input.length || !$items.length) {
return;
}
function update() {
var pw = $input.val() || '';
$items.each(function () {
var $li = $(this);
var rule = RULES[$li.attr('data-rule')];
if (!rule) {
return;
}
$li.removeClass('is-idle is-pass is-fail');
if (pw.length === 0) {
$li.addClass('is-idle');
} else {
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
}
});
}
$input.on('input.passwordPolicy', update);
update();
}
// 마크업 구동 자동 초기화
function init(root) {
var $root = root ? $(root) : $(document);
$root.find('ul.password-policy-checklist[data-password-input]').each(function () {
var $list = $(this);
var input = document.getElementById($list.attr('data-password-input'));
if (input) {
bind(input, $list);
}
});
}
global.PasswordPolicy = {
RULES: RULES,
isValid: isValid,
bind: bind,
init: init
};
$(function () {
init(document);
});
})(window);
@@ -0,0 +1,75 @@
// 비밀번호 문자열 정책 라이브 체크리스트 (마이페이지 비밀번호 변경 + 회원가입 공용)
.password-policy-checklist {
list-style: none;
padding: 0;
margin: 12px 0 0 0;
li {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
line-height: 20px;
margin-bottom: 6px;
transition: color 0.15s ease;
&:last-child {
margin-bottom: 0;
}
.policy-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
font-size: 13px;
font-weight: 700;
line-height: 1;
&::before {
content: "\2022"; // •
}
}
&.is-idle {
color: #888;
.policy-icon {
color: #b5b5b5;
}
}
&.is-pass {
color: #1a8f4c;
.policy-icon {
color: #1a8f4c;
&::before {
content: "\2714"; // ✔
}
}
}
&.is-fail {
color: #d63a3a;
.policy-icon {
color: #d63a3a;
&::before {
content: "\2716"; // ✖
}
}
}
}
}
.password-policy-note {
margin: 10px 0 0 0;
font-size: 14px;
line-height: 18px;
color: #888;
}
+1
View File
@@ -44,6 +44,7 @@
@use 'components/breadcrumb' as *; @use 'components/breadcrumb' as *;
@use 'components/test-env-notice' as *; @use 'components/test-env-notice' as *;
@use 'components/djb-inquiry-comments' as *; @use 'components/djb-inquiry-comments' as *;
@use 'components/password-policy' as *;
// 5. Page-specific styles // 5. Page-specific styles
@use 'pages/index' as *; @use 'pages/index' as *;
@@ -1232,6 +1232,14 @@
margin: 0; margin: 0;
} }
// 내부 결재 진행중: 신청 취소 불가 안내
.dt-cancel-notice {
margin: 10px 0 0;
text-align: center;
font-size: 14px;
color: #64748b;
}
// Footer Buttons layout // Footer Buttons layout
.dt-actions { .dt-actions {
display: flex; display: flex;
@@ -57,8 +57,8 @@
<!-- Status Badge --> <!-- Status Badge -->
<span class="app-card-badge" <span class="app-card-badge"
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}" th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}"> th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
승인대기 승인정보 없음
</span> </span>
</div> </div>
<!-- App Description & Expected Completion Date --> <!-- App Description & Expected Completion Date -->
@@ -54,6 +54,9 @@
대기중 대기중
</span> </span>
</div> </div>
<div class="dt-app-status-row" th:if="${appRequest.approval == null}">
<span class="dt-status-badge status-pending">승인정보 없음</span>
</div>
<h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3> <h3 class="dt-app-name" th:text="${appRequest.clientName}">앱 이름</h3>
<!-- App Description --> <!-- App Description -->
<p class="dt-app-desc" <p class="dt-app-desc"
@@ -200,12 +203,19 @@
</div> </div>
<!-- 내부 결재 진행중: 취소 불가 안내 -->
<p class="dt-cancel-notice" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and
appRequest.approval.approvalStatus.toString() == 'PROCESSING'}">
내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.
</p>
<!-- Bottom Navigation Actions --> <!-- Bottom Navigation Actions -->
<div class="dt-actions"> <div class="dt-actions">
<!-- Cancel Request Button (danger red) --> <!-- Cancel Request Button (danger red) -->
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red" <button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
th:if="${appRequest.approval != null and appRequest.approval.approvalStatus != null and th:if="${appRequest.approval == null or (appRequest.approval.approvalStatus != null and
(appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED')}" (appRequest.approval.approvalStatus.toString() == 'PENDING' or appRequest.approval.approvalStatus.toString() == 'REQUESTED'))}"
th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)"> th:data-request-id="${appRequest.id}" onclick="cancelRequestById(this)">
신청 취소 신청 취소
</button> </button>
@@ -51,16 +51,6 @@
<!-- Credential Info Section Card --> <!-- Credential Info Section Card -->
<div class="dt-info-container"> <div class="dt-info-container">
<!-- 인증 방식 -->
<div class="dt-row">
<div class="dt-col">
<label class="dt-label">인증 방식</label>
<div class="dt-input-group">
<div class="dt-input-box" th:text="${authType}">OAuth2</div>
</div>
</div>
</div>
<!-- Client ID --> <!-- Client ID -->
<div class="dt-row"> <div class="dt-row">
<div class="dt-col"> <div class="dt-col">
@@ -15,7 +15,7 @@
<a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a> <a class="nav-link" th:href="@{/my_company_info}">기업 정보 변경</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" th:href="@{/change_password}">비밀번호 변경</a> <a class="nav-link active" th:href="@{/password/verify}">비밀번호 변경</a>
</li> </li>
</ul> </ul>
<div class="card mt-2"> <div class="card mt-2">
@@ -19,7 +19,7 @@
<div class="password-change-wrapper"> <div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2> <h2 class="page-outer-title">비밀번호 변경</h2>
<form id="passwordChangeForm" th:action="@{/mypage/change_new_password}" method="post"> <form id="passwordChangeForm" th:action="@{/password/change}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container"> <div class="register-form-container">
@@ -37,7 +37,7 @@
<span class="form-label-text">새 비밀번호</span> <span class="form-label-text">새 비밀번호</span>
</div> </div>
<div class="form-field-wrapper"> <div class="form-field-wrapper">
<input type="password" name="newPassword" class="form-input" <input type="password" id="newPassword" name="newPassword" class="form-input"
placeholder="새 비밀번호 입력" required> placeholder="새 비밀번호 입력" required>
</div> </div>
</div> </div>
@@ -52,10 +52,16 @@
</div> </div>
</div> </div>
<ul class="password-rules-list"> <ul class="password-policy-checklist" data-password-input="newPassword">
<li>영문 대문자, 소문자, 숫자, 특수문자 8~20자</li> <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li>
<li>아이디, 휴대전화, 3자리 이상연속, 반복문자 사용불가</li> <li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li>
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
</ul> </ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div> </div>
<div class="form-actions" style="justify-content: flex-end;"> <div class="form-actions" style="justify-content: flex-end;">
@@ -16,7 +16,7 @@
<div class="password-change-wrapper"> <div class="password-change-wrapper">
<h2 class="page-outer-title">비밀번호 변경</h2> <h2 class="page-outer-title">비밀번호 변경</h2>
<form th:action="@{/verify_current_password}" method="post"> <form th:action="@{/password/verify}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="register-form-container"> <div class="register-form-container">
@@ -42,7 +42,7 @@
<div class="form-actions" style="justify-content: flex-end;"> <div class="form-actions" style="justify-content: flex-end;">
<div class="right-buttons"> <div class="right-buttons">
<button type="button" class="btn-cancel" th:onclick="|location.href='@{/}'|">취소</button> <button type="button" class="btn-cancel" th:unless="${forcedPasswordReset}" th:onclick="|location.href='@{/}'|">취소</button>
<button type="submit" class="btn-apply btn-primary">확인</button> <button type="submit" class="btn-apply btn-primary">확인</button>
</div> </div>
</div> </div>
@@ -55,6 +55,26 @@
customPopups.showAlert([[${error}]]); customPopups.showAlert([[${error}]]);
}) })
</script> </script>
<script th:if="${forcedPasswordReset}" th:inline="javascript">
// 비밀번호 재설정 강제(ENFORCE): "변경" 또는 "로그아웃"만 선택 가능
$(function () {
$('#customConfirmCloseButton').hide();
$('#customConfirmYesButton').text('변경');
$('#customConfirmNoButton').text('로그아웃');
customPopups.showConfirm(
'계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경하거나 로그아웃해 주세요.',
function (selection) {
if (selection) {
// 변경: 팝업 닫고 현재 페이지(비밀번호 변경)에서 진행
$('#customConfirmCloseButton').show();
} else {
// 로그아웃
window.location.href = [[@{/actionLogout.do}]];
}
}
);
});
</script>
</section> </section>
</body> </body>
</html> </html>
@@ -78,6 +78,16 @@
th:placeholder="#{portalUser.Register.pass}"> th:placeholder="#{portalUser.Register.pass}">
<input type="hidden" name="isPasswordValid" id="isPasswordValid"/> <input type="hidden" name="isPasswordValid" id="isPasswordValid"/>
<div id="password-validation" class="org-validation-message"></div> <div id="password-validation" class="org-validation-message"></div>
<ul class="password-policy-checklist" data-password-input="password">
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자 포함 8~50자</span></li>
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문 포함</span></li>
<li data-rule="digit" class="is-idle"><span class="policy-icon"></span><span class="policy-text">숫자 포함</span></li>
<li data-rule="special" class="is-idle"><span class="policy-icon"></span><span class="policy-text">특수문자 포함</span></li>
<li data-rule="nospace" class="is-idle"><span class="policy-icon"></span><span class="policy-text">공백 사용 불가</span></li>
<li data-rule="norepeat" class="is-idle"><span class="policy-icon"></span><span class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
</ul>
<p class="password-policy-note">아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
</div> </div>
</div> </div>
@@ -107,6 +107,7 @@
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script> <script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
<script th:src="@{/js/lodash.js}"></script> <script th:src="@{/js/lodash.js}"></script>
<script th:src="@{/js/common.js}"></script> <script th:src="@{/js/common.js}"></script>
<script th:src="@{/js/password-policy.js}"></script>
<script th:src="@{/js/moment.min.js}"></script> <script th:src="@{/js/moment.min.js}"></script>
<script th:src="@{/js/daterangepicker.js}"></script> <script th:src="@{/js/daterangepicker.js}"></script>
<script th:src="@{/plugins/codemirror/codemirror.js}"></script> <script th:src="@{/plugins/codemirror/codemirror.js}"></script>
@@ -97,7 +97,7 @@
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a> <a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
</li> </li>
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li> <li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li> <li><a th:href="@{/password/verify}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -253,7 +253,7 @@
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><a th:href="@{/webhook}">Webhook 관리</a></li> <li sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><a th:href="@{/webhook}">Webhook 관리</a></li>
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li> <li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
<li><a th:href="@{/mypage}">내 정보 관리</a></li> <li><a th:href="@{/mypage}">내 정보 관리</a></li>
<li><a th:href="@{/change_password}">비밀번호 변경</a></li> <li><a th:href="@{/password/verify}">비밀번호 변경</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -73,7 +73,7 @@
th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'profile'} ? 'service-nav__item--active' : ''"
class="service-nav__item">내정보 관리</a> class="service-nav__item">내정보 관리</a>
<a th:href="@{/change_password}" <a th:href="@{/password/verify}"
th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''" th:classappend="${activeMenu == 'password'} ? 'service-nav__item--active' : ''"
class="service-nav__item">비밀번호 변경</a> class="service-nav__item">비밀번호 변경</a>
</th:block> </th:block>