From 198f6fc82a1cf9d54ae603382637e4fe016d44d6 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Mon, 22 Jun 2026 19:20:03 +0900 Subject: [PATCH] =?UTF-8?q?-=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=ED=9C=B4=EB=8C=80=ED=8F=B0=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EC=A4=91=EB=B3=B5=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EA=B8=B0=EB=B0=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=9C=EC=84=B1=ED=99=94=20-=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=88=98=EC=8B=A0=20=EB=8F=99=EC=9D=98=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EB=B0=8F=20=EB=B7=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/service/UserSessionService.java | 11 +- .../apps/user/controller/AuthController.java | 17 +- .../controller/OrgRegisterController.java | 2 + .../controller/UserRegisterController.java | 74 ++++- .../user/facade/UserRegisterFacadeImpl.java | 17 +- .../apps/user/service/PortalUserService.java | 33 +++ .../register/components/newUserInfoForm.html | 1 + .../views/apps/register/orgUserRegister.html | 10 + .../register/signupVerificationEmail.html | 277 ++++++++++++++++++ .../apps/register/userAgreementContent.html | 22 ++ .../views/apps/register/userRegister.html | 2 + 11 files changed, 455 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/templates/views/apps/register/signupVerificationEmail.html 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; }