46 KiB
초대 코드 직접 입력 기능 구현 계획
작성일: 2025-11-24 목적: 폐쇄망 환경에서 이메일 링크를 사용할 수 없는 사용자를 위한 8자리 초대코드 직접 입력 기능 추가
📌 개요
배경
- 현재 초대 시스템은 이메일 링크 방식만 지원
- 폐쇄망 환경에서는 이메일 링크 클릭이 불가능
- 8자리 토큰(예: K7MNPQ2R)을 생성하고 있지만 직접 입력 UI가 없음
목표
- 로그인 페이지에 "초대를 받으셨나요?" 링크 추가
- 초대코드 직접 입력 페이지 생성
- 기존 회원 로그인 시 자동으로 초대 확인 및 리다이렉트
🎯 구현 대상 사용자 플로우
신규 회원 (미가입자)
로그인 페이지 → "초대를 받으셨나요?" 클릭 → 초대코드 입력 → 검증 성공 → 회원가입 페이지
기존 회원 (ROLE_USER)
방법1: 로그인 페이지 → "초대를 받으셨나요?" 클릭 → 로그인 유도 → 로그인 → 자동 초대 확인 → 초대 수락 페이지
방법2: 로그인 페이지 → 로그인 → 자동 초대 확인 → 초대 수락 페이지
🛠 작업 목록
0. 마이페이지 초대 알림 (재방문 사용자 대응)
로그인 후 자동 리다이렉트를 놓친 사용자나, 페이지를 벗어났다가 다시 돌아온 사용자를 위해 마이페이지에서 초대 알림을 표시합니다.
0.1 AccountController 수정
파일: src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java
GET /mypage 메서드 수정 (119-149라인):
@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);
// ... 기존 코드 계속
}
}
추가 필드:
private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;
예상 작업량: 20-25줄 추가
0.2 마이페이지 레이아웃 수정 (옵션 1: 공통 알림)
파일: src/main/resources/templates/views/layout/kbank_mypage_layout.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 스타일:
.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라인 다음):
<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 스타일:
.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: 로그인 후 초대 자동 확인 놓침
- 로그인 시 초대 자동 리다이렉트 발생
- 사용자가 뒤로가기나 다른 페이지 이동
- 마이페이지 접속
- 상단에 초대 알림 배너 표시 ✅
- "초대 확인하기" 클릭 → /signup/decision_process 이동
시나리오 2: 세션 만료 후 재로그인
- 로그인 세션 만료
- 재로그인
- 자동 리다이렉트 없음 (세션 초기화)
- 마이페이지 접속
- 초대 알림 표시 ✅
시나리오 3: 초대 수락 후 알림 제거
- 마이페이지에서 초대 알림 확인
- "초대 확인하기" 클릭 → 초대 수락
- 마이페이지 재방문
- 초대 알림 없음 ✅ (PENDING 상태 아님)
시나리오 4: 법인 회원은 알림 표시 안 됨
- ROLE_CORP_USER로 로그인
- 마이페이지 접속
- 초대 알림 없음 ✅ (ROLE_USER만 표시)
1. 프론트엔드 (Thymeleaf Templates)
1.1 로그인 페이지 수정
파일: src/main/resources/templates/views/apps/login/login.html
수정 위치: 53-73라인 login_list 영역
작업 내용:
<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줄
예시 구조:
<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
@GetMapping("/signup/invitation-code")
public String showInvitationCodeInput(HttpSession session, Model model) {
// 초대코드 입력 페이지 표시
return "apps/register/invitationCodeInput";
}
2.1.2 POST /signup/invitation-code
@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라인)
추가 로직:
@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 + "/");
}
}
추가 필드:
private final UserInvitationRepository userInvitationRepository;
예상 작업량: 15줄 추가
3. 보안 설정 (선택사항)
3.1 PortalConfigSecurity 확인
파일: src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java
확인 사항:
/signup/invitation-code경로가 인증 없이 접근 가능한지 확인- CSRF 토큰이 적용되는지 확인
예상 설정 (이미 /signup/**이 허용되어 있을 가능성 높음):
.antMatchers("/signup/**").permitAll()
🔍 테스트 계획
단위 테스트
3.1 Controller 테스트
@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 테스트
@Test
void testAutoRedirectWithPendingInvitation() {
// ROLE_USER 로그인 시 초대가 있으면 자동 리다이렉트
}
@Test
void testNoAutoRedirectForCorpUser() {
// ROLE_CORP_USER는 자동 리다이렉트 안됨
}
통합 테스트 시나리오
시나리오 1: 신규 회원 초대코드 입력
- 로그인 페이지 접속
- "초대를 받으셨나요?" 클릭
- 초대코드 입력 (예: K7MNPQ2R)
- 회원가입 페이지로 이동
- 가입 완료
예상 결과: ROLE_CORP_USER로 가입 완료
시나리오 2: 기존 회원 초대코드 입력 후 로그인
- 로그인 페이지 접속
- "초대를 받으셨나요?" 클릭
- 초대코드 입력 (예: K7MNPQ2R)
- "로그인하여 초대를 수락해주세요" 메시지
- 로그인 페이지로 리다이렉트
- 로그인
- 자동으로 초대 수락 페이지로 이동
- 약관 동의 후 수락
- 로그아웃 후 재로그인
예상 결과: ROLE_USER → ROLE_CORP_USER 전환 완료
시나리오 3: 기존 회원 직접 로그인 (초대 자동 확인)
- 로그인 페이지 접속
- 로그인 (초대받은 이메일로)
- 자동으로 초대 수락 페이지로 리다이렉트
- 약관 동의 후 수락
예상 결과: 초대 수락 완료
시나리오 4: 유효하지 않은 초대코드
- 초대코드 입력 페이지 접속
- 존재하지 않는 코드 입력 (예: INVALID1)
- "유효하지 않은 초대코드입니다" 에러 메시지
- 입력 페이지 유지
예상 결과: 에러 메시지 표시, 재입력 가능
시나리오 5: 만료된 초대코드
- 초대코드 입력 페이지 접속
- 7일 이상 경과한 코드 입력
- "만료된 초대코드입니다" 에러 메시지
예상 결과: 에러 메시지 표시
⚠️ 주의사항 및 고려사항
보안
- CSRF 토큰 적용 확인
- SQL Injection 방지 (JPA 사용으로 기본 방어)
- 초대코드 Brute Force 공격 방지 (세션 기반 실패 횟수 제한) - 아래 상세 구현 방법 참조
- 세션 하이재킹 방지 (기존 Spring Security로 대응)
- 이메일 형식 검증 (프론트엔드 + 백엔드 이중 검증) ✅ 구현 완료
사용성
- 입력 필드 자동 대문자 변환
- 8자리 제한 및 유효성 검증 (클라이언트 + 서버)
- 명확한 에러 메시지
- 모바일 반응형 디자인
우선순위 충돌 처리
PortalAuthenticationSuccessHandler에서 여러 조건 체크 순서:
- 이메일 인증 필요 (
READY상태) - 휴면 계정 (
DORMANT상태) - 임시 비밀번호 로그인
- 비밀번호 만료 (90일)
- → [새로 추가] 초대 코드 확인 ← ROLE_USER만 해당
- decisionToken 세션 체크
- 일반 홈페이지
결정: 비밀번호 관련 체크 이후, 일반 홈 이동 전에 초대 확인
📊 작업 규모 요약
| 항목 | 파일 수 | 예상 코드량 | 난이도 | 예상 시간 |
|---|---|---|---|---|
| 마이페이지 초대 알림 | 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. 세션 속성 정의
// 세션에 저장할 키
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 메서드 수정:
@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에 추가:
<!-- 차단 상태 표시 -->
<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라인):
// 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
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라인):
// Set expiration date (e.g., 7 days from now)
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));
변경 후:
// ===== 새로 추가 =====
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;
}
추가 필드:
private final PortalPropertyService portalPropertyService;
예상 작업량: 10줄 추가
PTL_PROPERTY 테이블 설정
데이터 삽입 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)
에러 처리
잘못된 형식 입력 시
// PTL_PROPERTY에 잘못된 값이 있는 경우
// 예: "invalid", "7", "d7", "7dd"
Duration duration = DurationParser.parseOrDefault(expirationDuration, Duration.ofDays(7));
// → 파싱 실패 시 기본값 7일 사용
로그 출력 (선택사항):
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라인 (기존 사용자 확인 로직 다음)
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
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라인)
현재 에러 처리 코드:
.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.htmlsrc/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.javasrc/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java
📌 후속 작업 (선택사항)
Brute Force 방지: 초대코드 입력 실패 횟수 제한✅ 상세 구현 계획 추가 (세션 기반)이메일 검증: 이메일 형식 검증✅ 구현 완료중복 초대 방지: PENDING 초대 중복 체크✅ 상세 구현 계획 추가초대 만료일 동적 설정: PTL_PROPERTY 기반 만료일 설정✅ 상세 구현 계획 추가- 관리자 알림: 초대코드 입력 시도 로그 기록
- 통계: 초대코드 직접 입력 vs 이메일 링크 사용 비율 추적
- QR 코드: 초대코드를 QR 코드로 제공 (모바일 스캔)
- 초대 재발송: 만료 전 초대 재발송 기능 (UI 버튼)
- 만료일 연장: 주 단위(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): 초기 작성