57 Commits

Author SHA1 Message Date
hong 80740a9543 merge 충돌 해결 2026-07-28 14:35:21 +09:00
hong cdd68f48f0 fix: 마이페이지 이용 통계 , 내정보 관리 , 비밀전호 변경 & 로그인 반응형 퍼블리싱 2026-07-28 14:30:03 +09:00
Rinjae fedd3e4c50 본인 인증 추가 기능 및 UI 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Step-up 비밀번호 확인 페이지 구현
- 기존 비밀번호 확인 로직 제거 및 반영 직전 2FA 추가
- 보호 경로 완화 정책(PASSWORD/TWO_FACTOR) 및 경로 관리 확장
2026-07-28 11:04:24 +09:00
Rinjae 6c13b6bc63 design → master 병합: 2FA·Step-up 인증 및 디자인 반영본 통합 2026-07-28 10:09:14 +09:00
Rinjae b54fedc280 feats/security → design 병합: 2FA·Step-up 인증 통합
- SuccessHandler 충돌 해결: LoginFinalizer 리팩터 버전 채택
- LoginFinalizer 로그 마스킹 재적용(design 변경 보존)
- CSS 생성물 충돌은 SASS 재컴파일로 해소(.tfa- 포함)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 09:55:57 +09:00
Rinjae a4316545dc 2FA 팝업 UI 개선 및 기능 추가:
- 디자인 가이드 기반 레이아웃/스타일 재정의(SASS)
- 인증 흐름 개선: 타이머/프로그레스바/버튼 상태 업데이트(JS)
- 사용자 친화적 안내 메시지 및 입력 상태 검증 추가
2026-07-28 09:50:45 +09:00
Rinjae b5ffa69eba 2FA (추가 인증) 기능 추가:
- Step-up 인증 Interceptor 및 보호 경로 관리 로직 도입
- 2FA 팝업 모듈 및 스타일(SASS, JS) 추가
- 로그인 실패 사유 Enum 및 2FA 관련 처리 로직 공통화
2026-07-27 20:12:13 +09:00
Rinjae 2106142d93 - UserRoleHistoryService 추가: 역할 변경 감사 이력 서비스 구현
- `ROLE_API_KEY_REQUEST` → `ROLE_WEBHOOK` 변경: Webhook 관리 권한 분리 및 강화
- "이용자" → "개발자" 용어 통일: 템플릿, 컨트롤러 및 예외 메시지 수정
2026-07-27 20:11:09 +09:00
Rinjae 0f58f043a8 Merge remote-tracking branch 'origin/design' into design 2026-07-27 18:20:43 +09:00
Rinjae 61be6bf108 - API 선택 검증 로직 수정 - 선택 사항으로 변경
- '인증 키 관리' → '앱 관리' 용어 변경
- API 미선택 시 확인 팝업 로직 추가
2026-07-27 18:20:37 +09:00
hong 96d6dd3df1 충돌 해결 2026-07-27 17:44:23 +09:00
hong f9b53810d9 fix: 인증키 관리 & webhook 수정및 반응형 퍼블리싱 2026-07-27 17:32:33 +09:00
Rinjae 41be827b81 비밀번호 정책 및 변경 강제 적용 기능 추가:
- 비밀번호 규칙 클라이언트 검증 JS/SASS 추가
- 세션 기반 비밀번호 변경 강제 Interceptor 구현 (`PasswordChangeEnforcementInterceptor`)
- URL 리팩토링 및 엔드포인트 변경 (`/password/*`)
- 비밀번호 유효성 검사 공통화 (`PasswordRuleValidator`)
- Logback ERROR 전용 파일 로그 추가
2026-07-27 17:25:42 +09:00
Rinjae 78898ab5a8 Merge dfc859c (승인 요청 처리 개선) into design
- 승인정보 없는 요청 목록 노출 및 삭제 로직 (MyAppController, AppServiceFacade)
- '승인대기' → '승인정보 없음' 텍스트 (apiKeyList.html, design 뱃지 클래스 유지)
- 내부 결재 중 취소 불가 안내 스타일 (_apikey-detail.scss)
- 충돌 CSS는 design(ours) 채택 후 SASS 재컴파일
2026-07-27 16:14:53 +09:00
hong 19c17f4e91 Merge remote-tracking branch 'origin/master' into design 2026-07-27 14:40:12 +09:00
hong c2d84bb024 마이페이지 이용자 관리 , 인증키 관리 반응형으로 수정 2026-07-27 14:31:51 +09:00
hong 6434d48b05 Merge branch 'design' of http://172.30.1.50:3000/djb-eapim/eapim-portal into design 2026-07-24 17:41:43 +09:00
hong d5f0f3aab4 fix: FAQ , Q&A , 피드백 반응형 퍼블리싱 2026-07-24 17:41:39 +09:00
Rinjae dfc859c9cf 승인 요청 처리 개선 및 UI/로직 추가:
- 승인정보 없는 요청 목록 노출 및 삭제 로직 추가
- 내부 결재 진행 중 취소 불가 안내 메시지 및 스타일 추가
- '승인대기' -> '승인정보 없음' 텍스트 수정
2026-07-24 17:22:40 +09:00
Rinjae 24c669b0be 접근 권한 처리 및 권한 설정 개선:
- 미로그인 사용자 접근 시 로그인 페이지 리다이렉트 처리
- Webhook 관리 접근 권한 `ROLE_API_KEY_REQUEST`로 변경
- 오류 페이지 스타일 수정: `white-space: pre-line` 추가
2026-07-24 13:14:17 +09:00
hong 3eedc522e2 merge 충돌수정 2026-07-24 10:24:07 +09:00
hong a361cc3dec fix : open api , 공지사항 반응형 퍼블리싱 2026-07-24 10:19:18 +09:00
Rinjae 1ac9fa5407 "SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00
Rinjae 9981459691 "SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00
Rinjae a2878f9cee Merge branch 'master' into design 2026-07-23 20:58:03 +09:00
Rinjae 1f10dda993 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae a6807a37c2 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae 6c10ae12c7 Merge branch 'master' into design 2026-07-23 20:26:55 +09:00
Rinjae 01c4a80182 API 테스트베드 개선 및 감사 로그 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gateway 프로퍼티 mock 토큰 관련 항목 추가
- API 요청/응답 감사 로그 작성 로직 구현 및 로깅 설정 추가
- Swagger UI 전용 spec 제공 및 서버 주소 치환 로직 분리
2026-07-23 20:26:40 +09:00
Rinjae a5611cf775 - 서비스 가이드 컬럼 레이아웃 개선: 1열 배치로 변경
- 다국어 코드 블럭 간격 문제 수정 및 SASS 재컴파일
- 버튼 및 배지 hover 컬러 RGB % 표기로 변경
2026-07-23 18:12:12 +09:00
Rinjae c733e6b200 Merge branch 'master' into design
# Conflicts:
#	src/main/resources/templates/views/apps/service/oauth2-guide.html
2026-07-23 18:09:00 +09:00
Rinjae a2813f391c - OAuth2 가이드 scope 고정값 "api" 반영(master 이식)
- 인증 헤더 표기 X-AUTH-TOKEN 으로 수정
- 가이드 히어로/사전준비/엔드포인트 1열 배치 전환
- webhook 개발가이드 문구 정리, SASS 재컴파일

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:07:58 +09:00
Rinjae f221c20ece - 세션 타임아웃 10분 고정: 서버/DB 설정 통일 및 관리 단순화
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- CSRF 토큰 조회/재발급 API 추가 - 익명 세션 유지 핑 로직 구현
- OAuth2 가이드 scope 고정값 "api"로 수정
2026-07-23 18:05:57 +09:00
Rinjae 4a3b5b43f3 불필요 주석 블록 처리:
- HTML 템플릿 주석 CSS 규격으로 통일
- guide.html 외 10개 파일 변경
- editor-content 클래스 관련 설명 추가
2026-07-22 15:51:43 +09:00
Rinjae 8de0d94063 API 관련 HTML 템플릿 파일 제거 및 상세 뷰 클래스 추가:
- api_lnb.html, head.html 템플릿 삭제
- API 상세 페이지에 editor-content 클래스 추가
2026-07-22 14:40:12 +09:00
Rinjae 57d4efb4eb Merge branch 'master' into design 2026-07-22 11:09:13 +09:00
Rinjae c865359819 사용자 이메일 마스킹 처리 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- PortalConfigLog에 StringMaskingUtil 적용
- 로그인 사용자 아이디 마스킹 처리(Log 개선)
2026-07-22 09:39:09 +09:00
Rinjae 41391c8df0 - Webhook 신청 및 관리 UI 전면 개편 - 신규 가이드 반영
- Webhook 수정/삭제 기능 UX 개선 - 단계별 디자인 업데이트
- Webhook 등록/수정 CSS 및 JS 로직 최적화 및 재사용 코드 추가
2026-07-20 19:53:54 +09:00
Rinjae 9f4874fe44 Merge branch 'feats/api-status' into design
# Conflicts:
#	src/main/resources/static/css/main.css.map
#	src/main/resources/static/css/main.min.css
#	src/main/resources/static/css/main.min.css.map
#	src/main/resources/templates/views/apps/mypage/apiKeyModifyStep2.html
#	src/main/resources/templates/views/apps/mypage/apiKeyRegisterStep2.html
#	src/main/resources/templates/views/fragment/djbank/header_container.html
2026-07-20 19:13:38 +09:00
Rinjae 43d915342e 모니터링 코드 엔티티 제거 및 API 선택 모듈 추가:
- MonitoringCode 엔티티, 리포지토리, 복합키 클래스 삭제
- API 선택 공용 모듈 JS 및 HTML 파일 추가 (api_selector)
- 웹훅 개발 가이드 페이지 신규 추가 (webhook-dev-guide)
2026-07-20 19:10:05 +09:00
Rinjae ba00b80bfe Webhook 신청/관리 및 EventType 제공 기능 추가:
- Webhook 신청/수정 3단계 UI 및 관련 서비스 구현
- TSEAIRM28 공통코드 기반 EventType 조회 기능 추가
- 신규/수정 세션 관리 및 비밀번호 재인증 로직 포함
2026-07-20 17:36:17 +09:00
Rinjae 87763ba325 CSS 메인 파일 업데이트:
- "main.min.css"에서 디자인 스타일 및 애니메이션 수정 및 최적화.
2026-07-20 16:48:12 +09:00
Rinjae 5b7a4a108d design-merge 강제 병합 - 디자인 반영본 전체 통합
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 16:32:18 +09:00
hong 9b9bcb5be4 패딩 수정 2026-07-20 14:05:43 +09:00
Rinjae bdb249b74b Merge branch 'master' into feats/api-status 2026-07-20 10:57:55 +09:00
hong 93f7c210bc 11 2026-07-14 15:52:10 +09:00
hong bb79b55f08 fix: 회원가입 , oauth 반응형 수정 2026-07-14 14:54:53 +09:00
hong 23bd0c1207 fix : 메인페이지 반응형 수정 2026-07-14 10:35:14 +09:00
hong 28a9ff1a7f feat: 마이페이지 퍼블리싱 2026-07-14 10:34:31 +09:00
hong 045f354e5c Q&A 리스트 퍼블리싱 2026-07-09 10:45:20 +09:00
hong 4f577c8c14 Faq , q&a 상세 , 피드백 , 마이페이지 이용자 관리 퍼블리싱 2026-07-09 09:47:16 +09:00
hong cc4c1a6b53 feat : 오픈 API & 공지사항 퍼블리싱 2026-07-06 17:21:58 +09:00
hong 551a2da717 feat: 회원가입 안내 및 oauth 2 페이지 퍼블리싱 2026-07-03 16:41:24 +09:00
hong 1c57d348e6 디자인 시안 적용 및 애니메이션 개선 2026-07-02 17:52:59 +09:00
Rinjae 2c29c8a466 공지사항 목록 정렬 및 UI 개선
- 상단고정(fixYn) 우선 정렬 로직 추가
- 고정 뱃지 UI 템플릿 추가
2026-06-16 14:46:25 +09:00
Rinjae d2052d0e16 Q&A 이벤트 리팩토링
- InquiryCommentCreatedAdminEvent 클래스 제거
- build.gradle 리소스 처리 코드 주석 처리
2026-06-16 10:53:39 +09:00
Rinjae 9c4f3cfb04 공지사항 상세 - 장애/점검 정보 노출 추가
- PortalNoticeDTO 장애/점검 필드 확장
- Facade에서 djb_incident 데이터 결합
- 목록/상세에 유형 뱃지 시작 종료 영향 API 표시

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-16 09:11:28 +09:00
186 changed files with 14935 additions and 5433 deletions
+2 -1
View File
@@ -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;
@@ -38,7 +39,24 @@ public class ApiController {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "api-info");
return "apps/apis/mainApiDetail";
}
// 테스트베드를 API 정보와 별도 URL로 분리(딥링크·북마크 가능). 미인증 사용자도 페이지 진입은
// 허용하되, 실제 테스트베드(Swagger)는 인증 사용자에게만 렌더하고 미인증에는 로그인 안내를 노출한다.
@GetMapping("/detail/testbed")
public String apidetailTestbed(@RequestParam(value = "id", required = false) String id, ModelMap model) {
if (id == null) {
return "redirect:/apis/common";
}
populateDetailModel(id, model);
model.addAttribute("activeTab", "testbed");
return "apps/apis/mainApiDetail";
}
private void populateDetailModel(String id, ModelMap model) {
ApiSpecInfoDto api = apiService.selectDetail(id);
if (api == null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
@@ -49,7 +67,7 @@ public class ApiController {
model.addAttribute("apiSpecInfo", api);
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
model.addAttribute("services", searchResult.get("services"));
return "apps/apis/mainApiDetail";
model.addAttribute("authenticated", SecurityUtil.isAuthenticated());
}
@GetMapping
@@ -68,6 +86,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 +115,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;
@@ -0,0 +1,294 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
*
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
*
* <p>마스킹 정책:</p>
* <ul>
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
* </ul>
*/
public final class ApiTesterAuditLogger {
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** 값 전체를 마스킹할 키(소문자 비교) */
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
private static final String[] AUDIT_HEADERS = {
"content-type", "accept", "referer", "origin", "x-forwarded-for",
"original-api-id", "authorization"};
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
private static final int JSON_LOG_MAX_LENGTH = 2000;
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
private static final int NON_JSON_PREVIEW_LENGTH = 8;
private ApiTesterAuditLogger() {
}
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
public static String newAuditId() {
return UUID.randomUUID().toString().substring(0, 8);
}
/**
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
*
* @param targetUrl original-url 헤더 값 (없으면 null)
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
* @param tokenRequest OAuth 토큰 발급 요청 여부
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
*/
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
String gatewayMode, boolean tokenRequest, String body) {
try {
StringBuilder sb = new StringBuilder(256);
sb.append("REQ [").append(auditId).append(']');
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
sb.append(" user=").append(currentUser());
sb.append(" method=").append(request.getMethod());
sb.append(" mode=").append(gatewayMode);
sb.append(" token=").append(tokenRequest);
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
sb.append(" headers=").append(buildHeaderSummary(request));
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
auditLogger.info(sb.toString());
} catch (Exception e) {
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
}
}
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
}
// =========================================================================
// 요청 정보 구성
// =========================================================================
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
private static String currentUser() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
return "anonymous";
}
String name = auth.getName();
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
} catch (Exception e) {
return "unknown";
}
}
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
private static String buildHeaderSummary(HttpServletRequest request) {
StringBuilder sb = new StringBuilder("{");
boolean first = true;
for (String name : AUDIT_HEADERS) {
String value = request.getHeader(name);
if (value == null) {
continue;
}
if (!first) {
sb.append(", ");
}
first = false;
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
}
return sb.append('}').toString();
}
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
private static String maskHeaderValue(String name, String value) {
if (!SECRET_KEYS.contains(name.toLowerCase())) {
return value;
}
int space = value.indexOf(' ');
if (space > 0) {
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
}
return partialMask(value);
}
// =========================================================================
// 본문 마스킹
// =========================================================================
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
if (body == null || body.isEmpty()) {
return "-";
}
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
if (tokenRequest) {
return "\"" + maskFormBody(body) + "\"";
}
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
if (contentType != null && contentType.toLowerCase().contains("json")) {
String maskedJson = tryMaskJson(body);
if (maskedJson != null) {
return maskedJson;
}
}
return nonJsonSummary(body);
}
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
private static String maskFormBody(String body) {
StringBuilder sb = new StringBuilder(body.length());
String[] pairs = body.split("&");
for (int i = 0; i < pairs.length; i++) {
if (i > 0) {
sb.append('&');
}
int eq = pairs[i].indexOf('=');
if (eq < 0) {
sb.append(partialMask(pairs[i]));
continue;
}
String key = pairs[i].substring(0, eq);
String value = pairs[i].substring(eq + 1);
sb.append(key).append('=');
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
}
return sanitize(sb.toString());
}
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
private static String maskQueryValues(String url) {
int qs = url.indexOf('?');
if (qs < 0) {
return url;
}
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
}
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
private static String tryMaskJson(String body) {
try {
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
String out = OBJECT_MAPPER.writeValueAsString(masked);
if (out.length() > JSON_LOG_MAX_LENGTH) {
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
}
return out;
} catch (Exception e) {
return null;
}
}
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
private static JsonNode maskJsonNode(JsonNode node) {
if (node.isObject()) {
ObjectNode obj = (ObjectNode) node;
Iterator<String> names = obj.fieldNames();
Set<String> fieldNames = new HashSet<>();
while (names.hasNext()) {
fieldNames.add(names.next());
}
for (String field : fieldNames) {
if (SECRET_KEYS.contains(field.toLowerCase())) {
obj.set(field, TextNode.valueOf("*****"));
} else {
obj.set(field, maskJsonNode(obj.get(field)));
}
}
return obj;
}
if (node.isArray()) {
ArrayNode arr = (ArrayNode) node;
for (int i = 0; i < arr.size(); i++) {
arr.set(i, maskJsonNode(arr.get(i)));
}
return arr;
}
if (node.isNull() || node.isMissingNode()) {
return node;
}
return TextNode.valueOf(partialMask(node.asText()));
}
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
private static String nonJsonSummary(String body) {
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
}
// =========================================================================
// 공통 helper
// =========================================================================
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
private static String partialMask(String value) {
if (value == null || value.isEmpty()) {
return "";
}
int len = value.length();
if (len <= 2) {
return stars(len);
}
int visible = Math.min(4, Math.max(1, len / 3));
return value.substring(0, visible) + "***";
}
private static String stars(int count) {
char[] arr = new char[count];
Arrays.fill(arr, '*');
return new String(arr);
}
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
private static String sanitize(String value) {
if (value == null) {
return "-";
}
StringBuilder sb = new StringBuilder(value.length());
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"') {
sb.append('\'');
} else if (c == '\r' || c == '\n' || c == '\t') {
sb.append(' ');
} else if (c < 0x20) {
sb.append('?');
} else {
sb.append(c);
}
}
return sb.toString();
}
}
@@ -68,8 +68,16 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
System.currentTimeMillis() - auditStart);
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
if (tokenRequest) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
String body = requestBody;
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&");
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default");
String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" +
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
" \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" +
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
response.getWriter().println(token);
} else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
auditType = "TOKEN_GW";
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded");
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
auditType = "SPEC_NOT_FOUND";
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
auditType = "SAMPLE";
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
auditType = gw ? "GW" : "MOCK";
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
}
headers.remove("original-url");
headers.remove("original-api-id");
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
String targetUri;
Map<String, String[]> paramMap;
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
System.currentTimeMillis() - auditStart);
}
}
@@ -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);
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,80 @@
package com.eactive.apim.portal.apps.auth.twofactor;
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#isInterceptorGuarded(String)} 경로 진입 시, 유효한 1회용
* 통과권이 없으면 2FA 를 요구한다.
* <ul>
* <li>GET(페이지 진입) → {@code /auth/2fa/challenge} 로 리다이렉트(원경로는 returnUrl 로 보존)</li>
* <li>POST(AJAX: Secret 조회/앱 해지) → {@code 401 + {"stepUpRequired":true}} JSON</li>
* </ul>
* "매번 인증" 정책이므로 통과권은 {@code consumeStepUpPass} 에서 즉시 소멸한다.</p>
*
* <p>내 정보 변경({@code /mypage}, PASSWORD 레벨)과 비밀번호 반영({@code POST /password/change},
* 반영 직전 2FA)은 각 컨트롤러가 직접 관장하므로 이 인터셉터 대상이 아니다.</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.isInterceptorGuarded(path)) {
return true;
}
// 지점별 스위치가 꺼져 있으면 해당 경로는 step-up 미적용
if (!twoFactorProperties.isStepUpPointEnabled(path)) {
return true;
}
HttpSession session = request.getSession(false);
if (session == null) {
// 세션(=인증)이 없으면 여기서 다루지 않고 보안 계층(@Secured)에 맡긴다.
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;
}
}
@@ -0,0 +1,73 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
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 javax.servlet.http.HttpSession;
/**
* 완화된 step-up(PASSWORD 레벨) 확인 페이지.
*
* <p>{@link StepUpProtectedPaths#isPasswordGated(String)} 경로(예: {@code /mypage})는 2FA 대신
* <b>현재 비밀번호 재확인</b>만 요구한다. 각 컨트롤러가 통과권이 없을 때 이 페이지로 유도하고,
* 확인 성공 시 해당 경로의 통과권을 발급한 뒤 원경로로 복귀시킨다.</p>
*
* <p>returnUrl 은 PASSWORD 레벨 화이트리스트로만 검증·복귀하여 open redirect 를 막는다.</p>
*/
@Controller
@Secured("ROLE_ACCOUNT")
@RequiredArgsConstructor
@RequestMapping("/auth/stepup")
public class StepUpPasswordController {
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
}
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
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,118 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* step-up(민감기능 추가 인증) 대상 서블릿 경로 화이트리스트 + 지점별 프로퍼티 키 + 검증 레벨.
*
* <p>검증 레벨(완화 정책)</p>
* <ul>
* <li>{@link Level#TWO_FACTOR} — 공통 2FA 팝업(휴대폰/이메일 인증번호). 진입 인터셉터 또는
* AJAX 401 신호로 유도. 예: Secret 조회/앱 해지/앱 정보수정.</li>
* <li>{@link Level#PASSWORD} — 현재 비밀번호 재확인만 요구(2FA 없음). 별도 확인 페이지
* ({@code /auth/stepup/password})로 유도. 예: 내 정보 변경({@code /mypage}).</li>
* </ul>
*
* <p>세 가지 관심사를 분리한다.</p>
* <ol>
* <li>{@link #isTwoFactorPurpose(String)} — 공통 2FA 팝업의 유효 대상(purpose) 화이트리스트.
* open redirect / 임의 purpose 로의 2FA 발송·통과권 발급을 막는 데 쓴다.</li>
* <li>{@link #isInterceptorGuarded(String)} — {@link StepUpAuthInterceptor} 가 진입 시점에
* 자동 차단하는 경로. 비밀번호 변경(반영 직전 확인)·내 정보(별도 확인 페이지)는
* 각 컨트롤러가 직접 관장하므로 여기서 제외한다.</li>
* <li>{@link #isPasswordGated(String)} — 현재 비밀번호 재확인으로 보호하는 경로.</li>
* </ol>
*
* <p>지점별 활성화는 PTL_PROPERTY 키({@code two-factor.stepup.<지점>})로 개별 제어하며
* 전체 스위치 {@code two-factor.stepup.enabled} 와 AND 로 동작한다.</p>
*/
public final class StepUpProtectedPaths {
/** step-up 검증 레벨(완화 정책) */
public enum Level {
/** 공통 2FA 팝업(인증번호) */
TWO_FACTOR,
/** 현재 비밀번호 재확인만 */
PASSWORD
}
/** 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, 정확 일치) — PASSWORD 레벨 */
public static final String MYPAGE = "/mypage";
/** 비밀번호 변경 반영(commit, POST) — 반영 직전 2FA. 진입(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;
/** 경로 → 검증 레벨 */
private static final Map<String, Level> PATH_TO_LEVEL;
/** 인터셉터가 진입 시점에 자동 차단하는 경로(2FA) */
private static final Set<String> INTERCEPTOR_GUARDED;
static {
Map<String, String> keys = new LinkedHashMap<>();
keys.put(REVEAL_SECRET, KEY_PREFIX + "reveal-secret");
keys.put(APP_MODIFY_STEP1, KEY_PREFIX + "app-modify");
keys.put(APP_KEY_DELETE, KEY_PREFIX + "app-delete");
keys.put(MYPAGE, KEY_PREFIX + "mypage");
keys.put(PASSWORD_CHANGE, KEY_PREFIX + "password-change");
PATH_TO_KEY = Collections.unmodifiableMap(keys);
Map<String, Level> levels = new LinkedHashMap<>();
levels.put(REVEAL_SECRET, Level.TWO_FACTOR);
levels.put(APP_MODIFY_STEP1, Level.TWO_FACTOR);
levels.put(APP_KEY_DELETE, Level.TWO_FACTOR);
levels.put(MYPAGE, Level.PASSWORD);
levels.put(PASSWORD_CHANGE, Level.TWO_FACTOR);
PATH_TO_LEVEL = Collections.unmodifiableMap(levels);
// 인터셉터 진입 자동 차단: 2FA 레벨 중 "진입 시점" 보호가 필요한 경로만.
// - PASSWORD_CHANGE 는 반영(POST commit) 직전에 컨트롤러가 통과권을 요구 → 제외
// - MYPAGE 는 별도 확인 페이지로 컨트롤러가 유도(PASSWORD 레벨) → 제외
Set<String> guarded = new java.util.LinkedHashSet<>();
guarded.add(REVEAL_SECRET);
guarded.add(APP_MODIFY_STEP1);
guarded.add(APP_KEY_DELETE);
INTERCEPTOR_GUARDED = Collections.unmodifiableSet(guarded);
}
private StepUpProtectedPaths() {
}
/** 공통 2FA 팝업의 유효 대상(purpose)인지 — open redirect / 임의 purpose 차단용 */
public static boolean isTwoFactorPurpose(String servletPath) {
return servletPath != null
&& PATH_TO_LEVEL.get(servletPath) == Level.TWO_FACTOR;
}
/** 인터셉터가 진입 시점에 자동 차단하는 경로인지 */
public static boolean isInterceptorGuarded(String servletPath) {
return servletPath != null && INTERCEPTOR_GUARDED.contains(servletPath);
}
/** 현재 비밀번호 재확인으로 보호하는 경로인지(PASSWORD 레벨) */
public static boolean isPasswordGated(String servletPath) {
return servletPath != null && PATH_TO_LEVEL.get(servletPath) == Level.PASSWORD;
}
/** 해당 경로의 지점별 활성화 프로퍼티 키. 대상 경로가 아니면 null */
public static String propertyKeyOf(String servletPath) {
return servletPath == null ? null : PATH_TO_KEY.get(servletPath);
}
/** 지점별 프로퍼티 키 접두 */
public static String keyPrefix() {
return KEY_PREFIX;
}
}
@@ -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.isTwoFactorPurpose(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,506 @@
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.isTwoFactorPurpose(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());
}
/**
* 보호 경로 수정 저장 직후 원경로로 되돌아가는 즉시 왕복(예: {@code /mypage} 수정 →
* {@code redirect:/mypage})에서 중복 step-up 을 막기 위해 통과권을 재발급한다.
*
* <p>경로 고정 + TTL({@link #STEPUP_PASS_TTL_SECONDS}s) 로 <b>1회 왕복만</b> 커버하며,
* 이후 새로 {@code /mypage} 에 진입하면 정상적으로 다시 인증을 요구한다("매번 인증" 유지).</p>
*/
public void grantStepUpPass(HttpSession session, String path) {
if (session != null && path != null) {
issueStepUpPass(session, path);
}
}
/**
* 지정 경로에 대한 유효한 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;
}
@@ -30,7 +30,7 @@ public class PartnershipApplicationController {
@GetMapping
public String newPartnershipApplicationForm(Model model, HttpServletRequest request) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?redirect=/partnership";
return "redirect:/login?reason=auth&redirect=/partnership";
}
String referer = request.getHeader("Referer");
@@ -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,276 @@
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");
/**
* 미인증 상태로 보호 페이지 접근 시 저장해 둔 원래 요청 경로(로그인+2FA 완료 후 복귀 대상).
* {@code PortalGlobalExceptionHandler} 가 저장하고 여기서 소비(1회용)한다.
*/
public static final String SESSION_POST_LOGIN_REDIRECT = "postLoginRedirect";
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";
}
// 미인증 접근으로 저장해 둔 원래 요청 페이지로 복귀(1회용).
// 단, 강제 유도 흐름(이메일 인증/휴면/비밀번호 변경 — session.redirectUrl 세팅)이 있으면
// 그쪽이 우선이므로 복귀시키지 않고 기본 경로로 보낸다.
String postLoginRedirect = (String) session.getAttribute(SESSION_POST_LOGIN_REDIRECT);
session.removeAttribute(SESSION_POST_LOGIN_REDIRECT);
if (postLoginRedirect != null && session.getAttribute("redirectUrl") == null) {
return contextPath + postLoginRedirect;
}
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/change");
}
}
/**
* 비밀번호 변경 대상자에게 정책(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());
}
}
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -25,6 +26,8 @@ import java.util.Optional;
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
* </ul>
*/
@Slf4j
@@ -109,6 +112,37 @@ public class SessionApiController {
return ResponseEntity.ok(result);
}
/**
* 익명(비로그인) 페이지용 세션 keepalive ping.
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
* 세션이 없으면 새로 만들지 않는다.
*/
@GetMapping("/ping")
public ResponseEntity<Void> ping(HttpServletRequest request) {
request.getSession(false);
return ResponseEntity.noContent().build();
}
/**
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
* 로그인 페이지 안전망으로만 사용한다.
*/
@GetMapping("/csrf")
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
Map<String, String> result = new HashMap<>();
if (token != null) {
result.put("headerName", token.getHeaderName());
result.put("parameterName", token.getParameterName());
result.put("token", token.getToken());
}
return ResponseEntity.ok(result);
}
/**
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
* 예: 192.168.240.178 → 192.168.***.178
@@ -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;
@@ -20,8 +21,9 @@ import java.util.Optional;
public class UserSessionService {
private static final String PROPERTY_GROUP = "Portal";
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
public static final int SESSION_TIMEOUT_MINUTES = 10;
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
@@ -64,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));
}
/**
@@ -74,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);
}
}
@@ -86,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);
}
}
@@ -107,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));
}
}
@@ -120,21 +123,10 @@ public class UserSessionService {
}
/**
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
* 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
*/
public int getSessionTimeoutMinutes() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
PROPERTY_NAME,
DEFAULT_TIMEOUT_MINUTES,
"세션 타임아웃 시간 (분)"
);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
}
return SESSION_TIMEOUT_MINUTES;
}
/**
@@ -35,9 +35,14 @@ public class ApiStatisticsController {
*/
@GetMapping
public String statisticsPage(Model model) {
// 미로그인 접근은 사유를 노출하도록 reason=auth 로 유도(로그인 페이지 안내 배너)
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth";
}
PortalOrg org = getPortalOrg();
if (org == null) {
return "redirect:/login";
// 로그인은 했으나 조직이 없는(권한 밖) 사용자는 홈으로
return "redirect:/";
}
String orgId = org.getId();
@@ -1,6 +1,10 @@
package com.eactive.apim.portal.apps.user.controller;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
import com.eactive.apim.portal.config.PasswordChangeEnforcementInterceptor;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.dto.*;
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
@@ -49,9 +53,11 @@ public class AccountController {
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
private final UserInvitationRepository userInvitationRepository;
private final UserSessionService userSessionService;
private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties;
@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 +66,29 @@ 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")
public String showNewPasswordPage(Model model) {
@GetMapping("/password/change")
public String showNewPasswordPage(Model model, HttpSession session) {
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
// 강제 변경(ENFORCE/만료) 진입 시 "변경/로그아웃" 강제 팝업을 띄운다.
if (isPasswordEnforced(session)) {
model.addAttribute("forcedPasswordReset", true);
}
// 반영 직전 2FA 필요 여부(강제 변경/2FA off 면 불필요) → 폼 JS 분기용
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
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 +96,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,
@@ -93,13 +109,23 @@ public class AccountController {
RedirectAttributes redirectAttributes, Model model) {
try {
// 반영 직전 2FA: 통과권이 없으면 커밋하지 않고 폼으로 되돌린다(프론트가 먼저 2FA 팝업을 띄운다).
if (isPwChangeTwofaRequired(session)
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.PASSWORD_CHANGE)) {
model.addAttribute("error", "추가 인증(2FA) 후 다시 시도해 주세요.");
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
model.addAttribute("twofaRequired", true);
return "apps/mypage/passwordChange";
}
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,19 +139,34 @@ 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());
// 2FA 통과권은 이미 소비됨 → 재제출 시 다시 2FA 를 요구하도록 플래그 유지
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
} catch (Exception e) {
model.addAttribute("error", e.getMessage());
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
logger.error("비밀번호 변경 처리 중 오류", e);
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
model.addAttribute("twofaRequired", isPwChangeTwofaRequired(session));
return "apps/mypage/passwordChange";
}
}
@GetMapping("/mypage")
public ModelAndView mypage() {
public ModelAndView mypage(HttpSession session) {
// 완화된 step-up(PASSWORD 레벨): 통과권이 없으면 현재 비밀번호 확인 페이지로 유도한다.
// (내 정보 변경은 2FA 대신 비밀번호 재확인만 요구)
if (isMypageStepUpActive()
&& !twoFactorService.consumeStepUpPass(session, StepUpProtectedPaths.MYPAGE)) {
return new ModelAndView("redirect:/auth/stepup/password?returnUrl=" + StepUpProtectedPaths.MYPAGE);
}
ModelAndView mav = new ModelAndView();
try {
@@ -238,6 +279,8 @@ public class AccountController {
return "redirect:/mypage";
} finally {
cleanupAuthSession(session);
// 수정 저장 후 redirect:/mypage 로 되돌아갈 때 step-up 재요구를 막는다(1회 왕복 한정).
twoFactorService.grantStepUpPass(session, StepUpProtectedPaths.MYPAGE);
}
}
@@ -250,6 +293,29 @@ public class AccountController {
}
}
/** 내 정보({@code /mypage}) 완화 step-up(비밀번호 재확인)이 현재 활성인지 */
private boolean isMypageStepUpActive() {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.MYPAGE);
}
/** 비밀번호 강제 변경 상태(ENFORCE/만료)인지 */
private boolean isPasswordEnforced(HttpSession session) {
return Boolean.TRUE.equals(session.getAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR))
|| Boolean.TRUE.equals(session.getAttribute("passwordExpired"));
}
/**
* 비밀번호 변경 반영 직전에 2FA 를 요구할지 여부.
* 전체/지점 step-up 스위치가 켜져 있고 <b>강제 변경 상태가 아닐 때만</b> 요구한다
* (강제/만료 변경은 기존대로 2FA 없이 진행).
*/
private boolean isPwChangeTwofaRequired(HttpSession session) {
return twoFactorProperties.isStepUpEnabled()
&& twoFactorProperties.isStepUpPointEnabled(StepUpProtectedPaths.PASSWORD_CHANGE)
&& !isPasswordEnforced(session);
}
@GetMapping("/mypage/org-transfer")
public String showOrgTransferPage(Model model) {
try {
@@ -364,7 +430,7 @@ public class AccountController {
PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
if (currentUser == null) {
return "redirect:/login";
return "redirect:/login?reason=auth";
}
// 사용자 이메일 주소를 모델에 추가
@@ -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);
}
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
}
/**
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
*/
@ModelAttribute("sessionTimeoutMinutes")
public int sessionTimeoutMinutes() {
@@ -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,9 @@ import java.util.Map;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
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 +52,74 @@ 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()) {
// 원래 요청 페이지를 세션에 저장 → 로그인+2FA 완료 후 LoginFinalizer 가 복귀시킨다.
savePostLoginRedirect(request);
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;
}
/**
* 미인증 상태로 보호 페이지(HTML 화면)에 접근한 GET 요청의 원 경로를 세션에 저장한다.
* 로그인+2FA 완료 후 {@link LoginFinalizer} 가 이 값으로 복귀시킨다(1회용).
* open redirect / 무의미 복귀를 막기 위해 내부 화면 GET 경로만 저장한다.
*/
private void savePostLoginRedirect(HttpServletRequest request) {
if (!"GET".equalsIgnoreCase(request.getMethod())) {
return;
}
// 페이지 네비게이션만 대상(AJAX/데이터 요청 제외)
String accept = request.getHeader("Accept");
if (accept == null || !accept.contains("text/html")) {
return;
}
String uri = request.getRequestURI();
if (uri == null) {
return;
}
String ctx = request.getContextPath();
String path = (ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
if (path.isEmpty()) {
path = "/";
}
if (!isSafePostLoginPath(path)) {
return;
}
String qs = request.getQueryString();
String target = (qs != null && !qs.isEmpty()) ? path + "?" + qs : path;
request.getSession().setAttribute(LoginFinalizer.SESSION_POST_LOGIN_REDIRECT, target);
}
/** 복귀 대상으로 허용할 내부 경로인지(로그인/인증/에러/정적/액션/홈 제외) */
private boolean isSafePostLoginPath(String path) {
if (path == null || !path.startsWith("/") || path.startsWith("//")) {
return false;
}
return !(path.equals("/")
|| path.startsWith("/login")
|| path.startsWith("/auth/")
|| path.startsWith("/actionLogin")
|| path.startsWith("/actionLogout")
|| path.startsWith("/error")
|| path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/img/")
|| path.startsWith("/webfonts/")
|| path.startsWith("/plugins/")
|| path.startsWith("/api/")
|| path.startsWith("/_proxy")
|| path.startsWith("/favicon"));
}
@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,86 @@
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/change";
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
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;
}
boolean dormant = PortalUserEnums.UserStatus.DORMANT.equals(user.getUserStatus());
// 로그인 2FA: ID/PW 는 맞았으므로 실패카운트만 리셋하고, 최종 확정은 2FA 성공까지 보류한다.
if (twoFactorProperties.isLoginEnabled() && !dormant) {
user.setLoginFailureCount(0);
portalUserRepository.save(user);
String ip = request.getRemoteAddr();
String sessionId = request.getSession().getId();
userLogService.logSuccess(username, ip, sessionId);
String contextPath = request.getContextPath();
HttpSession session = request.getSession();
twoFactorService.beginLoginChallenge(session, 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");
// 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);
// 2FA off (또는 DORMANT) → 기존과 동일하게 즉시 확정
String redirect = loginFinalizer.finalizeLogin(user, username, request, LoginType.NORMAL);
response.sendRedirect(redirect);
}
}
}
// 중복 로그인 방지: 기존 세션 강제 로그아웃 플래그 설정 + 현재 세션 등록
String clientIp = HttpRequestUtil.getClientIpAddress(request);
userSessionService.forceLogoutOtherSessions(normalizedUsername, sessionId);
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
// yml/weblogic.xml 기본값을 이 세션에 대해 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 + "/");
}
}
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());
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
import com.eactive.apim.portal.common.util.HttpRequestUtil;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
@@ -32,7 +33,7 @@ public class PortalConfigLog {
String query = request.getQueryString();
String ip = HttpRequestUtil.getClientIpAddress(request);
String user = SecurityUtil.getCurrentLoginId();
String user = StringMaskingUtil.maskEmail(SecurityUtil.getCurrentLoginId());
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
@@ -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");
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10";
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
/** PortalMock 모드 토큰 응답의 access_token 기본값. */
public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token";
public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
}
/** PortalMock 모드 토큰 응답에 넣을 access_token 값. */
public String mockAccessToken() {
return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN,
"PortalMock 모드 토큰 발급 응답의 access_token 값");
}
/**
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
/**
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
* 따른 실주소로 치환해 반환한다.
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다.
* <ul>
* <li>{@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환</li>
* <li>{@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환</li>
* </ul>
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
*/
@RestController
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
/**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return serverRewriter.rewriteServer(enriched, spec.get(), request);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(enriched, request)
: serverRewriter.rewriteServer(enriched, spec.get(), request);
}
}
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
return specJson.replace(SERVER_SENTINEL, base);
}
/**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) {
try {
@@ -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;
}
}
+47 -12
View File
@@ -2,11 +2,10 @@ server:
servlet:
context-path: /
session:
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
# timeout: 10m
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
timeout: 10m
cookie:
name: JSESSIONID_PORTAL
encoding:
@@ -30,6 +29,8 @@ spring:
default-page-size: '10'
jpa:
open-in-view: false
hibernate:
ddl-auto: none
web:
resources:
@@ -203,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
@@ -219,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
@@ -295,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: "#"
@@ -305,6 +316,9 @@ page:
api_detail:
name: "API 상세보기"
path: "/apis/detail"
api_testbed:
name: "테스트베드"
path: "/apis/detail/testbed"
community:
name: 고객지원
path: "#"
@@ -347,13 +361,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: "인증키 신청 상세"
@@ -361,12 +375,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"
@@ -388,6 +402,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:
+37
View File
@@ -56,6 +56,37 @@
</encoder>
</appender>
<!-- API 테스트베드(/api/call-api) 감사 로그: 요청지/헤더/마스킹된 본문 기록, 1년(365일) 보관 -->
<appender name="API_TESTER_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/apitester-audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>365</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
</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>
@@ -68,16 +99,22 @@
<appender-ref ref="HTTP_SESSION" />
</logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<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.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,491 @@
/**
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
*
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
*
* 계약:
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
if (!form) {
return; // 모듈 미사용 페이지
}
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
const sessionSelectedApis = window.API_SELECTOR_SELECTED;
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
sessionSelectedApis.forEach(function(apiId) {
selectedApis.add(apiId);
});
}
// Load APIs via AJAX
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
if (groupId) {
url += '?groupIds=' + encodeURIComponent(groupId);
}
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
emptyState.querySelector('h3').textContent = 'API 로드 실패';
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
});
}
// Render API cards
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
apis.forEach(api => {
fragment.appendChild(createApiCard(api));
});
apiCardGrid.appendChild(fragment);
attachCardEventListeners();
}
// Create API card element (figma s2 card)
function createApiCard(api) {
const card = document.createElement('div');
card.className = 's2-api-card';
card.setAttribute('data-group', api.apiGroupId || '');
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
card.setAttribute('data-api-id', api.apiId);
const isSelected = selectedApis.has(api.apiId);
if (isSelected) {
card.classList.add('selected');
}
const mainIconHtml = api.mainIcon
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
: `<i class="fas fa-cube"></i>`;
card.innerHTML = `
<div class="s2-api-card-badge">
<span>${api.apiGroupName || api.service || '카테고리'}</span>
</div>
<!-- Checkbox container with visible custom design -->
<label class="s2-checkbox-wrapper">
<input type="checkbox"
name="selectedApis"
value="${api.apiId}"
id="api-${api.apiId}"
class="s2-api-checkbox visually-hidden"
${isSelected ? 'checked' : ''}>
<span class="s2-checkbox-custom"></span>
</label>
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
<div class="s2-api-card-image">
${mainIconHtml}
</div>
`;
return card;
}
// Attach event listeners to cards
function attachCardEventListeners() {
const apiCards = document.querySelectorAll('.s2-api-card');
apiCards.forEach(card => {
card.addEventListener('click', function(e) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = !checkbox.checked;
updateCardSelection(checkbox);
updateSelectedCount();
}
});
});
// Prevent double toggle when clicking the checkbox wrapper
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
checkboxWrappers.forEach(wrapper => {
wrapper.addEventListener('click', function(e) {
e.stopPropagation(); // Stop click from bubbling to card!
});
const checkbox = wrapper.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.addEventListener('change', function() {
updateCardSelection(this);
updateSelectedCount();
});
}
});
}
// Update card visual state
function updateCardSelection(checkbox) {
const card = checkbox.closest('.s2-api-card');
if (checkbox.checked) {
card.classList.add('selected');
selectedApis.add(checkbox.value);
} else {
card.classList.remove('selected');
selectedApis.delete(checkbox.value);
}
}
// Update selected count
function updateSelectedCount() {
const floatingCartBtn = document.getElementById('floatingCartBtn');
const cartCount = document.querySelector('.s2-cart-count');
if (selectedApis.size > 0) {
floatingCartBtn.style.display = 'flex';
cartCount.textContent = selectedApis.size;
} else {
floatingCartBtn.style.display = 'none';
}
updateModalList();
updateSelectAllCheckboxState();
}
// Update select all UI visibility and text
function updateSelectAllUI() {
const selectAllWrapper = document.getElementById('selectAllWrapper');
const selectAllText = document.getElementById('selectAllText');
if (currentFilter === '') {
selectAllWrapper.style.display = 'none';
} else {
selectAllWrapper.style.display = 'flex';
selectAllText.textContent = currentServiceName + ' API 전체 선택';
}
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (visibleCards.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
if (selectedApis.size === 0) {
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
return;
}
selectedApis.forEach(function(apiId) {
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
apiPill.innerHTML = `
<span class="s2-api-pill-name">${apiName}</span>
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
`;
modalSelectedList.appendChild(apiPill);
});
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
btn.addEventListener('click', function() {
const value = this.getAttribute('data-value');
selectedApis.delete(value);
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
if (checkbox) {
checkbox.checked = false;
updateCardSelection(checkbox);
}
updateSelectedCount();
});
});
}
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
});
}
// Category tab selection
menuTitles.forEach(function(title) {
title.addEventListener('click', function(e) {
e.preventDefault();
menuTitles.forEach(t => t.classList.remove('active'));
this.classList.add('active');
const groupId = this.getAttribute('data-group');
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
});
});
// Category Carousel Scroll
const categoryListWrapper = document.getElementById('categoryListWrapper');
const btnPrevCategory = document.getElementById('btnPrevCategory');
const btnNextCategory = document.getElementById('btnNextCategory');
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
const scrollAmount = 200;
btnPrevCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
});
btnNextCategory.addEventListener('click', function() {
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
});
// Toggle buttons visibility/disabled state based on scroll position
function updateCarouselButtons() {
const scrollLeft = categoryListWrapper.scrollLeft;
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
btnPrevCategory.disabled = scrollLeft <= 0;
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
}
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
window.addEventListener('resize', updateCarouselButtons);
// Initial check after loading categories
setTimeout(updateCarouselButtons, 150);
}
// Modal control
const floatingCartBtn = document.getElementById('floatingCartBtn');
const selectedApisModal = document.getElementById('selectedApisModal');
const modalOverlay = document.getElementById('modalOverlay');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const modalCancelBtn = document.getElementById('modalCancelBtn');
function openModal() {
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
setTimeout(function() {
if (modalOverlay) {
modalOverlay.classList.add('show');
}
selectedApisModal.classList.add('show');
}, 10);
document.body.style.overflow = 'hidden';
}
function closeModal() {
if (modalOverlay) {
modalOverlay.classList.remove('show');
}
selectedApisModal.classList.remove('show');
setTimeout(function() {
selectedApisModal.style.display = 'none';
}, 300);
document.body.style.overflow = '';
}
if (floatingCartBtn) {
floatingCartBtn.addEventListener('click', openModal);
}
if (modalOverlay) {
modalOverlay.addEventListener('click', closeModal);
}
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeModal);
}
if (modalCancelBtn) {
modalCancelBtn.addEventListener('click', closeModal);
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
closeModal();
}
});
// Select All checkbox event
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
}
});
updateSelectedCount();
});
}
// DOM에 렌더되지 않은 선택분을 hidden input으로 주입 — 전송 유실 방지 (패치 3)
function syncHiddenSelected() {
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
selectedApis.forEach(function(apiId) {
if (!document.querySelector('.s2-api-checkbox[value="' + apiId + '"]')) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'selectedApis';
input.value = apiId;
input.className = 'hidden-selected-api';
form.appendChild(input);
}
});
}
// Previous step button — 선택 저장 후 step1 복귀
const btnPrevStep = document.getElementById('btnPrevStep');
if (btnPrevStep) {
btnPrevStep.addEventListener('click', function(e) {
e.preventDefault();
const saveAction = form.getAttribute('data-save-action');
if (saveAction) {
form.action = saveAction;
}
syncHiddenSelected();
form.submit();
});
}
// Form submit
// - Webhook/앱 변경은 API 가 필수(form data-api-required=true)이므로 미선택 시 차단한다.
// - 앱 신청(myapikey register)은 선택 사항이라, 미선택 시 확인 팝업으로 한 번 더 묻고 진행한다.
form.addEventListener('submit', function(e) {
if (selectedApis.size !== 0) {
syncHiddenSelected();
return;
}
var apiRequired = form.dataset.apiRequired === 'true';
if (apiRequired) {
e.preventDefault();
if (typeof customPopups !== 'undefined' && customPopups.showAlert) {
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
} else {
alert('최소 1개 이상의 API를 선택해주세요.');
}
return false;
}
// 미선택 상태로 앱 신청 → 확인 후 진행
e.preventDefault();
var message = '선택한 API가 없습니다. API 없이 앱을 신청하시겠습니까?';
// custom-popups.js 는 top-level const 라 window.customPopups 가 아닌 렉시컬 전역이다.
if (typeof customPopups !== 'undefined' && customPopups.showConfirm) {
customPopups.showConfirm(message, function (ok) {
if (ok) {
syncHiddenSelected();
form.submit(); // submit() 은 submit 이벤트를 재발생시키지 않음 → 재확인 루프 없음
}
});
} else if (window.confirm(message)) {
syncHiddenSelected();
form.submit();
}
return false;
});
// Initialize
updateSelectedCount();
loadApis('');
});

Some files were not shown because too many files have changed in this diff Show More