# 초대 코드 직접 입력 기능 구현 계획 **작성일**: 2025-11-24 **목적**: 폐쇄망 환경에서 이메일 링크를 사용할 수 없는 사용자를 위한 8자리 초대코드 직접 입력 기능 추가 --- ## 📌 개요 ### 배경 - 현재 초대 시스템은 이메일 링크 방식만 지원 - 폐쇄망 환경에서는 이메일 링크 클릭이 불가능 - 8자리 토큰(예: K7MNPQ2R)을 생성하고 있지만 직접 입력 UI가 없음 ### 목표 1. 로그인 페이지에 "초대를 받으셨나요?" 링크 추가 2. 초대코드 직접 입력 페이지 생성 3. 기존 회원 로그인 시 자동으로 초대 확인 및 리다이렉트 --- ## 🎯 구현 대상 사용자 플로우 ### 신규 회원 (미가입자) ``` 로그인 페이지 → "초대를 받으셨나요?" 클릭 → 초대코드 입력 → 검증 성공 → 회원가입 페이지 ``` ### 기존 회원 (ROLE_USER) ``` 방법1: 로그인 페이지 → "초대를 받으셨나요?" 클릭 → 로그인 유도 → 로그인 → 자동 초대 확인 → 초대 수락 페이지 방법2: 로그인 페이지 → 로그인 → 자동 초대 확인 → 초대 수락 페이지 ``` --- ## 🛠 작업 목록 ### 0. 마이페이지 초대 알림 (재방문 사용자 대응) 로그인 후 자동 리다이렉트를 놓친 사용자나, 페이지를 벗어났다가 다시 돌아온 사용자를 위해 마이페이지에서 초대 알림을 표시합니다. #### 0.1 AccountController 수정 **파일**: `src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java` **GET /mypage 메서드 수정** (119-149라인): ```java @GetMapping("/mypage") public ModelAndView mypage() { ModelAndView mav = new ModelAndView(); try { PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); PortalUserDTO user = userFacade.findById(currentUser.getId()); // ===== 새로 추가: 초대 확인 로직 ===== // ROLE_USER만 초대 확인 (법인 회원은 제외) if (currentUser.getRoleCode() == RoleCode.ROLE_USER) { Optional pendingInvitation = userInvitationRepository.findFirstByInvitationEmailAndStatus( currentUser.getLoginId(), InvitationStatus.PENDING); if (pendingInvitation.isPresent()) { // 초대 정보를 모델에 추가 mav.addObject("hasPendingInvitation", true); // 초대한 법인명 조회 String orgId = pendingInvitation.get().getOrgId(); Optional org = portalOrgRepository.findById(orgId); if (org.isPresent()) { mav.addObject("invitationOrgName", org.get().getOrgName()); } } } // ================================ // 기존 로직 mav.addObject("loginId", user.getLoginId()); mav.addObject("userName", user.getUserName()); mav.addObject("mobileNumber", user.getMobileNumber()); mav.addObject("user", user); // ... 기존 코드 계속 } } ``` **추가 필드**: ```java private final UserInvitationRepository userInvitationRepository; private final PortalOrgRepository portalOrgRepository; ``` **예상 작업량**: 20-25줄 추가 --- #### 0.2 마이페이지 레이아웃 수정 (옵션 1: 공통 알림) **파일**: `src/main/resources/templates/views/layout/kbank_mypage_layout.html` **헤더 다음에 알림 배너 추가**: ```html
📩
법인명에서 귀하를 법인회원으로 초대하였습니다.
초대 확인하기
``` **CSS 스타일**: ```css .invitation-alert-banner { background: #e3f2fd; border-bottom: 2px solid #2196F3; padding: 15px 0; } .invitation-banner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; padding: 12px 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .invitation-content { display: flex; align-items: center; gap: 15px; width: 100%; } .invitation-content .icon { font-size: 24px; } .invitation-content .message { flex: 1; font-size: 14px; color: #333; } .invitation-content .btn { white-space: nowrap; } ``` **예상 작업량**: 50-60줄 (HTML + CSS) --- #### 0.3 내 정보 관리 페이지 알림 (옵션 2: 페이지별 알림) **파일**: `src/main/resources/templates/views/apps/mypage/updatePersonalUser.html` **페이지 상단에 알림 추가** (8-10라인 다음): ```html

내 정보 관리

📩 법인회원 초대

법인명에서 귀하를 법인회원으로 초대하였습니다.

``` **CSS 스타일**: ```css .invitation-alert { background: #e3f2fd; border: 2px solid #2196F3; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center; } .invitation-alert h4 { color: #1976D2; margin-bottom: 10px; font-size: 18px; } .invitation-alert p { color: #333; margin-bottom: 15px; font-size: 14px; } ``` **예상 작업량**: 40-50줄 (HTML + CSS) --- #### 0.4 선택 기준 **옵션 1 (레이아웃 수정)** 추천: - ✅ 모든 마이페이지에서 초대 알림 표시 - ✅ 일관된 UX - ✅ 수정 파일 적음 (1개 레이아웃 파일) **옵션 2 (개별 페이지 수정)**: - ✅ 페이지별 커스터마이징 가능 - ❌ 여러 파일 수정 필요 (updatePersonalUser, updateCorporateUser, updateCorporateManager 등) - ❌ 일부 페이지에서 놓칠 가능성 **권장**: 옵션 1 (레이아웃 수정) --- #### 0.5 테스트 시나리오 **시나리오 1: 로그인 후 초대 자동 확인 놓침** 1. 로그인 시 초대 자동 리다이렉트 발생 2. 사용자가 뒤로가기나 다른 페이지 이동 3. 마이페이지 접속 4. 상단에 초대 알림 배너 표시 ✅ 5. "초대 확인하기" 클릭 → /signup/decision_process 이동 **시나리오 2: 세션 만료 후 재로그인** 1. 로그인 세션 만료 2. 재로그인 3. 자동 리다이렉트 없음 (세션 초기화) 4. 마이페이지 접속 5. 초대 알림 표시 ✅ **시나리오 3: 초대 수락 후 알림 제거** 1. 마이페이지에서 초대 알림 확인 2. "초대 확인하기" 클릭 → 초대 수락 3. 마이페이지 재방문 4. 초대 알림 없음 ✅ (PENDING 상태 아님) **시나리오 4: 법인 회원은 알림 표시 안 됨** 1. ROLE_CORP_USER로 로그인 2. 마이페이지 접속 3. 초대 알림 없음 ✅ (ROLE_USER만 표시) --- ### 1. 프론트엔드 (Thymeleaf Templates) #### 1.1 로그인 페이지 수정 **파일**: `src/main/resources/templates/views/apps/login/login.html` **수정 위치**: 53-73라인 `login_list` 영역 **작업 내용**: ```html
  • 초대를 받으셨나요?
  • |
  • ``` **예상 작업량**: 10줄 추가 --- #### 1.2 초대코드 입력 페이지 생성 (신규) **파일**: `src/main/resources/templates/views/apps/register/invitationCodeInput.html` **작업 내용**: - 8자리 초대코드 입력 폼 - 대문자 자동 변환 (JavaScript) - CSRF 토큰 포함 - 검증 실패 시 에러 메시지 표시 **UI 요구사항**: - 입력 필드: 8자리 영문+숫자 (예: K7MNPQ2R) - 실시간 대문자 변환 - 제출 버튼: "확인" - 취소 버튼: "돌아가기" (로그인 페이지로) **예상 작업량**: 100-120줄 **예시 구조**: ```html

    초대코드

    ``` **JavaScript 기능**: - 자동 대문자 변환 - 8자리 제한 - 영문+숫자만 입력 허용 --- ### 2. 백엔드 (Java Controller) #### 2.1 UserRegisterController 수정 **파일**: `src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java` **추가 엔드포인트**: ##### 2.1.1 GET /signup/invitation-code ```java @GetMapping("/signup/invitation-code") public String showInvitationCodeInput(HttpSession session, Model model) { // 초대코드 입력 페이지 표시 return "apps/register/invitationCodeInput"; } ``` ##### 2.1.2 POST /signup/invitation-code ```java @PostMapping("/signup/invitation-code") public String processInvitationCode( @RequestParam("invitationCode") String invitationCode, HttpSession session, RedirectAttributes redirectAttributes, Model model) { // 1. 입력값 검증 (8자리, 영문+숫자) if (!invitationCode.matches("[A-Z0-9]{8}")) { model.addAttribute("error", "올바른 형식의 초대코드를 입력해주세요."); return "apps/register/invitationCodeInput"; } // 2. DB에서 토큰 조회 Optional invitation = userInvitationRepository.findByToken(invitationCode); // 3. 유효성 검증 if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) { model.addAttribute("error", "유효하지 않은 초대코드입니다."); return "apps/register/invitationCodeInput"; } // 4. 만료 확인 if (invitation.get().getExpiresOn().isBefore(LocalDateTime.now())) { model.addAttribute("error", "만료된 초대코드입니다."); return "apps/register/invitationCodeInput"; } // 5. 사용자 존재 확인 String email = invitation.get().getInvitationEmail(); Optional existingUser = portalUserRepository.findPortalUserByEmailAddr(email); if (existingUser.isPresent()) { // 기존 회원 → 로그인 유도 session.setAttribute("invitationCodeForLogin", invitationCode); redirectAttributes.addFlashAttribute("message", "로그인하여 초대를 수락해주세요."); return "redirect:/login"; } else { // 신규 회원 → 가입 페이지 return "redirect:/signup/portalUser?invitation=" + invitationCode; } } ``` **예상 작업량**: 50-60줄 --- #### 2.2 PortalAuthenticationSuccessHandler 수정 **파일**: `src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java` **수정 위치**: `onAuthenticationSuccess()` 메서드 (38-83라인) **추가 로직**: ```java @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { String username = request.getParameter("id"); PortalUser user = portalUserRepository.findPortalUserByEmailAddr(username).orElse(null); // ... 기존 로직 (이메일 인증, 휴면 계정, 비밀번호 만료 체크) // ===== 새로 추가 ===== // 초대 코드 자동 확인 (ROLE_USER 회원만) if (user.getRoleCode() == RoleCode.ROLE_USER) { Optional pendingInvitation = userInvitationRepository.findFirstByInvitationEmailAndStatus( username, InvitationStatus.PENDING); if (pendingInvitation.isPresent()) { // 초대가 있으면 자동으로 초대 수락 페이지로 리다이렉트 response.sendRedirect(contextPath + "/signup/decision_process"); return; } } // ================== // 기존 decisionToken 체크 로직 String decisionToken = (String) request.getSession().getAttribute("decisionToken"); if (decisionToken != null) { response.sendRedirect(contextPath + "/signup/decision_process"); } else { response.sendRedirect(contextPath + "/"); } } ``` **추가 필드**: ```java private final UserInvitationRepository userInvitationRepository; ``` **예상 작업량**: 15줄 추가 --- ### 3. 보안 설정 (선택사항) #### 3.1 PortalConfigSecurity 확인 **파일**: `src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java` **확인 사항**: - `/signup/invitation-code` 경로가 인증 없이 접근 가능한지 확인 - CSRF 토큰이 적용되는지 확인 **예상 설정** (이미 `/signup/**`이 허용되어 있을 가능성 높음): ```java .antMatchers("/signup/**").permitAll() ``` --- ## 🔍 테스트 계획 ### 단위 테스트 #### 3.1 Controller 테스트 ```java @SpringBootTest @AutoConfigureMockMvc class InvitationCodeInputTest extends BaseWebTest { @Test void testValidInvitationCode_NewUser() { // 신규 회원용 유효한 코드 입력 → 가입 페이지로 리다이렉트 } @Test void testValidInvitationCode_ExistingUser() { // 기존 회원용 유효한 코드 입력 → 로그인 페이지로 리다이렉트 } @Test void testInvalidInvitationCode() { // 존재하지 않는 코드 → 에러 메시지 } @Test void testExpiredInvitationCode() { // 만료된 코드 → 에러 메시지 } @Test void testAlreadyProcessedCode() { // 이미 처리된 코드(COMPLETED) → 에러 메시지 } } ``` #### 3.2 AuthenticationSuccessHandler 테스트 ```java @Test void testAutoRedirectWithPendingInvitation() { // ROLE_USER 로그인 시 초대가 있으면 자동 리다이렉트 } @Test void testNoAutoRedirectForCorpUser() { // ROLE_CORP_USER는 자동 리다이렉트 안됨 } ``` ### 통합 테스트 시나리오 #### 시나리오 1: 신규 회원 초대코드 입력 1. 로그인 페이지 접속 2. "초대를 받으셨나요?" 클릭 3. 초대코드 입력 (예: K7MNPQ2R) 4. 회원가입 페이지로 이동 5. 가입 완료 **예상 결과**: ROLE_CORP_USER로 가입 완료 --- #### 시나리오 2: 기존 회원 초대코드 입력 후 로그인 1. 로그인 페이지 접속 2. "초대를 받으셨나요?" 클릭 3. 초대코드 입력 (예: K7MNPQ2R) 4. "로그인하여 초대를 수락해주세요" 메시지 5. 로그인 페이지로 리다이렉트 6. 로그인 7. 자동으로 초대 수락 페이지로 이동 8. 약관 동의 후 수락 9. 로그아웃 후 재로그인 **예상 결과**: ROLE_USER → ROLE_CORP_USER 전환 완료 --- #### 시나리오 3: 기존 회원 직접 로그인 (초대 자동 확인) 1. 로그인 페이지 접속 2. 로그인 (초대받은 이메일로) 3. 자동으로 초대 수락 페이지로 리다이렉트 4. 약관 동의 후 수락 **예상 결과**: 초대 수락 완료 --- #### 시나리오 4: 유효하지 않은 초대코드 1. 초대코드 입력 페이지 접속 2. 존재하지 않는 코드 입력 (예: INVALID1) 3. "유효하지 않은 초대코드입니다" 에러 메시지 4. 입력 페이지 유지 **예상 결과**: 에러 메시지 표시, 재입력 가능 --- #### 시나리오 5: 만료된 초대코드 1. 초대코드 입력 페이지 접속 2. 7일 이상 경과한 코드 입력 3. "만료된 초대코드입니다" 에러 메시지 **예상 결과**: 에러 메시지 표시 --- ## ⚠️ 주의사항 및 고려사항 ### 보안 - [ ] CSRF 토큰 적용 확인 - [ ] SQL Injection 방지 (JPA 사용으로 기본 방어) - [ ] **초대코드 Brute Force 공격 방지 (세션 기반 실패 횟수 제한)** - 아래 상세 구현 방법 참조 - [ ] 세션 하이재킹 방지 (기존 Spring Security로 대응) - [ ] 이메일 형식 검증 (프론트엔드 + 백엔드 이중 검증) ✅ 구현 완료 ### 사용성 - [ ] 입력 필드 자동 대문자 변환 - [ ] 8자리 제한 및 유효성 검증 (클라이언트 + 서버) - [ ] 명확한 에러 메시지 - [ ] 모바일 반응형 디자인 ### 우선순위 충돌 처리 `PortalAuthenticationSuccessHandler`에서 여러 조건 체크 순서: 1. 이메일 인증 필요 (`READY` 상태) 2. 휴면 계정 (`DORMANT` 상태) 3. 임시 비밀번호 로그인 4. 비밀번호 만료 (90일) 5. **→ [새로 추가] 초대 코드 확인** ← ROLE_USER만 해당 6. decisionToken 세션 체크 7. 일반 홈페이지 **결정**: 비밀번호 관련 체크 이후, 일반 홈 이동 전에 초대 확인 --- ## 📊 작업 규모 요약 | 항목 | 파일 수 | 예상 코드량 | 난이도 | 예상 시간 | |------|---------|------------|--------|----------| | **마이페이지 초대 알림** | 2 | 70-85줄 | ⭐⭐ 보통 | 1시간 | | HTML 수정 (로그인) | 1 | 10줄 | ⭐ 쉬움 | 15분 | | HTML 신규 (초대코드 입력) | 1 | 100-120줄 | ⭐⭐ 보통 | 1-1.5시간 | | Controller 수정 (UserRegister) | 1 | 50-60줄 | ⭐⭐ 보통 | 1시간 | | Handler 수정 | 1 | 15줄 | ⭐⭐ 보통 | 30분 | | 단위 테스트 | 2 | 100줄 | ⭐⭐ 보통 | 1시간 | | 통합 테스트 | - | 수동 | ⭐⭐⭐ 중요 | 2-2.5시간 | **총 예상 시간**: 6.5-8시간 --- ## 📝 체크리스트 ### 개발 전 - [ ] 기존 초대 플로우 완전 이해 - [ ] DB 스키마 확인 (UserInvitation 테이블) - [ ] 기존 테스트 케이스 리뷰 ### 개발 중 - [ ] **초대 만료일 동적 설정** 🔧 개선 - [ ] DurationParser 유틸리티 클래스 생성 - [ ] UserManFacade에 PortalPropertyService 의존성 추가 - [ ] createInvitation() 메서드 수정 (동적 만료일) - [ ] PTL_PROPERTY 테이블에 설정 추가 - [ ] **중복 초대 방지 추가** ⚠️ 필수 - [ ] UserInvitationRepository 메서드 추가 - [ ] UserManFacade.sendInvitation()에 중복 체크 로직 추가 - [ ] 같은 법인 중복 초대 차단 - [ ] 만료된 초대 자동 취소 후 재생성 - [ ] 다른 법인 중복 초대 차단 (비즈니스 정책 결정 필요) - [ ] **마이페이지 초대 알림 추가** (재방문 사용자 대응) - [ ] AccountController에 초대 확인 로직 추가 - [ ] kbank_mypage_layout.html에 알림 배너 추가 - [ ] CSS 스타일 작성 - [ ] 로그인 페이지 "초대를 받으셨나요?" 링크 추가 - [ ] 초대코드 입력 페이지 생성 - [ ] UserRegisterController 엔드포인트 구현 - [ ] GET /signup/invitation-code - [ ] POST /signup/invitation-code (Brute Force 방지 포함) - [ ] AuthenticationSuccessHandler 수정 (자동 초대 확인) - [ ] CSRF 토큰 적용 확인 - [ ] 에러 처리 구현 ### 테스트 - [ ] 단위 테스트 작성 및 통과 - [ ] **초대 만료일 동적 설정 테스트** 🔧 - [ ] 기본값 7일 테스트 - [ ] 2시간 설정 테스트 - [ ] 30분 설정 테스트 - [ ] 14일 설정 테스트 - [ ] 잘못된 형식 → 기본값 fallback 테스트 - [ ] **중복 초대 방지 테스트** ⚠️ 필수 - [ ] 같은 법인 중복 초대 차단 - [ ] 만료된 초대 재발송 - [ ] 다른 법인 중복 초대 차단 - [ ] 초대 수락 후 중복 차단 - [ ] **마이페이지 초대 알림 테스트** - [ ] 로그인 후 자동 확인 놓친 경우 - [ ] 세션 만료 후 재로그인 - [ ] 초대 수락 후 알림 제거 확인 - [ ] 법인 회원은 알림 표시 안 됨 - [ ] 신규 회원 플로우 테스트 - [ ] 기존 회원 플로우 테스트 - [ ] 에러 케이스 테스트 - [ ] 만료된 코드 테스트 - [ ] Brute Force 방지 테스트 (5회 실패 시나리오) - [ ] 우선순위 충돌 테스트 ### 배포 전 - [ ] 코드 리뷰 - [ ] 통합 테스트 완료 - [ ] 로그 확인 (Hibernate SQL, 애플리케이션 로그) - [ ] 스테이징 환경 배포 및 검증 - [ ] 운영 배포 계획 수립 --- ## 🔗 관련 파일 ### 주요 소스 - `UserRegisterController.java:149-191` - 기존 초대 처리 로직 - `UserManFacade.java:124-163` - 초대 생성 및 토큰 생성 - `PortalAuthenticationSuccessHandler.java:38-83` - 로그인 성공 처리 - `userDecisionProcess.html` - 초대 수락 페이지 (약관 포함) ### 참고 문서 - `kjb-docs/mermaid/invite-flow-integrated.mermaid` - 통합 플로우차트 - `kjb-docs/mermaid/invite-sequence.mermaid` - 시퀀스 다이어그램 - `CLAUDE.md` - 프로젝트 아키텍처 --- ## 📅 일정 (예상) | 단계 | 작업 내용 | 소요 시간 | 담당자 | |------|----------|----------|--------| | 0단계 | 마이페이지 초대 알림 구현 | 1시간 | - | | 1단계 | HTML 작업 (로그인, 초대코드 입력) | 1.5시간 | - | | 2단계 | Controller 구현 (Brute Force 방지 포함) | 1.5시간 | - | | 3단계 | AuthenticationSuccessHandler 수정 | 0.5시간 | - | | 4단계 | 단위 테스트 작성 | 1시간 | - | | 5단계 | 통합 테스트 (수동) | 2.5시간 | - | | 총 | | **8시간** | | --- ## 📌 Brute Force 방지 구현 (세션 기반) ### 개요 초대코드 입력 실패 시 세션 기반으로 실패 횟수를 추적하여, 일정 횟수 이상 실패 시 일시적으로 입력을 차단합니다. ### 구현 방법 #### 1. 세션 속성 정의 ```java // 세션에 저장할 키 public static final String INVITATION_CODE_FAILURE_COUNT = "invitationCodeFailureCount"; public static final String INVITATION_CODE_LOCKED_UNTIL = "invitationCodeLockedUntil"; // 설정값 public static final int MAX_FAILURE_COUNT = 5; // 최대 실패 허용 횟수 public static final int LOCK_DURATION_MINUTES = 15; // 차단 시간 (분) ``` #### 2. UserRegisterController 수정 **파일**: `UserRegisterController.java` **POST /signup/invitation-code 메서드 수정**: ```java @PostMapping("/signup/invitation-code") public String processInvitationCode( @RequestParam("invitationCode") String invitationCode, HttpSession session, RedirectAttributes redirectAttributes, Model model) { // 1. 차단 상태 확인 LocalDateTime lockedUntil = (LocalDateTime) session.getAttribute(INVITATION_CODE_LOCKED_UNTIL); if (lockedUntil != null && LocalDateTime.now().isBefore(lockedUntil)) { long minutesLeft = ChronoUnit.MINUTES.between(LocalDateTime.now(), lockedUntil); model.addAttribute("error", String.format("너무 많은 시도로 인해 %d분간 초대코드 입력이 차단되었습니다.", minutesLeft + 1)); return "apps/register/invitationCodeInput"; } // 2. 입력값 검증 if (!invitationCode.matches("[A-Z0-9]{8}")) { incrementFailureCount(session); model.addAttribute("error", "올바른 형식의 초대코드를 입력해주세요."); return "apps/register/invitationCodeInput"; } // 3. DB에서 토큰 조회 및 검증 Optional invitation = userInvitationRepository.findByToken(invitationCode); if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING || invitation.get().getExpiresOn().isBefore(LocalDateTime.now())) { // 실패 처리 int failureCount = incrementFailureCount(session); if (failureCount >= MAX_FAILURE_COUNT) { // 차단 처리 LocalDateTime lockTime = LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES); session.setAttribute(INVITATION_CODE_LOCKED_UNTIL, lockTime); model.addAttribute("error", "너무 많은 시도로 인해 15분간 초대코드 입력이 차단되었습니다."); } else { int remainingAttempts = MAX_FAILURE_COUNT - failureCount; model.addAttribute("error", String.format("유효하지 않은 초대코드입니다. (남은 시도: %d회)", remainingAttempts)); } return "apps/register/invitationCodeInput"; } // 4. 성공 시 실패 카운트 초기화 session.removeAttribute(INVITATION_CODE_FAILURE_COUNT); session.removeAttribute(INVITATION_CODE_LOCKED_UNTIL); // 5. 기존 로직 (사용자 확인 및 리다이렉트) String email = invitation.get().getInvitationEmail(); Optional existingUser = portalUserRepository.findPortalUserByEmailAddr(email); if (existingUser.isPresent()) { session.setAttribute("invitationCodeForLogin", invitationCode); redirectAttributes.addFlashAttribute("message", "로그인하여 초대를 수락해주세요."); return "redirect:/login"; } else { return "redirect:/signup/portalUser?invitation=" + invitationCode; } } // Helper 메서드 private int incrementFailureCount(HttpSession session) { Integer failureCount = (Integer) session.getAttribute(INVITATION_CODE_FAILURE_COUNT); if (failureCount == null) { failureCount = 0; } failureCount++; session.setAttribute(INVITATION_CODE_FAILURE_COUNT, failureCount); return failureCount; } ``` #### 3. 프론트엔드 UI 개선 **invitationCodeInput.html에 추가**: ```html
    ⚠️ 보안 경고

    잠시 후 다시 시도해주세요.

    ``` #### 4. 장점 - ✅ **DB 사용 불필요**: 세션 메모리만 사용하여 간단하고 빠름 - ✅ **자동 만료**: 세션 타임아웃(15분) 시 자동으로 초기화 - ✅ **사용자별 독립적**: 각 세션마다 독립적으로 카운트 - ✅ **성능 영향 최소**: 추가 DB 쿼리 없음 #### 5. 주의사항 - 세션이 만료되면 실패 카운트도 초기화됨 (긍정적 효과) - 다른 브라우저/디바이스에서는 별도 카운트 (IP 기반 차단 아님) - 로그아웃 시 세션 초기화로 카운트 리셋 가능 (큰 문제 아님) #### 6. 테스트 시나리오 ``` 시나리오 1: 5회 실패 후 차단 1. 잘못된 코드 입력 (1회) → "남은 시도: 4회" 2. 잘못된 코드 입력 (2회) → "남은 시도: 3회" 3. 잘못된 코드 입력 (3회) → "남은 시도: 2회" 4. 잘못된 코드 입력 (4회) → "남은 시도: 1회" 5. 잘못된 코드 입력 (5회) → "15분간 차단되었습니다" 6. 올바른 코드 입력 → 차단 메시지 표시, 입력 불가 시나리오 2: 차단 후 시간 경과 1. 5회 실패로 차단 상태 2. 15분 대기 3. 올바른 코드 입력 → 정상 처리 시나리오 3: 실패 후 성공 1. 잘못된 코드 입력 (2회) → "남은 시도: 3회" 2. 올바른 코드 입력 → 성공, 카운트 초기화 3. 다시 잘못된 코드 입력 → "남은 시도: 4회" (초기화됨) ``` --- ## 📌 초대 만료일 동적 설정 (PortalProperty) 🔧 개선 ### 현재 상황 **UserManFacade.createInvitation()** (149-150라인): ```java // Set expiration date (e.g., 7 days from now) newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7)); ``` **문제점**: - 만료일이 하드코딩되어 있음 (7일 고정) - 운영 중 변경 시 코드 수정 및 재배포 필요 - 환경별 다른 설정 불가 --- ### 개선 방안: PTL_PROPERTY 테이블 활용 **PTL_PROPERTY 테이블**에서 초대 만료 기간을 읽어와 동적으로 설정합니다. #### 1. 시간 표현 유틸리티 클래스 생성 (신규) **파일**: `src/main/java/com/eactive/apim/portal/common/util/DurationParser.java` ```java package com.eactive.apim.portal.common.util; import java.time.Duration; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 시간 표현 문자열을 Duration으로 파싱하는 유틸리티 * 지원 형식: 5m (5분), 2h (2시간), 7d (7일) */ public class DurationParser { private static final Pattern DURATION_PATTERN = Pattern.compile("^(\\d+)([mhd])$"); /** * 시간 표현 문자열을 Duration으로 변환 * * @param durationStr 시간 문자열 (예: "5m", "2h", "7d") * @return Duration 객체 * @throws IllegalArgumentException 형식이 올바르지 않을 경우 */ public static Duration parse(String durationStr) { if (durationStr == null || durationStr.trim().isEmpty()) { throw new IllegalArgumentException("Duration string cannot be null or empty"); } String normalized = durationStr.trim().toLowerCase(); Matcher matcher = DURATION_PATTERN.matcher(normalized); if (!matcher.matches()) { throw new IllegalArgumentException( "Invalid duration format: " + durationStr + ". Expected format: (e.g., 5m, 2h, 7d)" ); } long value = Long.parseLong(matcher.group(1)); String unit = matcher.group(2); switch (unit) { case "m": return Duration.ofMinutes(value); case "h": return Duration.ofHours(value); case "d": return Duration.ofDays(value); default: throw new IllegalArgumentException("Unsupported time unit: " + unit); } } /** * 시간 표현 문자열을 일(day) 단위로 변환 * * @param durationStr 시간 문자열 (예: "5m", "2h", "7d") * @return 일(day) 단위 long 값 */ public static long parseToDays(String durationStr) { return parse(durationStr).toDays(); } /** * 기본값을 제공하는 안전한 파싱 * * @param durationStr 시간 문자열 * @param defaultValue 파싱 실패 시 기본값 * @return Duration 객체 */ public static Duration parseOrDefault(String durationStr, Duration defaultValue) { try { return parse(durationStr); } catch (Exception e) { return defaultValue; } } } ``` **예상 작업량**: 70줄 --- #### 2. UserManFacade 수정 **파일**: `UserManFacade.java` **변경 전** (149-150라인): ```java // Set expiration date (e.g., 7 days from now) newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7)); ``` **변경 후**: ```java // ===== 새로 추가 ===== private final PortalPropertyService portalPropertyService; public String createInvitation(PortalUser sender, String emailAddr) { UserInvitation newInvitation = new UserInvitation(); newInvitation.setAdminId(sender.getId()); newInvitation.setOrgId(sender.getPortalOrg().getId()); newInvitation.setInvitationEmail(emailAddr); // Set additional required fields newInvitation.setInvitationDate(LocalDateTime.now()); newInvitation.setStatus(InvitationStatus.PENDING); // Generate 8-character token String token = generateEightCharToken(); newInvitation.setToken(token); // ===== 변경: 동적 만료일 설정 ===== // PTL_PROPERTY에서 초대 만료 기간 조회 Map properties = portalPropertyService.getPortalPropertiesAsMap("Portal"); String expirationDuration = properties.getOrDefault("invitation.expiration", "7d"); // 시간 문자열 파싱 (예: "7d" → 7일, "2h" → 2시간, "30m" → 30분) Duration duration = DurationParser.parseOrDefault(expirationDuration, Duration.ofDays(7)); // 만료일 설정 newInvitation.setExpiresOn(LocalDateTime.now().plus(duration)); // ================================ // Save the invitation userInvitationRepository.save(newInvitation); return token; } ``` **추가 필드**: ```java private final PortalPropertyService portalPropertyService; ``` **예상 작업량**: 10줄 추가 --- ### PTL_PROPERTY 테이블 설정 #### 데이터 삽입 SQL ```sql -- 초대 만료 기간 설정 (기본값: 7일) INSERT INTO PTL_PROPERTY (PROP_GROUP, PROP_KEY, PROP_VALUE, DESCRIPTION) VALUES ('Portal', 'invitation.expiration', '7d', '초대 만료 기간 (형식: 숫자+단위, 예: 5m, 2h, 7d)'); -- 다른 예시들 -- 30분: '30m' -- 2시간: '2h' -- 14일: '14d' ``` #### 설정 예시 | PROP_GROUP | PROP_KEY | PROP_VALUE | 설명 | |------------|----------|------------|------| | Portal | invitation.expiration | 7d | 7일 후 만료 (기본값) | | Portal | invitation.expiration | 2h | 2시간 후 만료 (테스트용) | | Portal | invitation.expiration | 30m | 30분 후 만료 (데모용) | | Portal | invitation.expiration | 14d | 14일 후 만료 (운영용) | --- ### 지원하는 시간 단위 | 단위 | 설명 | 예시 | 실제 기간 | |------|------|------|----------| | `m` | 분 (minutes) | `5m` | 5분 | | `m` | 분 (minutes) | `30m` | 30분 | | `h` | 시간 (hours) | `2h` | 2시간 | | `h` | 시간 (hours) | `24h` | 24시간 (1일) | | `d` | 일 (days) | `7d` | 7일 | | `d` | 일 (days) | `30d` | 30일 | **형식**: `<숫자><단위>` (예: `5m`, `2h`, `7d`) --- ### 에러 처리 #### 잘못된 형식 입력 시 ```java // PTL_PROPERTY에 잘못된 값이 있는 경우 // 예: "invalid", "7", "d7", "7dd" Duration duration = DurationParser.parseOrDefault(expirationDuration, Duration.ofDays(7)); // → 파싱 실패 시 기본값 7일 사용 ``` **로그 출력** (선택사항): ```java try { duration = DurationParser.parse(expirationDuration); } catch (IllegalArgumentException e) { log.warn("Invalid invitation expiration duration '{}', using default 7 days", expirationDuration, e); duration = Duration.ofDays(7); } ``` --- ### 테스트 시나리오 #### 테스트 1: 기본값 7일 ``` PTL_PROPERTY: 설정 없음 또는 "7d" 초대 생성: 2025-11-24 10:00 만료일: 2025-12-01 10:00 (7일 후) ✅ ``` #### 테스트 2: 2시간 (테스트용) ``` PTL_PROPERTY: "2h" 초대 생성: 2025-11-24 10:00 만료일: 2025-11-24 12:00 (2시간 후) ✅ ``` #### 테스트 3: 30분 (데모용) ``` PTL_PROPERTY: "30m" 초대 생성: 2025-11-24 10:00 만료일: 2025-11-24 10:30 (30분 후) ✅ ``` #### 테스트 4: 14일 (운영용) ``` PTL_PROPERTY: "14d" 초대 생성: 2025-11-24 10:00 만료일: 2025-12-08 10:00 (14일 후) ✅ ``` #### 테스트 5: 잘못된 형식 → 기본값 ``` PTL_PROPERTY: "invalid" 또는 빈 값 초대 생성: 2025-11-24 10:00 만료일: 2025-12-01 10:00 (기본 7일) ✅ 로그: "Invalid invitation expiration duration 'invalid', using default 7 days" ``` --- ### 장점 - ✅ **재배포 불필요**: DB 설정만 변경하면 즉시 적용 - ✅ **환경별 설정**: 개발/스테이징/운영 환경마다 다른 만료일 설정 가능 - ✅ **유연성**: 분/시간/일 단위 모두 지원 - ✅ **안전성**: 잘못된 설정 시 기본값(7일) 사용 - ✅ **가독성**: "7d"가 "7"보다 의미 명확 --- ## 📌 중복 초대 방지 구현 ⚠️ 필수 ### 현재 문제점 **UserManFacade.sendInvitation()** (91-132라인)에서: - ✅ 이미 법인 회원인지 체크함 (108-110라인) - ❌ **PENDING 상태의 초대가 이미 있는지 체크 안 함** **결과**: 같은 이메일에 여러 번 초대 가능 → 중복 초대 생성 ❌ --- ### 구현 방법 #### 1. UserManFacade.sendInvitation() 수정 **파일**: `src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java` **수정 위치**: 103-111라인 (기존 사용자 확인 로직 다음) ```java public void sendInvitation(PortalUser sender, String emailAddr) { // 이메일 형식 검증 if (emailAddr == null || emailAddr.trim().isEmpty()) { throw new IllegalArgumentException("이메일 주소를 입력해주세요."); } // 이메일 정규식 검증 String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$"; if (!emailAddr.matches(emailPattern)) { throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요."); } // 기존 사용자인지 확인 Optional existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr); if (existingUser.isPresent()) { PortalUser user = existingUser.get(); if (user.getPortalOrg() != null && !user.getRoleCode().equals(RoleCode.ROLE_USER)) { throw new IllegalArgumentException("기관에 등록된 이용자 입니다."); } } // ===== 새로 추가: 중복 초대 확인 ===== // 1. 현재 법인의 PENDING 초대 확인 Optional existingInvitation = userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus( emailAddr, sender.getPortalOrg().getId(), InvitationStatus.PENDING ); if (existingInvitation.isPresent()) { UserInvitation invitation = existingInvitation.get(); // 만료된 초대인 경우 → 기존 초대 취소 후 새로 생성 if (invitation.getExpiresOn().isBefore(LocalDateTime.now())) { invitation.setStatus(InvitationStatus.CANCELED); userInvitationRepository.save(invitation); // 계속 진행하여 새 초대 생성 } else { // 아직 유효한 초대가 있는 경우 → 에러 throw new IllegalArgumentException( "이미 초대 중인 사용자입니다. 초대 만료일: " + invitation.getExpiresOn().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) ); } } // 2. 다른 법인의 PENDING 초대 확인 (선택사항) Optional otherOrgInvitation = userInvitationRepository.findFirstByInvitationEmailAndStatus( emailAddr, InvitationStatus.PENDING ); if (otherOrgInvitation.isPresent() && !otherOrgInvitation.get().getOrgId().equals(sender.getPortalOrg().getId())) { // 다른 법인에서 초대 중 → 경고 메시지 (선택사항) // 비즈니스 정책에 따라 차단 또는 허용 throw new IllegalArgumentException( "해당 사용자는 다른 법인에서 초대 중입니다." ); } // ==================================== String token = createInvitation(sender, emailAddr); // ... 나머지 기존 로직 } ``` **예상 작업량**: 30-35줄 추가 --- #### 2. UserInvitationRepository 메서드 추가 (필요 시) 현재 `findFirstByInvitationEmailAndStatus` 메서드는 있지만, **같은 법인 내 중복 체크**를 위해 새 메서드가 필요할 수 있습니다. **파일**: `UserInvitationRepository.java` ```java public interface UserInvitationRepository extends JpaRepository { // 기존 메서드 Optional findFirstByInvitationEmailAndStatus( String invitationEmail, InvitationStatus status ); // 새로 추가: 같은 법인 내 중복 체크 Optional findFirstByInvitationEmailAndOrgIdAndStatus( String invitationEmail, String orgId, InvitationStatus status ); } ``` **예상 작업량**: 5줄 추가 (메서드 시그니처만) --- ### 중복 초대 시나리오 및 처리 방침 #### 시나리오 1: 같은 법인에서 중복 초대 ``` 상황: A법인 관리자가 user@example.com에게 초대 상태: PENDING (만료일: 2025-12-01) 재시도: A법인 관리자가 동일 이메일에 다시 초대 시도 결과: ❌ "이미 초대 중인 사용자입니다. 초대 만료일: 2025-12-01" ``` **정책**: 차단 (같은 법인 내 중복 초대 불가) --- #### 시나리오 2: 같은 법인, 만료된 초대 ``` 상황: A법인 관리자가 user@example.com에게 초대 상태: PENDING (만료일: 2025-11-20, 이미 만료됨) 재시도: A법인 관리자가 동일 이메일에 다시 초대 시도 처리: 1. 기존 초대 상태 → CANCELED 2. 새로운 PENDING 초대 생성 3. 새 토큰 발급 결과: ✅ 새 초대 이메일 발송 ``` **정책**: 허용 (만료된 초대는 자동 취소 후 재생성) --- #### 시나리오 3: 다른 법인에서 중복 초대 (비즈니스 정책 필요) **옵션 A: 차단** (추천) ``` 상황: A법인에서 user@example.com에게 초대 (PENDING) 재시도: B법인 관리자가 동일 이메일에 초대 시도 결과: ❌ "해당 사용자는 다른 법인에서 초대 중입니다." ``` **옵션 B: 허용** ``` 상황: A법인에서 user@example.com에게 초대 (PENDING) 재시도: B법인 관리자가 동일 이메일에 초대 시도 결과: ✅ 두 초대 모두 PENDING 상태 유지 선택: 사용자가 먼저 수락한 초대로 가입 ``` **권장**: 옵션 A (차단) - 사용자 혼란 방지 --- #### 시나리오 4: 초대 수락 후 다른 법인 초대 ``` 상황: user@example.com이 A법인 초대 수락 (ROLE_CORP_USER) 재시도: B법인 관리자가 동일 이메일에 초대 시도 결과: ❌ "기관에 등록된 이용자 입니다." (기존 로직) ``` **정책**: 차단 (이미 구현됨, 108-110라인) --- ### 프론트엔드 에러 메시지 처리 **파일**: `userList.html` (sendInvitation 함수, 223-235라인) 현재 에러 처리 코드: ```javascript .fail(function (jqXHR, textStatus, errorThrown) { let errorMessage; try { const errorResponse = JSON.parse(jqXHR.responseText); errorMessage = errorResponse.msg || '이용자 초대 중 오류가 발생했습니다'; } catch (e) { errorMessage = '이용자 초대 중 오류가 발생했습니다: ' + errorThrown; } customPopups.showAlert(errorMessage); }); ``` **변경 필요 없음**: 이미 서버 에러 메시지를 그대로 표시하므로 중복 초대 에러도 자동으로 표시됨 ✅ --- ### 테스트 시나리오 #### 테스트 1: 같은 법인 중복 초대 차단 ``` 1. A법인 관리자 로그인 2. user@example.com에게 초대 발송 → 성공 3. 다시 user@example.com에게 초대 시도 4. 에러: "이미 초대 중인 사용자입니다. 초대 만료일: 2025-12-01" 5. 초대 목록에서 1개만 표시 ✅ ``` #### 테스트 2: 만료된 초대 재발송 ``` 1. A법인 관리자 로그인 2. 만료된 초대 존재 (만료일: 2025-11-20) 3. user@example.com에게 다시 초대 시도 4. 성공: 기존 초대 CANCELED, 새 초대 PENDING 5. 새 토큰으로 이메일 발송 ✅ ``` #### 테스트 3: 다른 법인 중복 초대 차단 ``` 1. A법인 관리자가 user@example.com에게 초대 (PENDING) 2. B법인 관리자 로그인 3. user@example.com에게 초대 시도 4. 에러: "해당 사용자는 다른 법인에서 초대 중입니다." 5. B법인 초대 목록에 표시 안 됨 ✅ ``` #### 테스트 4: 초대 수락 후 중복 차단 ``` 1. user@example.com이 A법인 초대 수락 (ROLE_CORP_USER) 2. B법인 관리자가 user@example.com에게 초대 시도 3. 에러: "기관에 등록된 이용자 입니다." (기존 로직) 4. 초대 생성 안 됨 ✅ ``` --- ### 구현 우선순위 | 시나리오 | 우선순위 | 이유 | |----------|---------|------| | 같은 법인 중복 초대 차단 | 🔴 **필수** | 데이터 정합성 문제, 사용자 혼란 | | 만료된 초대 재생성 | 🟡 **권장** | UX 개선 (수동 취소 불필요) | | 다른 법인 중복 초대 차단 | 🟡 **권장** | 사용자 혼란 방지 | --- ## 📌 이메일 검증 구현 ✅ 완료 ### 구현 내역 #### 1. 프론트엔드 (userList.html) - ✅ `` 적용 - ✅ HTML5 pattern 속성 추가: `[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}` - ✅ JavaScript 정규식 검증 추가 #### 2. 백엔드 (UserManRestController.java) - ✅ 이메일 null/empty 체크 - ✅ 정규식 검증: `^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$` - ✅ 검증 실패 시 400 에러 반환 #### 3. Facade (UserManFacade.java) - ✅ 이메일 null/empty 체크 - ✅ 정규식 검증 - ✅ `IllegalArgumentException` 발생 ### 적용된 파일 - `src/main/resources/templates/views/apps/users/userList.html` - `src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java` - `src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java` --- ## 📌 후속 작업 (선택사항) 1. ~~**Brute Force 방지**: 초대코드 입력 실패 횟수 제한~~ ✅ 상세 구현 계획 추가 (세션 기반) 2. ~~**이메일 검증**: 이메일 형식 검증~~ ✅ 구현 완료 3. ~~**중복 초대 방지**: PENDING 초대 중복 체크~~ ✅ 상세 구현 계획 추가 4. ~~**초대 만료일 동적 설정**: PTL_PROPERTY 기반 만료일 설정~~ ✅ 상세 구현 계획 추가 5. **관리자 알림**: 초대코드 입력 시도 로그 기록 6. **통계**: 초대코드 직접 입력 vs 이메일 링크 사용 비율 추적 7. **QR 코드**: 초대코드를 QR 코드로 제공 (모바일 스캔) 8. **초대 재발송**: 만료 전 초대 재발송 기능 (UI 버튼) 9. **만료일 연장**: 주 단위(w), 월 단위(M), 년 단위(y) 지원 --- **문서 버전**: 1.4 **최종 수정**: 2025-11-24 **작성자**: Claude Code **변경 이력**: - v1.4 (2025-11-24): 초대 만료일 동적 설정 (PTL_PROPERTY 기반, 시간 표현 파싱 유틸리티) - v1.3 (2025-11-24): 중복 초대 방지 기능 추가 (4가지 시나리오 및 구현 방법) - v1.2 (2025-11-24): 마이페이지 초대 알림 기능 추가 (재방문 사용자 대응), 작업 규모 재산정 (6→8시간) - v1.1 (2025-11-24): Brute Force 방지 세션 기반 구현 방법 추가, 이메일 검증 구현 완료 표시 - v1.0 (2025-11-24): 초기 작성