design → master 병합: 2FA·Step-up 인증 및 디자인 반영본 통합
@@ -107,4 +107,6 @@ TODO.txt
|
||||
diff
|
||||
|
||||
*rinjae*
|
||||
*gf63*
|
||||
*gf63*
|
||||
*obsidian*
|
||||
design-backup
|
||||
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -68,6 +69,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
String selectedServiceId = "";
|
||||
boolean idExists = false;
|
||||
@@ -93,6 +98,10 @@ public class ApiController {
|
||||
|
||||
@GetMapping("/testbed")
|
||||
public String testbedByApiService(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/login?reason=auth";
|
||||
}
|
||||
String selectedApiServiceName = "API 서비스 선택";
|
||||
boolean idExists = false;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.app.controller;
|
||||
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -178,7 +179,6 @@ public class MyAppController {
|
||||
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("authType", "OAuth2");
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -217,9 +217,17 @@ public class MyAppController {
|
||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||
result.put("success", true);
|
||||
result.put("message", "신청이 취소되었습니다.");
|
||||
} catch (Exception e) {
|
||||
} catch (InvalidApprovalTransitionException e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -637,14 +645,8 @@ public class MyAppController {
|
||||
return new ModelAndView("redirect:/myapikey/register/step1");
|
||||
}
|
||||
|
||||
// API 선택 검증
|
||||
if (selectedApis == null || selectedApis.isEmpty()) {
|
||||
redirectAttributes.addFlashAttribute("error", "최소 1개 이상의 API를 선택해주세요.");
|
||||
return new ModelAndView("redirect:/myapikey/register/step2");
|
||||
}
|
||||
|
||||
// 선택된 API를 세션에 저장
|
||||
registration.setSelectedApis(selectedApis);
|
||||
// API 선택은 선택 사항 — 미선택(빈 목록)도 허용한다.
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new ArrayList<>());
|
||||
|
||||
// 등록이 완료되었는지 최종 검증
|
||||
if (!registration.isComplete()) {
|
||||
|
||||
@@ -99,6 +99,7 @@ public class ApiKeyRegistrationDTO implements Serializable {
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
// API 선택은 선택 사항이므로 기본 정보(Step1)만 완료되면 등록 가능하다.
|
||||
return isStep1Complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -25,8 +24,7 @@ public class AppRequestDTO {
|
||||
|
||||
private ApprovalDTO approval;
|
||||
|
||||
@NotEmpty
|
||||
private String apiList = ""; //comma separated api id list
|
||||
private String apiList = ""; //comma separated api id list (미선택 허용)
|
||||
|
||||
private String apiGroupList = ""; //comma separated api group id list
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ public class AppServiceFacade {
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
|
||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||
|
||||
@@ -68,9 +69,13 @@ public class AppServiceFacade {
|
||||
}
|
||||
|
||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
||||
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
@@ -134,7 +139,23 @@ public class AppServiceFacade {
|
||||
}
|
||||
|
||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||
if (request.getApproval() == null) {
|
||||
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||
if (StringUtils.isNotBlank(request.getClientId())) {
|
||||
try {
|
||||
adminGatewayClient.blockClient(request.getClientId());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||
}
|
||||
}
|
||||
appRequestRepository.delete(request);
|
||||
} else {
|
||||
approvalService.cancelAppApproval(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +185,15 @@ public class AppServiceFacade {
|
||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||
|
||||
for (String apiId : apiList) {
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
if (spec == null) {
|
||||
continue;
|
||||
}
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
if (serviceDTO != null) {
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
}
|
||||
appRequest.getApiSpecList().add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,11 @@ public interface AuthNumberService {
|
||||
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType);
|
||||
|
||||
/**
|
||||
* 인증번호를 지정한 유효시간(초)으로 발송한다. 로그인/step-up 2FA 는 회원가입 기본 TTL 과
|
||||
* 다른 값을 쓸 수 있으므로 호출부에서 TTL 을 지정한다.
|
||||
*/
|
||||
String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds);
|
||||
|
||||
boolean verifyAuthNumber(String recipientKey, String authNumber);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType) {
|
||||
logger.info("Sending auth number to: {} via {}", recipientKey, msgType);
|
||||
return sendRequestAuthNumber(recipientKey, msgType, authNumberExpirationTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = AuthNumberException.class)
|
||||
public String sendRequestAuthNumber(String recipientKey, String msgType, int ttlSeconds) {
|
||||
logger.info("Sending auth number to: {} via {} (ttl={}s)", recipientKey, msgType, ttlSeconds);
|
||||
|
||||
validateResendTime(recipientKey);
|
||||
|
||||
@@ -55,7 +61,7 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
messageSender.sendAuthMessage(recipient, authNumber, msgType);
|
||||
|
||||
storage.saveAuthNumber(recipientKey, authNumber,
|
||||
LocalDateTime.now().plusSeconds(authNumberExpirationTime));
|
||||
LocalDateTime.now().plusSeconds(ttlSeconds));
|
||||
|
||||
return authNumber;
|
||||
}
|
||||
@@ -66,17 +72,20 @@ public class AuthNumberServiceImpl implements AuthNumberService {
|
||||
logger.info("Verifying auth number for: {}", recipientKey);
|
||||
|
||||
TwoFactorAuth storedAuth = storage.getAuthNumber(recipientKey)
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요."));
|
||||
.orElseThrow(() -> new AuthNumberException("인증번호가 존재하지 않습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.NOT_FOUND));
|
||||
|
||||
if (storedAuth.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
storage.deleteAuthNumber(recipientKey);
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.");
|
||||
throw new AuthNumberException("입력 시간이 초과되었습니다. 인증번호를 다시 발송해주세요.",
|
||||
AuthNumberException.Reason.EXPIRED);
|
||||
}
|
||||
|
||||
if (authNumber.equals(storedAuth.getAuthNumber())) {
|
||||
return true;
|
||||
} else {
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.");
|
||||
throw new AuthNumberException("입력된 인증번호가 올바르지 않습니다.",
|
||||
AuthNumberException.Reason.MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* step-up 2FA(민감기능 추가 인증) 가드.
|
||||
*
|
||||
* <p>보호 경로({@link StepUpProtectedPaths}) 진입 시, 유효한 1회용 통과권이 없으면 인증을 요구한다.
|
||||
* <ul>
|
||||
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
|
||||
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
|
||||
* </ul>
|
||||
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
|
||||
*
|
||||
* <p>비밀번호 강제 변경 상태(pwEnforce/passwordExpired)의 {@code /password/*} 는 제외한다
|
||||
* (강제 변경 유도 경로 — {@code PasswordChangeEnforcementInterceptor} 가 이미 관장).</p>
|
||||
*/
|
||||
public class StepUpAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
public StepUpAuthInterceptor(TwoFactorService twoFactorService, TwoFactorProperties twoFactorProperties) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
if (!twoFactorProperties.isStepUpEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (!StepUpProtectedPaths.isProtected(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
|
||||
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null) {
|
||||
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
|
||||
return true;
|
||||
}
|
||||
|
||||
// 비밀번호 강제 변경 상태의 /password/* 는 step-up 제외
|
||||
if (StepUpProtectedPaths.isPasswordPath(path) && isPasswordEnforced(session)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1회용 통과권 소비 시도 (매번 인증: 있으면 소멸 후 통과)
|
||||
if (twoFactorService.consumeStepUpPass(session, path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("POST".equalsIgnoreCase(request.getMethod())) {
|
||||
// AJAX 지점(Secret 조회/앱 해지) → 프론트가 팝업을 띄우도록 신호
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"stepUpRequired\":true}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// GET 페이지 진입 → 챌린지 페이지로 유도(원경로+쿼리 보존)
|
||||
String returnUrl = path;
|
||||
String query = request.getQueryString();
|
||||
if (query != null && !query.isEmpty()) {
|
||||
returnUrl = returnUrl + "?" + query;
|
||||
}
|
||||
String encoded = URLEncoder.encode(returnUrl, StandardCharsets.UTF_8.name());
|
||||
response.sendRedirect(request.getContextPath() + "/auth/2fa/challenge?returnUrl=" + encoded);
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isPasswordEnforced(HttpSession session) {
|
||||
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|
||||
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* step-up 2FA 보호 대상 서블릿 경로(화이트리스트) + 지점별 프로퍼티 키 매핑.
|
||||
*
|
||||
* <p>인터셉터(진입 차단)와 서비스(통과권 발급 시 대상 검증)가 동일 목록을 공유한다.
|
||||
* open redirect / 임의 경로 통과권 발급을 막기 위해 반드시 이 집합으로 검증한다.</p>
|
||||
*
|
||||
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어한다.
|
||||
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
|
||||
*
|
||||
* <p>{@code /new_password} 계열(비밀번호 강제 변경 유도)은 제외 대상이므로 여기 없음.
|
||||
* 강제 접속(세션 pwEnforce/passwordExpired) 시 /password/* 도 인터셉터에서 별도 제외한다.</p>
|
||||
*/
|
||||
public final class StepUpProtectedPaths {
|
||||
|
||||
/** Secret 키 조회 (AJAX POST) */
|
||||
public static final String REVEAL_SECRET = "/myapikey/credential/reveal-secret";
|
||||
/** 앱 해지 신청 (AJAX POST) */
|
||||
public static final String APP_KEY_DELETE = "/myapikey/api_key_delete";
|
||||
/** 앱 정보 수정 페이지 진입 (GET) */
|
||||
public static final String APP_MODIFY_STEP1 = "/myapikey/modify/step1";
|
||||
/** 개인정보 변경 페이지 진입 (GET, 정확 일치) */
|
||||
public static final String MYPAGE = "/mypage";
|
||||
/** 비밀번호 변경 진입 - 현재비번 확인 (GET) */
|
||||
public static final String PASSWORD_VERIFY = "/password/verify";
|
||||
/** 비밀번호 변경 폼 (GET) */
|
||||
public static final String PASSWORD_CHANGE = "/password/change";
|
||||
|
||||
/** PTL_PROPERTY 지점 키 접두 (전체 스위치 two-factor.stepup.enabled 와 구분) */
|
||||
private static final String KEY_PREFIX = "two-factor.stepup.";
|
||||
|
||||
/** 경로 → 지점별 프로퍼티 키. 삽입 순서 유지(LinkedHashMap) */
|
||||
private static final Map<String, String> PATH_TO_KEY;
|
||||
static {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
// 비밀번호 변경은 verify/change 두 진입이 한 기능이므로 동일 키 공유
|
||||
m.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
|
||||
m.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
|
||||
m.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
|
||||
m.put(MYPAGE, KEY_PREFIX + "mypage");
|
||||
m.put(PASSWORD_VERIFY, KEY_PREFIX + "password-change");
|
||||
m.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
|
||||
PATH_TO_KEY = Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
private StepUpProtectedPaths() {
|
||||
}
|
||||
|
||||
public static boolean isProtected(String servletPath) {
|
||||
return servletPath != null && PATH_TO_KEY.containsKey(servletPath);
|
||||
}
|
||||
|
||||
/** 해당 경로의 지점별 활성화 프로퍼티 키. 보호 경로가 아니면 null */
|
||||
public static String propertyKeyOf(String servletPath) {
|
||||
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
|
||||
}
|
||||
|
||||
/** 지점별 프로퍼티 키 접두 */
|
||||
public static String keyPrefix() {
|
||||
return KEY_PREFIX;
|
||||
}
|
||||
|
||||
/** 비밀번호 강제 변경 상태(pwEnforce/passwordExpired)에서 step-up 을 건너뛸 경로인지 */
|
||||
public static boolean isPasswordPath(String servletPath) {
|
||||
return PASSWORD_VERIFY.equals(servletPath) || PASSWORD_CHANGE.equals(servletPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portaluser.repository.TwoFactorAuthRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 만료된 2FA 인증번호(PTL_TWO_FACTOR_AUTH) 정리 스케줄러.
|
||||
*
|
||||
* <p>검증은 접근 시점 lazy 만료 검사만 하므로, 발송 후 검증 없이 방치된 레코드가 남는다.
|
||||
* 1분 주기로 만료분을 일괄 삭제한다.</p>
|
||||
*
|
||||
* <p><b>다중화(스케일아웃) 안전성:</b> 작업이 "만료된 행만" 지우는 멱등 delete 라
|
||||
* 여러 인스턴스가 동시에 실행해도 결과가 동일하고 부작용이 없다. 따라서 분산 락
|
||||
* (ShedLock 등)이 필요 없다. 동일 행을 둘이 지우려 하면 한쪽이 0건 삭제로 끝날 뿐이다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorCleanupScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorCleanupScheduler.class);
|
||||
|
||||
private final TwoFactorAuthRepository twoFactorAuthRepository;
|
||||
|
||||
@Scheduled(fixedRate = 60000)
|
||||
@Transactional
|
||||
public void cleanupExpired() {
|
||||
try {
|
||||
int deleted = twoFactorAuthRepository.deleteAllByExpiresAtBefore(LocalDateTime.now());
|
||||
if (deleted > 0) {
|
||||
log.debug("만료된 2FA 인증번호 {}건 정리", deleted);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("2FA 인증번호 정리 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 진행 중인 2FA 절차 상태. HTTP 세션에 단일 소스로 보관한다.
|
||||
*
|
||||
* <p>세션 클러스터링(stage/prod Redis/Ehcache) 대상이므로 {@link Serializable} 이다.
|
||||
* 여러 탭/페이지에서 동시에 2FA 가 발동되지 않도록, 발송 시 이 컨텍스트 존재 여부로
|
||||
* "진행 중" 을 판정하고 confirm 후 강제 종료(force)로만 새 절차를 시작한다.</p>
|
||||
*/
|
||||
public class TwoFactorContext implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public enum Mode {
|
||||
/** 로그인 1차 인증 통과 후 대기(pending) 상태의 2FA */
|
||||
LOGIN,
|
||||
/** 로그인 이후 민감기능 접근 시 추가 인증(step-up) */
|
||||
STEPUP
|
||||
}
|
||||
|
||||
private Mode mode;
|
||||
/** 발송 채널 (EMAIL | SMS) */
|
||||
private String channel;
|
||||
/** AuthNumberService 에 전달한 실제 수신처 문자열(이메일 소문자 / 휴대폰 digits). 검증 시 동일 값 사용 */
|
||||
private String recipient;
|
||||
/** step-up 대상 보호 경로(purpose). LOGIN 모드에서는 null */
|
||||
private String purpose;
|
||||
/** 발송 시각 */
|
||||
private LocalDateTime startedAt;
|
||||
/** 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 검증 시도 횟수 */
|
||||
private int attempts;
|
||||
|
||||
public Mode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public String getChannel() {
|
||||
return channel;
|
||||
}
|
||||
|
||||
public void setChannel(String channel) {
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public String getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
public void setRecipient(String recipient) {
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
public String getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(String purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartedAt() {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
public void setStartedAt(LocalDateTime startedAt) {
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
public int getTtlSeconds() {
|
||||
return ttlSeconds;
|
||||
}
|
||||
|
||||
public void setTtlSeconds(int ttlSeconds) {
|
||||
this.ttlSeconds = ttlSeconds;
|
||||
}
|
||||
|
||||
public int getAttempts() {
|
||||
return attempts;
|
||||
}
|
||||
|
||||
public void setAttempts(int attempts) {
|
||||
this.attempts = attempts;
|
||||
}
|
||||
|
||||
public int incrementAttempts() {
|
||||
return ++this.attempts;
|
||||
}
|
||||
|
||||
/** startedAt + ttl 기준 만료 여부(세션 컨텍스트 lazy 만료 판정용) */
|
||||
public boolean isExpired(LocalDateTime now) {
|
||||
return startedAt == null || startedAt.plusSeconds(ttlSeconds).isBefore(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
|
||||
/**
|
||||
* 공통 2FA 팝업 백엔드. 로그인 pending·step-up 을 모두 처리한다.
|
||||
*
|
||||
* <p>수신처는 서버가 세션 대상 사용자로부터 결정하므로 클라이언트는 채널만 전달한다.
|
||||
* 모든 POST 는 세션 기반 CSRF(X-XSRF-TOKEN) 보호를 받는다.</p>
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/auth/2fa")
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorController {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
/** 팝업 초기 정보(채널·TTL·진행중 여부) */
|
||||
@GetMapping("/info")
|
||||
@ResponseBody
|
||||
public TwoFactorInfoResponse info(@RequestParam(required = false) String purpose, HttpSession session) {
|
||||
return twoFactorService.getInfo(session, purpose);
|
||||
}
|
||||
|
||||
/** 인증번호 발송 */
|
||||
@PostMapping("/send")
|
||||
@ResponseBody
|
||||
public TwoFactorSendResponse send(@RequestParam String channel,
|
||||
@RequestParam(required = false) String purpose,
|
||||
@RequestParam(required = false, defaultValue = "false") boolean force,
|
||||
HttpSession session) {
|
||||
return twoFactorService.send(session, channel, purpose, force);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 */
|
||||
@PostMapping("/verify")
|
||||
@ResponseBody
|
||||
public TwoFactorVerifyResponse verify(@RequestParam String code,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
return twoFactorService.verify(request, session, code);
|
||||
}
|
||||
|
||||
/** 팝업 닫기/타이머 만료 → 2차 인증 실패 처리 */
|
||||
@PostMapping("/cancel")
|
||||
@ResponseBody
|
||||
public void cancel(@RequestParam(required = false, defaultValue = "CANCELLED") String reason,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
twoFactorService.cancel(request, session, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* step-up GET 진입 지점용 챌린지 페이지. 인터셉터가 리다이렉트하며, 화면이 공통 팝업을 자동 오픈한다.
|
||||
* returnUrl 은 보호 경로 화이트리스트로 검증(open redirect 방지)한다.
|
||||
*/
|
||||
@GetMapping("/challenge")
|
||||
public String challenge(@RequestParam(required = false) String returnUrl, Model model) {
|
||||
// returnUrl 은 쿼리스트링을 포함할 수 있으므로 경로 부분만 화이트리스트로 검증(open redirect 방지)
|
||||
String purpose = pathOf(returnUrl);
|
||||
if (!StepUpProtectedPaths.isProtected(purpose)) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("returnUrl", returnUrl);
|
||||
model.addAttribute("purpose", purpose);
|
||||
return "apps/auth/twoFactorChallenge";
|
||||
}
|
||||
|
||||
private static String pathOf(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int q = url.indexOf('?');
|
||||
return q >= 0 ? url.substring(0, q) : url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 2FA 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례({@code session.timeout.minutes},
|
||||
* {@code org.hard-delete.enabled} 등)를 따른다.
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||
* 생성(그룹 존재 시)하므로 별도 초기 데이터가 필요 없다. 캐시가 없어 매 호출 DB 조회지만
|
||||
* 2FA 진입 경로가 제한적이라 허용 범위다.</p>
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_LOGIN_ENABLED = "two-factor.login.enabled";
|
||||
public static final String KEY_TTL_SECONDS = "two-factor.ttl.seconds";
|
||||
public static final String KEY_ATTEMPT_LIMIT = "two-factor.attempt.limit";
|
||||
public static final String KEY_TEST_NOTICE_ENABLED = "two-factor.test-notice.enabled";
|
||||
public static final String KEY_STEPUP_ENABLED = "two-factor.stepup.enabled";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 로그인 2FA 활성화 여부 */
|
||||
public boolean isLoginEnabled() {
|
||||
return parseBool(resolve(KEY_LOGIN_ENABLED, "false", "로그인 2차 인증 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/** step-up(민감기능) 2FA 전체 활성화 여부(마스터 스위치) */
|
||||
public boolean isStepUpEnabled() {
|
||||
return parseBool(resolve(KEY_STEPUP_ENABLED, "false", "민감기능 추가 인증(step-up) 전체 활성화 여부 (true/false)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 보호 경로에 step-up 2FA 를 적용할지 여부(지점별 스위치).
|
||||
* 전체 스위치({@link #isStepUpEnabled()})가 켜진 상태에서 지점별로 개별 on/off 한다.
|
||||
* 지점 프로퍼티({@code two-factor.stepup.<지점>})의 기본값은 true(전체 스위치를 켜면 기본 전 지점 적용).
|
||||
*
|
||||
* @param servletPath 보호 경로. 매핑 키가 없으면(비보호 경로) false
|
||||
*/
|
||||
public boolean isStepUpPointEnabled(String servletPath) {
|
||||
String key = StepUpProtectedPaths.propertyKeyOf(servletPath);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
return parseBool(resolve(key, "true", "step-up 2FA 지점 적용 여부 (true/false): " + servletPath));
|
||||
}
|
||||
|
||||
/** 2FA 인증번호 유효시간(초). 기본 180초(3분) */
|
||||
public int getTtlSeconds() {
|
||||
return parseInt(resolve(KEY_TTL_SECONDS, "180", "2차 인증번호 유효시간(초)"), 180);
|
||||
}
|
||||
|
||||
/** 인증번호 검증 시도 한도. 기본 5회 */
|
||||
public int getAttemptLimit() {
|
||||
return parseInt(resolve(KEY_ATTEMPT_LIMIT, "5", "2차 인증번호 검증 시도 한도"), 5);
|
||||
}
|
||||
|
||||
/** 팝업에 테스트용 인증번호를 노출할지 여부(개발/테스트 전용) */
|
||||
public boolean isTestNoticeEnabled() {
|
||||
return parseBool(resolve(KEY_TEST_NOTICE_ENABLED, "false", "2차 인증 팝업에 테스트용 인증번호 표시 여부 (true/false)"));
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* boolean PTL_PROPERTY 값 파싱.
|
||||
* DB 관례에 맞춰 <b>true/false</b> 문자열을 사용한다(예: {@code org.hard-delete.enabled=true}).
|
||||
* "true"(대소문자 무시)만 참으로 본다. 그 외(false/공백/null 등)는 모두 거짓.
|
||||
*/
|
||||
private static boolean parseBool(String value) {
|
||||
return value != null && "true".equalsIgnoreCase(value.trim());
|
||||
}
|
||||
|
||||
private static int parseInt(String value, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(value.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberService;
|
||||
import com.eactive.apim.portal.apps.auth.service.AuthNumberStorage;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorChannel;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorInfoResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorSendResponse;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.dto.TwoFactorVerifyResponse;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 2FA(2차 인증/추가 인증) 공통 서비스. 로그인 pending 인증과 step-up(민감기능) 인증을 모두 처리한다.
|
||||
*
|
||||
* <p>핵심 원칙:
|
||||
* <ul>
|
||||
* <li>수신처는 서버가 세션의 대상 사용자로부터 DB 기준으로 결정한다(클라이언트는 채널만 선택).</li>
|
||||
* <li>진행 상태는 세션 {@link TwoFactorContext} 단일 소스로 관리한다.</li>
|
||||
* <li>다른 플로우가 진행 중이면 발송을 막고(inProgress), confirm 후 force 로만 강제 종료·재시작한다.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class TwoFactorService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TwoFactorService.class);
|
||||
|
||||
// === 세션 attribute 키 ===
|
||||
/** 로그인 1차 인증 통과 후 대기 중인 사용자 id (존재 시 LOGIN 모드) */
|
||||
public static final String ATTR_PENDING_USER_ID = "TFA_PENDING_USER_ID";
|
||||
/** 대기 중 사용자 loginId (감사/세션 표기) */
|
||||
public static final String ATTR_PENDING_LOGIN_ID = "TFA_PENDING_LOGIN_ID";
|
||||
/** 진행 중 2FA 컨텍스트 */
|
||||
public static final String ATTR_CONTEXT = "TFA_CONTEXT";
|
||||
/** step-up 1회용 통과권 - 대상 경로 */
|
||||
public static final String ATTR_STEPUP_PASS_PATH = "TFA_STEPUP_PASS_PATH";
|
||||
/** step-up 1회용 통과권 - 발급 시각 */
|
||||
public static final String ATTR_STEPUP_PASS_AT = "TFA_STEPUP_PASS_AT";
|
||||
|
||||
/** step-up 통과권 유효시간(초). 인증 성공 후 대상 페이지 진입까지의 이동 여유분 */
|
||||
public static final int STEPUP_PASS_TTL_SECONDS = 120;
|
||||
/** 재발송/채널전환 통합 최소 간격(초) */
|
||||
private static final int RESEND_THROTTLE_SECONDS = 30;
|
||||
|
||||
private final TwoFactorProperties properties;
|
||||
private final AuthNumberService authNumberService;
|
||||
private final AuthNumberStorage authNumberStorage;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
// =========================================================================
|
||||
// INFO
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorInfoResponse getInfo(HttpSession session, String purpose) {
|
||||
TwoFactorInfoResponse res = new TwoFactorInfoResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setAvailable(false);
|
||||
return res;
|
||||
}
|
||||
|
||||
res.setAvailable(true);
|
||||
res.setMode(mode.name());
|
||||
res.setChannels(buildChannels(user));
|
||||
res.setTtlSeconds(properties.getTtlSeconds());
|
||||
res.setTestNoticeEnabled(properties.isTestNoticeEnabled());
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null && !isSameFlow(ctx, mode, purpose)) {
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다.");
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorSendResponse send(HttpSession session, String channel, String purpose, boolean force) {
|
||||
TwoFactorSendResponse res = new TwoFactorSendResponse();
|
||||
|
||||
TwoFactorContext.Mode mode = resolveMode(session);
|
||||
if (mode == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 정보가 없습니다. 다시 시도해주세요.");
|
||||
return res;
|
||||
}
|
||||
if (mode == TwoFactorContext.Mode.STEPUP && !StepUpProtectedPaths.isProtected(purpose)) {
|
||||
res.setValid(false);
|
||||
res.setMessage("허용되지 않은 요청입니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
PortalUser user = resolveTargetUser(session, mode);
|
||||
if (user == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("인증 대상 사용자를 찾을 수 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toUpperCase();
|
||||
String recipient = resolveRecipient(user, normalizedChannel);
|
||||
if (recipient == null) {
|
||||
res.setValid(false);
|
||||
res.setMessage("선택한 방법으로 인증할 수 있는 정보가 없습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 진행 중 컨텍스트 처리
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx != null) {
|
||||
boolean sameFlow = isSameFlow(ctx, mode, purpose);
|
||||
if (!sameFlow) {
|
||||
if (!force) {
|
||||
res.setValid(false);
|
||||
res.setInProgress(true);
|
||||
res.setMessage("진행 중인 다른 인증 절차가 있습니다. 강제 종료 후 진행하시겠습니까?");
|
||||
return res;
|
||||
}
|
||||
discardContext(session, ctx); // 강제 종료(감사 기록 포함)
|
||||
} else if (ctx.getStartedAt() != null
|
||||
&& ctx.getStartedAt().plusSeconds(RESEND_THROTTLE_SECONDS).isAfter(LocalDateTime.now())) {
|
||||
res.setValid(false);
|
||||
res.setMessage("잠시 후에 다시 시도해 주세요.");
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
int ttl = properties.getTtlSeconds();
|
||||
String authNumber;
|
||||
try {
|
||||
authNumber = authNumberService.sendRequestAuthNumber(recipient,
|
||||
"SMS".equals(normalizedChannel) ? "SMS" : "EMAIL", ttl);
|
||||
} catch (AuthNumberException e) {
|
||||
res.setValid(false);
|
||||
res.setMessage(e.getMessage());
|
||||
return res;
|
||||
}
|
||||
|
||||
TwoFactorContext newCtx = new TwoFactorContext();
|
||||
newCtx.setMode(mode);
|
||||
newCtx.setChannel(normalizedChannel);
|
||||
newCtx.setRecipient(recipient);
|
||||
newCtx.setPurpose(mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
newCtx.setStartedAt(LocalDateTime.now());
|
||||
newCtx.setTtlSeconds(ttl);
|
||||
newCtx.setAttempts(0);
|
||||
session.setAttribute(ATTR_CONTEXT, newCtx);
|
||||
|
||||
res.setValid(true);
|
||||
res.setMessage("인증번호를 발송하였습니다.");
|
||||
res.setTtlSeconds(ttl);
|
||||
if (properties.isTestNoticeEnabled()) {
|
||||
res.setTestAuthNumber(authNumber);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// VERIFY
|
||||
// =========================================================================
|
||||
|
||||
public TwoFactorVerifyResponse verify(HttpServletRequest request, HttpSession session, String code) {
|
||||
TwoFactorVerifyResponse res = new TwoFactorVerifyResponse();
|
||||
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
if (ctx == null) {
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시간이 만료되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
if (ctx.isExpired(LocalDateTime.now())) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
int attempts = ctx.incrementAttempts();
|
||||
int limit = properties.getAttemptLimit();
|
||||
|
||||
try {
|
||||
authNumberService.verifyAuthNumber(ctx.getRecipient(), code);
|
||||
} catch (AuthNumberException e) {
|
||||
AuthNumberException.Reason reason = e.getReason();
|
||||
if (reason == AuthNumberException.Reason.EXPIRED || reason == AuthNumberException.Reason.NOT_FOUND) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_TIMEOUT, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("입력 시간이 초과되었습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
// 코드 불일치
|
||||
if (attempts >= limit) {
|
||||
terminateWithFailure(session, ctx, LoginFailureReason.TWO_FACTOR_ATTEMPT_EXCEEDED, request);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("인증 시도 횟수를 초과했습니다. 처음부터 다시 진행해주세요.");
|
||||
return res;
|
||||
}
|
||||
session.setAttribute(ATTR_CONTEXT, ctx); // attempts 갱신 반영
|
||||
res.setValid(false);
|
||||
res.setRemainingAttempts(limit - attempts);
|
||||
res.setMessage("인증번호가 일치하지 않습니다. (남은 횟수 " + (limit - attempts) + "회)");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 검증 성공 — 인증번호 즉시 소비(재사용 방지, 2FA 한정)
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
return completeLogin(request, session, res);
|
||||
}
|
||||
|
||||
// STEPUP — 1회용 통과권 발급
|
||||
issueStepUpPass(session, ctx.getPurpose());
|
||||
res.setValid(true);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
private TwoFactorVerifyResponse completeLogin(HttpServletRequest request, HttpSession session,
|
||||
TwoFactorVerifyResponse res) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
|
||||
PortalUser user = userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
if (user == null) {
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage("로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// 1차 인증~2FA 사이 상태 변경 방어(잠금/차단/승인 취소)
|
||||
String stateError = revalidateLoginState(user);
|
||||
if (stateError != null) {
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(),
|
||||
LoginFailureReason.ACCOUNT_DISABLED);
|
||||
clearPending(session);
|
||||
res.setValid(false);
|
||||
res.setTerminated(true);
|
||||
res.setMessage(stateError);
|
||||
return res;
|
||||
}
|
||||
|
||||
// 프로그래매틱 인증 확정 (요청 종료 시 SecurityContextPersistenceFilter 가 세션에 저장)
|
||||
PortalAuthenticatedUser authUser = portalUserAuthService.buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
String redirect = loginFinalizer.finalizeLogin(user, loginId, request, LoginType.TWO_FACTOR);
|
||||
clearPending(session);
|
||||
|
||||
res.setValid(true);
|
||||
res.setRedirect(redirect);
|
||||
res.setMessage("인증이 완료되었습니다.");
|
||||
return res;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CANCEL (팝업 닫기 / 타이머 만료)
|
||||
// =========================================================================
|
||||
|
||||
public void cancel(HttpServletRequest request, HttpSession session, String reason) {
|
||||
TwoFactorContext ctx = getActiveContext(session);
|
||||
boolean timeout = "TIMEOUT".equalsIgnoreCase(reason);
|
||||
LoginFailureReason failureReason = timeout
|
||||
? LoginFailureReason.TWO_FACTOR_TIMEOUT : LoginFailureReason.TWO_FACTOR_CANCELLED;
|
||||
|
||||
if (ctx != null && ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), failureReason);
|
||||
}
|
||||
if (ctx != null && ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
|
||||
// 로그인 2FA 취소는 로그인 자체를 포기(익명 유지) → pending 제거
|
||||
if (ctx == null || ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
clearPending(session);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// LOGIN pending 진입 (SuccessHandler 에서 호출)
|
||||
// =========================================================================
|
||||
|
||||
/** 로그인 1차 인증 통과 사용자를 2FA 대기 상태로 세팅한다. (SecurityContext 클리어는 호출부 책임) */
|
||||
public void beginLoginChallenge(HttpSession session, PortalUser user) {
|
||||
session.setAttribute(ATTR_PENDING_USER_ID, user.getId());
|
||||
session.setAttribute(ATTR_PENDING_LOGIN_ID, user.getLoginId());
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
public boolean hasPendingLogin(HttpSession session) {
|
||||
return session != null && session.getAttribute(ATTR_PENDING_USER_ID) != null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STEP-UP 통과권
|
||||
// =========================================================================
|
||||
|
||||
private void issueStepUpPass(HttpSession session, String path) {
|
||||
session.setAttribute(ATTR_STEPUP_PASS_PATH, path);
|
||||
session.setAttribute(ATTR_STEPUP_PASS_AT, LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 대한 유효한 1회용 통과권이 있으면 소비(제거)하고 true 를 반환한다.
|
||||
* (매번 인증 정책 — 통과권은 즉시 소멸)
|
||||
*/
|
||||
public boolean consumeStepUpPass(HttpSession session, String servletPath) {
|
||||
Object passPath = session.getAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
Object passAt = session.getAttribute(ATTR_STEPUP_PASS_AT);
|
||||
if (!(passPath instanceof String) || !(passAt instanceof LocalDateTime)) {
|
||||
return false;
|
||||
}
|
||||
boolean valid = passPath.equals(servletPath)
|
||||
&& ((LocalDateTime) passAt).plusSeconds(STEPUP_PASS_TTL_SECONDS).isAfter(LocalDateTime.now());
|
||||
// 매번 인증: 일치/불일치 무관하게 통과권은 이번 판정에서 소멸시킨다.
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_PATH);
|
||||
session.removeAttribute(ATTR_STEPUP_PASS_AT);
|
||||
return valid;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 helper
|
||||
// =========================================================================
|
||||
|
||||
private TwoFactorContext.Mode resolveMode(HttpSession session) {
|
||||
if (session.getAttribute(ATTR_PENDING_USER_ID) != null) {
|
||||
return TwoFactorContext.Mode.LOGIN;
|
||||
}
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return TwoFactorContext.Mode.STEPUP;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private PortalUser resolveTargetUser(HttpSession session, TwoFactorContext.Mode mode) {
|
||||
if (mode == TwoFactorContext.Mode.LOGIN) {
|
||||
String userId = (String) session.getAttribute(ATTR_PENDING_USER_ID);
|
||||
return userId != null ? portalUserRepository.findById(userId).orElse(null) : null;
|
||||
}
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
// 세션 로드 이후 연락처 변경 반영을 위해 DB 재조회
|
||||
return portalUserRepository.findById(current.getId()).orElse(null);
|
||||
}
|
||||
|
||||
private List<TwoFactorChannel> buildChannels(PortalUser user) {
|
||||
List<TwoFactorChannel> channels = new ArrayList<>();
|
||||
if (StringUtils.hasText(user.getEmailAddr())) {
|
||||
channels.add(new TwoFactorChannel("EMAIL", StringMaskingUtil.maskEmail(user.getEmailAddr())));
|
||||
}
|
||||
if (StringUtils.hasText(user.getMobileNumber())) {
|
||||
channels.add(new TwoFactorChannel("SMS", StringMaskingUtil.maskMobileNumber(user.getMobileNumber())));
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
private String resolveRecipient(PortalUser user, String channel) {
|
||||
if ("EMAIL".equals(channel)) {
|
||||
return StringUtils.hasText(user.getEmailAddr()) ? user.getEmailAddr() : null;
|
||||
}
|
||||
if ("SMS".equals(channel)) {
|
||||
return StringUtils.hasText(user.getMobileNumber())
|
||||
? PhoneNumberUtil.digitsOnly(user.getMobileNumber()) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TwoFactorContext getActiveContext(HttpSession session) {
|
||||
Object ctx = session.getAttribute(ATTR_CONTEXT);
|
||||
if (!(ctx instanceof TwoFactorContext)) {
|
||||
return null;
|
||||
}
|
||||
TwoFactorContext context = (TwoFactorContext) ctx;
|
||||
if (context.isExpired(LocalDateTime.now())) {
|
||||
// 만료 컨텍스트는 정리(감사는 verify/cancel 경로에서 처리)
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
if (context.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(context.getRecipient());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
private boolean isSameFlow(TwoFactorContext ctx, TwoFactorContext.Mode mode, String purpose) {
|
||||
return ctx.getMode() == mode && Objects.equals(ctx.getPurpose(),
|
||||
mode == TwoFactorContext.Mode.STEPUP ? purpose : null);
|
||||
}
|
||||
|
||||
/** 강제 종료: 인증번호 삭제 + (로그인 컨텍스트면) 취소 감사 기록 */
|
||||
private void discardContext(HttpSession session, TwoFactorContext ctx) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, "-", session.getId(), LoginFailureReason.TWO_FACTOR_CANCELLED);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
/** 검증 실패로 절차 종료: 인증번호 삭제 + 감사 + 컨텍스트/pending 정리 */
|
||||
private void terminateWithFailure(HttpSession session, TwoFactorContext ctx,
|
||||
LoginFailureReason reason, HttpServletRequest request) {
|
||||
if (ctx.getMode() == TwoFactorContext.Mode.LOGIN) {
|
||||
String loginId = (String) session.getAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
userLogService.logFailure(loginId, request.getRemoteAddr(), session.getId(), reason);
|
||||
clearPending(session);
|
||||
}
|
||||
if (ctx.getRecipient() != null) {
|
||||
authNumberStorage.deleteAuthNumber(ctx.getRecipient());
|
||||
}
|
||||
session.removeAttribute(ATTR_CONTEXT);
|
||||
}
|
||||
|
||||
private void clearPending(HttpSession session) {
|
||||
session.removeAttribute(ATTR_PENDING_USER_ID);
|
||||
session.removeAttribute(ATTR_PENDING_LOGIN_ID);
|
||||
}
|
||||
|
||||
/** 1차 인증~2FA 사이 계정 상태 재검증. 문제 있으면 사용자 안내 메시지 반환, 정상이면 null */
|
||||
private String revalidateLoginState(PortalUser user) {
|
||||
if ("Y".equalsIgnoreCase(user.getAccountLockYn())) {
|
||||
return "계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.";
|
||||
}
|
||||
if (PortalUserEnums.UserStatus.ADMINBLOCK.equals(user.getUserStatus())) {
|
||||
return "법인 관리자에 의해 비활성화된 계정입니다.";
|
||||
}
|
||||
if (PortalUserEnums.ApprovalStatus.PENDING.equals(user.getApprovalStatus())) {
|
||||
return "사용자 승인 대기중입니다.";
|
||||
}
|
||||
if (user.getPortalOrg() != null
|
||||
&& !PortalOrgEnums.ApprovalStatus.COMPLETED.equals(user.getPortalOrg().getApprovalStatus())) {
|
||||
return "로그인할 수 없습니다. 관리자에게 문의하세요. (법인 승인대기중)";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
/** 2FA 발송 가능 채널 1건. masked 는 화면 표기용 마스킹 수신처. */
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class TwoFactorChannel {
|
||||
/** EMAIL | SMS */
|
||||
private String type;
|
||||
/** 마스킹된 수신처 (예: te**@ex**.com, 010-12**-34**) */
|
||||
private String masked;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** GET /auth/2fa/info 응답. 팝업 초기 렌더용. */
|
||||
@Data
|
||||
public class TwoFactorInfoResponse {
|
||||
/** 컨텍스트 유효 여부(로그인 pending 또는 인증 사용자). false 면 팝업 진입 불가 */
|
||||
private boolean available;
|
||||
/** LOGIN | STEPUP */
|
||||
private String mode;
|
||||
/** 발송 가능 채널(휴대폰 없으면 이메일만) */
|
||||
private List<TwoFactorChannel> channels;
|
||||
/** 인증번호 유효시간(초) — 타이머 초기값 */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호 노출 여부 */
|
||||
private boolean testNoticeEnabled;
|
||||
/** 이미 진행 중인 절차 존재 여부(다른 탭/페이지) */
|
||||
private boolean inProgress;
|
||||
/** 진행 중인 절차의 안내 메시지(있으면) */
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/send 응답. */
|
||||
@Data
|
||||
public class TwoFactorSendResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** 타이머 유효시간(초) */
|
||||
private int ttlSeconds;
|
||||
/** 테스트용 인증번호(테스트 노출 활성 시에만 채워짐) */
|
||||
private String testAuthNumber;
|
||||
/** 이미 진행 중인 절차가 있어 발송을 막은 경우 true (confirm 후 force 재요청 유도) */
|
||||
private boolean inProgress;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.eactive.apim.portal.apps.auth.twofactor.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** POST /auth/2fa/verify 응답. */
|
||||
@Data
|
||||
public class TwoFactorVerifyResponse {
|
||||
private boolean valid;
|
||||
private String message;
|
||||
/** LOGIN 모드 성공 시 이동 대상 URL */
|
||||
private String redirect;
|
||||
/** 실패 시 남은 시도 횟수 */
|
||||
private int remainingAttempts;
|
||||
/** 시도 초과/타임아웃 등으로 절차가 강제 종료되어 재시작이 필요한 경우 true */
|
||||
private boolean terminated;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 실패 사유 코드. PTL_USER_LOG.FAILURE_REASON 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginFailureReason {
|
||||
|
||||
/** 아이디(이메일) 미존재 */
|
||||
ID_NOT_FOUND,
|
||||
/** 비밀번호 불일치 */
|
||||
PASSWORD_MISMATCH,
|
||||
/** 계정 잠금(5회 실패 등) */
|
||||
ACCOUNT_LOCKED,
|
||||
/** 비활성 계정(승인 대기/관리자 차단/법인 미승인) */
|
||||
ACCOUNT_DISABLED,
|
||||
/** 세션 인증 오류(중복 로그인 등) */
|
||||
SESSION_AUTH,
|
||||
/** 2차 인증 - 인증번호 유효시간 초과 */
|
||||
TWO_FACTOR_TIMEOUT,
|
||||
/** 2차 인증 - 인증번호 불일치 */
|
||||
TWO_FACTOR_CODE_MISMATCH,
|
||||
/** 2차 인증 - 사용자가 팝업을 닫아 취소 */
|
||||
TWO_FACTOR_CANCELLED,
|
||||
/** 2차 인증 - 시도 횟수 초과 */
|
||||
TWO_FACTOR_ATTEMPT_EXCEEDED,
|
||||
/** 분류 불가 */
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.apps.login.constants;
|
||||
|
||||
/**
|
||||
* 로그인 유형 코드. PTL_USER_LOG.LOGIN_TYPE 에 문자열(name())로 저장된다.
|
||||
*/
|
||||
public enum LoginType {
|
||||
|
||||
/** 일반 로그인 (2FA 미적용) */
|
||||
NORMAL,
|
||||
/** 2차 인증을 통과한 로그인 */
|
||||
TWO_FACTOR,
|
||||
/** 회원가입 직후 자동 로그인 (2FA 미적용) */
|
||||
SIGNUP_AUTO
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.login.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.common.exception.PortalRedirectException;
|
||||
import com.eactive.apim.portal.common.pagerouter.PageHandler;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -22,6 +23,11 @@ import static com.eactive.apim.portal.apps.login.constants.LoginConstants.LOGIN_
|
||||
@Component("LoginHandler")
|
||||
public class LoginHandler implements PageHandler {
|
||||
|
||||
private final TwoFactorService twoFactorService;
|
||||
|
||||
public LoginHandler(TwoFactorService twoFactorService) {
|
||||
this.twoFactorService = twoFactorService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 화면으로 들어간다
|
||||
@@ -47,6 +53,10 @@ public class LoginHandler implements PageHandler {
|
||||
session.removeAttribute("loginId");
|
||||
}
|
||||
|
||||
// 로그인 2FA 대기 상태면(1차 인증 통과 후) 추가 인증 팝업 자동 오픈 플래그를 내려준다.
|
||||
// pending 중에는 아직 익명이므로 아래 인증자 리다이렉트에 걸리지 않는다.
|
||||
model.addAttribute("twoFactorPending", twoFactorService.hasPendingLogin(session));
|
||||
|
||||
// 이미 인증된 사용자인지 확인
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && !"anonymousUser".equalsIgnoreCase(authentication.getPrincipal().toString())) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.eactive.apim.portal.apps.login.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
|
||||
import com.eactive.apim.portal.config.PasswordEnforcementPolicy;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 로그인 최종 확정(finalize) 처리를 공통화한다.
|
||||
*
|
||||
* <p>기존 {@code PortalAuthenticationSuccessHandler} 에 인라인되어 있던 후처리
|
||||
* (실패카운트 리셋, 감사 성공 기록, 후속 유도 세션 플래그, 초대 확인, 중복로그인 정리,
|
||||
* 물리 세션 타임아웃, 최종 이동 URL 결정)를 여기로 추출했다.</p>
|
||||
*
|
||||
* <p>세 경로가 이 로직을 공유한다:
|
||||
* <ul>
|
||||
* <li>일반 로그인 — 2FA off 시 SuccessHandler 가 직접 호출({@link LoginType#NORMAL})</li>
|
||||
* <li>로그인 2FA 통과 — TwoFactorService 가 호출({@link LoginType#TWO_FACTOR})</li>
|
||||
* <li>회원가입 자동 로그인 — 가입 컨트롤러가 호출({@link LoginType#SIGNUP_AUTO})</li>
|
||||
* </ul>
|
||||
* 최종 이동 URL 을 반환하며, 리다이렉트(HTTP 302)는 호출부 책임이다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class LoginFinalizer {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/**
|
||||
* 로그인 확정 후처리를 수행하고 최종 이동 URL 을 반환한다.
|
||||
*
|
||||
* @param user 인증된 사용자
|
||||
* @param rawUsername 감사/세션 표기에 쓸 사용자 식별자(로그인 폼 입력 원본 또는 loginId)
|
||||
* @param request 현재 요청(IP/헤더/세션)
|
||||
* @param loginType 로그인 유형(감사 기록용)
|
||||
* @return 리다이렉트 대상 URL (contextPath 포함)
|
||||
*/
|
||||
public String finalizeLogin(PortalUser user, String rawUsername, HttpServletRequest request, LoginType loginType) {
|
||||
String normalizedUsername = rawUsername != null ? rawUsername.toLowerCase() : null;
|
||||
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(rawUsername, ip, sessionId, loginType);
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
|
||||
applyPostLoginState(user, session, rawUsername, contextPath);
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 (메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃 10분 고정 (콘솔 override 무관하게 물리=논리 단일화, CSRF 수명 포함)
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
logLoginSuccess(request, session, rawUsername);
|
||||
|
||||
String decisionToken = (String) session.getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
return contextPath + "/signup/decision_process";
|
||||
}
|
||||
return contextPath + "/";
|
||||
}
|
||||
|
||||
/** 후속 유도(이메일 인증/휴면/비밀번호 변경) 세션 플래그 세팅 */
|
||||
private void applyPostLoginState(PortalUser user, HttpSession session, String username, String contextPath) {
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/verify");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.session.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.entity.UserSession;
|
||||
import com.eactive.apim.portal.apps.session.repository.UserSessionRepository;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -65,7 +66,8 @@ public class UserSessionService {
|
||||
.forceLogout("N")
|
||||
.build();
|
||||
userSessionRepository.save(session);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}", loginId, sessionId, ipAddress);
|
||||
log.info("세션 등록 - loginId: {}, sessionId: {}, ip: {}",
|
||||
StringMaskingUtil.maskLoginId(loginId), StringMaskingUtil.maskToken(sessionId), StringMaskingUtil.maskIpAddress(ipAddress));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ public class UserSessionService {
|
||||
public void forceLogoutOtherSessions(String loginId, String currentSessionId) {
|
||||
int count = userSessionRepository.forceLogoutOtherSessions(loginId, currentSessionId);
|
||||
if (count > 0) {
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ public class UserSessionService {
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", StringMaskingUtil.maskLoginId(loginId), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +110,7 @@ public class UserSessionService {
|
||||
public void removeSession(String sessionId) {
|
||||
if (userSessionRepository.existsById(sessionId)) {
|
||||
userSessionRepository.deleteById(sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", sessionId);
|
||||
log.debug("세션 삭제 - sessionId: {}", StringMaskingUtil.maskToken(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AccountController {
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
||||
@@ -60,19 +60,23 @@ public class AccountController {
|
||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
||||
}
|
||||
|
||||
@GetMapping("/change_password")
|
||||
public String showChangePasswordPage(Model model) {
|
||||
@GetMapping("/password/verify")
|
||||
public String showChangePasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// ENFORCE 강제 상태면 변경 페이지에 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (Boolean.TRUE.equals(session.getAttribute("pwEnforce"))) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
return "apps/mypage/passwordChangeEntry";
|
||||
}
|
||||
|
||||
@GetMapping("/new_password")
|
||||
@GetMapping("/password/change")
|
||||
public String showNewPasswordPage(Model model) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@PostMapping("/verify_current_password")
|
||||
@PostMapping("/password/verify")
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||
@@ -80,11 +84,11 @@ public class AccountController {
|
||||
return "apps/mypage/passwordChange";
|
||||
} else {
|
||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
return "redirect:/change_password";
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mypage/change_new_password")
|
||||
@PostMapping("/password/change")
|
||||
public String updatePassword(@RequestParam String newPassword,
|
||||
@RequestParam String confirmPassword,
|
||||
HttpSession session,
|
||||
@@ -96,10 +100,11 @@ public class AccountController {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
|
||||
|
||||
// 비밀번호 만료 관련 세션 속성 제거
|
||||
// 비밀번호 만료/강제 관련 세션 속성 제거
|
||||
session.removeAttribute("passwordExpired");
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
session.removeAttribute("pwEnforce");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
@@ -113,11 +118,16 @@ public class AccountController {
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", e.getMessage());
|
||||
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
|
||||
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
|
||||
logger.error("비밀번호 변경 처리 중 오류", e);
|
||||
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ public class UserManRestController {
|
||||
return new ResponseDTO(200, "SUCCESS", "변경되었습니다. 확인 버튼을 누르시면 계정을 로그아웃 합니다.");
|
||||
}
|
||||
|
||||
// 관리자 -> 이용자 변경 (본인 제외)
|
||||
// 관리자 -> 개발자 변경 (본인 제외)
|
||||
@PostMapping("/revoke-manager")
|
||||
public ResponseDTO revokeManager(@RequestBody PortalUserDTO user) {
|
||||
userManFacade.revokeManager(SecurityUtil.getPortalAuthenticatedUser(), user.getId());
|
||||
return new ResponseDTO(200, "SUCCESS", "이용자로 변경되었습니다.");
|
||||
return new ResponseDTO(200, "SUCCESS", "개발자로 변경되었습니다.");
|
||||
}
|
||||
|
||||
// 소속 제외 -> 개인이용자로 전환
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import javax.validation.Valid;
|
||||
|
||||
@@ -114,6 +115,7 @@ public class UserRegisterController {
|
||||
@Valid @ModelAttribute("portalUser") PortalUserRegistrationDTO portalUserRegistrationDTO,
|
||||
BindingResult bindingResult,
|
||||
HttpSession session,
|
||||
HttpServletRequest request,
|
||||
RedirectAttributes redirectAttributes,
|
||||
Model model) {
|
||||
try {
|
||||
@@ -140,9 +142,19 @@ public class UserRegisterController {
|
||||
// 성공 시 PRG 패턴 적용: 결과 페이지로 리다이렉트
|
||||
session.removeAttribute("invitationToken");
|
||||
|
||||
// 개인 가입자 중 이메일 인증 대상(READY 상태)은 회원가입 직후 바로 이메일 인증 단계로 이동
|
||||
// 개인 가입자 처리 분기
|
||||
if (invitationToken == null) {
|
||||
Optional<PortalUser> registered = portalUserService.findByLoginId(portalUserRegistrationDTO.getLoginId());
|
||||
|
||||
// 이메일 인증을 마쳐 ACTIVE 로 저장된 경우 → 바로 자동 로그인(2차 인증 없이) 후 메인 이동
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.ACTIVE.equals(registered.get().getUserStatus())) {
|
||||
portalUserAuthService.autoLoginAfterSignup(registered.get(), request);
|
||||
redirectAttributes.addFlashAttribute("message", "회원가입이 완료되었습니다.");
|
||||
return "redirect:/";
|
||||
}
|
||||
|
||||
// 이메일 미인증(READY) → 회원가입 직후 이메일 인증 단계로 이동(기존 흐름 유지)
|
||||
if (registered.isPresent()
|
||||
&& PortalUserEnums.UserStatus.READY.equals(registered.get().getUserStatus())) {
|
||||
session.setAttribute("signupVerificationEmail", registered.get().getEmailAddr());
|
||||
@@ -197,6 +209,50 @@ public class UserRegisterController {
|
||||
return "apps/register/signupVerificationEmail";
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 발송. 형식·중복 선검증 후 발송하고, 세션에 대상 이메일을 저장한다.
|
||||
* (가입 완료 전, 폼에서 인라인으로 호출)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/send")
|
||||
public ResponseEntity<ValidationResponse> sendSignupFormEmailCode(@RequestParam String email,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
|
||||
// 형식 + 중복 선검증 (중복 이메일을 인증까지 마친 뒤 가입 단계에서 거절되는 것을 방지)
|
||||
ValidationResponse check = userRegisterFacade.handleCheckNewEmail(normalized);
|
||||
if (!check.isValid()) {
|
||||
return ResponseEntity.ok(check);
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.requestAuth(normalized, "EMAIL");
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupEmailPending", normalized);
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 폼 내 이메일 인증코드 검증. 성공 시 세션에 인증 완료 이메일을 저장한다.
|
||||
* (가입 제출 시 서버가 이 값과 DTO 이메일 일치를 재검증한다)
|
||||
*/
|
||||
@PostMapping("/signup/email-code/verify")
|
||||
public ResponseEntity<ValidationResponse> verifySignupFormEmailCode(@RequestParam String email,
|
||||
@RequestParam String code,
|
||||
HttpSession session) {
|
||||
String normalized = email != null ? email.trim().toLowerCase() : null;
|
||||
String pending = (String) session.getAttribute("signupEmailPending");
|
||||
if (pending == null || !pending.equalsIgnoreCase(normalized)) {
|
||||
return ResponseEntity.ok(new ValidationResponse(false, "인증 요청된 이메일과 일치하지 않습니다."));
|
||||
}
|
||||
|
||||
ValidationResponse response = authFacade.verifyAuthNumber(normalized, code);
|
||||
if (response.isValid()) {
|
||||
session.setAttribute("signupVerifiedEmail", normalized);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 이메일 인증코드 발송. 임의 이메일 타깃 방지를 위해 세션에 저장된 가입 이메일만 사용한다.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
|
||||
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
|
||||
import com.eactive.apim.portal.common.validator.CellPhone;
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotEmpty;
|
||||
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
public class PortalUserRegistrationDTO {
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import com.eactive.apim.portal.common.validator.UniqueId;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserStatus;
|
||||
import lombok.Data;
|
||||
@@ -13,7 +13,7 @@ import java.io.Serializable;
|
||||
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
@PasswordRule(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
public class UserRegisterDTO implements Serializable {
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.user.facade;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.apps.user.service.PasswordService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
|
||||
@@ -71,7 +72,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
updateUserBasicInfo(user, portalUserDTO);
|
||||
portalUserService.updateUser(user);
|
||||
|
||||
log.info("사용자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("사용자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -98,7 +99,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
portalUserService.updateUser(user);
|
||||
portalOrgService.updateOrg(org);
|
||||
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", user.getLoginId());
|
||||
log.info("법인 관리자 정보 업데이트 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 기본 업데이트
|
||||
@@ -136,7 +137,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
// 법인 관리자 탈퇴 제한
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
|
||||
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 이용자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ public class UserFacadeImpl implements UserFacade {
|
||||
messageRequestFacade.deleteUserMessage(user.getUserName(),user.getLoginId());
|
||||
|
||||
portalUserService.deleteUser(user);
|
||||
log.info("회원 탈퇴 처리 완료: {}", user.getLoginId());
|
||||
log.info("회원 탈퇴 처리 완료: {}", StringMaskingUtil.maskLoginId(user.getLoginId()));
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트 유효성 확인
|
||||
@@ -215,6 +216,6 @@ public class UserFacadeImpl implements UserFacade {
|
||||
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
|
||||
portalUserService.save(user);
|
||||
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", email);
|
||||
log.info("사용자 이메일 인증 완료 - 활성화: {}", StringMaskingUtil.maskEmail(email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRoleHistoryService;
|
||||
import com.eactive.apim.portal.apps.user.service.UserRoleHistoryService.ChangeType;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -59,6 +61,7 @@ public class UserManFacade {
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final UserRoleHistoryService userRoleHistoryService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -134,7 +137,7 @@ public class UserManFacade {
|
||||
if (existingUser.isPresent()) {
|
||||
PortalUser user = existingUser.get();
|
||||
if (user.getPortalOrg() != null && !user.getRoleCode().equals(RoleCode.ROLE_USER)) {
|
||||
throw new IllegalArgumentException("기관에 등록된 이용자 입니다.");
|
||||
throw new IllegalArgumentException("기관에 등록된 개발자 입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +243,9 @@ public class UserManFacade {
|
||||
PortalUser currentUser = portalUserRepository.findById(portalAuthenticatedUser.getId())
|
||||
.orElseThrow(() -> new NotFoundException("사용자를 찾을 수 없습니다. "));
|
||||
|
||||
RoleCode targetBefore = targetUser.getRoleCode();
|
||||
RoleCode currentBefore = currentUser.getRoleCode();
|
||||
|
||||
// 3. Assign role code ROLE_CORP_MANAGER to target User
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
|
||||
|
||||
@@ -249,6 +255,10 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 역할 변경 감사 이력: 대상 위임 + 본인 회수
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), targetBefore, RoleCode.ROLE_CORP_MANAGER, ChangeType.MANAGER_ASSIGN);
|
||||
userRoleHistoryService.record(currentUser.getLoginId(), currentBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
@@ -264,7 +274,7 @@ public class UserManFacade {
|
||||
}
|
||||
|
||||
/**
|
||||
* 법인 관리자 권한 회수 (관리자 → 이용자).
|
||||
* 법인 관리자 권한 회수 (관리자 → 개발자).
|
||||
* 대상은 본인이 아닌 같은 기관의 ROLE_CORP_MANAGER 여야 한다.
|
||||
*/
|
||||
public void revokeManager(PortalAuthenticatedUser admin, String targetUserId) {
|
||||
@@ -279,12 +289,16 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인의 권한은 변경할 수 없습니다.");
|
||||
}
|
||||
if (targetUser.getRoleCode() != RoleCode.ROLE_CORP_MANAGER) {
|
||||
throw new IllegalArgumentException("관리자만 이용자로 변경할 수 있습니다.");
|
||||
throw new IllegalArgumentException("관리자만 개발자로 변경할 수 있습니다.");
|
||||
}
|
||||
|
||||
RoleCode revokeBefore = targetUser.getRoleCode();
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 관리자 → 개발자
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), revokeBefore, RoleCode.ROLE_CORP_USER, ChangeType.MANAGER_REVOKE);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
@@ -305,10 +319,14 @@ public class UserManFacade {
|
||||
throw new IllegalArgumentException("본인은 소속에서 제외할 수 없습니다.");
|
||||
}
|
||||
|
||||
RoleCode removeBefore = targetUser.getRoleCode();
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 역할 변경 감사 이력: 소속 제외 → 개인 전환
|
||||
userRoleHistoryService.record(targetUser.getLoginId(), removeBefore, RoleCode.ROLE_USER, ChangeType.ORG_REMOVE);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
@Override
|
||||
public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) {
|
||||
boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber);
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~20자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~50자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
return new ValidationResponse(isValid, message);
|
||||
}
|
||||
|
||||
@@ -143,11 +143,20 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
// 가입 폼에서 이메일 인증을 마쳤는지 확인(세션 signupVerifiedEmail 이 가입 이메일과 일치)
|
||||
String verifiedEmail = (String) session.getAttribute("signupVerifiedEmail");
|
||||
boolean emailVerified = verifiedEmail != null
|
||||
&& verifiedEmail.equalsIgnoreCase(registrationDTO.getLoginId());
|
||||
|
||||
// 3. 사용자 등록 ("personal" 등록 유형으로 가정)
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal");
|
||||
PortalUser newUser = portalUserService.registerActiveUser(registrationDTO, "personal", emailVerified);
|
||||
if (newUser == null) {
|
||||
return new ValidationResponse(false,"사용자 등록에 실패했습니다.");
|
||||
}
|
||||
if (emailVerified) {
|
||||
session.removeAttribute("signupVerifiedEmail");
|
||||
session.removeAttribute("signupEmailPending");
|
||||
}
|
||||
|
||||
agreementsFacade.saveUserAgreements(newUser.getId(), AgreementType.PRIVACY_COLLECT);
|
||||
// 11.13 - 회원 가입단계가 아닌 로그인 단계로 이메일 인증 이동
|
||||
|
||||
@@ -43,6 +43,8 @@ public class PasswordService {
|
||||
// 새 비밀번호 설정
|
||||
String newPasswordHash = passwordEncoder.encode(newPassword);
|
||||
user.setPasswordHash(newPasswordHash);
|
||||
// 변경일 기록 → 재설정 강제(null 트리거) 해제
|
||||
user.setPasswordChangeDate(LocalDateTime.now());
|
||||
portalUserRepository.save(user);
|
||||
|
||||
savePasswordHistory(user.getId(), newPasswordHash);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
@@ -53,6 +55,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = UsernameNotFoundException.class)
|
||||
@@ -101,6 +104,28 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원가입 직후 자동 로그인. formLogin 을 경유하지 않으므로 세션 고정 방어(changeSessionId)를
|
||||
* 수동 수행하고, SuccessHandler 와 동일한 후처리({@link LoginFinalizer})로 세션 등록·감사 기록을 맞춘다.
|
||||
* SuccessHandler 를 타지 않으므로 로그인 2FA 는 자연히 건너뛴다.
|
||||
*
|
||||
* @return 이동 대상 URL
|
||||
*/
|
||||
@Transactional
|
||||
public String autoLoginAfterSignup(PortalUser user, javax.servlet.http.HttpServletRequest request) {
|
||||
// 세션 고정 공격 방어 (form login 미경유 → 수동)
|
||||
request.changeSessionId();
|
||||
|
||||
PortalAuthenticatedUser authUser = buildAuthenticatedUser(user);
|
||||
UsernamePasswordAuthenticationToken token =
|
||||
new UsernamePasswordAuthenticationToken(authUser, null, authUser.getAuthorities());
|
||||
token.setDetails(authUser);
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
|
||||
// 세션 등록 / 감사 성공 기록(SIGNUP_AUTO) / 타임아웃 설정 재사용
|
||||
return loginFinalizer.finalizeLogin(user, user.getLoginId(), request, LoginType.SIGNUP_AUTO);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.user.entity.UserLog;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import java.time.LocalDateTime;
|
||||
@@ -17,23 +19,33 @@ public class PortalUserLogService {
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId) {
|
||||
logSuccess(userId, ip, sessionId, LoginType.NORMAL);
|
||||
}
|
||||
|
||||
public void logSuccess(String userId, String ip, String sessionId, LoginType loginType) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(true);
|
||||
log.setLoginType(loginType != null ? loginType.name() : null);
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId) {
|
||||
logFailure(userId, ip, sessionId, LoginFailureReason.UNKNOWN);
|
||||
}
|
||||
|
||||
public void logFailure(String userId, String ip, String sessionId, LoginFailureReason reason) {
|
||||
UserLog log = new UserLog();
|
||||
log.setLoginId(userId);
|
||||
log.setLoginTime(LocalDateTime.now());
|
||||
log.setIp(ip);
|
||||
log.setSessionId(sessionId);
|
||||
log.setSuccess(false);
|
||||
log.setFailureReason(reason != null ? reason.name() : LoginFailureReason.UNKNOWN.name());
|
||||
|
||||
userLogRepository.save(log);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,14 @@ public class PortalUserService {
|
||||
|
||||
// 승인 대기 상태.
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType) {
|
||||
return registerActiveUser(newUserDTO, registrationType, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param emailVerified 가입 폼에서 이메일 인증을 이미 완료했으면 true → 바로 ACTIVE 로 저장
|
||||
* (가입 후 별도 이메일 인증 단계를 건너뛴다)
|
||||
*/
|
||||
public PortalUser registerActiveUser(PortalUserRegistrationDTO newUserDTO, String registrationType, boolean emailVerified) {
|
||||
PortalUser newUser = new PortalUser();
|
||||
mapDtoToEntity(newUser, newUserDTO);
|
||||
setUserProperties(newUser);
|
||||
@@ -149,8 +157,9 @@ public class PortalUserService {
|
||||
|
||||
newUser.setUserStatus(UserStatus.READY);
|
||||
|
||||
// 이메일 인증 기능 비활성화 시 바로 활성화 처리
|
||||
if ("true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
// 이메일 인증 기능 비활성화 시, 또는 가입 폼에서 이미 인증을 마친 경우 바로 활성화 처리
|
||||
if (emailVerified
|
||||
|| "true".equalsIgnoreCase(propertyMap.getOrDefault("disable_features.user_email_verify", ""))) {
|
||||
newUser.setUserStatus(UserStatus.ACTIVE);
|
||||
}
|
||||
newUser.setApprovalStatus(ApprovalStatus.COMPLETED);
|
||||
@@ -184,6 +193,8 @@ public class PortalUserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||||
user.setMobileNumber(dto.getMobileNumber());
|
||||
user.setEmailAddr(normalizedEmail);
|
||||
// 가입 시점을 비밀번호 변경일로 기록 → 신규 가입자는 재설정 강제 대상에서 제외된다.
|
||||
user.setPasswordChangeDate(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserRoleHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 역할(권한) 변경 감사 이력 기록 서비스.
|
||||
*
|
||||
* <p>법인 관리자 위임/회수, 소속 제외 등 역할 변경 이벤트를 {@code ptl_user_role_history} 에 남긴다.
|
||||
* 변경 수행자(changedBy)는 현재 인증된 관리자의 loginId 로 기록한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserRoleHistoryService {
|
||||
|
||||
/** 역할 변경 유형. */
|
||||
public enum ChangeType {
|
||||
MANAGER_ASSIGN, // 관리자 권한 위임
|
||||
MANAGER_REVOKE, // 관리자 권한 회수
|
||||
ORG_REMOVE // 소속 제외(개인으로 전환)
|
||||
}
|
||||
|
||||
private final UserRoleHistoryRepository userRoleHistoryRepository;
|
||||
|
||||
/**
|
||||
* 역할 변경 이력을 기록한다. 감사 목적이므로 실패해도 본 트랜잭션을 롤백시키지 않도록
|
||||
* 호출부에서 예외를 전파하지 않는다(내부에서 로깅만).
|
||||
*/
|
||||
@Transactional
|
||||
public void record(String targetLoginId, RoleCode before, RoleCode after, ChangeType changeType) {
|
||||
try {
|
||||
String actor = SecurityUtil.getCurrentLoginId();
|
||||
if (actor == null || actor.isEmpty()) {
|
||||
actor = "SYSTEM";
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
UserRoleHistory history = new UserRoleHistory();
|
||||
history.setUserId(targetLoginId);
|
||||
history.setBeforeRole(before != null ? before.name() : null);
|
||||
history.setAfterRole(after != null ? after.name() : null);
|
||||
history.setChangeType(changeType.name());
|
||||
history.setChangedBy(actor);
|
||||
history.setChangeDate(now);
|
||||
history.setCreatedBy(actor);
|
||||
history.setCreatedDate(now);
|
||||
|
||||
userRoleHistoryRepository.save(history);
|
||||
} catch (Exception e) {
|
||||
log.error("역할 변경 이력 기록 실패 - target: {}, type: {}", targetLoginId, changeType, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.user.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -17,13 +16,13 @@ public class PasswordValidator {
|
||||
}
|
||||
|
||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator();
|
||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||
return validator.isValid(password, loginId, mobileNumber);
|
||||
}
|
||||
|
||||
private boolean isValidLengthAndCharacters(String password) {
|
||||
final int MIN = 8;
|
||||
final int MAX = 20;
|
||||
final int MAX = 50;
|
||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
||||
return password.matches(REGEX);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.eactive.apim.portal.common.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
public class PasswordValidationDTO {
|
||||
private String password;
|
||||
private String loginId;
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -49,12 +51,20 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = UserNotLoginException.class)
|
||||
public ModelAndView handleUserNotLoginException(HttpServletRequest request, UserNotLoginException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||
return new ModelAndView("redirect:/login");
|
||||
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return new ModelAndView("redirect:/login?reason=auth");
|
||||
}
|
||||
log.warn("접근 권한 없음: loginId={}, uri={}", StringMaskingUtil.maskLoginId(SecurityUtil.getCurrentLoginId()), request.getRequestURI());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "페이지 접근 권한이 없습니다.");
|
||||
modelAndView.addObject("errorDescription", "해당 페이지를 이용할 수 있는 권한이 없는 계정입니다.\n권한이 필요한 경우 관리자에게 문의해 주세요.");
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = PortalRedirectException.class)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.common.migration;
|
||||
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -177,7 +178,8 @@ public class LegacyEncryptionMigrationController {
|
||||
|| "::1".equals(remote);
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
if (!localAddr || viaProxy) {
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", remote, request.getHeader("X-Forwarded-For"));
|
||||
log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}",
|
||||
StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,98 @@ public class StringMaskingUtil {
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 ID 마스킹 처리 (로그 출력용).
|
||||
* 이메일 형식이면 {@link #maskEmail(String)} 규칙을, 그 외(스태프 등 비이메일 ID)는 부분 마스킹을 적용한다.
|
||||
*/
|
||||
public static String maskLoginId(String loginId) {
|
||||
if (!isValidString(loginId)) {
|
||||
return loginId;
|
||||
}
|
||||
if (loginId.contains("@")) {
|
||||
return maskEmail(loginId);
|
||||
}
|
||||
int len = loginId.length();
|
||||
if (len <= 2) {
|
||||
return stars(len);
|
||||
}
|
||||
if (len <= 4) {
|
||||
return loginId.charAt(0) + stars(len - 1);
|
||||
}
|
||||
// 5자 이상: 앞2 + 마스킹 + 뒤1
|
||||
return loginId.substring(0, 2) + stars(len - 3) + loginId.charAt(len - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* IP 주소 마스킹 처리 (로그 출력용). 첫 옥텟만 남기고 나머지를 마스킹한다.
|
||||
* <pre>127.0.0.1 → 127.***.***.***</pre>
|
||||
* IPv4 형식이 아니면 원본을 반환한다(멱등).
|
||||
*/
|
||||
public static String maskIpAddress(String ip) {
|
||||
if (!isValidString(ip)) {
|
||||
return ip;
|
||||
}
|
||||
String[] parts = ip.split("\\.");
|
||||
if (parts.length != 4) {
|
||||
return ip;
|
||||
}
|
||||
return parts[0] + ".***.***.***";
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰/세션ID 등 식별자 마스킹 처리 (로그 출력용). 앞 4자 + {@code ***} + 뒤 4자만 노출한다.
|
||||
* 길이가 짧으면(9자 미만) 전체를 마스킹한다.
|
||||
*/
|
||||
public static String maskToken(String token) {
|
||||
if (!isValidString(token)) {
|
||||
return token;
|
||||
}
|
||||
int len = token.length();
|
||||
if (len < 9) {
|
||||
return stars(len);
|
||||
}
|
||||
return token.substring(0, 4) + "***" + token.substring(len - 4);
|
||||
}
|
||||
|
||||
// 로그에 값을 그대로 남기면 안 되는 민감 헤더(소문자 비교)
|
||||
private static final java.util.Set<String> SENSITIVE_HEADERS = new java.util.HashSet<>(Arrays.asList(
|
||||
"cookie", "set-cookie", "authorization", "proxy-authorization",
|
||||
"x-auth-token", "x-csrf-token", "x-xsrf-token", "x-api-key"));
|
||||
|
||||
private static final String REDACTED = "***REDACTED***";
|
||||
|
||||
/**
|
||||
* 요청/응답 헤더 로깅 시 민감 헤더(Cookie/Authorization 등)의 값을 {@code ***REDACTED***} 로 치환한다.
|
||||
* 그 외 헤더는 원본 값을 반환한다.
|
||||
*/
|
||||
public static String maskHeaderValue(String headerName, String headerValue) {
|
||||
if (headerName != null && SENSITIVE_HEADERS.contains(headerName.toLowerCase())) {
|
||||
return REDACTED;
|
||||
}
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
// 세션 속성 이름에 포함되면 값을 리댁트할 키워드(소문자 부분일치)
|
||||
private static final String[] SENSITIVE_ATTRIBUTE_KEYWORDS = {
|
||||
"token", "secret", "password", "credential", "csrf",
|
||||
"security_context", "loginid", "authentication"};
|
||||
|
||||
/**
|
||||
* 세션 속성 로깅 시 민감 속성(SPRING_SECURITY_CONTEXT, token, loginId 등)의 값을 리댁트한다.
|
||||
* 속성 이름에 민감 키워드가 부분일치하면 {@code ***REDACTED***} 를 반환한다.
|
||||
*/
|
||||
public static String maskAttributeValue(String attrName, String value) {
|
||||
if (attrName != null) {
|
||||
String lower = attrName.toLowerCase();
|
||||
for (String kw : SENSITIVE_ATTRIBUTE_KEYWORDS) {
|
||||
if (lower.contains(kw)) {
|
||||
return REDACTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 기존 메서드 오버로딩 (하위 호환성)
|
||||
public static String maskName(String name) {
|
||||
return maskName(name, null, null);
|
||||
|
||||
@@ -5,14 +5,20 @@ import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRule {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 3자리 이상 연속,반복 문자 불가)";
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~50자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForDjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForKbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKbankValidator implements ConstraintValidator<PasswordRuleForKbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -8,11 +10,15 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, String> {
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
// 최소 8자, 최대 50자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
private static final int MAX = 50;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
@@ -21,11 +27,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRule constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) {
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
@@ -43,10 +68,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
if (strLen > MAX || strLen < MIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
@@ -94,5 +139,4 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제(ENFORCE) 가드.
|
||||
*
|
||||
* <p>로그인 시 {@code PortalAuthenticationSuccessHandler} 가 대상자(비밀번호 미변경/만료)에게
|
||||
* 세션 플래그 {@link #ENFORCE_SESSION_ATTR} 를 설정한다. 이 플래그가 있는 동안에는 비밀번호
|
||||
* 변경/검증/로그아웃 경로를 제외한 모든 요청을 변경 페이지로 리다이렉트하여 접근을 차단한다.
|
||||
* 비밀번호 변경 완료 시 플래그가 제거되어 정상 접근이 회복된다.</p>
|
||||
*
|
||||
* <p>정적 자원 경로는 {@code PortalConfigWebDispatcherServlet.addInterceptors} 의
|
||||
* excludePathPatterns 로 제외한다.</p>
|
||||
*/
|
||||
public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** ENFORCE 대상 세션 플래그. 로그인 핸들러가 설정, 변경 완료 시 제거. */
|
||||
public static final String ENFORCE_SESSION_ATTR = "pwEnforce";
|
||||
|
||||
/** 강제 상태에서도 접근 허용하는 경로(화이트리스트) */
|
||||
private static final Set<String> ALLOWED_PATHS = new HashSet<>(Arrays.asList(
|
||||
"/password/verify", // 현재 비밀번호 입력(진입) + 검증(POST)
|
||||
"/password/change", // 새 비밀번호 폼(GET) + 실제 변경(POST)
|
||||
"/password/confirm", // 비밀번호 확인 AJAX
|
||||
"/actionLogout.do", // 로그아웃
|
||||
"/login",
|
||||
"/error", "/403", "/404"
|
||||
));
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null || !Boolean.TRUE.equals(session.getAttribute(ENFORCE_SESSION_ATTR))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AJAX/API 등 비(非)페이지 요청은 강제 리다이렉트 대상에서 제외한다.
|
||||
// (세션 heartbeat 같은 인프라 호출을 302로 튕기면 keepalive JS가 세션만료로 오판하여
|
||||
// 로그인↔홈↔변경페이지 무한 리다이렉트가 발생한다.)
|
||||
if (!isTopLevelHtmlNavigation(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (path != null && ALLOWED_PATHS.contains(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String target = request.getContextPath() + "/password/verify";
|
||||
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
|
||||
if (request.getRequestURI().equals(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
response.sendRedirect(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 주소창 이동(최상위 HTML 문서 요청)인지 판별한다.
|
||||
* GET + Accept: text/html + 비-AJAX 만 강제 리다이렉트 대상으로 본다.
|
||||
*/
|
||||
private boolean isTopLevelHtmlNavigation(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
|
||||
return false;
|
||||
}
|
||||
String fetchMode = request.getHeader("Sec-Fetch-Mode");
|
||||
if (fetchMode != null && !"navigate".equalsIgnoreCase(fetchMode)) {
|
||||
return false;
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
return accept != null && accept.contains("text/html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제 정책 레벨.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code password.change.enforcement}) 값으로 제어한다.
|
||||
* 키는 DB 관례(점 구분 소문자, 예: {@code session.timeout.minutes})를 따른다.
|
||||
* 값은 enum 명({@code NONE}/{@code PERMISSIVE}/{@code ENFORCE}, 대소문자 무시)이다.</p>
|
||||
* <ul>
|
||||
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
|
||||
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
|
||||
* <li>{@link #ENFORCE} — 대상자는 비밀번호 변경 완료 전까지 변경/검증/로그아웃 외 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum PasswordEnforcementPolicy {
|
||||
NONE,
|
||||
PERMISSIVE,
|
||||
ENFORCE;
|
||||
|
||||
/** PTL_PROPERTY 그룹명 */
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
/** PTL_PROPERTY 이름 (점 구분 소문자 관례) */
|
||||
public static final String PROPERTY_NAME = "password.change.enforcement";
|
||||
/** 기본값 (배포 직후 동작) */
|
||||
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
|
||||
|
||||
/**
|
||||
* 문자열을 정책으로 파싱한다. 대소문자 무시, 미해당/공백이면 {@link #DEFAULT} 반환.
|
||||
*/
|
||||
public static PasswordEnforcementPolicy from(String value) {
|
||||
if (value == null) {
|
||||
return DEFAULT;
|
||||
}
|
||||
try {
|
||||
return PasswordEnforcementPolicy.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
@@ -13,6 +15,9 @@ import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import org.apache.groovy.util.Maps;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
||||
@@ -83,11 +88,11 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
logger.error("{} login try {}", username, e.getMessage());
|
||||
logger.error("{} login try {}", StringMaskingUtil.maskLoginId(username), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
userLogService.logFailure(username, ip, sessionId);
|
||||
userLogService.logFailure(username, ip, sessionId, resolveFailureReason(exception));
|
||||
|
||||
// 로그인 실패 시 세션 정보 로깅
|
||||
logLoginFailure(request, username, exception);
|
||||
@@ -99,24 +104,45 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
response.sendRedirect(contextPath + "/login");
|
||||
}
|
||||
|
||||
/** 인증 예외 타입 → 감사 로그 실패 사유 코드 매핑 */
|
||||
private LoginFailureReason resolveFailureReason(AuthenticationException exception) {
|
||||
if (exception instanceof UsernameNotFoundException) {
|
||||
return LoginFailureReason.ID_NOT_FOUND;
|
||||
}
|
||||
if (exception instanceof BadCredentialsException) {
|
||||
return LoginFailureReason.PASSWORD_MISMATCH;
|
||||
}
|
||||
if (exception instanceof LockedException) {
|
||||
return LoginFailureReason.ACCOUNT_LOCKED;
|
||||
}
|
||||
if (exception instanceof DisabledException) {
|
||||
return LoginFailureReason.ACCOUNT_DISABLED;
|
||||
}
|
||||
if (exception instanceof SessionAuthenticationException) {
|
||||
return LoginFailureReason.SESSION_AUTH;
|
||||
}
|
||||
logger.warn("미분류 로그인 실패 예외 타입: {}", exception.getClass().getName());
|
||||
return LoginFailureReason.UNKNOWN;
|
||||
}
|
||||
|
||||
private void logLoginFailure(HttpServletRequest request, String username, AuthenticationException exception) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN FAILURE\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(request.getSession().getId()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(username)).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(request.getSession().getId())).append("\n");
|
||||
logMessage.append("Failed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Failure Reason: ").append(exception.getLocalizedMessage()).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -133,7 +159,7 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginType;
|
||||
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -31,192 +20,59 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* 로그인 1차 인증(ID/PW) 성공 핸들러.
|
||||
*
|
||||
* <p>로그인 2FA 가 활성화되어 있고 DORMANT 가 아니면, 후처리를 확정하지 않고
|
||||
* 2FA 대기(pending) 상태로 전환한다: 세션에 대기 정보를 저장하고 SecurityContext 를
|
||||
* 비워 사용자를 익명으로 되돌린 뒤 {@code /login?twofactor=1} 로 보낸다. 로그인 페이지가
|
||||
* 공통 2FA 팝업을 자동 오픈하고, 인증 성공 시 {@code TwoFactorService} 가 최종 확정한다.</p>
|
||||
*
|
||||
* <p>2FA off(또는 DORMANT)면 {@link LoginFinalizer} 로 기존과 동일하게 즉시 확정한다.
|
||||
* 실질 후처리 로직은 모두 {@link LoginFinalizer} 로 이관되어 로그인/2FA/가입자동로그인이 공유한다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class PortalAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
|
||||
|
||||
private static final Logger sessionLogger = LoggerFactory.getLogger("eapim.portal.session");
|
||||
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
|
||||
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalProperties portalProperties;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final UserPasswordHistoryRepository passwordHistoryRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final LoginFinalizer loginFinalizer;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
String username = request.getParameter("id");
|
||||
// 이메일 소문자 변환 적용
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername).orElse(null);
|
||||
if (user == null) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?error=true");
|
||||
return;
|
||||
}
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
String ip = request.getRemoteAddr();
|
||||
String sessionId = request.getSession().getId();
|
||||
userLogService.logSuccess(username, ip, sessionId);
|
||||
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
|
||||
String contextPath = request.getContextPath();
|
||||
HttpSession session = request.getSession();
|
||||
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
|
||||
if (twoFactorProperties.isLoginEnabled() && !dormant) {
|
||||
user.setLoginFailureCount(0);
|
||||
portalUserRepository.save(user);
|
||||
|
||||
// 세션에 상태 저장
|
||||
if (isEmailVerificationRequired(user)) {
|
||||
session.setAttribute("success", "이메일 인증이 완료되지 않았습니다. 이메일을 확인하여 인증을 완료해주세요.");
|
||||
session.setAttribute("emailVerificationRequired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/mypage/verification-email");
|
||||
} else if (isDormantAccount(user)) {
|
||||
session.setAttribute("success", "90일 이상 미접속하여 계정이 잠금 처리되었습니다. 본인인증 후 이용해주세요.");
|
||||
session.setAttribute("dormantAccount", true);
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
session.setAttribute("success", "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
session.setAttribute("success", "비밀번호를 변경한 지 90일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
HttpSession session = request.getSession();
|
||||
twoFactorService.beginLoginChallenge(session, user);
|
||||
|
||||
// 2FA 완료 전까지 익명 상태로 되돌린다(보호 경로 자동 차단, LoginHandler 튕김 회피).
|
||||
SecurityContextHolder.clearContext();
|
||||
session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
|
||||
|
||||
response.sendRedirect(request.getContextPath() + "/login?twofactor=1");
|
||||
return;
|
||||
}
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
if (invitation.getExpiresOn().isAfter(LocalDateTime.now())) {
|
||||
// 세션에 초대 정보 저장 (메인 페이지에서 팝업으로 표시)
|
||||
session.setAttribute("pendingInvitation", true);
|
||||
session.setAttribute("pendingInvitationToken", invitation.getToken());
|
||||
// orgId로 기관명 조회
|
||||
String orgName = portalOrgRepository.findById(invitation.getOrgId())
|
||||
.map(PortalOrg::getOrgName)
|
||||
.orElse("알 수 없는 기관");
|
||||
session.setAttribute("pendingInvitationOrgName", orgName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
|
||||
String clientIp = HttpRequestUtil.getClientIpAddress(request);
|
||||
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
logLoginSuccess(request, session, username);
|
||||
|
||||
String decisionToken = (String) request.getSession().getAttribute("decisionToken");
|
||||
if (decisionToken != null) {
|
||||
response.sendRedirect(contextPath + "/signup/decision_process");
|
||||
} else {
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
|
||||
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
|
||||
response.sendRedirect(redirect);
|
||||
}
|
||||
|
||||
private void logLoginSuccess(HttpServletRequest request, HttpSession session, String username) {
|
||||
StringBuilder logMessage = new StringBuilder();
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGIN SUCCESS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Username: ").append(username).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Login At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
logMessage.append("Query String: ").append(request.getQueryString()).append("\n");
|
||||
|
||||
logMessage.append("\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST HEADERS\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
|
||||
java.util.Enumeration<String> headerNames = request.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String headerName = headerNames.nextElement();
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
}
|
||||
}
|
||||
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
|
||||
// 비밀번호 변경 이력이 있는 경우
|
||||
if (latestHistory.isPresent()) {
|
||||
LocalDateTime lastChangeDate = latestHistory.get().getChangeDate();
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(lastChangeDate);
|
||||
}
|
||||
|
||||
return LocalDateTime.now()
|
||||
.minusDays(portalProperties.getPasswordExpirationDays())
|
||||
.isAfter(user.getCreatedDate());
|
||||
}
|
||||
|
||||
private boolean isTemporaryPasswordLogin(PortalUser user) {
|
||||
Optional<MessageRequest> latestResetRequest = messageRequestRepository.findFirstByEmailAndMessageCodeOrderByRequestDateDesc(
|
||||
user.getLoginId(), MessageCode.USER_PASSWORD_RESET);
|
||||
|
||||
if (latestResetRequest.isPresent()) {
|
||||
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
.findTopByUserIdOrderByChangeDateDesc(user.getId());
|
||||
return !latestHistory.isPresent() || latestHistory.get().getChangeDate().isBefore(latestResetRequest.get().getRequestDate());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDormantAccount(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
private boolean isEmailVerificationRequired(PortalUser user) {
|
||||
return PortalUserEnums.UserStatus.READY.equals(user.getUserStatus());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@@ -39,6 +40,8 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
public static final String ERROR = "error";
|
||||
|
||||
private final Environment environment;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService;
|
||||
private final com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties;
|
||||
|
||||
// 정적자원 해시 버전닝 토글(application.yml: app.resource-versioning.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceVersioningEnabled 참고).
|
||||
@@ -51,8 +54,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
public PortalConfigWebDispatcherServlet(Environment environment,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
|
||||
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties) {
|
||||
this.environment = environment;
|
||||
this.twoFactorService = twoFactorService;
|
||||
this.twoFactorProperties = twoFactorProperties;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +83,27 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registry.addConverter(enabledStatusConverter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
String[] staticExcludes = {
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**"};
|
||||
|
||||
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
|
||||
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes);
|
||||
|
||||
// step-up 2FA 가드. 비밀번호 강제 가드 "다음" 순서로 등록(강제 변경 상태가 우선).
|
||||
// 2FA 엔드포인트 자체(/auth/2fa/**)는 제외해 순환을 막는다.
|
||||
registry.addInterceptor(new com.eactive.apim.portal.apps.auth.twofactor.StepUpAuthInterceptor(
|
||||
twoFactorService, twoFactorProperties))
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(staticExcludes)
|
||||
.excludePathPatterns("/auth/2fa/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EnabledStatusConverter enabledStatusConverter() {
|
||||
return new EnabledStatusConverter();
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -56,11 +57,11 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("USER LOGOUT\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Logout At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
|
||||
if (authentication != null) {
|
||||
logMessage.append("Username: ").append(authentication.getName()).append("\n");
|
||||
logMessage.append("Username: ").append(StringMaskingUtil.maskLoginId(authentication.getName())).append("\n");
|
||||
logMessage.append("Authenticated: ").append(authentication.isAuthenticated()).append("\n");
|
||||
}
|
||||
|
||||
@@ -68,10 +69,10 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
logMessage.append("Request URI: ").append(request.getRequestURI()).append("\n");
|
||||
@@ -88,7 +89,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
java.util.Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ public class PortalLogoutSuccessHandler implements LogoutHandler, LogoutSuccessH
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
String valueStr = attrValue != null ? attrValue.toString() : "null";
|
||||
String valueStr = StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null");
|
||||
if (valueStr.length() > 100) {
|
||||
valueStr = valueStr.substring(0, 97) + "...";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.Logger;
|
||||
@@ -48,7 +49,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("NEW HTTP SESSION CREATED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Created At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -57,10 +58,10 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("REQUEST INFORMATION\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('-', 80)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(HttpRequestUtil.getClientIpAddress(request)).append("\n");
|
||||
logMessage.append("Client IP Address: ").append(StringMaskingUtil.maskIpAddress(HttpRequestUtil.getClientIpAddress(request))).append("\n");
|
||||
logMessage.append("Client Host: ").append(HttpRequestUtil.getClientHost(request)).append("\n");
|
||||
logMessage.append("Is Proxied: ").append(HttpRequestUtil.isProxied(request)).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(request.getRemoteAddr()).append("\n");
|
||||
logMessage.append("Remote Address (Direct): ").append(StringMaskingUtil.maskIpAddress(request.getRemoteAddr())).append("\n");
|
||||
logMessage.append("Remote Host (Direct): ").append(request.getRemoteHost()).append("\n");
|
||||
logMessage.append("Remote Port: ").append(request.getRemotePort()).append("\n");
|
||||
logMessage.append("Request Method: ").append(request.getMethod()).append("\n");
|
||||
@@ -84,7 +85,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
Enumeration<String> headerValues = request.getHeaders(headerName);
|
||||
while (headerValues.hasMoreElements()) {
|
||||
String headerValue = headerValues.nextElement();
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, headerValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", headerName, StringMaskingUtil.maskHeaderValue(headerName, headerValue)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -106,7 +107,7 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("HTTP SESSION DESTROYED\n");
|
||||
logMessage.append(StringRepeatUtil.repeat('=', 80)).append("\n");
|
||||
logMessage.append("Session ID: ").append(session.getId()).append("\n");
|
||||
logMessage.append("Session ID: ").append(StringMaskingUtil.maskToken(session.getId())).append("\n");
|
||||
logMessage.append("Destroyed At: ").append(LocalDateTime.now().format(formatter)).append("\n");
|
||||
logMessage.append("Max Inactive Interval: ").append(session.getMaxInactiveInterval()).append(" seconds\n");
|
||||
|
||||
@@ -121,7 +122,8 @@ public class SessionLoggingListener implements HttpSessionListener {
|
||||
while (attributeNames.hasMoreElements()) {
|
||||
String attrName = attributeNames.nextElement();
|
||||
Object attrValue = session.getAttribute(attrName);
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName, attrValue));
|
||||
logMessage.append(String.format(" %-30s : %s\n", attrName,
|
||||
StringMaskingUtil.maskAttributeValue(attrName, attrValue != null ? attrValue.toString() : "null")));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logMessage.append(" Unable to retrieve session attributes\n");
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package com.eactive.apim.portal.djb.webhook.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import javax.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.SessionAttributes;
|
||||
import org.springframework.web.bind.support.SessionStatus;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
|
||||
*
|
||||
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
|
||||
* 참조: {@code apps/app/controller/MyAppController}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Controller
|
||||
@RequestMapping("/webhook")
|
||||
@Secured("ROLE_WEBHOOK")
|
||||
@RequiredArgsConstructor
|
||||
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||
public class WebhookController {
|
||||
// Webhook 관리는 법인 관리자 전용(ROLE_WEBHOOK). 조회/신청/수정/삭제/Secret 관리 모두 관리자만 가능.
|
||||
// (개발자 ROLE_CORP_USER 의 앱 권한 확장과 분리하기 위해 ROLE_API_KEY_REQUEST 에서 별도 authority 로 격리)
|
||||
|
||||
private static final int TOTAL_STEPS = 3;
|
||||
|
||||
private final WebhookService webhookService;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final ApiServiceService apiServiceService;
|
||||
private final AppServiceFacade appServiceFacade;
|
||||
|
||||
@ModelAttribute("webhookRegistration")
|
||||
public WebhookRegistrationDTO webhookRegistration() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
@ModelAttribute("webhookModification")
|
||||
public WebhookRegistrationDTO webhookModification() {
|
||||
return new WebhookRegistrationDTO();
|
||||
}
|
||||
|
||||
// ============================ 조회 ============================
|
||||
|
||||
@Secured("ROLE_WEBHOOK")
|
||||
@GetMapping
|
||||
public ModelAndView index() {
|
||||
String orgId = currentOrgId();
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
|
||||
if (webhook.isPresent()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
|
||||
mav.addObject("webhook", webhook.get());
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("apps/webhook/webhookEmpty");
|
||||
}
|
||||
|
||||
// ======================= 신규 신청 플로우 =======================
|
||||
|
||||
@GetMapping("/register/step1")
|
||||
public ModelAndView registerStep1(
|
||||
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
Model model) {
|
||||
|
||||
if (webhookService.existsByOrg(currentOrgId())) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
if (clear) {
|
||||
sessionStatus.setComplete();
|
||||
registration = new WebhookRegistrationDTO();
|
||||
model.addAttribute("webhookRegistration", registration);
|
||||
}
|
||||
registration.setRequestType("NEW");
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/register/step1")
|
||||
public ModelAndView processStep1(
|
||||
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(registration, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/register/step2")
|
||||
public ModelAndView registerStep2(
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
if (!registration.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
|
||||
@PostMapping("/register/step2/save")
|
||||
public ModelAndView saveStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
|
||||
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/register/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/register/step2")
|
||||
public ModelAndView processStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
registration.setSelectedApis(selectedApis);
|
||||
if (!registration.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
|
||||
try {
|
||||
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
|
||||
webhookService.create(registration, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/register/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/register/step3")
|
||||
public ModelAndView registerStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/register/cancel")
|
||||
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ========================= 수정 플로우 =========================
|
||||
|
||||
@GetMapping("/modify/step1")
|
||||
public ModelAndView modifyStep1(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
Model model) {
|
||||
|
||||
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
|
||||
if (!current.isPresent()) {
|
||||
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
|
||||
}
|
||||
WebhookDTO webhook = current.get();
|
||||
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
|
||||
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
|
||||
modification.setId(webhook.getId());
|
||||
modification.setRequestType("MODIFY");
|
||||
modification.setTargetUrl(webhook.getTargetUrl());
|
||||
modification.setEventTypes(webhook.getEventTypes().stream()
|
||||
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
|
||||
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
|
||||
model.addAttribute("webhookModification", modification);
|
||||
}
|
||||
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step1")
|
||||
public ModelAndView processModifyStep1(
|
||||
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
BindingResult bindingResult,
|
||||
Model model) {
|
||||
|
||||
validateStep1(modification, bindingResult);
|
||||
if (bindingResult.hasErrors()) {
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||
addStepModel(mav, 1);
|
||||
return mav;
|
||||
}
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step2")
|
||||
public ModelAndView modifyStep2(
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
|
||||
if (modification.getId() == null || !modification.isStep1Complete()) {
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||
addStepModel(mav, 2);
|
||||
return mav;
|
||||
}
|
||||
|
||||
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
|
||||
@PostMapping("/modify/step2/save")
|
||||
public ModelAndView saveModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||
}
|
||||
|
||||
@PostMapping("/modify/step2")
|
||||
public ModelAndView processModifyStep2(
|
||||
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||
SessionStatus sessionStatus,
|
||||
RedirectAttributes redirectAttributes) {
|
||||
|
||||
modification.setSelectedApis(selectedApis);
|
||||
if (modification.getId() == null || !modification.isStep2Complete()) {
|
||||
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
try {
|
||||
webhookService.update(modification.getId(), modification, currentOrgId());
|
||||
sessionStatus.setComplete();
|
||||
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/modify/step3")
|
||||
public ModelAndView modifyStep3(Model model) {
|
||||
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
|
||||
return new ModelAndView("redirect:/webhook");
|
||||
}
|
||||
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
|
||||
addStepModel(mav, 3);
|
||||
return mav;
|
||||
}
|
||||
|
||||
@GetMapping("/modify/cancel")
|
||||
public String cancelModification(SessionStatus sessionStatus) {
|
||||
sessionStatus.setComplete();
|
||||
return "redirect:/webhook";
|
||||
}
|
||||
|
||||
// ==================== AJAX (비밀번호 재인증) ====================
|
||||
|
||||
@PostMapping("/verify-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> verifySecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
result.put("success", true);
|
||||
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/regenerate-secret")
|
||||
@ResponseBody
|
||||
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
result.put("secret", secret);
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@ResponseBody
|
||||
public Map<String, Object> delete(@RequestParam String password) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (!verifyPassword(password)) {
|
||||
result.put("success", false);
|
||||
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||
return result;
|
||||
}
|
||||
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||
if (!webhook.isPresent()) {
|
||||
result.put("success", false);
|
||||
result.put("message", "등록된 Webhook이 없습니다.");
|
||||
return result;
|
||||
}
|
||||
webhookService.delete(webhook.get().getId(), currentOrgId());
|
||||
result.put("success", true);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ helper ============================
|
||||
|
||||
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
|
||||
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||
if (!bindingResult.hasFieldErrors("targetUrl")
|
||||
&& !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
bindingResult.rejectValue("targetUrl", "invalid.url",
|
||||
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||
}
|
||||
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
|
||||
"EventType을 1개 이상 선택해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyPassword(String password) {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return appServiceFacade.verifyUserPassword(user, password);
|
||||
}
|
||||
|
||||
private String currentOrgId() {
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||
}
|
||||
|
||||
private void addStepModel(ModelAndView mav, int currentStep) {
|
||||
mav.addObject("currentStep", currentStep);
|
||||
mav.addObject("totalSteps", TOTAL_STEPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookCreatedResult {
|
||||
|
||||
private final Long id;
|
||||
private final String secret;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
private String targetUrl;
|
||||
private String secretMasked;
|
||||
private String createdDate;
|
||||
|
||||
/** 구독 API ID 목록. */
|
||||
private List<String> apiIds = new ArrayList<>();
|
||||
|
||||
/** 구독 EventType(코드+한글명) 목록. */
|
||||
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
|
||||
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WebhookEventTypeDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String code;
|
||||
private String name;
|
||||
private boolean selected;
|
||||
|
||||
public WebhookEventTypeDTO(String code, String name) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.eactive.apim.portal.djb.webhook.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
|
||||
*
|
||||
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
|
||||
*/
|
||||
@Data
|
||||
public class WebhookRegistrationDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
|
||||
private Long id;
|
||||
|
||||
/** "NEW" 또는 "MODIFY" */
|
||||
private String requestType;
|
||||
|
||||
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
|
||||
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||
private String targetUrl;
|
||||
|
||||
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||
private List<String> eventTypes = new ArrayList<>();
|
||||
|
||||
/** Step2: 알림 대상 API ID 목록. */
|
||||
private List<String> selectedApis = new ArrayList<>();
|
||||
|
||||
public boolean isStep1Complete() {
|
||||
return targetUrl != null && !targetUrl.trim().isEmpty()
|
||||
&& eventTypes != null && !eventTypes.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isStep2Complete() {
|
||||
return selectedApis != null && !selectedApis.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return isStep1Complete() && isStep2Complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
|
||||
*/
|
||||
public class WebhookAlreadyExistsException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookAlreadyExistsException(String orgId) {
|
||||
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.eactive.apim.portal.djb.webhook.exception;
|
||||
|
||||
/**
|
||||
* 대상 Webhook 신청을 찾을 수 없음.
|
||||
*/
|
||||
public class WebhookNotFoundException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public WebhookNotFoundException(Long id) {
|
||||
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.eactive.apim.portal.djb.webhook.mapper;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
/**
|
||||
* WebhookRequest → WebhookDTO 기본 필드 매핑.
|
||||
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
|
||||
*/
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface WebhookMapper {
|
||||
|
||||
@Mapping(target = "secretMasked", ignore = true)
|
||||
@Mapping(target = "apiIds", ignore = true)
|
||||
@Mapping(target = "eventTypes", ignore = true)
|
||||
WebhookDTO toDto(WebhookRequest entity);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
|
||||
|
||||
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
|
||||
|
||||
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
|
||||
|
||||
@Transactional
|
||||
void deleteByWebhookReqId(Long webhookReqId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
|
||||
|
||||
Optional<WebhookRequest> findByOrgId(String orgId);
|
||||
|
||||
boolean existsByOrgId(String orgId);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.SequenceGenerator;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
|
||||
*
|
||||
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
|
||||
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
|
||||
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
|
||||
* {@link #onCreate()} 에서 직접 세팅한다.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ")
|
||||
public class WebhookRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
|
||||
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
|
||||
@Column(name = "ID")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "ORG_ID", length = 36)
|
||||
private String orgId;
|
||||
|
||||
@Column(name = "TARGET_URL", length = 255)
|
||||
private String targetUrl;
|
||||
|
||||
@Column(name = "SECRET", length = 500)
|
||||
private String secret;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
|
||||
* 복합키(WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_API")
|
||||
@IdClass(WebhookRequestApiId.class)
|
||||
public class WebhookRequestApi implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "API_ID", length = 30)
|
||||
private String apiId;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestApi() {
|
||||
}
|
||||
|
||||
public WebhookRequestApi(Long webhookReqId, String apiId) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.apiId = apiId;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class WebhookRequestApiId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long webhookReqId;
|
||||
private String apiId;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.IdClass;
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
|
||||
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
|
||||
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
|
||||
@IdClass(WebhookRequestEventId.class)
|
||||
public class WebhookRequestEvent implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
@Id
|
||||
@Column(name = "WEBHOOK_REQ_ID")
|
||||
private Long webhookReqId;
|
||||
|
||||
@Id
|
||||
@Column(name = "EVENT_TYPE", length = 36)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "CREATED_BY", length = 200)
|
||||
private String createdBy;
|
||||
|
||||
@Column(name = "CREATED_DATE", length = 14)
|
||||
private String createdDate;
|
||||
|
||||
public WebhookRequestEvent() {
|
||||
}
|
||||
|
||||
public WebhookRequestEvent(Long webhookReqId, String eventType) {
|
||||
this.webhookReqId = webhookReqId;
|
||||
this.eventType = eventType;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (createdBy == null) {
|
||||
createdBy = SecurityUtil.getCurrentLoginId();
|
||||
}
|
||||
if (createdDate == null) {
|
||||
createdDate = LocalDateTime.now().format(TS);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* {@link WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode
|
||||
public class WebhookRequestEventId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long webhookReqId;
|
||||
private String eventType;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
|
||||
*
|
||||
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
|
||||
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
|
||||
*/
|
||||
@Component
|
||||
public class WebhookEventTypeProvider {
|
||||
|
||||
private static final String EVENT_TYPE_SQL =
|
||||
"SELECT CODE, CODENAME FROM TSEAIRM28 "
|
||||
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
|
||||
+ "ORDER BY SEQ, CODE";
|
||||
|
||||
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<WebhookEventTypeDTO> getAll() {
|
||||
List<WebhookEventTypeDTO> list = new ArrayList<>();
|
||||
for (Object[] row : rows()) {
|
||||
list.add(new WebhookEventTypeDTO(asString(row[0]), asString(row[1])));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, String> asMap() {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (Object[] row : rows()) {
|
||||
map.put(asString(row[0]), asString(row[1]));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean isValid(String code) {
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
for (Object[] row : rows()) {
|
||||
if (code.equals(asString(row[0]))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public String getName(String code) {
|
||||
return asMap().getOrDefault(code, code);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Object[]> rows() {
|
||||
return entityManager.createNativeQuery(EVENT_TYPE_SQL).getResultList();
|
||||
}
|
||||
|
||||
private String asString(Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Webhook HMAC Secret 생성기.
|
||||
*
|
||||
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
|
||||
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
|
||||
*/
|
||||
@Component
|
||||
public class WebhookSecretGenerator {
|
||||
|
||||
private static final char[] ALPHANUM =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
|
||||
private static final int LENGTH = 128;
|
||||
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
public String generate() {
|
||||
StringBuilder sb = new StringBuilder(LENGTH);
|
||||
for (int i = 0; i < LENGTH; i++) {
|
||||
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.eactive.apim.portal.djb.webhook.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
|
||||
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
|
||||
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
|
||||
*
|
||||
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
|
||||
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Transactional
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookService {
|
||||
|
||||
private static final String SECRET_MASK = "••••••••••••";
|
||||
|
||||
private final WebhookRequestRepository requestRepository;
|
||||
private final WebhookRequestApiRepository apiRepository;
|
||||
private final WebhookRequestEventRepository eventRepository;
|
||||
private final WebhookSecretGenerator secretGenerator;
|
||||
private final WebhookEventTypeProvider eventTypeProvider;
|
||||
private final WebhookMapper webhookMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public boolean existsByOrg(String orgId) {
|
||||
return requestRepository.existsByOrgId(orgId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<WebhookDTO> getByOrg(String orgId) {
|
||||
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
|
||||
*/
|
||||
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
|
||||
if (requestRepository.existsByOrgId(orgId)) {
|
||||
throw new WebhookAlreadyExistsException(orgId);
|
||||
}
|
||||
validate(dto);
|
||||
|
||||
String secret = secretGenerator.generate();
|
||||
WebhookRequest request = new WebhookRequest();
|
||||
request.setOrgId(orgId);
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
request.setSecret(secret);
|
||||
WebhookRequest saved = requestRepository.save(request);
|
||||
|
||||
persistChildren(saved.getId(), dto);
|
||||
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
|
||||
return new WebhookCreatedResult(saved.getId(), secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||
*/
|
||||
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
validate(dto);
|
||||
|
||||
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||
requestRepository.save(request);
|
||||
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
eventRepository.deleteByWebhookReqId(id);
|
||||
apiRepository.flush();
|
||||
eventRepository.flush();
|
||||
persistChildren(id, dto);
|
||||
|
||||
log.info("Webhook 수정 orgId={} id={}", orgId, id);
|
||||
return toDetailDto(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret 재발급(교체). 새 평문 Secret 반환.
|
||||
*/
|
||||
public String regenerateSecret(Long id, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
String secret = secretGenerator.generate();
|
||||
request.setSecret(secret);
|
||||
requestRepository.save(request);
|
||||
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3개 테이블 HardDelete.
|
||||
*/
|
||||
public void delete(Long id, String orgId) {
|
||||
WebhookRequest request = loadOwned(id, orgId);
|
||||
apiRepository.deleteByWebhookReqId(id);
|
||||
eventRepository.deleteByWebhookReqId(id);
|
||||
requestRepository.delete(request);
|
||||
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String getPlainSecret(Long id, String orgId) {
|
||||
return loadOwned(id, orgId).getSecret();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||
WebhookRequest request = requestRepository.findById(id)
|
||||
.orElseThrow(() -> new WebhookNotFoundException(id));
|
||||
if (!Objects.equals(request.getOrgId(), orgId)) {
|
||||
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
|
||||
for (String apiId : dedup(dto.getSelectedApis())) {
|
||||
apiRepository.save(new WebhookRequestApi(reqId, apiId));
|
||||
}
|
||||
for (String eventType : dedup(dto.getEventTypes())) {
|
||||
if (!eventTypeProvider.isValid(eventType)) {
|
||||
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
|
||||
}
|
||||
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(WebhookRegistrationDTO dto) {
|
||||
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||
}
|
||||
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
|
||||
}
|
||||
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
|
||||
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> dedup(List<String> values) {
|
||||
if (values == null) {
|
||||
return java.util.Collections.emptyList();
|
||||
}
|
||||
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
|
||||
}
|
||||
|
||||
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||
WebhookDTO dto = webhookMapper.toDto(request);
|
||||
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||
|
||||
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||
.map(WebhookRequestApi::getApiId)
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
Map<String, String> names = eventTypeProvider.asMap();
|
||||
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
|
||||
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
|
||||
names.getOrDefault(e.getEventType(), e.getEventType())))
|
||||
.collect(Collectors.toList()));
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ spring:
|
||||
default-page-size: '10'
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
web:
|
||||
resources:
|
||||
@@ -202,6 +204,10 @@ portal:
|
||||
method: GET
|
||||
view-name: apps/service/oauth2-guide
|
||||
|
||||
- path-pattern: /service/webhook-dev-guide
|
||||
method: GET
|
||||
view-name: apps/service/webhook-dev-guide
|
||||
|
||||
- path-pattern: /dashboard
|
||||
method: GET
|
||||
view-name: apps/mypage/dashboard
|
||||
@@ -218,12 +224,15 @@ portal:
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_USER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
ROLE_CORP_MANAGER:
|
||||
- ROLE_API_KEY_REQUEST
|
||||
- ROLE_API_KEY_REQUEST_VIEW
|
||||
- ROLE_WEBHOOK
|
||||
- ROLE_INQUIRY
|
||||
- ROLE_APP
|
||||
- ROLE_ACCOUNT
|
||||
@@ -294,6 +303,9 @@ page:
|
||||
oauth2_guide:
|
||||
name: "OAuth2 개발가이드"
|
||||
path: "/service/oauth2-guide"
|
||||
webhook_dev_guide:
|
||||
name: "웹훅 개발가이드"
|
||||
path: "/service/webhook-dev-guide"
|
||||
apis:
|
||||
name: "API"
|
||||
path: "#"
|
||||
@@ -346,13 +358,13 @@ page:
|
||||
name: "이메일 인증"
|
||||
path: "/mypage/verification-email"
|
||||
user_list:
|
||||
name: "이용자 관리"
|
||||
name: "개발자 관리"
|
||||
path: "/users"
|
||||
user_detail:
|
||||
name: "이용자 정보"
|
||||
name: "개발자 정보"
|
||||
path: "/users/detail"
|
||||
apikey:
|
||||
name: "인증키 관리"
|
||||
name: "앱 관리"
|
||||
path: "/myapikey"
|
||||
app_request_detail:
|
||||
name: "인증키 신청 상세"
|
||||
@@ -360,12 +372,12 @@ page:
|
||||
credential_detail:
|
||||
name: "인증키 정보"
|
||||
path: "/myapikey/credential_detail"
|
||||
change_password:
|
||||
password_verify:
|
||||
name: "비밀번호 변경"
|
||||
path: "/change_password"
|
||||
verify_current_password:
|
||||
path: "/password/verify"
|
||||
password_change:
|
||||
name: "비밀번호 변경"
|
||||
path: "/verify_current_password"
|
||||
path: "/password/change"
|
||||
myapikey_register_step1:
|
||||
name: "앱 생성 (기본 정보)"
|
||||
path: "/myapikey/register/step1"
|
||||
@@ -387,6 +399,27 @@ page:
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
path: "/statistics/api"
|
||||
webhook:
|
||||
name: "Webhook 관리"
|
||||
path: "/webhook"
|
||||
webhook_register_step1:
|
||||
name: "Webhook 신청 (기본 정보)"
|
||||
path: "/webhook/register/step1"
|
||||
webhook_register_step2:
|
||||
name: "Webhook 신청 (API 선택)"
|
||||
path: "/webhook/register/step2"
|
||||
webhook_register_step3:
|
||||
name: "Webhook 신청 완료"
|
||||
path: "/webhook/register/step3"
|
||||
webhook_modify_step1:
|
||||
name: "Webhook 수정 (기본 정보)"
|
||||
path: "/webhook/modify/step1"
|
||||
webhook_modify_step2:
|
||||
name: "Webhook 수정 (API 선택)"
|
||||
path: "/webhook/modify/step2"
|
||||
webhook_modify_step3:
|
||||
name: "Webhook 수정 완료"
|
||||
path: "/webhook/modify/step3"
|
||||
|
||||
# 에디터 이미지 설정 (약관 이미지 표시용)
|
||||
editor:
|
||||
|
||||
@@ -69,6 +69,24 @@
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- ERROR 레벨만 별도 수집(스택 트레이스 포함). 장애 원인 추적용. -->
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/backup/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>200MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
@@ -89,12 +107,14 @@
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.id=Department User ID
|
||||
deptUserManageRegister.name=Department User Name
|
||||
portalUser.confirm.password=Please enter your existing password
|
||||
portalUser.Register.userName=Name
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-20 characters)
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-50 characters)
|
||||
portalUser.Register.passConfirm=Confirm Password
|
||||
portalUser.Register.email=Email ID
|
||||
portalUser.Register.domain=Domain
|
||||
@@ -82,6 +82,6 @@ status=Status
|
||||
table.reger=Registrar
|
||||
zip=Zip Code
|
||||
ROLE_CORP_MANAGER=Manager
|
||||
ROLE_CORP_USER=User
|
||||
ROLE_CORP_USER=Developer
|
||||
ROLE_USER=Individual
|
||||
login.passLengthShort=Password must be over 8 characters.
|
||||
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.name=\uBD80\uC11C \uC0AC\uC6A9\uC790 \uC774\uB984
|
||||
entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984
|
||||
portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694
|
||||
portalUser.Register.userName=\uC774\uB984
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-20\uC790
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-50\uC790
|
||||
portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778
|
||||
portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514
|
||||
portalUser.Register.domain=\uB3C4\uBA54\uC778
|
||||
@@ -82,6 +82,6 @@ status=\uC0C1\uD0DC
|
||||
table.reger=\uB4F1\uB85D\uC790
|
||||
zip=\uC6B0\uD3B8\uBC88\uD638
|
||||
ROLE_CORP_MANAGER=\uAD00\uB9AC\uC790
|
||||
ROLE_CORP_USER=\uC774\uC6A9\uC790
|
||||
ROLE_CORP_USER=\uAC1C\uBC1C\uC790
|
||||
ROLE_USER=\uAC1C\uC778\uC0AC\uC6A9\uC790
|
||||
ConcurrentSessionControlAuthenticationStrategy.exceededAllowed=\uCD5C\uB300 \uB85C\uADF8\uC778 \uD5C8\uC6A9 \uC5F0\uACB0\uC744 \uCD08\uACFC\uD558\uC600\uC2B5\uB2C8\uB2E4.
|
||||
|
||||
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 380 KiB |
@@ -1,3 +1,4 @@
|
||||
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 314H0V0.447266C158.076 115.873 527.685 196.879 958.538 196.999H960.462C1391.87 196.878 1761.87 115.662 1919.61 0H1920V314Z" fill="#CFF2FF"/>
|
||||
<svg width="1920" height="318" viewBox="0 0 1920 318" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 318H0V64.4473C158.076 179.873 527.685 260.879 958.538 260.999H960.462C1391.87 260.878 1761.87 179.662 1919.61 64H1920V318Z" fill="#D3EFFB"/>
|
||||
<path d="M1920 281H0V13.71C158.194 148.461 528.235 243 959.499 243C1391.68 243 1762.37 148.059 1920 12.8525V0H1920V281ZM0 0V13.71H-0.000976562V0H0Z" fill="#CFF2FF" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 12 KiB |