법인 회원 초대 대응

This commit is contained in:
Rinjae
2025-11-24 22:23:04 +09:00
parent a7d94c4d92
commit d7a32e1dcf
16 changed files with 2293 additions and 20 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
flowchart TD
Start([법인 관리자가 사용자 초대]) --> GenerateToken[8글자 토큰 생성<br/>예: K7MNPQ2R<br/>UserInvitation 저장]
GenerateToken --> SendEmail[이메일 발송<br/>- 링크 포함<br/>- 8글자 토큰 표시]
SendEmail --> UserType{사용자 유형}
%% 신규 회원 플로우
UserType -->|신규 회원<br/>미가입자| NewUserPath{초대 확인 방법}
NewUserPath -->|폐쇄망 환경| LoginPage1[로그인 페이지 접근]
LoginPage1 --> InviteLink1["초대를 받으셨나요?" 링크 클릭]
InviteLink1 --> InputCodePage1[초대코드 입력 페이지]
InputCodePage1 --> InputCode1[8자리 코드 입력<br/>예: K7MNPQ2R]
InputCode1 --> ValidateCode1{코드 유효성 검증}
ValidateCode1 -->|유효하지 않음| Error1[에러: 유효하지 않은 초대코드]
ValidateCode1 -->|유효| SignupPage1[회원가입 페이지<br/>/signup/portalUser?invitation=CODE]
NewUserPath -->|이메일 링크| EmailLink1[이메일 링크 클릭<br/>/signup/portalUser?invitation=CODE]
EmailLink1 --> SignupPage1
SignupPage1 --> RegisterForm[가입 정보 입력<br/>- 이메일 자동 입력됨<br/>- 법인명 표시<br/>- 약관 동의]
RegisterForm --> RegisterComplete[회원가입 완료<br/>ROLE_CORP_USER로 생성]
RegisterComplete --> NewUserEnd([가입 완료 안내])
%% 기존 회원 플로우
UserType -->|기존 회원<br/>ROLE_USER| ExistingUserPath{초대 확인 방법}
ExistingUserPath -->|폐쇄망 환경| LoginPage2[로그인 페이지 접근]
LoginPage2 --> InviteLink2["초대를 받으셨나요?" 링크 클릭]
InviteLink2 --> InputCodePage2[초대코드 입력 페이지]
InputCodePage2 --> CheckLogin1{로그인 여부}
CheckLogin1 -->|미로그인| RedirectLogin1[로그인 페이지로 리다이렉트<br/>코드 저장]
RedirectLogin1 --> Login1[로그인]
Login1 --> AfterLogin1[로그인 성공]
CheckLogin1 -->|로그인됨| InputCode2[8자리 코드 입력]
InputCode2 --> ValidateCode2{코드 유효성 검증}
ValidateCode2 -->|유효하지 않음| Error2[에러: 유효하지 않은 초대코드]
ValidateCode2 -->|유효| DecisionPage1[초대 수락 페이지<br/>/signup/decision_process]
ExistingUserPath -->|이메일 링크| EmailLink2[이메일 링크 클릭<br/>/signup/decision?invitation=CODE]
EmailLink2 --> DecisionPagePublic[초대 수락 페이지<br/>비로그인 상태]
DecisionPagePublic --> RedirectLogin2[로그인 유도]
RedirectLogin2 --> Login2[로그인]
Login2 --> AfterLogin2[로그인 성공]
ExistingUserPath -->|직접 로그인| DirectLogin[로그인 페이지에서 로그인]
DirectLogin --> Login3[로그인]
Login3 --> AfterLogin3[로그인 성공]
%% 로그인 후 자동 초대 확인
AfterLogin1 --> CheckInvitation[초대 코드 자동 확인<br/>findFirstByInvitationEmailAndStatus]
AfterLogin2 --> CheckInvitation
AfterLogin3 --> CheckInvitation
CheckInvitation --> HasInvitation{초대 코드 존재?}
HasInvitation -->|없음| NormalHome[일반 홈페이지]
HasInvitation -->|있음| AutoRedirect[자동으로 초대 수락 페이지로 이동<br/>/signup/decision_process]
AutoRedirect --> DecisionPage1
DecisionPage1 --> UserDecision{사용자 선택}
UserDecision -->|수락| AcceptProcess[초대 수락 처리<br/>1. PortalOrg 변경<br/>2. ROLE_CORP_USER 전환<br/>3. 약관 재동의]
UserDecision -->|거절| RejectProcess[초대 거절 처리<br/>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
+44
View File
@@ -0,0 +1,44 @@
flowchart TD
Start([법인 관리자가 사용자 초대 시작]) --> CheckUser{가입된 사용자인가?}
CheckUser -->|아니오| Error1[에러: 가입된 사용자만 초대 가능]
CheckUser -->|예| CheckRole{이미 법인 사용자인가?}
CheckRole -->|예| Error2[에러: 이미 기관에 등록된 사용자]
CheckRole -->|아니오 ROLE_USER| CreateInvitation[8글자 토큰 생성<br/>UserInvitation 저장<br/>상태: PENDING]
CreateInvitation --> SendEmail[이메일 발송<br/>UserInvitationEvent]
SendEmail --> UserReceive{사용자가 토큰 받음}
UserReceive -->|폐쇄망| ManualInput[8글자 토큰 직접 입력<br/>예: K7MNPQ2R]
UserReceive -->|이메일 링크| EmailLink[Base64 인코딩된<br/>링크 클릭]
ManualInput --> DecodeToken[토큰 디코딩<br/>decodeInvitationToken]
EmailLink --> DecodeToken
DecodeToken --> ValidateToken{토큰 유효성 검증}
ValidateToken -->|무효| Error3[에러: 유효하지 않은 초대]
ValidateToken -->|유효 & PENDING| ShowDecision[약관 동의 페이지<br/>/signup/decision]
ShowDecision --> UserDecision{사용자 선택}
UserDecision -->|수락| ProcessAccept[processInvitation: accept]
UserDecision -->|거절| ProcessReject[processInvitation: reject]
ProcessAccept --> UpdateUser[1. PortalOrg 변경<br/>2. ROLE_CORP_USER로 전환<br/>3. 약관 재동의 PRIVACY_COLLECT_ORG]
UpdateUser --> CompleteInvitation[Invitation 상태:<br/>COMPLETED + completeDate]
CompleteInvitation --> Success([초대 수락 완료<br/>로그아웃 후 재로그인])
ProcessReject --> RejectInvitation[Invitation 상태:<br/>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
+64
View File
@@ -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: 사용자 이메일 입력<br/>초대 요청
Portal->>UserManFacade: sendInvitation(sender, emailAddr)
UserManFacade->>PortalUserRepo: findByEmailAddr(emailAddr)
alt 사용자가 없거나 이미 법인 사용자
UserManFacade-->>Portal: 에러 반환
Portal-->>법인관리자: 초대 불가 알림
else 일반 사용자 ROLE_USER
UserManFacade->>UserManFacade: generateEightCharToken()<br/>예: K7MNPQ2R
UserManFacade->>UserInvitationRepo: save(UserInvitation)<br/>status: PENDING<br/>expiresOn: 7일 후
UserManFacade->>MessageService: publishEvent(UserInvitationEvent)
MessageService->>Email: 초대 이메일 발송<br/>(법인명, 관리자명, 토큰)
UserManFacade-->>Portal: 초대 완료
Portal-->>법인관리자: 초대 성공 알림
end
Email->>초대받은사용자: 초대 이메일 수신
alt 폐쇄망 환경
초대받은사용자->>Portal: 8글자 토큰 직접 입력<br/>/signup/decision?invitation=K7MNPQ2R
else 이메일 링크
초대받은사용자->>Portal: 링크 클릭<br/>/signup/decision?invitation=BASE64TOKEN
end
Portal->>UserRegisterController: showDecisionPage(invitationToken)
UserRegisterController->>UserRegisterController: decodeInvitationToken()<br/>(Base64 or 직접입력 처리)
UserRegisterController->>UserInvitationRepo: findByToken(decodedToken)
alt 토큰 무효 or 상태가 PENDING 아님
UserRegisterController-->>Portal: 에러: 유효하지 않은 초대
Portal-->>초대받은사용자: 에러 페이지
else 토큰 유효
UserRegisterController->>PortalUserRepo: findByEmailAddr()
UserRegisterController-->>Portal: 약관 동의 페이지<br/>(법인명, 사용자명 표시)
Portal-->>초대받은사용자: 수락/거절 선택 화면
end
초대받은사용자->>Portal: 수락 or 거절 선택
Portal->>UserRegisterController: processInvitation(action)
UserRegisterController->>UserRegisterFacade: processInvitation(action, invitation)
alt 수락
UserRegisterFacade->>PortalUserRepo: findByEmailAddr()
UserRegisterFacade->>PortalUserRepo: updateUserToCorpUser()<br/>(PortalOrg 변경<br/>ROLE_CORP_USER 전환)
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: COMPLETED<br/>completeDate: now()
UserRegisterFacade-->>Portal: 수락 성공
Portal-->>초대받은사용자: 성공 메시지<br/>로그아웃 후 재로그인 안내
else 거절
UserRegisterFacade->>UserInvitationRepo: save(invitation)<br/>status: REJECTED<br/>completeDate: now()
UserRegisterFacade-->>Portal: 거절 처리 완료
Portal-->>초대받은사용자: 거절 완료 메시지
end
+20
View File
@@ -0,0 +1,20 @@
stateDiagram-v2
[*] --> 초대생성: 법인관리자가 초대 시작
초대생성 --> PENDING: 8글자 토큰 생성<br/>UserInvitation 저장
PENDING --> 토큰전달: 이메일 발송
토큰전달 --> 사용자접근: 폐쇄망 직접입력 or<br/>이메일 링크 클릭
사용자접근 --> 약관동의: 토큰 유효성 검증 성공
사용자접근 --> 에러: 토큰 무효
약관동의 --> COMPLETED: 사용자 수락<br/>ROLE_CORP_USER 전환<br/>PortalOrg 변경
약관동의 --> REJECTED: 사용자 거절
COMPLETED --> [*]: 초대 완료
REJECTED --> [*]: 초대 거절
PENDING --> CANCELED: 관리자가 초대 취소
CANCELED --> [*]: 초대 취소
에러 --> [*]: 처리 종료
@@ -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<UserInvitation> 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;
@@ -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", "초대가 재발송되었습니다.");
}
}
@@ -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<UserInvitation> 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<UserInvitation> 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<UserInvitation> 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<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(invitation.getInvitationEmail());
if (existingUser.isPresent()) {
// 기존 사용자: 초대 수락 페이지로 이동
return "redirect:/signup/decision?invitation=" + normalizedCode;
} else {
// 신규 사용자: 회원가입 페이지로 이동
return "redirect:/signup/portalUser?invitation=" + normalizedCode;
}
}
}
@@ -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<PortalUserDTO> 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<PortalUser> 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<String, Object> 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<UserInvitation> pendingInvitation =
userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
emailAddr, orgId, InvitationStatus.PENDING);
if (pendingInvitation.isPresent()) {
throw new IllegalArgumentException("이미 초대가 진행 중인 이메일 주소입니다.");
}
// 2. 다른 기관에서 PENDING 상태인 초대가 있는지 확인
Optional<UserInvitation> 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<String, String> 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);
}
}
@@ -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: <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;
}
}
}
@@ -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<UserInvitation> 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");
@@ -70,6 +70,13 @@
<img th:src="@{/img/icon/icon_login_join.png}" alt="">
<a th:href="@{/signup}">회원가입</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<img th:src="@{/img/icon/icon_login_join.png}" alt="">
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
</ul>
</div>
<div class="login_list m-only">
@@ -89,6 +96,12 @@
<li class="icon_join">
<a th:href="@{/signup}">회원가입</a>
</li>
<li>
<span>|</span>
</li>
<li class="icon_join">
<a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
</ul>
</div>
<div th:if="${param.logout}" class="alert alert-info mt-3">
@@ -9,6 +9,21 @@
<h2 class="title">내 정보 관리</h2>
</div>
<!-- 초대 알림 배너 -->
<div th:if="${hasPendingInvitation}" class="invitation_alert_banner" style="background-color: #f0f8ff; border: 1px solid #2196F3; border-radius: 4px; padding: 15px 20px; margin: 20px 0;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<strong style="color: #1976D2;">법인 회원 초대가 대기 중입니다</strong>
<p style="margin: 5px 0 0 0; color: #666;">기관에서 귀하를 법인 회원으로 초대했습니다. 초대를 수락하면 해당 기관의 법인 회원으로 전환됩니다.</p>
</div>
<div>
<a th:href="@{/signup/decision(invitation=${invitationToken})}" class="common_btn_type_1 blue" style="white-space: nowrap;">
<span>초대 확인</span>
</a>
</div>
</div>
</div>
<div class="inner i_cs h_inner8">
<form id="updateForm" method="post" th:action="@{/mypage/update}" th:object="${user}">
<div class="terms_box user_top">
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/kbank_base_layout}">
<body>
<section layout:fragment="contentFragment" class="content">
<div class="content_wrap">
<div class="sub_title2">
<h2 class="title add2">초대 코드 입력</h2>
</div>
<div class="inner i_cs9 h_inner9">
<div class="form_type">
<div class="con_title">
<p>법인 기관으로부터 받으신 8자리 초대 코드를 입력해 주세요.</p>
</div>
<form id="invitationCodeForm" method="post" th:action="@{/signup/invitation-code}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<div class="form_type_box">
<div class="info1 form_top">
<p class="title">
<span>초대 코드</span>
</p>
<div class="info_line info_add">
<div class="info_box1 add add2">
<!-- <input type="text"-->
<!-- id="invitationCode"-->
<!-- name="invitationCode"-->
<!-- class="common_input_type_1 h_inp"-->
<!-- placeholder="8자리 초대 코드를 입력해 주세요"-->
<!-- maxlength="8"-->
<!-- pattern="[A-Z0-9]{8}"-->
<!-- title="8자리 영문 대문자와 숫자로 입력해주세요"-->
<!-- required-->
<!-- style="text-transform: uppercase;">-->
<input type="text"
id="invitationCode"
name="invitationCode"
class="common_input_type_1 h_inp"
placeholder="8자리 초대 코드를 입력해 주세요"
maxlength="8"
required
style="text-transform: uppercase;">
</div>
</div>
</div>
<div class="info_txt" style="margin-top: 25px;">
<p>※ 초대 코드는 이메일로 전달받으실 수 있습니다.</p>
<p>※ 초대 코드는 대소문자를 구분하지 않으며, 8자리 영문자와 숫자로 구성되어 있습니다.</p>
</div>
</div>
<!-- 에러 메시지 표시 영역 -->
<div th:if="${error}" class="error_message" style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p th:text="${error}"></p>
</div>
<!-- 브루트포스 방지: 시도 횟수 표시 -->
<div th:if="${attemptsRemaining != null and attemptsRemaining <= 3}"
class="warning_message"
style="color: #f57c00; margin: 25px 0 15px 0; padding: 10px; background-color: #fff3e0; border-radius: 4px;">
<p>남은 시도 횟수: <strong th:text="${attemptsRemaining}"></strong></p>
<p>5회 실패 시 15분간 입력이 제한됩니다.</p>
</div>
<!-- 브루트포스 방지: 계정 잠금 메시지 -->
<div th:if="${lockoutTime != null}"
class="error_message"
style="color: #d32f2f; margin: 25px 0 15px 0; padding: 10px; background-color: #ffebee; border-radius: 4px;">
<p>잘못된 초대 코드를 5회 입력하셨습니다.</p>
<p th:text="${lockoutTime} + ' 후에 다시 시도해 주세요.'"></p>
</div>
<div class="btn_login">
<button type="submit" class="common_btn_type_1 h_btn" th:disabled="${lockoutTime != null}" style="width: 100%; border: none; cursor: pointer;">
<span>확인</span>
</button>
</div>
</form>
<div class="login_list pc-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
<div class="login_list m-only" style="margin-top: 20px;">
<ul>
<li>
<a th:href="@{/login}">로그인 페이지로 돌아가기</a>
</li>
<li>
<span>|</span>
</li>
<li>
<a th:href="@{/signup}">회원가입</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<th:block layout:fragment="contentScript">
<script>
$(document).ready(function() {
// 초대 코드 입력 필드를 대문자로 자동 변환
$('#invitationCode').on('input', function() {
this.value = this.value.toUpperCase();
});
// 성공 메시지가 있으면 표시
var successMsg = [[${success}]];
if (successMsg) {
customPopups.showAlert(successMsg);
}
});
</script>
</th:block>
</body>
</html>
@@ -18,7 +18,7 @@
<p class="tit_txt">
<span th:text="${userName}"></span>님 안녕하세요.<br><br>
<span th:text="${orgName}"></span>에서 귀하를 Kbank API Portal 법인회원으로 초대하였습니다.<br>
<br> Kbank와 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
<br> 광주은행과 함께 금융서비스를 손쉽게 개발해 보세요.<br><br>
로그인 하여 법인회원 전환을 진행하세요.<br>
</p>
</div>
@@ -67,9 +67,10 @@
<td th:text="${user.maskedEmailAddr}">이메일 아이디</td>
<td>
</td>
<td></td>
<td>초대 대기 중</td>
<td>
<div class="btn_wrap btn_set">
<a href="#none" class="common_btn_type_4 resend_invitation"><span>재발송</span></a>
<a href="#none" class="common_btn_type_4 cancel_invitation"><span>초대취소</span></a>
</div>
</td>
@@ -103,8 +104,10 @@
<div class="contents">
<p class="con_txt2">추가할 이용자 e-mail 주소를 입력해 주세요.</p>
<div class="inp_box">
<input type="text" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소">
<input type="email" class="common_input_type_1 w_input" id="new_user_email"
placeholder="이용자 e-mail 주소"
pattern="[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
title="올바른 이메일 형식을 입력해주세요 (예: user@example.com)">
</div>
</div>
</div>
@@ -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();
});
}
});
}
});
</script>
</th:block>