From a7d94c4d9296aa4a449c1c6dc890eb069d39ba4c Mon Sep 17 00:00:00 2001 From: Rinjae Date: Mon, 24 Nov 2025 19:50:00 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EA=B8=B0=EB=8A=A5=20=EB=8C=80=EC=9D=91=20=EC=A4=80?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kjb-docs/mermaid/invite-flow.mermaid | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 kjb-docs/mermaid/invite-flow.mermaid diff --git a/kjb-docs/mermaid/invite-flow.mermaid b/kjb-docs/mermaid/invite-flow.mermaid new file mode 100644 index 0000000..e69de29 From d7a32e1dcf3616c71d2e4f1748e719675622704c Mon Sep 17 00:00:00 2001 From: Rinjae Date: Mon, 24 Nov 2025 22:23:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=B2=95=EC=9D=B8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...vitation-code-input-implementation-plan.md | 1466 +++++++++++++++++ .../mermaid/invite-flow-integrated.mermaid | 99 ++ kjb-docs/mermaid/invite-flow.mermaid | 44 + kjb-docs/mermaid/invite-sequence.mermaid | 64 + kjb-docs/mermaid/invite-state.mermaid | 20 + .../user/controller/AccountController.java | 23 +- .../controller/UserManRestController.java | 21 +- .../controller/UserRegisterController.java | 126 +- .../apps/user/facade/UserManFacade.java | 140 +- .../portal/common/util/DurationParser.java | 76 + .../PortalAuthenticationSuccessHandler.java | 20 + .../templates/views/apps/login/login.html | 13 + .../views/apps/mypage/updatePersonalUser.html | 15 + .../apps/register/invitationCodeInput.html | 131 ++ .../apps/register/userDecisionAccept.html | 2 +- .../templates/views/apps/users/userList.html | 53 +- 16 files changed, 2293 insertions(+), 20 deletions(-) create mode 100644 kjb-docs/invitation-code-input-implementation-plan.md create mode 100644 kjb-docs/mermaid/invite-flow-integrated.mermaid create mode 100644 kjb-docs/mermaid/invite-sequence.mermaid create mode 100644 kjb-docs/mermaid/invite-state.mermaid create mode 100644 src/main/java/com/eactive/apim/portal/common/util/DurationParser.java create mode 100644 src/main/resources/templates/views/apps/register/invitationCodeInput.html diff --git a/kjb-docs/invitation-code-input-implementation-plan.md b/kjb-docs/invitation-code-input-implementation-plan.md new file mode 100644 index 0000000..cd156df --- /dev/null +++ b/kjb-docs/invitation-code-input-implementation-plan.md @@ -0,0 +1,1466 @@ +# 초대 코드 직접 입력 기능 구현 계획 + +**작성일**: 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): 초기 작성 diff --git a/kjb-docs/mermaid/invite-flow-integrated.mermaid b/kjb-docs/mermaid/invite-flow-integrated.mermaid new file mode 100644 index 0000000..aa267a6 --- /dev/null +++ b/kjb-docs/mermaid/invite-flow-integrated.mermaid @@ -0,0 +1,99 @@ +flowchart TD + Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성
    예: K7MNPQ2R
    UserInvitation 저장] + + GenerateToken --> SendEmail[이메일 발송
    - 링크 포함
    - 8글자 토큰 표시] + + SendEmail --> UserType{사용자 유형} + + %% 신규 회원 플로우 + UserType -->|신규 회원
    미가입자| NewUserPath{초대 확인 방법} + + NewUserPath -->|폐쇄망 환경| LoginPage1[로그인 페이지 접근] + LoginPage1 --> InviteLink1["초대를 받으셨나요?" 링크 클릭] + InviteLink1 --> InputCodePage1[초대코드 입력 페이지] + InputCodePage1 --> InputCode1[8자리 코드 입력
    예: K7MNPQ2R] + InputCode1 --> ValidateCode1{코드 유효성 검증} + + ValidateCode1 -->|유효하지 않음| Error1[에러: 유효하지 않은 초대코드] + ValidateCode1 -->|유효| SignupPage1[회원가입 페이지
    /signup/portalUser?invitation=CODE] + + NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭
    /signup/portalUser?invitation=CODE] + EmailLink1 --> SignupPage1 + + SignupPage1 --> RegisterForm[가입 정보 입력
    - 이메일 자동 입력됨
    - 법인명 표시
    - 약관 동의] + RegisterForm --> RegisterComplete[회원가입 완료
    ROLE_CORP_USER로 생성] + RegisterComplete --> NewUserEnd([가입 완료 안내]) + + %% 기존 회원 플로우 + UserType -->|기존 회원
    ROLE_USER| ExistingUserPath{초대 확인 방법} + + ExistingUserPath -->|폐쇄망 환경| LoginPage2[로그인 페이지 접근] + LoginPage2 --> InviteLink2["초대를 받으셨나요?" 링크 클릭] + InviteLink2 --> InputCodePage2[초대코드 입력 페이지] + InputCodePage2 --> CheckLogin1{로그인 여부} + + CheckLogin1 -->|미로그인| RedirectLogin1[로그인 페이지로 리다이렉트
    코드 저장] + RedirectLogin1 --> Login1[로그인] + Login1 --> AfterLogin1[로그인 성공] + + CheckLogin1 -->|로그인됨| InputCode2[8자리 코드 입력] + InputCode2 --> ValidateCode2{코드 유효성 검증} + ValidateCode2 -->|유효하지 않음| Error2[에러: 유효하지 않은 초대코드] + ValidateCode2 -->|유효| DecisionPage1[초대 수락 페이지
    /signup/decision_process] + + ExistingUserPath -->|이메일 링크| EmailLink2[이메일 링크 클릭
    /signup/decision?invitation=CODE] + EmailLink2 --> DecisionPagePublic[초대 수락 페이지
    비로그인 상태] + DecisionPagePublic --> RedirectLogin2[로그인 유도] + RedirectLogin2 --> Login2[로그인] + Login2 --> AfterLogin2[로그인 성공] + + ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인] + DirectLogin --> Login3[로그인] + Login3 --> AfterLogin3[로그인 성공] + + %% 로그인 후 자동 초대 확인 + AfterLogin1 --> CheckInvitation[초대 코드 자동 확인
    findFirstByInvitationEmailAndStatus] + AfterLogin2 --> CheckInvitation + AfterLogin3 --> CheckInvitation + + CheckInvitation --> HasInvitation{초대 코드 존재?} + + HasInvitation -->|없음| NormalHome[일반 홈페이지] + HasInvitation -->|있음| AutoRedirect[자동으로 초대 수락 페이지로 이동
    /signup/decision_process] + + AutoRedirect --> DecisionPage1 + DecisionPage1 --> UserDecision{사용자 선택} + + UserDecision -->|수락| AcceptProcess[초대 수락 처리
    1. PortalOrg 변경
    2. ROLE_CORP_USER 전환
    3. 약관 재동의] + UserDecision -->|거절| RejectProcess[초대 거절 처리
    Invitation.status = REJECTED] + + AcceptProcess --> CompleteInvitation[Invitation.status = COMPLETED] + CompleteInvitation --> LogoutPrompt[로그아웃 후 재로그인 안내] + LogoutPrompt --> ExistingUserEnd([초대 수락 완료]) + + RejectProcess --> RejectEnd([초대 거절 완료]) + + %% 에러 처리 + Error1 --> ErrorEnd([처리 종료]) + Error2 --> ErrorEnd + + %% 스타일링 + style Start fill:#e1f5e1 + style NewUserEnd fill:#c8e6c9 + style ExistingUserEnd fill:#c8e6c9 + style RejectEnd fill:#fff9c4 + style ErrorEnd fill:#ffcdd2 + style Error1 fill:#ffcdd2 + style Error2 fill:#ffcdd2 + + style GenerateToken fill:#bbdefb + style AcceptProcess fill:#c5cae9 + style CheckInvitation fill:#ffe0b2 + style AutoRedirect fill:#ffe0b2 + + style LoginPage1 fill:#e1bee7 + style LoginPage2 fill:#e1bee7 + style InviteLink1 fill:#f8bbd0 + style InviteLink2 fill:#f8bbd0 + style InputCodePage1 fill:#f8bbd0 + style InputCodePage2 fill:#f8bbd0 diff --git a/kjb-docs/mermaid/invite-flow.mermaid b/kjb-docs/mermaid/invite-flow.mermaid index e69de29..618e4b6 100644 --- a/kjb-docs/mermaid/invite-flow.mermaid +++ b/kjb-docs/mermaid/invite-flow.mermaid @@ -0,0 +1,44 @@ +flowchart TD + Start([법인 관리자가 사용자 초대 시작]) --> CheckUser{가입된 사용자인가?} + + CheckUser -->|아니오| Error1[에러: 가입된 사용자만 초대 가능] + CheckUser -->|예| CheckRole{이미 법인 사용자인가?} + + CheckRole -->|예| Error2[에러: 이미 기관에 등록된 사용자] + CheckRole -->|아니오 ROLE_USER| CreateInvitation[8글자 토큰 생성
    UserInvitation 저장
    상태: PENDING] + + CreateInvitation --> SendEmail[이메일 발송
    UserInvitationEvent] + + SendEmail --> UserReceive{사용자가 토큰 받음} + + UserReceive -->|폐쇄망| ManualInput[8글자 토큰 직접 입력
    예: K7MNPQ2R] + UserReceive -->|이메일 링크| EmailLink[Base64 인코딩된
    링크 클릭] + + ManualInput --> DecodeToken[토큰 디코딩
    decodeInvitationToken] + EmailLink --> DecodeToken + + DecodeToken --> ValidateToken{토큰 유효성 검증} + + ValidateToken -->|무효| Error3[에러: 유효하지 않은 초대] + ValidateToken -->|유효 & PENDING| ShowDecision[약관 동의 페이지
    /signup/decision] + + ShowDecision --> UserDecision{사용자 선택} + + UserDecision -->|수락| ProcessAccept[processInvitation: accept] + UserDecision -->|거절| ProcessReject[processInvitation: reject] + + ProcessAccept --> UpdateUser[1. PortalOrg 변경
    2. ROLE_CORP_USER로 전환
    3. 약관 재동의 PRIVACY_COLLECT_ORG] + UpdateUser --> CompleteInvitation[Invitation 상태:
    COMPLETED + completeDate] + CompleteInvitation --> Success([초대 수락 완료
    로그아웃 후 재로그인]) + + ProcessReject --> RejectInvitation[Invitation 상태:
    REJECTED + completeDate] + RejectInvitation --> End([초대 거절 완료]) + + style Start fill:#e1f5e1 + style Success fill:#c8e6c9 + style End fill:#fff9c4 + style Error1 fill:#ffcdd2 + style Error2 fill:#ffcdd2 + style Error3 fill:#ffcdd2 + style CreateInvitation fill:#bbdefb + style UpdateUser fill:#c5cae9 diff --git a/kjb-docs/mermaid/invite-sequence.mermaid b/kjb-docs/mermaid/invite-sequence.mermaid new file mode 100644 index 0000000..5d3214c --- /dev/null +++ b/kjb-docs/mermaid/invite-sequence.mermaid @@ -0,0 +1,64 @@ +sequenceDiagram + actor 법인관리자 + participant Portal as Portal UI + participant UserManFacade + participant UserInvitationRepo as UserInvitation Repository + participant MessageService as Message Handler + participant Email as Email System + actor 초대받은사용자 + participant UserRegisterController + participant UserRegisterFacade + participant PortalUserRepo as PortalUser Repository + + 법인관리자->>Portal: 사용자 이메일 입력
    초대 요청 + Portal->>UserManFacade: sendInvitation(sender, emailAddr) + + UserManFacade->>PortalUserRepo: findByEmailAddr(emailAddr) + alt 사용자가 없거나 이미 법인 사용자 + UserManFacade-->>Portal: 에러 반환 + Portal-->>법인관리자: 초대 불가 알림 + else 일반 사용자 ROLE_USER + UserManFacade->>UserManFacade: generateEightCharToken()
    예: K7MNPQ2R + UserManFacade->>UserInvitationRepo: save(UserInvitation)
    status: PENDING
    expiresOn: 7일 후 + UserManFacade->>MessageService: publishEvent(UserInvitationEvent) + MessageService->>Email: 초대 이메일 발송
    (법인명, 관리자명, 토큰) + UserManFacade-->>Portal: 초대 완료 + Portal-->>법인관리자: 초대 성공 알림 + end + + Email->>초대받은사용자: 초대 이메일 수신 + + alt 폐쇄망 환경 + 초대받은사용자->>Portal: 8글자 토큰 직접 입력
    /signup/decision?invitation=K7MNPQ2R + else 이메일 링크 + 초대받은사용자->>Portal: 링크 클릭
    /signup/decision?invitation=BASE64TOKEN + end + + Portal->>UserRegisterController: showDecisionPage(invitationToken) + UserRegisterController->>UserRegisterController: decodeInvitationToken()
    (Base64 or 직접입력 처리) + UserRegisterController->>UserInvitationRepo: findByToken(decodedToken) + + alt 토큰 무효 or 상태가 PENDING 아님 + UserRegisterController-->>Portal: 에러: 유효하지 않은 초대 + Portal-->>초대받은사용자: 에러 페이지 + else 토큰 유효 + UserRegisterController->>PortalUserRepo: findByEmailAddr() + UserRegisterController-->>Portal: 약관 동의 페이지
    (법인명, 사용자명 표시) + Portal-->>초대받은사용자: 수락/거절 선택 화면 + end + + 초대받은사용자->>Portal: 수락 or 거절 선택 + Portal->>UserRegisterController: processInvitation(action) + UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation) + + alt 수락 + UserRegisterFacade->>PortalUserRepo: findByEmailAddr() + UserRegisterFacade->>PortalUserRepo: updateUserToCorpUser()
    (PortalOrg 변경
    ROLE_CORP_USER 전환) + UserRegisterFacade->>UserInvitationRepo: save(invitation)
    status: COMPLETED
    completeDate: now() + UserRegisterFacade-->>Portal: 수락 성공 + Portal-->>초대받은사용자: 성공 메시지
    로그아웃 후 재로그인 안내 + else 거절 + UserRegisterFacade->>UserInvitationRepo: save(invitation)
    status: REJECTED
    completeDate: now() + UserRegisterFacade-->>Portal: 거절 처리 완료 + Portal-->>초대받은사용자: 거절 완료 메시지 + end diff --git a/kjb-docs/mermaid/invite-state.mermaid b/kjb-docs/mermaid/invite-state.mermaid new file mode 100644 index 0000000..79bba32 --- /dev/null +++ b/kjb-docs/mermaid/invite-state.mermaid @@ -0,0 +1,20 @@ +stateDiagram-v2 + [*] --> 초대생성: 법인관리자가 초대 시작 + + 초대생성 --> PENDING: 8글자 토큰 생성
    UserInvitation 저장 + + PENDING --> 토큰전달: 이메일 발송 + + 토큰전달 --> 사용자접근: 폐쇄망 직접입력 or
    이메일 링크 클릭 + + 사용자접근 --> 약관동의: 토큰 유효성 검증 성공 + 사용자접근 --> 에러: 토큰 무효 + + 약관동의 --> COMPLETED: 사용자 수락
    ROLE_CORP_USER 전환
    PortalOrg 변경 + 약관동의 --> REJECTED: 사용자 거절 + + COMPLETED --> [*]: 초대 완료 + REJECTED --> [*]: 초대 거절 + PENDING --> CANCELED: 관리자가 초대 취소 + CANCELED --> [*]: 초대 취소 + 에러 --> [*]: 처리 종료 diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java index a3e49dc..fec67da 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java @@ -10,6 +10,9 @@ import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade; import com.eactive.apim.portal.apps.user.facade.UserFacade; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; import com.eactive.apim.portal.common.util.SecurityUtil; +import com.eactive.apim.portal.invitation.entity.UserInvitation; +import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus; +import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import java.util.Arrays; @@ -47,6 +50,7 @@ public class AccountController { private final OrgRegisterFacade orgRegisterFacade; private final AgreementsFacade agreementsFacade; private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade; + private final UserInvitationRepository userInvitationRepository; @PostMapping("/confirm_password") @@ -122,7 +126,8 @@ public class AccountController { try { // 현재 로그인된 사용자의 정보를 가져옴 - PortalUserDTO user = userFacade.findById(SecurityUtil.getPortalAuthenticatedUser().getId()); + PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser(); + PortalUserDTO user = userFacade.findById(currentUser.getId()); // 개별 필드들을 모델에 추가 mav.addObject("loginId", user.getLoginId()); @@ -132,6 +137,20 @@ public class AccountController { // 기존 user 객체도 유지 (다른 곳에서 필요할 수 있으므로) mav.addObject("user", user); + // ROLE_USER인 경우 초대 여부 확인 + if (currentUser.getRoleCode() == RoleCode.ROLE_USER) { + java.util.Optional pendingInvitation = + userInvitationRepository.findFirstByInvitationEmailAndStatus( + user.getEmailAddr(), InvitationStatus.PENDING); + + if (pendingInvitation.isPresent()) { + mav.addObject("hasPendingInvitation", true); + mav.addObject("invitationToken", pendingInvitation.get().getToken()); + } else { + mav.addObject("hasPendingInvitation", false); + } + } + // IP 목록을 분리하여 모델에 추가 (법인 관리자인 경우만) if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER && user.getPortalOrg() != null @@ -143,7 +162,7 @@ public class AccountController { } // 사용자의 RoleCode에 따라 다른 페이지로 이동 - switch (SecurityUtil.getPortalAuthenticatedUser().getRoleCode()) { + switch (currentUser.getRoleCode()) { case ROLE_USER: mav.setViewName("apps/mypage/updatePersonalUser"); break; diff --git a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java index 066525f..f64ae36 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java @@ -43,8 +43,20 @@ public class UserManRestController { //5. register new user @PostMapping("/invite") public ResponseDTO sendInvitation(@RequestBody PortalUserDTO newUser) { + String email = newUser.getEmailAddr(); - userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), newUser.getEmailAddr()); + // 이메일 형식 검증 + if (email == null || email.trim().isEmpty()) { + return new ResponseDTO(400, "ERROR", "이메일 주소를 입력해주세요."); + } + + // 이메일 정규식 검증 + String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$"; + if (!email.matches(emailPattern)) { + return new ResponseDTO(400, "ERROR", "올바른 이메일 형식을 입력해주세요."); + } + + userManFacade.sendInvitation(SecurityUtil.getPortalAuthenticatedUser(), email); return new ResponseDTO(200, "SUCCESS", "요청 메일이 발송되었습니다."); } @@ -56,4 +68,11 @@ public class UserManRestController { return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다."); } + + // 6. Resend invitation + @PostMapping("/resend-invitation") + public ResponseDTO resendInvitation(@RequestBody PortalUserDTO user) { + userManFacade.resendInvitation(SecurityUtil.getPortalAuthenticatedUser(), user.getId()); + return new ResponseDTO(200, "SUCCESS", "초대가 재발송되었습니다."); + } } 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 caf0524..54e84e8 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 @@ -71,7 +71,8 @@ public class UserRegisterController { String[] parts = null; if (invitationToken != null) { - String decodedToken = new String(Base64.decode(invitationToken)); + // 8글자 토큰 사용 + String decodedToken = decodeInvitationToken(invitationToken); session.setAttribute("invitationToken", decodedToken); Optional invitation = userInvitationRepository.findByToken(decodedToken); if (invitation.isPresent() && invitation.get().getStatus() == UserInvitationEnums.InvitationStatus.PENDING) { @@ -153,7 +154,8 @@ public class UserRegisterController { return "redirect:/"; } - String decodedToken = new String(Base64.decode(invitationToken)); + // 8글자 토큰 사용 + String decodedToken = decodeInvitationToken(invitationToken); session.setAttribute("decisionToken", decodedToken); Optional invitation = userInvitationRepository.findByToken(decodedToken); @@ -294,4 +296,124 @@ public class UserRegisterController { model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT_IND")); model.addAttribute("msgType", "sms"); } + + /** + * 초대 토큰 반환 + * @param invitationToken 원본 토큰 (8글자 토큰) + * @return 토큰 + */ + private String decodeInvitationToken(String invitationToken) { + return invitationToken; + } + + /** + * 초대 코드 입력 페이지 표시 + */ + @GetMapping("/signup/invitation-code") + public String showInvitationCodeInput(HttpSession session, Model model) { + // 브루트포스 방지: 세션에서 시도 횟수 확인 + Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts"); + Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil"); + + if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) { + // 잠금 상태: 남은 시간 계산 + long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000; + long remainingMinutes = remainingSeconds / 60; + long seconds = remainingSeconds % 60; + String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds); + model.addAttribute("lockoutTime", lockoutTime); + model.addAttribute("attemptsRemaining", 0); + } else { + // 잠금 해제: 시도 횟수 표시 + if (failedAttempts == null) { + failedAttempts = 0; + } + int attemptsRemaining = 5 - failedAttempts; + model.addAttribute("attemptsRemaining", attemptsRemaining); + } + + return "apps/register/invitationCodeInput"; + } + + /** + * 초대 코드 입력 처리 + */ + @PostMapping("/signup/invitation-code") + public String processInvitationCode( + @RequestParam("invitationCode") String invitationCode, + HttpSession session, + RedirectAttributes redirectAttributes, + Model model) { + + // 브루트포스 방지: 잠금 상태 확인 + Long lockoutUntil = (Long) session.getAttribute("invitationCodeLockoutUntil"); + if (lockoutUntil != null && lockoutUntil > System.currentTimeMillis()) { + long remainingSeconds = (lockoutUntil - System.currentTimeMillis()) / 1000; + long remainingMinutes = remainingSeconds / 60; + long seconds = remainingSeconds % 60; + String lockoutTime = String.format("%d분 %d초", remainingMinutes, seconds); + model.addAttribute("lockoutTime", lockoutTime); + model.addAttribute("error", "잠금 해제 시간을 기다려 주세요."); + return "apps/register/invitationCodeInput"; + } + + // 초대 코드 정규화 (대문자 변환 및 공백 제거) + String normalizedCode = invitationCode.toUpperCase().trim(); + + // 초대 코드 검증 + Optional invitationOpt = userInvitationRepository.findByToken(normalizedCode); + + if (!invitationOpt.isPresent() || + invitationOpt.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) { + + // 실패 처리: 시도 횟수 증가 + Integer failedAttempts = (Integer) session.getAttribute("invitationCodeAttempts"); + if (failedAttempts == null) { + failedAttempts = 0; + } + failedAttempts++; + session.setAttribute("invitationCodeAttempts", failedAttempts); + + if (failedAttempts >= 5) { + // 5회 실패: 15분 잠금 + long lockoutTime = System.currentTimeMillis() + (15 * 60 * 1000); // 15분 + session.setAttribute("invitationCodeLockoutUntil", lockoutTime); + session.setAttribute("invitationCodeAttempts", 0); // 시도 횟수 초기화 + + model.addAttribute("lockoutTime", "15분 0초"); + model.addAttribute("error", "잘못된 초대 코드를 5회 입력하여 15분간 잠금되었습니다."); + } else { + // 실패 메시지와 남은 시도 횟수 표시 + int attemptsRemaining = 5 - failedAttempts; + model.addAttribute("attemptsRemaining", attemptsRemaining); + model.addAttribute("error", "유효하지 않은 초대 코드입니다. 다시 확인해 주세요."); + } + + return "apps/register/invitationCodeInput"; + } + + // 성공: 초대 코드 검증 완료 + UserInvitation invitation = invitationOpt.get(); + + // 만료 확인 + if (invitation.getExpiresOn().isBefore(java.time.LocalDateTime.now())) { + model.addAttribute("error", "만료된 초대 코드입니다. 관리자에게 재발송을 요청해 주세요."); + return "apps/register/invitationCodeInput"; + } + + // 시도 횟수 초기화 + session.removeAttribute("invitationCodeAttempts"); + session.removeAttribute("invitationCodeLockoutUntil"); + + // 기존 사용자 여부 확인 + Optional existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail()); + + if (existingUser.isPresent()) { + // 기존 사용자: 초대 수락 페이지로 이동 + return "redirect:/signup/decision?invitation=" + normalizedCode; + } else { + // 신규 사용자: 회원가입 페이지로 이동 + return "redirect:/signup/portalUser?invitation=" + normalizedCode; + } + } } diff --git a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java index 961578f..cb0a9f8 100644 --- a/src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java @@ -6,6 +6,7 @@ import com.eactive.apim.portal.apps.user.mapper.PortalOrgMapper; import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper; import com.eactive.apim.portal.common.exception.NotFoundException; import com.eactive.apim.portal.common.user.PortalAuthenticatedUser; +import com.eactive.apim.portal.common.util.DurationParser; import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.StringMaskingUtil; import com.eactive.apim.portal.invitation.entity.UserInvitation; @@ -18,23 +19,24 @@ import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus; import com.eactive.apim.portal.portaluser.repository.PortalUserRepository; +import com.eactive.apim.portal.portalproperty.service.PortalPropertyService; import com.eactive.apim.portal.template.service.MessageHandlerService; import com.eactive.apim.portal.template.service.MessageRecipient; import com.eactive.apim.portal.user.entity.UserLog; import com.eactive.apim.portal.user.repository.UserLogRepository; -import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; -import org.apache.xerces.impl.dv.util.Base64; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; +import java.time.Duration; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.stream.Collectors; @Service @@ -48,6 +50,7 @@ public class UserManFacade { private final UserInvitationRepository userInvitationRepository; private final MessageHandlerService messageHandlerService; private final UserLogRepository userLogRepository; + private final PortalPropertyService portalPropertyService; public Page getUsers(PortalOrgDTO userOrg, Pageable pageable) { return portalUserRepository @@ -91,6 +94,19 @@ public class UserManFacade { } 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("올바른 이메일 형식을 입력해주세요."); + } + + // 중복 초대 확인 + checkDuplicateInvitation(sender.getPortalOrg().getId(), emailAddr); //기존 사용자인지 확인 Optional existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr); @@ -104,16 +120,24 @@ public class UserManFacade { String token = createInvitation(sender, emailAddr); + // {"CRPT_NM":"%corpName%", "MNGR_NM":"%managerName%", "CUST_ID":"%loginId%", "AUTH_NO": "%authNumber%", "NAME": "%loginId%"} MessageRecipient recipient = new MessageRecipient(); recipient.setUserId(emailAddr); HashMap params = new HashMap<>(); - params.put("manager", sender.getUserName()); - params.put("company", sender.getPortalOrg().getOrgName()); + params.put("managerName", sender.getUserName()); + params.put("corpName", sender.getPortalOrg().getOrgName()); + params.put("authNumber", token); // 8-character invitation code + // 기존 사용자인 경우 회원 이름 사용, 비회원인 경우 이메일 사용 if (existingUser.isPresent()) { - params.put("url", "signup/decision?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8))); + PortalUser user = existingUser.get(); + params.put("loginId", user.getUserName()); + params.put("NAME", user.getUserName()); + params.put("url", "signup/decision?invitation=" + token); } else { - params.put("url", "signup/portalUser?invitation=" + Base64.encode(token.getBytes(StandardCharsets.UTF_8))); + params.put("loginId", emailAddr); + params.put("NAME", emailAddr); + params.put("url", "signup/portalUser?invitation=" + token); } messageHandlerService.publishEvent(UserInvitationEvent.KEY, recipient, params); @@ -129,18 +153,38 @@ public class UserManFacade { newInvitation.setInvitationDate(LocalDateTime.now()); newInvitation.setStatus(InvitationStatus.PENDING); - // Generate a unique token (you might want to use a more secure method) - String token = UUID.randomUUID().toString(); + // Generate 8-character token for manual entry in closed network + // Excludes confusing characters: O, 0, I, 1 + String token = generateEightCharToken(); newInvitation.setToken(token); - // Set expiration date (e.g., 7 days from now) - newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7)); + // Set expiration date from PTL_PROPERTY (default: 7 days) + Duration expiration = getInvitationExpiration(); + newInvitation.setExpiresOn(LocalDateTime.now().plus(expiration)); // Save the invitation userInvitationRepository.save(newInvitation); return token; } + /** + * 폐쇄망에서 사람이 직접 입력할 수 있는 8글자 토큰 생성 + * 혼동되기 쉬운 문자(O, 0, I, 1)를 제외한 대문자 영문자와 숫자 조합 + */ + private String generateEightCharToken() { + // 혼동되기 쉬운 문자 제외: O, 0, I, 1 + String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + SecureRandom random = new SecureRandom(); + StringBuilder token = new StringBuilder(8); + + for (int i = 0; i < 8; i++) { + int index = random.nextInt(chars.length()); + token.append(chars.charAt(index)); + } + + return token.toString(); + } + public void assignManager(PortalAuthenticatedUser portalAuthenticatedUser, String targetUserId) { PortalUser targetUser = portalUserRepository.findById(targetUserId) .orElseThrow(() -> new NotFoundException("ID [" + targetUserId + "] 사용자를 찾을 수 없습니다. ")); @@ -251,4 +295,78 @@ public class UserManFacade { }) .collect(Collectors.toList()); } + + /** + * 초대 재발송 + * 기존 PENDING 또는 EXPIRED 상태의 초대를 취소하고 새로운 초대를 생성합니다. + */ + public void resendInvitation(PortalUser sender, String invitationId) { + UserInvitation existingInvitation = userInvitationRepository.findById(invitationId) + .orElseThrow(() -> new NotFoundException("초대를 찾을 수 없습니다: " + invitationId)); + + // 권한 확인: 같은 기관의 초대인지 확인 + if (!existingInvitation.getOrgId().equals(sender.getPortalOrg().getId())) { + throw new IllegalArgumentException("초대를 재발송할 권한이 없습니다."); + } + + // 재발송 가능한 상태인지 확인 + if (existingInvitation.getStatus() != InvitationStatus.PENDING + && existingInvitation.getStatus() != InvitationStatus.EXPIRED) { + throw new IllegalStateException("재발송 가능한 상태가 아닙니다. 현재 상태: " + existingInvitation.getStatus().getDescription()); + } + + // 기존 초대를 취소 처리 + existingInvitation.setStatus(InvitationStatus.CANCELED); + userInvitationRepository.save(existingInvitation); + + // 새로운 초대 생성 및 발송 + sendInvitation(sender, existingInvitation.getInvitationEmail()); + } + + /** + * 중복 초대 확인 + * 같은 기관에서 같은 이메일로 이미 PENDING 상태인 초대가 있는지 확인 + */ + private void checkDuplicateInvitation(String orgId, String emailAddr) { + // 1. 같은 기관에서 PENDING 상태인 초대가 있는지 확인 + Optional pendingInvitation = + userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus( + emailAddr, orgId, InvitationStatus.PENDING); + + if (pendingInvitation.isPresent()) { + throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다."); + } + + // 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인 + Optional otherOrgPendingInvitation = + userInvitationRepository.findFirstByInvitationEmailAndStatus( + emailAddr, InvitationStatus.PENDING); + + if (otherOrgPendingInvitation.isPresent() + && !otherOrgPendingInvitation.get().getOrgId().equals(orgId)) { + throw new IllegalArgumentException("해당 이메일은 다른 기관에서 초대 진행 중입니다."); + } + } + + /** + * PTL_PROPERTY에서 초대 만료 기간을 가져옵니다. + * 설정이 없으면 기본값 7일을 반환합니다. + * + * @return 초대 만료 기간 + */ + private Duration getInvitationExpiration() { + try { + Map properties = portalPropertyService.getPortalPropertiesAsMap("invitation"); + String expirationStr = properties.get("expiration"); + + if (expirationStr != null && !expirationStr.trim().isEmpty()) { + return DurationParser.parseOrDefault(expirationStr, Duration.ofDays(7)); + } + } catch (Exception e) { + // 설정을 가져오는 데 실패하면 기본값 사용 + } + + // 기본값: 7일 + return Duration.ofDays(7); + } } diff --git a/src/main/java/com/eactive/apim/portal/common/util/DurationParser.java b/src/main/java/com/eactive/apim/portal/common/util/DurationParser.java new file mode 100644 index 0000000..d6fd033 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/util/DurationParser.java @@ -0,0 +1,76 @@ +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; + } + } +} diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java index 0806640..6fa089f 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java @@ -1,6 +1,9 @@ package com.eactive.apim.portal.config; import com.eactive.apim.portal.apps.user.service.PortalUserLogService; +import com.eactive.apim.portal.invitation.entity.UserInvitation; +import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus; +import com.eactive.apim.portal.invitation.repository.UserInvitationRepository; import com.eactive.apim.portal.portaluser.entity.PortalUser; import com.eactive.apim.portal.portaluser.entity.PortalUserEnums; import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory; @@ -34,6 +37,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess private final PortalUserLogService userLogService; private final UserPasswordHistoryRepository passwordHistoryRepository; private final MessageRequestRepository messageRequestRepository; + private final UserInvitationRepository userInvitationRepository; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -74,6 +78,22 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess session.setAttribute("redirectUrl", contextPath + "/new_password"); } + // 초대 코드 확인 - ROLE_USER만 확인 + if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) { + Optional pendingInvitation = + userInvitationRepository.findFirstByInvitationEmailAndStatus( + user.getLoginId(), InvitationStatus.PENDING); + + if (pendingInvitation.isPresent()) { + // 만료되지 않은 초대가 있으면 자동으로 초대 수락 페이지로 이동 + UserInvitation invitation = pendingInvitation.get(); + if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) { + response.sendRedirect(contextPath + "/signup/decision?invitation=" + invitation.getToken()); + return; + } + } + } + String decisionToken = (String) request.getSession().getAttribute("decisionToken"); if (decisionToken != null) { response.sendRedirect(contextPath + "/signup/decision_process"); diff --git a/src/main/resources/templates/views/apps/login/login.html b/src/main/resources/templates/views/apps/login/login.html index 0061ef3..9b6ccbc 100644 --- a/src/main/resources/templates/views/apps/login/login.html +++ b/src/main/resources/templates/views/apps/login/login.html @@ -70,6 +70,13 @@ 회원가입 +
  • + | +
  • +
  • + + 초대를 받으셨나요? +
  • diff --git a/src/main/resources/templates/views/apps/mypage/updatePersonalUser.html b/src/main/resources/templates/views/apps/mypage/updatePersonalUser.html index 8838544..d892edc 100644 --- a/src/main/resources/templates/views/apps/mypage/updatePersonalUser.html +++ b/src/main/resources/templates/views/apps/mypage/updatePersonalUser.html @@ -9,6 +9,21 @@

    내 정보 관리

    + +
    +
    +
    + 법인 회원 초대가 대기 중입니다 +

    기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.

    +
    + +
    +
    +
    diff --git a/src/main/resources/templates/views/apps/register/invitationCodeInput.html b/src/main/resources/templates/views/apps/register/invitationCodeInput.html new file mode 100644 index 0000000..2d1d70b --- /dev/null +++ b/src/main/resources/templates/views/apps/register/invitationCodeInput.html @@ -0,0 +1,131 @@ + + + +
    +
    +
    +

    초대 코드 입력

    +
    +
    +
    +
    +

    법인 기관으로부터 받으신 8자리 초대 코드를 입력해 주세요.

    +
    + + + + +
    +
    +

    + 초대 코드 +

    +
    +
    + + + + + + + + + + + +
    +
    +
    +
    +

    ※ 초대 코드는 이메일로 전달받으실 수 있습니다.

    +

    ※ 초대 코드는 대소문자를 구분하지 않으며, 8자리 영문자와 숫자로 구성되어 있습니다.

    +
    +
    + + +
    +

    +
    + + +
    +

    남은 시도 횟수:

    +

    5회 실패 시 15분간 입력이 제한됩니다.

    +
    + + +
    +

    잘못된 초대 코드를 5회 입력하셨습니다.

    +

    +
    + + + + + + +
    +
    +
    +
    + + + + + + diff --git a/src/main/resources/templates/views/apps/register/userDecisionAccept.html b/src/main/resources/templates/views/apps/register/userDecisionAccept.html index a7b0c5a..b04a3a6 100644 --- a/src/main/resources/templates/views/apps/register/userDecisionAccept.html +++ b/src/main/resources/templates/views/apps/register/userDecisionAccept.html @@ -18,7 +18,7 @@

    님 안녕하세요.

    에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.
    -
    Kbank와 함께 금융서비스를 손쉽게 개발해 보세요.

    +
    광주은행과 함께 금융서비스를 손쉽게 개발해 보세요.

    로그인 하여 법인회원 전환을 진행하세요.

    diff --git a/src/main/resources/templates/views/apps/users/userList.html b/src/main/resources/templates/views/apps/users/userList.html index 1452d7c..a369cea 100644 --- a/src/main/resources/templates/views/apps/users/userList.html +++ b/src/main/resources/templates/views/apps/users/userList.html @@ -67,9 +67,10 @@ 이메일 아이디 - + 초대 대기 중 @@ -103,8 +104,10 @@

    추가할 이용자 e-mail 주소를 입력해 주세요.

    - +
    @@ -208,6 +211,13 @@ return; } + // 이메일 형식 검증 + const emailPattern = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/; + if (!emailPattern.test(email)) { + customPopups.showAlert('올바른 이메일 형식을 입력해주세요. (예: user@example.com)'); + return; + } + $.ajax({ url: '/users/invite', type: 'POST', @@ -292,6 +302,7 @@ const activateButtons = document.querySelectorAll('.activate_user'); const inactivateButtons = document.querySelectorAll('.inactivate_user'); const cancelInvitationButtons = document.querySelectorAll('.cancel_invitation'); + const resendInvitationButtons = document.querySelectorAll('.resend_invitation'); activateButtons.forEach(button => { button.addEventListener('click', handleUserStatusChange); @@ -305,6 +316,10 @@ button.addEventListener('click', handleCancelInvitation); }); + resendInvitationButtons.forEach(button => { + button.addEventListener('click', handleResendInvitation); + }); + function handleUserStatusChange(event) { const row = event.target.closest('tr'); const userId = row.querySelector('a').getAttribute('href').split('=')[1]; @@ -361,6 +376,38 @@ }); } + function handleResendInvitation(event) { + const row = event.target.closest('tr'); + const userId = row.querySelector('td[data-id]').getAttribute('data-id'); + const userName = row.querySelector('td:nth-child(3)').textContent; + + customPopups.showConfirm(`${userName}에게 초대를 재발송하시겠습니까?`, function (selection) { + if (selection) { + $.ajax({ + url: '/users/resend-invitation', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({id: userId}) + }).done(function (response) { + customPopups.showAlert(response.msg, function () { + location.reload(); // Reload the page to reflect changes + }); + }).fail(function (jqXHR, textStatus, errorThrown) { + let errorMessage; + try { + const errorResponse = JSON.parse(jqXHR.responseText); + errorMessage = errorResponse.msg || '초대 재발송 중 오류가 발생했습니다'; + } catch (e) { + errorMessage = '초대 재발송 중 오류가 발생했습니다: ' + errorThrown; + } + customPopups.showAlert(errorMessage); + }).always(function () { + $('#customConfirm').hide(); + }); + } + }); + } + }); From 39ca5837efdd74265f56309f464d90cdce213c71 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 25 Nov 2025 14:29:22 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=B2=95=EC=9D=B8=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=B4=88=EB=8C=80=20=EB=B0=A9=EB=B2=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +++++++- .../mermaid/invite-flow-integrated.mermaid | 118 +++++++++--------- kjb-docs/mermaid/invite-sequence.mermaid | 90 ++++++++++--- kjb-docs/mermaid/invite-state.mermaid | 42 +++++-- .../apps/main/controller/IndexController.java | 1 + .../PortalAuthenticationSuccessHandler.java | 16 ++- .../templates/views/apps/login/login.html | 7 -- .../templates/views/apps/main/index.html | 14 +++ 8 files changed, 245 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 64b2e56..5e405d9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,55 @@ 광주은행 엔터프라이즈 API 포털 관리 시스템 +## 프로젝트 클론 (Git Clone) + +아래 스크립트를 복사하여 한 번에 실행하면 모든 프로젝트를 클론합니다: + +```bash +# 변수 설정 (환경에 맞게 수정하세요) +GIT_URL="ssh://git@192.168.240.178:18081/eapim" +PROJECT_HOME="/c/eactive/workspaces/kjb-eapim-devPortal" +DEFAULT_BRANCH="jenkins_with_weblogic" + +# 프로젝트 홈 디렉토리 생성 및 이동 +mkdir -p "$PROJECT_HOME" +cd "$PROJECT_HOME" + +# 메인 프로젝트 클론 +git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-eapim.git" kjb-eapim + +# 의존 모듈 클론 (kjb-eapim 폴더와 동일 레벨) +mkdir -p eapim-online +git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-online-core-jpa.git" eapim-online/elink-online-core-jpa +git clone -b "$DEFAULT_BRANCH" "$GIT_URL/elink-portal-common.git" elink-portal-common +git clone -b "$DEFAULT_BRANCH" "$GIT_URL/kjb-safedb.git" kjb-safedb + +# 클론 후 폴더 구조 확인 +echo "=== 프로젝트 구조 ===" +ls -la "$PROJECT_HOME" +``` + +### 클론 후 폴더 구조 + +``` +$PROJECT_HOME/ +├── kjb-eapim/ # 메인 프로젝트 +│ └── eapim-portal/ # 이 프로젝트 (Portal) +├── eapim-online/ # Gateway 데이터 모델 +│ └── elink-online-core-jpa/ # JPA 엔티티 모듈 +├── elink-portal-common/ # 공통 유틸리티 +└── kjb-safedb/ # SafeDB 암호화 라이브러리 +``` + +### 의존 관계 (settings.gradle 참조) + +``` +eapim-portal +├── elink-online-core-jpa → ../eapim-online/elink-online-core-jpa +├── elink-portal-common → ../elink-portal-common +└── kjb-safedb → ../kjb-safedb +``` + ## 프로젝트 개요 **EAPIM Portal**은 광주은행의 API 서비스를 관리하고, 사용자 등록, API 키 발급, 문서화, 테스트 기능을 제공하는 웹 기반 플랫폼입니다. @@ -136,7 +185,3 @@ docker run -p 8080:8080 eapim-portal:latest - **프로젝트 상세 지침**: `CLAUDE.md` (한글) - **사용자 가이드**: `개발자포탈.md` (한글) - **빌드 스크립트**: `build-gf63.sh`, `deploy_portal.sh` - -## 라이선스 - -© 광주은행 (Kwangju Bank) diff --git a/kjb-docs/mermaid/invite-flow-integrated.mermaid b/kjb-docs/mermaid/invite-flow-integrated.mermaid index aa267a6..c5d5f1d 100644 --- a/kjb-docs/mermaid/invite-flow-integrated.mermaid +++ b/kjb-docs/mermaid/invite-flow-integrated.mermaid @@ -1,99 +1,105 @@ flowchart TD - Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성
    예: K7MNPQ2R
    UserInvitation 저장] + Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성
    예: K7MNPQ2R
    UserInvitation 저장
    상태: PENDING] GenerateToken --> SendEmail[이메일 발송
    - 링크 포함
    - 8글자 토큰 표시] SendEmail --> UserType{사용자 유형} + %% ======================================== %% 신규 회원 플로우 + %% ======================================== UserType -->|신규 회원
    미가입자| NewUserPath{초대 확인 방법} - NewUserPath -->|폐쇄망 환경| LoginPage1[로그인 페이지 접근] - LoginPage1 --> InviteLink1["초대를 받으셨나요?" 링크 클릭] - InviteLink1 --> InputCodePage1[초대코드 입력 페이지] - InputCodePage1 --> InputCode1[8자리 코드 입력
    예: K7MNPQ2R] - InputCode1 --> ValidateCode1{코드 유효성 검증} - - ValidateCode1 -->|유효하지 않음| Error1[에러: 유효하지 않은 초대코드] - ValidateCode1 -->|유효| SignupPage1[회원가입 페이지
    /signup/portalUser?invitation=CODE] - NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭
    /signup/portalUser?invitation=CODE] - EmailLink1 --> SignupPage1 + NewUserPath -->|일반 회원가입| DirectSignup[일반 회원가입 진행
    ROLE_USER로 생성] - SignupPage1 --> RegisterForm[가입 정보 입력
    - 이메일 자동 입력됨
    - 법인명 표시
    - 약관 동의] - RegisterForm --> RegisterComplete[회원가입 완료
    ROLE_CORP_USER로 생성] + EmailLink1 --> SignupPage1[회원가입 페이지
    - 이메일 자동 입력됨
    - 법인명 표시] + SignupPage1 --> RegisterForm[가입 정보 입력
    약관 동의] + RegisterForm --> RegisterComplete[회원가입 완료
    ROLE_CORP_USER로 생성
    상태: COMPLETED] RegisterComplete --> NewUserEnd([가입 완료 안내]) + DirectSignup --> Login4[로그인] + Login4 --> AfterLogin4[로그인 성공] + AfterLogin4 --> CheckInvitation + + %% ======================================== %% 기존 회원 플로우 + %% ======================================== UserType -->|기존 회원
    ROLE_USER| ExistingUserPath{초대 확인 방법} - ExistingUserPath -->|폐쇄망 환경| LoginPage2[로그인 페이지 접근] - LoginPage2 --> InviteLink2["초대를 받으셨나요?" 링크 클릭] - InviteLink2 --> InputCodePage2[초대코드 입력 페이지] - InputCodePage2 --> CheckLogin1{로그인 여부} + ExistingUserPath -->|이메일 링크
    비로그인 상태| EmailLink2[이메일 링크 클릭
    /signup/decision?invitation=CODE] + ExistingUserPath -->|이메일 링크
    로그인 상태| EmailLinkLoggedIn[이메일 링크 클릭
    /signup/decision?invitation=CODE] + ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인] - CheckLogin1 -->|미로그인| RedirectLogin1[로그인 페이지로 리다이렉트
    코드 저장] - RedirectLogin1 --> Login1[로그인] - Login1 --> AfterLogin1[로그인 성공] - - CheckLogin1 -->|로그인됨| InputCode2[8자리 코드 입력] - InputCode2 --> ValidateCode2{코드 유효성 검증} - ValidateCode2 -->|유효하지 않음| Error2[에러: 유효하지 않은 초대코드] - ValidateCode2 -->|유효| DecisionPage1[초대 수락 페이지
    /signup/decision_process] - - ExistingUserPath -->|이메일 링크| EmailLink2[이메일 링크 클릭
    /signup/decision?invitation=CODE] + %% 비로그인 상태에서 이메일 링크 클릭 EmailLink2 --> DecisionPagePublic[초대 수락 페이지
    비로그인 상태] DecisionPagePublic --> RedirectLogin2[로그인 유도] RedirectLogin2 --> Login2[로그인] Login2 --> AfterLogin2[로그인 성공] - ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인] + %% 로그인 상태에서 이메일 링크 클릭 → 바로 수락 페이지 + EmailLinkLoggedIn --> DecisionPageDirect[초대 수락 페이지
    /signup/decision_process
    약관 동의] + DecisionPageDirect --> UserDecision + + %% 직접 로그인 DirectLogin --> Login3[로그인] Login3 --> AfterLogin3[로그인 성공] - %% 로그인 후 자동 초대 확인 - AfterLogin1 --> CheckInvitation[초대 코드 자동 확인
    findFirstByInvitationEmailAndStatus] - AfterLogin2 --> CheckInvitation + %% ======================================== + %% 로그인 후 초대 확인 (PortalAuthenticationSuccessHandler) + %% ======================================== + AfterLogin2 --> CheckInvitation[PortalAuthenticationSuccessHandler
    PENDING 초대 확인
    findFirstByInvitationEmailAndStatus] AfterLogin3 --> CheckInvitation - CheckInvitation --> HasInvitation{초대 코드 존재?} + CheckInvitation --> HasInvitation{PENDING 초대 존재?} - HasInvitation -->|없음| NormalHome[일반 홈페이지] - HasInvitation -->|있음| AutoRedirect[자동으로 초대 수락 페이지로 이동
    /signup/decision_process] + HasInvitation -->|없음| NormalHome[메인 페이지 이동] + HasInvitation -->|있음| SaveToSession[세션에 초대 정보 저장
    pendingInvitation=true
    pendingInvitationToken
    pendingInvitationOrgName] - AutoRedirect --> DecisionPage1 - DecisionPage1 --> UserDecision{사용자 선택} + SaveToSession --> RedirectHome[메인 페이지로 이동] - UserDecision -->|수락| AcceptProcess[초대 수락 처리
    1. PortalOrg 변경
    2. ROLE_CORP_USER 전환
    3. 약관 재동의] - UserDecision -->|거절| RejectProcess[초대 거절 처리
    Invitation.status = REJECTED] + %% ======================================== + %% 메인 페이지 팝업 (index.html) + %% ======================================== + RedirectHome --> ShowPopup[팝업 표시
    OOO에서 초대가 도착했습니다
    초대를 확인하시겠습니까?] - AcceptProcess --> CompleteInvitation[Invitation.status = COMPLETED] - CompleteInvitation --> LogoutPrompt[로그아웃 후 재로그인 안내] + ShowPopup --> PopupChoice{사용자 선택} + + PopupChoice -->|확인| GoDecisionPage[초대 수락 페이지 이동
    /signup/decision?invitation=TOKEN] + PopupChoice -->|취소| StayHome[메인 페이지 유지
    다음 로그인 시 팝업 재표시] + + StayHome --> NextLogin[다음 로그인] + NextLogin --> CheckInvitation + + GoDecisionPage --> DecisionPage[초대 수락 페이지
    /signup/decision_process
    약관 동의] + + DecisionPage --> UserDecision{사용자 선택} + + UserDecision -->|수락| AcceptProcess[초대 수락 처리
    1. PortalOrg 변경
    2. ROLE_CORP_USER 전환
    3. 약관 재동의
    상태: COMPLETED] + UserDecision -->|거절| RejectProcess[초대 거절 처리
    상태: REJECTED] + + AcceptProcess --> LogoutPrompt[로그아웃 후 재로그인 안내] LogoutPrompt --> ExistingUserEnd([초대 수락 완료]) - RejectProcess --> RejectEnd([초대 거절 완료]) - - %% 에러 처리 - Error1 --> ErrorEnd([처리 종료]) - Error2 --> ErrorEnd + RejectProcess --> RejectEnd([초대 거절 완료
    다음 로그인 시 팝업 안 뜸]) + %% ======================================== %% 스타일링 + %% ======================================== style Start fill:#e1f5e1 style NewUserEnd fill:#c8e6c9 style ExistingUserEnd fill:#c8e6c9 style RejectEnd fill:#fff9c4 - style ErrorEnd fill:#ffcdd2 - style Error1 fill:#ffcdd2 - style Error2 fill:#ffcdd2 + style NormalHome fill:#e3f2fd style GenerateToken fill:#bbdefb style AcceptProcess fill:#c5cae9 - style CheckInvitation fill:#ffe0b2 - style AutoRedirect fill:#ffe0b2 + style RejectProcess fill:#ffecb3 - style LoginPage1 fill:#e1bee7 - style LoginPage2 fill:#e1bee7 - style InviteLink1 fill:#f8bbd0 - style InviteLink2 fill:#f8bbd0 - style InputCodePage1 fill:#f8bbd0 - style InputCodePage2 fill:#f8bbd0 + style CheckInvitation fill:#ffe0b2 + style SaveToSession fill:#ffe0b2 + style ShowPopup fill:#f8bbd0 + style PopupChoice fill:#f8bbd0 + + style StayHome fill:#e1bee7 + style NextLogin fill:#e1bee7 diff --git a/kjb-docs/mermaid/invite-sequence.mermaid b/kjb-docs/mermaid/invite-sequence.mermaid index 5d3214c..5204e6e 100644 --- a/kjb-docs/mermaid/invite-sequence.mermaid +++ b/kjb-docs/mermaid/invite-sequence.mermaid @@ -6,10 +6,17 @@ sequenceDiagram participant MessageService as Message Handler participant Email as Email System actor 초대받은사용자 + participant AuthHandler as PortalAuthenticationSuccessHandler + participant IndexController + participant MainPage as 메인 페이지 (index.html) participant UserRegisterController participant UserRegisterFacade participant PortalUserRepo as PortalUser Repository + participant PortalOrgRepo as PortalOrg Repository + %% ======================================== + %% 1. 초대 발송 + %% ======================================== 법인관리자->>Portal: 사용자 이메일 입력
    초대 요청 Portal->>UserManFacade: sendInvitation(sender, emailAddr) @@ -17,7 +24,7 @@ sequenceDiagram alt 사용자가 없거나 이미 법인 사용자 UserManFacade-->>Portal: 에러 반환 Portal-->>법인관리자: 초대 불가 알림 - else 일반 사용자 ROLE_USER + else 일반 사용자 ROLE_USER 또는 비회원 UserManFacade->>UserManFacade: generateEightCharToken()
    예: K7MNPQ2R UserManFacade->>UserInvitationRepo: save(UserInvitation)
    status: PENDING
    expiresOn: 7일 후 UserManFacade->>MessageService: publishEvent(UserInvitationEvent) @@ -28,25 +35,74 @@ sequenceDiagram Email->>초대받은사용자: 초대 이메일 수신 - alt 폐쇄망 환경 - 초대받은사용자->>Portal: 8글자 토큰 직접 입력
    /signup/decision?invitation=K7MNPQ2R - else 이메일 링크 - 초대받은사용자->>Portal: 링크 클릭
    /signup/decision?invitation=BASE64TOKEN + %% ======================================== + %% 2-A. 로그인 상태에서 이메일 링크 클릭 (바로 수락 페이지) + %% ======================================== + alt 로그인 상태에서 이메일 링크 클릭 + 초대받은사용자->>Portal: 이메일 링크 클릭
    /signup/decision?invitation=TOKEN + Portal->>UserRegisterController: showDecisionPage(invitationToken) + UserRegisterController->>UserInvitationRepo: findByToken(decodedToken) + UserRegisterController-->>Portal: 약관 동의 페이지 + Portal-->>초대받은사용자: 초대 수락/거절 화면
    (바로 표시, 팝업 없음) + Note over 초대받은사용자: 6. 초대 수락/거절 처리로 이동 end - Portal->>UserRegisterController: showDecisionPage(invitationToken) - UserRegisterController->>UserRegisterController: decodeInvitationToken()
    (Base64 or 직접입력 처리) - UserRegisterController->>UserInvitationRepo: findByToken(decodedToken) - - alt 토큰 무효 or 상태가 PENDING 아님 - UserRegisterController-->>Portal: 에러: 유효하지 않은 초대 - Portal-->>초대받은사용자: 에러 페이지 - else 토큰 유효 - UserRegisterController->>PortalUserRepo: findByEmailAddr() - UserRegisterController-->>Portal: 약관 동의 페이지
    (법인명, 사용자명 표시) - Portal-->>초대받은사용자: 수락/거절 선택 화면 + %% ======================================== + %% 2-B. 로그인 (기존 회원 또는 신규 가입 후) + %% ======================================== + alt 기존 회원 (비로그인 상태) + 초대받은사용자->>Portal: 로그인 + else 비회원이 일반 회원가입 후 + 초대받은사용자->>Portal: 회원가입 (ROLE_USER) + 초대받은사용자->>Portal: 로그인 end + %% ======================================== + %% 3. 로그인 성공 처리 (AuthenticationSuccessHandler) + %% ======================================== + Portal->>AuthHandler: onAuthenticationSuccess() + AuthHandler->>AuthHandler: 이메일 인증, 휴면계정,
    비밀번호 만료 체크 + + AuthHandler->>UserInvitationRepo: findFirstByInvitationEmailAndStatus
    (loginId, PENDING) + + alt PENDING 초대 없음 + AuthHandler->>Portal: redirect to / + Portal-->>초대받은사용자: 메인 페이지 (팝업 없음) + else PENDING 초대 있음 & 만료 안됨 + AuthHandler->>PortalOrgRepo: findById(invitation.orgId) + PortalOrgRepo-->>AuthHandler: PortalOrg (기관명) + AuthHandler->>AuthHandler: session.setAttribute
    ("pendingInvitation", true)
    ("pendingInvitationToken", token)
    ("pendingInvitationOrgName", orgName) + AuthHandler->>Portal: redirect to / + end + + %% ======================================== + %% 4. 메인 페이지 팝업 표시 + %% ======================================== + Portal->>IndexController: GET / + IndexController->>MainPage: render index.html + + MainPage->>MainPage: 세션에서 pendingInvitation 확인 + + alt pendingInvitation == true + MainPage-->>초대받은사용자: 팝업 표시
    "[기관명]에서 초대가 도착했습니다.
    초대를 확인하시겠습니까?" + end + + %% ======================================== + %% 5. 사용자 팝업 선택 + %% ======================================== + alt 팝업에서 "확인" 클릭 + 초대받은사용자->>Portal: /signup/decision?invitation=TOKEN + Portal->>UserRegisterController: showDecisionPage(invitationToken) + UserRegisterController->>UserInvitationRepo: findByToken(decodedToken) + UserRegisterController-->>Portal: 약관 동의 페이지 + Portal-->>초대받은사용자: 초대 수락/거절 화면 + else 팝업에서 "취소" 클릭 + Note over 초대받은사용자,MainPage: 메인 페이지 유지
    다음 로그인 시 팝업 재표시 + end + + %% ======================================== + %% 6. 초대 수락/거절 처리 + %% ======================================== 초대받은사용자->>Portal: 수락 or 거절 선택 Portal->>UserRegisterController: processInvitation(action) UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation) @@ -57,8 +113,10 @@ sequenceDiagram UserRegisterFacade->>UserInvitationRepo: save(invitation)
    status: COMPLETED
    completeDate: now() UserRegisterFacade-->>Portal: 수락 성공 Portal-->>초대받은사용자: 성공 메시지
    로그아웃 후 재로그인 안내 + Note over 초대받은사용자: 다음 로그인 시
    PENDING 초대 없음 → 팝업 안 뜸 else 거절 UserRegisterFacade->>UserInvitationRepo: save(invitation)
    status: REJECTED
    completeDate: now() UserRegisterFacade-->>Portal: 거절 처리 완료 Portal-->>초대받은사용자: 거절 완료 메시지 + Note over 초대받은사용자: 다음 로그인 시
    REJECTED 상태 → 팝업 안 뜸 end diff --git a/kjb-docs/mermaid/invite-state.mermaid b/kjb-docs/mermaid/invite-state.mermaid index 79bba32..1699fec 100644 --- a/kjb-docs/mermaid/invite-state.mermaid +++ b/kjb-docs/mermaid/invite-state.mermaid @@ -1,20 +1,44 @@ stateDiagram-v2 [*] --> 초대생성: 법인관리자가 초대 시작 - 초대생성 --> PENDING: 8글자 토큰 생성
    UserInvitation 저장 + 초대생성 --> PENDING: 토큰 생성 및 저장 - PENDING --> 토큰전달: 이메일 발송 + PENDING --> 이메일발송: 초대 이메일 발송 - 토큰전달 --> 사용자접근: 폐쇄망 직접입력 or
    이메일 링크 클릭 + state 로그인후팝업 { + 이메일발송 --> 로그인: 사용자 로그인 + 로그인 --> 초대확인: AuthSuccessHandler + 초대확인 --> 세션저장: 세션에 초대 정보 저장 + 세션저장 --> 팝업표시: 메인 페이지 팝업 + 팝업표시 --> 팝업확인: 확인 클릭 + 팝업표시 --> 팝업취소: 취소 클릭 + 팝업취소 --> 로그인: 다음 로그인 시 재표시 + } - 사용자접근 --> 약관동의: 토큰 유효성 검증 성공 - 사용자접근 --> 에러: 토큰 무효 + 팝업확인 --> 약관동의: 수락 페이지 이동 - 약관동의 --> COMPLETED: 사용자 수락
    ROLE_CORP_USER 전환
    PortalOrg 변경 - 약관동의 --> REJECTED: 사용자 거절 + 약관동의 --> COMPLETED: 수락 + 약관동의 --> REJECTED: 거절 COMPLETED --> [*]: 초대 완료 REJECTED --> [*]: 초대 거절 - PENDING --> CANCELED: 관리자가 초대 취소 + PENDING --> CANCELED: 관리자 취소 + PENDING --> EXPIRED: 만료일 경과 CANCELED --> [*]: 초대 취소 - 에러 --> [*]: 처리 종료 + EXPIRED --> [*]: 초대 만료 + + note right of PENDING + PENDING 상태일 때만 + 로그인 시 팝업 표시 + end note + + note right of REJECTED + REJECTED 상태가 되면 + 더 이상 팝업 표시 안 함 + end note + + note right of COMPLETED + COMPLETED 상태가 되면 + 더 이상 팝업 표시 안 함 + ROLE_CORP_USER로 전환됨 + end note diff --git a/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java b/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java index 2a51fab..2b70e66 100644 --- a/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java +++ b/src/main/java/com/eactive/apim/portal/apps/main/controller/IndexController.java @@ -19,6 +19,7 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; + @Controller @RequiredArgsConstructor public class IndexController { diff --git a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java index 6fa089f..736e4f8 100644 --- a/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java +++ b/src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java @@ -1,9 +1,11 @@ package com.eactive.apim.portal.config; +import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository; import com.eactive.apim.portal.apps.user.service.PortalUserLogService; import com.eactive.apim.portal.invitation.entity.UserInvitation; import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus; 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.entity.UserPasswordHistory; @@ -38,6 +40,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess private final UserPasswordHistoryRepository passwordHistoryRepository; private final MessageRequestRepository messageRequestRepository; private final UserInvitationRepository userInvitationRepository; + private final PortalOrgRepository portalOrgRepository; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -78,18 +81,23 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess session.setAttribute("redirectUrl", contextPath + "/new_password"); } - // 초대 코드 확인 - ROLE_USER만 확인 + // 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시) if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) { Optional pendingInvitation = userInvitationRepository.findFirstByInvitationEmailAndStatus( user.getLoginId(), InvitationStatus.PENDING); if (pendingInvitation.isPresent()) { - // 만료되지 않은 초대가 있으면 자동으로 초대 수락 페이지로 이동 UserInvitation invitation = pendingInvitation.get(); if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) { - response.sendRedirect(contextPath + "/signup/decision?invitation=" + invitation.getToken()); - return; + // 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시) + session.setAttribute("pendingInvitation", true); + session.setAttribute("pendingInvitationToken", invitation.getToken()); + // orgId로 기관명 조회 + String orgName = portalOrgRepository.findById(invitation.getOrgId()) + .map(PortalOrg::getOrgName) + .orElse("알 수 없는 기관"); + session.setAttribute("pendingInvitationOrgName", orgName); } } } diff --git a/src/main/resources/templates/views/apps/login/login.html b/src/main/resources/templates/views/apps/login/login.html index 9b6ccbc..fa154a7 100644 --- a/src/main/resources/templates/views/apps/login/login.html +++ b/src/main/resources/templates/views/apps/login/login.html @@ -70,13 +70,6 @@ 회원가입 -
  • - | -
  • -
  • - - 초대를 받으셨나요? -
  • +
    +

    + 초대코드: + +

    +

    + 이메일로 받은 초대코드와 일치하는지 확인해 주세요. +

    +
    +
    +

    + 초대코드: + +

    +

    + 이메일로 받은 초대코드와 일치하는지 확인해 주세요. +

    +