1467 lines
46 KiB
Markdown
1467 lines
46 KiB
Markdown
# 초대 코드 직접 입력 기능 구현 계획
|
|
|
|
**작성일**: 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<UserInvitation> pendingInvitation =
|
|
userInvitationRepository.findFirstByInvitationEmailAndStatus(
|
|
currentUser.getLoginId(), InvitationStatus.PENDING);
|
|
|
|
if (pendingInvitation.isPresent()) {
|
|
// 초대 정보를 모델에 추가
|
|
mav.addObject("hasPendingInvitation", true);
|
|
|
|
// 초대한 법인명 조회
|
|
String orgId = pendingInvitation.get().getOrgId();
|
|
Optional<PortalOrg> 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
|
|
<header th:replace="fragment/kbank/header :: headerFragment"></header>
|
|
|
|
<!-- 초대 알림 배너 (모든 마이페이지에서 표시) -->
|
|
<div th:if="${hasPendingInvitation}" class="invitation-alert-banner">
|
|
<div class="alert alert-info invitation-banner">
|
|
<div class="invitation-content">
|
|
<span class="icon">📩</span>
|
|
<div class="message">
|
|
<strong th:text="${invitationOrgName}">법인명</strong>에서 귀하를 법인회원으로 초대하였습니다.
|
|
</div>
|
|
<a th:href="@{/signup/decision_process}" class="btn btn-primary btn-sm">
|
|
초대 확인하기
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section layout:fragment="contentFragment">
|
|
</section>
|
|
```
|
|
|
|
**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
|
|
<div class="sub_title3">
|
|
<h2 class="title">내 정보 관리</h2>
|
|
</div>
|
|
|
|
<!-- 초대 알림 -->
|
|
<div th:if="${hasPendingInvitation}" class="alert alert-info invitation-alert">
|
|
<h4>📩 법인회원 초대</h4>
|
|
<p>
|
|
<strong th:text="${invitationOrgName}">법인명</strong>에서 귀하를 법인회원으로 초대하였습니다.
|
|
</p>
|
|
<div class="btn_wrap mt-3">
|
|
<a th:href="@{/signup/decision_process}" class="common_btn_type_1">
|
|
<span>초대 확인하기</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="inner i_cs h_inner8">
|
|
<!-- 기존 폼 내용 -->
|
|
</div>
|
|
```
|
|
|
|
**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
|
|
<li class="icon_invite">
|
|
<img th:src="@{/img/icon/icon_login_invite.png}" alt="">
|
|
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
|
|
</li>
|
|
<li>
|
|
<span>|</span>
|
|
</li>
|
|
```
|
|
|
|
**예상 작업량**: 10줄 추가
|
|
|
|
---
|
|
|
|
#### 1.2 초대코드 입력 페이지 생성 (신규)
|
|
**파일**: `src/main/resources/templates/views/apps/register/invitationCodeInput.html`
|
|
|
|
**작업 내용**:
|
|
- 8자리 초대코드 입력 폼
|
|
- 대문자 자동 변환 (JavaScript)
|
|
- CSRF 토큰 포함
|
|
- 검증 실패 시 에러 메시지 표시
|
|
|
|
**UI 요구사항**:
|
|
- 입력 필드: 8자리 영문+숫자 (예: K7MNPQ2R)
|
|
- 실시간 대문자 변환
|
|
- 제출 버튼: "확인"
|
|
- 취소 버튼: "돌아가기" (로그인 페이지로)
|
|
|
|
**예상 작업량**: 100-120줄
|
|
|
|
**예시 구조**:
|
|
```html
|
|
<form th:action="@{/signup/invitation-code}" method="post">
|
|
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
|
|
|
|
<div class="form_type_box">
|
|
<div class="info1">
|
|
<p class="title">초대코드</p>
|
|
<div class="info_line">
|
|
<input type="text" name="invitationCode"
|
|
maxlength="8"
|
|
placeholder="8자리 초대코드 입력 (예: K7MNPQ2R)"
|
|
pattern="[A-Z0-9]{8}"
|
|
required>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="btn_login">
|
|
<button type="submit">확인</button>
|
|
</div>
|
|
</form>
|
|
```
|
|
|
|
**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<UserInvitation> 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<PortalUser> 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<UserInvitation> 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<UserInvitation> 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<PortalUser> 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
|
|
<!-- 차단 상태 표시 -->
|
|
<div th:if="${error != null and error.contains('차단')}" class="alert alert-danger">
|
|
<strong>⚠️ 보안 경고</strong>
|
|
<p th:text="${error}"></p>
|
|
<p>잠시 후 다시 시도해주세요.</p>
|
|
</div>
|
|
|
|
<!-- 일반 에러 표시 (남은 시도 횟수 포함) -->
|
|
<div th:if="${error != null and !error.contains('차단')}" class="alert alert-warning">
|
|
<p th:text="${error}"></p>
|
|
</div>
|
|
```
|
|
|
|
#### 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: <number><unit> (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<String, String> 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<PortalUser> 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<UserInvitation> 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<UserInvitation> 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<UserInvitation, String> {
|
|
|
|
// 기존 메서드
|
|
Optional<UserInvitation> findFirstByInvitationEmailAndStatus(
|
|
String invitationEmail,
|
|
InvitationStatus status
|
|
);
|
|
|
|
// 새로 추가: 같은 법인 내 중복 체크
|
|
Optional<UserInvitation> 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)
|
|
- ✅ `<input type="email">` 적용
|
|
- ✅ 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): 초기 작성
|