diff --git a/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java b/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java index e8b43d0..a69bf17 100644 --- a/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java +++ b/src/main/java/com/eactive/apim/portal/apps/session/service/UserSessionService.java @@ -23,6 +23,9 @@ public class UserSessionService { private static final String PROPERTY_NAME = "session.timeout.minutes"; private static final String DEFAULT_TIMEOUT_MINUTES = "15"; + /** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */ + private static final long CLEANUP_MARGIN_MINUTES = 1; + private final UserSessionRepository userSessionRepository; private final PortalPropertyService portalPropertyService; @@ -134,13 +137,15 @@ public class UserSessionService { } /** - * 만료 세션 정리 (5분 주기). 타임아웃의 2배 이상 지난 세션 삭제 (안전 마진). + * 만료 세션 정리 (1분 주기). + * 논리적 만료(lastAccessTime + 타임아웃)가 지난 세션을 소량 마진({@value #CLEANUP_MARGIN_MINUTES}분) 후 삭제한다. + * heartbeat/활동이 멈춰 더 이상 갱신되지 않는 세션(닫힌 탭·크래시·네트워크 단절 등)을 신속히 제거한다. */ - @Scheduled(fixedRate = 300000) + @Scheduled(fixedRate = 60000) @Transactional public void cleanupExpiredSessions() { int timeoutMinutes = getSessionTimeoutMinutes(); - LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) timeoutMinutes * 2); + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes + CLEANUP_MARGIN_MINUTES); int deleted = userSessionRepository.deleteExpiredSessions(cutoff); if (deleted > 0) { log.info("만료 세션 정리 - 삭제 수: {}", deleted); diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/AuthController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/AuthController.java index b933c9c..e05e747 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/AuthController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/AuthController.java @@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.controller; import com.eactive.apim.portal.apps.user.dto.ValidationResponse; import com.eactive.apim.portal.apps.user.facade.AuthFacade; +import com.eactive.apim.portal.apps.user.service.PortalUserService; import com.eactive.apim.portal.apps.user.validator.EmailValidator; import com.eactive.apim.portal.common.validator.CellPhoneValidator; import org.springframework.web.bind.annotation.PostMapping; @@ -22,18 +23,22 @@ public class AuthController { private final AuthFacade authFacade; private final CellPhoneValidator cellPhoneValidator; private final EmailValidator emailValidator; + private final PortalUserService portalUserService; - public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator) { + public AuthController(AuthFacade authFacade, CellPhoneValidator cellPhoneValidator, EmailValidator emailValidator, + PortalUserService portalUserService) { this.authFacade = authFacade; this.cellPhoneValidator = cellPhoneValidator; this.emailValidator = emailValidator; + this.portalUserService = portalUserService; } @PostMapping("/request_auth_number") public ValidationResponse requestAuthNumber( @RequestParam(required = false) String mobileNumber, + @RequestParam(required = false) String purpose, HttpSession session) { ValidationResponse response = new ValidationResponse(); @@ -44,6 +49,16 @@ public class AuthController { return response; } + // 회원가입 흐름에서는 인증번호 발송 전에 휴대폰 번호 중복을 미리 검증한다. + // (중복 번호를 인증까지 마친 뒤 가입 단계에서야 거절되는 것을 방지) + // mobileNumber 는 저장 포맷과 동일한 하이픈 포함 형태이므로 sanitize 전 값으로 조회한다. + if ("signup".equals(purpose) && portalUserService.isMobileDuplicateCheckEnabled() + && portalUserService.existsByMobileNumber(mobileNumber)) { + response.setValid(false); + response.setMessage("이미 가입된 휴대폰 번호입니다."); + return response; + } + String sanitizedMobile = HtmlUtils.htmlEscape(mobileNumber.replace("-", "")); // 이전 세션 데이터 정리 diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java index 3ae08aa..affa11d 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/OrgRegisterController.java @@ -30,6 +30,7 @@ public class OrgRegisterController { model.addAttribute("portalOrg", new PortalOrgRegistrationDTO()); model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE")); model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG")); + model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT")); return MAIN_ORG_REGISTER; } @@ -92,5 +93,6 @@ public class OrgRegisterController { model.addAttribute("error", errorMessage); model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE")); model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_ORG")); + model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT")); } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java index f6f872c..840cafc 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java @@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade; import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO; import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO; import com.eactive.apim.portal.apps.user.dto.ValidationResponse; +import com.eactive.apim.portal.apps.user.facade.AuthFacade; import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade; import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; import com.eactive.apim.portal.apps.user.service.PortalUserService; @@ -16,11 +17,15 @@ import com.eactive.apim.portal.invitation.entity.UserInvitationEnums; import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; import com.eactive.apim.portal.portalorg.entity.PortalOrg; import com.eactive.apim.portal.portaluser.entity.PortalUser; +import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.Map; import java.util.Optional; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; import javax.crypto.BadPaddingException; import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; @@ -50,6 +55,7 @@ public class UserRegisterController { private final PortalUserService portalUserService; private final UserRegisterFacade userRegisterFacade; + private final AuthFacade authFacade; private final PortalUserRepository portalUserRepository; private final PortalOrgRepository portalOrgRepository; private final AgreementsFacade agreementsFacade; @@ -103,6 +109,7 @@ public class UserRegisterController { model.addAttribute("portalUser", new PortalUserRegistrationDTO()); model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE")); model.addAttribute("privacyCollect", agreementsFacade.getAgreement(isInvited ? "PRIVACY_COLLECT_ORG" : "PRIVACY_COLLECT_IND")); + model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT")); return MAIN_USER_REGISTER; @@ -138,8 +145,21 @@ public class UserRegisterController { } // 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트 session.removeAttribute("invitationToken"); + + // 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동 + if (invitationToken == null) { + Optional registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId()); + if (registered.isPresent() + && PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) { + session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr()); + return "redirect:/signup/verification-email"; + } + redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다."); + return "redirect:/signup/complete"; + } + redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다."); - return invitationToken != null ? "redirect:/signup/complete/corporate" : "redirect:/signup/complete"; + return "redirect:/signup/complete/corporate"; } catch (Exception e) { setModelForError(session, model, agreement, portalUserRegistrationDTO, "처리 중 오류가 발생했습니다: " + e.getMessage()); @@ -169,6 +189,57 @@ public class UserRegisterController { return MAIN_EMAIL_COMPLETED; } + /** + * 회원가입 직후 이메일 인증 페이지. + * 가입 단계에서 세션에 저장된 이메일이 있어야 진입 가능하다. + */ + @GetMapping("/signup/verification-email") + public String showSignupVerificationEmail(HttpSession session, Model model) { + String email = (String) session.getAttribute("signupVerificationEmail"); + if (email == null) { + return "redirect:/login"; + } + model.addAttribute("email", email); + return "apps/register/signupVerificationEmail"; + } + + /** + * 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다. + */ + @PostMapping("/signup/send-verification-code") + public ResponseEntity sendSignupVerificationCode(HttpSession session) { + String email = (String) session.getAttribute("signupVerificationEmail"); + if (email == null) { + return ResponseEntity.badRequest() + .body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요.")); + } + ValidationResponse response = authFacade.requestAuth(email, "EMAIL"); + return ResponseEntity.ok(response); + } + + /** + * 회원가입 이메일 인증코드 검증. 성공 시 사용자 상태를 READY → ACTIVE 로 활성화한다. + */ + @PostMapping("/signup/verify-email-code") + public ResponseEntity verifySignupEmailCode(@RequestBody Map request, + HttpSession session) { + String email = (String) session.getAttribute("signupVerificationEmail"); + if (email == null) { + return ResponseEntity.badRequest() + .body(new ValidationResponse(false, "유효하지 않은 요청입니다. 회원가입을 다시 진행해주세요.")); + } + + String code = request.get("code"); + ValidationResponse response = authFacade.verifyAuthNumber(email, code); + + if (response.isValid()) { + PortalUser user = portalUserService.findByEmailAddr(email); + portalUserService.activateUser(user.getId()); + session.removeAttribute("signupVerificationEmail"); + } + return ResponseEntity.ok(response); + } + @GetMapping("/signup/decision") public String showDecisionPage(@RequestParam(name = "invitation", required = false) String invitationToken, HttpSession session, @@ -326,6 +397,7 @@ public class UserRegisterController { model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE")); model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND")); + model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT")); model.addAttribute("msgType", "sms"); } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java index c34f9a8..4c35d26 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserRegisterFacadeImpl.java @@ -137,12 +137,11 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade { return new ValidationResponse(false, "이미 사용 중인 이메일입니다."); } - // 25.10.01 - 휴대폰 번호 중복이여도 가입 가능 -// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber()); -// -// if(existingUser != null) { -// return new ValidationResponse(false, "가입된 계정이 이미 존재합니다."); -// } + // 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단) + if (portalUserService.isMobileDuplicateCheckEnabled() + && portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) { + return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다."); + } // 3. 사용자 등록 ("personal" 등록 유형으로 가정) PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal"); @@ -173,6 +172,12 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade { return new ValidationResponse(false,"입력값을 확인해 주세요."); } + // 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단) + if (portalUserService.isMobileDuplicateCheckEnabled() + && portalUserService.existsByMobileNumber(registrationDTO.getMobileNumber())) { + return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다."); + } + PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber()); if(existingUser != null) { diff --git a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java index 20921e5..6e968d4 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/service/PortalUserService.java @@ -24,6 +24,12 @@ import org.springframework.util.StringUtils; @Service @Transactional public class PortalUserService { + + /** PortalProperty 그룹명 */ + private static final String PORTAL_PROPERTY_GROUP = "Portal"; + /** 휴대폰 번호 중복 허용 프로퍼티 키 (true:허용, false:허용안함, 기본 false) */ + private static final String PROP_MOBILE_DUPLICATE_ALLOW = "user.mobile.duplicate.allow"; + private final PortalUserRepository portalUserRepository; private final PortalOrgRepository portalOrgRepository; private final PasswordEncoder passwordEncoder; @@ -68,6 +74,33 @@ public class PortalUserService { return portalUserRepository.existsByLoginId(normalizedLoginId); } + /** + * 휴대폰 번호 중복 여부 확인. + * mobileNumber 는 저장 포맷(하이픈 포함, 예: 010-1234-5678)과 동일한 형태로 전달해야 한다. + * (암호화 컬럼이므로 결정적 암호화 기준 평등 조회로 매칭된다) + */ + public boolean existsByMobileNumber(String mobileNumber) { + if (mobileNumber == null || mobileNumber.trim().isEmpty()) { + return false; + } + return portalUserRepository.existsByMobileNumber(mobileNumber.trim()); + } + + /** + * 회원가입 시 휴대폰 번호 중복 차단 활성 여부. + * 공유 프로퍼티(Portal/user.mobile.duplicate.allow)를 true/false 로 해석한다. + * - allow=true(또는 레거시 Y): 중복 허용 → 차단 비활성 + * - allow=false(또는 레거시 N) / 미설정: 중복 허용안함 → 차단 활성 (기본값 false) + */ + @Transactional(readOnly = true) + public boolean isMobileDuplicateCheckEnabled() { + String allow = portalPropertyService + .getPortalPropertiesAsMap(PORTAL_PROPERTY_GROUP) + .getOrDefault(PROP_MOBILE_DUPLICATE_ALLOW, "false"); + boolean allowed = "true".equalsIgnoreCase(allow) || "Y".equalsIgnoreCase(allow); + return !allowed; + } + public boolean checkOrgHasOtherUsers(PortalOrg portalOrg) { return portalUserRepository.countByPortalOrg(portalOrg) > 1; } diff --git a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html index ffc8127..f85883a 100644 --- a/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html +++ b/src/main/resources/templates/views/apps/register/components/newUserInfoForm.html @@ -317,6 +317,7 @@ type: 'POST', data: { mobileNumber: mobileNumber, + purpose: 'signup', _csrf: $('input[name="_csrf"]').val() }, success: function(response) { diff --git a/src/main/resources/templates/views/apps/register/orgUserRegister.html b/src/main/resources/templates/views/apps/register/orgUserRegister.html index c5b7e53..25841fd 100644 --- a/src/main/resources/templates/views/apps/register/orgUserRegister.html +++ b/src/main/resources/templates/views/apps/register/orgUserRegister.html @@ -237,6 +237,14 @@ form.appendChild(input); } + if (document.getElementById('notificationConsent')) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'notificationConsent'; + input.value = document.getElementById('notificationConsent').checked; + form.appendChild(input); + } + // 시스템 필드 추가 const csrfToken = document.querySelector('input[name="_csrf"]'); if (csrfToken) { @@ -338,9 +346,11 @@ return false; } + const notificationConsentEl = document.getElementById('notificationConsent'); const validations = { termsOfUse: $('#termsOfUse').prop('checked'), privacyPolicy: $('#privacyCollect').prop('checked'), + notificationConsent: !notificationConsentEl || notificationConsentEl.checked, compRegNo: $('#compRegNo').val().trim() !== '', corpRegNo: $('#corpRegNo').val().trim() !== '', orgName: $('#orgName').val().trim() !== '', diff --git a/src/main/resources/templates/views/apps/register/signupVerificationEmail.html b/src/main/resources/templates/views/apps/register/signupVerificationEmail.html new file mode 100644 index 0000000..99f52a5 --- /dev/null +++ b/src/main/resources/templates/views/apps/register/signupVerificationEmail.html @@ -0,0 +1,277 @@ + + + +
+
+ +

회원가입

+
+
+
+
+ +
+

이메일 인증

+
+ +
+

+ 회원가입을 완료하려면 가입하신 이메일 주소로 인증을 진행해 주세요. +

+
+ + +
+ +
+
+ 이메일 주소 +
+
+ + +
+
+ +
+
+   +
+
+

위 이메일 주소로 인증코드가 발송됩니다.

+
+
+ + + +
+
+
+ + +
+ +
+
+
+ + + + + diff --git a/src/main/resources/templates/views/apps/register/userAgreementContent.html b/src/main/resources/templates/views/apps/register/userAgreementContent.html index dfacf17..c0922bb 100644 --- a/src/main/resources/templates/views/apps/register/userAgreementContent.html +++ b/src/main/resources/templates/views/apps/register/userAgreementContent.html @@ -71,6 +71,28 @@
+ + + +
+ + +
+ +
diff --git a/src/main/resources/templates/views/apps/register/userRegister.html b/src/main/resources/templates/views/apps/register/userRegister.html index 22eefc3..0bccfdb 100644 --- a/src/main/resources/templates/views/apps/register/userRegister.html +++ b/src/main/resources/templates/views/apps/register/userRegister.html @@ -105,6 +105,7 @@ const userNameElement = $('#userName'); const termsOfUseElement = $('#termsOfUse'); const privacyCollectElement = $('#privacyCollect'); + const notificationConsentElement = $('#notificationConsent'); const mobileNumberElement = $('#mobileNumber'); const isPasswordValid = $('#isPasswordValid').val() === 'true'; const isPasswordMatch = $('#isPasswordMatch').val() === 'true'; @@ -115,6 +116,7 @@ userNameElement.val().trim() !== '' && termsOfUseElement.prop('checked') && privacyCollectElement.prop('checked') && + (notificationConsentElement.length === 0 || notificationConsentElement.prop('checked')) && mobileNumberElement.val().trim() !== '' && isAuthVerified; }