API 스펙 관리 문구 및 로직 개선
- 게이트웨이 관련 문구 수정 및 그룹 설정 안내 추가 - 비 로그인 사용자 공개 관련 문구 수정 - description 필드 저장/복원 로직 추가
This commit is contained in:
@@ -57,6 +57,13 @@ public class AccountController {
|
|||||||
private final TwoFactorProperties twoFactorProperties;
|
private final TwoFactorProperties twoFactorProperties;
|
||||||
|
|
||||||
|
|
||||||
|
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||||
|
@PostMapping("/password/content-check")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> checkPasswordContent(@RequestParam String password) {
|
||||||
|
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||||
|
return ResponseEntity.ok(userFacade.checkPasswordContent(currentLoginId, password));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/password/confirm")
|
@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();
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final BasicValidationService validationService;
|
private final BasicValidationService validationService;
|
||||||
private final UserRegistrationValidationService userRegistrationValidationService;
|
private final UserRegistrationValidationService userRegistrationValidationService;
|
||||||
|
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AgreementValidator agreementValidator;
|
private final AgreementValidator agreementValidator;
|
||||||
private final ApprovalService approvalService;
|
private final ApprovalService approvalService;
|
||||||
@@ -75,14 +76,29 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
|||||||
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
|
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2025.10.20 - 휴대폰 번호 중복 무시
|
// 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
|
||||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
|
// 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (retain/change 시나리오는 기존 비밀번호 유지라 제외)
|
||||||
|
if (!passwordValidator.isValidPassword(orgDTO.getPassword(), orgDTO.getLoginId(), orgDTO.getMobileNumber())) {
|
||||||
|
return new ValidationResponse(false,
|
||||||
|
"비밀번호는 영문/숫자/특수문자 포함 8~50자이며, 아이디·휴대전화 번호, 3자리 이상 연속·반복 문자는 사용할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgDTO.getConfirmPassword() == null || !orgDTO.getConfirmPassword().equals(orgDTO.getPassword())) {
|
||||||
|
return new ValidationResponse(false, "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
|
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
|
||||||
|
|
||||||
if(existingUser.isPresent()) {
|
if(existingUser.isPresent()) {
|
||||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||||
|
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||||
|
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
|
||||||
|
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
|
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
|
||||||
if (uploadedFile == null) {
|
if (uploadedFile == null) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ public interface UserFacade {
|
|||||||
|
|
||||||
void updatePassword(String loginId, String newPassword, String confirmPassword);
|
void updatePassword(String loginId, String newPassword, String confirmPassword);
|
||||||
|
|
||||||
|
/** 비밀번호에 아이디/휴대전화 번호가 포함되는지 라이브 체크용 판정 (키: idIncluded, mobileIncluded) */
|
||||||
|
java.util.Map<String, Boolean> checkPasswordContent(String loginId, String password);
|
||||||
|
|
||||||
void updateUser(PortalUserDTO portalUserDTO);
|
void updateUser(PortalUserDTO portalUserDTO);
|
||||||
|
|
||||||
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
|
|||||||
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
|
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||||
|
return passwordService.checkPasswordContent(loginId, password);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateUser(PortalUserDTO portalUserDTO) {
|
public void updateUser(PortalUserDTO portalUserDTO) {
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
|||||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(),
|
||||||
|
com.eactive.apim.portal.common.util.PhoneNumberUtil.normalize(registrationDTO.getMobileNumber()));
|
||||||
|
|
||||||
if(existingUser != null) {
|
if(existingUser != null) {
|
||||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.eactive.apim.portal.apps.user.service;
|
package com.eactive.apim.portal.apps.user.service;
|
||||||
|
|
||||||
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
|
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
|
||||||
|
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||||
@@ -71,6 +72,19 @@ public class PasswordService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 비밀번호에 본인 아이디(local part)/휴대전화 세그먼트가 포함되는지 판정 — 변경 화면 라이브 체크용 */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public java.util.HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||||
|
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||||
|
java.util.HashMap<String, Boolean> result = new java.util.HashMap<>();
|
||||||
|
result.put("idIncluded",
|
||||||
|
PasswordRuleValidator.containsLoginIdLocalPart(password, user.getLoginId()));
|
||||||
|
result.put("mobileIncluded",
|
||||||
|
PasswordRuleValidator.containsMobileSegment(password, user.getMobileNumber()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private void checkPasswordHistory(String userId, String newPassword) {
|
private void checkPasswordHistory(String userId, String newPassword) {
|
||||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
|||||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||||
import com.eactive.apim.portal.common.exception.SystemException;
|
import com.eactive.apim.portal.common.exception.SystemException;
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||||
@@ -151,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void resetPassword(String loginId, String userName, String mobileNumber) {
|
public void resetPassword(String loginId, String userName, String mobileNumber) {
|
||||||
|
// 입력 그룹핑이 저장 정규형과 달라도 매칭되도록 조회 전 정규화 (암호화 컬럼 등가 비교)
|
||||||
|
mobileNumber = PhoneNumberUtil.normalize(mobileNumber);
|
||||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||||
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
|
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
|
||||||
}
|
}
|
||||||
@@ -185,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
|
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
|
||||||
try {
|
try {
|
||||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber)
|
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
|
||||||
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||||
|
|
||||||
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
|
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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)에서 조회한다.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY (group={@code Portal}, name={@code login.failure.lock.count}) 값으로 제어한다.
|
||||||
|
* 값이 없으면 기본값 {@value #DEFAULT_LOCK_COUNT}로 자동 생성되고, 숫자가 아니거나
|
||||||
|
* 0 이하이면 기본값으로 동작한다.</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LoginLockPolicy {
|
||||||
|
|
||||||
|
private static final String GROUP = "Portal";
|
||||||
|
private static final String NAME = "login.failure.lock.count";
|
||||||
|
|
||||||
|
/** 기본 잠금 임계 횟수 (프로퍼티 미존재/파싱 실패 시) */
|
||||||
|
public static final int DEFAULT_LOCK_COUNT = 5;
|
||||||
|
|
||||||
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
|
||||||
|
/** 연속 로그인 실패가 이 값 이상이면 계정을 잠근다. */
|
||||||
|
public int lockCount() {
|
||||||
|
String raw = portalPropertyService.getOrCreateProperty(
|
||||||
|
GROUP, NAME, String.valueOf(DEFAULT_LOCK_COUNT),
|
||||||
|
"로그인 연속 실패 계정 잠금 임계 횟수 (이 값 이상 실패 시 잠금)");
|
||||||
|
try {
|
||||||
|
int parsed = Integer.parseInt(raw.trim());
|
||||||
|
if (parsed > 0) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
log.warn("login.failure.lock.count 값이 0 이하({}) - 기본값 {} 사용", parsed, DEFAULT_LOCK_COUNT);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("login.failure.lock.count 값이 숫자가 아님('{}') - 기본값 {} 사용", raw, DEFAULT_LOCK_COUNT);
|
||||||
|
}
|
||||||
|
return DEFAULT_LOCK_COUNT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.common.validator;
|
package com.eactive.apim.portal.common.validator;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||||
import org.apache.commons.beanutils.PropertyUtils;
|
import org.apache.commons.beanutils.PropertyUtils;
|
||||||
|
|
||||||
import javax.validation.ConstraintValidator;
|
import javax.validation.ConstraintValidator;
|
||||||
@@ -72,24 +73,12 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginId != null && !loginId.isEmpty()) {
|
if (containsLoginIdLocalPart(password, loginId)) {
|
||||||
String[] loginParts = loginId.split("@");
|
return false;
|
||||||
if (loginParts.length > 0) {
|
|
||||||
String username = loginParts[0].toUpperCase();
|
|
||||||
if (tmpPw.contains(username)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile number validation
|
if (containsMobileSegment(password, mobileNumber)) {
|
||||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
return false;
|
||||||
String[] mobileParts = mobileNumber.split("-");
|
|
||||||
for (String part : mobileParts) {
|
|
||||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공백 체크
|
// 공백 체크
|
||||||
@@ -129,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
||||||
|
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
||||||
|
if (password == null || loginId == null || loginId.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String username = loginId.split("@")[0].toUpperCase();
|
||||||
|
return !username.isEmpty() && password.toUpperCase().contains(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴대전화 번호의 하이픈 세그먼트(010/1234/5678)가 비밀번호에 포함되는지.
|
||||||
|
* DB 에 하이픈 없이 저장된 legacy 값도 잡도록 정규형으로 변환 후 분리한다.
|
||||||
|
*/
|
||||||
|
public static boolean containsMobileSegment(String password, String mobileNumber) {
|
||||||
|
if (password == null || mobileNumber == null || mobileNumber.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String tmpPw = password.toUpperCase();
|
||||||
|
String[] mobileParts = PhoneNumberUtil.normalize(mobileNumber).split("-");
|
||||||
|
for (String part : mobileParts) {
|
||||||
|
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static boolean isContinuous(int first, int third) {
|
static boolean isContinuous(int first, int third) {
|
||||||
// 첫 글자 A-Z / 0-9
|
// 첫 글자 A-Z / 0-9
|
||||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||||
|
|||||||
+8
-3
@@ -4,6 +4,7 @@ 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.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;
|
||||||
@@ -47,14 +48,17 @@ 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 MessageHandlerService messageHandlerService;
|
||||||
|
private final LoginLockPolicy loginLockPolicy;
|
||||||
|
|
||||||
|
|
||||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||||
PortalUserLogService userLogService,
|
PortalUserLogService userLogService,
|
||||||
MessageHandlerService messageHandlerService) {
|
MessageHandlerService messageHandlerService,
|
||||||
|
LoginLockPolicy loginLockPolicy) {
|
||||||
this.portalUserRepository = portalUserRepository;
|
this.portalUserRepository = portalUserRepository;
|
||||||
this.userLogService = userLogService;
|
this.userLogService = userLogService;
|
||||||
this.messageHandlerService = messageHandlerService;
|
this.messageHandlerService = messageHandlerService;
|
||||||
|
this.loginLockPolicy = loginLockPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -73,15 +77,16 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
|||||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
|
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
|
||||||
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
|
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
|
||||||
|
|
||||||
|
int lockCount = loginLockPolicy.lockCount();
|
||||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
||||||
if (user.getLoginFailureCount() >= 5) {
|
if (user.getLoginFailureCount() >= lockCount) {
|
||||||
user.setAccountLockYn("Y");
|
user.setAccountLockYn("Y");
|
||||||
|
|
||||||
// 계정 잠금 알림
|
// 계정 잠금 알림
|
||||||
messageHandlerService.publishEvent(
|
messageHandlerService.publishEvent(
|
||||||
MessageCode.USER_ACCOUNT_LOCKED,
|
MessageCode.USER_ACCOUNT_LOCKED,
|
||||||
MessageRecipient.of(user),
|
MessageRecipient.of(user),
|
||||||
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
|
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
|
||||||
}
|
}
|
||||||
portalUserRepository.save(user);
|
portalUserRepository.save(user);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||||
|
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||||
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
|||||||
private final PortalUserAuthService portalUserAuthService;
|
private final PortalUserAuthService portalUserAuthService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final MessageHandlerService messageHandlerService;
|
private final MessageHandlerService messageHandlerService;
|
||||||
|
private final LoginLockPolicy loginLockPolicy;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(noRollbackFor = {AuthenticationException.class})
|
@Transactional(noRollbackFor = {AuthenticationException.class})
|
||||||
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
|||||||
|
|
||||||
|
|
||||||
if (!user.isAccountNonLocked()) {
|
if (!user.isAccountNonLocked()) {
|
||||||
if (user.getLoginFailureCount() >= 5) {
|
if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
|
||||||
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.eactive.apim.portal.custom.config;
|
package com.eactive.apim.portal.custom.config;
|
||||||
|
|
||||||
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
|
|||||||
@Override
|
@Override
|
||||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||||
return bcryptEncoder.matches(rawPassword, encodedPassword);
|
return bcryptEncoder.matches(rawPassword, encodedPassword);
|
||||||
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
|
|
||||||
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
|
|
||||||
// return bcryptEncoder.matches(rawPassword, bcryptHash);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
||||||
*
|
*
|
||||||
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
|
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
|
||||||
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
|
* 규칙을 클라이언트로 포팅한다. 아이디/휴대전화 포함 규칙(noid/nomobile)은
|
||||||
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
|
* 사용자가 폼에 직접 입력한 값이 페이지에 이미 있을 때만 ul 의
|
||||||
|
* data-context-loginid/data-context-mobile 로 연결해 쓴다 — DB 값을 새로
|
||||||
|
* 내려받아야 하는 화면(비밀번호 변경)은 서버 AJAX(/password/content-check)로 판정한다.
|
||||||
*
|
*
|
||||||
* 사용법(마크업 구동):
|
* 사용법(마크업 구동):
|
||||||
* <ul class="password-policy-checklist" data-password-input="newPassword">
|
* <ul class="password-policy-checklist" data-password-input="newPassword">
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 규칙별 판정 함수 (통과=true)
|
// 규칙별 판정 함수 (통과=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; },
|
||||||
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
||||||
@@ -39,23 +41,46 @@
|
|||||||
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
||||||
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); },
|
||||||
|
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
|
||||||
|
noid: function (pw, ctx) {
|
||||||
|
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
|
||||||
|
return !id || pw.toUpperCase().indexOf(id) === -1;
|
||||||
|
},
|
||||||
|
// 휴대전화 하이픈 세그먼트 포함 금지 — 서버 containsMobileSegment 포팅
|
||||||
|
nomobile: function (pw, ctx) {
|
||||||
|
var m = ctx && ctx.mobile ? String(ctx.mobile) : '';
|
||||||
|
if (!m) { return true; }
|
||||||
|
var up = pw.toUpperCase();
|
||||||
|
var parts = m.split('-');
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
if (parts[i] && up.indexOf(parts[i]) !== -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전체 문자열 규칙 통과 여부
|
// 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
|
||||||
function isValid(pw) {
|
function isValid(pw, ctx) {
|
||||||
if (!pw) {
|
if (!pw) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (var key in RULES) {
|
for (var key in RULES) {
|
||||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
|
if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 체크리스트(ul) 하나를 대상 input 에 바인딩
|
// bind 된 체크리스트들의 update 함수 목록 (컨텍스트 값 변경 시 refresh 용)
|
||||||
|
var updaters = [];
|
||||||
|
|
||||||
|
// 체크리스트(ul) 하나를 대상 input 에 바인딩.
|
||||||
|
// ul 의 data-context-loginid / data-context-mobile 속성에 소스 input 의 id 를 주면
|
||||||
|
// noid/nomobile 규칙이 해당 값 기준으로 라이브 판정된다.
|
||||||
function bind(input, list) {
|
function bind(input, list) {
|
||||||
var $input = (input && input.jquery) ? input : $(input);
|
var $input = (input && input.jquery) ? input : $(input);
|
||||||
var $list = (list && list.jquery) ? list : $(list);
|
var $list = (list && list.jquery) ? list : $(list);
|
||||||
@@ -64,8 +89,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ctxValue(attr) {
|
||||||
|
var id = $list.attr(attr);
|
||||||
|
var el = id ? document.getElementById(id) : null;
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
function update() {
|
function update() {
|
||||||
var pw = $input.val() || '';
|
var pw = $input.val() || '';
|
||||||
|
var ctx = {
|
||||||
|
loginId: ctxValue('data-context-loginid'),
|
||||||
|
mobile: ctxValue('data-context-mobile')
|
||||||
|
};
|
||||||
$items.each(function () {
|
$items.each(function () {
|
||||||
var $li = $(this);
|
var $li = $(this);
|
||||||
var rule = RULES[$li.attr('data-rule')];
|
var rule = RULES[$li.attr('data-rule')];
|
||||||
@@ -76,15 +111,29 @@
|
|||||||
if (pw.length === 0) {
|
if (pw.length === 0) {
|
||||||
$li.addClass('is-idle');
|
$li.addClass('is-idle');
|
||||||
} else {
|
} else {
|
||||||
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
|
$li.addClass(rule(pw, ctx) ? 'is-pass' : 'is-fail');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 컨텍스트 소스 input 이 직접 타이핑되는 경우도 즉시 반영
|
||||||
|
['data-context-loginid', 'data-context-mobile'].forEach(function (attr) {
|
||||||
|
var id = $list.attr(attr);
|
||||||
|
if (id && document.getElementById(id)) {
|
||||||
|
$(document.getElementById(id)).on('input.passwordPolicy change.passwordPolicy', update);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updaters.push(update);
|
||||||
$input.on('input.passwordPolicy', update);
|
$input.on('input.passwordPolicy', update);
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
|
||||||
|
function refresh() {
|
||||||
|
updaters.forEach(function (u) { u(); });
|
||||||
|
}
|
||||||
|
|
||||||
// 마크업 구동 자동 초기화
|
// 마크업 구동 자동 초기화
|
||||||
function init(root) {
|
function init(root) {
|
||||||
var $root = root ? $(root) : $(document);
|
var $root = root ? $(root) : $(document);
|
||||||
@@ -101,7 +150,8 @@
|
|||||||
RULES: RULES,
|
RULES: RULES,
|
||||||
isValid: isValid,
|
isValid: isValid,
|
||||||
bind: bind,
|
bind: bind,
|
||||||
init: init
|
init: init,
|
||||||
|
refresh: refresh
|
||||||
};
|
};
|
||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|||||||
@@ -407,8 +407,12 @@ const customPopups = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 하이픈 유무 무관 입력을 저장 표준(010-1234-5678)으로 통일해 전달
|
||||||
|
const formattedMobile = mobile.replace(/-/g, '')
|
||||||
|
.replace(/^(01[016-9])(\d{3,4})(\d{4})$/, '$1-$2-$3');
|
||||||
|
|
||||||
if (typeof onConfirm === 'function') {
|
if (typeof onConfirm === 'function') {
|
||||||
onConfirm(mobile, notifyConsent);
|
onConfirm(formattedMobile, notifyConsent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,11 @@
|
|||||||
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="noid-server" class="is-idle"><span class="policy-icon"></span><span
|
||||||
|
class="policy-text">아이디(이메일) 포함 불가</span></li>
|
||||||
|
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
|
||||||
|
class="policy-text">휴대전화 번호 포함 불가</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;">
|
||||||
@@ -88,6 +91,52 @@
|
|||||||
customPopups.showAlert([[${ error }]]);
|
customPopups.showAlert([[${ error }]]);
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<script th:inline="javascript">
|
||||||
|
// 아이디/휴대전화 포함 여부 라이브 체크 — 민감정보를 페이지에 내리지 않고
|
||||||
|
// 서버(/password/content-check, 세션 사용자 기준)로 판정한다. data-rule 이
|
||||||
|
// RULES 에 없는 *-server 항목은 password-policy.js 가 건드리지 않는다.
|
||||||
|
(function () {
|
||||||
|
var input = document.getElementById('newPassword');
|
||||||
|
var liId = document.querySelector('li[data-rule="noid-server"]');
|
||||||
|
var liMobile = document.querySelector('li[data-rule="nomobile-server"]');
|
||||||
|
if (!input || !liId || !liMobile) return;
|
||||||
|
|
||||||
|
function setState(li, state) {
|
||||||
|
li.classList.remove('is-idle', 'is-pass', 'is-fail');
|
||||||
|
li.classList.add(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
var csrfToken = document.querySelector('meta[name="_csrf"]');
|
||||||
|
var csrfHeader = document.querySelector('meta[name="_csrf_header"]');
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var pw = input.value;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (!pw) {
|
||||||
|
setState(liId, 'is-idle');
|
||||||
|
setState(liMobile, 'is-idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
var headers = {};
|
||||||
|
if (csrfToken && csrfHeader) {
|
||||||
|
headers[csrfHeader.content] = csrfToken.content;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
data: { password: pw }
|
||||||
|
}).done(function (res) {
|
||||||
|
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
||||||
|
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
||||||
|
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
@@ -402,16 +402,17 @@
|
|||||||
const last = document.getElementById('newMobileLast')?.value.trim();
|
const last = document.getElementById('newMobileLast')?.value.trim();
|
||||||
|
|
||||||
if (prefix === '선택' || !middle || !last) return null;
|
if (prefix === '선택' || !middle || !last) return null;
|
||||||
return prefix + middle + last;
|
// 저장 표준(하이픈 정규형)에 맞춰 조합
|
||||||
|
return `${prefix}-${middle}-${last}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
|
// 기존 휴대폰 번호 가져오기 (하이픈 정규형)
|
||||||
getExistingMobileNumber: () => {
|
getExistingMobileNumber: () => {
|
||||||
const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
|
const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
|
||||||
const middle = document.querySelector('[name="mobileMiddle"]')?.value;
|
const middle = document.querySelector('[name="mobileMiddle"]')?.value;
|
||||||
const last = document.querySelector('[name="mobileLast"]')?.value;
|
const last = document.querySelector('[name="mobileLast"]')?.value;
|
||||||
|
|
||||||
return prefix + middle + last;
|
return `${prefix}-${middle}-${last}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 휴대폰 번호 변경 여부 확인
|
// 휴대폰 번호 변경 여부 확인
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
||||||
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
|
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
|
||||||
const originalLast = document.querySelector('input[name="mobileLast"]').value;
|
const originalLast = document.querySelector('input[name="mobileLast"]').value;
|
||||||
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`;
|
// 저장 표준(하이픈 정규형)에 맞춰 조합 — 미변경 제출 시에도 이 값이 그대로 전송된다
|
||||||
|
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
|
||||||
|
|
||||||
// 현재 값들 가져오기
|
// 현재 값들 가져오기
|
||||||
const currentName = document.querySelector('input[name="userName"]').value.trim();
|
const currentName = document.querySelector('input[name="userName"]').value.trim();
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
|
|
||||||
// 하이픈 제거 후 비교
|
// 하이픈 제거 후 비교
|
||||||
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
||||||
if (newMobileRaw !== originalMobileNumber) {
|
if (newMobileRaw !== originalMobileNumber.replace(/-/g, '')) {
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,8 @@
|
|||||||
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">
|
<ul class="password-policy-checklist" data-password-input="password"
|
||||||
|
data-context-loginid="loginId" data-context-mobile="mobileNumber">
|
||||||
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
|
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
|
||||||
포함 8~50자</span></li>
|
포함 8~50자</span></li>
|
||||||
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문
|
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문
|
||||||
@@ -95,8 +96,11 @@
|
|||||||
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="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
|
||||||
|
포함 불가</span></li>
|
||||||
|
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
|
||||||
|
포함 불가</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="password-policy-note">※ 아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,6 +145,10 @@
|
|||||||
if (mobileNumberInput) {
|
if (mobileNumberInput) {
|
||||||
mobileNumberInput.value = formattedNumber;
|
mobileNumberInput.value = formattedNumber;
|
||||||
}
|
}
|
||||||
|
// hidden 값 변경은 input 이벤트가 없으므로 체크리스트(nomobile) 수동 재판정
|
||||||
|
if (window.PasswordPolicy) {
|
||||||
|
PasswordPolicy.refresh();
|
||||||
|
}
|
||||||
return formattedNumber;
|
return formattedNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<input type="tel"
|
<input type="tel"
|
||||||
id="userInviteMobileInput"
|
id="userInviteMobileInput"
|
||||||
class="pop_input_field"
|
class="pop_input_field"
|
||||||
placeholder="휴대폰 번호 ('-' 없이 입력)"
|
placeholder="휴대폰 번호 (예: 010-1234-5678)"
|
||||||
maxlength="13">
|
maxlength="13">
|
||||||
<div id="userInvitePopupError" class="error-message"></div>
|
<div id="userInvitePopupError" class="error-message"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user